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=