Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions schemas/raid-defs.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -633,6 +633,27 @@
}
},
"additionalProperties": false
},
"verifyArray": {
"type": "array",
"description": "Declarative precondition checks. Each entry runs `tasks:` to assert a dependency or environmental precondition; if any task exits non-zero and `onFail:` is provided, raid runs the remediation once and re-runs `tasks:` exactly once. Verify entries are inert at the CLI today — `raid doctor` (#42) surfaces them as findings. Keep checks small and fast: each entry is run on every doctor invocation.",
"items": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Human-readable label surfaced in failure messages and doctor's findings list."
},
"tasks": {
"$ref": "#/properties/tasks"
},
"onFail": {
"$ref": "#/properties/tasks"
}
},
"required": ["name", "tasks"],
"additionalProperties": false
}
}
}
}
3 changes: 3 additions & 0 deletions schemas/raid-profile.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@
},
"commands": {
"$ref": "https://raidcli.dev/schema/v1/raid-defs.schema.json#/properties/commands"
},
"verify": {
"$ref": "https://raidcli.dev/schema/v1/raid-defs.schema.json#/$defs/verifyArray"
}
},
"required": ["name"]
Expand Down
3 changes: 3 additions & 0 deletions schemas/raid-repo.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@
},
"commands": {
"$ref": "https://raidcli.dev/schema/v1/raid-defs.schema.json#/properties/commands"
},
"verify": {
"$ref": "https://raidcli.dev/schema/v1/raid-defs.schema.json#/$defs/verifyArray"
}
},
"required": ["name","branch"]
Expand Down
1 change: 1 addition & 0 deletions site/docs/references/errors.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ still reflects what the user's script returned.
| `PROFILE_ALREADY_EXISTS` | config | `raid profile add` collided with a registered profile. |
| `REPO_INVALID` | config | A repo entry or repo `raid.yaml` is malformed. |
| `CONFIG_INVALID` | config | The root config (`~/.raid/config.toml`) is malformed. |
| `VERIFY_FAILED` | config | A `verify:` entry failed after its (optional) `onFail:` remediation ran. |
| `CONFIG_LOAD_FAILED` | config | Couldn't load the root config. |
| `SCHEMA_VALIDATION_FAILED` | config | A JSON Schema check failed. |
| `ARG_INVALID` | config | A CLI argument failed validation. |
Expand Down
29 changes: 29 additions & 0 deletions site/docs/references/schema.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ task_groups:
| [`install`](#install) | object | No | Install configuration |
| [`commands`](#command) | list | No | Custom commands available via `raid <name>` |
| [`task_groups`](#task-groups) | map | No | Reusable named task sequences |
| [`verify`](#verify) | list | No | Declarative preconditions surfaced by `raid doctor` |

---

Expand Down Expand Up @@ -206,6 +207,33 @@ install:

---

## Verify

Declarative precondition checks. Each entry runs `tasks:` to assert that a dependency or environmental requirement is in place. An optional `onFail:` remediation gets exactly one chance to fix things — if remediation succeeds, raid re-runs `tasks:` once; if that pass succeeds the verify is reported as remediated, otherwise it fails.

Verify entries are accepted on both profiles and per-repo `raid.yaml` files. They share execution context with `install:` — the active environment, raid vars, and task options all apply. The schema accepts verify entries today; a future release will surface them through `raid doctor` (and, longer-term, a dedicated `raid verify` command).

```yaml
verify:
- name: "Node installed"
tasks:
- type: Shell
cmd: "node --version"
onFail:
- type: Shell
cmd: "nvm install --lts"
```

| Field | Type | Required | Description |
|---|---|---|---|
| `name` | string | Yes | Human-readable label for the precondition. Surfaced in doctor output and structured errors. |
| [`tasks`](#task) | list | Yes | Task sequence asserting the precondition. A non-zero exit anywhere in the sequence marks the verify as failed. |
| [`onFail`](#task) | list | No | Remediation task sequence. Runs once when `tasks:` fails; if it succeeds, `tasks:` is re-run a single time. |

Keep verify checks small and fast — they're meant to be run frequently as part of health diagnostics. Heavy bootstrap work belongs in `install:`.

---

## Task groups

```yaml
Expand Down Expand Up @@ -471,3 +499,4 @@ environments:
| [`install`](#install) | object | No | Install configuration for this repo |
| [`commands`](#command) | list | No | Repo-scoped custom commands |
| [`environments`](#environment) | list | No | Repo-scoped environment variables and tasks |
| [`verify`](#verify) | list | No | Declarative preconditions surfaced by `raid doctor` |
2 changes: 2 additions & 0 deletions site/docs/whats-new.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ User-visible changes per release, latest first. For full commit history see the

## 0.14.0 — upcoming

**Declarative `verify:` blocks.** Profiles and per-repo `raid.yaml`s now accept a top-level `verify:` list. Each entry runs `tasks:` to assert a precondition (a tool is installed, a port is reachable, a credentials file exists), and an optional `onFail:` remediation gets exactly one chance to fix things — if it succeeds, raid re-runs `tasks:` once and the verify is reported as remediated; otherwise it surfaces as a structured `VERIFY_FAILED` error. Verify entries share execution context with `install:` tasks (active env + raid vars + task options). `raid doctor` integration to surface verify entries as health-report findings follows in [#42](https://github.com/8bitAlex/raid/issues/42). See [Schema → Verify](/docs/references/schema#verify). Closes [#38](https://github.com/8bitAlex/raid/issues/38).

**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).

**Best-effort tasks via `options.continueOnFailure`.** Setting `options.continueOnFailure: true` on a task makes its non-zero exit non-fatal — subsequent tasks still run and the command's overall exit code is only affected by *non-ignored* failures. The ignored failure is surfaced as a dim warning on stderr (`warning: <name> failed (continueOnFailure): <err>`) so it stays visible. Useful for cleanup teardown, optional lint/format probes, and best-effort smoke checks that shouldn't block the rest of the command. Closes [#76](https://github.com/8bitAlex/raid/issues/76).
Expand Down
15 changes: 15 additions & 0 deletions src/internal/lib/errs/constructors.go
Original file line number Diff line number Diff line change
Expand Up @@ -281,3 +281,18 @@ func TaskHTTPFailed(url string, cause error) *RaidError {
return newRaidError(CodeTaskHTTPFailed, CategoryNetwork, msg, "",
map[string]any{"task": "HTTP", "url": url}, cause)
}

// VerifyFailed — a `verify:` entry failed after the (optional) onFail
// remediation didn't bring it back. CategoryConfig because the user's
// config asserts a precondition that isn't actually true. The verify
// name is surfaced as a detail so JSON consumers can pivot on it
// without parsing the message.
func VerifyFailed(name string, cause error) *RaidError {
msg := formatMsg("verify %q failed", name)
if cause != nil {
msg = formatMsg("verify %q failed: %v", name, cause)
}
return newRaidError(CodeVerifyFailed, CategoryConfig, msg,
"Fix the underlying dependency or update the verify block to match reality.",
map[string]any{"verify": name}, cause)
}
1 change: 1 addition & 0 deletions src/internal/lib/errs/raid_error.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ const (
CodeRepoNotCloned = "REPO_NOT_CLONED"
CodeEnvNotFound = "ENV_NOT_FOUND"
CodeCommandNotFound = "COMMAND_NOT_FOUND"
CodeVerifyFailed = "VERIFY_FAILED"
)

// RaidError is the canonical implementation of raid's Error interface.
Expand Down
2 changes: 2 additions & 0 deletions src/internal/lib/errs/raid_error_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,8 @@ func TestEveryConstructor_isWellFormed(t *testing.T) {
{"TaskGitFailed(nil)", func() *RaidError { return TaskGitFailed(nil) }, CodeTaskGitFailed},
{"TaskHTTPFailed", func() *RaidError { return TaskHTTPFailed("u", errors.New("c")) }, CodeTaskHTTPFailed},
{"TaskHTTPFailed(nil)", func() *RaidError { return TaskHTTPFailed("u", nil) }, CodeTaskHTTPFailed},
{"VerifyFailed", func() *RaidError { return VerifyFailed("v", errors.New("c")) }, CodeVerifyFailed},
{"VerifyFailed(nil)", func() *RaidError { return VerifyFailed("v", nil) }, CodeVerifyFailed},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
Expand Down
1 change: 1 addition & 0 deletions src/internal/lib/profile.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ type Profile struct {
Install OnInstall `json:"install"`
Groups map[string][]Task `json:"task_groups" yaml:"task_groups"`
Commands []Command `json:"commands"`
Verify []Verify `json:"verify,omitempty"`
}

// IsZero reports whether the profile is uninitialized.
Expand Down
78 changes: 78 additions & 0 deletions src/internal/lib/profile_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -716,6 +716,84 @@ install:
}
}

// TestValidateProfile_verify exercises schema constraints on the
// top-level `verify:` block: that valid entries pass, and that
// entries missing `name` or `tasks` are rejected.
func TestValidateProfile_verify(t *testing.T) {
tests := []struct {
name string
body string
wantErr bool
}{
{
name: "verify accepted with name, tasks, and onFail",
body: `name: t
verify:
- name: Node installed
tasks:
- type: Shell
cmd: node --version
onFail:
- type: Shell
cmd: nvm install --lts
`,
},
{
name: "verify accepted with only name and tasks",
body: `name: t
verify:
- name: simple
tasks:
- type: Print
message: hi
`,
},
{
name: "verify rejected when name is missing",
body: `name: t
verify:
- tasks:
- type: Shell
cmd: node --version
`,
wantErr: true,
},
{
name: "verify rejected when tasks is missing",
body: `name: t
verify:
- name: Node installed
`,
wantErr: true,
},
{
name: "verify rejected with unknown field",
body: `name: t
verify:
- name: x
tasks:
- type: Shell
cmd: exit 0
bogus: nope
`,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "profile.yaml")
if err := os.WriteFile(path, []byte(tt.body), 0644); err != nil {
t.Fatalf("write profile: %v", err)
}
err := ValidateProfile(path)
if (err != nil) != tt.wantErr {
t.Errorf("ValidateProfile() err = %v, wantErr = %v", err, tt.wantErr)
}
})
}
}

// --- WriteProfileFile ---

func TestWriteProfileFile_createsFile(t *testing.T) {
Expand Down
2 changes: 2 additions & 0 deletions src/internal/lib/repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ type Repo struct {
Environments []Env `json:"environments"`
Install OnInstall `json:"install"`
Commands []Command `json:"commands"`
Verify []Verify `json:"verify,omitempty"`
}

// IsZero reports whether the repo is uninitialized. URL is intentionally
Expand Down Expand Up @@ -74,6 +75,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...)
repo.Verify = append(repo.Verify, repoConfig.Verify...)

return nil
}
Expand Down
59 changes: 59 additions & 0 deletions src/internal/lib/repo_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -243,3 +243,62 @@ func TestValidateRepo_schemaViolation(t *testing.T) {
t.Fatal("ValidateRepo() expected error for schema violation")
}
}

// TestValidateRepo_verify exercises the repo schema's top-level
// `verify:` block: that valid entries pass, and that entries missing
// `name` or `tasks` are rejected.
func TestValidateRepo_verify(t *testing.T) {
tests := []struct {
name string
body string
wantErr bool
}{
{
name: "verify accepted with name, tasks, and onFail",
body: `name: r
branch: main
verify:
- name: Go installed
tasks:
- type: Shell
cmd: go version
onFail:
- type: Shell
cmd: brew install go
`,
},
{
name: "verify rejected when name is missing",
body: `name: r
branch: main
verify:
- tasks:
- type: Shell
cmd: go version
`,
wantErr: true,
},
{
name: "verify rejected when tasks is missing",
body: `name: r
branch: main
verify:
- name: Go installed
`,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "raid.yaml")
if err := os.WriteFile(path, []byte(tt.body), 0644); err != nil {
t.Fatalf("write repo: %v", err)
}
err := ValidateRepo(path)
if (err != nil) != tt.wantErr {
t.Errorf("ValidateRepo() err = %v, wantErr = %v", err, tt.wantErr)
}
})
}
}
76 changes: 76 additions & 0 deletions src/internal/lib/verify.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package lib

import (
liberrs "github.com/8bitalex/raid/src/internal/lib/errs"
)

// Verify is a declarative precondition check. Each entry runs `Tasks` —
// if any task exits non-zero, the optional `OnFail` block runs once as
// remediation and then the original `Tasks` re-runs. Verify passes if
// the original or remediated run succeeds; otherwise it's reported as
// failed via errs.VerifyFailed.
//
// Verify entries live at the top level of profiles and per-repo
// raid.yaml files. raid doctor (#42) will surface them as findings in
// its health report; this package provides the runner so any caller
// can drive it the same way doctor will.
type Verify struct {
// Name is the human-readable label surfaced in failure messages
// and (eventually) doctor's findings list. Required.
Name string `json:"name" yaml:"name"`
// Tasks is the precondition assertion. All tasks must exit 0 for
// the verify to pass on the first try.
Tasks []Task `json:"tasks" yaml:"tasks"`
// OnFail is the optional one-shot remediation. When present, a
// first-pass failure triggers OnFail followed by exactly one
// re-run of Tasks. Empty OnFail means the first failure is final.
// The explicit yaml tag is required so YAML's default lowercasing
// doesn't turn `onFail:` into a silently ignored key.
OnFail []Task `json:"onFail,omitempty" yaml:"onFail,omitempty"`
}

// IsZero reports whether the verify is uninitialised — used to skip
// empty entries that the YAML parser might surface from stray list
// items.
func (v Verify) IsZero() bool {
return v.Name == "" && len(v.Tasks) == 0 && len(v.OnFail) == 0
}

// RunVerify executes the verify entry per the documented semantics:
// run Tasks; on failure, if OnFail is set, run it and re-run Tasks
// once. Returns nil on success (including remediated success), or
// errs.VerifyFailed wrapping the underlying cause otherwise.
//
// Tasks run with raid's normal env / raidVars / command-session
// context — same as install: tasks — so verifies see whatever the
// caller has loaded. The caller is responsible for setting up
// `startSession` / `endSession` if it needs session isolation; doctor
// is expected to wrap RunVerify calls accordingly.
func RunVerify(v Verify) error {
if len(v.Tasks) == 0 {
// No-op: an entry with no Tasks asserts nothing, so it
// vacuously passes. Treat as success rather than rejecting
// at the schema layer so doctor can iterate generously.
return nil
}

if err := ExecuteTasks(v.Tasks); err == nil {
return nil
} else if len(v.OnFail) == 0 {
return liberrs.VerifyFailed(v.Name, err)
}

// First pass failed and we have remediation. Run it once.
if err := ExecuteTasks(v.OnFail); err != nil {
// Remediation itself failed — don't retry the asserts; the
// user's fix-up step is broken, that's the more useful
// failure to surface.
return liberrs.VerifyFailed(v.Name, err)
}

// Exactly one retry of the asserts.
if err := ExecuteTasks(v.Tasks); err != nil {
return liberrs.VerifyFailed(v.Name, err)
}
return nil
}
Loading
Loading