diff --git a/docs/commands/actions.md b/docs/commands/actions.md index e5a906b..4766f33 100644 --- a/docs/commands/actions.md +++ b/docs/commands/actions.md @@ -29,9 +29,9 @@ crabbox run --id blue-lobster -- pnpm test Subcommands: ```text -hydrate --id [--repo owner/name] [--workflow ] [--ref ] [--wait-timeout 20m] [--keep-alive-minutes 90] [--reclaim] [--timing-json] [-f key=value] +hydrate --id [--repo owner/name] [--workflow ] [--ref ] [--wait-timeout 20m] [--keep-alive-minutes 90] [--reclaim] [--timing-json] [-f key=value] [--field key=value] register --id [--repo owner/name] [--name ] [--labels ] [--version latest] [--ephemeral=true] [--reclaim] -dispatch [--repo owner/name] [--workflow ] [--ref ] [-f key=value] +dispatch [--repo owner/name] [--workflow ] [--ref ] [-f key=value] [--field key=value] ``` Hydrate/register validate the local repo claim before touching the lease. Use `--reclaim` when intentionally moving a lease to the current repo. @@ -54,7 +54,7 @@ actions: Workflow jobs should target the dynamic label printed by registration, for example `crabbox-cbx-123`, plus any static labels configured for the project. When `actions.job` is set and the workflow declares `crabbox_job`, Crabbox sends it and verifies that the ready marker came from that job. Older workflows can omit both. -Use `actions.fields` for repository-specific workflow inputs that should be sent on every hydration. CLI `-f key=value` values override matching configured fields for that dispatch. +Use `actions.fields` for repository-specific workflow inputs that should be sent on every hydration. CLI `-f key=value` / `--field key=value` values override matching configured fields for that dispatch. ## Hydration Flow diff --git a/internal/cli/actions.go b/internal/cli/actions.go index 0748ff2..ca17e9f 100644 --- a/internal/cli/actions.go +++ b/internal/cli/actions.go @@ -29,7 +29,12 @@ func (r GitHubRepo) Slug() string { func (a App) actions(ctx context.Context, args []string) error { if len(args) == 0 { - return exit(2, "usage: crabbox actions hydrate|register|dispatch") + a.printActionsHelp() + return exit(2, "missing actions subcommand") + } + if wantsHelp(args) { + a.printActionsHelp() + return nil } switch args[0] { case "hydrate": @@ -43,6 +48,47 @@ func (a App) actions(ctx context.Context, args []string) error { } } +func (a App) printActionsHelp() { + fmt.Fprintln(a.Stdout, `Usage: + crabbox actions hydrate --id [flags] + crabbox actions register --id [flags] + crabbox actions dispatch [flags] + +Subcommands: + hydrate Register a runner, dispatch the hydrate workflow, wait for readiness + register Register an existing Linux lease as a GitHub Actions runner + dispatch Dispatch the configured GitHub Actions workflow + +Hydrate Flags: + --id + --repo + --workflow + --ref + --wait-timeout + --keep-alive-minutes + --reclaim + --timing-json + -f, --field + +Register Flags: + --id + --repo + --name + --labels + --version + --ephemeral + --reclaim + +Dispatch Flags: + --repo + --workflow + --ref + -f, --field + +Docs: + docs/commands/actions.md`) +} + func (a App) actionsHydrate(ctx context.Context, args []string) error { started := time.Now() fs := newFlagSet("actions hydrate", a.Stderr) diff --git a/internal/cli/admin.go b/internal/cli/admin.go index d01f7b3..53e7b29 100644 --- a/internal/cli/admin.go +++ b/internal/cli/admin.go @@ -8,7 +8,12 @@ import ( func (a App) admin(ctx context.Context, args []string) error { if len(args) == 0 { - return exit(2, "usage: crabbox admin leases|release|delete") + a.printAdminHelp() + return exit(2, "missing admin subcommand") + } + if wantsHelp(args) { + a.printAdminHelp() + return nil } switch args[0] { case "leases": @@ -22,6 +27,26 @@ func (a App) admin(ctx context.Context, args []string) error { } } +func (a App) printAdminHelp() { + fmt.Fprintln(a.Stdout, `Usage: + crabbox admin leases [flags] + crabbox admin release [flags] + crabbox admin delete --force [flags] + +Subcommands: + leases List coordinator lease records + release Mark a lease released, optionally deleting the backing server + delete Delete the backing server and mark the lease released + +Flags: + leases: --state --owner --org --limit --json + release: --id --delete --json + delete: --id --force --json + +Docs: + docs/commands/admin.md`) +} + func (a App) adminLeases(ctx context.Context, args []string) error { fs := newFlagSet("admin leases", a.Stderr) state := fs.String("state", "", "filter by state") diff --git a/internal/cli/app.go b/internal/cli/app.go index 067f61b..04dcc63 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -246,3 +246,10 @@ func parseFlags(fs *flag.FlagSet, args []string) error { } return nil } + +func wantsHelp(args []string) bool { + if len(args) == 0 { + return false + } + return args[0] == "-h" || args[0] == "--help" || args[0] == "help" +} diff --git a/internal/cli/cache.go b/internal/cli/cache.go index f5329b2..c653b73 100644 --- a/internal/cli/cache.go +++ b/internal/cli/cache.go @@ -16,7 +16,12 @@ type cacheEntry struct { func (a App) cache(ctx context.Context, args []string) error { if len(args) == 0 { - return exit(2, "usage: crabbox cache list|stats|purge|warm") + a.printCacheHelp() + return exit(2, "missing cache subcommand") + } + if wantsHelp(args) { + a.printCacheHelp() + return nil } switch args[0] { case "list", "stats": @@ -30,6 +35,27 @@ func (a App) cache(ctx context.Context, args []string) error { } } +func (a App) printCacheHelp() { + fmt.Fprintln(a.Stdout, `Usage: + crabbox cache stats --id [flags] + crabbox cache list --id [flags] + crabbox cache purge --id --kind --force [flags] + crabbox cache warm --id -- + +Subcommands: + list, stats Show remote cache usage + purge Remove selected cache content + warm Run a command that populates caches + +Flags: + stats/list: --id --reclaim --json + purge: --id --kind pnpm|npm|docker|git|all --force --reclaim + warm: --id --reclaim + +Docs: + docs/commands/cache.md`) +} + func (a App) cacheStats(ctx context.Context, args []string) error { fs := newFlagSet("cache stats", a.Stderr) id := fs.String("id", "", "lease id or slug") diff --git a/internal/cli/config_cmd.go b/internal/cli/config_cmd.go index 8a6eee9..870f1b5 100644 --- a/internal/cli/config_cmd.go +++ b/internal/cli/config_cmd.go @@ -11,7 +11,12 @@ import ( func (a App) config(_ context.Context, args []string) error { if len(args) == 0 { - return exit(2, "usage: crabbox config show|path|set-broker") + a.printConfigHelp() + return exit(2, "missing config subcommand") + } + if wantsHelp(args) { + a.printConfigHelp() + return nil } switch args[0] { case "path": @@ -30,6 +35,25 @@ func (a App) config(_ context.Context, args []string) error { } } +func (a App) printConfigHelp() { + fmt.Fprintln(a.Stdout, `Usage: + crabbox config path + crabbox config show [--json] + crabbox config set-broker --url [flags] + +Subcommands: + path Print the user config path + show Print merged config without secret values + set-broker Store broker URL and optional tokens in user config + +Flags: + show: --json + set-broker: --url --provider hetzner|aws --token-stdin --admin-token-stdin + +Docs: + docs/commands/config.md`) +} + func (a App) configShow(args []string) error { fs := newFlagSet("config show", a.Stderr) jsonOut := fs.Bool("json", false, "print JSON") diff --git a/internal/cli/desktop.go b/internal/cli/desktop.go index f81bc95..2fa4fe6 100644 --- a/internal/cli/desktop.go +++ b/internal/cli/desktop.go @@ -9,7 +9,12 @@ import ( func (a App) desktop(ctx context.Context, args []string) error { if len(args) == 0 { - return exit(2, "usage: crabbox desktop launch --id [--browser] [--url ] -- ") + a.printDesktopHelp() + return exit(2, "missing desktop subcommand") + } + if wantsHelp(args) { + a.printDesktopHelp() + return nil } switch args[0] { case "launch": @@ -19,6 +24,31 @@ func (a App) desktop(ctx context.Context, args []string) error { } } +func (a App) printDesktopHelp() { + fmt.Fprintln(a.Stdout, `Usage: + crabbox desktop launch --id [flags] -- + crabbox desktop launch --id --browser [--url ] + +Subcommands: + launch Start an app inside a desktop lease + +Flags: + --id + --provider hetzner|aws|ssh + --target linux|macos|windows + --windows-mode normal|wsl2 + --static-host + --static-user + --static-port + --static-work-root + --browser + --url + --reclaim + +Docs: + docs/commands/desktop.md`) +} + func (a App) desktopLaunch(ctx context.Context, args []string) error { defaults := defaultConfig() fs := newFlagSet("desktop launch", a.Stderr) diff --git a/internal/cli/init_test.go b/internal/cli/init_test.go index fe5fe0a..a1e68bb 100644 --- a/internal/cli/init_test.go +++ b/internal/cli/init_test.go @@ -81,6 +81,22 @@ func TestSubcommandHelpExitsZero(t *testing.T) { } } +func TestGroupedCommandHelpExitsZero(t *testing.T) { + for _, command := range []string{"actions", "admin", "cache", "config", "desktop", "pool", "machine"} { + t.Run(command, func(t *testing.T) { + var stdout bytes.Buffer + app := App{Stdout: &stdout, Stderr: &bytes.Buffer{}} + err := app.Run(context.Background(), []string{command, "--help"}) + if err != nil { + t.Fatalf("%s --help error=%v, want nil", command, err) + } + if !strings.Contains(stdout.String(), "Usage:") { + t.Fatalf("%s --help output missing usage: %s", command, stdout.String()) + } + }) + } +} + func TestHelpSubcommandRoutesToCommandHelp(t *testing.T) { var stderr bytes.Buffer app := App{Stdout: &bytes.Buffer{}, Stderr: &stderr} diff --git a/internal/cli/pool.go b/internal/cli/pool.go index 9ab766c..0e2b041 100644 --- a/internal/cli/pool.go +++ b/internal/cli/pool.go @@ -9,6 +9,14 @@ import ( ) func (a App) pool(ctx context.Context, args []string) error { + if wantsHelp(args) { + fmt.Fprintln(a.Stdout, `Usage: + crabbox pool list [--json] + +Alias: + crabbox pool list is an alias for crabbox list`) + return nil + } if len(args) == 0 || args[0] != "list" { return exit(2, "usage: crabbox pool list [--json]") } @@ -141,7 +149,20 @@ func coordinatorMachineOrphanField(labels map[string]string, activeLeaseIDs map[ func (a App) machine(ctx context.Context, args []string) error { if len(args) == 0 { - return exit(2, "usage: crabbox machine cleanup [--dry-run]") + fmt.Fprintln(a.Stdout, `Usage: + crabbox machine cleanup [--dry-run] + +Alias: + crabbox machine cleanup is an alias for crabbox cleanup`) + return exit(2, "missing machine subcommand") + } + if wantsHelp(args) { + fmt.Fprintln(a.Stdout, `Usage: + crabbox machine cleanup [--dry-run] + +Alias: + crabbox machine cleanup is an alias for crabbox cleanup`) + return nil } switch args[0] { case "cleanup": diff --git a/scripts/check-command-docs.mjs b/scripts/check-command-docs.mjs index 58b8299..eb01d1f 100644 --- a/scripts/check-command-docs.mjs +++ b/scripts/check-command-docs.mjs @@ -1,5 +1,7 @@ #!/usr/bin/env node +import { spawnSync } from "node:child_process"; import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; const root = process.cwd(); @@ -10,6 +12,7 @@ const commands = readHelpCommands(); const commandNames = commands.map((command) => command.name); const commandSet = new Set(commandNames); const indexEntries = readCommandIndex(); +const cliPath = buildCLIForHelp(); for (const command of commands) { const file = path.join(commandDocsDir, `${command.name}.md`); @@ -21,6 +24,12 @@ for (const command of commands) { if (firstLine !== `# ${command.name}`) { failures.push(`${rel(file)} should start with "# ${command.name}"`); } + const help = runCommandHelp(command.name); + if (help.status !== 0) { + failures.push(`crabbox ${command.name} --help should exit 0, got ${help.status}: ${help.output.trim()}`); + } else if (!help.output.includes("Usage:") && !help.output.includes("Usage of ")) { + failures.push(`crabbox ${command.name} --help should print usage text`); + } } const seenIndexEntries = new Set(); @@ -93,6 +102,40 @@ function readHelpCommands() { return commands; } +function runCommandHelp(command) { + if (!cliPath) { + return { + status: 1, + output: "failed to build crabbox CLI for help checks", + }; + } + const result = spawnSync(cliPath, [command, "--help"], { + cwd: root, + encoding: "utf8", + }); + return { + status: result.status ?? 1, + output: `${result.stdout ?? ""}${result.stderr ?? ""}`, + }; +} + +function buildCLIForHelp() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "crabbox-docs-")); + const binary = path.join(dir, process.platform === "win32" ? "crabbox.exe" : "crabbox"); + process.on("exit", () => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + const result = spawnSync("go", ["build", "-o", binary, "./cmd/crabbox"], { + cwd: root, + encoding: "utf8", + }); + if (result.status !== 0) { + failures.push(`go build ./cmd/crabbox failed: ${`${result.stdout ?? ""}${result.stderr ?? ""}`.trim()}`); + return ""; + } + return binary; +} + function readCommandIndex() { const readmePath = path.join(commandDocsDir, "README.md"); const readme = fs.readFileSync(readmePath, "utf8");