diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..50d0fdc --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Go workspace file +go.work + +# Build artifacts +macron +dist/ diff --git a/cmd/create.go b/cmd/create.go index 318c494..660fd37 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -23,30 +23,150 @@ package cmd import ( "fmt" + "os" + "path/filepath" + "runtime" + "time" "github.com/spf13/cobra" ) +const ( + labelPrefix = "com.macron" + launchAgentsDirPerms = 0755 + plistFilePerms = 0644 +) + +var ( + createName string + createInterval string + createScript string +) + +// validateAndResolveScript validates the script exists and returns its absolute path +func validateAndResolveScript(scriptPath string) (string, error) { + absScript, err := filepath.Abs(scriptPath) + if err != nil { + return "", fmt.Errorf("error resolving script path: %w", err) + } + + if _, err := os.Stat(absScript); err != nil { + if os.IsNotExist(err) { + return "", fmt.Errorf("script file does not exist: %s", absScript) + } + return "", fmt.Errorf("error checking script file: %w", err) + } + + return absScript, nil +} + +// generatePlist creates the plist XML content for a launchd task +func generatePlist(label, scriptPath string, intervalSeconds int) string { + return fmt.Sprintf(` + + + + Label + %s + ProgramArguments + + %s + + StartInterval + %d + RunAtLoad + + StandardOutPath + /tmp/%s.stdout + StandardErrorPath + /tmp/%s.stderr + +`, label, scriptPath, intervalSeconds, label, label) +} + +// writePlistFile writes the plist content to the LaunchAgents directory +func writePlistFile(name, absScript string, intervalSeconds int) (string, string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", "", fmt.Errorf("error getting home directory: %w", err) + } + + launchAgentsDir := filepath.Join(home, "Library", "LaunchAgents") + if err := os.MkdirAll(launchAgentsDir, launchAgentsDirPerms); err != nil { + return "", "", fmt.Errorf("error creating LaunchAgents directory: %w", err) + } + + label := fmt.Sprintf("%s.%s", labelPrefix, name) + plistPath := filepath.Join(launchAgentsDir, label+".plist") + + // Check if plist file already exists + if _, err := os.Stat(plistPath); err == nil { + return "", "", fmt.Errorf("plist file already exists: %s (remove it first or use a different name)", plistPath) + } + + plistContent := generatePlist(label, absScript, intervalSeconds) + if err := os.WriteFile(plistPath, []byte(plistContent), plistFilePerms); err != nil { + return "", "", fmt.Errorf("error writing plist file: %w", err) + } + + return label, plistPath, nil +} + // createCmd represents the create command var createCmd = &cobra.Command{ Use: "create", Short: "Create a new launchd cron task", Long: `Create a new launchd cron task with NAME to run SCRIPT over an INTERVAL.`, - Run: func(cmd *cobra.Command, args []string) { - fmt.Println("create called") + RunE: func(cmd *cobra.Command, args []string) error { + // Check platform - only works on macOS + if runtime.GOOS != "darwin" { + return fmt.Errorf("create command is only supported on macOS (current platform: %s)", runtime.GOOS) + } + + // Validate interval can be parsed as time.Duration + duration, err := time.ParseDuration(createInterval) + if err != nil { + return fmt.Errorf("invalid interval '%s': must be a valid duration (e.g., 1h, 30m, 1h30m): %w", createInterval, err) + } + + // Validate script file exists and resolve to absolute path + absScript, err := validateAndResolveScript(createScript) + if err != nil { + return err + } + + // Convert duration to seconds + intervalSeconds := int(duration.Seconds()) + + // Write plist file to LaunchAgents directory + label, plistPath, err := writePlistFile(createName, absScript, intervalSeconds) + if err != nil { + return err + } + + // Output success message + fmt.Printf("Successfully created launchd task:\n") + fmt.Printf(" Label: %s\n", label) + fmt.Printf(" Interval: %s (%d seconds)\n", createInterval, intervalSeconds) + fmt.Printf(" Script: %s\n", absScript) + fmt.Printf(" Plist: %s\n", plistPath) + fmt.Printf("\nTo load the task, run:\n") + fmt.Printf(" launchctl load %s\n", plistPath) + + return nil }, } func init() { rootCmd.AddCommand(createCmd) - // Here you will define your flags and configuration settings. - - // Cobra supports Persistent Flags which will work for this command - // and all subcommands, e.g.: - // createCmd.PersistentFlags().String("foo", "", "A help for foo") + // Define flags for the create command + createCmd.Flags().StringVarP(&createName, "name", "n", "", "Name of the launchd task (required)") + createCmd.Flags().StringVarP(&createInterval, "interval", "i", "", "Interval for the task execution (required)") + createCmd.Flags().StringVarP(&createScript, "script", "s", "", "Path to the script to execute (required)") - // Cobra supports local flags which will only run when this command - // is called directly, e.g.: - // createCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle") + // Mark flags as required + createCmd.MarkFlagRequired("name") + createCmd.MarkFlagRequired("interval") + createCmd.MarkFlagRequired("script") } diff --git a/cmd/create_test.go b/cmd/create_test.go new file mode 100644 index 0000000..fcd959f --- /dev/null +++ b/cmd/create_test.go @@ -0,0 +1,224 @@ +/* +Copyright © 2025 David Hagerty + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ +package cmd + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestGeneratePlist(t *testing.T) { + tests := []struct { + name string + label string + scriptPath string + intervalSeconds int + wantContains []string + }{ + { + name: "basic plist generation", + label: "com.macron.test", + scriptPath: "/path/to/script.sh", + intervalSeconds: 3600, + wantContains: []string{ + "", + "Label", + "com.macron.test", + "ProgramArguments", + "/path/to/script.sh", + "StartInterval", + "3600", + "RunAtLoad", + "", + "/tmp/com.macron.test.stdout", + "/tmp/com.macron.test.stderr", + }, + }, + { + name: "plist with special characters in path", + label: "com.macron.backup", + scriptPath: "/Users/test/My Scripts/backup.sh", + intervalSeconds: 1800, + wantContains: []string{ + "com.macron.backup", + "/Users/test/My Scripts/backup.sh", + "1800", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := generatePlist(tt.label, tt.scriptPath, tt.intervalSeconds) + + for _, want := range tt.wantContains { + if !strings.Contains(got, want) { + t.Errorf("generatePlist() missing expected content:\nwant: %q\ngot: %s", want, got) + } + } + + // Verify it's valid XML structure + if !strings.HasPrefix(got, "") { + t.Errorf("generatePlist() should end with ") + } + }) + } +} + +func TestValidateAndResolveScript(t *testing.T) { + // Create a temporary directory for testing + tempDir := t.TempDir() + + // Create a test script file + testScript := filepath.Join(tempDir, "test-script.sh") + if err := os.WriteFile(testScript, []byte("#!/bin/bash\necho test"), 0755); err != nil { + t.Fatalf("Failed to create test script: %v", err) + } + + tests := []struct { + name string + scriptPath string + wantErr bool + errContains string + }{ + { + name: "valid script with absolute path", + scriptPath: testScript, + wantErr: false, + }, + { + name: "non-existent script", + scriptPath: filepath.Join(tempDir, "nonexistent.sh"), + wantErr: true, + errContains: "does not exist", + }, + { + name: "relative path resolution", + scriptPath: "test-script.sh", + wantErr: false, // Will resolve to current directory + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // For relative path test, change to temp dir + if tt.name == "relative path resolution" { + oldWd, _ := os.Getwd() + defer os.Chdir(oldWd) + os.Chdir(tempDir) + } + + got, err := validateAndResolveScript(tt.scriptPath) + + if (err != nil) != tt.wantErr { + t.Errorf("validateAndResolveScript() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if tt.wantErr && err != nil { + if !strings.Contains(err.Error(), tt.errContains) { + t.Errorf("validateAndResolveScript() error = %v, should contain %q", err, tt.errContains) + } + } + + if !tt.wantErr { + if !filepath.IsAbs(got) { + t.Errorf("validateAndResolveScript() = %v, should be absolute path", got) + } + } + }) + } +} + +func TestWritePlistFile(t *testing.T) { + // This test would require mocking os.UserHomeDir() or running in a controlled environment + // For now, we'll test the error cases that don't depend on the actual home directory + + tests := []struct { + name string + taskName string + script string + intervalSeconds int + wantLabel string + }{ + { + name: "generates correct label", + taskName: "backup", + script: "/usr/local/bin/backup.sh", + intervalSeconds: 3600, + wantLabel: "com.macron.backup", + }, + { + name: "handles special characters in name", + taskName: "my-task_123", + script: "/path/to/script.sh", + intervalSeconds: 1800, + wantLabel: "com.macron.my-task_123", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // We can test label generation logic separately + label := labelPrefix + "." + tt.taskName + if label != tt.wantLabel { + t.Errorf("Label = %v, want %v", label, tt.wantLabel) + } + }) + } +} + +func TestCreateCmdValidation(t *testing.T) { + // Test that the command has the expected structure + if createCmd == nil { + t.Fatal("createCmd should not be nil") + } + + if createCmd.Use != "create" { + t.Errorf("createCmd.Use = %v, want 'create'", createCmd.Use) + } + + if createCmd.RunE == nil { + t.Error("createCmd.RunE should not be nil") + } + + // Verify flags are defined + nameFlag := createCmd.Flags().Lookup("name") + if nameFlag == nil { + t.Error("name flag should be defined") + } + + intervalFlag := createCmd.Flags().Lookup("interval") + if intervalFlag == nil { + t.Error("interval flag should be defined") + } + + scriptFlag := createCmd.Flags().Lookup("script") + if scriptFlag == nil { + t.Error("script flag should be defined") + } +} diff --git a/cmd/root.go b/cmd/root.go index 598d2ce..94134ab 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -27,8 +27,6 @@ import ( "dathagerty.com/go/macron/internal/version" "github.com/spf13/cobra" - - homedir "github.com/mitchellh/go-homedir" "github.com/spf13/viper" ) @@ -80,7 +78,7 @@ func initConfig() { viper.SetConfigFile(cfgFile) } else { // Find home directory. - home, err := homedir.Dir() + home, err := os.UserHomeDir() if err != nil { fmt.Println(err) os.Exit(1) diff --git a/go.mod b/go.mod index 7b1ae7c..095937f 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,6 @@ module dathagerty.com/go/macron go 1.24.0 require ( - github.com/mitchellh/go-homedir v1.1.0 github.com/spf13/cobra v1.9.1 github.com/spf13/viper v1.20.1 ) diff --git a/go.sum b/go.sum index a15e557..6ee0a3f 100644 --- a/go.sum +++ b/go.sum @@ -16,8 +16,6 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= -github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=