From 1c83537f5e6a5af29c146def5528cf084fbcb337 Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 4 Oct 2025 19:35:11 -0700 Subject: [PATCH 01/42] simple task-runner --- docs/examples/example.raid.yaml | 31 ++--- docs/examples/hello.sh | 3 +- src/internal/lib/env.go | 30 +++++ src/internal/lib/task_runner.go | 212 ++++++++++++-------------------- src/raid/raid.go | 1 + 5 files changed, 118 insertions(+), 159 deletions(-) diff --git a/docs/examples/example.raid.yaml b/docs/examples/example.raid.yaml index a2295e0..aeaffc3 100644 --- a/docs/examples/example.raid.yaml +++ b/docs/examples/example.raid.yaml @@ -14,8 +14,10 @@ environments: tasks: - type: Shell cmd: echo "Hello, world! - From profile" + - type: Shell + cmd: ls - type: Script - path: ./hello.sh + path: ~/Developer/raid/docs/examples/hello.sh variables: - name: NODE_ENV value: development @@ -24,26 +26,7 @@ environments: - name: API_URL value: https://api.example.com ---- - -name: example2 -repositories: - - name: raid - path: ~/Developer/raid - url: https://github.com/8bitAlex/raid.git - ---- - -name: example3 -repositories: - - name: raid - path: ~/Developer/raid - url: https://github.com/8bitAlex/raid.git - ---- - -name: example4 -repositories: - - name: raid - path: ~/Developer/raid - url: https://github.com/8bitAlex/raid.git \ No newline at end of file +# install: +# - tasks: +# - type: Shell +# cmd: echo "Installing dependencies..." \ No newline at end of file diff --git a/docs/examples/hello.sh b/docs/examples/hello.sh index 8e8fe0a..d714f32 100755 --- a/docs/examples/hello.sh +++ b/docs/examples/hello.sh @@ -1 +1,2 @@ -echo "Hello, world!" \ No newline at end of file +#!/bin/sh +echo "Hello, world! - From script" \ No newline at end of file diff --git a/src/internal/lib/env.go b/src/internal/lib/env.go index 84bf6ea..fea1a92 100644 --- a/src/internal/lib/env.go +++ b/src/internal/lib/env.go @@ -15,6 +15,7 @@ const ( type Env struct { Name string `json:"name"` Variables []EnvVar `json:"variables"` + Tasks []Task `json:"tasks"` } func (e Env) IsZero() bool { @@ -61,6 +62,20 @@ func ContainsEnv(name string) bool { } func ExecuteEnv(name string) error { + err := setEnvVariablesForRepos(name) + if err != nil { + return fmt.Errorf("failed to set env variables: %w", err) + } + + err = runTasksForEnv(name) + if err != nil { + return fmt.Errorf("failed to run env tasks: %w", err) + } + + return nil +} + +func setEnvVariablesForRepos(name string) error { for _, repo := range context.Profile.Repositories { fmt.Printf("Setting up environment for repo: %s\n", repo.Name) @@ -113,3 +128,18 @@ func setEnvVariables(profVars []EnvVar, repoVars []EnvVar, path string) error { return nil } +func runTasksForEnv(name string) error { + env := context.Profile.getEnv(name) + if env.IsZero() || len(env.Tasks) == 0 { + return nil + } + + for _, task := range env.Tasks { + err := ExecuteTask(task) + if err != nil { + return fmt.Errorf("failed to execute task '%s': %w", task.Cmd, err) + } + } + return nil +} + diff --git a/src/internal/lib/task_runner.go b/src/internal/lib/task_runner.go index 81a3a6e..1074e2f 100644 --- a/src/internal/lib/task_runner.go +++ b/src/internal/lib/task_runner.go @@ -1,136 +1,80 @@ package lib -// import ( -// "fmt" -// "os" -// "os/exec" -// "path/filepath" -// "sync" -// ) - -// // TaskRunner handles the execution of tasks -// type TaskRunner struct { -// concurrency int -// } - -// // NewTaskRunner creates a new task runner with the specified concurrency limit -// func NewTaskRunner(concurrency int) *TaskRunner { -// return &TaskRunner{ -// concurrency: concurrency, -// } -// } - -// // ExecuteTasks executes a list of tasks concurrently -// func (tr *TaskRunner) ExecuteTasks(tasks []Task) error { -// if len(tasks) == 0 { -// return nil -// } - -// // Use a semaphore to limit concurrency -// var semaphore chan struct{} -// if tr.concurrency > 0 { -// semaphore = make(chan struct{}, tr.concurrency) -// } - -// var wg sync.WaitGroup -// errorChan := make(chan error, len(tasks)) -// var outputMutex sync.Mutex - -// for i, task := range tasks { -// wg.Add(1) -// go func(taskIndex int, task Task) { -// defer wg.Done() - -// // Acquire semaphore if concurrency is limited -// if semaphore != nil { -// semaphore <- struct{}{} -// defer func() { <-semaphore }() -// } - -// // Lock output to prevent interleaved messages -// outputMutex.Lock() -// fmt.Printf("Executing task %d: %s\n", taskIndex+1, task.Type) -// outputMutex.Unlock() - -// if err := tr.executeTask(task); err != nil { -// errorChan <- fmt.Errorf("task %d failed: %w", taskIndex+1, err) -// } else { -// outputMutex.Lock() -// fmt.Printf("Task %d completed successfully\n", taskIndex+1) -// outputMutex.Unlock() -// } -// }(i, task) -// } - -// wg.Wait() -// close(errorChan) - -// // Check for any errors -// var errors []error -// for err := range errorChan { -// errors = append(errors, err) -// } - -// if len(errors) > 0 { -// return fmt.Errorf("some tasks failed: %v", errors) -// } - -// return nil -// } - -// // executeTask executes a single task -// func (tr *TaskRunner) executeTask(task Task) error { -// switch task.Type { -// case "Shell": -// return tr.executeShellTask(task) -// case "Script": -// return tr.executeScriptTask(task) -// default: -// return fmt.Errorf("unknown task type: %s", task.Type) -// } -// } - -// // executeShellTask executes a shell command -// func (tr *TaskRunner) executeShellTask(task Task) error { -// if task.Cmd == "" { -// return fmt.Errorf("shell task requires 'cmd' field") -// } - -// cmd := exec.Command("sh", "-c", task.Cmd) -// cmd.Stdout = os.Stdout -// cmd.Stderr = os.Stderr -// cmd.Stdin = os.Stdin - -// return cmd.Run() -// } - -// // executeScriptTask executes a script file -// func (tr *TaskRunner) executeScriptTask(task Task) error { -// if task.Path == "" { -// return fmt.Errorf("script task requires 'path' field") -// } - -// // Resolve the script path -// scriptPath, err := filepath.Abs(task.Path) -// if err != nil { -// return fmt.Errorf("failed to resolve script path: %w", err) -// } - -// // Check if the script file exists -// if _, err := os.Stat(scriptPath); os.IsNotExist(err) { -// return fmt.Errorf("script file not found: %s", scriptPath) -// } - -// // Make the script executable -// if err := os.Chmod(scriptPath, 0755); err != nil { -// return fmt.Errorf("failed to make script executable: %w", err) -// } - -// // Execute the script -// cmd := exec.Command(scriptPath) -// cmd.Stdout = os.Stdout -// cmd.Stderr = os.Stderr -// cmd.Stdin = os.Stdin - -// return cmd.Run() -// } +import ( + "fmt" + "os" + "os/exec" + "strings" + + "github.com/8bitalex/raid/src/internal/sys" +) + +type Task struct { + Type TaskType `json:"type"` + Cmd string `json:"cmd"` + Path string `json:"path"` +} + +type TaskType string +const ( + Shell TaskType = "shell" + Script TaskType = "script" +) + +func (t Task) IsZero() bool { + return t.Cmd == "" && t.Path == "" && t.Type == "" +} + +func (t TaskType) ToLower() TaskType { + return TaskType(strings.ToLower(string(t))) +} + +func ExecuteTask(task Task) error { + if task.IsZero() { + return nil + } + + switch task.Type.ToLower() { + case Shell: + return execShell(task) + case Script: + return execScript(task) + default: + return fmt.Errorf("invalid task type: %s", task.Type) + } +} + +func execShell(task Task) error { + args := strings.Split(task.Cmd, " ") + cmd := exec.Command(args[0], args[1:]...) + + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + + err := cmd.Run() + if err != nil { + return fmt.Errorf("failed to execute shell command '%s': %w", task.Cmd, err) + } + + return nil +} + +func execScript(task Task) error { + path := sys.ExpandPath(task.Path) + if !sys.FileExists(path) { + return fmt.Errorf("script file does not exist: %s", path) + } + + cmd := exec.Command(path) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + + err := cmd.Run() + if err != nil { + return fmt.Errorf("failed to execute script '%s': %w", path, err) + } + + return nil +} \ No newline at end of file diff --git a/src/raid/raid.go b/src/raid/raid.go index 5c03d04..cc58e0c 100644 --- a/src/raid/raid.go +++ b/src/raid/raid.go @@ -40,6 +40,7 @@ func Initialize() { if err := Load(); err != nil { log.Fatalf("Failed to compile configurations: %v", err) } + // todo load env } // Load the raid configurations for execution. Uses cached results if available. From 8769cbf86a1e74be5d266a0d5edbcfc61e67aca3 Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 6 Oct 2025 16:37:41 -0700 Subject: [PATCH 02/42] additional task configurations --- docs/examples/example.raid.yaml | 5 ++- schemas/raid-defs.schema.json | 26 +++++++++----- src/internal/lib/env.go | 2 +- src/internal/lib/task.go | 42 +++++++++++++++++++++++ src/internal/lib/task_runner.go | 60 +++++++++++++++------------------ 5 files changed, 93 insertions(+), 42 deletions(-) create mode 100644 src/internal/lib/task.go diff --git a/docs/examples/example.raid.yaml b/docs/examples/example.raid.yaml index aeaffc3..f1daf43 100644 --- a/docs/examples/example.raid.yaml +++ b/docs/examples/example.raid.yaml @@ -15,9 +15,12 @@ environments: - type: Shell cmd: echo "Hello, world! - From profile" - type: Shell - cmd: ls + literal: false + cmd: echo $NODE_ENV - type: Script path: ~/Developer/raid/docs/examples/hello.sh + runner: /bin/bash + concurrent: false variables: - name: NODE_ENV value: development diff --git a/schemas/raid-defs.schema.json b/schemas/raid-defs.schema.json index a33f504..e1fa3d5 100644 --- a/schemas/raid-defs.schema.json +++ b/schemas/raid-defs.schema.json @@ -18,14 +18,19 @@ "properties": { "type": { "type": "string", - "enum": ["Shell"] + "const": "Shell" + }, + "concurrent": { + "ref": "#/concurrent" }, "cmd": { "type": "string", - "description": "Shell command to execute" + "description": "Command to execute" }, - "concurrent": { - "ref": "#/concurrent" + "literal": { + "type": "boolean", + "description": "Whether to execute the command as a literal string (without shell interpretation)", + "default": false } }, "required": ["type", "cmd"], @@ -36,14 +41,19 @@ "properties": { "type": { "type": "string", - "enum": ["Script"] + "const": "Script" + }, + "concurrent": { + "ref": "#/concurrent" }, "path": { "type": "string", - "description": "Path to the script file" + "description": "Absolute path to the script file" }, - "concurrent": { - "ref": "#/concurrent" + "runner": { + "type": "string", + "enum": ["/bin/bash", "/bin/sh", "/bin/zsh", "python", "python2", "python3", "node", "powershell"], + "description": "Optional runner to execute the script (e.g., bash, python)" } }, "required": ["type", "path"], diff --git a/src/internal/lib/env.go b/src/internal/lib/env.go index fea1a92..3db7c87 100644 --- a/src/internal/lib/env.go +++ b/src/internal/lib/env.go @@ -137,7 +137,7 @@ func runTasksForEnv(name string) error { for _, task := range env.Tasks { err := ExecuteTask(task) if err != nil { - return fmt.Errorf("failed to execute task '%s': %w", task.Cmd, err) + return fmt.Errorf("failed to execute task '%s': %w", task.Type, err) } } return nil diff --git a/src/internal/lib/task.go b/src/internal/lib/task.go new file mode 100644 index 0000000..2dbb41a --- /dev/null +++ b/src/internal/lib/task.go @@ -0,0 +1,42 @@ +package lib + +import ( + "strings" + + "github.com/8bitalex/raid/src/internal/sys" +) + +type Task struct { + Type TaskType `json:"type"` + Concurrent bool `json:"concurrent,omitempty"` + // Shell + Cmd string `json:"cmd,omitempty"` + Literal bool `json:"literal,omitempty"` + // Script + Path string `json:"path,omitempty"` + Runner string `json:"runner,omitempty"` +} + +func (t Task) IsZero() bool { + return t.Type == "" +} + +func (t Task) Expand() Task { + return Task{ + Type: t.Type, + Concurrent: t.Concurrent, + Cmd: sys.ExpandPath(t.Cmd), + Path: sys.ExpandPath(t.Path), + Runner: sys.ExpandPath(t.Runner), + } +} + +type TaskType string +const ( + Shell TaskType = "shell" + Script TaskType = "script" +) + +func (t TaskType) ToLower() TaskType { + return TaskType(strings.ToLower(string(t))) +} \ No newline at end of file diff --git a/src/internal/lib/task_runner.go b/src/internal/lib/task_runner.go index 1074e2f..1c1c6ef 100644 --- a/src/internal/lib/task_runner.go +++ b/src/internal/lib/task_runner.go @@ -9,26 +9,6 @@ import ( "github.com/8bitalex/raid/src/internal/sys" ) -type Task struct { - Type TaskType `json:"type"` - Cmd string `json:"cmd"` - Path string `json:"path"` -} - -type TaskType string -const ( - Shell TaskType = "shell" - Script TaskType = "script" -) - -func (t Task) IsZero() bool { - return t.Cmd == "" && t.Path == "" && t.Type == "" -} - -func (t TaskType) ToLower() TaskType { - return TaskType(strings.ToLower(string(t))) -} - func ExecuteTask(task Task) error { if task.IsZero() { return nil @@ -45,12 +25,17 @@ func ExecuteTask(task Task) error { } func execShell(task Task) error { + if !task.Literal { + task = task.Expand() + } + args := strings.Split(task.Cmd, " ") - cmd := exec.Command(args[0], args[1:]...) + if len(args) == 0 { + return fmt.Errorf("no command provided for task") + } - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - cmd.Stdin = os.Stdin + cmd := exec.Command(args[0], args[1:]...) + setCmdOutput(cmd) err := cmd.Run() if err != nil { @@ -61,20 +46,31 @@ func execShell(task Task) error { } func execScript(task Task) error { - path := sys.ExpandPath(task.Path) - if !sys.FileExists(path) { - return fmt.Errorf("script file does not exist: %s", path) + task = task.Expand() + + if !sys.FileExists(task.Path) { + return fmt.Errorf("file does not exist: %s", task.Path) } - cmd := exec.Command(path) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - cmd.Stdin = os.Stdin + var cmd *exec.Cmd + if task.Runner != "" { + cmd = exec.Command(task.Runner, task.Path) + } else { + cmd = exec.Command(task.Path) + } + + setCmdOutput(cmd) err := cmd.Run() if err != nil { - return fmt.Errorf("failed to execute script '%s': %w", path, err) + return fmt.Errorf("failed to execute script '%s': %w", task.Path, err) } return nil +} + +func setCmdOutput(cmd *exec.Cmd) { + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin } \ No newline at end of file From 11fbaf371c217dc1de28c0fc40e18f67316a058c Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 7 Oct 2025 11:20:10 -0700 Subject: [PATCH 03/42] fix shell exec and add shell config --- docs/examples/example.raid.yaml | 6 +- go.mod | 3 +- go.sum | 6 ++ schemas/raid-defs.schema.json | 7 ++- src/internal/lib/env.go | 15 ++--- src/internal/lib/lib.go | 2 + src/internal/lib/task.go | 25 +++++---- src/internal/lib/task_runner.go | 97 +++++++++++++++++++++++++++------ src/internal/sys/system.go | 85 +++++++++++++++++++++++++++-- 9 files changed, 201 insertions(+), 45 deletions(-) diff --git a/docs/examples/example.raid.yaml b/docs/examples/example.raid.yaml index f1daf43..cc5182c 100644 --- a/docs/examples/example.raid.yaml +++ b/docs/examples/example.raid.yaml @@ -14,13 +14,17 @@ environments: tasks: - type: Shell cmd: echo "Hello, world! - From profile" + shell: bash - type: Shell literal: false cmd: echo $NODE_ENV - type: Script path: ~/Developer/raid/docs/examples/hello.sh - runner: /bin/bash + runner: bash concurrent: false + - type: Shell + cmd: touch ~/testfile.txt && echo "Created testfile.txt" + concurrent: true variables: - name: NODE_ENV value: development diff --git a/go.mod b/go.mod index eef56cd..e5c08bd 100644 --- a/go.mod +++ b/go.mod @@ -3,12 +3,13 @@ module github.com/8bitalex/raid go 1.24.4 require ( + github.com/joho/godotenv v1.5.1 github.com/mitchellh/go-homedir v1.1.0 github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 github.com/spf13/cobra v1.9.1 github.com/spf13/viper v1.20.1 gopkg.in/yaml.v3 v3.0.1 - github.com/joho/godotenv v1.5.1 + github.com/thoas/go-funk v0.9.3 ) require ( diff --git a/go.sum b/go.sum index cfbd5a5..b2c0b8d 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,5 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= @@ -45,10 +46,14 @@ github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M= github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/thoas/go-funk v0.9.3 h1:7+nAEx3kn5ZJcnDm2Bh23N2yOtweO14bi//dvRtgLpw= +github.com/thoas/go-funk v0.9.3/go.mod h1:+IWnUfUmFO1+WVYQWQtIJHeRRdaIyyYglZN7xzUPe4Q= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= @@ -56,5 +61,6 @@ golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/schemas/raid-defs.schema.json b/schemas/raid-defs.schema.json index e1fa3d5..70fe0b8 100644 --- a/schemas/raid-defs.schema.json +++ b/schemas/raid-defs.schema.json @@ -27,6 +27,11 @@ "type": "string", "description": "Command to execute" }, + "shell": { + "type": "string", + "enum": ["bash", "sh", "zsh", "powershell", "cmd"], + "description": "Optional shell to execute the command (e.g., bash, powershell)" + }, "literal": { "type": "boolean", "description": "Whether to execute the command as a literal string (without shell interpretation)", @@ -52,7 +57,7 @@ }, "runner": { "type": "string", - "enum": ["/bin/bash", "/bin/sh", "/bin/zsh", "python", "python2", "python3", "node", "powershell"], + "enum": ["bash", "sh", "zsh", "python", "python2", "python3", "node", "powershell"], "description": "Optional runner to execute the script (e.g., bash, python)" } }, diff --git a/src/internal/lib/env.go b/src/internal/lib/env.go index 3db7c87..f8bd583 100644 --- a/src/internal/lib/env.go +++ b/src/internal/lib/env.go @@ -13,9 +13,9 @@ const ( ) type Env struct { - Name string `json:"name"` - Variables []EnvVar `json:"variables"` - Tasks []Task `json:"tasks"` + Name string `json:"name"` + Variables []EnvVar `json:"variables"` + Tasks []Task `json:"tasks"` } func (e Env) IsZero() bool { @@ -134,12 +134,9 @@ func runTasksForEnv(name string) error { return nil } - for _, task := range env.Tasks { - err := ExecuteTask(task) - if err != nil { - return fmt.Errorf("failed to execute task '%s': %w", task.Type, err) - } + err := ExecuteTasks(env.Tasks) + if err != nil { + return err } return nil } - diff --git a/src/internal/lib/lib.go b/src/internal/lib/lib.go index e249d9b..901604f 100644 --- a/src/internal/lib/lib.go +++ b/src/internal/lib/lib.go @@ -38,6 +38,8 @@ func ForceLoad() error { } func Install(maxThreads int) error { + + // todo migrate to task runner profile := context.Profile if profile.IsZero() { return fmt.Errorf("profile not found") diff --git a/src/internal/lib/task.go b/src/internal/lib/task.go index 2dbb41a..1a6bf5e 100644 --- a/src/internal/lib/task.go +++ b/src/internal/lib/task.go @@ -6,15 +6,17 @@ import ( "github.com/8bitalex/raid/src/internal/sys" ) +// There has to be a better way to do this... todo type Task struct { - Type TaskType `json:"type"` - Concurrent bool `json:"concurrent,omitempty"` + Type TaskType `json:"type"` + Concurrent bool `json:"concurrent,omitempty"` // Shell - Cmd string `json:"cmd,omitempty"` - Literal bool `json:"literal,omitempty"` + Cmd string `json:"cmd,omitempty"` + Literal bool `json:"literal,omitempty"` + Shell string `json:"shell,omitempty"` // Script - Path string `json:"path,omitempty"` - Runner string `json:"runner,omitempty"` + Path string `json:"path,omitempty"` + Runner string `json:"runner,omitempty"` } func (t Task) IsZero() bool { @@ -25,13 +27,16 @@ func (t Task) Expand() Task { return Task{ Type: t.Type, Concurrent: t.Concurrent, - Cmd: sys.ExpandPath(t.Cmd), - Path: sys.ExpandPath(t.Path), - Runner: sys.ExpandPath(t.Runner), + Cmd: sys.Expand(t.Cmd), + Literal: t.Literal, + Shell: t.Shell, + Path: sys.Expand(t.Path), + Runner: sys.Expand(t.Runner), } } type TaskType string + const ( Shell TaskType = "shell" Script TaskType = "script" @@ -39,4 +44,4 @@ const ( func (t TaskType) ToLower() TaskType { return TaskType(strings.ToLower(string(t))) -} \ No newline at end of file +} diff --git a/src/internal/lib/task_runner.go b/src/internal/lib/task_runner.go index 1c1c6ef..0d12911 100644 --- a/src/internal/lib/task_runner.go +++ b/src/internal/lib/task_runner.go @@ -5,22 +5,58 @@ import ( "os" "os/exec" "strings" + "sync" "github.com/8bitalex/raid/src/internal/sys" ) +func ExecuteTasks(tasks []Task) error { + var wg sync.WaitGroup + errorChan := make(chan error, len(tasks)) + + for _, task := range tasks { + if task.Concurrent { + wg.Add(1) + go func(task Task) { + defer wg.Done() + if err := ExecuteTask(task); err != nil { + errorChan <- fmt.Errorf("failed to execute task '%s': %w", task.Type, err) + } + }(task) + } else { + if err := ExecuteTask(task); err != nil { + errorChan <- fmt.Errorf("failed to execute task '%s': %w", task.Type, err) + } + } + } + + wg.Wait() + close(errorChan) + + var errors []error + for err := range errorChan { + errors = append(errors, err) + } + + if len(errors) > 0 { + return fmt.Errorf("some tasks failed to execute: %v", errors) + } + + return nil +} + func ExecuteTask(task Task) error { if task.IsZero() { return nil } switch task.Type.ToLower() { - case Shell: - return execShell(task) - case Script: - return execScript(task) - default: - return fmt.Errorf("invalid task type: %s", task.Type) + case Shell: + return execShell(task) + case Script: + return execScript(task) + default: + return fmt.Errorf("invalid task type: %s", task.Type) } } @@ -28,26 +64,49 @@ func execShell(task Task) error { if !task.Literal { task = task.Expand() } - - args := strings.Split(task.Cmd, " ") - if len(args) == 0 { - return fmt.Errorf("no command provided for task") - } - cmd := exec.Command(args[0], args[1:]...) - setCmdOutput(cmd) + shell := getShell(task.Shell) + cmd := exec.Command(shell[0], append(shell[1:], task.Cmd)...) + if !task.Concurrent { + setCmdOutput(cmd) + } err := cmd.Run() if err != nil { return fmt.Errorf("failed to execute shell command '%s': %w", task.Cmd, err) } - + return nil } +func getShell(shell string) []string { + if shell == "" { + if sys.GetPlatform() == sys.Windows { + return []string{"cmd", "/c"} + } + return []string{"/bin/bash", "-c"} + } + + shell = strings.ToLower(shell) + switch shell { + case "/bin/bash", "bash": + return []string{"/bin/bash", "-c"} + case "/bin/sh", "sh": + return []string{"/bin/sh", "-c"} + case "/bin/zsh", "zsh": + return []string{"/bin/zsh", "-c"} + case "powershell", "pwsh", "ps": + return []string{"powershell"} + case "cmd": + return []string{"cmd", "/c"} + default: + return []string{"/bin/bash", "-c"} + } +} + func execScript(task Task) error { task = task.Expand() - + if !sys.FileExists(task.Path) { return fmt.Errorf("file does not exist: %s", task.Path) } @@ -58,8 +117,10 @@ func execScript(task Task) error { } else { cmd = exec.Command(task.Path) } - - setCmdOutput(cmd) + + if !task.Concurrent { + setCmdOutput(cmd) + } err := cmd.Run() if err != nil { @@ -73,4 +134,4 @@ func setCmdOutput(cmd *exec.Cmd) { cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr cmd.Stdin = os.Stdin -} \ No newline at end of file +} diff --git a/src/internal/sys/system.go b/src/internal/sys/system.go index f002d24..9dea11d 100644 --- a/src/internal/sys/system.go +++ b/src/internal/sys/system.go @@ -4,14 +4,25 @@ import ( "log" "os" "path" + "runtime" "strings" "github.com/mitchellh/go-homedir" + "github.com/thoas/go-funk" ) // Path Separator as a string const Sep = string(os.PathSeparator) +type Platform string + +const ( + Windows Platform = "windows" + Linux Platform = "linux" + Darwin Platform = "darwin" + Other Platform = "other" +) + func GetHomeDir() string { home, err := homedir.Dir() if err != nil { @@ -32,15 +43,79 @@ func CreateFile(filePath string) (*os.File, error) { func FileExists(path string) bool { path = ExpandPath(path) - if _, err := os.Open(path); err != nil { + if _, err := os.Stat(path); err != nil { return false } return true } -func ExpandPath(path string) string { - if strings.HasPrefix(path, "~") { - return strings.Replace(path, "~", GetHomeDir(), 1) +func Expand(input string) string { + if input == "" { + return input + } + + i := funk.Map(SplitInput(input), func(x string) string { + return ExpandPath(x) + }).([]string) + return strings.Join(i, " ") +} + +func ExpandPath(input string) string { + if input == "" { + return input + } + + input = os.ExpandEnv(input) + input = strings.TrimSpace(input) + input, _ = homedir.Expand(input) + return input +} + +// this is a mess — todo +func SplitInput(input string) []string { + out := []string{} + quote := false + ignore := false + b := strings.Builder{} + for _, s := range input { + if s == '"' { + quote = !quote + } + if s == ' ' && !quote { + if ignore { + continue + } + ignore = true + if b.Len() > 0 { + out = append(out, b.String()) + b.Reset() + } + } else if s == '"' && quote { + ignore = false + if b.Len() > 0 { + out = append(out, b.String()) + b.Reset() + } + b.WriteRune(s) + } else { + ignore = false + b.WriteRune(s) + } + } + out = append(out, b.String()) + return out +} + +// todo platform dependent files? +func GetPlatform() Platform { + switch runtime.GOOS { + case "windows": + return Windows + case "darwin": + return Darwin + case "linux": + return Linux + default: + return Other } - return os.ExpandEnv(path) } From 80e3208fc832e2942f953e0603587a9d92c14303 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 7 Oct 2025 16:11:28 -0700 Subject: [PATCH 04/42] load env vars --- src/internal/lib/env.go | 18 ++++++++++++++++++ src/raid/raid.go | 5 ++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/internal/lib/env.go b/src/internal/lib/env.go index f8bd583..843bd08 100644 --- a/src/internal/lib/env.go +++ b/src/internal/lib/env.go @@ -6,6 +6,7 @@ import ( sys "github.com/8bitalex/raid/src/internal/sys" "github.com/joho/godotenv" "github.com/spf13/viper" + "github.com/thoas/go-funk" ) const ( @@ -140,3 +141,20 @@ func runTasksForEnv(name string) error { } return nil } + +func LoadEnv() error { + if context == nil { + return fmt.Errorf("context not initialized") + } + + repos := context.Profile.Repositories + paths := funk.Map(repos, func(r Repo) string { + return sys.ExpandPath(r.Path) + sys.Sep + ".env" + }).([]string) + + err := godotenv.Load(paths...) + if err != nil { + return fmt.Errorf("failed to load env files: %w", err) + } + return nil +} diff --git a/src/raid/raid.go b/src/raid/raid.go index cc58e0c..8319c82 100644 --- a/src/raid/raid.go +++ b/src/raid/raid.go @@ -15,6 +15,7 @@ Related packages: package raid import ( + "fmt" "log" "github.com/8bitalex/raid/src/internal/lib" @@ -40,7 +41,9 @@ func Initialize() { if err := Load(); err != nil { log.Fatalf("Failed to compile configurations: %v", err) } - // todo load env + if err := lib.LoadEnv(); err != nil { + fmt.Printf("Failed to load environment variables: %v", err) + } } // Load the raid configurations for execution. Uses cached results if available. From 99d47d0bd1129fcd3a5e69d6181299ec1cdbb6e1 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 12 Mar 2026 17:44:16 -0700 Subject: [PATCH 05/42] cleanup and tests --- CLAUDE.md | 51 ++++ docs/examples/example.raid.yaml | 8 +- schemas/raid-defs.schema.json | 13 +- schemas/raid-profile.schema.json | 3 + schemas/raid-repo.schema.json | 3 + src/internal/lib/lib.go | 82 +++++- src/internal/lib/profile.go | 66 +---- src/internal/lib/repo.go | 59 ++++- src/internal/lib/task_runner_test.go | 361 +++++++++++++++++++++++++++ src/internal/lib/task_test.go | 114 +++++++++ src/internal/utils/Common.go | 16 +- src/raid/raid.go | 7 +- 12 files changed, 711 insertions(+), 72 deletions(-) create mode 100644 CLAUDE.md create mode 100644 src/internal/lib/task_runner_test.go create mode 100644 src/internal/lib/task_test.go diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..7011661 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,51 @@ +# Coding Standards + +## Core Principles + +- **Readable over clever** — code is read far more often than written. +- **Single Responsibility** — every function, type, and package does one thing. +- **Self-documenting** — names and structure should make intent obvious without comments. +- **DRY** — don't repeat yourself; but don't over-abstract prematurely either. +- **No util/helper/common/misc packages** — these are catch-all bins that accumulate debt. Put code in the package whose domain it belongs to. +- **Short functions** — if a function needs a comment to explain what a section does, that section should be its own function. +- **Lean parameter lists** — more than three parameters is a signal to introduce a type. + +## Naming + +- Names reveal intent: `usersByID` not `m`, `retryCount` not `n`. +- Follow ecosystem conventions: Go uses `MixedCaps`, not `snake_case`. +- No obscure abbreviations: `request` not `req`, `response` not `resp` (except where the convention is universal and unambiguous, e.g. `ctx`, `err`). +- Package names are singular, lowercase, and noun-based: `profile`, `repo`, not `profiles`, `repoUtils`. + +## Comments + +- Every exported type, function, and constant gets a doc comment. +- Comments explain *why*, not *what* — the code already shows what. +- Never commit commented-out code. Delete it; version control remembers. +- Don't annotate the obvious: `// increment i` above `i++` adds noise, not signal. + +## Testing + +- Everything gets tests. No exceptions for "simple" code. +- Test behavior, not implementation — tests should survive refactors. +- Failing tests must be self-explanatory: the failure message should tell you what broke and why, without reading the test body. +- Table-driven tests are preferred in Go for covering multiple cases cleanly. +- Avoid mocking internals; mock at boundaries (I/O, network, time). + +## Go-Specific + +- **Explicit error handling** — never ignore an error with `_` unless the reason is documented. +- **Composition over inheritance** — embed interfaces and structs; avoid deep type hierarchies. +- **Errors are values** — wrap with context using `fmt.Errorf("doing X: %w", err)`; return, don't panic. +- **No `init()` side effects** — prefer explicit initialization at the call site. +- **Interface segregation** — define small, focused interfaces at the point of use, not in the package that implements them. +- **No util/common/misc packages** — see Core Principles above. +- Doc comments on all exported types follow the `// TypeName does...` convention. + +## Reference Shelf + +- *Clean Code* — Robert C. Martin +- *Clean Architecture* — Robert C. Martin +- *The Pragmatic Programmer* — Hunt & Thomas +- *Design Patterns (GoF)* — Gamma et al. +- *Effective Java* — Joshua Bloch (language-agnostic principles translate well) diff --git a/docs/examples/example.raid.yaml b/docs/examples/example.raid.yaml index cc5182c..98131e2 100644 --- a/docs/examples/example.raid.yaml +++ b/docs/examples/example.raid.yaml @@ -33,7 +33,7 @@ environments: - name: API_URL value: https://api.example.com -# install: -# - tasks: -# - type: Shell -# cmd: echo "Installing dependencies..." \ No newline at end of file +install: + tasks: + - type: Shell + cmd: echo "Installing dependencies..." \ No newline at end of file diff --git a/schemas/raid-defs.schema.json b/schemas/raid-defs.schema.json index 70fe0b8..d34c212 100644 --- a/schemas/raid-defs.schema.json +++ b/schemas/raid-defs.schema.json @@ -103,8 +103,19 @@ } } }, - "required": ["name"] + "required": ["name"], + "additionalProperties": false } + }, + "install": { + "type": "object", + "description": "Options for installing the raid profile", + "properties": { + "tasks": { + "$ref": "#/properties/tasks" + } + }, + "additionalProperties": false } }, "$defs": { diff --git a/schemas/raid-profile.schema.json b/schemas/raid-profile.schema.json index 14fcbae..73a9f2a 100644 --- a/schemas/raid-profile.schema.json +++ b/schemas/raid-profile.schema.json @@ -35,6 +35,9 @@ }, "environments": { "$ref": "raid-defs.schema.json#/properties/environments" + }, + "install": { + "$ref": "raid-defs.schema.json#/properties/install" } }, "required": ["name"] diff --git a/schemas/raid-repo.schema.json b/schemas/raid-repo.schema.json index f1e2ec5..aad7b5a 100644 --- a/schemas/raid-repo.schema.json +++ b/schemas/raid-repo.schema.json @@ -16,6 +16,9 @@ }, "environments": { "$ref": "raid-defs.schema.json#/properties/environments" + }, + "install": { + "$ref": "raid-defs.schema.json#/properties/install" } }, "required": ["name","branch"] diff --git a/src/internal/lib/lib.go b/src/internal/lib/lib.go index 901604f..f9ddc67 100644 --- a/src/internal/lib/lib.go +++ b/src/internal/lib/lib.go @@ -2,17 +2,33 @@ package lib import ( + "bytes" "fmt" + "io" + "os" + "path/filepath" + "strings" "sync" + + sys "github.com/8bitalex/raid/src/internal/sys" + "github.com/8bitalex/raid/src/internal/utils" + "github.com/santhosh-tekuri/jsonschema/v6" + "github.com/thoas/go-funk" ) const ( - YAML_SEP = "---" + YAML_SEP = "---" + RaidConfigFileName = "raid.yaml" ) type Context struct { Profile Profile Env string + // Options Options +} + +type OnInstall struct { + Tasks []Task `json:"tasks"` } var context *Context @@ -30,6 +46,12 @@ func ForceLoad() error { return err } + for i := range profile.Repositories { + if err := buildRepo(&profile.Repositories[i]); err != nil { + return err + } + } + context = &Context{ Profile: profile, Env: GetEnv(), @@ -81,5 +103,63 @@ func Install(maxThreads int) error { return fmt.Errorf("some repositories failed to install: %v", errors) } + pts := profile.Install.Tasks + if err := ExecuteTasks(pts); err != nil { + return fmt.Errorf("failed to execute install tasks: %w", err) + } + + rts := funk.FlatMap(profile.Repositories, func(r Repo) []Task { + return r.Install.Tasks + }).([]Task) + if err := ExecuteTasks(rts); err != nil { + return fmt.Errorf("failed to execute repository install tasks: %w", err) + } + + return nil +} + +func ValidateSchema(path string, schemaPath string) error { + path = sys.ExpandPath(path) + schemaPath = sys.ExpandPath(schemaPath) + + if !sys.FileExists(path) || path == "" { + return fmt.Errorf("file not found at %s", path) + } + if !sys.FileExists(schemaPath) || schemaPath == "" { + return fmt.Errorf("file not found at %s", schemaPath) + } + + c := jsonschema.NewCompiler() + sch, err := c.Compile(schemaPath) + if err != nil { + return err + } + + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + + contents := io.Reader(f) + + ext := strings.ToLower(filepath.Ext(path)) + if ext == ".yaml" || ext == ".yml" { + data, err := utils.YAMLToJSON(f) + if err != nil { + return err + } + contents = bytes.NewReader(data) + } + + json, err := jsonschema.UnmarshalJSON(contents) + if err != nil { + return err + } + + err = sch.Validate(json) + if err != nil { + return fmt.Errorf("invalid format: %w", err) + } return nil } diff --git a/src/internal/lib/profile.go b/src/internal/lib/profile.go index ea46510..3147f10 100644 --- a/src/internal/lib/profile.go +++ b/src/internal/lib/profile.go @@ -1,31 +1,29 @@ package lib import ( - "bytes" "encoding/json" "fmt" - "io" "os" "path/filepath" "strings" sys "github.com/8bitalex/raid/src/internal/sys" - "github.com/santhosh-tekuri/jsonschema/v6" "github.com/spf13/viper" "gopkg.in/yaml.v3" ) const ( - ACTIVE_PROFILE_KEY = "profile" - ALL_PROFILES_KEY = "profiles" - PROFILE_SCHEMA_PATH = "schemas/raid-profile.schema.json" + ACTIVE_PROFILE_KEY = "profile" + ALL_PROFILES_KEY = "profiles" + PROFILE_SCHEMA_PATH = "schemas/raid-profile.schema.json" ) type Profile struct { - Name string `json:"name"` - Path string `json:"path"` - Repositories []Repo `json:"repositories"` - Environments []Env `json:"environments"` + Name string `json:"name"` + Path string `json:"path"` + Repositories []Repo `json:"repositories"` + Environments []Env `json:"environments"` + Install OnInstall `json:"install"` } func (p Profile) IsZero() bool { @@ -53,7 +51,7 @@ func GetProfile() Profile { if context != nil && !context.Profile.IsZero() { return context.Profile } - + name := viper.GetString(ACTIVE_PROFILE_KEY) paths := getProfilePaths() return Profile{ @@ -206,51 +204,7 @@ func ContainsProfile(name string) bool { } func ValidateProfile(path string) error { - if !sys.FileExists(path) { - return fmt.Errorf("file not found at %s", path) - } - - c := jsonschema.NewCompiler() - sch, err := c.Compile(PROFILE_SCHEMA_PATH) - if err != nil { - return err - } - - f, err := os.Open(path) - if err != nil { - return err - } - defer f.Close() - - profile := io.Reader(f) - - ext := strings.ToLower(filepath.Ext(path)) - if ext == ".yaml" || ext == ".yml" { - data, err := yamlToJSON(f) - if err != nil { - return err - } - profile = bytes.NewReader(data) - } - - json, err := jsonschema.UnmarshalJSON(profile) - if err != nil { - return err - } - - err = sch.Validate(json) - if err != nil { - return fmt.Errorf("invalid profile format: %w", err) - } - return nil -} - -func yamlToJSON(file io.Reader) ([]byte, error) { - var data interface{} - if err := yaml.NewDecoder(file).Decode(&data); err != nil { - return nil, err - } - return json.Marshal(data) + return ValidateSchema(path, PROFILE_SCHEMA_PATH) } func buildProfile(profile Profile) (Profile, error) { diff --git a/src/internal/lib/repo.go b/src/internal/lib/repo.go index ecb9d75..99a96c9 100644 --- a/src/internal/lib/repo.go +++ b/src/internal/lib/repo.go @@ -7,13 +7,19 @@ import ( "path/filepath" sys "github.com/8bitalex/raid/src/internal/sys" + "gopkg.in/yaml.v3" +) + +const ( + REPO_SCHEMA_PATH = "schemas/raid-repo.schema.json" ) type Repo struct { - Name string `json:"name"` - Path string `json:"path"` - URL string `json:"url"` - Environments []Env `json:"environments"` + Name string `json:"name"` + Path string `json:"path"` + URL string `json:"url"` + Environments []Env `json:"environments"` + Install OnInstall `json:"install"` } func (r Repo) IsZero() bool { @@ -29,8 +35,29 @@ func (r Repo) getEnv(name string) Env { return Env{} } -func buildRepo(repo Repo) (Repo, error) { - return repo, nil +func buildRepo(repo *Repo) error { + if repo.IsZero() { + return fmt.Errorf("invalid repository: %v", repo) + } + + raidFile := filepath.Join(sys.ExpandPath(repo.Path), RaidConfigFileName) + if !sys.FileExists(raidFile) { + return nil + } + + if err := ValidateRepo(raidFile); err != nil { + return fmt.Errorf("invalid raid configuration for '%s': %w", repo.Name, err) + } + + repoConfig, err := ExtractRepo(repo.Path) + if err != nil { + return fmt.Errorf("failed to read config for '%s': %w", repo.Name, err) + } + + repo.Environments = append(repo.Environments, repoConfig.Environments...) + repo.Install.Tasks = append(repo.Install.Tasks, repoConfig.Install.Tasks...) + + return nil } func CloneRepository(repo Repo) error { @@ -72,3 +99,23 @@ func clone(path string, url string) error { cmd.Stderr = os.Stderr return cmd.Run() } + +func ValidateRepo(path string) error { + return ValidateSchema(path, REPO_SCHEMA_PATH) +} + +// ExtractRepo reads and parses the raid.yaml from the given repository directory. +func ExtractRepo(path string) (Repo, error) { + filePath := filepath.Join(sys.ExpandPath(path), RaidConfigFileName) + data, err := os.ReadFile(filePath) + if err != nil { + return Repo{}, fmt.Errorf("failed to read %s: %w", filePath, err) + } + + var repo Repo + if err := yaml.Unmarshal(data, &repo); err != nil { + return Repo{}, fmt.Errorf("failed to parse %s: %w", filePath, err) + } + + return repo, nil +} diff --git a/src/internal/lib/task_runner_test.go b/src/internal/lib/task_runner_test.go new file mode 100644 index 0000000..b4e6909 --- /dev/null +++ b/src/internal/lib/task_runner_test.go @@ -0,0 +1,361 @@ +package lib + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// --- getShell --- + +func TestGetShell(t *testing.T) { + tests := []struct { + input string + want []string + }{ + {"bash", []string{"/bin/bash", "-c"}}, + {"/bin/bash", []string{"/bin/bash", "-c"}}, + {"sh", []string{"/bin/sh", "-c"}}, + {"/bin/sh", []string{"/bin/sh", "-c"}}, + {"zsh", []string{"/bin/zsh", "-c"}}, + {"/bin/zsh", []string{"/bin/zsh", "-c"}}, + {"BASH", []string{"/bin/bash", "-c"}}, // case-insensitive + {"unknown", []string{"/bin/bash", "-c"}}, + {"", []string{"/bin/bash", "-c"}}, // default on non-Windows + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := getShell(tt.input) + if len(got) != len(tt.want) { + t.Fatalf("getShell(%q) = %v, want %v", tt.input, got, tt.want) + } + for i := range got { + if got[i] != tt.want[i] { + t.Errorf("getShell(%q)[%d] = %q, want %q", tt.input, i, got[i], tt.want[i]) + } + } + }) + } +} + +func TestGetShell_powershell(t *testing.T) { + tests := []struct { + input string + wantFirst string + }{ + {"powershell", "powershell"}, + {"pwsh", "powershell"}, + {"ps", "powershell"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := getShell(tt.input) + if got[0] != tt.wantFirst { + t.Errorf("getShell(%q)[0] = %q, want %q", tt.input, got[0], tt.wantFirst) + } + }) + } +} + +// --- ExecuteTask --- + +func TestExecuteTask_zeroTaskIsNoop(t *testing.T) { + if err := ExecuteTask(Task{}); err != nil { + t.Errorf("ExecuteTask(zero) returned unexpected error: %v", err) + } +} + +func TestExecuteTask_unknownType(t *testing.T) { + err := ExecuteTask(Task{Type: "bogus", Cmd: "exit 0"}) + if err == nil { + t.Fatal("expected error for unknown task type, got nil") + } + if !strings.Contains(err.Error(), "bogus") { + t.Errorf("error %q should mention the invalid type", err.Error()) + } +} + +func TestExecuteTask_shell(t *testing.T) { + tests := []struct { + name string + task Task + wantErr bool + }{ + { + name: "successful command", + task: Task{Type: Shell, Cmd: "exit 0"}, + wantErr: false, + }, + { + name: "failing command", + task: Task{Type: Shell, Cmd: "exit 1"}, + wantErr: true, + }, + { + name: "type is case-insensitive", + task: Task{Type: "SHELL", Cmd: "exit 0"}, + wantErr: false, + }, + { + name: "explicit bash shell", + task: Task{Type: Shell, Cmd: "exit 0", Shell: "bash"}, + wantErr: false, + }, + { + name: "explicit sh shell", + task: Task{Type: Shell, Cmd: "exit 0", Shell: "sh"}, + wantErr: false, + }, + { + name: "explicit zsh shell", + task: Task{Type: Shell, Cmd: "exit 0", Shell: "zsh"}, + wantErr: false, + }, + { + name: "literal=true still executes", + task: Task{Type: Shell, Cmd: "exit 0", Literal: true}, + wantErr: false, + }, + { + name: "concurrent shell task succeeds", + task: Task{Type: Shell, Cmd: "exit 0", Concurrent: true}, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ExecuteTask(tt.task) + if (err != nil) != tt.wantErr { + t.Errorf("ExecuteTask() error = %v, wantErr = %v", err, tt.wantErr) + } + }) + } +} + +func TestExecuteTask_shell_errorMentionsCommand(t *testing.T) { + task := Task{Type: Shell, Cmd: "exit 42"} + err := ExecuteTask(task) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "exit 42") { + t.Errorf("error %q should mention the failed command", err.Error()) + } +} + +func TestExecuteTask_shell_literal_skipsGoExpansion(t *testing.T) { + // With Literal=false, sys.Expand runs before the shell sees the command. + // With Literal=true, the raw string is passed to the shell unchanged. + // We verify by using a variable that exists in Go's env but NOT in the shell. + // sys.Expand would replace it; with literal=true it is left for the shell. + // The shell will expand it to empty, so the command still succeeds either way — + // what we care about is that Literal=true does not crash or error. + os.Setenv("RAID_LITERAL_TEST", "value") + defer os.Unsetenv("RAID_LITERAL_TEST") + + task := Task{Type: Shell, Cmd: "test -n anything", Literal: true} + if err := ExecuteTask(task); err != nil { + t.Errorf("literal task returned unexpected error: %v", err) + } +} + +// --- Script tasks --- + +func TestExecuteTask_script(t *testing.T) { + successScript := writeTempScript(t, "#!/bin/sh\nexit 0\n") + failScript := writeTempScript(t, "#!/bin/sh\nexit 1\n") + + tests := []struct { + name string + task Task + wantErr bool + }{ + { + name: "missing file", + task: Task{Type: Script, Path: "/nonexistent/path/script.sh"}, + wantErr: true, + }, + { + name: "success with runner", + task: Task{Type: Script, Path: successScript, Runner: "bash"}, + wantErr: false, + }, + { + name: "failure with runner", + task: Task{Type: Script, Path: failScript, Runner: "bash"}, + wantErr: true, + }, + { + name: "success without runner (direct execution)", + task: Task{Type: Script, Path: successScript}, + wantErr: false, + }, + { + name: "type is case-insensitive", + task: Task{Type: "SCRIPT", Path: successScript, Runner: "bash"}, + wantErr: false, + }, + { + name: "concurrent script task succeeds", + task: Task{Type: Script, Path: successScript, Runner: "bash", Concurrent: true}, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ExecuteTask(tt.task) + if (err != nil) != tt.wantErr { + t.Errorf("ExecuteTask() error = %v, wantErr = %v", err, tt.wantErr) + } + }) + } +} + +func TestExecuteTask_script_missingFile_errorMentionsPath(t *testing.T) { + task := Task{Type: Script, Path: "/no/such/file.sh"} + err := ExecuteTask(task) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "does not exist") { + t.Errorf("error %q should mention file not existing", err.Error()) + } +} + +// --- ExecuteTasks --- + +func TestExecuteTasks(t *testing.T) { + successScript := writeTempScript(t, "#!/bin/sh\nexit 0\n") + failScript := writeTempScript(t, "#!/bin/sh\nexit 1\n") + + tests := []struct { + name string + tasks []Task + wantErr bool + }{ + { + name: "nil slice is noop", + tasks: nil, + wantErr: false, + }, + { + name: "empty slice is noop", + tasks: []Task{}, + wantErr: false, + }, + { + name: "all sequential succeed", + tasks: []Task{ + {Type: Shell, Cmd: "exit 0"}, + {Type: Shell, Cmd: "exit 0"}, + }, + wantErr: false, + }, + { + name: "sequential failure reports error", + tasks: []Task{ + {Type: Shell, Cmd: "exit 1"}, + }, + wantErr: true, + }, + { + name: "all concurrent succeed", + tasks: []Task{ + {Type: Shell, Cmd: "exit 0", Concurrent: true}, + {Type: Shell, Cmd: "exit 0", Concurrent: true}, + {Type: Shell, Cmd: "exit 0", Concurrent: true}, + }, + wantErr: false, + }, + { + name: "concurrent failure reports error", + tasks: []Task{ + {Type: Shell, Cmd: "exit 1", Concurrent: true}, + {Type: Shell, Cmd: "exit 0", Concurrent: true}, + }, + wantErr: true, + }, + { + name: "mixed sequential and concurrent", + tasks: []Task{ + {Type: Shell, Cmd: "exit 0"}, + {Type: Shell, Cmd: "exit 0", Concurrent: true}, + {Type: Shell, Cmd: "exit 0"}, + }, + wantErr: false, + }, + { + name: "script tasks included", + tasks: []Task{ + {Type: Shell, Cmd: "exit 0"}, + {Type: Script, Path: successScript, Runner: "bash"}, + }, + wantErr: false, + }, + { + name: "script failure reported", + tasks: []Task{ + {Type: Script, Path: failScript, Runner: "bash"}, + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ExecuteTasks(tt.tasks) + if (err != nil) != tt.wantErr { + t.Errorf("ExecuteTasks() error = %v, wantErr = %v", err, tt.wantErr) + } + }) + } +} + +func TestExecuteTasks_multipleFailures_allReported(t *testing.T) { + tasks := []Task{ + {Type: Shell, Cmd: "exit 1"}, + {Type: Shell, Cmd: "exit 1"}, + } + + err := ExecuteTasks(tasks) + if err == nil { + t.Fatal("expected error, got nil") + } + + // Both failures should be captured in the combined error message. + const wantOccurrences = 2 + count := strings.Count(err.Error(), "failed to execute task") + if count < wantOccurrences { + t.Errorf("error should mention %d failures, got %d occurrences in: %q", wantOccurrences, count, err.Error()) + } +} + +func TestExecuteTasks_errorMentionsTaskType(t *testing.T) { + tasks := []Task{ + {Type: Shell, Cmd: "exit 1"}, + } + + err := ExecuteTasks(tasks) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "shell") { + t.Errorf("error %q should mention the task type", err.Error()) + } +} + +// --- helpers --- + +func writeTempScript(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "script.sh") + if err := os.WriteFile(path, []byte(content), 0755); err != nil { + t.Fatalf("failed to write temp script: %v", err) + } + return path +} diff --git a/src/internal/lib/task_test.go b/src/internal/lib/task_test.go new file mode 100644 index 0000000..db8a2e7 --- /dev/null +++ b/src/internal/lib/task_test.go @@ -0,0 +1,114 @@ +package lib + +import ( + "os" + "testing" +) + +func TestTaskIsZero(t *testing.T) { + tests := []struct { + name string + task Task + want bool + }{ + {"empty task", Task{}, true}, + {"task with type only", Task{Type: Shell}, false}, + {"task with cmd but no type", Task{Cmd: "echo hi"}, true}, + {"shell task with cmd", Task{Type: Shell, Cmd: "echo hi"}, false}, + {"script task with path", Task{Type: Script, Path: "/tmp/script.sh"}, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.task.IsZero(); got != tt.want { + t.Errorf("IsZero() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestTaskTypeToLower(t *testing.T) { + tests := []struct { + input TaskType + want TaskType + }{ + {"shell", "shell"}, + {"Shell", "shell"}, + {"SHELL", "shell"}, + {"script", "script"}, + {"Script", "script"}, + {"SCRIPT", "script"}, + {"", ""}, + } + + for _, tt := range tests { + t.Run(string(tt.input), func(t *testing.T) { + if got := tt.input.ToLower(); got != tt.want { + t.Errorf("ToLower() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestTaskExpand_expandsEnvVarsInFields(t *testing.T) { + os.Setenv("RAID_TEST_EXPAND", "hello") + defer os.Unsetenv("RAID_TEST_EXPAND") + + task := Task{ + Type: Shell, + Concurrent: true, + Literal: true, + Cmd: "echo $RAID_TEST_EXPAND", + Shell: "bash", + Path: "/tmp/$RAID_TEST_EXPAND/script.sh", + Runner: "$RAID_TEST_EXPAND", + } + + got := task.Expand() + + if got.Cmd != "echo hello" { + t.Errorf("Expand().Cmd = %q, want %q", got.Cmd, "echo hello") + } + if got.Path != "/tmp/hello/script.sh" { + t.Errorf("Expand().Path = %q, want %q", got.Path, "/tmp/hello/script.sh") + } + if got.Runner != "hello" { + t.Errorf("Expand().Runner = %q, want %q", got.Runner, "hello") + } +} + +func TestTaskExpand_preservesNonStringFields(t *testing.T) { + task := Task{ + Type: Script, + Concurrent: true, + Literal: true, + Shell: "zsh", + } + + got := task.Expand() + + if got.Type != task.Type { + t.Errorf("Expand().Type = %q, want %q", got.Type, task.Type) + } + if got.Concurrent != task.Concurrent { + t.Errorf("Expand().Concurrent = %v, want %v", got.Concurrent, task.Concurrent) + } + if got.Literal != task.Literal { + t.Errorf("Expand().Literal = %v, want %v", got.Literal, task.Literal) + } + if got.Shell != task.Shell { + t.Errorf("Expand().Shell = %q, want %q", got.Shell, task.Shell) + } +} + +func TestTaskExpand_doesNotMutateOriginal(t *testing.T) { + os.Setenv("RAID_TEST_ORIG", "changed") + defer os.Unsetenv("RAID_TEST_ORIG") + + original := Task{Type: Shell, Cmd: "echo $RAID_TEST_ORIG"} + _ = original.Expand() + + if original.Cmd != "echo $RAID_TEST_ORIG" { + t.Errorf("Expand() mutated original: Cmd = %q", original.Cmd) + } +} diff --git a/src/internal/utils/Common.go b/src/internal/utils/Common.go index 273762e..8a2a596 100644 --- a/src/internal/utils/Common.go +++ b/src/internal/utils/Common.go @@ -1,6 +1,12 @@ package utils -import "fmt" +import ( + "encoding/json" + "fmt" + "io" + + "gopkg.in/yaml.v3" +) func MergeErr(errs []error) error { var result string @@ -13,3 +19,11 @@ func MergeErr(errs []error) error { } return fmt.Errorf("%s", result) } + +func YAMLToJSON(file io.Reader) ([]byte, error) { + var data interface{} + if err := yaml.NewDecoder(file).Decode(&data); err != nil { + return nil, err + } + return json.Marshal(data) +} diff --git a/src/raid/raid.go b/src/raid/raid.go index 8319c82..03bfe19 100644 --- a/src/raid/raid.go +++ b/src/raid/raid.go @@ -22,6 +22,7 @@ import ( ) const ( + RaidConfigFileName = lib.RaidConfigFileName ConfigPathFlag = lib.ConfigPathFlag ConfigPathFlagDesc = lib.ConfigPathFlagDesc ConfigPathFlagShort = lib.ConfigPathFlagShort @@ -36,13 +37,13 @@ var ConfigPath = &lib.CfgPath // Initialize the raid environment, including loading configurations and initializing data storage. func Initialize() { if err := lib.InitConfig(); err != nil { - log.Fatalf("Failed to initialize configuration: %v", err) + log.Fatalf("%v\n", err) } if err := Load(); err != nil { - log.Fatalf("Failed to compile configurations: %v", err) + log.Fatalf("%v\n", err) } if err := lib.LoadEnv(); err != nil { - fmt.Printf("Failed to load environment variables: %v", err) + fmt.Printf("%v\n", err) } } From 4f3c1e16e1c23836328fc9afa957c6ac185ecda4 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 12 Mar 2026 19:00:51 -0700 Subject: [PATCH 06/42] bug fixes --- src/internal/lib/env.go | 19 ++++++++++++------- src/internal/lib/lib.go | 8 +++++++- src/internal/lib/task_runner.go | 9 ++++++++- src/internal/lib/task_runner_test.go | 16 ++++++++-------- 4 files changed, 35 insertions(+), 17 deletions(-) diff --git a/src/internal/lib/env.go b/src/internal/lib/env.go index 843bd08..e3929e5 100644 --- a/src/internal/lib/env.go +++ b/src/internal/lib/env.go @@ -6,7 +6,6 @@ import ( sys "github.com/8bitalex/raid/src/internal/sys" "github.com/joho/godotenv" "github.com/spf13/viper" - "github.com/thoas/go-funk" ) const ( @@ -147,13 +146,19 @@ func LoadEnv() error { return fmt.Errorf("context not initialized") } - repos := context.Profile.Repositories - paths := funk.Map(repos, func(r Repo) string { - return sys.ExpandPath(r.Path) + sys.Sep + ".env" - }).([]string) + var paths []string + for _, r := range context.Profile.Repositories { + p := sys.ExpandPath(r.Path) + sys.Sep + ".env" + if sys.FileExists(p) { + paths = append(paths, p) + } + } - err := godotenv.Load(paths...) - if err != nil { + if len(paths) == 0 { + return nil + } + + if err := godotenv.Load(paths...); err != nil { return fmt.Errorf("failed to load env files: %w", err) } return nil diff --git a/src/internal/lib/lib.go b/src/internal/lib/lib.go index f9ddc67..0b5bb43 100644 --- a/src/internal/lib/lib.go +++ b/src/internal/lib/lib.go @@ -41,7 +41,13 @@ func Load() error { } func ForceLoad() error { - profile, err := buildProfile(GetProfile()) + p := GetProfile() + if p.IsZero() { + context = &Context{Env: GetEnv()} + return nil + } + + profile, err := buildProfile(p) if err != nil { return err } diff --git a/src/internal/lib/task_runner.go b/src/internal/lib/task_runner.go index 0d12911..348d6b3 100644 --- a/src/internal/lib/task_runner.go +++ b/src/internal/lib/task_runner.go @@ -25,7 +25,14 @@ func ExecuteTasks(tasks []Task) error { }(task) } else { if err := ExecuteTask(task); err != nil { - errorChan <- fmt.Errorf("failed to execute task '%s': %w", task.Type, err) + // Wait for any already-started concurrent tasks before returning. + wg.Wait() + close(errorChan) + errs := []error{fmt.Errorf("failed to execute task '%s': %w", task.Type, err)} + for e := range errorChan { + errs = append(errs, e) + } + return fmt.Errorf("some tasks failed to execute: %v", errs) } } } diff --git a/src/internal/lib/task_runner_test.go b/src/internal/lib/task_runner_test.go index b4e6909..dda81fc 100644 --- a/src/internal/lib/task_runner_test.go +++ b/src/internal/lib/task_runner_test.go @@ -316,22 +316,22 @@ func TestExecuteTasks(t *testing.T) { } } -func TestExecuteTasks_multipleFailures_allReported(t *testing.T) { +func TestExecuteTasks_sequentialFailsFast(t *testing.T) { + // A failing sequential task must halt further sequential execution. + // We verify this by placing a task after the failure that would write a + // marker file if it ran — the marker must not exist after the run. + marker := filepath.Join(t.TempDir(), "should-not-exist") tasks := []Task{ {Type: Shell, Cmd: "exit 1"}, - {Type: Shell, Cmd: "exit 1"}, + {Type: Shell, Cmd: "touch " + marker}, } err := ExecuteTasks(tasks) if err == nil { t.Fatal("expected error, got nil") } - - // Both failures should be captured in the combined error message. - const wantOccurrences = 2 - count := strings.Count(err.Error(), "failed to execute task") - if count < wantOccurrences { - t.Errorf("error should mention %d failures, got %d occurrences in: %q", wantOccurrences, count, err.Error()) + if _, statErr := os.Stat(marker); statErr == nil { + t.Error("second task ran after sequential failure; expected fail-fast") } } From 0a7a49b303ce11b19b49ee743b1b159bcfa52f72 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 12 Mar 2026 20:16:45 -0700 Subject: [PATCH 07/42] Simplify readme --- README.md | 424 ++++++++---------------------------------------------- 1 file changed, 61 insertions(+), 363 deletions(-) diff --git a/README.md b/README.md index 4d55728..83ed152 100644 --- a/README.md +++ b/README.md @@ -1,230 +1,83 @@ [![Build and Test](https://github.com/8bitAlex/raid/actions/workflows/build.yml/badge.svg)](https://github.com/8bitAlex/raid/actions/workflows/build.yml) [![codecov](https://codecov.io/github/8bitAlex/raid/graph/badge.svg?token=Z75V7I2TLW)](https://codecov.io/github/8bitAlex/raid) -[![Go Report Card](https://goreportcard.com/badge/github.com/8bitAlex/raid)](https://goreportcard.com/report/github.com/8bitAlex/raid◊) +[![Go Report Card](https://goreportcard.com/badge/github.com/8bitAlex/raid)](https://goreportcard.com/report/github.com/8bitAlex/raid◊) -# Raid - Distributed Development Orchestration +# Raid — Distributed Development Orchestration ![Windows](https://img.shields.io/badge/Windows-Yes-blue?logo=windows) ![macOS](https://img.shields.io/badge/macOS-Yes-lightgrey?logo=apple) ![Linux](https://img.shields.io/badge/Linux-Yes-yellow?logo=linux) - `Raid` is a configurable command-line application that orchestrates common development tasks, environments, and dependencies across distributed code repositories. -If you have ever pulled a repo (or repos) that require days of configuration just to get a passing build, -or have onboarded to a new team that has no documentation, or have a folder of scripts to automate your tasks but haven't -shared them yet, then you are probably in need of this. - -`Raid` handles the pain of error-prone knowledge-dependent tasks and management of your development environment. You no longer need -to worry about wasted time onboarding new contributors. Tribal knowledge can be codified into the repo itself. And you will -never miss running that one test ever again. +Tribal knowledge codified into the repo itself — onboarding becomes a single command. -📖 For a deeper look at the goals and design of raid, see the [design proposal blog post](https://alexsalerno.dev/blog/raid-design-proposal?utm_source=chatgpt.com). +📖 For a deeper look at the goals and design, see the [design proposal blog post](https://alexsalerno.dev/blog/raid-design-proposal?utm_source=chatgpt.com). ## Key Features -- **Portable YAML Configurations**: Define your development environments, tasks, and dependencies using simple, version-controlled YAML files. Your configurations live alongside your code, making them easy to share and maintain. -- **Multiple Profiles**: Easily switch between different project setups or team configurations with isolated profiles. -- **Automated Task Execution**: Orchestrate shell commands, scripts, and custom tasks across multiple repositories with a single command. -- **Environment Management**: Define, share, and execute complex development environments to ensure consistency for all contributors. +- **Portable YAML Configurations** — define environments, tasks, and dependencies in version-controlled YAML files that live alongside your code. +- **Multiple Profiles** — switch between project setups or team configurations with isolated profiles. +- **Automated Task Execution** — orchestrate shell commands and scripts across multiple repositories with a single command. +- **Environment Management** — define and apply consistent development environments for all contributors. -## Development +## Development Status -`Raid` is currently in the **prototype stage**. Core functionality is still being explored and iterated on, so expect frequent changes and incomplete features. +`Raid` is currently in the **prototype stage**. Core functionality is still being explored and iterated on — expect frequent changes and incomplete features. Feedback, issues, and contributions are welcome as the project takes shape. --- -[Getting Started](#getting-started) • [Best Practices](#best-practices) • [Documentation](#usage--documentation) - ---- - ## Getting Started ### Installation -#### MacOS - ```bash -brew install raid # coming soon +brew install raid # macOS — Linux and Windows coming soon ``` -#### Linux - -```bash -# coming soon -``` - -#### Windows - -```bash -# coming soon -``` - -### Configuration - -1. Create a profile configuration file (e.g., `my-project.raid.yaml`) -2. Define your repositories and dependencies -3. Configure environment settings +### Quickstart -### Execution +First, create a profile file (see [Configuration](#configuration) below), then: ```bash -raid profile add my-project.raid.yaml # Add and activate a profile -raid install # Clone repos and setup environment -raid env dev # Execute development environment (if configured) +raid profile add my-project.raid.yaml # register and activate a profile +raid install # clone repos and run install tasks +raid env dev # apply the dev environment ``` -## Best Practices - - ### Store sensitive profiles securely - - If your raid profile contains sensitive configuration or secrets, keep it in a secure, private location outside of your public codebase. - - ### Never commit secrets - - Always keep secrets and credentials in private raid profiles. Do not store them in public repositories. - -## Usage & Documentation - -**Note:** Raid is currently in the prototype stage. Some features may be incomplete or in development. - -[Commands](#commands) • [Profile Configuration](#profile-configuration) • [Repository Configuration](#repository-configuration) • [JSON Schema Specifications](#json-schema-specifications) +--- ## Commands -[profile](#raid-profile) • [install](#raid-install) • [env](#raid-env) - ### `raid profile` -Manage raid profiles. If there are no non-option arguments, the currently active profile is displayed. - -#### Subcommands - -##### `raid profile add ` - -Add profile(s) from a YAML (.yaml, .yml) or JSON (.json) file. The file will be validated against the raid profile schema. - -**Features:** -- **Multiple Profiles Support**: Add multiple profiles from a single file using YAML document separators (`---`) or JSON arrays -- **Validation**: Each profile is validated against the JSON schema - -**Examples:** -```bash -# Add a single profile -raid profile add my-project.raid.yaml +Manage profiles. A profile is a named collection of repositories and environments. -# Add multiple profiles from YAML with document separators -raid profile add multiple-profiles.yaml - -# Add multiple profiles from JSON array -raid profile add multiple-profiles.json -``` - -**Example Output:** -```bash -# Single profile (auto-activated) -Profile 'my-project' has been successfully added from my-project.raid.yaml - -# Multiple profiles (first auto-activated) -Profiles: - development - personal - open-source -have been successfully added from multiple-profiles.yaml -Profile 'development' set as active - -# Some profiles already exist -Profiles already exist with names: - development - -Profiles: - personal - open-source -have been successfully added from multiple-profiles.yaml -``` - -##### `raid profile list` - -List all available profiles and show the currently active profile. - -**Example Output:** -```bash -Available profiles: - my-project (active) ~/.raid/profiles/my-project.raid.yaml - development ~/.raid/profiles/development.raid.yaml - personal ~/.raid/profiles/personal.raid.yaml -``` - -##### `raid profile use ` - -Set a specific profile as the active profile. - -**Example:** -```bash -raid profile use my-project -# Output: Profile 'my-project' is now active. -``` - -##### `raid profile remove [profile-name...]` - -Remove one or more profiles. You can specify multiple profile names to remove them all at once. - -**Examples:** -```bash -# Remove a single profile -raid profile remove old-project - -# Remove multiple profiles -raid profile remove project1 project2 project3 -``` - -**Example Output:** -```bash -Profile 'old-project' has been removed. -Profile 'project1' has been removed. -Profile 'project2' has been removed. -``` +- `raid profile add ` — register profiles from a YAML or JSON file; the first added profile is set as active automatically +- `raid profile list` — list all registered profiles +- `raid profile ` — pass a profile name as an argument to switch the active profile +- `raid profile remove ` — remove a profile ### `raid install` -Clones all repositories defined in the active profile to their specified paths. If a repository already exists, it will be skipped. Repositories are cloned concurrently for better performance. - -**Prerequisites:** -- An active profile must be set using `raid profile use ` -- The active profile must contain valid repository definitions - -**Features:** -- **Concurrent**: All repositories are cloned simultaneously for faster installation - -**Options:** -- `--threads, -t`: Maximum number of concurrent repository clones (default: 0 = unlimited) - -**Examples:** -```bash -# Set an active profile first -raid profile use my-project +Clone all repositories in the active profile and run any configured install tasks. Already-cloned repos are skipped. Use `-t` to limit concurrent clone threads. -# Install all repositories with unlimited concurrency (default) -raid install +### `raid env` -# Install with limited concurrency (max 3 concurrent clones) -raid install --threads 3 +- `raid env ` — apply a named environment: writes `.env` files into each repo and runs environment tasks +- `raid env` — show the currently active environment +- `raid env list` — list available environments -# Install with limited concurrency using short flag -raid install -t 5 -``` +--- -**Concurrency Guidelines:** -- **Unlimited (default)**: Best for fast networks and when you want maximum speed -- **Limited (3-5)**: Good for slower networks or when you want to avoid overwhelming the system -- **Very Limited (1-2)**: Useful for very slow connections or when you need to minimize resource usage +## Configuration -## Profile Configuration +Tasks can be defined under `install` or within any environment, in both profile and repo configs. -A profile configuration file follows the naming pattern `*.raid.yaml` and defines the properties of a raid profile—a group of repositories and their environments. +### Profile (`*.raid.yaml`) -### Single Profile Configuration +A profile defines the repositories and environments for a project. The `$schema` annotation enables autocomplete and validation in editors like VS Code. ```yaml # yaml-language-server: $schema=schemas/raid-profile.schema.json @@ -235,7 +88,6 @@ repositories: - name: frontend path: ~/Developer/frontend url: https://github.com/myorg/frontend - - name: backend path: ~/Developer/backend url: https://github.com/myorg/backend @@ -249,95 +101,22 @@ environments: value: postgresql://localhost:5432/myproject tasks: - type: Shell - cmd: echo "Setting up development environment..." + cmd: echo "dev environment ready" - type: Script path: ./scripts/setup-dev.sh -``` + runner: bash -### Multiple Profiles in a Single File - -You can define multiple profiles in a single file using YAML document separators (`---`) or JSON arrays. Each profile in the file is individually validated against the schema. - -#### YAML with Document Separators - -```yaml -# yaml-language-server: $schema=schemas/raid-profile.schema.json - -name: development -repositories: - - name: frontend - path: ~/Developer/company/frontend - url: https://github.com/company/frontend - - name: backend - path: ~/Developer/company/backend - url: https://github.com/company/backend ---- -name: personal -repositories: - - name: blog - path: ~/Developer/blog - url: https://github.com/username/blog - - name: dotfiles - path: ~/Developer/dotfiles - url: https://github.com/username/dotfiles ---- -name: open-source -repositories: - - name: raid - path: ~/Developer/raid - url: https://github.com/8bitAlex/raid -``` - -#### JSON with Arrays - -```json -[ - { - "$schema": "schemas/raid-profile.schema.json", - "name": "development", - "repositories": [ - { - "name": "frontend", - "path": "~/Developer/company/frontend", - "url": "https://github.com/company/frontend" - }, - { - "name": "backend", - "path": "~/Developer/company/backend", - "url": "https://github.com/company/backend" - } - ] - }, - { - "name": "personal", - "repositories": [ - { - "name": "blog", - "path": "~/Developer/blog", - "url": "https://github.com/username/blog" - } - ] - } -] +install: + tasks: + - type: Shell + cmd: echo "installing..." ``` +Multiple profiles can be defined in a single file using YAML document separators (`---`) or a JSON array. +### Repository (`raid.yaml`) -### Profile Management Features - -- **Schema Validation**: Each profile is validated against the JSON schema -- **Multiple Format Support**: YAML and JSON files are both supported -- **IDE Integration**: Use `$schema` references for autocomplete and validation - -**Note:** For detailed schema information, see the [JSON Schema Specifications](#json-schema-specifications) section. - -## Repository Configuration - -A repository configuration file named `raid.yaml` defines the properties of an individual repository. This file should be located in the root directory of a git repository. - -**Note:** Repository configurations follow the `raid-repo.schema.json` schema. See the [JSON Schema Specifications](#json-schema-specifications) section for detailed schema information. - -### Example Repository Configuration +Individual repositories can carry their own `raid.yaml` at their root to define repo-specific environments and install tasks. These are merged with the profile configuration at load time. Committing this file to each repo is the recommended way to share setup knowledge with your team. ```yaml # yaml-language-server: $schema=schemas/raid-repo.schema.json @@ -347,131 +126,50 @@ branch: main environments: - name: dev - variables: - - name: NODE_ENV - value: development tasks: - type: Shell cmd: npm install - - type: Shell - cmd: npm run build - type: Shell cmd: npm test ``` -## JSON Schema Specifications +### Tasks -Raid uses **JSON Schema Draft 2020-12** for configuration validation. The schema system consists of three main files: +Two task types are supported: -- **`raid-profile.schema.json`** - Main profile configuration schema -- **`raid-defs.schema.json`** - Shared definitions for environments and tasks -- **`raid-repo.schema.json`** - Individual repository configuration schema - -### Schema Validation - -All profile and repo configurations are validated against the **JSON Schema Draft 2020-12** specification. This ensures your configuration files have the correct structure and required fields. - -### IDE Integration - -For the best development experience, include schema references in your configuration files: - -```yaml -# yaml-language-server: $schema=schemas/raid-profile.schema.json -``` - -This provides: -- ✅ **Autocomplete** for field names and values -- ✅ **Real-time validation** of your configuration -- ✅ **Error highlighting** for invalid configurations -- ✅ **Documentation tooltips** for each field - -### Schema Structure Details - -#### Profile Schema (`raid-profile.schema.json`) -A raid profile configuration must contain: - -- **`name`** (string, required) - The name of the raid profile -- **`repositories`** (array, required) - Array of repository configurations - - Each repository must have: - - `name` (string, required) - The name of the repository - - `path` (string, required) - The local path to the repository - - `url` (string, required) - The URL of the repository -- **`environments`** (array, optional) - Array of environment configurations - -#### Repository Schema (`raid-repo.schema.json`) -A repository configuration must contain: - -- **`name`** (string, required) - The name of the repository -- **`branch`** (string, required) - The branch to checkout -- **`environments`** (array, optional) - Array of environment configurations (follows `raid-defs.schema.json`) - -#### Definitions Schema (`raid-defs.schema.json`) -Environments and tasks follow this shared schema: - -**Environment Schema:** -- **`name`** (string, required) - The name of the environment -- **`variables`** (array, optional) - Environment variables to set - - Each variable must have: - - `name` (string, required) - The name of the variable - - `value` (string, required) - The value of the variable -- **`tasks`** (array, optional) - Tasks to be executed - -**Task Schema:** -Tasks support two types: - -**Shell Tasks:** +**Shell** — run a command string in a configurable shell: ```yaml - type: Shell - cmd: echo "Hello World" - concurrent: true # Optional: execute concurrently with other tasks + cmd: echo "hello" + shell: bash # optional: bash (default), sh, zsh, powershell + literal: false # optional: skip env var expansion before passing to shell + concurrent: true # optional: run concurrently with other tasks ``` -**Script Tasks:** +**Script** — execute a script file: ```yaml - type: Script path: ./scripts/setup.sh - concurrent: false # Optional: execute sequentially + runner: bash # optional: interpreter to use + concurrent: false ``` -### Technical Details - -- **Schema Compatibility**: Fully compatible with JSON Schema Draft 2020-12 -- **Validation Engine**: Uses `github.com/santhosh-tekuri/jsonschema/v6` library for validation -- **File Format Support**: Both YAML and JSON files are supported -- **Multiple Profiles**: Each profile in a multi-profile file is individually validated - -## Contributing - -We welcome contributions! Please see our [Contributing Guidelines](docs/CONTRIBUTING.md) for details. - -## License +--- -This project is licensed under the **GNU General Public License v3.0** (GPL-3.0). +## Best Practices -### Key License Highlights +**Commit `raid.yaml` to each repo.** This is how setup knowledge gets shared — anyone with raid can run `raid install` and get a working environment without reading a wiki. -**What you can do:** -- ✅ **Use** the software for any purpose -- ✅ **Study** how the software works -- ✅ **Modify** the software to suit your needs -- ✅ **Distribute** copies of the software -- ✅ **Distribute** modified versions +**Keep profiles in a dotfiles repo.** Profile files reference your repos and environments. Storing them in a private dotfiles repo keeps them version-controlled and accessible across machines. -**What you must do:** -- 📋 **License your modifications** under the same GPL-3.0 license -- 📋 **Include source code** when distributing the software -- 📋 **State changes** you made to the software -- 📋 **Include the license** and copyright notices +**Never commit secrets.** Use environment variable references or keep sensitive values in private profiles — never hardcode credentials in a committed raid file. -**What you cannot do:** -- ❌ **Make the software proprietary** - modifications must remain open source -- ❌ **Remove the license** or copyright notices -- ❌ **Sublicense** under different terms +--- -### Full License Text +## Contributing -The complete license text is available in the [LICENSE](LICENSE) file. For more information about the GNU GPL, visit [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/). +Contributions are welcome. See [docs/CONTRIBUTING.md](docs/CONTRIBUTING.md) for details. -### Contributing +## License -By contributing to this project, you agree that your contributions will be licensed under the same GPL-3.0 license +Licensed under the **GNU General Public License v3.0**. See [LICENSE](LICENSE) for the full text. From 8e07c85be98f2fb3aa32532a5516dc007ff29605 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 12 Mar 2026 20:34:35 -0700 Subject: [PATCH 08/42] add http, wait, and template task types --- schemas/raid-defs.schema.json | 66 ++++++ src/internal/lib/task.go | 18 +- src/internal/lib/task_runner.go | 133 +++++++++++++ src/internal/lib/task_runner_test.go | 287 +++++++++++++++++++++++++++ 4 files changed, 502 insertions(+), 2 deletions(-) diff --git a/schemas/raid-defs.schema.json b/schemas/raid-defs.schema.json index d34c212..44a7de4 100644 --- a/schemas/raid-defs.schema.json +++ b/schemas/raid-defs.schema.json @@ -63,6 +63,72 @@ }, "required": ["type", "path"], "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "HTTP" + }, + "concurrent": { + "ref": "#/concurrent" + }, + "url": { + "type": "string", + "description": "URL to download from" + }, + "dest": { + "type": "string", + "description": "Local path to write the downloaded file to" + } + }, + "required": ["type", "url", "dest"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "Wait" + }, + "concurrent": { + "ref": "#/concurrent" + }, + "url": { + "type": "string", + "description": "HTTP(S) URL or TCP host:port to poll until ready" + }, + "timeout": { + "type": "string", + "description": "How long to wait before failing (e.g. 30s, 1m). Defaults to 30s." + } + }, + "required": ["type", "url"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "Template" + }, + "concurrent": { + "ref": "#/concurrent" + }, + "src": { + "type": "string", + "description": "Path to the template file. Supports $VAR and ${VAR} substitution from the current environment." + }, + "dest": { + "type": "string", + "description": "Path to write the rendered output file to" + } + }, + "required": ["type", "src", "dest"], + "additionalProperties": false } ] } diff --git a/src/internal/lib/task.go b/src/internal/lib/task.go index 1a6bf5e..561e49a 100644 --- a/src/internal/lib/task.go +++ b/src/internal/lib/task.go @@ -17,6 +17,13 @@ type Task struct { // Script Path string `json:"path,omitempty"` Runner string `json:"runner,omitempty"` + // HTTP + URL string `json:"url,omitempty"` + Dest string `json:"dest,omitempty"` + // Wait + Timeout string `json:"timeout,omitempty"` + // Template + Src string `json:"src,omitempty"` } func (t Task) IsZero() bool { @@ -32,14 +39,21 @@ func (t Task) Expand() Task { Shell: t.Shell, Path: sys.Expand(t.Path), Runner: sys.Expand(t.Runner), + URL: sys.Expand(t.URL), + Dest: sys.Expand(t.Dest), + Timeout: t.Timeout, + Src: sys.Expand(t.Src), } } type TaskType string const ( - Shell TaskType = "shell" - Script TaskType = "script" + Shell TaskType = "shell" + Script TaskType = "script" + HTTP TaskType = "http" + Wait TaskType = "wait" + Template TaskType = "template" ) func (t TaskType) ToLower() TaskType { diff --git a/src/internal/lib/task_runner.go b/src/internal/lib/task_runner.go index 348d6b3..299d8ed 100644 --- a/src/internal/lib/task_runner.go +++ b/src/internal/lib/task_runner.go @@ -2,10 +2,15 @@ package lib import ( "fmt" + "io" + "net" + "net/http" "os" "os/exec" + "path/filepath" "strings" "sync" + "time" "github.com/8bitalex/raid/src/internal/sys" ) @@ -62,6 +67,12 @@ func ExecuteTask(task Task) error { return execShell(task) case Script: return execScript(task) + case HTTP: + return execHTTP(task) + case Wait: + return execWait(task) + case Template: + return execTemplate(task) default: return fmt.Errorf("invalid task type: %s", task.Type) } @@ -142,3 +153,125 @@ func setCmdOutput(cmd *exec.Cmd) { cmd.Stderr = os.Stderr cmd.Stdin = os.Stdin } + +func execHTTP(task Task) error { + task = task.Expand() + + if task.URL == "" { + return fmt.Errorf("url is required for HTTP task") + } + if task.Dest == "" { + return fmt.Errorf("dest is required for HTTP task") + } + + resp, err := http.Get(task.URL) + if err != nil { + return fmt.Errorf("failed to fetch '%s': %w", task.URL, err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("HTTP request to '%s' returned status %d", task.URL, resp.StatusCode) + } + + if err := os.MkdirAll(filepath.Dir(task.Dest), 0755); err != nil { + return fmt.Errorf("failed to create directory for '%s': %w", task.Dest, err) + } + + f, err := os.Create(task.Dest) + if err != nil { + return fmt.Errorf("failed to create file '%s': %w", task.Dest, err) + } + defer f.Close() + + if _, err := io.Copy(f, resp.Body); err != nil { + return fmt.Errorf("failed to write to '%s': %w", task.Dest, err) + } + + return nil +} + +func execWait(task Task) error { + task = task.Expand() + + if task.URL == "" { + return fmt.Errorf("url is required for Wait task") + } + + timeout := 30 * time.Second + if task.Timeout != "" { + d, err := time.ParseDuration(task.Timeout) + if err != nil { + return fmt.Errorf("invalid timeout '%s': %w", task.Timeout, err) + } + timeout = d + } + + fmt.Printf("Waiting for %s (timeout: %s)...\n", task.URL, timeout) + + check := checkHTTP + if !strings.HasPrefix(task.URL, "http://") && !strings.HasPrefix(task.URL, "https://") { + check = checkTCP + } + + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if check(task.URL) == nil { + return nil + } + time.Sleep(1 * time.Second) + } + + return fmt.Errorf("timed out waiting for '%s' after %s", task.URL, timeout) +} + +func checkHTTP(url string) error { + client := &http.Client{Timeout: 2 * time.Second} + resp, err := client.Get(url) + if err != nil { + return err + } + resp.Body.Close() + return nil +} + +func checkTCP(address string) error { + conn, err := net.DialTimeout("tcp", address, 2*time.Second) + if err != nil { + return err + } + conn.Close() + return nil +} + +func execTemplate(task Task) error { + task = task.Expand() + + if task.Src == "" { + return fmt.Errorf("src is required for Template task") + } + if task.Dest == "" { + return fmt.Errorf("dest is required for Template task") + } + + if !sys.FileExists(task.Src) { + return fmt.Errorf("template file does not exist: %s", task.Src) + } + + data, err := os.ReadFile(task.Src) + if err != nil { + return fmt.Errorf("failed to read template '%s': %w", task.Src, err) + } + + rendered := os.ExpandEnv(string(data)) + + if err := os.MkdirAll(filepath.Dir(task.Dest), 0755); err != nil { + return fmt.Errorf("failed to create directory for '%s': %w", task.Dest, err) + } + + if err := os.WriteFile(task.Dest, []byte(rendered), 0644); err != nil { + return fmt.Errorf("failed to write output file '%s': %w", task.Dest, err) + } + + return nil +} diff --git a/src/internal/lib/task_runner_test.go b/src/internal/lib/task_runner_test.go index dda81fc..601899c 100644 --- a/src/internal/lib/task_runner_test.go +++ b/src/internal/lib/task_runner_test.go @@ -1,6 +1,9 @@ package lib import ( + "net" + "net/http" + "net/http/httptest" "os" "path/filepath" "strings" @@ -349,6 +352,290 @@ func TestExecuteTasks_errorMentionsTaskType(t *testing.T) { } } +// --- HTTP tasks --- + +func TestExecuteTask_http(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("downloaded content")) + })) + defer srv.Close() + + dest := filepath.Join(t.TempDir(), "output.txt") + + tests := []struct { + name string + task Task + wantErr bool + }{ + { + name: "successful download writes file", + task: Task{Type: HTTP, URL: srv.URL, Dest: dest}, + wantErr: false, + }, + { + name: "missing url", + task: Task{Type: HTTP, Dest: dest}, + wantErr: true, + }, + { + name: "missing dest", + task: Task{Type: HTTP, URL: srv.URL}, + wantErr: true, + }, + { + name: "unreachable url", + task: Task{Type: HTTP, URL: "http://localhost:0/no", Dest: dest}, + wantErr: true, + }, + { + name: "type is case-insensitive", + task: Task{Type: "HTTP", URL: srv.URL, Dest: dest}, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ExecuteTask(tt.task) + if (err != nil) != tt.wantErr { + t.Errorf("ExecuteTask() error = %v, wantErr = %v", err, tt.wantErr) + } + }) + } +} + +func TestExecuteTask_http_writesCorrectContent(t *testing.T) { + const body = "hello from server" + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(body)) + })) + defer srv.Close() + + dest := filepath.Join(t.TempDir(), "out.txt") + if err := ExecuteTask(Task{Type: HTTP, URL: srv.URL, Dest: dest}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + got, err := os.ReadFile(dest) + if err != nil { + t.Fatalf("failed to read output file: %v", err) + } + if string(got) != body { + t.Errorf("file content = %q, want %q", got, body) + } +} + +func TestExecuteTask_http_createsDestDirectory(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("ok")) + })) + defer srv.Close() + + dest := filepath.Join(t.TempDir(), "a", "b", "c", "out.txt") + if err := ExecuteTask(Task{Type: HTTP, URL: srv.URL, Dest: dest}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if _, err := os.Stat(dest); err != nil { + t.Errorf("expected dest file to exist: %v", err) + } +} + +func TestExecuteTask_http_nonSuccessStatus(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + dest := filepath.Join(t.TempDir(), "out.txt") + err := ExecuteTask(Task{Type: HTTP, URL: srv.URL, Dest: dest}) + if err == nil { + t.Fatal("expected error for non-2xx status, got nil") + } + if !strings.Contains(err.Error(), "404") { + t.Errorf("error %q should mention the status code", err.Error()) + } +} + +// --- Wait tasks --- + +func TestExecuteTask_wait_http(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + tests := []struct { + name string + task Task + wantErr bool + }{ + { + name: "http url responds immediately", + task: Task{Type: Wait, URL: srv.URL, Timeout: "5s"}, + wantErr: false, + }, + { + name: "default timeout used when not specified", + task: Task{Type: Wait, URL: srv.URL}, + wantErr: false, + }, + { + name: "missing url", + task: Task{Type: Wait}, + wantErr: true, + }, + { + name: "invalid timeout", + task: Task{Type: Wait, URL: srv.URL, Timeout: "not-a-duration"}, + wantErr: true, + }, + { + name: "type is case-insensitive", + task: Task{Type: "WAIT", URL: srv.URL, Timeout: "5s"}, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ExecuteTask(tt.task) + if (err != nil) != tt.wantErr { + t.Errorf("ExecuteTask() error = %v, wantErr = %v", err, tt.wantErr) + } + }) + } +} + +func TestExecuteTask_wait_tcp(t *testing.T) { + ln, err := net.Listen("tcp", "localhost:0") + if err != nil { + t.Fatal(err) + } + defer ln.Close() + + task := Task{Type: Wait, URL: ln.Addr().String(), Timeout: "5s"} + if err := ExecuteTask(task); err != nil { + t.Errorf("unexpected error waiting for TCP: %v", err) + } +} + +func TestExecuteTask_wait_timeout(t *testing.T) { + // Nothing is listening — must time out. + task := Task{Type: Wait, URL: "localhost:19234", Timeout: "1s"} + err := ExecuteTask(task) + if err == nil { + t.Fatal("expected timeout error, got nil") + } + if !strings.Contains(err.Error(), "timed out") { + t.Errorf("error %q should mention timeout", err.Error()) + } +} + +// --- Template tasks --- + +func TestExecuteTask_template(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "tmpl.txt") + dest := filepath.Join(dir, "out.txt") + os.WriteFile(src, []byte("hello $TMPL_TEST_VAR"), 0644) + + tests := []struct { + name string + task Task + wantErr bool + }{ + { + name: "successful render", + task: Task{Type: Template, Src: src, Dest: dest}, + wantErr: false, + }, + { + name: "missing src", + task: Task{Type: Template, Dest: dest}, + wantErr: true, + }, + { + name: "missing dest", + task: Task{Type: Template, Src: src}, + wantErr: true, + }, + { + name: "src file does not exist", + task: Task{Type: Template, Src: "/nonexistent/tmpl.txt", Dest: dest}, + wantErr: true, + }, + { + name: "type is case-insensitive", + task: Task{Type: "TEMPLATE", Src: src, Dest: dest}, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ExecuteTask(tt.task) + if (err != nil) != tt.wantErr { + t.Errorf("ExecuteTask() error = %v, wantErr = %v", err, tt.wantErr) + } + }) + } +} + +func TestExecuteTask_template_expandsEnvVars(t *testing.T) { + os.Setenv("TMPL_TEST_VAR", "world") + defer os.Unsetenv("TMPL_TEST_VAR") + + dir := t.TempDir() + src := filepath.Join(dir, "tmpl.txt") + dest := filepath.Join(dir, "out.txt") + os.WriteFile(src, []byte("hello $TMPL_TEST_VAR and ${TMPL_TEST_VAR}"), 0644) + + if err := ExecuteTask(Task{Type: Template, Src: src, Dest: dest}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + got, err := os.ReadFile(dest) + if err != nil { + t.Fatalf("failed to read output file: %v", err) + } + const want = "hello world and world" + if string(got) != want { + t.Errorf("rendered content = %q, want %q", got, want) + } +} + +func TestExecuteTask_template_createsDestDirectory(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "tmpl.txt") + dest := filepath.Join(dir, "a", "b", "out.txt") + os.WriteFile(src, []byte("content"), 0644) + + if err := ExecuteTask(Task{Type: Template, Src: src, Dest: dest}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if _, err := os.Stat(dest); err != nil { + t.Errorf("expected dest file to exist: %v", err) + } +} + +func TestExecuteTask_template_unsetVarExpandsToEmpty(t *testing.T) { + os.Unsetenv("TMPL_UNSET_VAR") + + dir := t.TempDir() + src := filepath.Join(dir, "tmpl.txt") + dest := filepath.Join(dir, "out.txt") + os.WriteFile(src, []byte("value=$TMPL_UNSET_VAR"), 0644) + + if err := ExecuteTask(Task{Type: Template, Src: src, Dest: dest}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + got, _ := os.ReadFile(dest) + if string(got) != "value=" { + t.Errorf("rendered content = %q, want %q", got, "value=") + } +} + // --- helpers --- func writeTempScript(t *testing.T, content string) string { From 509dfe89bea10b7d38251b32291aef596415b875 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 12 Mar 2026 21:05:38 -0700 Subject: [PATCH 09/42] add condition, group, and git task types --- schemas/raid-defs.schema.json | 144 ++++++++-------- schemas/raid-profile.schema.json | 7 + src/internal/lib/profile.go | 11 +- src/internal/lib/task.go | 29 +++- src/internal/lib/task_runner.go | 104 +++++++++++ src/internal/lib/task_runner_test.go | 249 +++++++++++++++++++++++++++ 6 files changed, 467 insertions(+), 77 deletions(-) diff --git a/schemas/raid-defs.schema.json b/schemas/raid-defs.schema.json index 44a7de4..e755c34 100644 --- a/schemas/raid-defs.schema.json +++ b/schemas/raid-defs.schema.json @@ -16,25 +16,18 @@ { "type": "object", "properties": { - "type": { - "type": "string", - "const": "Shell" - }, - "concurrent": { - "ref": "#/concurrent" - }, - "cmd": { - "type": "string", - "description": "Command to execute" - }, + "type": { "type": "string", "const": "Shell" }, + "concurrent": { "$ref": "#/$defs/concurrent" }, + "condition": { "$ref": "#/$defs/condition" }, + "cmd": { "type": "string", "description": "Command to execute" }, "shell": { "type": "string", "enum": ["bash", "sh", "zsh", "powershell", "cmd"], - "description": "Optional shell to execute the command (e.g., bash, powershell)" + "description": "Shell to use (default: bash)" }, "literal": { "type": "boolean", - "description": "Whether to execute the command as a literal string (without shell interpretation)", + "description": "Pass the command to the shell without prior env var expansion", "default": false } }, @@ -44,21 +37,14 @@ { "type": "object", "properties": { - "type": { - "type": "string", - "const": "Script" - }, - "concurrent": { - "ref": "#/concurrent" - }, - "path": { - "type": "string", - "description": "Absolute path to the script file" - }, + "type": { "type": "string", "const": "Script" }, + "concurrent": { "$ref": "#/$defs/concurrent" }, + "condition": { "$ref": "#/$defs/condition" }, + "path": { "type": "string", "description": "Path to the script file" }, "runner": { "type": "string", "enum": ["bash", "sh", "zsh", "python", "python2", "python3", "node", "powershell"], - "description": "Optional runner to execute the script (e.g., bash, python)" + "description": "Interpreter to use (optional)" } }, "required": ["type", "path"], @@ -67,21 +53,11 @@ { "type": "object", "properties": { - "type": { - "type": "string", - "const": "HTTP" - }, - "concurrent": { - "ref": "#/concurrent" - }, - "url": { - "type": "string", - "description": "URL to download from" - }, - "dest": { - "type": "string", - "description": "Local path to write the downloaded file to" - } + "type": { "type": "string", "const": "HTTP" }, + "concurrent": { "$ref": "#/$defs/concurrent" }, + "condition": { "$ref": "#/$defs/condition" }, + "url": { "type": "string", "description": "URL to download from" }, + "dest": { "type": "string", "description": "Local path to write the file to" } }, "required": ["type", "url", "dest"], "additionalProperties": false @@ -89,21 +65,11 @@ { "type": "object", "properties": { - "type": { - "type": "string", - "const": "Wait" - }, - "concurrent": { - "ref": "#/concurrent" - }, - "url": { - "type": "string", - "description": "HTTP(S) URL or TCP host:port to poll until ready" - }, - "timeout": { - "type": "string", - "description": "How long to wait before failing (e.g. 30s, 1m). Defaults to 30s." - } + "type": { "type": "string", "const": "Wait" }, + "concurrent": { "$ref": "#/$defs/concurrent" }, + "condition": { "$ref": "#/$defs/condition" }, + "url": { "type": "string", "description": "HTTP(S) URL or TCP host:port to poll" }, + "timeout": { "type": "string", "description": "Max wait duration (e.g. 30s, 1m). Defaults to 30s." } }, "required": ["type", "url"], "additionalProperties": false @@ -111,23 +77,41 @@ { "type": "object", "properties": { - "type": { - "type": "string", - "const": "Template" - }, - "concurrent": { - "ref": "#/concurrent" - }, - "src": { + "type": { "type": "string", "const": "Template" }, + "concurrent": { "$ref": "#/$defs/concurrent" }, + "condition": { "$ref": "#/$defs/condition" }, + "src": { "type": "string", "description": "Path to the template file. Supports $VAR and ${VAR} substitution." }, + "dest": { "type": "string", "description": "Path to write the rendered file to" } + }, + "required": ["type", "src", "dest"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { "type": "string", "const": "Group" }, + "concurrent": { "$ref": "#/$defs/concurrent" }, + "condition": { "$ref": "#/$defs/condition" }, + "ref": { "type": "string", "description": "Name of the group to execute, as defined in the profile's top-level groups map" } + }, + "required": ["type", "ref"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { "type": "string", "const": "Git" }, + "concurrent": { "$ref": "#/$defs/concurrent" }, + "condition": { "$ref": "#/$defs/condition" }, + "op": { "type": "string", - "description": "Path to the template file. Supports $VAR and ${VAR} substitution from the current environment." + "enum": ["pull", "checkout", "fetch", "reset"], + "description": "Git operation to perform" }, - "dest": { - "type": "string", - "description": "Path to write the rendered output file to" - } + "branch": { "type": "string", "description": "Target branch (required for checkout, optional for others)" }, + "dir": { "type": "string", "description": "Path to the git repository. Defaults to the current working directory." } }, - "required": ["type", "src", "dest"], + "required": ["type", "op"], "additionalProperties": false } ] @@ -188,6 +172,26 @@ "concurrent": { "type": "boolean", "description": "Whether to execute the task concurrently with other tasks" + }, + "condition": { + "type": "object", + "description": "All specified fields must be satisfied for the task to run", + "properties": { + "platform": { + "type": "string", + "enum": ["darwin", "linux", "windows"], + "description": "Only run on this platform" + }, + "exists": { + "type": "string", + "description": "Only run if this file or directory exists" + }, + "cmd": { + "type": "string", + "description": "Only run if this command exits with code 0" + } + }, + "additionalProperties": false } } -} \ No newline at end of file +} diff --git a/schemas/raid-profile.schema.json b/schemas/raid-profile.schema.json index 73a9f2a..3b5b69c 100644 --- a/schemas/raid-profile.schema.json +++ b/schemas/raid-profile.schema.json @@ -38,6 +38,13 @@ }, "install": { "$ref": "raid-defs.schema.json#/properties/install" + }, + "groups": { + "type": "object", + "description": "Named reusable task sequences. Reference them in any task list with type: Group and ref: .", + "additionalProperties": { + "$ref": "raid-defs.schema.json#/properties/tasks" + } } }, "required": ["name"] diff --git a/src/internal/lib/profile.go b/src/internal/lib/profile.go index 3147f10..f2e80ab 100644 --- a/src/internal/lib/profile.go +++ b/src/internal/lib/profile.go @@ -19,11 +19,12 @@ const ( ) type Profile struct { - Name string `json:"name"` - Path string `json:"path"` - Repositories []Repo `json:"repositories"` - Environments []Env `json:"environments"` - Install OnInstall `json:"install"` + Name string `json:"name"` + Path string `json:"path"` + Repositories []Repo `json:"repositories"` + Environments []Env `json:"environments"` + Install OnInstall `json:"install"` + Groups map[string][]Task `json:"groups"` } func (p Profile) IsZero() bool { diff --git a/src/internal/lib/task.go b/src/internal/lib/task.go index 561e49a..0dc160a 100644 --- a/src/internal/lib/task.go +++ b/src/internal/lib/task.go @@ -6,10 +6,22 @@ import ( "github.com/8bitalex/raid/src/internal/sys" ) +// Condition guards a task — all specified fields must be satisfied for the task to run. +type Condition struct { + Platform string `json:"platform,omitempty"` + Exists string `json:"exists,omitempty"` + Cmd string `json:"cmd,omitempty"` +} + +func (c Condition) IsZero() bool { + return c.Platform == "" && c.Exists == "" && c.Cmd == "" +} + // There has to be a better way to do this... todo type Task struct { - Type TaskType `json:"type"` - Concurrent bool `json:"concurrent,omitempty"` + Type TaskType `json:"type"` + Concurrent bool `json:"concurrent,omitempty"` + Condition *Condition `json:"condition,omitempty"` // Shell Cmd string `json:"cmd,omitempty"` Literal bool `json:"literal,omitempty"` @@ -24,6 +36,12 @@ type Task struct { Timeout string `json:"timeout,omitempty"` // Template Src string `json:"src,omitempty"` + // Group + Ref string `json:"ref,omitempty"` + // Git + Op string `json:"op,omitempty"` + Branch string `json:"branch,omitempty"` + Dir string `json:"dir,omitempty"` } func (t Task) IsZero() bool { @@ -34,6 +52,7 @@ func (t Task) Expand() Task { return Task{ Type: t.Type, Concurrent: t.Concurrent, + Condition: t.Condition, Cmd: sys.Expand(t.Cmd), Literal: t.Literal, Shell: t.Shell, @@ -43,6 +62,10 @@ func (t Task) Expand() Task { Dest: sys.Expand(t.Dest), Timeout: t.Timeout, Src: sys.Expand(t.Src), + Ref: t.Ref, + Op: t.Op, + Branch: sys.Expand(t.Branch), + Dir: sys.Expand(t.Dir), } } @@ -54,6 +77,8 @@ const ( HTTP TaskType = "http" Wait TaskType = "wait" Template TaskType = "template" + Group TaskType = "group" + Git TaskType = "git" ) func (t TaskType) ToLower() TaskType { diff --git a/src/internal/lib/task_runner.go b/src/internal/lib/task_runner.go index 299d8ed..eb0ce58 100644 --- a/src/internal/lib/task_runner.go +++ b/src/internal/lib/task_runner.go @@ -15,6 +15,27 @@ import ( "github.com/8bitalex/raid/src/internal/sys" ) +func evaluateCondition(c *Condition) bool { + if c.Platform != "" { + if string(sys.GetPlatform()) != strings.ToLower(c.Platform) { + return false + } + } + if c.Exists != "" { + if !sys.FileExists(sys.ExpandPath(c.Exists)) { + return false + } + } + if c.Cmd != "" { + shell := getShell("") + cmd := exec.Command(shell[0], append(shell[1:], c.Cmd)...) + if err := cmd.Run(); err != nil { + return false + } + } + return true +} + func ExecuteTasks(tasks []Task) error { var wg sync.WaitGroup errorChan := make(chan error, len(tasks)) @@ -62,6 +83,10 @@ func ExecuteTask(task Task) error { return nil } + if task.Condition != nil && !evaluateCondition(task.Condition) { + return nil + } + switch task.Type.ToLower() { case Shell: return execShell(task) @@ -73,6 +98,10 @@ func ExecuteTask(task Task) error { return execWait(task) case Template: return execTemplate(task) + case Group: + return execGroup(task) + case Git: + return execGit(task) default: return fmt.Errorf("invalid task type: %s", task.Type) } @@ -275,3 +304,78 @@ func execTemplate(task Task) error { return nil } + +func execGroup(task Task) error { + if task.Ref == "" { + return fmt.Errorf("ref is required for Group task") + } + if context == nil || context.Profile.Groups == nil { + return fmt.Errorf("no groups defined in the active profile") + } + + tasks, ok := context.Profile.Groups[task.Ref] + if !ok { + return fmt.Errorf("group '%s' not found in profile", task.Ref) + } + + return ExecuteTasks(tasks) +} + +func execGit(task Task) error { + task = task.Expand() + + if task.Op == "" { + return fmt.Errorf("op is required for Git task") + } + + dir := task.Dir + if dir == "" { + var err error + dir, err = os.Getwd() + if err != nil { + return fmt.Errorf("failed to get working directory: %w", err) + } + } + + if !sys.FileExists(dir) { + return fmt.Errorf("directory does not exist: %s", dir) + } + + var args []string + switch strings.ToLower(task.Op) { + case "pull": + args = []string{"pull"} + if task.Branch != "" { + args = append(args, "origin", task.Branch) + } + case "checkout": + if task.Branch == "" { + return fmt.Errorf("branch is required for git checkout") + } + args = []string{"checkout", task.Branch} + case "fetch": + args = []string{"fetch"} + if task.Branch != "" { + args = append(args, "origin", task.Branch) + } + case "reset": + args = []string{"reset", "--hard"} + if task.Branch != "" { + args = append(args, task.Branch) + } + default: + return fmt.Errorf("invalid git operation '%s' (supported: pull, checkout, fetch, reset)", task.Op) + } + + cmd := exec.Command("git", args...) + cmd.Dir = dir + if !task.Concurrent { + setCmdOutput(cmd) + } + + if err := cmd.Run(); err != nil { + return fmt.Errorf("git %s failed in '%s': %w", task.Op, dir, err) + } + + return nil +} diff --git a/src/internal/lib/task_runner_test.go b/src/internal/lib/task_runner_test.go index 601899c..727f901 100644 --- a/src/internal/lib/task_runner_test.go +++ b/src/internal/lib/task_runner_test.go @@ -5,6 +5,7 @@ import ( "net/http" "net/http/httptest" "os" + "os/exec" "path/filepath" "strings" "testing" @@ -636,6 +637,254 @@ func TestExecuteTask_template_unsetVarExpandsToEmpty(t *testing.T) { } } +// --- Condition --- + +func TestExecuteTask_condition_platform(t *testing.T) { + // Task with a condition that will never match (impossible platform). + task := Task{ + Type: Shell, + Cmd: "exit 0", + Condition: &Condition{Platform: "nonexistent-platform"}, + } + if err := ExecuteTask(task); err != nil { + t.Errorf("task with unmet condition should be skipped, got error: %v", err) + } +} + +func TestExecuteTask_condition_exists_filePresent(t *testing.T) { + f, err := os.CreateTemp(t.TempDir(), "exists-*") + if err != nil { + t.Fatal(err) + } + f.Close() + + // Condition is met — task runs. + task := Task{ + Type: Shell, + Cmd: "exit 0", + Condition: &Condition{Exists: f.Name()}, + } + if err := ExecuteTask(task); err != nil { + t.Errorf("unexpected error with met exists condition: %v", err) + } +} + +func TestExecuteTask_condition_exists_fileMissing(t *testing.T) { + // Condition is NOT met — task is skipped (no error). + task := Task{ + Type: Shell, + Cmd: "exit 1", + Condition: &Condition{Exists: "/nonexistent/file/path"}, + } + if err := ExecuteTask(task); err != nil { + t.Errorf("task with unmet exists condition should be skipped, got error: %v", err) + } +} + +func TestExecuteTask_condition_cmd_passes(t *testing.T) { + // Condition command exits 0 — task runs. + task := Task{ + Type: Shell, + Cmd: "exit 0", + Condition: &Condition{Cmd: "true"}, + } + if err := ExecuteTask(task); err != nil { + t.Errorf("unexpected error when condition cmd passes: %v", err) + } +} + +func TestExecuteTask_condition_cmd_fails(t *testing.T) { + // Condition command exits non-0 — task is skipped (no error). + marker := filepath.Join(t.TempDir(), "should-not-exist") + task := Task{ + Type: Shell, + Cmd: "touch " + marker, + Condition: &Condition{Cmd: "false"}, + } + if err := ExecuteTask(task); err != nil { + t.Errorf("task with failing condition cmd should be skipped, got error: %v", err) + } + if _, err := os.Stat(marker); err == nil { + t.Error("task body ran despite failing condition cmd") + } +} + +// --- Group tasks --- + +func TestExecuteTask_group_noContext(t *testing.T) { + context = nil + task := Task{Type: Group, Ref: "mygroup"} + err := ExecuteTask(task) + if err == nil { + t.Fatal("expected error when context is nil, got nil") + } +} + +func TestExecuteTask_group_noGroups(t *testing.T) { + context = &Context{Profile: Profile{}} + defer func() { context = nil }() + + task := Task{Type: Group, Ref: "mygroup"} + err := ExecuteTask(task) + if err == nil { + t.Fatal("expected error when profile has no groups, got nil") + } +} + +func TestExecuteTask_group_missingRef(t *testing.T) { + context = &Context{ + Profile: Profile{ + Groups: map[string][]Task{ + "other": {{Type: Shell, Cmd: "exit 0"}}, + }, + }, + } + defer func() { context = nil }() + + task := Task{Type: Group, Ref: "missing"} + err := ExecuteTask(task) + if err == nil { + t.Fatal("expected error for missing group ref, got nil") + } + if !strings.Contains(err.Error(), "missing") { + t.Errorf("error %q should mention the group name", err.Error()) + } +} + +func TestExecuteTask_group_success(t *testing.T) { + marker := filepath.Join(t.TempDir(), "group-ran") + context = &Context{ + Profile: Profile{ + Groups: map[string][]Task{ + "mygroup": { + {Type: Shell, Cmd: "touch " + marker}, + }, + }, + }, + } + defer func() { context = nil }() + + task := Task{Type: Group, Ref: "mygroup"} + if err := ExecuteTask(task); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if _, err := os.Stat(marker); err != nil { + t.Errorf("group tasks did not run: marker file missing") + } +} + +func TestExecuteTask_group_propagatesFailure(t *testing.T) { + context = &Context{ + Profile: Profile{ + Groups: map[string][]Task{ + "failgroup": { + {Type: Shell, Cmd: "exit 1"}, + }, + }, + }, + } + defer func() { context = nil }() + + task := Task{Type: Group, Ref: "failgroup"} + if err := ExecuteTask(task); err == nil { + t.Fatal("expected error from failing group task, got nil") + } +} + +func TestExecuteTask_group_emptyRefError(t *testing.T) { + task := Task{Type: Group, Ref: ""} + if err := ExecuteTask(task); err == nil { + t.Fatal("expected error for empty ref, got nil") + } +} + +// --- Git tasks --- + +func initTempGitRepo(t *testing.T) string { + t.Helper() + dir := t.TempDir() + run(t, dir, "git", "init") + run(t, dir, "git", "config", "user.email", "test@example.com") + run(t, dir, "git", "config", "user.name", "Test") + // Create an initial commit so HEAD exists. + run(t, dir, "git", "commit", "--allow-empty", "-m", "init") + return dir +} + +func run(t *testing.T, dir string, name string, args ...string) { + t.Helper() + cmd := exec.Command(name, args...) + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("command %q failed: %v\n%s", name+" "+strings.Join(args, " "), err, out) + } +} + +func TestExecuteTask_git(t *testing.T) { + dir := initTempGitRepo(t) + + tests := []struct { + name string + task Task + wantErr bool + }{ + { + name: "missing op", + task: Task{Type: Git, Dir: dir}, + wantErr: true, + }, + { + name: "invalid op", + task: Task{Type: Git, Op: "push", Dir: dir}, + wantErr: true, + }, + { + name: "dir does not exist", + task: Task{Type: Git, Op: "pull", Dir: "/nonexistent/path"}, + wantErr: true, + }, + { + name: "fetch with no remote succeeds", + task: Task{Type: Git, Op: "fetch", Dir: dir}, + wantErr: false, + }, + { + name: "checkout nonexistent branch fails", + task: Task{Type: Git, Op: "checkout", Branch: "nonexistent-branch-xyz", Dir: dir}, + wantErr: true, + }, + { + name: "reset hard HEAD succeeds", + task: Task{Type: Git, Op: "reset", Branch: "HEAD", Dir: dir}, + wantErr: false, + }, + { + name: "type is case-insensitive", + task: Task{Type: "GIT", Op: "fetch", Dir: dir}, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ExecuteTask(tt.task) + if (err != nil) != tt.wantErr { + t.Errorf("ExecuteTask() error = %v, wantErr = %v", err, tt.wantErr) + } + }) + } +} + +func TestExecuteTask_git_defaultsToWorkingDir(t *testing.T) { + // When Dir is empty, git runs in the current working directory. + // We just verify it doesn't crash — the actual git op (fetch) exits 0 with no remote. + task := Task{Type: Git, Op: "fetch"} + // The test runner's working dir is the package directory (a valid git repo). + if err := ExecuteTask(task); err != nil { + t.Logf("note: git fetch returned error (possibly no remote): %v", err) + } +} + // --- helpers --- func writeTempScript(t *testing.T, content string) string { From a26330a7852a43067ebce8d3d13a4adbb92bddf3 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 12 Mar 2026 21:58:16 -0700 Subject: [PATCH 10/42] add command based tasks --- README.md | 174 ++++++++++++++-- schemas/raid-defs.schema.json | 73 +++++++ src/internal/lib/task.go | 23 ++- src/internal/lib/task_runner.go | 149 ++++++++++++++ src/internal/lib/task_runner_test.go | 295 +++++++++++++++++++++++++++ 5 files changed, 693 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 83ed152..a5d4e2c 100644 --- a/README.md +++ b/README.md @@ -17,8 +17,9 @@ Tribal knowledge codified into the repo itself — onboarding becomes a single c - **Portable YAML Configurations** — define environments, tasks, and dependencies in version-controlled YAML files that live alongside your code. - **Multiple Profiles** — switch between project setups or team configurations with isolated profiles. -- **Automated Task Execution** — orchestrate shell commands and scripts across multiple repositories with a single command. +- **Rich Task Runner** — 12 built-in task types covering shell commands, scripts, HTTP downloads, service health checks, git operations, template rendering, user prompts, and more. - **Environment Management** — define and apply consistent development environments for all contributors. +- **Custom Commands** — codify repeated operational tasks (patch, proxy, verify, deploy) as first-class raid commands runnable from anywhere. ## Development Status @@ -56,7 +57,7 @@ Manage profiles. A profile is a named collection of repositories and environment - `raid profile add ` — register profiles from a YAML or JSON file; the first added profile is set as active automatically - `raid profile list` — list all registered profiles -- `raid profile ` — pass a profile name as an argument to switch the active profile +- `raid profile ` — switch the active profile - `raid profile remove ` — remove a profile ### `raid install` @@ -73,11 +74,11 @@ Clone all repositories in the active profile and run any configured install task ## Configuration -Tasks can be defined under `install` or within any environment, in both profile and repo configs. +Tasks can be defined under `install`, within any environment, or in named `groups` — in both profile and repo configs. ### Profile (`*.raid.yaml`) -A profile defines the repositories and environments for a project. The `$schema` annotation enables autocomplete and validation in editors like VS Code. +A profile defines the repositories, environments, and reusable task groups for a project. The `$schema` annotation enables autocomplete and validation in editors like VS Code. ```yaml # yaml-language-server: $schema=schemas/raid-profile.schema.json @@ -100,16 +101,28 @@ environments: - name: DATABASE_URL value: postgresql://localhost:5432/myproject tasks: + - type: Print + message: "Applying dev environment..." + color: green - type: Shell - cmd: echo "dev environment ready" - - type: Script - path: ./scripts/setup-dev.sh - runner: bash + cmd: docker compose up -d + - type: Wait + url: localhost:5432 + timeout: 30s install: tasks: - type: Shell - cmd: echo "installing..." + cmd: brew install node + +groups: + verify-services: + - type: Wait + url: http://localhost:3000 + timeout: 10s + - type: Wait + url: localhost:5432 + timeout: 10s ``` Multiple profiles can be defined in a single file using YAML document separators (`---`) or a JSON array. @@ -130,28 +143,143 @@ environments: - type: Shell cmd: npm install - type: Shell - cmd: npm test + cmd: npm run build ``` -### Tasks +--- + +## Tasks + +All task types support two optional modifiers: + +```yaml +concurrent: true # run in parallel with other concurrent tasks +condition: # skip this task unless all conditions are met + platform: darwin # only on this OS (darwin, linux, windows) + exists: ~/.config/myapp # only if this path exists + cmd: which docker # only if this command exits 0 +``` -Two task types are supported: +### Shell + +Run a command string in a configurable shell. -**Shell** — run a command string in a configurable shell: ```yaml - type: Shell - cmd: echo "hello" - shell: bash # optional: bash (default), sh, zsh, powershell - literal: false # optional: skip env var expansion before passing to shell - concurrent: true # optional: run concurrently with other tasks + cmd: echo "hello $USER" + shell: bash # optional: bash (default), sh, zsh, powershell, cmd + literal: false # optional: skip env var expansion before passing to shell ``` -**Script** — execute a script file: +### Script + +Execute a script file directly. + ```yaml - type: Script path: ./scripts/setup.sh - runner: bash # optional: interpreter to use - concurrent: false + runner: bash # optional: bash, sh, zsh, python, python3, node, powershell +``` + +### HTTP + +Download a file from a URL. + +```yaml +- type: HTTP + url: https://example.com/config.json + dest: ~/.config/myapp/config.json +``` + +### Wait + +Poll an HTTP(S) URL or TCP address until it responds, then continue. + +```yaml +- type: Wait + url: http://localhost:8080/health # or TCP: localhost:5432 + timeout: 60s # optional, default: 30s +``` + +### Template + +Render a file by substituting `$VAR` and `${VAR}` references with environment variable values. + +```yaml +- type: Template + src: ./config/app.env.template + dest: ~/.config/myapp/app.env +``` + +### Group + +Execute a named group of tasks defined in the profile's top-level `groups` map. + +```yaml +- type: Group + ref: verify-services +``` + +### Parallel + +Like `Group`, but forces all tasks in the group to run concurrently, then waits for all to finish before continuing. + +```yaml +- type: Parallel + ref: start-services +``` + +### Git + +Perform a git operation in a repository directory. + +```yaml +- type: Git + op: pull # pull, checkout, fetch, reset + branch: main # required for checkout; optional for pull, fetch, reset + dir: ~/Developer/myrepo # optional, defaults to current directory +``` + +### Print + +Print a formatted message to stdout. Useful for labelling steps in long task sequences. + +```yaml +- type: Print + message: "Deploying $APP_VERSION to production..." + color: yellow # optional: red, green, yellow, blue, cyan, white + literal: false # optional: skip env var expansion +``` + +### Prompt + +Ask the user for input and store the result in an environment variable for use by downstream tasks. + +```yaml +- type: Prompt + var: TARGET_ENV + message: "Which environment? (dev/staging/prod)" + default: dev # optional: used when user presses enter with no input +``` + +### Confirm + +Pause and require explicit confirmation (`y` or `yes`) before continuing. Useful before destructive operations. + +```yaml +- type: Confirm + message: "This will reset the production database. Continue?" +``` + +### Retry + +Re-run a group of tasks on failure, up to a configurable number of attempts. + +```yaml +- type: Retry + ref: run-migrations + attempts: 3 # optional, default: 3 + delay: 5s # optional, default: 1s ``` --- @@ -160,6 +288,12 @@ Two task types are supported: **Commit `raid.yaml` to each repo.** This is how setup knowledge gets shared — anyone with raid can run `raid install` and get a working environment without reading a wiki. +**Use `groups` to build custom commands.** Define reusable task sequences under `groups` and reference them with `Group`, `Parallel`, or `Retry`. This lets teams codify repeated workflows (patch, proxy, verify, deploy) that anyone can run with a single command. + +**Gate destructive steps with `Confirm`.** Any task sequence that resets data, force-pushes, or modifies production should begin with a `Confirm` task to prevent accidental runs. + +**Use `Print` to structure long sequences.** Clear section headers make install and deploy output readable at a glance, especially for new team members. + **Keep profiles in a dotfiles repo.** Profile files reference your repos and environments. Storing them in a private dotfiles repo keeps them version-controlled and accessible across machines. **Never commit secrets.** Use environment variable references or keep sensitive values in private profiles — never hardcode credentials in a committed raid file. diff --git a/schemas/raid-defs.schema.json b/schemas/raid-defs.schema.json index e755c34..aee329e 100644 --- a/schemas/raid-defs.schema.json +++ b/schemas/raid-defs.schema.json @@ -113,6 +113,79 @@ }, "required": ["type", "op"], "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { "type": "string", "const": "Prompt" }, + "concurrent": { "$ref": "#/$defs/concurrent" }, + "condition": { "$ref": "#/$defs/condition" }, + "var": { "type": "string", "description": "Environment variable name to set with the user's input" }, + "message": { "type": "string", "description": "Message to display to the user" }, + "default": { "type": "string", "description": "Default value if the user provides no input" } + }, + "required": ["type", "var"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { "type": "string", "const": "Confirm" }, + "concurrent": { "$ref": "#/$defs/concurrent" }, + "condition": { "$ref": "#/$defs/condition" }, + "message": { "type": "string", "description": "Confirmation prompt to display to the user" } + }, + "required": ["type"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { "type": "string", "const": "Parallel" }, + "concurrent": { "$ref": "#/$defs/concurrent" }, + "condition": { "$ref": "#/$defs/condition" }, + "ref": { "type": "string", "description": "Name of the group whose tasks will all be executed concurrently" } + }, + "required": ["type", "ref"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { "type": "string", "const": "Print" }, + "concurrent": { "$ref": "#/$defs/concurrent" }, + "condition": { "$ref": "#/$defs/condition" }, + "message": { "type": "string", "description": "Message to print. Supports $VAR substitution unless literal is true." }, + "color": { + "type": "string", + "enum": ["red", "green", "yellow", "blue", "cyan", "white"], + "description": "Optional terminal color for the output" + }, + "literal": { + "type": "boolean", + "description": "Skip environment variable expansion in the message", + "default": false + } + }, + "required": ["type", "message"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { "type": "string", "const": "Retry" }, + "concurrent": { "$ref": "#/$defs/concurrent" }, + "condition": { "$ref": "#/$defs/condition" }, + "ref": { "type": "string", "description": "Name of the group to retry on failure" }, + "attempts": { + "type": "integer", + "minimum": 1, + "description": "Maximum number of attempts (default: 3)" + }, + "delay": { "type": "string", "description": "Duration to wait between attempts (e.g. 1s, 500ms). Default: 1s." } + }, + "required": ["type", "ref"], + "additionalProperties": false } ] } diff --git a/src/internal/lib/task.go b/src/internal/lib/task.go index 0dc160a..1d4a567 100644 --- a/src/internal/lib/task.go +++ b/src/internal/lib/task.go @@ -36,12 +36,22 @@ type Task struct { Timeout string `json:"timeout,omitempty"` // Template Src string `json:"src,omitempty"` - // Group + // Group / Parallel / Retry Ref string `json:"ref,omitempty"` // Git Op string `json:"op,omitempty"` Branch string `json:"branch,omitempty"` Dir string `json:"dir,omitempty"` + // Prompt / Confirm / Print + Message string `json:"message,omitempty"` + // Prompt + Var string `json:"var,omitempty"` + Default string `json:"default,omitempty"` + // Print + Color string `json:"color,omitempty"` + // Retry + Attempts int `json:"attempts,omitempty"` + Delay string `json:"delay,omitempty"` } func (t Task) IsZero() bool { @@ -66,6 +76,12 @@ func (t Task) Expand() Task { Op: t.Op, Branch: sys.Expand(t.Branch), Dir: sys.Expand(t.Dir), + Message: sys.Expand(t.Message), + Var: t.Var, + Default: sys.Expand(t.Default), + Color: t.Color, + Attempts: t.Attempts, + Delay: t.Delay, } } @@ -79,6 +95,11 @@ const ( Template TaskType = "template" Group TaskType = "group" Git TaskType = "git" + Prompt TaskType = "prompt" + Confirm TaskType = "confirm" + Parallel TaskType = "parallel" + Print TaskType = "print" + Retry TaskType = "retry" ) func (t TaskType) ToLower() TaskType { diff --git a/src/internal/lib/task_runner.go b/src/internal/lib/task_runner.go index eb0ce58..d27b723 100644 --- a/src/internal/lib/task_runner.go +++ b/src/internal/lib/task_runner.go @@ -1,6 +1,7 @@ package lib import ( + "bufio" "fmt" "io" "net" @@ -15,6 +16,16 @@ import ( "github.com/8bitalex/raid/src/internal/sys" ) +var colorCodes = map[string]string{ + "red": "\033[31m", + "green": "\033[32m", + "yellow": "\033[33m", + "blue": "\033[34m", + "cyan": "\033[36m", + "white": "\033[37m", + "reset": "\033[0m", +} + func evaluateCondition(c *Condition) bool { if c.Platform != "" { if string(sys.GetPlatform()) != strings.ToLower(c.Platform) { @@ -102,6 +113,16 @@ func ExecuteTask(task Task) error { return execGroup(task) case Git: return execGit(task) + case Prompt: + return execPrompt(task) + case Confirm: + return execConfirm(task) + case Parallel: + return execParallel(task) + case Print: + return execPrint(task) + case Retry: + return execRetry(task) default: return fmt.Errorf("invalid task type: %s", task.Type) } @@ -379,3 +400,131 @@ func execGit(task Task) error { return nil } + +func execPrompt(task Task) error { + if task.Var == "" { + return fmt.Errorf("var is required for Prompt task") + } + + message := task.Message + if message == "" { + message = fmt.Sprintf("Enter value for %s:", task.Var) + } + fmt.Print(message + " ") + + reader := bufio.NewReader(os.Stdin) + value, err := reader.ReadString('\n') + if err != nil { + return fmt.Errorf("failed to read input: %w", err) + } + value = strings.TrimRight(value, "\r\n") + + if value == "" && task.Default != "" { + value = task.Default + } + + os.Setenv(task.Var, value) + return nil +} + +func execConfirm(task Task) error { + message := task.Message + if message == "" { + message = "Continue?" + } + fmt.Print(message + " [y/N] ") + + reader := bufio.NewReader(os.Stdin) + answer, err := reader.ReadString('\n') + if err != nil { + return fmt.Errorf("failed to read input: %w", err) + } + answer = strings.TrimSpace(strings.ToLower(answer)) + + if answer != "y" && answer != "yes" { + return fmt.Errorf("aborted by user") + } + return nil +} + +func execParallel(task Task) error { + if task.Ref == "" { + return fmt.Errorf("ref is required for Parallel task") + } + if context == nil || context.Profile.Groups == nil { + return fmt.Errorf("no groups defined in the active profile") + } + + tasks, ok := context.Profile.Groups[task.Ref] + if !ok { + return fmt.Errorf("group '%s' not found in profile", task.Ref) + } + + concurrent := make([]Task, len(tasks)) + for i, t := range tasks { + t.Concurrent = true + concurrent[i] = t + } + + return ExecuteTasks(concurrent) +} + +func execPrint(task Task) error { + msg := task.Message + if !task.Literal { + msg = os.ExpandEnv(msg) + } + + if task.Color != "" { + if code, ok := colorCodes[strings.ToLower(task.Color)]; ok { + fmt.Printf("%s%s%s\n", code, msg, colorCodes["reset"]) + return nil + } + } + + fmt.Println(msg) + return nil +} + +func execRetry(task Task) error { + if task.Ref == "" { + return fmt.Errorf("ref is required for Retry task") + } + if context == nil || context.Profile.Groups == nil { + return fmt.Errorf("no groups defined in the active profile") + } + + tasks, ok := context.Profile.Groups[task.Ref] + if !ok { + return fmt.Errorf("group '%s' not found in profile", task.Ref) + } + + attempts := task.Attempts + if attempts <= 0 { + attempts = 3 + } + + delay := time.Second + if task.Delay != "" { + d, err := time.ParseDuration(task.Delay) + if err != nil { + return fmt.Errorf("invalid delay '%s': %w", task.Delay, err) + } + delay = d + } + + var lastErr error + for i := 0; i < attempts; i++ { + if i > 0 { + fmt.Printf("Retrying... (attempt %d/%d)\n", i+1, attempts) + time.Sleep(delay) + } + if err := ExecuteTasks(tasks); err != nil { + lastErr = err + continue + } + return nil + } + + return fmt.Errorf("all %d attempts failed: %w", attempts, lastErr) +} diff --git a/src/internal/lib/task_runner_test.go b/src/internal/lib/task_runner_test.go index 727f901..aec1cf7 100644 --- a/src/internal/lib/task_runner_test.go +++ b/src/internal/lib/task_runner_test.go @@ -885,6 +885,301 @@ func TestExecuteTask_git_defaultsToWorkingDir(t *testing.T) { } } +// --- Print tasks --- + +func TestExecuteTask_print(t *testing.T) { + os.Setenv("PRINT_TEST_VAR", "world") + defer os.Unsetenv("PRINT_TEST_VAR") + + tests := []struct { + name string + task Task + wantErr bool + }{ + { + name: "basic message", + task: Task{Type: Print, Message: "hello"}, + wantErr: false, + }, + { + name: "expands env vars", + task: Task{Type: Print, Message: "hello $PRINT_TEST_VAR"}, + wantErr: false, + }, + { + name: "literal skips expansion", + task: Task{Type: Print, Message: "hello $PRINT_TEST_VAR", Literal: true}, + wantErr: false, + }, + { + name: "with valid color", + task: Task{Type: Print, Message: "colored", Color: "green"}, + wantErr: false, + }, + { + name: "with unknown color falls back gracefully", + task: Task{Type: Print, Message: "msg", Color: "magenta"}, + wantErr: false, + }, + { + name: "type is case-insensitive", + task: Task{Type: "PRINT", Message: "hello"}, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ExecuteTask(tt.task) + if (err != nil) != tt.wantErr { + t.Errorf("ExecuteTask() error = %v, wantErr = %v", err, tt.wantErr) + } + }) + } +} + +// --- Prompt tasks --- + +func TestExecuteTask_prompt_missingVar(t *testing.T) { + task := Task{Type: Prompt} + if err := ExecuteTask(task); err == nil { + t.Fatal("expected error for missing var, got nil") + } +} + +func TestExecuteTask_prompt_setsEnvVar(t *testing.T) { + os.Unsetenv("RAID_PROMPT_TEST") + + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + w.WriteString("myvalue\n") + w.Close() + + origStdin := os.Stdin + os.Stdin = r + defer func() { os.Stdin = origStdin }() + + task := Task{Type: Prompt, Var: "RAID_PROMPT_TEST", Message: "Enter:"} + if err := ExecuteTask(task); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if got := os.Getenv("RAID_PROMPT_TEST"); got != "myvalue" { + t.Errorf("env var RAID_PROMPT_TEST = %q, want %q", got, "myvalue") + } + os.Unsetenv("RAID_PROMPT_TEST") +} + +func TestExecuteTask_prompt_usesDefault(t *testing.T) { + os.Unsetenv("RAID_PROMPT_DEFAULT_TEST") + + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + w.WriteString("\n") // empty input + w.Close() + + origStdin := os.Stdin + os.Stdin = r + defer func() { os.Stdin = origStdin }() + + task := Task{Type: Prompt, Var: "RAID_PROMPT_DEFAULT_TEST", Default: "fallback"} + if err := ExecuteTask(task); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if got := os.Getenv("RAID_PROMPT_DEFAULT_TEST"); got != "fallback" { + t.Errorf("env var = %q, want %q", got, "fallback") + } + os.Unsetenv("RAID_PROMPT_DEFAULT_TEST") +} + +// --- Confirm tasks --- + +func TestExecuteTask_confirm_yes(t *testing.T) { + for _, answer := range []string{"y\n", "yes\n", "Y\n", "YES\n"} { + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + w.WriteString(answer) + w.Close() + + origStdin := os.Stdin + os.Stdin = r + + task := Task{Type: Confirm, Message: "Proceed?"} + err = ExecuteTask(task) + os.Stdin = origStdin + + if err != nil { + t.Errorf("answer %q: unexpected error: %v", answer, err) + } + } +} + +func TestExecuteTask_confirm_no(t *testing.T) { + for _, answer := range []string{"n\n", "no\n", "\n", "maybe\n"} { + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + w.WriteString(answer) + w.Close() + + origStdin := os.Stdin + os.Stdin = r + + task := Task{Type: Confirm, Message: "Proceed?"} + err = ExecuteTask(task) + os.Stdin = origStdin + + if err == nil { + t.Errorf("answer %q: expected error (aborted), got nil", answer) + } + if err != nil && !strings.Contains(err.Error(), "aborted") { + t.Errorf("answer %q: error %q should mention 'aborted'", answer, err.Error()) + } + } +} + +// --- Parallel tasks --- + +func TestExecuteTask_parallel_noContext(t *testing.T) { + context = nil + task := Task{Type: Parallel, Ref: "mygroup"} + if err := ExecuteTask(task); err == nil { + t.Fatal("expected error when context is nil, got nil") + } +} + +func TestExecuteTask_parallel_missingRef(t *testing.T) { + task := Task{Type: Parallel, Ref: ""} + if err := ExecuteTask(task); err == nil { + t.Fatal("expected error for empty ref, got nil") + } +} + +func TestExecuteTask_parallel_success(t *testing.T) { + markerA := filepath.Join(t.TempDir(), "a") + markerB := filepath.Join(t.TempDir(), "b") + context = &Context{ + Profile: Profile{ + Groups: map[string][]Task{ + "workers": { + {Type: Shell, Cmd: "touch " + markerA}, + {Type: Shell, Cmd: "touch " + markerB}, + }, + }, + }, + } + defer func() { context = nil }() + + task := Task{Type: Parallel, Ref: "workers"} + if err := ExecuteTask(task); err != nil { + t.Fatalf("unexpected error: %v", err) + } + for _, m := range []string{markerA, markerB} { + if _, err := os.Stat(m); err != nil { + t.Errorf("expected marker %s to exist", m) + } + } +} + +func TestExecuteTask_parallel_propagatesFailure(t *testing.T) { + context = &Context{ + Profile: Profile{ + Groups: map[string][]Task{ + "broken": { + {Type: Shell, Cmd: "exit 1"}, + }, + }, + }, + } + defer func() { context = nil }() + + task := Task{Type: Parallel, Ref: "broken"} + if err := ExecuteTask(task); err == nil { + t.Fatal("expected error from failing parallel task, got nil") + } +} + +// --- Retry tasks --- + +func TestExecuteTask_retry_missingRef(t *testing.T) { + task := Task{Type: Retry, Ref: ""} + if err := ExecuteTask(task); err == nil { + t.Fatal("expected error for empty ref, got nil") + } +} + +func TestExecuteTask_retry_noContext(t *testing.T) { + context = nil + task := Task{Type: Retry, Ref: "mygroup"} + if err := ExecuteTask(task); err == nil { + t.Fatal("expected error when context is nil, got nil") + } +} + +func TestExecuteTask_retry_succeedsOnFirstAttempt(t *testing.T) { + marker := filepath.Join(t.TempDir(), "ran") + context = &Context{ + Profile: Profile{ + Groups: map[string][]Task{ + "work": {{Type: Shell, Cmd: "touch " + marker}}, + }, + }, + } + defer func() { context = nil }() + + task := Task{Type: Retry, Ref: "work", Attempts: 3} + if err := ExecuteTask(task); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if _, err := os.Stat(marker); err != nil { + t.Error("expected marker to exist after successful retry task") + } +} + +func TestExecuteTask_retry_exhaustsAllAttempts(t *testing.T) { + context = &Context{ + Profile: Profile{ + Groups: map[string][]Task{ + "always-fail": {{Type: Shell, Cmd: "exit 1"}}, + }, + }, + } + defer func() { context = nil }() + + task := Task{Type: Retry, Ref: "always-fail", Attempts: 2, Delay: "1ms"} + err := ExecuteTask(task) + if err == nil { + t.Fatal("expected error after all retries exhausted, got nil") + } + if !strings.Contains(err.Error(), "2 attempts") { + t.Errorf("error %q should mention attempt count", err.Error()) + } +} + +func TestExecuteTask_retry_invalidDelay(t *testing.T) { + context = &Context{ + Profile: Profile{ + Groups: map[string][]Task{ + "work": {{Type: Shell, Cmd: "exit 0"}}, + }, + }, + } + defer func() { context = nil }() + + task := Task{Type: Retry, Ref: "work", Delay: "not-a-duration"} + if err := ExecuteTask(task); err == nil { + t.Fatal("expected error for invalid delay, got nil") + } +} + // --- helpers --- func writeTempScript(t *testing.T, content string) string { From 301e37f75bbecb7133d863277ac21712c6e3a0c1 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 13 Mar 2026 09:42:35 -0700 Subject: [PATCH 11/42] general cleanup --- go.mod | 1 - go.sum | 6 ---- src/cmd/env/env.go | 46 +++++++++++++----------- src/cmd/env/list.go | 1 - src/cmd/install/install.go | 4 +-- src/cmd/profile/add.go | 1 - src/cmd/profile/list.go | 1 - src/cmd/profile/remove.go | 2 -- src/internal/lib/env.go | 53 +++++++++++---------------- src/internal/lib/lib.go | 38 ++++++++++---------- src/internal/lib/profile.go | 41 +++++++++++++-------- src/internal/lib/repo.go | 10 +++--- src/internal/lib/task.go | 7 +++- src/internal/sys/system.go | 72 ++++++++++++++++++++----------------- 14 files changed, 143 insertions(+), 140 deletions(-) diff --git a/go.mod b/go.mod index e5c08bd..0a194e7 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,6 @@ require ( github.com/spf13/cobra v1.9.1 github.com/spf13/viper v1.20.1 gopkg.in/yaml.v3 v3.0.1 - github.com/thoas/go-funk v0.9.3 ) require ( diff --git a/go.sum b/go.sum index b2c0b8d..cfbd5a5 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,4 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= @@ -46,14 +45,10 @@ github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M= github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= -github.com/thoas/go-funk v0.9.3 h1:7+nAEx3kn5ZJcnDm2Bh23N2yOtweO14bi//dvRtgLpw= -github.com/thoas/go-funk v0.9.3/go.mod h1:+IWnUfUmFO1+WVYQWQtIJHeRRdaIyyYglZN7xzUPe4Q= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= @@ -61,6 +56,5 @@ golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/src/cmd/env/env.go b/src/cmd/env/env.go index 14b0e93..1c16de8 100644 --- a/src/cmd/env/env.go +++ b/src/cmd/env/env.go @@ -17,30 +17,34 @@ var Command = &cobra.Command{ Args: cobra.RangeArgs(0, 1), Run: func(cmd *cobra.Command, args []string) { if len(args) == 0 { - env := env.Get() - if env == "" { + active := env.Get() + if active == "" { cmd.PrintErrln("No active environment set.") } else { - cmd.Println("Active environment:", env) + cmd.Println("Active environment:", active) } - } else if len(args) == 1 { - name := args[0] - if !env.Contains(name) { - cmd.PrintErrln("Environment not found:", name) - } else { - cmd.Println("Setting up environment:", name) - if err := env.Set(name); err != nil { - cmd.PrintErrln("Failed to switch environment:", err) - } - raid.ForceLoad() - if err := env.Execute(env.Get()); err != nil { - cmd.PrintErrln("Failed to execute environment:", err) - } else { - cmd.Println("Environment executed successfully.") - } - } - } else { - cmd.PrintErrln("Invalid number of arguments.") + return + } + + name := args[0] + if !env.Contains(name) { + cmd.PrintErrln("Environment not found:", name) + return + } + + cmd.Println("Setting up environment:", name) + if err := env.Set(name); err != nil { + cmd.PrintErrln("Failed to switch environment:", err) + return + } + if err := raid.ForceLoad(); err != nil { + cmd.PrintErrln("Failed to reload profile:", err) + return + } + if err := env.Execute(env.Get()); err != nil { + cmd.PrintErrln("Failed to execute environment:", err) + return } + cmd.Println("Environment executed successfully.") }, } diff --git a/src/cmd/env/list.go b/src/cmd/env/list.go index 3d85b74..a37fdc8 100644 --- a/src/cmd/env/list.go +++ b/src/cmd/env/list.go @@ -20,6 +20,5 @@ var ListEnvCmd = &cobra.Command{ for _, env := range envs { fmt.Printf("\t%s\n", env) } - fmt.Print() }, } diff --git a/src/cmd/install/install.go b/src/cmd/install/install.go index cce625d..8208a3a 100644 --- a/src/cmd/install/install.go +++ b/src/cmd/install/install.go @@ -7,9 +7,7 @@ import ( "github.com/spf13/cobra" ) -var ( - maxThreads int = 0 -) +var maxThreads int func init() { Command.Flags().IntVarP(&maxThreads, "threads", "t", 0, "Maximum number of concurrent threads (0 = unlimited)") diff --git a/src/cmd/profile/add.go b/src/cmd/profile/add.go index e38c63b..4c71fbb 100644 --- a/src/cmd/profile/add.go +++ b/src/cmd/profile/add.go @@ -68,6 +68,5 @@ var AddProfileCmd = &cobra.Command{ } fmt.Printf("Profiles:\n\t%s\nhave been successfully added from %s\n", strings.Join(names, ",\n\t"), path) } - fmt.Print() }, } diff --git a/src/cmd/profile/list.go b/src/cmd/profile/list.go index fad7a1e..b13028b 100644 --- a/src/cmd/profile/list.go +++ b/src/cmd/profile/list.go @@ -27,6 +27,5 @@ var ListProfileCmd = &cobra.Command{ } fmt.Printf("\t%s%s\t%s\n", profile.Name, activeIndicator, profile.Path) } - fmt.Print() }, } diff --git a/src/cmd/profile/remove.go b/src/cmd/profile/remove.go index 61be14f..a594d24 100644 --- a/src/cmd/profile/remove.go +++ b/src/cmd/profile/remove.go @@ -21,7 +21,5 @@ var RemoveProfileCmd = &cobra.Command{ fmt.Printf("Profile '%s' has been removed.\n", name) } } - - fmt.Print() }, } diff --git a/src/internal/lib/env.go b/src/internal/lib/env.go index e3929e5..e75a6f2 100644 --- a/src/internal/lib/env.go +++ b/src/internal/lib/env.go @@ -8,38 +8,42 @@ import ( "github.com/spf13/viper" ) -const ( - ACTIVE_ENV_KEY = "env" -) +const activeEnvKey = "env" +// Env represents a named environment with variables and tasks. type Env struct { Name string `json:"name"` Variables []EnvVar `json:"variables"` Tasks []Task `json:"tasks"` } +// IsZero reports whether the environment is uninitialized. func (e Env) IsZero() bool { return e.Name == "" } +// EnvVar is a key/value pair written into a repository's .env file. type EnvVar struct { Name string `json:"name"` Value string `json:"value"` } +// SetEnv sets the named environment as the active environment. func SetEnv(name string) error { if name == "" || !ContainsEnv(name) { return fmt.Errorf("environment '%s' not found", name) } - Set(ACTIVE_ENV_KEY, name) + Set(activeEnvKey, name) return nil } +// GetEnv returns the name of the currently active environment. func GetEnv() string { - return viper.GetString(ACTIVE_ENV_KEY) + return viper.GetString(activeEnvKey) } +// ListEnvs returns the names of all environments in the active profile. func ListEnvs() []string { if context == nil || len(context.Profile.Environments) == 0 { return []string{} @@ -52,6 +56,7 @@ func ListEnvs() []string { return names } +// ContainsEnv reports whether an environment with the given name exists in the active profile. func ContainsEnv(name string) bool { for _, envName := range ListEnvs() { if envName == name { @@ -61,17 +66,14 @@ func ContainsEnv(name string) bool { return false } +// ExecuteEnv writes environment variables to each repo's .env file and runs the environment's tasks. func ExecuteEnv(name string) error { - err := setEnvVariablesForRepos(name) - if err != nil { + if err := setEnvVariablesForRepos(name); err != nil { return fmt.Errorf("failed to set env variables: %w", err) } - - err = runTasksForEnv(name) - if err != nil { + if err := runTasksForEnv(name); err != nil { return fmt.Errorf("failed to run env tasks: %w", err) } - return nil } @@ -84,11 +86,7 @@ func setEnvVariablesForRepos(name string) error { return fmt.Errorf("invalid path for repo '%s': %w", repo.Name, err) } - pEnv := context.Profile.getEnv(name) - rEnv := repo.getEnv(name) - - err = setEnvVariables(pEnv.Variables, rEnv.Variables, path) - if err != nil { + if err := setEnvVariables(context.Profile.getEnv(name).Variables, repo.getEnv(name).Variables, path); err != nil { return fmt.Errorf("failed to set env variables for repo '%s': %w", repo.Name, err) } } @@ -96,14 +94,13 @@ func setEnvVariablesForRepos(name string) error { } func buildEnvPath(path string) (string, error) { - filepath := sys.ExpandPath(path) + sys.Sep + ".env" - // create file if it does not exist - file, err := sys.CreateFile(filepath) + filePath := sys.ExpandPath(path) + sys.Sep + ".env" + file, err := sys.CreateFile(filePath) if err != nil { return "", err } file.Close() - return filepath, nil + return filePath, nil } func setEnvVariables(profVars []EnvVar, repoVars []EnvVar, path string) error { @@ -115,17 +112,11 @@ func setEnvVariables(profVars []EnvVar, repoVars []EnvVar, path string) error { for _, v := range profVars { envMap[v.Name] = v.Value } - for _, v := range repoVars { - fmt.Printf("Setting variable %s=%s\n", v.Name, v.Value) envMap[v.Name] = v.Value } - err = godotenv.Write(envMap, path) - if err != nil { - return err - } - return nil + return godotenv.Write(envMap, path) } func runTasksForEnv(name string) error { @@ -133,14 +124,10 @@ func runTasksForEnv(name string) error { if env.IsZero() || len(env.Tasks) == 0 { return nil } - - err := ExecuteTasks(env.Tasks) - if err != nil { - return err - } - return nil + return ExecuteTasks(env.Tasks) } +// LoadEnv loads .env files from all repositories in the active profile into the process environment. func LoadEnv() error { if context == nil { return fmt.Errorf("context not initialized") diff --git a/src/internal/lib/lib.go b/src/internal/lib/lib.go index 0b5bb43..67f53c8 100644 --- a/src/internal/lib/lib.go +++ b/src/internal/lib/lib.go @@ -13,26 +13,27 @@ import ( sys "github.com/8bitalex/raid/src/internal/sys" "github.com/8bitalex/raid/src/internal/utils" "github.com/santhosh-tekuri/jsonschema/v6" - "github.com/thoas/go-funk" ) const ( - YAML_SEP = "---" + yamlSep = "---" RaidConfigFileName = "raid.yaml" ) +// Context holds the active profile and environment for the current raid session. type Context struct { Profile Profile Env string - // Options Options } +// OnInstall holds the tasks to run during profile installation. type OnInstall struct { Tasks []Task `json:"tasks"` } var context *Context +// Load initializes the context from the active profile, using cached results if available. func Load() error { if context == nil { return ForceLoad() @@ -40,6 +41,7 @@ func Load() error { return nil } +// ForceLoad rebuilds the context from the active profile, ignoring any cached state. func ForceLoad() error { p := GetProfile() if p.IsZero() { @@ -65,9 +67,8 @@ func ForceLoad() error { return nil } +// Install clones all repositories in the active profile and runs install tasks. func Install(maxThreads int) error { - - // todo migrate to task runner profile := context.Profile if profile.IsZero() { return fmt.Errorf("profile not found") @@ -109,29 +110,30 @@ func Install(maxThreads int) error { return fmt.Errorf("some repositories failed to install: %v", errors) } - pts := profile.Install.Tasks - if err := ExecuteTasks(pts); err != nil { + if err := ExecuteTasks(profile.Install.Tasks); err != nil { return fmt.Errorf("failed to execute install tasks: %w", err) } - rts := funk.FlatMap(profile.Repositories, func(r Repo) []Task { - return r.Install.Tasks - }).([]Task) - if err := ExecuteTasks(rts); err != nil { + var repoTasks []Task + for _, r := range profile.Repositories { + repoTasks = append(repoTasks, r.Install.Tasks...) + } + if err := ExecuteTasks(repoTasks); err != nil { return fmt.Errorf("failed to execute repository install tasks: %w", err) } return nil } +// ValidateSchema validates the file at path against the JSON schema at schemaPath. func ValidateSchema(path string, schemaPath string) error { path = sys.ExpandPath(path) schemaPath = sys.ExpandPath(schemaPath) - if !sys.FileExists(path) || path == "" { + if path == "" || !sys.FileExists(path) { return fmt.Errorf("file not found at %s", path) } - if !sys.FileExists(schemaPath) || schemaPath == "" { + if schemaPath == "" || !sys.FileExists(schemaPath) { return fmt.Errorf("file not found at %s", schemaPath) } @@ -147,24 +149,22 @@ func ValidateSchema(path string, schemaPath string) error { } defer f.Close() - contents := io.Reader(f) - + var reader io.Reader = f ext := strings.ToLower(filepath.Ext(path)) if ext == ".yaml" || ext == ".yml" { data, err := utils.YAMLToJSON(f) if err != nil { return err } - contents = bytes.NewReader(data) + reader = bytes.NewReader(data) } - json, err := jsonschema.UnmarshalJSON(contents) + doc, err := jsonschema.UnmarshalJSON(reader) if err != nil { return err } - err = sch.Validate(json) - if err != nil { + if err := sch.Validate(doc); err != nil { return fmt.Errorf("invalid format: %w", err) } return nil diff --git a/src/internal/lib/profile.go b/src/internal/lib/profile.go index f2e80ab..ff67ab8 100644 --- a/src/internal/lib/profile.go +++ b/src/internal/lib/profile.go @@ -13,11 +13,12 @@ import ( ) const ( - ACTIVE_PROFILE_KEY = "profile" - ALL_PROFILES_KEY = "profiles" - PROFILE_SCHEMA_PATH = "schemas/raid-profile.schema.json" + activeProfileKey = "profile" + allProfilesKey = "profiles" + profileSchemaPath = "schemas/raid-profile.schema.json" ) +// Profile represents a named collection of repositories, environments, and task groups. type Profile struct { Name string `json:"name"` Path string `json:"path"` @@ -27,6 +28,7 @@ type Profile struct { Groups map[string][]Task `json:"groups"` } +// IsZero reports whether the profile is uninitialized. func (p Profile) IsZero() bool { return p.Name == "" || p.Path == "" } @@ -40,20 +42,22 @@ func (p Profile) getEnv(name string) Env { return Env{} } +// SetProfile sets the named profile as the active profile. func SetProfile(name string) error { if !ContainsProfile(name) { return fmt.Errorf("profile '%s' not found", name) } - Set(ACTIVE_PROFILE_KEY, name) + Set(activeProfileKey, name) return nil } +// GetProfile returns the currently active profile. func GetProfile() Profile { if context != nil && !context.Profile.IsZero() { return context.Profile } - name := viper.GetString(ACTIVE_PROFILE_KEY) + name := viper.GetString(activeProfileKey) paths := getProfilePaths() return Profile{ Name: name, @@ -61,22 +65,25 @@ func GetProfile() Profile { } } +// AddProfile registers a profile in the config store. func AddProfile(profile Profile) { - profiles := viper.GetStringMapString(ALL_PROFILES_KEY) + profiles := viper.GetStringMapString(allProfilesKey) if profiles == nil { profiles = make(map[string]string) } profiles[profile.Name] = profile.Path - Set(ALL_PROFILES_KEY, profiles) + Set(allProfilesKey, profiles) } +// AddProfiles registers multiple profiles in the config store. func AddProfiles(profiles []Profile) { for _, profile := range profiles { AddProfile(profile) } } +// ListProfiles returns all registered profiles. func ListProfiles() []Profile { profilesMap := getProfilePaths() results := make([]Profile, 0, len(profilesMap)) @@ -87,15 +94,16 @@ func ListProfiles() []Profile { } func getProfilePaths() map[string]string { - profiles := viper.GetStringMapString(ALL_PROFILES_KEY) + profiles := viper.GetStringMapString(allProfilesKey) if profiles == nil { return make(map[string]string) } return profiles } +// RemoveProfile removes a registered profile by name. func RemoveProfile(name string) error { - profiles := viper.GetStringMapString(ALL_PROFILES_KEY) + profiles := viper.GetStringMapString(allProfilesKey) if profiles == nil { return fmt.Errorf("no profiles found") } @@ -103,10 +111,11 @@ func RemoveProfile(name string) error { return fmt.Errorf("profile '%s' not found", name) } delete(profiles, name) - Set(ALL_PROFILES_KEY, profiles) + Set(allProfilesKey, profiles) return nil } +// ExtractProfile reads and returns a single named profile from the given file. func ExtractProfile(name, path string) (Profile, error) { profiles, err := ExtractProfiles(path) if err != nil { @@ -120,6 +129,7 @@ func ExtractProfile(name, path string) (Profile, error) { return Profile{}, fmt.Errorf("profile '%s' not found in %s", name, path) } +// ExtractProfiles reads all profiles from a YAML or JSON file. func ExtractProfiles(path string) ([]Profile, error) { profileData, err := os.ReadFile(path) if err != nil { @@ -152,7 +162,7 @@ func ExtractProfiles(path string) ([]Profile, error) { func extractProfilesFromYAML(data []byte, path string) ([]Profile, error) { var profiles []Profile - documents := strings.Split(string(data), YAML_SEP) + documents := strings.Split(string(data), yamlSep) for _, doc := range documents { doc = strings.TrimSpace(doc) @@ -173,14 +183,13 @@ func extractProfilesFromYAML(data []byte, path string) ([]Profile, error) { } func extractProfilesFromJSON(data []byte, path string) ([]Profile, error) { - var profiles []Profile - var profile Profile if err := json.Unmarshal(data, &profile); err == nil { profile.Path = path return []Profile{profile}, nil } + var profiles []Profile if err := json.Unmarshal(data, &profiles); err != nil { return nil, fmt.Errorf("invalid JSON format in %s: %w", path, err) } @@ -194,8 +203,9 @@ func extractProfilesFromJSON(data []byte, path string) ([]Profile, error) { return results, nil } +// ContainsProfile reports whether a profile with the given name is registered. func ContainsProfile(name string) bool { - profiles := viper.GetStringMapString(ALL_PROFILES_KEY) + profiles := viper.GetStringMapString(allProfilesKey) if profiles == nil { return false } @@ -204,8 +214,9 @@ func ContainsProfile(name string) bool { return exists } +// ValidateProfile validates the profile file at path against the profile JSON schema. func ValidateProfile(path string) error { - return ValidateSchema(path, PROFILE_SCHEMA_PATH) + return ValidateSchema(path, profileSchemaPath) } func buildProfile(profile Profile) (Profile, error) { diff --git a/src/internal/lib/repo.go b/src/internal/lib/repo.go index 99a96c9..405ad42 100644 --- a/src/internal/lib/repo.go +++ b/src/internal/lib/repo.go @@ -10,10 +10,9 @@ import ( "gopkg.in/yaml.v3" ) -const ( - REPO_SCHEMA_PATH = "schemas/raid-repo.schema.json" -) +const repoSchemaPath = "schemas/raid-repo.schema.json" +// Repo represents a single repository entry in a profile. type Repo struct { Name string `json:"name"` Path string `json:"path"` @@ -22,6 +21,7 @@ type Repo struct { Install OnInstall `json:"install"` } +// IsZero reports whether the repo is uninitialized. func (r Repo) IsZero() bool { return r.Name == "" || r.Path == "" || r.URL == "" } @@ -60,6 +60,7 @@ func buildRepo(repo *Repo) error { return nil } +// CloneRepository clones a repository to its configured path. Skips if it already exists. func CloneRepository(repo Repo) error { path := sys.ExpandPath(repo.Path) @@ -100,8 +101,9 @@ func clone(path string, url string) error { return cmd.Run() } +// ValidateRepo validates the repo config file at path against the repo JSON schema. func ValidateRepo(path string) error { - return ValidateSchema(path, REPO_SCHEMA_PATH) + return ValidateSchema(path, repoSchemaPath) } // ExtractRepo reads and parses the raid.yaml from the given repository directory. diff --git a/src/internal/lib/task.go b/src/internal/lib/task.go index 1d4a567..b8d0d16 100644 --- a/src/internal/lib/task.go +++ b/src/internal/lib/task.go @@ -13,11 +13,12 @@ type Condition struct { Cmd string `json:"cmd,omitempty"` } +// IsZero reports whether no condition fields are set. func (c Condition) IsZero() bool { return c.Platform == "" && c.Exists == "" && c.Cmd == "" } -// There has to be a better way to do this... todo +// Task represents a single unit of work in a task sequence. type Task struct { Type TaskType `json:"type"` Concurrent bool `json:"concurrent,omitempty"` @@ -54,10 +55,12 @@ type Task struct { Delay string `json:"delay,omitempty"` } +// IsZero reports whether the task has no type set. func (t Task) IsZero() bool { return t.Type == "" } +// Expand returns a copy of the task with all string fields passed through environment variable expansion. func (t Task) Expand() Task { return Task{ Type: t.Type, @@ -85,6 +88,7 @@ func (t Task) Expand() Task { } } +// TaskType identifies which task executor to dispatch to. type TaskType string const ( @@ -102,6 +106,7 @@ const ( Retry TaskType = "retry" ) +// ToLower returns the task type normalized to lowercase for case-insensitive comparisons. func (t TaskType) ToLower() TaskType { return TaskType(strings.ToLower(string(t))) } diff --git a/src/internal/sys/system.go b/src/internal/sys/system.go index 9dea11d..673ba05 100644 --- a/src/internal/sys/system.go +++ b/src/internal/sys/system.go @@ -1,6 +1,7 @@ package sys import ( + "fmt" "log" "os" "path" @@ -8,12 +9,12 @@ import ( "strings" "github.com/mitchellh/go-homedir" - "github.com/thoas/go-funk" ) -// Path Separator as a string +// Sep is the OS path separator as a string. const Sep = string(os.PathSeparator) +// Platform identifies the host operating system. type Platform string const ( @@ -23,6 +24,7 @@ const ( Other Platform = "other" ) +// GetHomeDir returns the current user's home directory, fatally logging on failure. func GetHomeDir() string { home, err := homedir.Dir() if err != nil { @@ -31,16 +33,20 @@ func GetHomeDir() string { return home } +// CreateFile opens the file at filePath if it exists, or creates it (including parent directories). func CreateFile(filePath string) (*os.File, error) { pathEx := ExpandPath(filePath) if FileExists(pathEx) { return os.Open(pathEx) } - os.MkdirAll(path.Dir(pathEx), os.ModeDir|0755) + if err := os.MkdirAll(path.Dir(pathEx), os.ModeDir|0755); err != nil { + return nil, fmt.Errorf("failed to create directories for '%s': %w", filePath, err) + } return os.Create(pathEx) } +// FileExists reports whether the file or directory at path exists. func FileExists(path string) bool { path = ExpandPath(path) if _, err := os.Stat(path); err != nil { @@ -49,17 +55,20 @@ func FileExists(path string) bool { return true } +// Expand expands environment variables and home directory references in each whitespace-delimited +// token of input, then rejoins them with spaces. func Expand(input string) string { if input == "" { return input } - - i := funk.Map(SplitInput(input), func(x string) string { - return ExpandPath(x) - }).([]string) - return strings.Join(i, " ") + parts := SplitInput(input) + for i, p := range parts { + parts[i] = ExpandPath(p) + } + return strings.Join(parts, " ") } +// ExpandPath expands environment variables and a leading ~ in the given path. func ExpandPath(input string) string { if input == "" { return input @@ -71,42 +80,41 @@ func ExpandPath(input string) string { return input } -// this is a mess — todo +// SplitInput splits a command string into tokens, respecting double-quoted segments. +// todo: replace with a proper shell-quoting parser func SplitInput(input string) []string { - out := []string{} - quote := false - ignore := false - b := strings.Builder{} - for _, s := range input { - if s == '"' { - quote = !quote - } - if s == ' ' && !quote { - if ignore { - continue - } - ignore = true - if b.Len() > 0 { + var out []string + var b strings.Builder + inQuote := false + skipSpace := false + + for _, ch := range input { + switch { + case ch == '"': + inQuote = !inQuote + if !inQuote && b.Len() > 0 { out = append(out, b.String()) b.Reset() } - } else if s == '"' && quote { - ignore = false - if b.Len() > 0 { + skipSpace = false + case ch == ' ' && !inQuote: + if !skipSpace && b.Len() > 0 { out = append(out, b.String()) b.Reset() } - b.WriteRune(s) - } else { - ignore = false - b.WriteRune(s) + skipSpace = true + default: + skipSpace = false + b.WriteRune(ch) } } - out = append(out, b.String()) + if b.Len() > 0 { + out = append(out, b.String()) + } return out } -// todo platform dependent files? +// GetPlatform returns the current operating system as a Platform value. func GetPlatform() Platform { switch runtime.GOOS { case "windows": From 5cc298e13f425fbc8157eb403bcf509bf3387571 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 13 Mar 2026 09:45:41 -0700 Subject: [PATCH 12/42] ignore any ai files --- .gitignore | 4 ++++ CLAUDE.md | 51 --------------------------------------------------- 2 files changed, 4 insertions(+), 51 deletions(-) delete mode 100644 CLAUDE.md diff --git a/.gitignore b/.gitignore index e5103f4..f3fef72 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,7 @@ go.work.sum # env file .env + +# AI assistant files +CLAUDE.md +.claude/ diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 7011661..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,51 +0,0 @@ -# Coding Standards - -## Core Principles - -- **Readable over clever** — code is read far more often than written. -- **Single Responsibility** — every function, type, and package does one thing. -- **Self-documenting** — names and structure should make intent obvious without comments. -- **DRY** — don't repeat yourself; but don't over-abstract prematurely either. -- **No util/helper/common/misc packages** — these are catch-all bins that accumulate debt. Put code in the package whose domain it belongs to. -- **Short functions** — if a function needs a comment to explain what a section does, that section should be its own function. -- **Lean parameter lists** — more than three parameters is a signal to introduce a type. - -## Naming - -- Names reveal intent: `usersByID` not `m`, `retryCount` not `n`. -- Follow ecosystem conventions: Go uses `MixedCaps`, not `snake_case`. -- No obscure abbreviations: `request` not `req`, `response` not `resp` (except where the convention is universal and unambiguous, e.g. `ctx`, `err`). -- Package names are singular, lowercase, and noun-based: `profile`, `repo`, not `profiles`, `repoUtils`. - -## Comments - -- Every exported type, function, and constant gets a doc comment. -- Comments explain *why*, not *what* — the code already shows what. -- Never commit commented-out code. Delete it; version control remembers. -- Don't annotate the obvious: `// increment i` above `i++` adds noise, not signal. - -## Testing - -- Everything gets tests. No exceptions for "simple" code. -- Test behavior, not implementation — tests should survive refactors. -- Failing tests must be self-explanatory: the failure message should tell you what broke and why, without reading the test body. -- Table-driven tests are preferred in Go for covering multiple cases cleanly. -- Avoid mocking internals; mock at boundaries (I/O, network, time). - -## Go-Specific - -- **Explicit error handling** — never ignore an error with `_` unless the reason is documented. -- **Composition over inheritance** — embed interfaces and structs; avoid deep type hierarchies. -- **Errors are values** — wrap with context using `fmt.Errorf("doing X: %w", err)`; return, don't panic. -- **No `init()` side effects** — prefer explicit initialization at the call site. -- **Interface segregation** — define small, focused interfaces at the point of use, not in the package that implements them. -- **No util/common/misc packages** — see Core Principles above. -- Doc comments on all exported types follow the `// TypeName does...` convention. - -## Reference Shelf - -- *Clean Code* — Robert C. Martin -- *Clean Architecture* — Robert C. Martin -- *The Pragmatic Programmer* — Hunt & Thomas -- *Design Patterns (GoF)* — Gamma et al. -- *Effective Java* — Joshua Bloch (language-agnostic principles translate well) From d1eb07731aff75a50143e9842c6ce01f2e7c5e02 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 13 Mar 2026 09:49:14 -0700 Subject: [PATCH 13/42] rename --- src/internal/utils/{Common.go => common.go} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/internal/utils/{Common.go => common.go} (100%) diff --git a/src/internal/utils/Common.go b/src/internal/utils/common.go similarity index 100% rename from src/internal/utils/Common.go rename to src/internal/utils/common.go From d08b0d83f37d92cc40bae8a3aa5c45834c332b31 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 13 Mar 2026 11:44:46 -0700 Subject: [PATCH 14/42] add unit tests --- src/internal/lib/config_test.go | 101 ++++++ src/internal/lib/env_test.go | 378 ++++++++++++++++++++ src/internal/lib/lib_test.go | 383 +++++++++++++++++++++ src/internal/lib/profile_test.go | 381 ++++++++++++++++++++ src/internal/lib/repo_test.go | 202 +++++++++++ src/internal/lib/task_runner_extra_test.go | 260 ++++++++++++++ src/internal/sys/system_test.go | 213 ++++++++++++ src/internal/utils/cobra_ext_test.go | 34 ++ src/internal/utils/common_test.go | 50 +++ 9 files changed, 2002 insertions(+) create mode 100644 src/internal/lib/config_test.go create mode 100644 src/internal/lib/env_test.go create mode 100644 src/internal/lib/lib_test.go create mode 100644 src/internal/lib/profile_test.go create mode 100644 src/internal/lib/repo_test.go create mode 100644 src/internal/lib/task_runner_extra_test.go create mode 100644 src/internal/sys/system_test.go create mode 100644 src/internal/utils/cobra_ext_test.go create mode 100644 src/internal/utils/common_test.go diff --git a/src/internal/lib/config_test.go b/src/internal/lib/config_test.go new file mode 100644 index 0000000..e4046c0 --- /dev/null +++ b/src/internal/lib/config_test.go @@ -0,0 +1,101 @@ +package lib + +import ( + "os" + "path/filepath" + "testing" + + "github.com/spf13/viper" +) + +// setupTestConfig initializes a fresh viper config backed by a temp file and +// registers cleanup to restore global state after the test. +func setupTestConfig(t *testing.T) { + t.Helper() + dir := t.TempDir() + configPath := filepath.Join(dir, "config.toml") + + oldCfgPath := CfgPath + oldContext := context + t.Cleanup(func() { + CfgPath = oldCfgPath + context = oldContext + viper.Reset() + }) + + CfgPath = configPath + context = nil + + if err := InitConfig(); err != nil { + t.Fatalf("setupTestConfig: InitConfig() error: %v", err) + } +} + +func TestInitConfig_createsAndReadsConfig(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.toml") + + oldCfgPath := CfgPath + t.Cleanup(func() { + CfgPath = oldCfgPath + viper.Reset() + }) + CfgPath = configPath + + if err := InitConfig(); err != nil { + t.Fatalf("InitConfig() error: %v", err) + } + + if _, err := os.Stat(configPath); err != nil { + t.Errorf("InitConfig() did not create config file: %v", err) + } +} + +func TestInitConfig_existingConfig(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.toml") + + if err := os.WriteFile(configPath, []byte(""), 0644); err != nil { + t.Fatal(err) + } + + oldCfgPath := CfgPath + t.Cleanup(func() { + CfgPath = oldCfgPath + viper.Reset() + }) + CfgPath = configPath + + if err := InitConfig(); err != nil { + t.Fatalf("InitConfig() on existing file error: %v", err) + } +} + +func TestInitConfig_invalidTOML(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.toml") + if err := os.WriteFile(configPath, []byte("invalid = [toml"), 0644); err != nil { + t.Fatal(err) + } + + oldCfgPath := CfgPath + t.Cleanup(func() { + CfgPath = oldCfgPath + viper.Reset() + }) + CfgPath = configPath + + if err := InitConfig(); err == nil { + t.Fatal("InitConfig() expected error for invalid TOML, got nil") + } +} + +func TestSet_persistsKeyInViper(t *testing.T) { + setupTestConfig(t) + + Set("testkey", "testvalue") + + if got := viper.GetString("testkey"); got != "testvalue" { + t.Errorf("Set() did not persist key: got %q, want %q", got, "testvalue") + } +} diff --git a/src/internal/lib/env_test.go b/src/internal/lib/env_test.go new file mode 100644 index 0000000..2e88d28 --- /dev/null +++ b/src/internal/lib/env_test.go @@ -0,0 +1,378 @@ +package lib + +import ( + "os" + "path/filepath" + "testing" +) + +func TestEnvIsZero(t *testing.T) { + tests := []struct { + name string + env Env + want bool + }{ + {"empty env", Env{}, true}, + {"named env", Env{Name: "dev"}, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.env.IsZero(); got != tt.want { + t.Errorf("IsZero() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestListEnvs_nilContext(t *testing.T) { + setupTestConfig(t) + + envs := ListEnvs() + if len(envs) != 0 { + t.Errorf("ListEnvs() with nil context = %v, want empty slice", envs) + } +} + +func TestListEnvs_emptyEnvironments(t *testing.T) { + setupTestConfig(t) + + context = &Context{ + Profile: Profile{Name: "test", Path: "/path"}, + } + + envs := ListEnvs() + if len(envs) != 0 { + t.Errorf("ListEnvs() with no environments = %v, want empty slice", envs) + } +} + +func TestListEnvs_withEnvironments(t *testing.T) { + setupTestConfig(t) + + context = &Context{ + Profile: Profile{ + Environments: []Env{ + {Name: "dev"}, + {Name: "prod"}, + }, + }, + } + + envs := ListEnvs() + if len(envs) != 2 { + t.Fatalf("ListEnvs() = %v, want 2 environments", envs) + } +} + +func TestContainsEnv(t *testing.T) { + setupTestConfig(t) + + context = &Context{ + Profile: Profile{ + Environments: []Env{ + {Name: "dev"}, + }, + }, + } + + if !ContainsEnv("dev") { + t.Error("ContainsEnv(\"dev\") = false, want true") + } + if ContainsEnv("prod") { + t.Error("ContainsEnv(\"prod\") = true, want false") + } +} + +func TestSetEnv_emptyName(t *testing.T) { + setupTestConfig(t) + + err := SetEnv("") + if err == nil { + t.Fatal("SetEnv(\"\") expected error, got nil") + } +} + +func TestSetEnv_notFound(t *testing.T) { + setupTestConfig(t) + + context = &Context{ + Profile: Profile{ + Environments: []Env{{Name: "dev"}}, + }, + } + + err := SetEnv("nonexistent") + if err == nil { + t.Fatal("SetEnv() expected error for nonexistent env") + } +} + +func TestSetAndGetEnv(t *testing.T) { + setupTestConfig(t) + + context = &Context{ + Profile: Profile{ + Environments: []Env{{Name: "dev"}}, + }, + } + + if err := SetEnv("dev"); err != nil { + t.Fatalf("SetEnv() error: %v", err) + } + + got := GetEnv() + if got != "dev" { + t.Errorf("GetEnv() = %q, want %q", got, "dev") + } +} + +func TestExecuteEnv_buildEnvPathError(t *testing.T) { + setupTestConfig(t) + + // Use a regular file as the repo path; .env cannot be created inside a file. + tmpFile, err := os.CreateTemp("", "raid-test-*") + if err != nil { + t.Fatal(err) + } + tmpFile.Close() + defer os.Remove(tmpFile.Name()) + + context = &Context{ + Profile: Profile{ + Name: "test", + Path: "/test", + Repositories: []Repo{ + {Name: "repo1", Path: tmpFile.Name(), URL: "http://x.com"}, + }, + }, + } + + err = ExecuteEnv("dev") + if err == nil { + t.Fatal("ExecuteEnv() expected error when buildEnvPath fails") + } +} + +func TestExecuteEnv_taskFailure(t *testing.T) { + setupTestConfig(t) + + dir := t.TempDir() + + context = &Context{ + Profile: Profile{ + Name: "test", + Path: "/test", + Repositories: []Repo{ + {Name: "repo1", Path: dir, URL: "http://x.com"}, + }, + Environments: []Env{ + { + Name: "dev", + Tasks: []Task{{Type: Shell, Cmd: "exit 1"}}, + }, + }, + }, + } + + err := ExecuteEnv("dev") + if err == nil { + t.Fatal("ExecuteEnv() expected error from failing task") + } +} + +func TestExecuteEnv_success(t *testing.T) { + setupTestConfig(t) + + dir := t.TempDir() + + context = &Context{ + Profile: Profile{ + Name: "test", + Path: "/test", + Repositories: []Repo{ + {Name: "repo1", Path: dir, URL: "http://x.com"}, + }, + Environments: []Env{ + { + Name: "dev", + Variables: []EnvVar{{Name: "APP_ENV", Value: "development"}}, + }, + }, + }, + } + + if err := ExecuteEnv("dev"); err != nil { + t.Errorf("ExecuteEnv() error: %v", err) + } +} + +func TestExecuteEnv_noMatchingEnvTasks(t *testing.T) { + setupTestConfig(t) + + dir := t.TempDir() + + context = &Context{ + Profile: Profile{ + Name: "test", + Path: "/test", + Repositories: []Repo{ + {Name: "repo1", Path: dir, URL: "http://x.com"}, + }, + // No environments defined — env name won't match anything. + }, + } + + // Runs setEnvVariablesForRepos (empty vars) then runTasksForEnv (zero env, returns nil). + if err := ExecuteEnv("nonexistent"); err != nil { + t.Errorf("ExecuteEnv() with no matching env error: %v", err) + } +} + +func TestLoadEnv_nilContext(t *testing.T) { + context = nil + + err := LoadEnv() + if err == nil { + t.Fatal("LoadEnv() expected error for nil context") + } +} + +func TestLoadEnv_noEnvFiles(t *testing.T) { + setupTestConfig(t) + + context = &Context{ + Profile: Profile{ + Repositories: []Repo{ + {Name: "repo1", Path: "/nonexistent/path"}, + }, + }, + } + + if err := LoadEnv(); err != nil { + t.Errorf("LoadEnv() with no .env files error: %v", err) + } +} + +func TestLoadEnv_withEnvFiles(t *testing.T) { + setupTestConfig(t) + + dir := t.TempDir() + envFile := filepath.Join(dir, ".env") + if err := os.WriteFile(envFile, []byte("RAID_LOAD_TEST=hello\n"), 0644); err != nil { + t.Fatal(err) + } + defer os.Unsetenv("RAID_LOAD_TEST") + + context = &Context{ + Profile: Profile{ + Repositories: []Repo{ + {Name: "repo1", Path: dir}, + }, + }, + } + + if err := LoadEnv(); err != nil { + t.Errorf("LoadEnv() with .env files error: %v", err) + } +} + +func TestExecuteEnv_repoEnvVars(t *testing.T) { + setupTestConfig(t) + + dir := t.TempDir() + + context = &Context{ + Profile: Profile{ + Name: "test", + Path: "/test", + Repositories: []Repo{ + { + Name: "repo1", + Path: dir, + URL: "http://x.com", + // Repo has its own env vars for "dev" — exercises the repoVars loop. + Environments: []Env{ + { + Name: "dev", + Variables: []EnvVar{{Name: "REPO_SPECIFIC", Value: "repo_val"}}, + }, + }, + }, + }, + Environments: []Env{ + { + Name: "dev", + Variables: []EnvVar{{Name: "PROFILE_VAR", Value: "prof_val"}}, + }, + }, + }, + } + + if err := ExecuteEnv("dev"); err != nil { + t.Errorf("ExecuteEnv() with repo env vars error: %v", err) + } +} + +func TestExecuteEnv_setEnvWriteError(t *testing.T) { + setupTestConfig(t) + + dir := t.TempDir() + // Pre-create .env as read-only so godotenv.Write fails. + envPath := filepath.Join(dir, ".env") + if err := os.WriteFile(envPath, []byte(""), 0444); err != nil { + t.Fatal(err) + } + defer os.Chmod(envPath, 0644) + + context = &Context{ + Profile: Profile{ + Name: "test", + Path: "/test", + Repositories: []Repo{ + {Name: "repo1", Path: dir, URL: "http://x.com"}, + }, + Environments: []Env{ + {Name: "dev", Variables: []EnvVar{{Name: "KEY", Value: "val"}}}, + }, + }, + } + + if err := ExecuteEnv("dev"); err == nil { + t.Fatal("ExecuteEnv() expected error when .env is read-only") + } +} + +func TestLoadEnv_loadFailure(t *testing.T) { + setupTestConfig(t) + + dir := t.TempDir() + // Create a directory named .env — godotenv.Load will fail reading it as a file. + fakeEnvPath := filepath.Join(dir, ".env") + if err := os.MkdirAll(fakeEnvPath, 0755); err != nil { + t.Fatal(err) + } + + context = &Context{ + Profile: Profile{ + Repositories: []Repo{ + {Name: "repo1", Path: dir}, + }, + }, + } + + if err := LoadEnv(); err == nil { + t.Fatal("LoadEnv() expected error when .env is a directory") + } +} + +func TestLoadEnv_emptyRepositories(t *testing.T) { + setupTestConfig(t) + + context = &Context{ + Profile: Profile{}, + } + + if err := LoadEnv(); err != nil { + t.Errorf("LoadEnv() with empty repositories error: %v", err) + } +} diff --git a/src/internal/lib/lib_test.go b/src/internal/lib/lib_test.go new file mode 100644 index 0000000..2b083f1 --- /dev/null +++ b/src/internal/lib/lib_test.go @@ -0,0 +1,383 @@ +package lib + +import ( + "os" + "path/filepath" + "testing" +) + +// repoRoot walks up from the package directory to find the repository root +// (identified by a schemas/ subdirectory). +func repoRoot(t *testing.T) string { + t.Helper() + wd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + dir := wd + for { + if fi, err := os.Stat(filepath.Join(dir, "schemas")); err == nil && fi.IsDir() { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + t.Fatal("could not find repo root (no schemas/ dir found)") + } + dir = parent + } +} + +func TestConditionIsZero(t *testing.T) { + tests := []struct { + name string + c Condition + want bool + }{ + {"all empty", Condition{}, true}, + {"platform set", Condition{Platform: "linux"}, false}, + {"exists set", Condition{Exists: "/tmp"}, false}, + {"cmd set", Condition{Cmd: "exit 0"}, false}, + {"all set", Condition{Platform: "linux", Exists: "/tmp", Cmd: "exit 0"}, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.c.IsZero(); got != tt.want { + t.Errorf("IsZero() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestLoad_noContext(t *testing.T) { + setupTestConfig(t) + + if err := Load(); err != nil { + t.Errorf("Load() error: %v", err) + } +} + +func TestLoad_withExistingContext(t *testing.T) { + setupTestConfig(t) + + context = &Context{Profile: Profile{Name: "test", Path: "/path"}} + + if err := Load(); err != nil { + t.Errorf("Load() with existing context error: %v", err) + } + // Should not reload — cached context must be preserved. + if context.Profile.Name != "test" { + t.Errorf("Load() modified existing context unexpectedly") + } +} + +func TestForceLoad_buildProfileError(t *testing.T) { + setupTestConfig(t) + + dir := t.TempDir() + profilePath := filepath.Join(dir, "profile.yaml") + os.WriteFile(profilePath, []byte("name: test"), 0644) + + AddProfile(Profile{Name: "test", Path: profilePath}) + SetProfile("test") + + // profileSchemaPath is relative; not found from test CWD → buildProfile fails. + if err := ForceLoad(); err == nil { + t.Fatal("ForceLoad() expected error when buildProfile fails") + } +} + +func TestForceLoad_buildRepoError(t *testing.T) { + root := repoRoot(t) + setupTestConfig(t) + + dir := t.TempDir() + repoDir := filepath.Join(dir, "repo") + os.MkdirAll(repoDir, 0755) + + // Invalid raid.yaml: missing "branch" (required) and has extra field (additionalProperties:false). + os.WriteFile(filepath.Join(repoDir, RaidConfigFileName), []byte("name: myrepo\nextrafield: bad"), 0644) + + profilePath := filepath.Join(dir, "profile.yaml") + content := "name: buildrepoerr\nrepositories:\n - name: myrepo\n path: " + repoDir + "\n url: http://example.com/repo.git\n" + os.WriteFile(profilePath, []byte(content), 0644) + + wd, _ := os.Getwd() + os.Chdir(root) + defer os.Chdir(wd) + + AddProfile(Profile{Name: "buildrepoerr", Path: profilePath}) + SetProfile("buildrepoerr") + + if err := ForceLoad(); err == nil { + t.Fatal("ForceLoad() expected error when buildRepo fails") + } +} + +func TestForceLoad_noActiveProfile(t *testing.T) { + setupTestConfig(t) + + if err := ForceLoad(); err != nil { + t.Errorf("ForceLoad() with no active profile error: %v", err) + } + if context == nil { + t.Fatal("ForceLoad() did not set context") + } +} + +func TestForceLoad_withValidProfile(t *testing.T) { + root := repoRoot(t) + setupTestConfig(t) + + // Build a valid profile file in a temp directory with a fake git repo inside. + dir := t.TempDir() + repoDir := filepath.Join(dir, "myrepo") + os.MkdirAll(filepath.Join(repoDir, ".git"), 0755) + + profilePath := filepath.Join(dir, "profile.yaml") + content := "name: testprofile\nrepositories:\n - name: myrepo\n path: " + repoDir + "\n url: http://example.com/repo.git\n" + if err := os.WriteFile(profilePath, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + // Schema resolution requires the CWD to be the repo root. + wd, _ := os.Getwd() + if err := os.Chdir(root); err != nil { + t.Fatal(err) + } + defer os.Chdir(wd) + + AddProfile(Profile{Name: "testprofile", Path: profilePath}) + SetProfile("testprofile") + + if err := ForceLoad(); err != nil { + t.Errorf("ForceLoad() with valid profile error: %v", err) + } + if context == nil || context.Profile.Name != "testprofile" { + t.Errorf("ForceLoad() context = %v, want profile named testprofile", context) + } +} + +func TestValidateSchema_missingFile(t *testing.T) { + err := ValidateSchema("/nonexistent/file.yaml", "/nonexistent/schema.json") + if err == nil { + t.Fatal("ValidateSchema() expected error for missing file") + } +} + +func TestValidateSchema_missingSchema(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "file.yaml") + os.WriteFile(path, []byte("name: test"), 0644) + + err := ValidateSchema(path, "/nonexistent/schema.json") + if err == nil { + t.Fatal("ValidateSchema() expected error for missing schema") + } +} + +func TestValidateSchema_emptyPaths(t *testing.T) { + err := ValidateSchema("", "") + if err == nil { + t.Fatal("ValidateSchema() expected error for empty paths") + } +} + +func TestValidateSchema_badSchemaFile(t *testing.T) { + dir := t.TempDir() + dataPath := filepath.Join(dir, "file.json") + os.WriteFile(dataPath, []byte(`{"name": "test"}`), 0644) + + schemaPath := filepath.Join(dir, "bad-schema.json") + os.WriteFile(schemaPath, []byte(`not valid json`), 0644) + + err := ValidateSchema(dataPath, schemaPath) + if err == nil { + t.Fatal("ValidateSchema() expected error for malformed schema file") + } +} + +func TestValidateSchema_invalidJSONContent(t *testing.T) { + dir := t.TempDir() + schemaPath := filepath.Join(dir, "schema.json") + os.WriteFile(schemaPath, []byte(`{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object"}`), 0644) + + dataPath := filepath.Join(dir, "bad.json") + os.WriteFile(dataPath, []byte(`not valid json`), 0644) + + err := ValidateSchema(dataPath, schemaPath) + if err == nil { + t.Fatal("ValidateSchema() expected error for invalid JSON content") + } +} + +func TestValidateSchema_invalidYAMLContent(t *testing.T) { + dir := t.TempDir() + schemaPath := filepath.Join(dir, "schema.json") + os.WriteFile(schemaPath, []byte(`{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object"}`), 0644) + + dataPath := filepath.Join(dir, "invalid.yaml") + os.WriteFile(dataPath, []byte("key: [unclosed"), 0644) + + err := ValidateSchema(dataPath, schemaPath) + if err == nil { + t.Fatal("ValidateSchema() expected error for invalid YAML content") + } +} + +func TestValidateSchema_validYAML(t *testing.T) { + dir := t.TempDir() + schemaPath := filepath.Join(dir, "schema.json") + os.WriteFile(schemaPath, []byte(`{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","required":["name"],"properties":{"name":{"type":"string"}}}`), 0644) + + dataPath := filepath.Join(dir, "profile.yaml") + os.WriteFile(dataPath, []byte("name: myprofile"), 0644) + + if err := ValidateSchema(dataPath, schemaPath); err != nil { + t.Errorf("ValidateSchema() on valid YAML error: %v", err) + } +} + +func TestValidateSchema_validJSON(t *testing.T) { + dir := t.TempDir() + schemaPath := filepath.Join(dir, "schema.json") + os.WriteFile(schemaPath, []byte(`{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","required":["name"],"properties":{"name":{"type":"string"}}}`), 0644) + + dataPath := filepath.Join(dir, "profile.json") + os.WriteFile(dataPath, []byte(`{"name":"myprofile"}`), 0644) + + if err := ValidateSchema(dataPath, schemaPath); err != nil { + t.Errorf("ValidateSchema() on valid JSON error: %v", err) + } +} + +func TestValidateSchema_schemaViolation(t *testing.T) { + dir := t.TempDir() + schemaPath := filepath.Join(dir, "schema.json") + // Require "name" field and disallow additional properties. + os.WriteFile(schemaPath, []byte(`{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","required":["name"],"additionalProperties":false,"properties":{"name":{"type":"string"}}}`), 0644) + + dataPath := filepath.Join(dir, "bad.yaml") + os.WriteFile(dataPath, []byte("unknown: field"), 0644) + + err := ValidateSchema(dataPath, schemaPath) + if err == nil { + t.Fatal("ValidateSchema() expected error for schema violation") + } +} + +func TestInstall_noProfile(t *testing.T) { + setupTestConfig(t) + + context = &Context{Profile: Profile{}} + + err := Install(1) + if err == nil { + t.Fatal("Install() expected error when profile is zero") + } +} + +func TestInstall_noRepos(t *testing.T) { + setupTestConfig(t) + + context = &Context{ + Profile: Profile{Name: "test", Path: "/path"}, + } + + if err := Install(0); err != nil { + t.Errorf("Install() with no repos error: %v", err) + } +} + +func TestInstall_withSemaphoreAndExistingRepo(t *testing.T) { + setupTestConfig(t) + + dir := t.TempDir() + // Fake git repo — CloneRepository will skip cloning. + os.MkdirAll(filepath.Join(dir, ".git"), 0755) + + context = &Context{ + Profile: Profile{ + Name: "test", + Path: "/path", + Repositories: []Repo{ + {Name: "repo1", Path: dir, URL: "http://example.com"}, + }, + }, + } + + // maxThreads=1 exercises the semaphore acquisition/release paths. + if err := Install(1); err != nil { + t.Errorf("Install() with semaphore and existing repo error: %v", err) + } +} + +func TestInstall_cloneFailure(t *testing.T) { + setupTestConfig(t) + + dir := t.TempDir() + + context = &Context{ + Profile: Profile{ + Name: "test", + Path: "/path", + Repositories: []Repo{ + // Non-existent local path causes git to fail immediately. + {Name: "repo1", Path: filepath.Join(dir, "newrepo"), URL: "file:///nonexistent/repo.git"}, + }, + }, + } + + err := Install(0) + if err == nil { + t.Fatal("Install() expected error for failed clone") + } +} + +func TestInstall_installTaskFailure(t *testing.T) { + setupTestConfig(t) + + context = &Context{ + Profile: Profile{ + Name: "test", + Path: "/path", + Install: OnInstall{ + Tasks: []Task{{Type: Shell, Cmd: "exit 1"}}, + }, + }, + } + + err := Install(0) + if err == nil { + t.Fatal("Install() expected error from failing install task") + } +} + +func TestInstall_repoInstallTaskFailure(t *testing.T) { + setupTestConfig(t) + + dir := t.TempDir() + os.MkdirAll(filepath.Join(dir, ".git"), 0755) + + context = &Context{ + Profile: Profile{ + Name: "test", + Path: "/path", + Repositories: []Repo{ + { + Name: "repo1", + Path: dir, + URL: "http://example.com", + Install: OnInstall{ + Tasks: []Task{{Type: Shell, Cmd: "exit 1"}}, + }, + }, + }, + }, + } + + err := Install(0) + if err == nil { + t.Fatal("Install() expected error from failing repo install task") + } +} diff --git a/src/internal/lib/profile_test.go b/src/internal/lib/profile_test.go new file mode 100644 index 0000000..0012aa4 --- /dev/null +++ b/src/internal/lib/profile_test.go @@ -0,0 +1,381 @@ +package lib + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "gopkg.in/yaml.v3" +) + +func TestProfileIsZero(t *testing.T) { + tests := []struct { + name string + profile Profile + want bool + }{ + {"empty profile", Profile{}, true}, + {"name only", Profile{Name: "test"}, true}, + {"path only", Profile{Path: "/some/path"}, true}, + {"name and path", Profile{Name: "test", Path: "/some/path"}, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.profile.IsZero(); got != tt.want { + t.Errorf("IsZero() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestProfileGetEnv(t *testing.T) { + profile := Profile{ + Environments: []Env{ + {Name: "dev", Variables: []EnvVar{{Name: "KEY", Value: "val"}}}, + {Name: "prod"}, + }, + } + + t.Run("found", func(t *testing.T) { + env := profile.getEnv("dev") + if env.Name != "dev" { + t.Errorf("getEnv(\"dev\") = %q, want \"dev\"", env.Name) + } + }) + + t.Run("not found returns zero", func(t *testing.T) { + env := profile.getEnv("staging") + if !env.IsZero() { + t.Errorf("getEnv(\"staging\") should return zero Env, got %v", env) + } + }) +} + +func TestAddAndContainsProfile(t *testing.T) { + setupTestConfig(t) + + AddProfile(Profile{Name: "myprofile", Path: "/some/path"}) + + if !ContainsProfile("myprofile") { + t.Error("ContainsProfile() = false after AddProfile(), want true") + } +} + +func TestContainsProfile_notFound(t *testing.T) { + setupTestConfig(t) + + if ContainsProfile("nonexistent") { + t.Error("ContainsProfile() = true for nonexistent profile, want false") + } +} + +func TestListProfiles(t *testing.T) { + setupTestConfig(t) + + AddProfile(Profile{Name: "list-a", Path: "/a"}) + AddProfile(Profile{Name: "list-b", Path: "/b"}) + + profiles := ListProfiles() + names := make(map[string]bool) + for _, p := range profiles { + names[p.Name] = true + } + if !names["list-a"] || !names["list-b"] { + t.Errorf("ListProfiles() = %v, missing added profiles", profiles) + } +} + +func TestAddProfiles(t *testing.T) { + setupTestConfig(t) + + AddProfiles([]Profile{ + {Name: "bulk-a", Path: "/a"}, + {Name: "bulk-b", Path: "/b"}, + }) + + if !ContainsProfile("bulk-a") || !ContainsProfile("bulk-b") { + t.Error("AddProfiles() did not add all profiles") + } +} + +func TestRemoveProfile(t *testing.T) { + setupTestConfig(t) + + AddProfile(Profile{Name: "toremove", Path: "/path"}) + if err := RemoveProfile("toremove"); err != nil { + t.Fatalf("RemoveProfile() error: %v", err) + } + if ContainsProfile("toremove") { + t.Error("ContainsProfile() = true after RemoveProfile(), want false") + } +} + +func TestRemoveProfile_notFound(t *testing.T) { + setupTestConfig(t) + + AddProfile(Profile{Name: "existing", Path: "/path"}) + err := RemoveProfile("nonexistent") + if err == nil { + t.Fatal("RemoveProfile() expected error for nonexistent profile") + } +} + +func TestRemoveProfile_noProfiles(t *testing.T) { + setupTestConfig(t) + + err := RemoveProfile("anything") + if err == nil { + t.Fatal("RemoveProfile() on empty config should error") + } +} + +func TestSetProfile_notFound(t *testing.T) { + setupTestConfig(t) + + err := SetProfile("nonexistent") + if err == nil { + t.Fatal("SetProfile() expected error for nonexistent profile") + } +} + +func TestSetAndGetProfile(t *testing.T) { + setupTestConfig(t) + + AddProfile(Profile{Name: "active", Path: "/active/path"}) + if err := SetProfile("active"); err != nil { + t.Fatalf("SetProfile() error: %v", err) + } + + got := GetProfile() + if got.Name != "active" { + t.Errorf("GetProfile() name = %q, want %q", got.Name, "active") + } +} + +func TestGetProfile_fromContext(t *testing.T) { + setupTestConfig(t) + + context = &Context{ + Profile: Profile{Name: "ctx-profile", Path: "/ctx/path"}, + } + + got := GetProfile() + if got.Name != "ctx-profile" { + t.Errorf("GetProfile() from context = %q, want %q", got.Name, "ctx-profile") + } +} + +func TestExtractProfiles_singleYAML(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "profile.yaml") + + data := Profile{Name: "yamltest", Path: path} + b, _ := yaml.Marshal(data) + if err := os.WriteFile(path, b, 0644); err != nil { + t.Fatal(err) + } + + profiles, err := ExtractProfiles(path) + if err != nil { + t.Fatalf("ExtractProfiles() error: %v", err) + } + if len(profiles) != 1 || profiles[0].Name != "yamltest" { + t.Errorf("ExtractProfiles() = %v, want single profile named yamltest", profiles) + } +} + +func TestExtractProfiles_multiDocYAML(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "profiles.yaml") + + content := "name: first\n---\nname: second\n" + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + profiles, err := ExtractProfiles(path) + if err != nil { + t.Fatalf("ExtractProfiles() error: %v", err) + } + if len(profiles) != 2 { + t.Fatalf("ExtractProfiles() returned %d profiles, want 2", len(profiles)) + } +} + +func TestExtractProfiles_singleJSON(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "profile.json") + + data, _ := json.Marshal(Profile{Name: "jsontest"}) + if err := os.WriteFile(path, data, 0644); err != nil { + t.Fatal(err) + } + + profiles, err := ExtractProfiles(path) + if err != nil { + t.Fatalf("ExtractProfiles() error: %v", err) + } + if len(profiles) != 1 || profiles[0].Name != "jsontest" { + t.Errorf("ExtractProfiles() = %v, want profile named jsontest", profiles) + } +} + +func TestExtractProfiles_arrayJSON(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "profiles.json") + + data, _ := json.Marshal([]Profile{{Name: "a"}, {Name: "b"}}) + if err := os.WriteFile(path, data, 0644); err != nil { + t.Fatal(err) + } + + profiles, err := ExtractProfiles(path) + if err != nil { + t.Fatalf("ExtractProfiles() error: %v", err) + } + if len(profiles) != 2 { + t.Fatalf("ExtractProfiles() returned %d profiles, want 2", len(profiles)) + } +} + +func TestExtractProfiles_invalidJSON(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "bad.json") + os.WriteFile(path, []byte("{invalid json}"), 0644) + + _, err := ExtractProfiles(path) + if err == nil { + t.Fatal("ExtractProfiles() expected error for invalid JSON") + } +} + +func TestExtractProfiles_invalidYAML(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "bad.yaml") + os.WriteFile(path, []byte("key: [unclosed"), 0644) + + _, err := ExtractProfiles(path) + if err == nil { + t.Fatal("ExtractProfiles() expected error for invalid YAML") + } +} + +func TestExtractProfiles_unsupportedExtension(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "profile.xml") + os.WriteFile(path, []byte(""), 0644) + + _, err := ExtractProfiles(path) + if err == nil { + t.Fatal("ExtractProfiles() expected error for unsupported extension") + } +} + +func TestExtractProfiles_fileNotFound(t *testing.T) { + _, err := ExtractProfiles("/nonexistent/path/profile.yaml") + if err == nil { + t.Fatal("ExtractProfiles() expected error for missing file") + } +} + +func TestExtractProfiles_emptyYAML(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "empty.yaml") + os.WriteFile(path, []byte(""), 0644) + + _, err := ExtractProfiles(path) + if err == nil { + t.Fatal("ExtractProfiles() expected error for empty YAML (no profiles)") + } +} + +func TestExtractProfile_found(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "profiles.yaml") + os.WriteFile(path, []byte("name: first\n---\nname: second\n"), 0644) + + p, err := ExtractProfile("first", path) + if err != nil { + t.Fatalf("ExtractProfile() error: %v", err) + } + if p.Name != "first" { + t.Errorf("ExtractProfile() name = %q, want %q", p.Name, "first") + } +} + +func TestExtractProfile_notFound(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "profiles.yaml") + os.WriteFile(path, []byte("name: only\n"), 0644) + + _, err := ExtractProfile("nonexistent", path) + if err == nil { + t.Fatal("ExtractProfile() expected error for missing profile name") + } +} + +func TestExtractProfile_extractionError(t *testing.T) { + _, err := ExtractProfile("anyname", "/nonexistent/path/profile.yaml") + if err == nil { + t.Fatal("ExtractProfile() expected error for missing file") + } +} + +func TestBuildProfile_zero(t *testing.T) { + _, err := buildProfile(Profile{}) + if err == nil { + t.Fatal("buildProfile() expected error for zero profile") + } +} + +func TestBuildProfile_fileNotFound(t *testing.T) { + _, err := buildProfile(Profile{Name: "test", Path: "/nonexistent/path/profile.yaml"}) + if err == nil { + t.Fatal("buildProfile() expected error when profile file not found") + } +} + +func TestBuildProfile_validationError(t *testing.T) { + dir := t.TempDir() + profilePath := filepath.Join(dir, "profile.yaml") + os.WriteFile(profilePath, []byte("name: test"), 0644) + + // profileSchemaPath is relative; schema not found from test CWD → validation error. + _, err := buildProfile(Profile{Name: "test", Path: profilePath}) + if err == nil { + t.Fatal("buildProfile() expected error when schema validation fails") + } +} + +func TestBuildProfile_extractionError(t *testing.T) { + root := repoRoot(t) + dir := t.TempDir() + + profilePath := filepath.Join(dir, "profile.yaml") + // Valid per profile schema (only requires "name"), but we'll look for a different name. + os.WriteFile(profilePath, []byte("name: actualname"), 0644) + + wd, _ := os.Getwd() + os.Chdir(root) + defer os.Chdir(wd) + + _, err := buildProfile(Profile{Name: "wrongname", Path: profilePath}) + if err == nil { + t.Fatal("buildProfile() expected error when profile name not found in file") + } +} + +func TestValidateProfile_schemaNotFound(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "profile.yaml") + os.WriteFile(path, []byte("name: test"), 0644) + + // profileSchemaPath is relative to repo root; won't resolve from test CWD. + // We just need to exercise the function; the error path is valid coverage. + err := ValidateProfile(path) + if err == nil { + t.Fatal("ValidateProfile() expected error when schema cannot be found from test CWD") + } +} diff --git a/src/internal/lib/repo_test.go b/src/internal/lib/repo_test.go new file mode 100644 index 0000000..74e9b58 --- /dev/null +++ b/src/internal/lib/repo_test.go @@ -0,0 +1,202 @@ +package lib + +import ( + "os" + "os/exec" + "path/filepath" + "testing" +) + +func TestRepoIsZero(t *testing.T) { + tests := []struct { + name string + repo Repo + want bool + }{ + {"empty", Repo{}, true}, + {"name only", Repo{Name: "test"}, true}, + {"name and path", Repo{Name: "test", Path: "/path"}, true}, + {"name and url", Repo{Name: "test", URL: "http://example.com"}, true}, + {"path and url", Repo{Path: "/path", URL: "http://example.com"}, true}, + {"all three fields", Repo{Name: "test", Path: "/path", URL: "http://example.com"}, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.repo.IsZero(); got != tt.want { + t.Errorf("IsZero() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestRepoGetEnv(t *testing.T) { + repo := Repo{ + Environments: []Env{ + {Name: "dev"}, + {Name: "prod"}, + }, + } + + t.Run("found", func(t *testing.T) { + env := repo.getEnv("dev") + if env.Name != "dev" { + t.Errorf("getEnv(\"dev\") = %q, want \"dev\"", env.Name) + } + }) + + t.Run("not found returns zero", func(t *testing.T) { + env := repo.getEnv("staging") + if !env.IsZero() { + t.Errorf("getEnv(\"staging\") should return zero Env, got %v", env) + } + }) +} + +func TestExtractRepo_validYAML(t *testing.T) { + dir := t.TempDir() + content := "environments:\n - name: dev\n" + if err := os.WriteFile(filepath.Join(dir, RaidConfigFileName), []byte(content), 0644); err != nil { + t.Fatal(err) + } + + repo, err := ExtractRepo(dir) + if err != nil { + t.Fatalf("ExtractRepo() error: %v", err) + } + if len(repo.Environments) != 1 || repo.Environments[0].Name != "dev" { + t.Errorf("ExtractRepo() environments = %v, want [{dev}]", repo.Environments) + } +} + +func TestExtractRepo_invalidYAML(t *testing.T) { + dir := t.TempDir() + os.WriteFile(filepath.Join(dir, RaidConfigFileName), []byte("key: [unclosed"), 0644) + + _, err := ExtractRepo(dir) + if err == nil { + t.Fatal("ExtractRepo() expected error for invalid YAML") + } +} + +func TestExtractRepo_missingFile(t *testing.T) { + _, err := ExtractRepo("/nonexistent/path") + if err == nil { + t.Fatal("ExtractRepo() expected error for missing directory") + } +} + +func TestCloneRepository_alreadyExists(t *testing.T) { + dir := t.TempDir() + // Simulate an existing git repository. + if err := os.MkdirAll(filepath.Join(dir, ".git"), 0755); err != nil { + t.Fatal(err) + } + + repo := Repo{Name: "test", Path: dir, URL: "http://example.com"} + if err := CloneRepository(repo); err != nil { + t.Errorf("CloneRepository() on existing repo should skip, got error: %v", err) + } +} + +func TestCloneRepository_pathExistsNotGitRepo(t *testing.T) { + dir := t.TempDir() + // Directory exists but is not a git repo, and no network URL is reachable. + // Use a local file:// URL pointing to a nonexistent path so git fails fast. + repo := Repo{Name: "test", Path: filepath.Join(dir, "newrepo"), URL: "file:///nonexistent/repo.git"} + + err := CloneRepository(repo) + if err == nil { + t.Fatal("CloneRepository() expected error for failed clone") + } +} + +func TestBuildRepo_zero(t *testing.T) { + repo := Repo{} + err := buildRepo(&repo) + if err == nil { + t.Fatal("buildRepo() expected error for zero repo") + } +} + +func TestBuildRepo_noRaidYAML(t *testing.T) { + dir := t.TempDir() + repo := Repo{Name: "test", Path: dir, URL: "http://x.com"} + if err := buildRepo(&repo); err != nil { + t.Errorf("buildRepo() with no raid.yaml should return nil, got: %v", err) + } +} + +func TestBuildRepo_validationError(t *testing.T) { + dir := t.TempDir() + // Create raid.yaml; schema not found from test CWD → ValidateRepo returns error. + os.WriteFile(filepath.Join(dir, RaidConfigFileName), []byte("name: test\nbranch: main"), 0644) + + repo := Repo{Name: "test", Path: dir, URL: "http://x.com"} + err := buildRepo(&repo) + if err == nil { + t.Fatal("buildRepo() expected error when schema validation fails") + } +} + +func TestBuildRepo_validRaidYAML(t *testing.T) { + root := repoRoot(t) + dir := t.TempDir() + + // Valid raid.yaml: repo schema requires "name" and "branch". + os.WriteFile(filepath.Join(dir, RaidConfigFileName), []byte("name: myrepo\nbranch: main"), 0644) + + wd, _ := os.Getwd() + os.Chdir(root) + defer os.Chdir(wd) + + repo := Repo{Name: "myrepo", Path: dir, URL: "http://x.com"} + if err := buildRepo(&repo); err != nil { + t.Errorf("buildRepo() with valid raid.yaml error: %v", err) + } +} + +func TestCloneRepository_mkdirAllError(t *testing.T) { + // Use a regular file as a parent directory — os.MkdirAll will fail with ENOTDIR. + tmpFile, err := os.CreateTemp("", "raid-test-*") + if err != nil { + t.Fatal(err) + } + tmpFile.Close() + defer os.Remove(tmpFile.Name()) + + repo := Repo{Name: "test", Path: filepath.Join(tmpFile.Name(), "subdir"), URL: "file:///nonexistent.git"} + if err := CloneRepository(repo); err == nil { + t.Fatal("CloneRepository() expected error when MkdirAll fails") + } +} + +func TestCloneRepository_successLocalRepo(t *testing.T) { + if !isGitInstalled() { + t.Skip("git not installed") + } + + srcDir := t.TempDir() + if err := exec.Command("git", "init", "--bare", srcDir).Run(); err != nil { + t.Skipf("git init --bare failed: %v", err) + } + + destDir := filepath.Join(t.TempDir(), "clone") + repo := Repo{Name: "test", Path: destDir, URL: "file://" + srcDir} + + if err := CloneRepository(repo); err != nil { + t.Errorf("CloneRepository() to local bare repo error: %v", err) + } +} + +func TestValidateRepo_schemaNotFound(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "raid.yaml") + os.WriteFile(path, []byte("name: test\nbranch: main"), 0644) + + // repoSchemaPath is relative to repo root; won't resolve from test CWD. + err := ValidateRepo(path) + if err == nil { + t.Fatal("ValidateRepo() expected error when schema cannot be found from test CWD") + } +} diff --git a/src/internal/lib/task_runner_extra_test.go b/src/internal/lib/task_runner_extra_test.go new file mode 100644 index 0000000..60579da --- /dev/null +++ b/src/internal/lib/task_runner_extra_test.go @@ -0,0 +1,260 @@ +package lib + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +// --- execConfirm --- + +func TestExecuteTask_confirm_noMessage(t *testing.T) { + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + w.WriteString("y\n") + w.Close() + + origStdin := os.Stdin + os.Stdin = r + defer func() { os.Stdin = origStdin }() + + // Empty Message exercises the `message = "Continue?"` default branch. + task := Task{Type: Confirm} + if err := ExecuteTask(task); err != nil { + t.Errorf("confirm with no message and answer 'y': unexpected error: %v", err) + } +} + +func TestExecuteTask_confirm_readError(t *testing.T) { + r, _, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + r.Close() // Close the read end so ReadString returns an error. + + origStdin := os.Stdin + os.Stdin = r + defer func() { os.Stdin = origStdin }() + + task := Task{Type: Confirm, Message: "Proceed?"} + if err := ExecuteTask(task); err == nil { + t.Fatal("expected error from closed stdin, got nil") + } +} + +// --- execPrompt --- + +func TestExecuteTask_prompt_readError(t *testing.T) { + r, _, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + r.Close() + + origStdin := os.Stdin + os.Stdin = r + defer func() { os.Stdin = origStdin }() + + task := Task{Type: Prompt, Var: "RAID_PROMPT_ERR_TEST"} + if err := ExecuteTask(task); err == nil { + t.Fatal("expected error from closed stdin, got nil") + } +} + +// --- execParallel / execRetry group not found --- + +func TestExecuteTask_parallel_groupNotFound(t *testing.T) { + context = &Context{ + Profile: Profile{ + Groups: map[string][]Task{ + "other": {{Type: Shell, Cmd: "exit 0"}}, + }, + }, + } + defer func() { context = nil }() + + task := Task{Type: Parallel, Ref: "nonexistent"} + if err := ExecuteTask(task); err == nil { + t.Fatal("expected error for nonexistent parallel group ref, got nil") + } +} + +func TestExecuteTask_retry_groupNotFound(t *testing.T) { + context = &Context{ + Profile: Profile{ + Groups: map[string][]Task{ + "other": {{Type: Shell, Cmd: "exit 0"}}, + }, + }, + } + defer func() { context = nil }() + + task := Task{Type: Retry, Ref: "nonexistent"} + if err := ExecuteTask(task); err == nil { + t.Fatal("expected error for nonexistent retry group ref, got nil") + } +} + +// --- execGit branch coverage --- + +func TestExecuteTask_git_checkoutNoBranch(t *testing.T) { + dir := t.TempDir() + task := Task{Type: Git, Op: "checkout", Dir: dir} + err := ExecuteTask(task) + if err == nil { + t.Fatal("expected error for checkout without branch") + } + if !strings.Contains(err.Error(), "branch is required") { + t.Errorf("error %q should mention 'branch is required'", err.Error()) + } +} + +func TestExecuteTask_git_pullWithBranch(t *testing.T) { + // Exercises the `args = append(args, "origin", task.Branch)` branch. + // Git will fail (not a real repo), but the code path is covered. + dir := t.TempDir() + _ = ExecuteTask(Task{Type: Git, Op: "pull", Branch: "main", Dir: dir}) +} + +func TestExecuteTask_git_fetchWithBranch(t *testing.T) { + dir := t.TempDir() + _ = ExecuteTask(Task{Type: Git, Op: "fetch", Branch: "main", Dir: dir}) +} + +// --- execHTTP error paths --- + +func TestExecuteTask_http_mkdirAllError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Write([]byte("content")) + })) + defer srv.Close() + + f, err := os.CreateTemp("", "raid-http-test-*") + if err != nil { + t.Fatal(err) + } + f.Close() + defer os.Remove(f.Name()) + + // Parent path contains a file component — MkdirAll fails. + task := Task{ + Type: HTTP, + URL: srv.URL, + Dest: filepath.Join(f.Name(), "subdir", "output.txt"), + } + if err := ExecuteTask(task); err == nil { + t.Fatal("execHTTP: expected error when MkdirAll fails") + } +} + +func TestExecuteTask_http_createError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Write([]byte("content")) + })) + defer srv.Close() + + dir := t.TempDir() + // Create a directory where the output file should be — os.Create on a dir fails. + dest := filepath.Join(dir, "output") + os.MkdirAll(dest, 0755) + + task := Task{ + Type: HTTP, + URL: srv.URL, + Dest: dest, + } + if err := ExecuteTask(task); err == nil { + t.Fatal("execHTTP: expected error when dest is a directory") + } +} + +// --- execTemplate error paths --- + +func TestExecuteTask_template_readError(t *testing.T) { + dir := t.TempDir() + srcPath := filepath.Join(dir, "template.txt") + if err := os.WriteFile(srcPath, []byte("content"), 0000); err != nil { + t.Fatal(err) + } + defer os.Chmod(srcPath, 0644) + + task := Task{ + Type: Template, + Src: srcPath, + Dest: filepath.Join(dir, "output.txt"), + } + if err := ExecuteTask(task); err == nil { + t.Fatal("execTemplate: expected error when src is unreadable") + } +} + +func TestExecuteTask_template_mkdirAllError(t *testing.T) { + dir := t.TempDir() + srcPath := filepath.Join(dir, "template.txt") + os.WriteFile(srcPath, []byte("hello"), 0644) + + f, err := os.CreateTemp("", "raid-template-test-*") + if err != nil { + t.Fatal(err) + } + f.Close() + defer os.Remove(f.Name()) + + task := Task{ + Type: Template, + Src: srcPath, + Dest: filepath.Join(f.Name(), "subdir", "output.txt"), + } + if err := ExecuteTask(task); err == nil { + t.Fatal("execTemplate: expected error when dest parent MkdirAll fails") + } +} + +func TestExecuteTask_template_writeError(t *testing.T) { + dir := t.TempDir() + srcPath := filepath.Join(dir, "template.txt") + os.WriteFile(srcPath, []byte("hello"), 0644) + + destPath := filepath.Join(dir, "output.txt") + os.WriteFile(destPath, []byte(""), 0444) // read-only + defer os.Chmod(destPath, 0644) + + task := Task{ + Type: Template, + Src: srcPath, + Dest: destPath, + } + if err := ExecuteTask(task); err == nil { + t.Fatal("execTemplate: expected error when dest is read-only") + } +} + +// --- checkHTTP error path --- + +func TestCheckHTTP_unreachableURL(t *testing.T) { + // Port 0 is reserved; connection should be refused immediately. + err := checkHTTP("http://127.0.0.1:1/") + if err == nil { + t.Fatal("checkHTTP() expected error for unreachable URL, got nil") + } +} + +// --- ExecuteTasks concurrent error collection --- + +func TestExecuteTasks_concurrentErrorCollected(t *testing.T) { + // A concurrent task fails AND the following sequential task also fails. + // When the sequential task fails, wg.Wait() completes then errorChan is drained, + // exercising the `errs = append(errs, e)` line. + tasks := []Task{ + {Type: Shell, Cmd: "exit 1", Concurrent: true}, + {Type: Shell, Cmd: "exit 1"}, // sequential failure triggers the drain + } + if err := ExecuteTasks(tasks); err == nil { + t.Fatal("expected error when concurrent and sequential tasks both fail") + } +} diff --git a/src/internal/sys/system_test.go b/src/internal/sys/system_test.go new file mode 100644 index 0000000..3a7ef09 --- /dev/null +++ b/src/internal/sys/system_test.go @@ -0,0 +1,213 @@ +package sys + +import ( + "os" + "path/filepath" + "testing" +) + +func TestGetPlatform(t *testing.T) { + p := GetPlatform() + switch p { + case Windows, Linux, Darwin, Other: + // valid Platform value + default: + t.Errorf("GetPlatform() = %q, not a valid Platform value", p) + } +} + +func TestGetHomeDir(t *testing.T) { + home := GetHomeDir() + if home == "" { + t.Error("GetHomeDir() returned empty string") + } +} + +func TestFileExists(t *testing.T) { + tests := []struct { + name string + path func(t *testing.T) string + want bool + }{ + { + name: "existing file", + path: func(t *testing.T) string { + f, err := os.CreateTemp(t.TempDir(), "raid-test-*") + if err != nil { + t.Fatal(err) + } + f.Close() + return f.Name() + }, + want: true, + }, + { + name: "non-existing file", + path: func(t *testing.T) string { + return filepath.Join(t.TempDir(), "does-not-exist") + }, + want: false, + }, + { + name: "existing directory", + path: func(t *testing.T) string { + return t.TempDir() + }, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path := tt.path(t) + if got := FileExists(path); got != tt.want { + t.Errorf("FileExists(%q) = %v, want %v", path, got, tt.want) + } + }) + } +} + +func TestExpandPath(t *testing.T) { + t.Run("empty string", func(t *testing.T) { + if got := ExpandPath(""); got != "" { + t.Errorf("ExpandPath(\"\") = %q, want \"\"", got) + } + }) + + t.Run("expands env var", func(t *testing.T) { + os.Setenv("RAID_SYS_TEST", "testvalue") + defer os.Unsetenv("RAID_SYS_TEST") + + got := ExpandPath("/tmp/$RAID_SYS_TEST/path") + if got != "/tmp/testvalue/path" { + t.Errorf("ExpandPath() = %q, want %q", got, "/tmp/testvalue/path") + } + }) + + t.Run("expands tilde", func(t *testing.T) { + got := ExpandPath("~/something") + if got == "~/something" { + t.Error("ExpandPath() did not expand tilde") + } + if got == "" { + t.Error("ExpandPath() returned empty string for ~/something") + } + }) + + t.Run("absolute path unchanged", func(t *testing.T) { + got := ExpandPath("/usr/local/bin") + if got != "/usr/local/bin" { + t.Errorf("ExpandPath(%q) = %q, want unchanged", "/usr/local/bin", got) + } + }) +} + +func TestExpand(t *testing.T) { + t.Run("empty string", func(t *testing.T) { + if got := Expand(""); got != "" { + t.Errorf("Expand(\"\") = %q, want \"\"", got) + } + }) + + t.Run("single token with env var", func(t *testing.T) { + os.Setenv("RAID_EXPAND_A", "hello") + defer os.Unsetenv("RAID_EXPAND_A") + + got := Expand("$RAID_EXPAND_A") + if got != "hello" { + t.Errorf("Expand() = %q, want %q", got, "hello") + } + }) + + t.Run("multiple tokens with env vars", func(t *testing.T) { + os.Setenv("RAID_EXPAND_X", "foo") + os.Setenv("RAID_EXPAND_Y", "bar") + defer os.Unsetenv("RAID_EXPAND_X") + defer os.Unsetenv("RAID_EXPAND_Y") + + got := Expand("$RAID_EXPAND_X $RAID_EXPAND_Y") + if got != "foo bar" { + t.Errorf("Expand() = %q, want %q", got, "foo bar") + } + }) +} + +func TestSplitInput(t *testing.T) { + tests := []struct { + name string + input string + want []string + }{ + {"empty string", "", nil}, + {"single word", "hello", []string{"hello"}}, + {"two words", "hello world", []string{"hello", "world"}}, + {"quoted string with space", `"hello world"`, []string{"hello world"}}, + {"word then quoted", `echo "hello world"`, []string{"echo", "hello world"}}, + {"multiple spaces", "a b", []string{"a", "b"}}, + {"trailing space", "a b ", []string{"a", "b"}}, + {"leading space", " a b", []string{"a", "b"}}, + {"empty quotes", `""`, nil}, + {"quoted then unquoted", `"foo" bar`, []string{"foo", "bar"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := SplitInput(tt.input) + if len(got) != len(tt.want) { + t.Fatalf("SplitInput(%q) = %v, want %v", tt.input, got, tt.want) + } + for i := range got { + if got[i] != tt.want[i] { + t.Errorf("SplitInput(%q)[%d] = %q, want %q", tt.input, i, got[i], tt.want[i]) + } + } + }) + } +} + +func TestCreateFile_newFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "sub", "nested", "file.txt") + + f, err := CreateFile(path) + if err != nil { + t.Fatalf("CreateFile() error: %v", err) + } + f.Close() + + if !FileExists(path) { + t.Errorf("CreateFile() did not create file at %q", path) + } +} + +func TestCreateFile_mkdirAllError(t *testing.T) { + // Use a regular file as a parent directory component — os.MkdirAll will fail with ENOTDIR. + f, err := os.CreateTemp("", "raid-test-*") + if err != nil { + t.Fatal(err) + } + f.Close() + defer os.Remove(f.Name()) + + path := filepath.Join(f.Name(), "subdir", "file.txt") + if _, err := CreateFile(path); err == nil { + t.Fatal("CreateFile() expected error when parent path contains a file component") + } +} + +func TestCreateFile_existingFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "existing.txt") + + existing, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + existing.Close() + + f, err := CreateFile(path) + if err != nil { + t.Fatalf("CreateFile() on existing file error: %v", err) + } + f.Close() +} diff --git a/src/internal/utils/cobra_ext_test.go b/src/internal/utils/cobra_ext_test.go new file mode 100644 index 0000000..b2d4f37 --- /dev/null +++ b/src/internal/utils/cobra_ext_test.go @@ -0,0 +1,34 @@ +package utils + +import ( + "testing" + + "github.com/spf13/cobra" +) + +func TestMatchOne_allFail(t *testing.T) { + validator := MatchOne(cobra.ExactArgs(1), cobra.MinimumNArgs(2)) + cmd := &cobra.Command{} + err := validator(cmd, []string{}) + if err == nil { + t.Fatal("MatchOne() expected error when all validators fail, got nil") + } +} + +func TestMatchOne_firstSucceeds(t *testing.T) { + validator := MatchOne(cobra.ExactArgs(1), cobra.ExactArgs(2)) + cmd := &cobra.Command{} + err := validator(cmd, []string{"one"}) + if err != nil { + t.Errorf("MatchOne() unexpected error when first validator passes: %v", err) + } +} + +func TestMatchOne_secondSucceeds(t *testing.T) { + validator := MatchOne(cobra.ExactArgs(2), cobra.ExactArgs(1)) + cmd := &cobra.Command{} + err := validator(cmd, []string{"one"}) + if err != nil { + t.Errorf("MatchOne() unexpected error when second validator passes: %v", err) + } +} diff --git a/src/internal/utils/common_test.go b/src/internal/utils/common_test.go new file mode 100644 index 0000000..1244c39 --- /dev/null +++ b/src/internal/utils/common_test.go @@ -0,0 +1,50 @@ +package utils + +import ( + "errors" + "strings" + "testing" +) + +func TestMergeErr_singleError(t *testing.T) { + err := MergeErr([]error{errors.New("only error")}) + if err == nil { + t.Fatal("MergeErr() returned nil, want error") + } + if err.Error() != "only error" { + t.Errorf("MergeErr() = %q, want %q", err.Error(), "only error") + } +} + +func TestMergeErr_multipleErrors(t *testing.T) { + err := MergeErr([]error{errors.New("first"), errors.New("second"), errors.New("third")}) + if err == nil { + t.Fatal("MergeErr() returned nil, want error") + } + msg := err.Error() + for _, sub := range []string{"first", "second", "third"} { + if !strings.Contains(msg, sub) { + t.Errorf("MergeErr() = %q, missing %q", msg, sub) + } + } +} + +func TestYAMLToJSON_validYAML(t *testing.T) { + yaml := strings.NewReader("name: test\nvalue: 42") + result, err := YAMLToJSON(yaml) + if err != nil { + t.Fatalf("YAMLToJSON() error: %v", err) + } + got := string(result) + if !strings.Contains(got, `"name"`) || !strings.Contains(got, "test") { + t.Errorf("YAMLToJSON() = %q, expected JSON with name and test", got) + } +} + +func TestYAMLToJSON_invalidYAML(t *testing.T) { + invalid := strings.NewReader("key: [unclosed") + _, err := YAMLToJSON(invalid) + if err == nil { + t.Fatal("YAMLToJSON() expected error for invalid YAML, got nil") + } +} From 9fcb626ba270ae7dd8667c424aeeae51e58cf325 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 13 Mar 2026 14:09:10 -0700 Subject: [PATCH 15/42] user defined raid subcommands --- schemas/raid-defs.schema.json | 42 ++++++++++++++ schemas/raid-profile.schema.json | 3 + schemas/raid-repo.schema.json | 3 + src/cmd/raid.go | 47 ++++++++++++++++ src/internal/lib/command.go | 96 ++++++++++++++++++++++++++++++++ src/internal/lib/lib.go | 1 + src/internal/lib/profile.go | 1 + src/internal/lib/repo.go | 1 + src/internal/lib/task_runner.go | 11 +++- src/raid/raid.go | 10 ++++ 10 files changed, 213 insertions(+), 2 deletions(-) create mode 100644 src/internal/lib/command.go diff --git a/schemas/raid-defs.schema.json b/schemas/raid-defs.schema.json index aee329e..fa0ea3c 100644 --- a/schemas/raid-defs.schema.json +++ b/schemas/raid-defs.schema.json @@ -239,6 +239,48 @@ } }, "additionalProperties": false + }, + "commands": { + "type": "array", + "description": "Custom commands exposed as top-level raid subcommands", + "minItems": 1, + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Command name used to invoke it via 'raid '" + }, + "usage": { + "type": "string", + "description": "Short description shown in 'raid --help'" + }, + "tasks": { + "$ref": "#/properties/tasks" + }, + "out": { + "type": "object", + "description": "Output configuration for the command", + "properties": { + "stdout": { + "type": "boolean", + "description": "Show stdout from tasks (default: true when out is omitted)" + }, + "stderr": { + "type": "boolean", + "description": "Show stderr from tasks (default: true when out is omitted)" + }, + "file": { + "type": "string", + "description": "Path to additionally write all output to. Supports $VAR expansion." + } + }, + "additionalProperties": false + } + }, + "required": ["name", "tasks"], + "additionalProperties": false + } } }, "$defs": { diff --git a/schemas/raid-profile.schema.json b/schemas/raid-profile.schema.json index 3b5b69c..5629847 100644 --- a/schemas/raid-profile.schema.json +++ b/schemas/raid-profile.schema.json @@ -45,6 +45,9 @@ "additionalProperties": { "$ref": "raid-defs.schema.json#/properties/tasks" } + }, + "commands": { + "$ref": "raid-defs.schema.json#/properties/commands" } }, "required": ["name"] diff --git a/schemas/raid-repo.schema.json b/schemas/raid-repo.schema.json index aad7b5a..1b93c94 100644 --- a/schemas/raid-repo.schema.json +++ b/schemas/raid-repo.schema.json @@ -19,6 +19,9 @@ }, "install": { "$ref": "raid-defs.schema.json#/properties/install" + }, + "commands": { + "$ref": "raid-defs.schema.json#/properties/commands" } }, "required": ["name","branch"] diff --git a/src/cmd/raid.go b/src/cmd/raid.go index 95bbe9e..9b3cd14 100644 --- a/src/cmd/raid.go +++ b/src/cmd/raid.go @@ -2,6 +2,8 @@ package cmd import ( "log" + "os" + "strings" "github.com/8bitalex/raid/src/cmd/env" "github.com/8bitalex/raid/src/cmd/install" @@ -10,6 +12,16 @@ import ( "github.com/spf13/cobra" ) +// reservedNames are built-in cobra subcommands that custom commands cannot shadow. +var reservedNames = map[string]bool{ + "profile": true, + "install": true, + "env": true, + "help": true, + "version": true, + "completion": true, +} + var rootCmd = &cobra.Command{ Use: "raid", Version: "1.0.0-Alpha", @@ -29,7 +41,42 @@ func init() { } func Execute() { + // Pre-initialize before cobra parses args so that profile commands can be + // registered as subcommands. cobra.OnInitialize runs after arg parsing, + // which is too late for dynamic subcommand registration. + applyConfigFlag(os.Args) + raid.Initialize() + + for _, cmd := range raid.GetCommands() { + if reservedNames[cmd.Name] { + continue + } + name := cmd.Name + rootCmd.AddCommand(&cobra.Command{ + Use: name, + Short: cmd.Usage, + RunE: func(c *cobra.Command, args []string) error { + return raid.ExecuteCommand(name) + }, + }) + } + if err := rootCmd.Execute(); err != nil { log.Fatalf("Failed to execute root command: %v", err) } } + +// applyConfigFlag scans args for --config / -c so the config path is set +// before the pre-initialization call, matching cobra's later flag parsing. +func applyConfigFlag(args []string) { + for i, arg := range args { + if (arg == "--config" || arg == "-c") && i+1 < len(args) { + *raid.ConfigPath = args[i+1] + return + } + if strings.HasPrefix(arg, "--config=") { + *raid.ConfigPath = strings.TrimPrefix(arg, "--config=") + return + } + } +} diff --git a/src/internal/lib/command.go b/src/internal/lib/command.go new file mode 100644 index 0000000..a5f194b --- /dev/null +++ b/src/internal/lib/command.go @@ -0,0 +1,96 @@ +package lib + +import ( + "fmt" + "io" + "os" + + "github.com/8bitalex/raid/src/internal/sys" +) + +// Command is a named, user-defined CLI command that can be invoked via 'raid '. +type Command struct { + Name string `json:"name"` + Usage string `json:"usage"` + Tasks []Task `json:"tasks"` + Out *Output `json:"out,omitempty"` +} + +// Output configures how a command's task output is handled. +// Stdout and Stderr default to true when Out is nil. +// When Out is set, only streams explicitly set to true are shown. +type Output struct { + Stdout bool `json:"stdout"` + Stderr bool `json:"stderr"` + File string `json:"file,omitempty"` +} + +// IsZero reports whether the command is uninitialized. +func (c Command) IsZero() bool { + return c.Name == "" +} + +// GetCommands returns all commands available in the active profile. +func GetCommands() []Command { + if context == nil { + return nil + } + return context.Profile.Commands +} + +// ExecuteCommand runs the tasks for the named command, applying any output configuration. +func ExecuteCommand(name string) error { + for _, cmd := range GetCommands() { + if cmd.Name == name { + return runCommand(cmd) + } + } + return fmt.Errorf("command '%s' not found", name) +} + +func runCommand(cmd Command) error { + if cmd.Out == nil { + return ExecuteTasks(cmd.Tasks) + } + + origOut, origErr := commandStdout, commandStderr + defer func() { + commandStdout = origOut + commandStderr = origErr + }() + + if !cmd.Out.Stdout { + commandStdout = io.Discard + } + if !cmd.Out.Stderr { + commandStderr = io.Discard + } + + if cmd.Out.File != "" { + expanded := sys.ExpandPath(cmd.Out.File) + f, err := os.Create(expanded) + if err != nil { + return fmt.Errorf("failed to open output file '%s': %w", cmd.Out.File, err) + } + defer f.Close() + commandStdout = io.MultiWriter(commandStdout, f) + commandStderr = io.MultiWriter(commandStderr, f) + } + + return ExecuteTasks(cmd.Tasks) +} + +// mergeCommands merges additional into base. On name conflicts, base takes priority. +func mergeCommands(base, additional []Command) []Command { + existing := make(map[string]bool, len(base)) + for _, c := range base { + existing[c.Name] = true + } + result := append([]Command(nil), base...) + for _, c := range additional { + if !existing[c.Name] { + result = append(result, c) + } + } + return result +} diff --git a/src/internal/lib/lib.go b/src/internal/lib/lib.go index 67f53c8..6d3ea58 100644 --- a/src/internal/lib/lib.go +++ b/src/internal/lib/lib.go @@ -58,6 +58,7 @@ func ForceLoad() error { if err := buildRepo(&profile.Repositories[i]); err != nil { return err } + profile.Commands = mergeCommands(profile.Commands, profile.Repositories[i].Commands) } context = &Context{ diff --git a/src/internal/lib/profile.go b/src/internal/lib/profile.go index ff67ab8..f3f1a58 100644 --- a/src/internal/lib/profile.go +++ b/src/internal/lib/profile.go @@ -26,6 +26,7 @@ type Profile struct { Environments []Env `json:"environments"` Install OnInstall `json:"install"` Groups map[string][]Task `json:"groups"` + Commands []Command `json:"commands"` } // IsZero reports whether the profile is uninitialized. diff --git a/src/internal/lib/repo.go b/src/internal/lib/repo.go index 405ad42..38a1b49 100644 --- a/src/internal/lib/repo.go +++ b/src/internal/lib/repo.go @@ -19,6 +19,7 @@ type Repo struct { URL string `json:"url"` Environments []Env `json:"environments"` Install OnInstall `json:"install"` + Commands []Command `json:"commands"` } // IsZero reports whether the repo is uninitialized. diff --git a/src/internal/lib/task_runner.go b/src/internal/lib/task_runner.go index d27b723..ad8a550 100644 --- a/src/internal/lib/task_runner.go +++ b/src/internal/lib/task_runner.go @@ -16,6 +16,13 @@ import ( "github.com/8bitalex/raid/src/internal/sys" ) +// commandStdout and commandStderr are the output writers used by task execution. +// ExecuteCommand replaces these temporarily when a command's Out field is set. +var ( + commandStdout io.Writer = os.Stdout + commandStderr io.Writer = os.Stderr +) + var colorCodes = map[string]string{ "red": "\033[31m", "green": "\033[32m", @@ -199,8 +206,8 @@ func execScript(task Task) error { } func setCmdOutput(cmd *exec.Cmd) { - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr + cmd.Stdout = commandStdout + cmd.Stderr = commandStderr cmd.Stdin = os.Stdin } diff --git a/src/raid/raid.go b/src/raid/raid.go index 03bfe19..7d46f4a 100644 --- a/src/raid/raid.go +++ b/src/raid/raid.go @@ -61,3 +61,13 @@ func ForceLoad() error { func Install(maxThreads int) error { return lib.Install(maxThreads) } + +// GetCommands returns all commands available in the active profile. +func GetCommands() []lib.Command { + return lib.GetCommands() +} + +// ExecuteCommand runs the named command from the active profile. +func ExecuteCommand(name string) error { + return lib.ExecuteCommand(name) +} From c059452048d9f42dcd6695702f5a7bf72d6bed8b Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 13 Mar 2026 14:14:22 -0700 Subject: [PATCH 16/42] command tests --- src/cmd/raid_test.go | 60 +++++++ src/internal/lib/command_test.go | 259 +++++++++++++++++++++++++++++++ src/internal/lib/lib_test.go | 40 +++++ src/internal/lib/repo.go | 1 + 4 files changed, 360 insertions(+) create mode 100644 src/cmd/raid_test.go create mode 100644 src/internal/lib/command_test.go diff --git a/src/cmd/raid_test.go b/src/cmd/raid_test.go new file mode 100644 index 0000000..3efd12a --- /dev/null +++ b/src/cmd/raid_test.go @@ -0,0 +1,60 @@ +package cmd + +import ( + "testing" + + "github.com/8bitalex/raid/src/raid" +) + +func TestApplyConfigFlag(t *testing.T) { + tests := []struct { + name string + args []string + wantPath string + }{ + { + name: "no config flag", + args: []string{"raid", "install"}, + wantPath: "", + }, + { + name: "long flag with separate value", + args: []string{"raid", "--config", "/custom/config.toml", "install"}, + wantPath: "/custom/config.toml", + }, + { + name: "long flag with equals", + args: []string{"raid", "--config=/custom/config.toml"}, + wantPath: "/custom/config.toml", + }, + { + name: "short flag", + args: []string{"raid", "-c", "/custom/config.toml"}, + wantPath: "/custom/config.toml", + }, + { + name: "config flag at end with no value", + args: []string{"raid", "--config"}, + wantPath: "", + }, + { + name: "config flag before subcommand", + args: []string{"raid", "--config", "/path.toml", "env", "list"}, + wantPath: "/path.toml", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + old := *raid.ConfigPath + *raid.ConfigPath = "" + t.Cleanup(func() { *raid.ConfigPath = old }) + + applyConfigFlag(tt.args) + + if got := *raid.ConfigPath; got != tt.wantPath { + t.Errorf("applyConfigFlag() ConfigPath = %q, want %q", got, tt.wantPath) + } + }) + } +} diff --git a/src/internal/lib/command_test.go b/src/internal/lib/command_test.go new file mode 100644 index 0000000..5203630 --- /dev/null +++ b/src/internal/lib/command_test.go @@ -0,0 +1,259 @@ +package lib + +import ( + "bytes" + "io" + "os" + "path/filepath" + "strings" + "testing" +) + +// --- Command.IsZero --- + +func TestCommandIsZero(t *testing.T) { + tests := []struct { + name string + cmd Command + want bool + }{ + {"empty", Command{}, true}, + {"name only", Command{Name: "build"}, false}, + {"name and usage", Command{Name: "build", Usage: "Build services"}, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.cmd.IsZero(); got != tt.want { + t.Errorf("IsZero() = %v, want %v", got, tt.want) + } + }) + } +} + +// --- GetCommands --- + +func TestGetCommands_nilContext(t *testing.T) { + setupTestConfig(t) + if got := GetCommands(); got != nil { + t.Errorf("GetCommands() with nil context = %v, want nil", got) + } +} + +func TestGetCommands_withCommands(t *testing.T) { + setupTestConfig(t) + context = &Context{ + Profile: Profile{ + Commands: []Command{ + {Name: "build", Usage: "Build services"}, + {Name: "test", Usage: "Run tests"}, + }, + }, + } + + got := GetCommands() + if len(got) != 2 { + t.Fatalf("GetCommands() = %d commands, want 2", len(got)) + } + if got[0].Name != "build" || got[1].Name != "test" { + t.Errorf("GetCommands() names = %v/%v, want build/test", got[0].Name, got[1].Name) + } +} + +// --- ExecuteCommand --- + +func TestExecuteCommand_notFound(t *testing.T) { + setupTestConfig(t) + context = &Context{ + Profile: Profile{ + Commands: []Command{{Name: "other", Tasks: []Task{{Type: Shell, Cmd: "exit 0"}}}}, + }, + } + + if err := ExecuteCommand("nonexistent"); err == nil { + t.Fatal("ExecuteCommand() expected error for unknown command, got nil") + } +} + +func TestExecuteCommand_success(t *testing.T) { + setupTestConfig(t) + origOut := commandStdout + commandStdout = io.Discard + t.Cleanup(func() { commandStdout = origOut }) + + context = &Context{ + Profile: Profile{ + Commands: []Command{{Name: "noop", Tasks: []Task{{Type: Shell, Cmd: "exit 0"}}}}, + }, + } + + if err := ExecuteCommand("noop"); err != nil { + t.Errorf("ExecuteCommand() error: %v", err) + } +} + +func TestExecuteCommand_taskFailure(t *testing.T) { + setupTestConfig(t) + context = &Context{ + Profile: Profile{ + Commands: []Command{{Name: "fail", Tasks: []Task{{Type: Shell, Cmd: "exit 1"}}}}, + }, + } + + if err := ExecuteCommand("fail"); err == nil { + t.Fatal("ExecuteCommand() expected error from failing task, got nil") + } +} + +// --- runCommand --- + +func TestRunCommand_nilOut(t *testing.T) { + origOut := commandStdout + commandStdout = io.Discard + t.Cleanup(func() { commandStdout = origOut }) + + cmd := Command{Name: "noop", Tasks: []Task{{Type: Shell, Cmd: "exit 0"}}} + if err := runCommand(cmd); err != nil { + t.Errorf("runCommand() with nil Out error: %v", err) + } +} + +func TestRunCommand_suppressOutput(t *testing.T) { + // Replace writers with buffers so we can verify nothing was written. + bufOut := &bytes.Buffer{} + bufErr := &bytes.Buffer{} + origOut, origErr := commandStdout, commandStderr + commandStdout, commandStderr = bufOut, bufErr + t.Cleanup(func() { + commandStdout = origOut + commandStderr = origErr + }) + + cmd := Command{ + Name: "silent", + Tasks: []Task{{Type: Shell, Cmd: "echo suppressed"}}, + Out: &Output{Stdout: false, Stderr: false}, + } + if err := runCommand(cmd); err != nil { + t.Fatalf("runCommand() error: %v", err) + } + + if bufOut.Len() > 0 { + t.Errorf("commandStdout received %q, want nothing (stdout suppressed)", bufOut.String()) + } +} + +func TestRunCommand_restoresWriters(t *testing.T) { + // Set sentinel writers and confirm they are restored after runCommand. + bufOut := &bytes.Buffer{} + bufErr := &bytes.Buffer{} + origOut, origErr := commandStdout, commandStderr + commandStdout, commandStderr = bufOut, bufErr + t.Cleanup(func() { + commandStdout = origOut + commandStderr = origErr + }) + + cmd := Command{ + Name: "restore-check", + Tasks: []Task{{Type: Shell, Cmd: "exit 0"}}, + Out: &Output{Stdout: false, Stderr: false}, + } + _ = runCommand(cmd) + + if commandStdout != bufOut { + t.Error("commandStdout not restored to original writer after runCommand") + } + if commandStderr != bufErr { + t.Error("commandStderr not restored to original writer after runCommand") + } +} + +func TestRunCommand_fileOutput(t *testing.T) { + // Silence normal output so the test stays clean. + origOut, origErr := commandStdout, commandStderr + commandStdout, commandStderr = io.Discard, io.Discard + t.Cleanup(func() { + commandStdout = origOut + commandStderr = origErr + }) + + outFile := filepath.Join(t.TempDir(), "output.txt") + cmd := Command{ + Name: "file-out", + Tasks: []Task{{Type: Shell, Cmd: "echo filetest"}}, + Out: &Output{Stdout: true, Stderr: false, File: outFile}, + } + if err := runCommand(cmd); err != nil { + t.Fatalf("runCommand() error: %v", err) + } + + data, err := os.ReadFile(outFile) + if err != nil { + t.Fatalf("ReadFile(%q) error: %v", outFile, err) + } + if !strings.Contains(string(data), "filetest") { + t.Errorf("output file = %q, want to contain 'filetest'", string(data)) + } +} + +func TestRunCommand_fileCreateError(t *testing.T) { + // A parent directory that doesn't exist causes os.Create to fail. + outFile := filepath.Join(t.TempDir(), "nonexistent", "output.txt") + cmd := Command{ + Name: "bad-file", + Tasks: []Task{{Type: Shell, Cmd: "exit 0"}}, + Out: &Output{Stdout: true, File: outFile}, + } + if err := runCommand(cmd); err == nil { + t.Fatal("runCommand() expected error for bad output file path, got nil") + } +} + +// --- mergeCommands --- + +func TestMergeCommands(t *testing.T) { + a := Command{Name: "a", Usage: "from-a"} + b := Command{Name: "b"} + c := Command{Name: "c"} + aAlt := Command{Name: "a", Usage: "from-alt"} + + tests := []struct { + name string + base []Command + additional []Command + wantNames []string + wantUsage map[string]string // optional spot-check + }{ + {"both nil", nil, nil, nil, nil}, + {"empty additional", []Command{a, b}, nil, []string{"a", "b"}, nil}, + {"empty base", nil, []Command{c}, []string{"c"}, nil}, + {"no conflicts", []Command{a}, []Command{b}, []string{"a", "b"}, nil}, + { + "conflict: base wins", + []Command{a}, + []Command{aAlt, c}, + []string{"a", "c"}, + map[string]string{"a": "from-a"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := mergeCommands(tt.base, tt.additional) + + if len(got) != len(tt.wantNames) { + t.Fatalf("mergeCommands() len = %d, want %d", len(got), len(tt.wantNames)) + } + for i, cmd := range got { + if cmd.Name != tt.wantNames[i] { + t.Errorf("result[%d].Name = %q, want %q", i, cmd.Name, tt.wantNames[i]) + } + if tt.wantUsage != nil { + if expected, ok := tt.wantUsage[cmd.Name]; ok && cmd.Usage != expected { + t.Errorf("result[%d].Usage = %q, want %q", i, cmd.Usage, expected) + } + } + } + }) + } +} diff --git a/src/internal/lib/lib_test.go b/src/internal/lib/lib_test.go index 2b083f1..899bbd1 100644 --- a/src/internal/lib/lib_test.go +++ b/src/internal/lib/lib_test.go @@ -381,3 +381,43 @@ func TestInstall_repoInstallTaskFailure(t *testing.T) { t.Fatal("Install() expected error from failing repo install task") } } + +func TestForceLoad_mergesRepoCommands(t *testing.T) { + root := repoRoot(t) + setupTestConfig(t) + + dir := t.TempDir() + repoDir := filepath.Join(dir, "myrepo") + os.MkdirAll(filepath.Join(repoDir, ".git"), 0755) + + // repo raid.yaml defines a command "repo-cmd" alongside the required fields. + repoYAML := "name: myrepo\nbranch: main\ncommands:\n - name: repo-cmd\n tasks:\n - type: Shell\n cmd: exit 0\n" + os.WriteFile(filepath.Join(repoDir, RaidConfigFileName), []byte(repoYAML), 0644) + + profileContent := "name: mergetest\nrepositories:\n - name: myrepo\n path: " + repoDir + "\n url: http://example.com/repo.git\ncommands:\n - name: profile-cmd\n tasks:\n - type: Shell\n cmd: exit 0\n" + profilePath := filepath.Join(dir, "profile.yaml") + os.WriteFile(profilePath, []byte(profileContent), 0644) + + wd, _ := os.Getwd() + os.Chdir(root) + defer os.Chdir(wd) + + AddProfile(Profile{Name: "mergetest", Path: profilePath}) + SetProfile("mergetest") + + if err := ForceLoad(); err != nil { + t.Fatalf("ForceLoad() error: %v", err) + } + + cmds := GetCommands() + names := make(map[string]bool, len(cmds)) + for _, c := range cmds { + names[c.Name] = true + } + if !names["profile-cmd"] { + t.Error("GetCommands() missing 'profile-cmd' from profile") + } + if !names["repo-cmd"] { + t.Error("GetCommands() missing 'repo-cmd' merged from repo") + } +} diff --git a/src/internal/lib/repo.go b/src/internal/lib/repo.go index 38a1b49..6a20599 100644 --- a/src/internal/lib/repo.go +++ b/src/internal/lib/repo.go @@ -57,6 +57,7 @@ func buildRepo(repo *Repo) error { repo.Environments = append(repo.Environments, repoConfig.Environments...) repo.Install.Tasks = append(repo.Install.Tasks, repoConfig.Install.Tasks...) + repo.Commands = append(repo.Commands, repoConfig.Commands...) return nil } From f4a973a66b1ae953a356727299de9c4eb2efb88e Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 13 Mar 2026 15:13:02 -0700 Subject: [PATCH 17/42] set task path --- README.md | 72 +++++++++++++++++++++- schemas/raid-defs.schema.json | 6 +- src/internal/lib/env.go | 2 +- src/internal/lib/lib.go | 16 ++++- src/internal/lib/task.go | 2 - src/internal/lib/task_runner.go | 22 ++++++- src/internal/lib/task_runner_extra_test.go | 6 +- src/internal/lib/task_runner_test.go | 16 ++--- 8 files changed, 120 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index a5d4e2c..0369981 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Tribal knowledge codified into the repo itself — onboarding becomes a single c - **Multiple Profiles** — switch between project setups or team configurations with isolated profiles. - **Rich Task Runner** — 12 built-in task types covering shell commands, scripts, HTTP downloads, service health checks, git operations, template rendering, user prompts, and more. - **Environment Management** — define and apply consistent development environments for all contributors. -- **Custom Commands** — codify repeated operational tasks (patch, proxy, verify, deploy) as first-class raid commands runnable from anywhere. +- **Custom Commands** — codify repeated operational tasks (patch, proxy, verify, deploy) as first-class `raid ` subcommands that live alongside your configuration. ## Development Status @@ -70,6 +70,17 @@ Clone all repositories in the active profile and run any configured install task - `raid env` — show the currently active environment - `raid env list` — list available environments +### `raid ` + +Run a custom command defined in the active profile or any of its repositories. + +```bash +raid build # run the "build" command +raid deploy # run the "deploy" command +``` + +Custom commands appear alongside built-in commands in `raid --help`. Commands defined in a profile take priority over same-named commands from repositories. + --- ## Configuration @@ -123,6 +134,19 @@ groups: - type: Wait url: localhost:5432 timeout: 10s + +commands: + - name: sync + usage: "Pull latest on all repos and restart services" + tasks: + - type: Git + op: pull + path: ~/Developer/frontend + - type: Git + op: pull + path: ~/Developer/backend + - type: Shell + cmd: docker compose restart ``` Multiple profiles can be defined in a single file using YAML document separators (`---`) or a JSON array. @@ -144,6 +168,13 @@ environments: cmd: npm install - type: Shell cmd: npm run build + +commands: + - name: test + usage: "Run the test suite" + tasks: + - type: Shell + cmd: npm test ``` --- @@ -169,6 +200,7 @@ Run a command string in a configurable shell. cmd: echo "hello $USER" shell: bash # optional: bash (default), sh, zsh, powershell, cmd literal: false # optional: skip env var expansion before passing to shell + path: ~/project # optional: working directory. Defaults to ~ for profile tasks, repo dir for repo tasks ``` ### Script @@ -237,7 +269,7 @@ Perform a git operation in a repository directory. - type: Git op: pull # pull, checkout, fetch, reset branch: main # required for checkout; optional for pull, fetch, reset - dir: ~/Developer/myrepo # optional, defaults to current directory + path: ~/Developer/myrepo # optional, defaults to current directory ``` ### Print @@ -284,11 +316,45 @@ Re-run a group of tasks on failure, up to a configurable number of attempts. --- +## Commands Configuration + +Custom commands are defined in the `commands` array of a profile or repository `raid.yaml`. They become first-class `raid ` subcommands at runtime. + +```yaml +commands: + - name: deploy + usage: "Build and deploy all services" # shown in raid --help + tasks: + - type: Confirm + message: "Deploy to production?" + - type: Shell + cmd: make deploy + out: # optional — defaults to full stdout+stderr when omitted + stdout: true + stderr: false + file: $DEPLOY_LOG # also write all output here; supports $VAR expansion +``` + +**`name`** (required) — the subcommand name; e.g. `name: deploy` is invoked as `raid deploy`. Cannot shadow built-in names (`profile`, `install`, `env`). + +**`usage`** (optional) — short description shown next to the command in `raid --help`. + +**`tasks`** (required) — the task sequence to run. All standard task types are supported. + +**`out`** (optional) — controls output handling. When omitted, stdout and stderr behave normally. When present: +- `stdout` — show task stdout (default: `true` when `out` is omitted; set explicitly when using `out`) +- `stderr` — show task stderr (default: `true` when `out` is omitted; set explicitly when using `out`) +- `file` — additionally write all output to this path; supports `$VAR` expansion + +**Priority** — when a profile and one of its repositories define a command with the same name, the profile's definition wins. + +--- + ## Best Practices **Commit `raid.yaml` to each repo.** This is how setup knowledge gets shared — anyone with raid can run `raid install` and get a working environment without reading a wiki. -**Use `groups` to build custom commands.** Define reusable task sequences under `groups` and reference them with `Group`, `Parallel`, or `Retry`. This lets teams codify repeated workflows (patch, proxy, verify, deploy) that anyone can run with a single command. +**Use `commands` to codify team workflows.** Repeated operational tasks — patching, proxying, deploying, verifying — belong in `commands`, not in Slack messages or shared scripts. Anyone on the team can run `raid deploy` without knowing the steps. Use `groups` for reusable internal sequences that commands and other tasks compose from. **Gate destructive steps with `Confirm`.** Any task sequence that resets data, force-pushes, or modifies production should begin with a `Confirm` task to prevent accidental runs. diff --git a/schemas/raid-defs.schema.json b/schemas/raid-defs.schema.json index fa0ea3c..f75b6f8 100644 --- a/schemas/raid-defs.schema.json +++ b/schemas/raid-defs.schema.json @@ -29,6 +29,10 @@ "type": "boolean", "description": "Pass the command to the shell without prior env var expansion", "default": false + }, + "path": { + "type": "string", + "description": "Working directory for the command. Defaults to ~ for profile tasks, the repo directory for repo tasks." } }, "required": ["type", "cmd"], @@ -109,7 +113,7 @@ "description": "Git operation to perform" }, "branch": { "type": "string", "description": "Target branch (required for checkout, optional for others)" }, - "dir": { "type": "string", "description": "Path to the git repository. Defaults to the current working directory." } + "path": { "type": "string", "description": "Path to the git repository. Defaults to the current working directory." } }, "required": ["type", "op"], "additionalProperties": false diff --git a/src/internal/lib/env.go b/src/internal/lib/env.go index e75a6f2..8e18801 100644 --- a/src/internal/lib/env.go +++ b/src/internal/lib/env.go @@ -124,7 +124,7 @@ func runTasksForEnv(name string) error { if env.IsZero() || len(env.Tasks) == 0 { return nil } - return ExecuteTasks(env.Tasks) + return ExecuteTasks(withDefaultDir(env.Tasks, sys.GetHomeDir())) } // LoadEnv loads .env files from all repositories in the active profile into the process environment. diff --git a/src/internal/lib/lib.go b/src/internal/lib/lib.go index 6d3ea58..b2cfc19 100644 --- a/src/internal/lib/lib.go +++ b/src/internal/lib/lib.go @@ -54,11 +54,21 @@ func ForceLoad() error { return err } + homeDir := sys.GetHomeDir() + for i := range profile.Commands { + profile.Commands[i].Tasks = withDefaultDir(profile.Commands[i].Tasks, homeDir) + } + for i := range profile.Repositories { if err := buildRepo(&profile.Repositories[i]); err != nil { return err } - profile.Commands = mergeCommands(profile.Commands, profile.Repositories[i].Commands) + repo := &profile.Repositories[i] + repoDir := sys.ExpandPath(repo.Path) + for j := range repo.Commands { + repo.Commands[j].Tasks = withDefaultDir(repo.Commands[j].Tasks, repoDir) + } + profile.Commands = mergeCommands(profile.Commands, repo.Commands) } context = &Context{ @@ -111,13 +121,13 @@ func Install(maxThreads int) error { return fmt.Errorf("some repositories failed to install: %v", errors) } - if err := ExecuteTasks(profile.Install.Tasks); err != nil { + if err := ExecuteTasks(withDefaultDir(profile.Install.Tasks, sys.GetHomeDir())); err != nil { return fmt.Errorf("failed to execute install tasks: %w", err) } var repoTasks []Task for _, r := range profile.Repositories { - repoTasks = append(repoTasks, r.Install.Tasks...) + repoTasks = append(repoTasks, withDefaultDir(r.Install.Tasks, sys.ExpandPath(r.Path))...) } if err := ExecuteTasks(repoTasks); err != nil { return fmt.Errorf("failed to execute repository install tasks: %w", err) diff --git a/src/internal/lib/task.go b/src/internal/lib/task.go index b8d0d16..f8518e6 100644 --- a/src/internal/lib/task.go +++ b/src/internal/lib/task.go @@ -42,7 +42,6 @@ type Task struct { // Git Op string `json:"op,omitempty"` Branch string `json:"branch,omitempty"` - Dir string `json:"dir,omitempty"` // Prompt / Confirm / Print Message string `json:"message,omitempty"` // Prompt @@ -78,7 +77,6 @@ func (t Task) Expand() Task { Ref: t.Ref, Op: t.Op, Branch: sys.Expand(t.Branch), - Dir: sys.Expand(t.Dir), Message: sys.Expand(t.Message), Var: t.Var, Default: sys.Expand(t.Default), diff --git a/src/internal/lib/task_runner.go b/src/internal/lib/task_runner.go index ad8a550..cf58031 100644 --- a/src/internal/lib/task_runner.go +++ b/src/internal/lib/task_runner.go @@ -142,6 +142,9 @@ func execShell(task Task) error { shell := getShell(task.Shell) cmd := exec.Command(shell[0], append(shell[1:], task.Cmd)...) + if task.Path != "" { + cmd.Dir = sys.ExpandPath(task.Path) + } if !task.Concurrent { setCmdOutput(cmd) } @@ -356,7 +359,7 @@ func execGit(task Task) error { return fmt.Errorf("op is required for Git task") } - dir := task.Dir + dir := task.Path if dir == "" { var err error dir, err = os.Getwd() @@ -535,3 +538,20 @@ func execRetry(task Task) error { return fmt.Errorf("all %d attempts failed: %w", attempts, lastErr) } + +// withDefaultDir returns a copy of tasks with path set to dir on any Shell task +// that does not already have an explicit path. Used to apply profile-level (home) +// and repository-level (repo path) defaults without modifying the original slice. +func withDefaultDir(tasks []Task, dir string) []Task { + if dir == "" || len(tasks) == 0 { + return tasks + } + result := make([]Task, len(tasks)) + for i, t := range tasks { + if t.Type.ToLower() == Shell && t.Path == "" { + t.Path = dir + } + result[i] = t + } + return result +} diff --git a/src/internal/lib/task_runner_extra_test.go b/src/internal/lib/task_runner_extra_test.go index 60579da..2d129f5 100644 --- a/src/internal/lib/task_runner_extra_test.go +++ b/src/internal/lib/task_runner_extra_test.go @@ -104,7 +104,7 @@ func TestExecuteTask_retry_groupNotFound(t *testing.T) { func TestExecuteTask_git_checkoutNoBranch(t *testing.T) { dir := t.TempDir() - task := Task{Type: Git, Op: "checkout", Dir: dir} + task := Task{Type: Git, Op: "checkout", Path: dir} err := ExecuteTask(task) if err == nil { t.Fatal("expected error for checkout without branch") @@ -118,12 +118,12 @@ func TestExecuteTask_git_pullWithBranch(t *testing.T) { // Exercises the `args = append(args, "origin", task.Branch)` branch. // Git will fail (not a real repo), but the code path is covered. dir := t.TempDir() - _ = ExecuteTask(Task{Type: Git, Op: "pull", Branch: "main", Dir: dir}) + _ = ExecuteTask(Task{Type: Git, Op: "pull", Branch: "main", Path: dir}) } func TestExecuteTask_git_fetchWithBranch(t *testing.T) { dir := t.TempDir() - _ = ExecuteTask(Task{Type: Git, Op: "fetch", Branch: "main", Dir: dir}) + _ = ExecuteTask(Task{Type: Git, Op: "fetch", Branch: "main", Path: dir}) } // --- execHTTP error paths --- diff --git a/src/internal/lib/task_runner_test.go b/src/internal/lib/task_runner_test.go index aec1cf7..efd71de 100644 --- a/src/internal/lib/task_runner_test.go +++ b/src/internal/lib/task_runner_test.go @@ -830,37 +830,37 @@ func TestExecuteTask_git(t *testing.T) { }{ { name: "missing op", - task: Task{Type: Git, Dir: dir}, + task: Task{Type: Git, Path: dir}, wantErr: true, }, { name: "invalid op", - task: Task{Type: Git, Op: "push", Dir: dir}, + task: Task{Type: Git, Op: "push", Path: dir}, wantErr: true, }, { - name: "dir does not exist", - task: Task{Type: Git, Op: "pull", Dir: "/nonexistent/path"}, + name: "path does not exist", + task: Task{Type: Git, Op: "pull", Path: "/nonexistent/path"}, wantErr: true, }, { name: "fetch with no remote succeeds", - task: Task{Type: Git, Op: "fetch", Dir: dir}, + task: Task{Type: Git, Op: "fetch", Path: dir}, wantErr: false, }, { name: "checkout nonexistent branch fails", - task: Task{Type: Git, Op: "checkout", Branch: "nonexistent-branch-xyz", Dir: dir}, + task: Task{Type: Git, Op: "checkout", Branch: "nonexistent-branch-xyz", Path: dir}, wantErr: true, }, { name: "reset hard HEAD succeeds", - task: Task{Type: Git, Op: "reset", Branch: "HEAD", Dir: dir}, + task: Task{Type: Git, Op: "reset", Branch: "HEAD", Path: dir}, wantErr: false, }, { name: "type is case-insensitive", - task: Task{Type: "GIT", Op: "fetch", Dir: dir}, + task: Task{Type: "GIT", Op: "fetch", Path: dir}, wantErr: false, }, } From cbfeb2ae1e94ee6fa59de1474ae909624a7f0e96 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 13 Mar 2026 15:32:48 -0700 Subject: [PATCH 18/42] support multi platform --- src/internal/lib/config.go | 6 ++-- src/internal/lib/env.go | 5 +-- src/internal/lib/task_runner.go | 15 ++++---- src/internal/lib/task_runner_test.go | 53 +++++++++++++++------------- src/internal/sys/system.go | 4 +-- 5 files changed, 45 insertions(+), 38 deletions(-) diff --git a/src/internal/lib/config.go b/src/internal/lib/config.go index 0ee715f..a0486de 100644 --- a/src/internal/lib/config.go +++ b/src/internal/lib/config.go @@ -1,6 +1,8 @@ package lib import ( + "path/filepath" + sys "github.com/8bitalex/raid/src/internal/sys" "github.com/spf13/viper" ) @@ -16,7 +18,7 @@ const ( var CfgPath string -var defaultConfigPath = sys.GetHomeDir() + sys.Sep + ConfigDirName + sys.Sep +var defaultConfigPath = filepath.Join(sys.GetHomeDir(), ConfigDirName) func InitConfig() error { viper.SetConfigFile(getOrCreateConfigFile()) @@ -36,7 +38,7 @@ func getOrCreateConfigFile() string { func getPath() string { if CfgPath == "" { - CfgPath = defaultConfigPath + ConfigFileName + CfgPath = filepath.Join(defaultConfigPath, ConfigFileName) } return CfgPath } diff --git a/src/internal/lib/env.go b/src/internal/lib/env.go index 8e18801..f2625bc 100644 --- a/src/internal/lib/env.go +++ b/src/internal/lib/env.go @@ -2,6 +2,7 @@ package lib import ( "fmt" + "path/filepath" sys "github.com/8bitalex/raid/src/internal/sys" "github.com/joho/godotenv" @@ -94,7 +95,7 @@ func setEnvVariablesForRepos(name string) error { } func buildEnvPath(path string) (string, error) { - filePath := sys.ExpandPath(path) + sys.Sep + ".env" + filePath := filepath.Join(sys.ExpandPath(path), ".env") file, err := sys.CreateFile(filePath) if err != nil { return "", err @@ -135,7 +136,7 @@ func LoadEnv() error { var paths []string for _, r := range context.Profile.Repositories { - p := sys.ExpandPath(r.Path) + sys.Sep + ".env" + p := filepath.Join(sys.ExpandPath(r.Path), ".env") if sys.FileExists(p) { paths = append(paths, p) } diff --git a/src/internal/lib/task_runner.go b/src/internal/lib/task_runner.go index cf58031..43b927e 100644 --- a/src/internal/lib/task_runner.go +++ b/src/internal/lib/task_runner.go @@ -162,23 +162,22 @@ func getShell(shell string) []string { if sys.GetPlatform() == sys.Windows { return []string{"cmd", "/c"} } - return []string{"/bin/bash", "-c"} + return []string{"bash", "-c"} } - shell = strings.ToLower(shell) - switch shell { + switch strings.ToLower(shell) { case "/bin/bash", "bash": - return []string{"/bin/bash", "-c"} + return []string{"bash", "-c"} case "/bin/sh", "sh": - return []string{"/bin/sh", "-c"} + return []string{"sh", "-c"} case "/bin/zsh", "zsh": - return []string{"/bin/zsh", "-c"} + return []string{"zsh", "-c"} case "powershell", "pwsh", "ps": - return []string{"powershell"} + return []string{"powershell", "-Command"} case "cmd": return []string{"cmd", "/c"} default: - return []string{"/bin/bash", "-c"} + return []string{"bash", "-c"} } } diff --git a/src/internal/lib/task_runner_test.go b/src/internal/lib/task_runner_test.go index efd71de..16c0e78 100644 --- a/src/internal/lib/task_runner_test.go +++ b/src/internal/lib/task_runner_test.go @@ -18,15 +18,15 @@ func TestGetShell(t *testing.T) { input string want []string }{ - {"bash", []string{"/bin/bash", "-c"}}, - {"/bin/bash", []string{"/bin/bash", "-c"}}, - {"sh", []string{"/bin/sh", "-c"}}, - {"/bin/sh", []string{"/bin/sh", "-c"}}, - {"zsh", []string{"/bin/zsh", "-c"}}, - {"/bin/zsh", []string{"/bin/zsh", "-c"}}, - {"BASH", []string{"/bin/bash", "-c"}}, // case-insensitive - {"unknown", []string{"/bin/bash", "-c"}}, - {"", []string{"/bin/bash", "-c"}}, // default on non-Windows + {"bash", []string{"bash", "-c"}}, + {"/bin/bash", []string{"bash", "-c"}}, + {"sh", []string{"sh", "-c"}}, + {"/bin/sh", []string{"sh", "-c"}}, + {"zsh", []string{"zsh", "-c"}}, + {"/bin/zsh", []string{"zsh", "-c"}}, + {"BASH", []string{"bash", "-c"}}, // case-insensitive + {"unknown", []string{"bash", "-c"}}, + {"", []string{"bash", "-c"}}, // default on non-Windows } for _, tt := range tests { @@ -46,19 +46,24 @@ func TestGetShell(t *testing.T) { func TestGetShell_powershell(t *testing.T) { tests := []struct { - input string - wantFirst string + input string + want []string }{ - {"powershell", "powershell"}, - {"pwsh", "powershell"}, - {"ps", "powershell"}, + {"powershell", []string{"powershell", "-Command"}}, + {"pwsh", []string{"powershell", "-Command"}}, + {"ps", []string{"powershell", "-Command"}}, } for _, tt := range tests { t.Run(tt.input, func(t *testing.T) { got := getShell(tt.input) - if got[0] != tt.wantFirst { - t.Errorf("getShell(%q)[0] = %q, want %q", tt.input, got[0], tt.wantFirst) + if len(got) != len(tt.want) { + t.Fatalf("getShell(%q) = %v, want %v", tt.input, got, tt.want) + } + for i := range got { + if got[i] != tt.want[i] { + t.Errorf("getShell(%q)[%d] = %q, want %q", tt.input, i, got[i], tt.want[i]) + } } }) } @@ -327,7 +332,7 @@ func TestExecuteTasks_sequentialFailsFast(t *testing.T) { marker := filepath.Join(t.TempDir(), "should-not-exist") tasks := []Task{ {Type: Shell, Cmd: "exit 1"}, - {Type: Shell, Cmd: "touch " + marker}, + {Type: Shell, Cmd: "echo done > " + marker}, } err := ExecuteTasks(tasks) @@ -686,7 +691,7 @@ func TestExecuteTask_condition_cmd_passes(t *testing.T) { task := Task{ Type: Shell, Cmd: "exit 0", - Condition: &Condition{Cmd: "true"}, + Condition: &Condition{Cmd: "exit 0"}, } if err := ExecuteTask(task); err != nil { t.Errorf("unexpected error when condition cmd passes: %v", err) @@ -698,8 +703,8 @@ func TestExecuteTask_condition_cmd_fails(t *testing.T) { marker := filepath.Join(t.TempDir(), "should-not-exist") task := Task{ Type: Shell, - Cmd: "touch " + marker, - Condition: &Condition{Cmd: "false"}, + Cmd: "echo done > " + marker, + Condition: &Condition{Cmd: "exit 1"}, } if err := ExecuteTask(task); err != nil { t.Errorf("task with failing condition cmd should be skipped, got error: %v", err) @@ -757,7 +762,7 @@ func TestExecuteTask_group_success(t *testing.T) { Profile: Profile{ Groups: map[string][]Task{ "mygroup": { - {Type: Shell, Cmd: "touch " + marker}, + {Type: Shell, Cmd: "echo done > " + marker}, }, }, }, @@ -1070,8 +1075,8 @@ func TestExecuteTask_parallel_success(t *testing.T) { Profile: Profile{ Groups: map[string][]Task{ "workers": { - {Type: Shell, Cmd: "touch " + markerA}, - {Type: Shell, Cmd: "touch " + markerB}, + {Type: Shell, Cmd: "echo done > " + markerA}, + {Type: Shell, Cmd: "echo done > " + markerB}, }, }, }, @@ -1129,7 +1134,7 @@ func TestExecuteTask_retry_succeedsOnFirstAttempt(t *testing.T) { context = &Context{ Profile: Profile{ Groups: map[string][]Task{ - "work": {{Type: Shell, Cmd: "touch " + marker}}, + "work": {{Type: Shell, Cmd: "echo done > " + marker}}, }, }, } diff --git a/src/internal/sys/system.go b/src/internal/sys/system.go index 673ba05..9c46ca9 100644 --- a/src/internal/sys/system.go +++ b/src/internal/sys/system.go @@ -4,7 +4,7 @@ import ( "fmt" "log" "os" - "path" + "path/filepath" "runtime" "strings" @@ -40,7 +40,7 @@ func CreateFile(filePath string) (*os.File, error) { return os.Open(pathEx) } - if err := os.MkdirAll(path.Dir(pathEx), os.ModeDir|0755); err != nil { + if err := os.MkdirAll(filepath.Dir(pathEx), 0755); err != nil { return nil, fmt.Errorf("failed to create directories for '%s': %w", filePath, err) } return os.Create(pathEx) From e6a7db3d567e0095a798939afc1388238245d8d1 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 13 Mar 2026 15:53:48 -0700 Subject: [PATCH 19/42] add zsh support and direct script execution tests --- src/internal/lib/task_runner_test.go | 32 +++++++++++++++++++--------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/src/internal/lib/task_runner_test.go b/src/internal/lib/task_runner_test.go index 16c0e78..b9a2fdf 100644 --- a/src/internal/lib/task_runner_test.go +++ b/src/internal/lib/task_runner_test.go @@ -7,6 +7,7 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "strings" "testing" ) @@ -118,11 +119,6 @@ func TestExecuteTask_shell(t *testing.T) { task: Task{Type: Shell, Cmd: "exit 0", Shell: "sh"}, wantErr: false, }, - { - name: "explicit zsh shell", - task: Task{Type: Shell, Cmd: "exit 0", Shell: "zsh"}, - wantErr: false, - }, { name: "literal=true still executes", task: Task{Type: Shell, Cmd: "exit 0", Literal: true}, @@ -145,6 +141,16 @@ func TestExecuteTask_shell(t *testing.T) { } } +func TestExecuteTask_shell_zsh(t *testing.T) { + if _, err := exec.LookPath("zsh"); err != nil { + t.Skip("zsh not available on this system") + } + task := Task{Type: Shell, Cmd: "exit 0", Shell: "zsh"} + if err := ExecuteTask(task); err != nil { + t.Errorf("unexpected error with zsh shell: %v", err) + } +} + func TestExecuteTask_shell_errorMentionsCommand(t *testing.T) { task := Task{Type: Shell, Cmd: "exit 42"} err := ExecuteTask(task) @@ -198,11 +204,6 @@ func TestExecuteTask_script(t *testing.T) { task: Task{Type: Script, Path: failScript, Runner: "bash"}, wantErr: true, }, - { - name: "success without runner (direct execution)", - task: Task{Type: Script, Path: successScript}, - wantErr: false, - }, { name: "type is case-insensitive", task: Task{Type: "SCRIPT", Path: successScript, Runner: "bash"}, @@ -225,6 +226,17 @@ func TestExecuteTask_script(t *testing.T) { } } +func TestExecuteTask_script_directExecution(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("direct .sh execution not supported on Windows") + } + script := writeTempScript(t, "#!/bin/sh\nexit 0\n") + task := Task{Type: Script, Path: script} + if err := ExecuteTask(task); err != nil { + t.Errorf("unexpected error with direct script execution: %v", err) + } +} + func TestExecuteTask_script_missingFile_errorMentionsPath(t *testing.T) { task := Task{Type: Script, Path: "/no/such/file.sh"} err := ExecuteTask(task) From 2b573440b969b63bbd8487acdf20895971c1ffe6 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 13 Mar 2026 15:59:31 -0700 Subject: [PATCH 20/42] fix: ensure file is closed after creation in getOrCreateConfigFile --- src/internal/lib/config.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/internal/lib/config.go b/src/internal/lib/config.go index a0486de..5995ced 100644 --- a/src/internal/lib/config.go +++ b/src/internal/lib/config.go @@ -31,7 +31,9 @@ func InitConfig() error { func getOrCreateConfigFile() string { path := getPath() if !sys.FileExists(path) { - sys.CreateFile(path) + if f, err := sys.CreateFile(path); err == nil { + f.Close() + } } return path } From 726a64a242580013330fd17be8083a26df8ea153 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 13 Mar 2026 16:06:59 -0700 Subject: [PATCH 21/42] fix: update getShell tests for Windows compatibility and remove redundant cases --- src/internal/lib/task_runner_extra_test.go | 4 +++ src/internal/lib/task_runner_test.go | 33 ++++++++++++++++++++-- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/internal/lib/task_runner_extra_test.go b/src/internal/lib/task_runner_extra_test.go index 2d129f5..4b173de 100644 --- a/src/internal/lib/task_runner_extra_test.go +++ b/src/internal/lib/task_runner_extra_test.go @@ -5,6 +5,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "runtime" "strings" "testing" ) @@ -176,6 +177,9 @@ func TestExecuteTask_http_createError(t *testing.T) { // --- execTemplate error paths --- func TestExecuteTask_template_readError(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("file permission 0000 not enforced on Windows") + } dir := t.TempDir() srcPath := filepath.Join(dir, "template.txt") if err := os.WriteFile(srcPath, []byte("content"), 0000); err != nil { diff --git a/src/internal/lib/task_runner_test.go b/src/internal/lib/task_runner_test.go index b9a2fdf..f35ce70 100644 --- a/src/internal/lib/task_runner_test.go +++ b/src/internal/lib/task_runner_test.go @@ -26,8 +26,6 @@ func TestGetShell(t *testing.T) { {"zsh", []string{"zsh", "-c"}}, {"/bin/zsh", []string{"zsh", "-c"}}, {"BASH", []string{"bash", "-c"}}, // case-insensitive - {"unknown", []string{"bash", "-c"}}, - {"", []string{"bash", "-c"}}, // default on non-Windows } for _, tt := range tests { @@ -45,6 +43,37 @@ func TestGetShell(t *testing.T) { } } +func TestGetShell_defaults(t *testing.T) { + var wantEmpty, wantUnknown []string + if runtime.GOOS == "windows" { + wantEmpty = []string{"cmd", "/c"} + wantUnknown = []string{"cmd", "/c"} + } else { + wantEmpty = []string{"bash", "-c"} + wantUnknown = []string{"bash", "-c"} + } + + for _, tt := range []struct { + input string + want []string + }{ + {"", wantEmpty}, + {"unknown", wantUnknown}, + } { + t.Run(tt.input, func(t *testing.T) { + got := getShell(tt.input) + if len(got) != len(tt.want) { + t.Fatalf("getShell(%q) = %v, want %v", tt.input, got, tt.want) + } + for i := range got { + if got[i] != tt.want[i] { + t.Errorf("getShell(%q)[%d] = %q, want %q", tt.input, i, got[i], tt.want[i]) + } + } + }) + } +} + func TestGetShell_powershell(t *testing.T) { tests := []struct { input string From a16d07fbff38073bfe30f6977dbc656a38e187b5 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 13 Mar 2026 16:20:48 -0700 Subject: [PATCH 22/42] resolve PR review comments: correctness and cross-platform fixes --- README.md | 2 +- docs/examples/example.raid.yaml | 4 +- src/cmd/raid.go | 1 - src/internal/lib/lib.go | 47 +++ src/internal/lib/lib_test.go | 4 +- src/internal/lib/profile.go | 4 +- src/internal/lib/profile_test.go | 16 +- src/internal/lib/repo.go | 4 +- src/internal/lib/repo_test.go | 18 +- .../lib/schemas/raid-defs.schema.json | 316 ++++++++++++++++++ .../lib/schemas/raid-profile.schema.json | 55 +++ .../lib/schemas/raid-repo.schema.json | 28 ++ src/internal/lib/task.go | 8 +- src/internal/lib/task_runner.go | 12 +- src/internal/sys/system.go | 8 +- src/internal/utils/common.go | 16 +- src/raid/raid.go | 3 +- 17 files changed, 491 insertions(+), 55 deletions(-) create mode 100644 src/internal/lib/schemas/raid-defs.schema.json create mode 100644 src/internal/lib/schemas/raid-profile.schema.json create mode 100644 src/internal/lib/schemas/raid-repo.schema.json diff --git a/README.md b/README.md index 0369981..8617f7e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ [![Build and Test](https://github.com/8bitAlex/raid/actions/workflows/build.yml/badge.svg)](https://github.com/8bitAlex/raid/actions/workflows/build.yml) [![codecov](https://codecov.io/github/8bitAlex/raid/graph/badge.svg?token=Z75V7I2TLW)](https://codecov.io/github/8bitAlex/raid) -[![Go Report Card](https://goreportcard.com/badge/github.com/8bitAlex/raid)](https://goreportcard.com/report/github.com/8bitAlex/raid◊) +[![Go Report Card](https://goreportcard.com/badge/github.com/8bitAlex/raid)](https://goreportcard.com/report/github.com/8bitAlex/raid) # Raid — Distributed Development Orchestration ![Windows](https://img.shields.io/badge/Windows-Yes-blue?logo=windows) diff --git a/docs/examples/example.raid.yaml b/docs/examples/example.raid.yaml index 98131e2..5f73196 100644 --- a/docs/examples/example.raid.yaml +++ b/docs/examples/example.raid.yaml @@ -19,11 +19,11 @@ environments: literal: false cmd: echo $NODE_ENV - type: Script - path: ~/Developer/raid/docs/examples/hello.sh + path: ./docs/examples/hello.sh runner: bash concurrent: false - type: Shell - cmd: touch ~/testfile.txt && echo "Created testfile.txt" + cmd: echo "Hello from concurrent task" concurrent: true variables: - name: NODE_ENV diff --git a/src/cmd/raid.go b/src/cmd/raid.go index 9b3cd14..6a8cbf1 100644 --- a/src/cmd/raid.go +++ b/src/cmd/raid.go @@ -31,7 +31,6 @@ var rootCmd = &cobra.Command{ } func init() { - cobra.OnInitialize(raid.Initialize) // Global Flags rootCmd.PersistentFlags().StringVarP(raid.ConfigPath, raid.ConfigPathFlag, raid.ConfigPathFlagShort, "", raid.ConfigPathFlagDesc) // Subcommands diff --git a/src/internal/lib/lib.go b/src/internal/lib/lib.go index b2cfc19..a15dc50 100644 --- a/src/internal/lib/lib.go +++ b/src/internal/lib/lib.go @@ -3,6 +3,8 @@ package lib import ( "bytes" + "embed" + "encoding/json" "fmt" "io" "os" @@ -15,6 +17,9 @@ import ( "github.com/santhosh-tekuri/jsonschema/v6" ) +//go:embed schemas/*.json +var schemaFS embed.FS + const ( yamlSep = "---" RaidConfigFileName = "raid.yaml" @@ -137,6 +142,7 @@ func Install(maxThreads int) error { } // ValidateSchema validates the file at path against the JSON schema at schemaPath. +// schemaPath must be an absolute or CWD-relative path to a schema file on disk. func ValidateSchema(path string, schemaPath string) error { path = sys.ExpandPath(path) schemaPath = sys.ExpandPath(schemaPath) @@ -154,6 +160,47 @@ func ValidateSchema(path string, schemaPath string) error { return err } + return validateFile(path, sch) +} + +// validateWithEmbeddedSchema validates path against a schema embedded in the binary. +// schemaName must be the bare filename of a schema in the embedded schemas directory +// (e.g. "raid-profile.schema.json"). All embedded schemas are registered so that +// cross-schema $ref values resolve correctly. +func validateWithEmbeddedSchema(path, schemaName string) error { + path = sys.ExpandPath(path) + if path == "" || !sys.FileExists(path) { + return fmt.Errorf("file not found at %s", path) + } + + c := jsonschema.NewCompiler() + entries, err := schemaFS.ReadDir("schemas") + if err != nil { + return fmt.Errorf("failed to read embedded schemas: %w", err) + } + for _, entry := range entries { + data, err := schemaFS.ReadFile("schemas/" + entry.Name()) + if err != nil { + return fmt.Errorf("failed to read embedded schema %s: %w", entry.Name(), err) + } + var doc any + if err := json.Unmarshal(data, &doc); err != nil { + return fmt.Errorf("failed to parse embedded schema %s: %w", entry.Name(), err) + } + if err := c.AddResource(entry.Name(), doc); err != nil { + return fmt.Errorf("failed to register embedded schema %s: %w", entry.Name(), err) + } + } + + sch, err := c.Compile(schemaName) + if err != nil { + return err + } + + return validateFile(path, sch) +} + +func validateFile(path string, sch *jsonschema.Schema) error { f, err := os.Open(path) if err != nil { return err diff --git a/src/internal/lib/lib_test.go b/src/internal/lib/lib_test.go index 899bbd1..69c5df2 100644 --- a/src/internal/lib/lib_test.go +++ b/src/internal/lib/lib_test.go @@ -76,12 +76,12 @@ func TestForceLoad_buildProfileError(t *testing.T) { dir := t.TempDir() profilePath := filepath.Join(dir, "profile.yaml") - os.WriteFile(profilePath, []byte("name: test"), 0644) + // badfield violates additionalProperties:false in the profile schema. + os.WriteFile(profilePath, []byte("name: test\nbadfield: invalid"), 0644) AddProfile(Profile{Name: "test", Path: profilePath}) SetProfile("test") - // profileSchemaPath is relative; not found from test CWD → buildProfile fails. if err := ForceLoad(); err == nil { t.Fatal("ForceLoad() expected error when buildProfile fails") } diff --git a/src/internal/lib/profile.go b/src/internal/lib/profile.go index f3f1a58..c28e7e4 100644 --- a/src/internal/lib/profile.go +++ b/src/internal/lib/profile.go @@ -15,7 +15,7 @@ import ( const ( activeProfileKey = "profile" allProfilesKey = "profiles" - profileSchemaPath = "schemas/raid-profile.schema.json" + profileSchemaPath = "raid-profile.schema.json" ) // Profile represents a named collection of repositories, environments, and task groups. @@ -217,7 +217,7 @@ func ContainsProfile(name string) bool { // ValidateProfile validates the profile file at path against the profile JSON schema. func ValidateProfile(path string) error { - return ValidateSchema(path, profileSchemaPath) + return validateWithEmbeddedSchema(path, profileSchemaPath) } func buildProfile(profile Profile) (Profile, error) { diff --git a/src/internal/lib/profile_test.go b/src/internal/lib/profile_test.go index 0012aa4..c59f4f2 100644 --- a/src/internal/lib/profile_test.go +++ b/src/internal/lib/profile_test.go @@ -340,9 +340,9 @@ func TestBuildProfile_fileNotFound(t *testing.T) { func TestBuildProfile_validationError(t *testing.T) { dir := t.TempDir() profilePath := filepath.Join(dir, "profile.yaml") - os.WriteFile(profilePath, []byte("name: test"), 0644) + // badfield violates additionalProperties:false in the profile schema. + os.WriteFile(profilePath, []byte("name: test\nbadfield: invalid"), 0644) - // profileSchemaPath is relative; schema not found from test CWD → validation error. _, err := buildProfile(Profile{Name: "test", Path: profilePath}) if err == nil { t.Fatal("buildProfile() expected error when schema validation fails") @@ -367,15 +367,13 @@ func TestBuildProfile_extractionError(t *testing.T) { } } -func TestValidateProfile_schemaNotFound(t *testing.T) { +func TestValidateProfile_schemaViolation(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "profile.yaml") - os.WriteFile(path, []byte("name: test"), 0644) + // badfield violates additionalProperties:false in the profile schema. + os.WriteFile(path, []byte("name: test\nbadfield: invalid"), 0644) - // profileSchemaPath is relative to repo root; won't resolve from test CWD. - // We just need to exercise the function; the error path is valid coverage. - err := ValidateProfile(path) - if err == nil { - t.Fatal("ValidateProfile() expected error when schema cannot be found from test CWD") + if err := ValidateProfile(path); err == nil { + t.Fatal("ValidateProfile() expected error for schema violation") } } diff --git a/src/internal/lib/repo.go b/src/internal/lib/repo.go index 6a20599..647ce7c 100644 --- a/src/internal/lib/repo.go +++ b/src/internal/lib/repo.go @@ -10,7 +10,7 @@ import ( "gopkg.in/yaml.v3" ) -const repoSchemaPath = "schemas/raid-repo.schema.json" +const repoSchemaPath = "raid-repo.schema.json" // Repo represents a single repository entry in a profile. type Repo struct { @@ -105,7 +105,7 @@ func clone(path string, url string) error { // ValidateRepo validates the repo config file at path against the repo JSON schema. func ValidateRepo(path string) error { - return ValidateSchema(path, repoSchemaPath) + return validateWithEmbeddedSchema(path, repoSchemaPath) } // ExtractRepo reads and parses the raid.yaml from the given repository directory. diff --git a/src/internal/lib/repo_test.go b/src/internal/lib/repo_test.go index 74e9b58..34630bc 100644 --- a/src/internal/lib/repo_test.go +++ b/src/internal/lib/repo_test.go @@ -129,12 +129,11 @@ func TestBuildRepo_noRaidYAML(t *testing.T) { func TestBuildRepo_validationError(t *testing.T) { dir := t.TempDir() - // Create raid.yaml; schema not found from test CWD → ValidateRepo returns error. - os.WriteFile(filepath.Join(dir, RaidConfigFileName), []byte("name: test\nbranch: main"), 0644) + // badfield violates additionalProperties:false in the repo schema. + os.WriteFile(filepath.Join(dir, RaidConfigFileName), []byte("name: test\nbranch: main\nbadfield: invalid"), 0644) repo := Repo{Name: "test", Path: dir, URL: "http://x.com"} - err := buildRepo(&repo) - if err == nil { + if err := buildRepo(&repo); err == nil { t.Fatal("buildRepo() expected error when schema validation fails") } } @@ -189,14 +188,13 @@ func TestCloneRepository_successLocalRepo(t *testing.T) { } } -func TestValidateRepo_schemaNotFound(t *testing.T) { +func TestValidateRepo_schemaViolation(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "raid.yaml") - os.WriteFile(path, []byte("name: test\nbranch: main"), 0644) + // badfield violates additionalProperties:false in the repo schema. + os.WriteFile(path, []byte("name: test\nbranch: main\nbadfield: invalid"), 0644) - // repoSchemaPath is relative to repo root; won't resolve from test CWD. - err := ValidateRepo(path) - if err == nil { - t.Fatal("ValidateRepo() expected error when schema cannot be found from test CWD") + if err := ValidateRepo(path); err == nil { + t.Fatal("ValidateRepo() expected error for schema violation") } } diff --git a/src/internal/lib/schemas/raid-defs.schema.json b/src/internal/lib/schemas/raid-defs.schema.json new file mode 100644 index 0000000..f75b6f8 --- /dev/null +++ b/src/internal/lib/schemas/raid-defs.schema.json @@ -0,0 +1,316 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "raid-defs.schema.json", + "title": "Raid Schema Definitions", + "description": "Shared schema definitions for raid", + "type": "object", + "additionalProperties": false, + "properties": { + "tasks": { + "type": "array", + "description": "Tasks to be executed", + "minItems": 1, + "items": { + "type": "object", + "oneOf": [ + { + "type": "object", + "properties": { + "type": { "type": "string", "const": "Shell" }, + "concurrent": { "$ref": "#/$defs/concurrent" }, + "condition": { "$ref": "#/$defs/condition" }, + "cmd": { "type": "string", "description": "Command to execute" }, + "shell": { + "type": "string", + "enum": ["bash", "sh", "zsh", "powershell", "cmd"], + "description": "Shell to use (default: bash)" + }, + "literal": { + "type": "boolean", + "description": "Pass the command to the shell without prior env var expansion", + "default": false + }, + "path": { + "type": "string", + "description": "Working directory for the command. Defaults to ~ for profile tasks, the repo directory for repo tasks." + } + }, + "required": ["type", "cmd"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { "type": "string", "const": "Script" }, + "concurrent": { "$ref": "#/$defs/concurrent" }, + "condition": { "$ref": "#/$defs/condition" }, + "path": { "type": "string", "description": "Path to the script file" }, + "runner": { + "type": "string", + "enum": ["bash", "sh", "zsh", "python", "python2", "python3", "node", "powershell"], + "description": "Interpreter to use (optional)" + } + }, + "required": ["type", "path"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { "type": "string", "const": "HTTP" }, + "concurrent": { "$ref": "#/$defs/concurrent" }, + "condition": { "$ref": "#/$defs/condition" }, + "url": { "type": "string", "description": "URL to download from" }, + "dest": { "type": "string", "description": "Local path to write the file to" } + }, + "required": ["type", "url", "dest"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { "type": "string", "const": "Wait" }, + "concurrent": { "$ref": "#/$defs/concurrent" }, + "condition": { "$ref": "#/$defs/condition" }, + "url": { "type": "string", "description": "HTTP(S) URL or TCP host:port to poll" }, + "timeout": { "type": "string", "description": "Max wait duration (e.g. 30s, 1m). Defaults to 30s." } + }, + "required": ["type", "url"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { "type": "string", "const": "Template" }, + "concurrent": { "$ref": "#/$defs/concurrent" }, + "condition": { "$ref": "#/$defs/condition" }, + "src": { "type": "string", "description": "Path to the template file. Supports $VAR and ${VAR} substitution." }, + "dest": { "type": "string", "description": "Path to write the rendered file to" } + }, + "required": ["type", "src", "dest"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { "type": "string", "const": "Group" }, + "concurrent": { "$ref": "#/$defs/concurrent" }, + "condition": { "$ref": "#/$defs/condition" }, + "ref": { "type": "string", "description": "Name of the group to execute, as defined in the profile's top-level groups map" } + }, + "required": ["type", "ref"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { "type": "string", "const": "Git" }, + "concurrent": { "$ref": "#/$defs/concurrent" }, + "condition": { "$ref": "#/$defs/condition" }, + "op": { + "type": "string", + "enum": ["pull", "checkout", "fetch", "reset"], + "description": "Git operation to perform" + }, + "branch": { "type": "string", "description": "Target branch (required for checkout, optional for others)" }, + "path": { "type": "string", "description": "Path to the git repository. Defaults to the current working directory." } + }, + "required": ["type", "op"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { "type": "string", "const": "Prompt" }, + "concurrent": { "$ref": "#/$defs/concurrent" }, + "condition": { "$ref": "#/$defs/condition" }, + "var": { "type": "string", "description": "Environment variable name to set with the user's input" }, + "message": { "type": "string", "description": "Message to display to the user" }, + "default": { "type": "string", "description": "Default value if the user provides no input" } + }, + "required": ["type", "var"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { "type": "string", "const": "Confirm" }, + "concurrent": { "$ref": "#/$defs/concurrent" }, + "condition": { "$ref": "#/$defs/condition" }, + "message": { "type": "string", "description": "Confirmation prompt to display to the user" } + }, + "required": ["type"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { "type": "string", "const": "Parallel" }, + "concurrent": { "$ref": "#/$defs/concurrent" }, + "condition": { "$ref": "#/$defs/condition" }, + "ref": { "type": "string", "description": "Name of the group whose tasks will all be executed concurrently" } + }, + "required": ["type", "ref"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { "type": "string", "const": "Print" }, + "concurrent": { "$ref": "#/$defs/concurrent" }, + "condition": { "$ref": "#/$defs/condition" }, + "message": { "type": "string", "description": "Message to print. Supports $VAR substitution unless literal is true." }, + "color": { + "type": "string", + "enum": ["red", "green", "yellow", "blue", "cyan", "white"], + "description": "Optional terminal color for the output" + }, + "literal": { + "type": "boolean", + "description": "Skip environment variable expansion in the message", + "default": false + } + }, + "required": ["type", "message"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { "type": "string", "const": "Retry" }, + "concurrent": { "$ref": "#/$defs/concurrent" }, + "condition": { "$ref": "#/$defs/condition" }, + "ref": { "type": "string", "description": "Name of the group to retry on failure" }, + "attempts": { + "type": "integer", + "minimum": 1, + "description": "Maximum number of attempts (default: 3)" + }, + "delay": { "type": "string", "description": "Duration to wait between attempts (e.g. 1s, 500ms). Default: 1s." } + }, + "required": ["type", "ref"], + "additionalProperties": false + } + ] + } + }, + "environments": { + "type": "array", + "description": "The environments to include in the raid profile", + "minItems": 1, + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The name of the environment" + }, + "tasks": { + "$ref": "#/properties/tasks" + }, + "variables": { + "type": "array", + "description": "Environment variables to set", + "minItems": 1, + "items": { + "type": "object", + "description": "Environment variables to set", + "properties": { + "name": { + "type": "string", + "description": "The name of the variable" + }, + "value": { + "type": "string", + "description": "The value of the variable" + } + }, + "required": ["name", "value"], + "additionalProperties": false + } + } + }, + "required": ["name"], + "additionalProperties": false + } + }, + "install": { + "type": "object", + "description": "Options for installing the raid profile", + "properties": { + "tasks": { + "$ref": "#/properties/tasks" + } + }, + "additionalProperties": false + }, + "commands": { + "type": "array", + "description": "Custom commands exposed as top-level raid subcommands", + "minItems": 1, + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Command name used to invoke it via 'raid '" + }, + "usage": { + "type": "string", + "description": "Short description shown in 'raid --help'" + }, + "tasks": { + "$ref": "#/properties/tasks" + }, + "out": { + "type": "object", + "description": "Output configuration for the command", + "properties": { + "stdout": { + "type": "boolean", + "description": "Show stdout from tasks (default: true when out is omitted)" + }, + "stderr": { + "type": "boolean", + "description": "Show stderr from tasks (default: true when out is omitted)" + }, + "file": { + "type": "string", + "description": "Path to additionally write all output to. Supports $VAR expansion." + } + }, + "additionalProperties": false + } + }, + "required": ["name", "tasks"], + "additionalProperties": false + } + } + }, + "$defs": { + "concurrent": { + "type": "boolean", + "description": "Whether to execute the task concurrently with other tasks" + }, + "condition": { + "type": "object", + "description": "All specified fields must be satisfied for the task to run", + "properties": { + "platform": { + "type": "string", + "enum": ["darwin", "linux", "windows"], + "description": "Only run on this platform" + }, + "exists": { + "type": "string", + "description": "Only run if this file or directory exists" + }, + "cmd": { + "type": "string", + "description": "Only run if this command exits with code 0" + } + }, + "additionalProperties": false + } + } +} diff --git a/src/internal/lib/schemas/raid-profile.schema.json b/src/internal/lib/schemas/raid-profile.schema.json new file mode 100644 index 0000000..5629847 --- /dev/null +++ b/src/internal/lib/schemas/raid-profile.schema.json @@ -0,0 +1,55 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "raid-profile.schema.json", + "title": "Raid Profile Configuration", + "description": "Configuration for one or more raid profiles.", + "type": "object", + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "description": "The name of the raid profile" + }, + "repositories": { + "type": "array", + "description": "The repositories to include in the raid profile", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The name of the repository" + }, + "path": { + "type": "string", + "description": "The local path to the repository" + }, + "url": { + "type": "string", + "description": "The URL of the repository" + } + }, + "required": ["name", "path", "url"] + }, + "minItems": 1 + }, + "environments": { + "$ref": "raid-defs.schema.json#/properties/environments" + }, + "install": { + "$ref": "raid-defs.schema.json#/properties/install" + }, + "groups": { + "type": "object", + "description": "Named reusable task sequences. Reference them in any task list with type: Group and ref: .", + "additionalProperties": { + "$ref": "raid-defs.schema.json#/properties/tasks" + } + }, + "commands": { + "$ref": "raid-defs.schema.json#/properties/commands" + } + }, + "required": ["name"] +} + diff --git a/src/internal/lib/schemas/raid-repo.schema.json b/src/internal/lib/schemas/raid-repo.schema.json new file mode 100644 index 0000000..1b93c94 --- /dev/null +++ b/src/internal/lib/schemas/raid-repo.schema.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "raid-repo.schema.json", + "title": "Raid Repository Configuration", + "description": "Configuration for a single repository.", + "type": "object", + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "description": "The name of the repository" + }, + "branch": { + "type": "string", + "description": "The branch to checkout" + }, + "environments": { + "$ref": "raid-defs.schema.json#/properties/environments" + }, + "install": { + "$ref": "raid-defs.schema.json#/properties/install" + }, + "commands": { + "$ref": "raid-defs.schema.json#/properties/commands" + } + }, + "required": ["name","branch"] +} \ No newline at end of file diff --git a/src/internal/lib/task.go b/src/internal/lib/task.go index f8518e6..1df70f6 100644 --- a/src/internal/lib/task.go +++ b/src/internal/lib/task.go @@ -68,12 +68,12 @@ func (t Task) Expand() Task { Cmd: sys.Expand(t.Cmd), Literal: t.Literal, Shell: t.Shell, - Path: sys.Expand(t.Path), - Runner: sys.Expand(t.Runner), + Path: sys.ExpandPath(t.Path), + Runner: sys.ExpandPath(t.Runner), URL: sys.Expand(t.URL), - Dest: sys.Expand(t.Dest), + Dest: sys.ExpandPath(t.Dest), Timeout: t.Timeout, - Src: sys.Expand(t.Src), + Src: sys.ExpandPath(t.Src), Ref: t.Ref, Op: t.Op, Branch: sys.Expand(t.Branch), diff --git a/src/internal/lib/task_runner.go b/src/internal/lib/task_runner.go index 43b927e..236c4b3 100644 --- a/src/internal/lib/task_runner.go +++ b/src/internal/lib/task_runner.go @@ -145,9 +145,7 @@ func execShell(task Task) error { if task.Path != "" { cmd.Dir = sys.ExpandPath(task.Path) } - if !task.Concurrent { - setCmdOutput(cmd) - } + setCmdOutput(cmd) err := cmd.Run() if err != nil { @@ -195,9 +193,7 @@ func execScript(task Task) error { cmd = exec.Command(task.Path) } - if !task.Concurrent { - setCmdOutput(cmd) - } + setCmdOutput(cmd) err := cmd.Run() if err != nil { @@ -399,9 +395,7 @@ func execGit(task Task) error { cmd := exec.Command("git", args...) cmd.Dir = dir - if !task.Concurrent { - setCmdOutput(cmd) - } + setCmdOutput(cmd) if err := cmd.Run(); err != nil { return fmt.Errorf("git %s failed in '%s': %w", task.Op, dir, err) diff --git a/src/internal/sys/system.go b/src/internal/sys/system.go index 9c46ca9..fe5fce8 100644 --- a/src/internal/sys/system.go +++ b/src/internal/sys/system.go @@ -33,17 +33,13 @@ func GetHomeDir() string { return home } -// CreateFile opens the file at filePath if it exists, or creates it (including parent directories). +// CreateFile opens or creates the file at filePath for reading and writing, creating parent directories as needed. func CreateFile(filePath string) (*os.File, error) { pathEx := ExpandPath(filePath) - if FileExists(pathEx) { - return os.Open(pathEx) - } - if err := os.MkdirAll(filepath.Dir(pathEx), 0755); err != nil { return nil, fmt.Errorf("failed to create directories for '%s': %w", filePath, err) } - return os.Create(pathEx) + return os.OpenFile(pathEx, os.O_RDWR|os.O_CREATE, 0644) } // FileExists reports whether the file or directory at path exists. diff --git a/src/internal/utils/common.go b/src/internal/utils/common.go index 8a2a596..86b958a 100644 --- a/src/internal/utils/common.go +++ b/src/internal/utils/common.go @@ -4,20 +4,24 @@ import ( "encoding/json" "fmt" "io" + "strings" "gopkg.in/yaml.v3" ) +// MergeErr combines multiple errors into a single error, skipping nil entries. +// Returns nil if all errors are nil or the slice is empty. func MergeErr(errs []error) error { - var result string + var msgs []string for _, err := range errs { - if len(result) == 0 { - result = err.Error() - } else { - result = result + ", " + err.Error() + if err != nil { + msgs = append(msgs, err.Error()) } } - return fmt.Errorf("%s", result) + if len(msgs) == 0 { + return nil + } + return fmt.Errorf("%s", strings.Join(msgs, ", ")) } func YAMLToJSON(file io.Reader) ([]byte, error) { diff --git a/src/raid/raid.go b/src/raid/raid.go index 7d46f4a..8aa6027 100644 --- a/src/raid/raid.go +++ b/src/raid/raid.go @@ -17,6 +17,7 @@ package raid import ( "fmt" "log" + "os" "github.com/8bitalex/raid/src/internal/lib" ) @@ -43,7 +44,7 @@ func Initialize() { log.Fatalf("%v\n", err) } if err := lib.LoadEnv(); err != nil { - fmt.Printf("%v\n", err) + fmt.Fprintf(os.Stderr, "%v\n", err) } } From c2aea8f58ac812b31290620a146068f67d46e893 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 13 Mar 2026 16:21:50 -0700 Subject: [PATCH 23/42] fix: update codecov workflow to include Go setup and improve test coverage command --- .github/workflows/codecov.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml index 9e861d2..c4b0c1a 100644 --- a/.github/workflows/codecov.yml +++ b/.github/workflows/codecov.yml @@ -14,8 +14,12 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.24.4' - name: Run Tests - run: go test -coverprofile=coverage.txt + run: go test -coverprofile=coverage.txt -covermode=atomic ./... - uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} From 9fc7a6a374a1d387f2fdfb1d7e20e855dd161d61 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 13 Mar 2026 16:24:26 -0700 Subject: [PATCH 24/42] fix: add Windows support for getShell function --- src/internal/lib/task_runner.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/internal/lib/task_runner.go b/src/internal/lib/task_runner.go index 236c4b3..fd1539f 100644 --- a/src/internal/lib/task_runner.go +++ b/src/internal/lib/task_runner.go @@ -175,6 +175,9 @@ func getShell(shell string) []string { case "cmd": return []string{"cmd", "/c"} default: + if sys.GetPlatform() == sys.Windows { + return []string{"cmd", "/c"} + } return []string{"bash", "-c"} } } From 43a9bbb90582ca8618e61084fed590afb71f30e8 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 13 Mar 2026 16:49:19 -0700 Subject: [PATCH 25/42] fix: enhance command handling and error reporting in task execution --- src/cmd/raid.go | 2 +- src/internal/lib/env.go | 3 +++ src/internal/lib/repo.go | 11 ++++++++--- src/internal/lib/task_runner.go | 15 ++++++++------- src/internal/sys/system.go | 9 ++++++--- 5 files changed, 26 insertions(+), 14 deletions(-) diff --git a/src/cmd/raid.go b/src/cmd/raid.go index 6a8cbf1..2f962b8 100644 --- a/src/cmd/raid.go +++ b/src/cmd/raid.go @@ -69,7 +69,7 @@ func Execute() { // before the pre-initialization call, matching cobra's later flag parsing. func applyConfigFlag(args []string) { for i, arg := range args { - if (arg == "--config" || arg == "-c") && i+1 < len(args) { + if (arg == "--config" || arg == "-c") && i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") { *raid.ConfigPath = args[i+1] return } diff --git a/src/internal/lib/env.go b/src/internal/lib/env.go index f2625bc..fa9f53e 100644 --- a/src/internal/lib/env.go +++ b/src/internal/lib/env.go @@ -69,6 +69,9 @@ func ContainsEnv(name string) bool { // ExecuteEnv writes environment variables to each repo's .env file and runs the environment's tasks. func ExecuteEnv(name string) error { + if context == nil { + return fmt.Errorf("raid context is not initialized") + } if err := setEnvVariablesForRepos(name); err != nil { return fmt.Errorf("failed to set env variables: %w", err) } diff --git a/src/internal/lib/repo.go b/src/internal/lib/repo.go index 647ce7c..1dd5e66 100644 --- a/src/internal/lib/repo.go +++ b/src/internal/lib/repo.go @@ -17,6 +17,7 @@ type Repo struct { Name string `json:"name"` Path string `json:"path"` URL string `json:"url"` + Branch string `json:"branch"` Environments []Env `json:"environments"` Install OnInstall `json:"install"` Commands []Command `json:"commands"` @@ -79,7 +80,7 @@ func CloneRepository(repo Repo) error { return fmt.Errorf("failed to create directory '%s': %w", path, err) } - if err := clone(path, repo.URL); err != nil { + if err := clone(path, repo.URL, repo.Branch); err != nil { return fmt.Errorf("failed to clone repository '%s': %w", repo.Name, err) } @@ -96,8 +97,12 @@ func isGitInstalled() bool { return cmd.Run() == nil } -func clone(path string, url string) error { - cmd := exec.Command("git", "clone", url, path) +func clone(path string, url string, branch string) error { + args := []string{"clone", url, path} + if branch != "" { + args = append(args, "--branch", branch) + } + cmd := exec.Command("git", args...) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr return cmd.Run() diff --git a/src/internal/lib/task_runner.go b/src/internal/lib/task_runner.go index fd1539f..4c10c50 100644 --- a/src/internal/lib/task_runner.go +++ b/src/internal/lib/task_runner.go @@ -222,7 +222,8 @@ func execHTTP(task Task) error { return fmt.Errorf("dest is required for HTTP task") } - resp, err := http.Get(task.URL) + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Get(task.URL) if err != nil { return fmt.Errorf("failed to fetch '%s': %w", task.URL, err) } @@ -265,7 +266,7 @@ func execWait(task Task) error { timeout = d } - fmt.Printf("Waiting for %s (timeout: %s)...\n", task.URL, timeout) + fmt.Fprintf(commandStdout, "Waiting for %s (timeout: %s)...\n", task.URL, timeout) check := checkHTTP if !strings.HasPrefix(task.URL, "http://") && !strings.HasPrefix(task.URL, "https://") { @@ -416,7 +417,7 @@ func execPrompt(task Task) error { if message == "" { message = fmt.Sprintf("Enter value for %s:", task.Var) } - fmt.Print(message + " ") + fmt.Fprint(commandStdout, message+" ") reader := bufio.NewReader(os.Stdin) value, err := reader.ReadString('\n') @@ -438,7 +439,7 @@ func execConfirm(task Task) error { if message == "" { message = "Continue?" } - fmt.Print(message + " [y/N] ") + fmt.Fprint(commandStdout, message+" [y/N] ") reader := bufio.NewReader(os.Stdin) answer, err := reader.ReadString('\n') @@ -483,12 +484,12 @@ func execPrint(task Task) error { if task.Color != "" { if code, ok := colorCodes[strings.ToLower(task.Color)]; ok { - fmt.Printf("%s%s%s\n", code, msg, colorCodes["reset"]) + fmt.Fprintf(commandStdout, "%s%s%s\n", code, msg, colorCodes["reset"]) return nil } } - fmt.Println(msg) + fmt.Fprintln(commandStdout, msg) return nil } @@ -522,7 +523,7 @@ func execRetry(task Task) error { var lastErr error for i := 0; i < attempts; i++ { if i > 0 { - fmt.Printf("Retrying... (attempt %d/%d)\n", i+1, attempts) + fmt.Fprintf(commandStdout, "Retrying... (attempt %d/%d)\n", i+1, attempts) time.Sleep(delay) } if err := ExecuteTasks(tasks); err != nil { diff --git a/src/internal/sys/system.go b/src/internal/sys/system.go index fe5fce8..c80a44c 100644 --- a/src/internal/sys/system.go +++ b/src/internal/sys/system.go @@ -43,12 +43,15 @@ func CreateFile(filePath string) (*os.File, error) { } // FileExists reports whether the file or directory at path exists. +// Permission errors are treated as the path existing to avoid silently +// overwriting or recreating inaccessible files. func FileExists(path string) bool { path = ExpandPath(path) - if _, err := os.Stat(path); err != nil { - return false + _, err := os.Stat(path) + if err == nil { + return true } - return true + return os.IsPermission(err) } // Expand expands environment variables and home directory references in each whitespace-delimited From 2d47ec5a3fdcd499313a3055e0ce91f07a6fa15d Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 13 Mar 2026 18:27:14 -0700 Subject: [PATCH 26/42] fix: improve config file handling and error reporting, enhance YAML to JSON conversion --- src/internal/lib/config.go | 17 ++++++++++++----- src/internal/lib/repo.go | 3 ++- src/internal/sys/system.go | 13 +++---------- src/internal/utils/common.go | 11 ++++++++++- 4 files changed, 27 insertions(+), 17 deletions(-) diff --git a/src/internal/lib/config.go b/src/internal/lib/config.go index 5995ced..dc0935a 100644 --- a/src/internal/lib/config.go +++ b/src/internal/lib/config.go @@ -1,6 +1,7 @@ package lib import ( + "fmt" "path/filepath" sys "github.com/8bitalex/raid/src/internal/sys" @@ -21,21 +22,27 @@ var CfgPath string var defaultConfigPath = filepath.Join(sys.GetHomeDir(), ConfigDirName) func InitConfig() error { - viper.SetConfigFile(getOrCreateConfigFile()) + path, err := getOrCreateConfigFile() + if err != nil { + return err + } + viper.SetConfigFile(path) if err := viper.ReadInConfig(); err != nil { return err } return nil } -func getOrCreateConfigFile() string { +func getOrCreateConfigFile() (string, error) { path := getPath() if !sys.FileExists(path) { - if f, err := sys.CreateFile(path); err == nil { - f.Close() + f, err := sys.CreateFile(path) + if err != nil { + return "", fmt.Errorf("failed to create config file at %s: %w", path, err) } + f.Close() } - return path + return path, nil } func getPath() string { diff --git a/src/internal/lib/repo.go b/src/internal/lib/repo.go index 1dd5e66..cdea140 100644 --- a/src/internal/lib/repo.go +++ b/src/internal/lib/repo.go @@ -98,10 +98,11 @@ func isGitInstalled() bool { } func clone(path string, url string, branch string) error { - args := []string{"clone", url, path} + args := []string{"clone"} if branch != "" { args = append(args, "--branch", branch) } + args = append(args, url, path) cmd := exec.Command("git", args...) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr diff --git a/src/internal/sys/system.go b/src/internal/sys/system.go index c80a44c..32a8dd9 100644 --- a/src/internal/sys/system.go +++ b/src/internal/sys/system.go @@ -54,17 +54,10 @@ func FileExists(path string) bool { return os.IsPermission(err) } -// Expand expands environment variables and home directory references in each whitespace-delimited -// token of input, then rejoins them with spaces. +// Expand expands environment variables in input without tokenizing, preserving +// quoting and spacing. Use ExpandPath for file system paths that also need ~ expansion. func Expand(input string) string { - if input == "" { - return input - } - parts := SplitInput(input) - for i, p := range parts { - parts[i] = ExpandPath(p) - } - return strings.Join(parts, " ") + return os.ExpandEnv(input) } // ExpandPath expands environment variables and a leading ~ in the given path. diff --git a/src/internal/utils/common.go b/src/internal/utils/common.go index 86b958a..725fce3 100644 --- a/src/internal/utils/common.go +++ b/src/internal/utils/common.go @@ -24,10 +24,19 @@ func MergeErr(errs []error) error { return fmt.Errorf("%s", strings.Join(msgs, ", ")) } +// YAMLToJSON converts the first YAML document in file to JSON. +// Returns an error if the reader contains more than one YAML document, as +// only the first would be validated and silently ignoring later documents +// can mask configuration mistakes. func YAMLToJSON(file io.Reader) ([]byte, error) { + dec := yaml.NewDecoder(file) var data interface{} - if err := yaml.NewDecoder(file).Decode(&data); err != nil { + if err := dec.Decode(&data); err != nil { return nil, err } + var extra interface{} + if err := dec.Decode(&extra); err == nil { + return nil, fmt.Errorf("multi-document YAML is not supported for schema validation") + } return json.Marshal(data) } From ad0c79916adbbefc12c592ba28226404d1e6101c Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 13 Mar 2026 18:45:31 -0700 Subject: [PATCH 27/42] fix: enhance getShell function to prioritize pwsh over powershell, update tests accordingly --- src/internal/lib/task_runner.go | 3 +++ src/internal/lib/task_runner_test.go | 30 +++++++++++++--------------- src/raid/raid.go | 4 ++-- 3 files changed, 19 insertions(+), 18 deletions(-) diff --git a/src/internal/lib/task_runner.go b/src/internal/lib/task_runner.go index 4c10c50..881c417 100644 --- a/src/internal/lib/task_runner.go +++ b/src/internal/lib/task_runner.go @@ -171,6 +171,9 @@ func getShell(shell string) []string { case "/bin/zsh", "zsh": return []string{"zsh", "-c"} case "powershell", "pwsh", "ps": + if _, err := exec.LookPath("pwsh"); err == nil { + return []string{"pwsh", "-Command"} + } return []string{"powershell", "-Command"} case "cmd": return []string{"cmd", "/c"} diff --git a/src/internal/lib/task_runner_test.go b/src/internal/lib/task_runner_test.go index f35ce70..d5da4a3 100644 --- a/src/internal/lib/task_runner_test.go +++ b/src/internal/lib/task_runner_test.go @@ -75,25 +75,23 @@ func TestGetShell_defaults(t *testing.T) { } func TestGetShell_powershell(t *testing.T) { - tests := []struct { - input string - want []string - }{ - {"powershell", []string{"powershell", "-Command"}}, - {"pwsh", []string{"powershell", "-Command"}}, - {"ps", []string{"powershell", "-Command"}}, + // The resolved binary is "pwsh" when available, "powershell" otherwise. + wantBin := "powershell" + if _, err := exec.LookPath("pwsh"); err == nil { + wantBin = "pwsh" } - for _, tt := range tests { - t.Run(tt.input, func(t *testing.T) { - got := getShell(tt.input) - if len(got) != len(tt.want) { - t.Fatalf("getShell(%q) = %v, want %v", tt.input, got, tt.want) + for _, input := range []string{"powershell", "pwsh", "ps"} { + t.Run(input, func(t *testing.T) { + got := getShell(input) + if len(got) != 2 { + t.Fatalf("getShell(%q) = %v, want 2-element slice", input, got) } - for i := range got { - if got[i] != tt.want[i] { - t.Errorf("getShell(%q)[%d] = %q, want %q", tt.input, i, got[i], tt.want[i]) - } + if got[0] != wantBin { + t.Errorf("getShell(%q)[0] = %q, want %q", input, got[0], wantBin) + } + if got[1] != "-Command" { + t.Errorf("getShell(%q)[1] = %q, want \"-Command\"", input, got[1]) } }) } diff --git a/src/raid/raid.go b/src/raid/raid.go index 8aa6027..ca9d9b1 100644 --- a/src/raid/raid.go +++ b/src/raid/raid.go @@ -38,10 +38,10 @@ var ConfigPath = &lib.CfgPath // Initialize the raid environment, including loading configurations and initializing data storage. func Initialize() { if err := lib.InitConfig(); err != nil { - log.Fatalf("%v\n", err) + log.Fatalf("%v", err) } if err := Load(); err != nil { - log.Fatalf("%v\n", err) + log.Fatalf("%v", err) } if err := lib.LoadEnv(); err != nil { fmt.Fprintf(os.Stderr, "%v\n", err) From e2b8c3d385ffca871b96bdf6082985073e8d7f52 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 13 Mar 2026 19:07:15 -0700 Subject: [PATCH 28/42] fix: enhance config file handling by expanding paths and improving directory validation --- src/internal/lib/config.go | 2 +- src/internal/lib/lib.go | 33 +++++++++++++++++++++++++-------- src/internal/lib/task_runner.go | 5 +++-- 3 files changed, 29 insertions(+), 11 deletions(-) diff --git a/src/internal/lib/config.go b/src/internal/lib/config.go index dc0935a..6915a91 100644 --- a/src/internal/lib/config.go +++ b/src/internal/lib/config.go @@ -34,7 +34,7 @@ func InitConfig() error { } func getOrCreateConfigFile() (string, error) { - path := getPath() + path := sys.ExpandPath(getPath()) if !sys.FileExists(path) { f, err := sys.CreateFile(path) if err != nil { diff --git a/src/internal/lib/lib.go b/src/internal/lib/lib.go index a15dc50..7090a93 100644 --- a/src/internal/lib/lib.go +++ b/src/internal/lib/lib.go @@ -13,8 +13,8 @@ import ( "sync" sys "github.com/8bitalex/raid/src/internal/sys" - "github.com/8bitalex/raid/src/internal/utils" "github.com/santhosh-tekuri/jsonschema/v6" + "gopkg.in/yaml.v3" ) //go:embed schemas/*.json @@ -207,21 +207,38 @@ func validateFile(path string, sch *jsonschema.Schema) error { } defer f.Close() - var reader io.Reader = f ext := strings.ToLower(filepath.Ext(path)) if ext == ".yaml" || ext == ".yml" { - data, err := utils.YAMLToJSON(f) - if err != nil { - return err + // Validate every document in a multi-doc YAML stream individually so + // that profile files using --- separators are fully validated. + dec := yaml.NewDecoder(f) + for { + var raw any + if err := dec.Decode(&raw); err != nil { + if err == io.EOF { + break + } + return err + } + jsonBytes, err := json.Marshal(raw) + if err != nil { + return err + } + doc, err := jsonschema.UnmarshalJSON(bytes.NewReader(jsonBytes)) + if err != nil { + return err + } + if err := sch.Validate(doc); err != nil { + return fmt.Errorf("invalid format: %w", err) + } } - reader = bytes.NewReader(data) + return nil } - doc, err := jsonschema.UnmarshalJSON(reader) + doc, err := jsonschema.UnmarshalJSON(f) if err != nil { return err } - if err := sch.Validate(doc); err != nil { return fmt.Errorf("invalid format: %w", err) } diff --git a/src/internal/lib/task_runner.go b/src/internal/lib/task_runner.go index 881c417..264e694 100644 --- a/src/internal/lib/task_runner.go +++ b/src/internal/lib/task_runner.go @@ -370,8 +370,9 @@ func execGit(task Task) error { } } - if !sys.FileExists(dir) { - return fmt.Errorf("directory does not exist: %s", dir) + info, statErr := os.Stat(dir) + if statErr != nil || !info.IsDir() { + return fmt.Errorf("path is not a directory: %s", dir) } var args []string From ff78a43b92c46f4824b00143626f6d9b4b725304 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 13 Mar 2026 19:23:18 -0700 Subject: [PATCH 29/42] fix: validate JSON arrays element-by-element and apply withDefaultDir to Groups - validateFile now detects top-level JSON arrays and validates each element individually, matching extractProfilesFromJSON which supports both single- object and array-of-objects profile files - ForceLoad now applies withDefaultDir to profile.Groups so Shell tasks inside Group/Parallel/Retry tasks use the home dir as default working directory, consistent with Commands tasks Co-Authored-By: Claude Sonnet 4.6 --- src/internal/lib/lib.go | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/src/internal/lib/lib.go b/src/internal/lib/lib.go index 7090a93..8db1a85 100644 --- a/src/internal/lib/lib.go +++ b/src/internal/lib/lib.go @@ -63,6 +63,9 @@ func ForceLoad() error { for i := range profile.Commands { profile.Commands[i].Tasks = withDefaultDir(profile.Commands[i].Tasks, homeDir) } + for name, tasks := range profile.Groups { + profile.Groups[name] = withDefaultDir(tasks, homeDir) + } for i := range profile.Repositories { if err := buildRepo(&profile.Repositories[i]); err != nil { @@ -235,7 +238,33 @@ func validateFile(path string, sch *jsonschema.Schema) error { return nil } - doc, err := jsonschema.UnmarshalJSON(f) + data, err := io.ReadAll(f) + if err != nil { + return err + } + + // Detect a top-level JSON array and validate each element individually, + // mirroring how extractProfilesFromJSON supports both single-object and + // array-of-objects JSON profile files. + var arr []any + if json.Unmarshal(data, &arr) == nil { + for _, elem := range arr { + jsonBytes, err := json.Marshal(elem) + if err != nil { + return err + } + doc, err := jsonschema.UnmarshalJSON(bytes.NewReader(jsonBytes)) + if err != nil { + return err + } + if err := sch.Validate(doc); err != nil { + return fmt.Errorf("invalid format: %w", err) + } + } + return nil + } + + doc, err := jsonschema.UnmarshalJSON(bytes.NewReader(data)) if err != nil { return err } From c994ad73420a3fd4ab4888a745e68c979fab0951 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 13 Mar 2026 19:38:35 -0700 Subject: [PATCH 30/42] test: use exit 0 instead of test -n in literal shell task test test -n is a POSIX shell built-in unavailable in cmd.exe on Windows, causing the test to fail in the windows-latest CI matrix. exit 0 is universally available in both cmd and POSIX shells. Co-Authored-By: Claude Sonnet 4.6 --- src/internal/lib/task_runner_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/internal/lib/task_runner_test.go b/src/internal/lib/task_runner_test.go index d5da4a3..36e89fe 100644 --- a/src/internal/lib/task_runner_test.go +++ b/src/internal/lib/task_runner_test.go @@ -199,7 +199,7 @@ func TestExecuteTask_shell_literal_skipsGoExpansion(t *testing.T) { os.Setenv("RAID_LITERAL_TEST", "value") defer os.Unsetenv("RAID_LITERAL_TEST") - task := Task{Type: Shell, Cmd: "test -n anything", Literal: true} + task := Task{Type: Shell, Cmd: "exit 0", Literal: true} if err := ExecuteTask(task); err != nil { t.Errorf("literal task returned unexpected error: %v", err) } From cb5202a8ef16708f8422b855ad4395c803f1c323 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 13 Mar 2026 19:38:57 -0700 Subject: [PATCH 31/42] fix: error on empty YAML in validateFile An empty YAML file produced zero decode iterations, so validateFile returned nil even when the schema required fields like 'name'. Track the document count and return an error when the stream contains no documents. Co-Authored-By: Claude Sonnet 4.6 --- src/internal/lib/lib.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/internal/lib/lib.go b/src/internal/lib/lib.go index 8db1a85..b13099f 100644 --- a/src/internal/lib/lib.go +++ b/src/internal/lib/lib.go @@ -215,6 +215,7 @@ func validateFile(path string, sch *jsonschema.Schema) error { // Validate every document in a multi-doc YAML stream individually so // that profile files using --- separators are fully validated. dec := yaml.NewDecoder(f) + count := 0 for { var raw any if err := dec.Decode(&raw); err != nil { @@ -223,6 +224,7 @@ func validateFile(path string, sch *jsonschema.Schema) error { } return err } + count++ jsonBytes, err := json.Marshal(raw) if err != nil { return err @@ -235,6 +237,9 @@ func validateFile(path string, sch *jsonschema.Schema) error { return fmt.Errorf("invalid format: %w", err) } } + if count == 0 { + return fmt.Errorf("invalid format: file contains no YAML documents") + } return nil } From e03c8313f7fa3b007f295c41c7e5da9f91fb2564 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 13 Mar 2026 19:39:12 -0700 Subject: [PATCH 32/42] fix: handle -c=path shorthand and -- end-of-flags in applyConfigFlag Previously applyConfigFlag missed the -c=value shorthand form and would continue scanning positional args after a -- end-of-flags marker, risking picking up the wrong config path or treating positional args as flags. Co-Authored-By: Claude Sonnet 4.6 --- src/cmd/raid.go | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/cmd/raid.go b/src/cmd/raid.go index 2f962b8..01eebef 100644 --- a/src/cmd/raid.go +++ b/src/cmd/raid.go @@ -67,15 +67,23 @@ func Execute() { // applyConfigFlag scans args for --config / -c so the config path is set // before the pre-initialization call, matching cobra's later flag parsing. +// Scanning stops at the first -- end-of-flags marker. func applyConfigFlag(args []string) { for i, arg := range args { - if (arg == "--config" || arg == "-c") && i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") { - *raid.ConfigPath = args[i+1] + if arg == "--" { return } if strings.HasPrefix(arg, "--config=") { *raid.ConfigPath = strings.TrimPrefix(arg, "--config=") return } + if strings.HasPrefix(arg, "-c=") { + *raid.ConfigPath = strings.TrimPrefix(arg, "-c=") + return + } + if (arg == "--config" || arg == "-c") && i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") { + *raid.ConfigPath = args[i+1] + return + } } } From 23df80fd2453abcad680b3d963df8ba263a77fbb Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 13 Mar 2026 19:45:11 -0700 Subject: [PATCH 33/42] fix: return error from Write/Set instead of panicking config.Write() panicked on viper.WriteConfig failure, which crashes the process instead of letting callers handle the error. Changed Write() and Set() to return error, and propagated through SetProfile, AddProfile, AddProfiles, RemoveProfile, and SetEnv. Co-Authored-By: Claude Sonnet 4.6 --- src/cmd/profile/add.go | 10 ++++++++-- src/internal/lib/config.go | 12 ++++++------ src/internal/lib/config_test.go | 4 +++- src/internal/lib/env.go | 3 +-- src/internal/lib/lib_test.go | 32 ++++++++++++++++++++++++-------- src/internal/lib/profile.go | 18 +++++++++--------- src/internal/lib/profile_test.go | 30 ++++++++++++++++++++++-------- src/raid/profile/profile.go | 8 ++++---- 8 files changed, 77 insertions(+), 40 deletions(-) diff --git a/src/cmd/profile/add.go b/src/cmd/profile/add.go index 4c71fbb..0c15280 100644 --- a/src/cmd/profile/add.go +++ b/src/cmd/profile/add.go @@ -52,10 +52,16 @@ var AddProfileCmd = &cobra.Command{ os.Exit(0) } - pro.AddAll(newProfiles) + if err := pro.AddAll(newProfiles); err != nil { + fmt.Printf("Failed to save profiles: %v\n", err) + os.Exit(1) + } if pro.Get().IsZero() { - pro.Set(newProfiles[0].Name) + if err := pro.Set(newProfiles[0].Name); err != nil { + fmt.Printf("Failed to set active profile: %v\n", err) + os.Exit(1) + } fmt.Printf("Profile '%s' set as active\n", newProfiles[0].Name) } diff --git a/src/internal/lib/config.go b/src/internal/lib/config.go index 6915a91..27cb52e 100644 --- a/src/internal/lib/config.go +++ b/src/internal/lib/config.go @@ -52,13 +52,13 @@ func getPath() string { return CfgPath } -func Set(key string, value any) { +// Set stores key in the viper config and persists it to disk. +func Set(key string, value any) error { viper.Set(key, value) - Write() + return Write() } -func Write() { - if err := viper.WriteConfig(); err != nil { - panic(err) - } +// Write persists the current viper config to disk. +func Write() error { + return viper.WriteConfig() } diff --git a/src/internal/lib/config_test.go b/src/internal/lib/config_test.go index e4046c0..785beca 100644 --- a/src/internal/lib/config_test.go +++ b/src/internal/lib/config_test.go @@ -93,7 +93,9 @@ func TestInitConfig_invalidTOML(t *testing.T) { func TestSet_persistsKeyInViper(t *testing.T) { setupTestConfig(t) - Set("testkey", "testvalue") + if err := Set("testkey", "testvalue"); err != nil { + t.Fatalf("Set() error: %v", err) + } if got := viper.GetString("testkey"); got != "testvalue" { t.Errorf("Set() did not persist key: got %q, want %q", got, "testvalue") diff --git a/src/internal/lib/env.go b/src/internal/lib/env.go index fa9f53e..ef4f264 100644 --- a/src/internal/lib/env.go +++ b/src/internal/lib/env.go @@ -35,8 +35,7 @@ func SetEnv(name string) error { return fmt.Errorf("environment '%s' not found", name) } - Set(activeEnvKey, name) - return nil + return Set(activeEnvKey, name) } // GetEnv returns the name of the currently active environment. diff --git a/src/internal/lib/lib_test.go b/src/internal/lib/lib_test.go index 69c5df2..e36310e 100644 --- a/src/internal/lib/lib_test.go +++ b/src/internal/lib/lib_test.go @@ -79,8 +79,12 @@ func TestForceLoad_buildProfileError(t *testing.T) { // badfield violates additionalProperties:false in the profile schema. os.WriteFile(profilePath, []byte("name: test\nbadfield: invalid"), 0644) - AddProfile(Profile{Name: "test", Path: profilePath}) - SetProfile("test") + if err := AddProfile(Profile{Name: "test", Path: profilePath}); err != nil { + t.Fatalf("AddProfile() error: %v", err) + } + if err := SetProfile("test"); err != nil { + t.Fatalf("SetProfile() error: %v", err) + } if err := ForceLoad(); err == nil { t.Fatal("ForceLoad() expected error when buildProfile fails") @@ -106,8 +110,12 @@ func TestForceLoad_buildRepoError(t *testing.T) { os.Chdir(root) defer os.Chdir(wd) - AddProfile(Profile{Name: "buildrepoerr", Path: profilePath}) - SetProfile("buildrepoerr") + if err := AddProfile(Profile{Name: "buildrepoerr", Path: profilePath}); err != nil { + t.Fatalf("AddProfile() error: %v", err) + } + if err := SetProfile("buildrepoerr"); err != nil { + t.Fatalf("SetProfile() error: %v", err) + } if err := ForceLoad(); err == nil { t.Fatal("ForceLoad() expected error when buildRepo fails") @@ -147,8 +155,12 @@ func TestForceLoad_withValidProfile(t *testing.T) { } defer os.Chdir(wd) - AddProfile(Profile{Name: "testprofile", Path: profilePath}) - SetProfile("testprofile") + if err := AddProfile(Profile{Name: "testprofile", Path: profilePath}); err != nil { + t.Fatalf("AddProfile() error: %v", err) + } + if err := SetProfile("testprofile"); err != nil { + t.Fatalf("SetProfile() error: %v", err) + } if err := ForceLoad(); err != nil { t.Errorf("ForceLoad() with valid profile error: %v", err) @@ -402,8 +414,12 @@ func TestForceLoad_mergesRepoCommands(t *testing.T) { os.Chdir(root) defer os.Chdir(wd) - AddProfile(Profile{Name: "mergetest", Path: profilePath}) - SetProfile("mergetest") + if err := AddProfile(Profile{Name: "mergetest", Path: profilePath}); err != nil { + t.Fatalf("AddProfile() error: %v", err) + } + if err := SetProfile("mergetest"); err != nil { + t.Fatalf("SetProfile() error: %v", err) + } if err := ForceLoad(); err != nil { t.Fatalf("ForceLoad() error: %v", err) diff --git a/src/internal/lib/profile.go b/src/internal/lib/profile.go index c28e7e4..3c364e4 100644 --- a/src/internal/lib/profile.go +++ b/src/internal/lib/profile.go @@ -48,8 +48,7 @@ func SetProfile(name string) error { if !ContainsProfile(name) { return fmt.Errorf("profile '%s' not found", name) } - Set(activeProfileKey, name) - return nil + return Set(activeProfileKey, name) } // GetProfile returns the currently active profile. @@ -67,21 +66,23 @@ func GetProfile() Profile { } // AddProfile registers a profile in the config store. -func AddProfile(profile Profile) { +func AddProfile(profile Profile) error { profiles := viper.GetStringMapString(allProfilesKey) if profiles == nil { profiles = make(map[string]string) } - profiles[profile.Name] = profile.Path - Set(allProfilesKey, profiles) + return Set(allProfilesKey, profiles) } // AddProfiles registers multiple profiles in the config store. -func AddProfiles(profiles []Profile) { +func AddProfiles(profiles []Profile) error { for _, profile := range profiles { - AddProfile(profile) + if err := AddProfile(profile); err != nil { + return err + } } + return nil } // ListProfiles returns all registered profiles. @@ -112,8 +113,7 @@ func RemoveProfile(name string) error { return fmt.Errorf("profile '%s' not found", name) } delete(profiles, name) - Set(allProfilesKey, profiles) - return nil + return Set(allProfilesKey, profiles) } // ExtractProfile reads and returns a single named profile from the given file. diff --git a/src/internal/lib/profile_test.go b/src/internal/lib/profile_test.go index c59f4f2..bf52711 100644 --- a/src/internal/lib/profile_test.go +++ b/src/internal/lib/profile_test.go @@ -56,7 +56,9 @@ func TestProfileGetEnv(t *testing.T) { func TestAddAndContainsProfile(t *testing.T) { setupTestConfig(t) - AddProfile(Profile{Name: "myprofile", Path: "/some/path"}) + if err := AddProfile(Profile{Name: "myprofile", Path: "/some/path"}); err != nil { + t.Fatalf("AddProfile() error: %v", err) + } if !ContainsProfile("myprofile") { t.Error("ContainsProfile() = false after AddProfile(), want true") @@ -74,8 +76,12 @@ func TestContainsProfile_notFound(t *testing.T) { func TestListProfiles(t *testing.T) { setupTestConfig(t) - AddProfile(Profile{Name: "list-a", Path: "/a"}) - AddProfile(Profile{Name: "list-b", Path: "/b"}) + if err := AddProfile(Profile{Name: "list-a", Path: "/a"}); err != nil { + t.Fatalf("AddProfile() error: %v", err) + } + if err := AddProfile(Profile{Name: "list-b", Path: "/b"}); err != nil { + t.Fatalf("AddProfile() error: %v", err) + } profiles := ListProfiles() names := make(map[string]bool) @@ -90,10 +96,12 @@ func TestListProfiles(t *testing.T) { func TestAddProfiles(t *testing.T) { setupTestConfig(t) - AddProfiles([]Profile{ + if err := AddProfiles([]Profile{ {Name: "bulk-a", Path: "/a"}, {Name: "bulk-b", Path: "/b"}, - }) + }); err != nil { + t.Fatalf("AddProfiles() error: %v", err) + } if !ContainsProfile("bulk-a") || !ContainsProfile("bulk-b") { t.Error("AddProfiles() did not add all profiles") @@ -103,7 +111,9 @@ func TestAddProfiles(t *testing.T) { func TestRemoveProfile(t *testing.T) { setupTestConfig(t) - AddProfile(Profile{Name: "toremove", Path: "/path"}) + if err := AddProfile(Profile{Name: "toremove", Path: "/path"}); err != nil { + t.Fatalf("AddProfile() error: %v", err) + } if err := RemoveProfile("toremove"); err != nil { t.Fatalf("RemoveProfile() error: %v", err) } @@ -115,7 +125,9 @@ func TestRemoveProfile(t *testing.T) { func TestRemoveProfile_notFound(t *testing.T) { setupTestConfig(t) - AddProfile(Profile{Name: "existing", Path: "/path"}) + if err := AddProfile(Profile{Name: "existing", Path: "/path"}); err != nil { + t.Fatalf("AddProfile() error: %v", err) + } err := RemoveProfile("nonexistent") if err == nil { t.Fatal("RemoveProfile() expected error for nonexistent profile") @@ -143,7 +155,9 @@ func TestSetProfile_notFound(t *testing.T) { func TestSetAndGetProfile(t *testing.T) { setupTestConfig(t) - AddProfile(Profile{Name: "active", Path: "/active/path"}) + if err := AddProfile(Profile{Name: "active", Path: "/active/path"}); err != nil { + t.Fatalf("AddProfile() error: %v", err) + } if err := SetProfile("active"); err != nil { t.Fatalf("SetProfile() error: %v", err) } diff --git a/src/raid/profile/profile.go b/src/raid/profile/profile.go index 85717d1..976b6d1 100644 --- a/src/raid/profile/profile.go +++ b/src/raid/profile/profile.go @@ -16,13 +16,13 @@ func ListAll() []Profile { } // Adds a profile to the available profile list -func Add(profile Profile) { - lib.AddProfile(profile) +func Add(profile Profile) error { + return lib.AddProfile(profile) } // Adds multiple profiles to the profile list -func AddAll(profiles []Profile) { - lib.AddProfiles(profiles) +func AddAll(profiles []Profile) error { + return lib.AddProfiles(profiles) } // Sets the active profile From cb9b37ae99dcbda6376eff038d9aaaff36bc56be Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 13 Mar 2026 19:45:16 -0700 Subject: [PATCH 34/42] fix: discard stdout/stderr during condition command evaluation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Condition task's Cmd ran with inherited stdout/stderr, polluting the terminal output with the condition check's output. Condition evaluation is a boolean probe — its output is noise to the user. Co-Authored-By: Claude Sonnet 4.6 --- src/internal/lib/task_runner.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/internal/lib/task_runner.go b/src/internal/lib/task_runner.go index 264e694..445254c 100644 --- a/src/internal/lib/task_runner.go +++ b/src/internal/lib/task_runner.go @@ -47,6 +47,8 @@ func evaluateCondition(c *Condition) bool { if c.Cmd != "" { shell := getShell("") cmd := exec.Command(shell[0], append(shell[1:], c.Cmd)...) + cmd.Stdout = io.Discard + cmd.Stderr = io.Discard if err := cmd.Run(); err != nil { return false } From 33ee89695e48f66f8cdf366c456350a711f487b4 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 13 Mar 2026 19:45:21 -0700 Subject: [PATCH 35/42] fix: create parent directories before opening command output file os.Create fails when the parent directory does not exist. Added MkdirAll before creating the output file, matching the behaviour of the HTTP download task. Updated the test to use a path whose parent is a regular file so MkdirAll itself fails. Co-Authored-By: Claude Sonnet 4.6 --- src/internal/lib/command.go | 4 ++++ src/internal/lib/command_test.go | 13 +++++++++++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/internal/lib/command.go b/src/internal/lib/command.go index a5f194b..1f67f50 100644 --- a/src/internal/lib/command.go +++ b/src/internal/lib/command.go @@ -4,6 +4,7 @@ import ( "fmt" "io" "os" + "path/filepath" "github.com/8bitalex/raid/src/internal/sys" ) @@ -68,6 +69,9 @@ func runCommand(cmd Command) error { if cmd.Out.File != "" { expanded := sys.ExpandPath(cmd.Out.File) + if err := os.MkdirAll(filepath.Dir(expanded), 0755); err != nil { + return fmt.Errorf("failed to create output directory for '%s': %w", cmd.Out.File, err) + } f, err := os.Create(expanded) if err != nil { return fmt.Errorf("failed to open output file '%s': %w", cmd.Out.File, err) diff --git a/src/internal/lib/command_test.go b/src/internal/lib/command_test.go index 5203630..a063e3c 100644 --- a/src/internal/lib/command_test.go +++ b/src/internal/lib/command_test.go @@ -5,6 +5,7 @@ import ( "io" "os" "path/filepath" + "runtime" "strings" "testing" ) @@ -197,8 +198,16 @@ func TestRunCommand_fileOutput(t *testing.T) { } func TestRunCommand_fileCreateError(t *testing.T) { - // A parent directory that doesn't exist causes os.Create to fail. - outFile := filepath.Join(t.TempDir(), "nonexistent", "output.txt") + if runtime.GOOS == "windows" { + t.Skip("permission-based path tricks don't apply on Windows") + } + // Use a path whose parent is a regular file, so MkdirAll fails. + dir := t.TempDir() + blockingFile := filepath.Join(dir, "not-a-dir") + if err := os.WriteFile(blockingFile, []byte{}, 0644); err != nil { + t.Fatal(err) + } + outFile := filepath.Join(blockingFile, "output.txt") cmd := Command{ Name: "bad-file", Tasks: []Task{{Type: Shell, Cmd: "exit 0"}}, From 059299bca05eef955c52797e723ba836343bcb0e Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 13 Mar 2026 20:02:39 -0700 Subject: [PATCH 36/42] fix: treat non-2xx responses as not-ready in checkHTTP checkHTTP returned nil for any HTTP response including 4xx/5xx, causing execWait to consider a server that is returning errors as ready and stop waiting prematurely. Co-Authored-By: Claude Sonnet 4.6 --- src/internal/lib/task_runner.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/internal/lib/task_runner.go b/src/internal/lib/task_runner.go index 445254c..4102401 100644 --- a/src/internal/lib/task_runner.go +++ b/src/internal/lib/task_runner.go @@ -249,6 +249,8 @@ func execHTTP(task Task) error { defer f.Close() if _, err := io.Copy(f, resp.Body); err != nil { + f.Close() + os.Remove(task.Dest) return fmt.Errorf("failed to write to '%s': %w", task.Dest, err) } @@ -296,6 +298,9 @@ func checkHTTP(url string) error { return err } resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("HTTP %d", resp.StatusCode) + } return nil } From 9d015825f6b604d74d22510afca68e74c740c893 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 13 Mar 2026 20:11:19 -0700 Subject: [PATCH 37/42] fix: add pwsh and ps to shell enum in both schemas getShell() accepts "pwsh" and "ps" as aliases for PowerShell, but neither schema listed them, causing schema validation to reject otherwise-valid configs. Both the root schema and the embedded runtime copy are updated together. Co-Authored-By: Claude Sonnet 4.6 --- schemas/raid-defs.schema.json | 2 +- src/internal/lib/schemas/raid-defs.schema.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/schemas/raid-defs.schema.json b/schemas/raid-defs.schema.json index f75b6f8..584fdbd 100644 --- a/schemas/raid-defs.schema.json +++ b/schemas/raid-defs.schema.json @@ -22,7 +22,7 @@ "cmd": { "type": "string", "description": "Command to execute" }, "shell": { "type": "string", - "enum": ["bash", "sh", "zsh", "powershell", "cmd"], + "enum": ["bash", "sh", "zsh", "powershell", "pwsh", "ps", "cmd"], "description": "Shell to use (default: bash)" }, "literal": { diff --git a/src/internal/lib/schemas/raid-defs.schema.json b/src/internal/lib/schemas/raid-defs.schema.json index f75b6f8..584fdbd 100644 --- a/src/internal/lib/schemas/raid-defs.schema.json +++ b/src/internal/lib/schemas/raid-defs.schema.json @@ -22,7 +22,7 @@ "cmd": { "type": "string", "description": "Command to execute" }, "shell": { "type": "string", - "enum": ["bash", "sh", "zsh", "powershell", "cmd"], + "enum": ["bash", "sh", "zsh", "powershell", "pwsh", "ps", "cmd"], "description": "Shell to use (default: bash)" }, "literal": { From b548abeb4dab8f218f17df384e556dbbd4fa3e5f Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 13 Mar 2026 20:11:36 -0700 Subject: [PATCH 38/42] fix: validate that Cmd is non-empty in execShell An empty Cmd would silently invoke the shell with no arguments and exit 0, masking misconfigurations. Return a clear error instead, consistent with the required-field checks in HTTP/Wait/Template tasks. Co-Authored-By: Claude Sonnet 4.6 --- src/internal/lib/task_runner.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/internal/lib/task_runner.go b/src/internal/lib/task_runner.go index 4102401..77a5b55 100644 --- a/src/internal/lib/task_runner.go +++ b/src/internal/lib/task_runner.go @@ -141,6 +141,9 @@ func execShell(task Task) error { if !task.Literal { task = task.Expand() } + if task.Cmd == "" { + return fmt.Errorf("cmd is required for Shell task") + } shell := getShell(task.Shell) cmd := exec.Command(shell[0], append(shell[1:], task.Cmd)...) From 716c0abd61b46e9646496bbda5ea0773c4257362 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 13 Mar 2026 20:12:20 -0700 Subject: [PATCH 39/42] fix: serialize stdin reads with a mutex in Prompt and Confirm Both tasks read from os.Stdin and can be marked concurrent:true. Without synchronization, concurrent Prompt/Confirm tasks interleave reads, hang, or consume each other's input. A package-level stdinMu mutex ensures stdin is held exclusively for the duration of each prompt, making concurrent execution effectively sequential for user input. Co-Authored-By: Claude Sonnet 4.6 --- src/internal/lib/task_runner.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/internal/lib/task_runner.go b/src/internal/lib/task_runner.go index 77a5b55..cb572a0 100644 --- a/src/internal/lib/task_runner.go +++ b/src/internal/lib/task_runner.go @@ -23,6 +23,10 @@ var ( commandStderr io.Writer = os.Stderr ) +// stdinMu serializes all stdin reads so that concurrent Prompt and Confirm +// tasks do not interleave reads or compete for input. +var stdinMu sync.Mutex + var colorCodes = map[string]string{ "red": "\033[31m", "green": "\033[32m", @@ -431,6 +435,10 @@ func execPrompt(task Task) error { if message == "" { message = fmt.Sprintf("Enter value for %s:", task.Var) } + + stdinMu.Lock() + defer stdinMu.Unlock() + fmt.Fprint(commandStdout, message+" ") reader := bufio.NewReader(os.Stdin) @@ -453,6 +461,10 @@ func execConfirm(task Task) error { if message == "" { message = "Continue?" } + + stdinMu.Lock() + defer stdinMu.Unlock() + fmt.Fprint(commandStdout, message+" [y/N] ") reader := bufio.NewReader(os.Stdin) From 996373d5b37a7e5eabb521667b6540ccd279c516 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 13 Mar 2026 20:12:31 -0700 Subject: [PATCH 40/42] fix: add stage prefix to Initialize() fatal error messages Without context, a fatal during initialization only shows the raw error string with no indication of which stage failed. Adding "init config:" and "load profile:" prefixes makes CLI failures easier to diagnose. Co-Authored-By: Claude Sonnet 4.6 --- src/raid/raid.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/raid/raid.go b/src/raid/raid.go index ca9d9b1..fcd7c74 100644 --- a/src/raid/raid.go +++ b/src/raid/raid.go @@ -38,10 +38,10 @@ var ConfigPath = &lib.CfgPath // Initialize the raid environment, including loading configurations and initializing data storage. func Initialize() { if err := lib.InitConfig(); err != nil { - log.Fatalf("%v", err) + log.Fatalf("init config: %v", err) } if err := Load(); err != nil { - log.Fatalf("%v", err) + log.Fatalf("load profile: %v", err) } if err := lib.LoadEnv(); err != nil { fmt.Fprintf(os.Stderr, "%v\n", err) From f0a5a76b0f4f14ea4153d81bb4c5c461fbddde9f Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 13 Mar 2026 20:12:57 -0700 Subject: [PATCH 41/42] fix: warn to stderr when a profile command shadows a built-in Commands whose names match reserved subcommands were silently skipped, giving no indication in help output or logs. Now emits a warning to stderr so users know why their command is missing. Co-Authored-By: Claude Sonnet 4.6 --- src/cmd/raid.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/cmd/raid.go b/src/cmd/raid.go index 01eebef..8347b34 100644 --- a/src/cmd/raid.go +++ b/src/cmd/raid.go @@ -1,6 +1,7 @@ package cmd import ( + "fmt" "log" "os" "strings" @@ -48,6 +49,7 @@ func Execute() { for _, cmd := range raid.GetCommands() { if reservedNames[cmd.Name] { + fmt.Fprintf(os.Stderr, "warning: command '%s' conflicts with a built-in subcommand and will be ignored\n", cmd.Name) continue } name := cmd.Name From ad9488bd7896fc0bef2872a4e96e221022a050f5 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 13 Mar 2026 20:14:53 -0700 Subject: [PATCH 42/42] fix: nil-check context before dereferencing in Install Install() accessed context.Profile unconditionally. If called before Initialize() sets the context, this panics. Added a nil guard consistent with the pattern used in ExecuteEnv and GetCommands. Co-Authored-By: Claude Sonnet 4.6 --- src/internal/lib/lib.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/internal/lib/lib.go b/src/internal/lib/lib.go index b13099f..0efad9e 100644 --- a/src/internal/lib/lib.go +++ b/src/internal/lib/lib.go @@ -88,6 +88,9 @@ func ForceLoad() error { // Install clones all repositories in the active profile and runs install tasks. func Install(maxThreads int) error { + if context == nil { + return fmt.Errorf("raid context is not initialized") + } profile := context.Profile if profile.IsZero() { return fmt.Errorf("profile not found")