diff --git a/schemas/raid-defs.schema.json b/schemas/raid-defs.schema.json index 680c926..ce51615 100644 --- a/schemas/raid-defs.schema.json +++ b/schemas/raid-defs.schema.json @@ -544,6 +544,9 @@ "tasks": { "$ref": "#/properties/tasks" }, + "options": { + "$ref": "#/$defs/taskOptions" + }, "out": { "type": "object", "description": "Output configuration for the command", @@ -608,8 +611,23 @@ } }, "additionalProperties": false + }, + "options": { + "$ref": "#/$defs/taskOptions" } } + }, + "taskOptions": { + "type": "object", + "description": "Shared task / command options. Compose cleanly across task types and on commands; omitting `options` (or any field within) leaves default behavior unchanged. Additional fields will be added in future releases.", + "properties": { + "showExeTime": { + "type": "boolean", + "description": "When true, print a dim line to stderr after the task (or command) completes showing the elapsed time, e.g. `task-name complete in 1.2s`.", + "default": false + } + }, + "additionalProperties": false } } } diff --git a/site/docs/references/schema.mdx b/site/docs/references/schema.mdx index 3efb7f6..21f8ab5 100644 --- a/site/docs/references/schema.mdx +++ b/site/docs/references/schema.mdx @@ -149,6 +149,7 @@ commands: | `args` | list | No | Declared positional arguments. See [Args](#command-args). | | `flags` | list | No | Declared flags / options. See [Flags](#command-flags). | | [`tasks`](#task) | list | Yes | Task sequence to run | +| [`options`](#options) | object | No | Shared options block. Same shape as on tasks. Fires once per command — independent of per-task `options`. | | [`out`](#output) | object | No | Output configuration | Command names cannot shadow built-in names: `install`, `env`, `profile`, `doctor`. @@ -236,6 +237,41 @@ All tasks share these common fields: | `name` | string | No | Human-readable label, surfaced in logs and agent output. Does not affect execution. | | `concurrent` | bool | No | Run in parallel with adjacent concurrent tasks | | [`condition`](#condition) | object | No | Conditions that must all pass for the task to run | +| [`options`](#options) | object | No | Shared options block. Composes across every task type and is also accepted on commands. | + +### Options + +The `options:` block is shared by every task type and by user-defined commands. Omitting it (or any field within) leaves default behavior unchanged. New fields ship additively. + +| Field | Type | Default | Description | +|---|---|---|---| +| `showExeTime` | bool | `false` | When true, raid prints a dim line to stderr after the task (or command) completes: ` complete in 1.2s`. Fires for both success and failure. On a command this fires once for the whole command's elapsed time; on a task it fires for that single task. The two are independent — set both to time the command end-to-end *and* see per-task breakdowns. | + +```yaml +commands: + - name: build + options: + showExeTime: true # one final line after the whole command + tasks: + - type: Shell + name: server-build + cmd: swift build + options: + showExeTime: true # per-task line too + - type: Shell + name: client-install + cmd: npm install + options: + showExeTime: true +``` + +Output: + +``` +server-build complete in 164.1s +client-install complete in 0.9s +build complete in 165.0s +``` ### Condition diff --git a/site/docs/whats-new.mdx b/site/docs/whats-new.mdx index b88de99..033287e 100644 --- a/site/docs/whats-new.mdx +++ b/site/docs/whats-new.mdx @@ -9,6 +9,10 @@ description: Feature-by-feature release notes for Raid. User-visible changes per release, latest first. For full commit history see the [GitHub releases page](https://github.com/8bitalex/raid/releases). +## 0.14.0 — upcoming + +**Shared `options:` block on every task type.** A new `options:` block on the base task definition composes uniformly across every task type (and on user-defined commands) so cross-cutting fields don't have to be re-declared per type. The initial field, `showExeTime: bool`, prints a dim line to stderr after a task or command completes with the elapsed time: `task-name complete in 1.2s`. Omitting `options` (or any field within it) leaves current behavior unchanged, so the addition is fully backwards compatible. Additional fields (`quiet`, `timeout`, …) will ship additively. Closes [#54](https://github.com/8bitAlex/raid/issues/54). + ## 0.13.0 — upcoming **Structured error output.** Every raid failure now carries a stable `code` (e.g. `PROFILE_NOT_FOUND`, `REPO_NOT_CLONED`, `CLONE_FAILED`, `TASK_SHELL_FAILED`), a category that maps directly to one of five exit codes (`1` generic / `2` config / `3` task / `4` network / `5` not-found), and an optional `hint` explaining what the user can do next. Pass `--json` to any command (including `raid install`, `raid profile add`, custom commands) and errors come back as a single line of `{"error": {"code": "...", "category": "...", "message": "...", "hint": "..."}}` — agents can pivot on the code instead of parsing prose. The `--json` flag itself moved from per-command to a persistent flag on `rootCmd`, so it now works on every command. Error codes are treated as semver-stable: new codes ship additively; existing codes never change name or category across minor versions. See the new [Errors reference](/docs/references/errors) for the full table and JSON shape. Closes [#47](https://github.com/8bitAlex/raid/issues/47). diff --git a/src/internal/lib/command.go b/src/internal/lib/command.go index 658424b..5c9f2f3 100644 --- a/src/internal/lib/command.go +++ b/src/internal/lib/command.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "strings" + "time" liberrs "github.com/8bitalex/raid/src/internal/lib/errs" "github.com/8bitalex/raid/src/internal/sys" @@ -13,12 +14,13 @@ import ( // 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"` - Args []Arg `json:"args,omitempty"` - Flags []Flag `json:"flags,omitempty"` - Tasks []Task `json:"tasks"` - Out *Output `json:"out,omitempty"` + Name string `json:"name"` + Usage string `json:"usage"` + Args []Arg `json:"args,omitempty"` + Flags []Flag `json:"flags,omitempty"` + Tasks []Task `json:"tasks"` + Options *TaskOptions `json:"options,omitempty"` + Out *Output `json:"out,omitempty"` } // Arg declares a positional argument for a custom command. The supplied value @@ -219,13 +221,44 @@ func clearRaidArgs() { } } +// runCommand applies the optional Out wrapping and runs the task +// sequence. Command-level showExeTime is independent of per-task timing — +// both flags can be set together. The command line is emitted after the +// final task (and after any per-task lines) so the timeline reads +// top-down. Like the task variant, it fires for both success and failure +// so the elapsed time is always visible. +// +// The exe-time line is emitted *inside* the Out wrapping so it respects +// `out.stderr: false` (suppression) and `out.file` (capture) the same way +// task output does. Emitting it after the wrappers unwound would bypass +// both, so we keep all emission ordered against the Out lifecycle here. func runCommand(cmd Command) error { + showExeTime := cmd.Options != nil && cmd.Options.ShowExeTime + var start time.Time + if showExeTime { + start = timeNowFn() + } + if cmd.Out == nil { - return ExecuteTasks(cmd.Tasks) + err := ExecuteTasks(cmd.Tasks) + if showExeTime { + emitExeTime(cmd.Name, timeNowFn().Sub(start)) + } + return err } origOut, origErr := commandStdout, commandStderr + var outFile *os.File defer func() { + // Emit the exe-time line BEFORE the file is closed and the + // original writers are restored, so it lands in the same place + // as the task output (file capture, stderr suppression). + if showExeTime { + emitExeTime(cmd.Name, timeNowFn().Sub(start)) + } + if outFile != nil { + outFile.Close() + } commandStdout = origOut commandStderr = origErr }() @@ -246,7 +279,7 @@ func runCommand(cmd Command) error { if err != nil { return liberrs.Newf(liberrs.CodeTaskFailed, liberrs.CategoryTask, "failed to open output file '%s': %v", cmd.Out.File, err) } - defer f.Close() + outFile = f commandStdout = io.MultiWriter(commandStdout, f) commandStderr = io.MultiWriter(commandStderr, f) } diff --git a/src/internal/lib/command_test.go b/src/internal/lib/command_test.go index ff62cde..b5ecfed 100644 --- a/src/internal/lib/command_test.go +++ b/src/internal/lib/command_test.go @@ -8,6 +8,7 @@ import ( "runtime" "strings" "testing" + "time" ) // --- Command.IsZero --- @@ -295,6 +296,94 @@ func TestExecuteCommand_namedBindings_skipsInvalidKeys(t *testing.T) { } } +func TestRunCommand_showExeTime_emitsLine(t *testing.T) { + origNow := timeNowFn + calls := 0 + t0 := time.Date(2026, 5, 12, 0, 0, 0, 0, time.UTC) + timeNowFn = func() time.Time { + defer func() { calls++ }() + if calls == 0 { + return t0 + } + return t0.Add(750 * time.Millisecond) + } + t.Cleanup(func() { timeNowFn = origNow }) + + var buf bytes.Buffer + restore := SetCommandOutput(io.Discard, &buf) + t.Cleanup(restore) + + cmd := Command{ + Name: "build", + Options: &TaskOptions{ShowExeTime: true}, + Tasks: []Task{{Type: Shell, Cmd: "exit 0"}}, + } + if err := runCommand(cmd); err != nil { + t.Fatalf("runCommand: %v", err) + } + if !strings.Contains(buf.String(), "build complete in 750ms") { + t.Errorf("stderr %q should carry command-level exe-time line", buf.String()) + } +} + +func TestRunCommand_showExeTime_respectsOutSuppressionAndFile(t *testing.T) { + // When `out.stderr: false` and `out.file: ...` are both set, the + // exe-time line must (a) NOT appear on the original stderr, and + // (b) BE captured in the output file — same rules as task output. + origNow := timeNowFn + calls := 0 + t0 := time.Date(2026, 5, 12, 0, 0, 0, 0, time.UTC) + timeNowFn = func() time.Time { + defer func() { calls++ }() + if calls == 0 { + return t0 + } + return t0.Add(750 * time.Millisecond) + } + t.Cleanup(func() { timeNowFn = origNow }) + + var stderrBuf bytes.Buffer + restore := SetCommandOutput(io.Discard, &stderrBuf) + t.Cleanup(restore) + + outFile := filepath.Join(t.TempDir(), "captured.txt") + cmd := Command{ + Name: "build", + Options: &TaskOptions{ShowExeTime: true}, + Tasks: []Task{{Type: Shell, Cmd: "exit 0"}}, + Out: &Output{Stdout: true, Stderr: false, File: outFile}, + } + if err := runCommand(cmd); err != nil { + t.Fatalf("runCommand: %v", err) + } + + if strings.Contains(stderrBuf.String(), "build complete in") { + t.Errorf("exe-time line leaked to suppressed stderr: %q", stderrBuf.String()) + } + + data, err := os.ReadFile(outFile) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + if !strings.Contains(string(data), "build complete in 750ms") { + t.Errorf("exe-time line missing from out.file capture: %q", string(data)) + } +} + +func TestRunCommand_showExeTime_off_byDefault(t *testing.T) { + var buf bytes.Buffer + restore := SetCommandOutput(io.Discard, &buf) + t.Cleanup(restore) + + cmd := Command{Name: "noop", Tasks: []Task{{Type: Shell, Cmd: "exit 0"}}} + if err := runCommand(cmd); err != nil { + t.Fatalf("runCommand: %v", err) + } + if buf.Len() != 0 { + t.Errorf("default runCommand must not emit on stderr, got %q", buf.String()) + } +} + func TestExecuteCommand_namedBindings(t *testing.T) { setupTestConfig(t) origOut, origErr := commandStdout, commandStderr diff --git a/src/internal/lib/profile_test.go b/src/internal/lib/profile_test.go index 3134904..78050d9 100644 --- a/src/internal/lib/profile_test.go +++ b/src/internal/lib/profile_test.go @@ -552,6 +552,87 @@ install: } } +// TestValidateProfile_optionsAccepted_onEveryTaskType locks in the +// taskCommon `options` block: every task variant must accept it so +// schema authors don't have to re-declare per type. Also covers the +// command-level options block. +func TestValidateProfile_optionsAccepted_onEveryTaskType(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "profile.yaml") + body := `name: opts +install: + tasks: + - type: Shell + cmd: 'true' + options: {showExeTime: true} + - type: Script + path: ./x.sh + options: {showExeTime: false} + - type: HTTP + url: 'https://example.com/a' + dest: /tmp/a + options: {showExeTime: true} + - type: Wait + url: 'https://example.com/ready' + options: {showExeTime: true} + - type: Template + src: ./t.tmpl + dest: /tmp/out + options: {showExeTime: true} + - type: Git + op: pull + options: {showExeTime: true} + - type: Prompt + var: NAME + options: {showExeTime: true} + - type: Confirm + message: ok? + options: {showExeTime: true} + - type: Print + message: hi + options: {showExeTime: true} + - type: Set + var: X + value: y + options: {showExeTime: true} +commands: + - name: build + options: {showExeTime: true} + tasks: + - type: Shell + cmd: 'true' +` + if err := os.WriteFile(path, []byte(body), 0644); err != nil { + t.Fatalf("setup: %v", err) + } + if err := ValidateProfile(path); err != nil { + t.Fatalf("ValidateProfile() unexpected error: %v", err) + } +} + +func TestValidateProfile_optionsRejectsUnknownField(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "profile.yaml") + // `quiet` is reserved for a future option (#54 mentions it explicitly + // as out of scope). Until it lands, the schema must reject it so + // authors get a clear error instead of silent acceptance. + body := `name: t +install: + tasks: + - type: Shell + cmd: 'true' + options: + showExeTime: true + quiet: true +` + if err := os.WriteFile(path, []byte(body), 0644); err != nil { + t.Fatalf("setup: %v", err) + } + if err := ValidateProfile(path); err == nil { + t.Fatal("ValidateProfile() should reject unknown option fields") + } +} + // TestValidateProfile_taskRejectsUnknownField verifies that an unknown // property on a task is still rejected after the schema was refactored to use // allOf + unevaluatedProperties:false instead of additionalProperties:false on diff --git a/src/internal/lib/task.go b/src/internal/lib/task.go index fbce545..2741dea 100644 --- a/src/internal/lib/task.go +++ b/src/internal/lib/task.go @@ -25,6 +25,30 @@ type TaskProps struct { // Name is an optional human-readable label for the task, surfaced in logs // and agent-facing output. It does not affect execution. Name string `json:"name,omitempty" yaml:"name,omitempty"` + // Options is the shared options block — see TaskOptions. Composes across + // every task type and is also accepted on user-defined commands. Omitting + // the block (or any field within) leaves default behavior unchanged. + Options *TaskOptions `json:"options,omitempty" yaml:"options,omitempty"` +} + +// TaskOptions is the shared `options:` block applied uniformly across all +// task types and to user-defined commands. New fields ship additively; old +// fields keep their semantics across minor versions. +type TaskOptions struct { + // ShowExeTime, when true, prints a dim "→