fix: add grouped command help docs guard

This commit is contained in:
Peter Steinberger 2026-05-04 22:34:31 +01:00
parent 41ad14cc81
commit 50ceed86bf
No known key found for this signature in database
10 changed files with 247 additions and 9 deletions

View File

@ -29,9 +29,9 @@ crabbox run --id blue-lobster -- pnpm test
Subcommands:
```text
hydrate --id <lease-id-or-slug> [--repo owner/name] [--workflow <file|name|id>] [--ref <ref>] [--wait-timeout 20m] [--keep-alive-minutes 90] [--reclaim] [--timing-json] [-f key=value]
hydrate --id <lease-id-or-slug> [--repo owner/name] [--workflow <file|name|id>] [--ref <ref>] [--wait-timeout 20m] [--keep-alive-minutes 90] [--reclaim] [--timing-json] [-f key=value] [--field key=value]
register --id <lease-id-or-slug> [--repo owner/name] [--name <runner-name>] [--labels <csv>] [--version latest] [--ephemeral=true] [--reclaim]
dispatch [--repo owner/name] [--workflow <file|name|id>] [--ref <ref>] [-f key=value]
dispatch [--repo owner/name] [--workflow <file|name|id>] [--ref <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

View File

@ -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 <lease-id-or-slug> [flags]
crabbox actions register --id <lease-id-or-slug> [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 <lease-id-or-slug>
--repo <owner/name>
--workflow <file|name|id>
--ref <ref>
--wait-timeout <duration>
--keep-alive-minutes <n>
--reclaim
--timing-json
-f, --field <key=value>
Register Flags:
--id <lease-id-or-slug>
--repo <owner/name>
--name <runner-name>
--labels <csv>
--version <version|latest>
--ephemeral
--reclaim
Dispatch Flags:
--repo <owner/name>
--workflow <file|name|id>
--ref <ref>
-f, --field <key=value>
Docs:
docs/commands/actions.md`)
}
func (a App) actionsHydrate(ctx context.Context, args []string) error {
started := time.Now()
fs := newFlagSet("actions hydrate", a.Stderr)

View File

@ -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 <lease-id-or-slug> [flags]
crabbox admin delete <lease-id-or-slug> --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 <state> --owner <email> --org <name> --limit <n> --json
release: --id <lease-id-or-slug> --delete --json
delete: --id <lease-id-or-slug> --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")

View File

@ -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"
}

View File

@ -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 <lease-id-or-slug> [flags]
crabbox cache list --id <lease-id-or-slug> [flags]
crabbox cache purge --id <lease-id-or-slug> --kind <kind> --force [flags]
crabbox cache warm --id <lease-id-or-slug> -- <command...>
Subcommands:
list, stats Show remote cache usage
purge Remove selected cache content
warm Run a command that populates caches
Flags:
stats/list: --id <lease-id-or-slug> --reclaim --json
purge: --id <lease-id-or-slug> --kind pnpm|npm|docker|git|all --force --reclaim
warm: --id <lease-id-or-slug> --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")

View File

@ -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 <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 <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")

View File

@ -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 <lease-id-or-slug> [--browser] [--url <url>] -- <command...>")
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 <lease-id-or-slug> [flags] -- <command...>
crabbox desktop launch --id <lease-id-or-slug> --browser [--url <url>]
Subcommands:
launch Start an app inside a desktop lease
Flags:
--id <lease-id-or-slug>
--provider hetzner|aws|ssh
--target linux|macos|windows
--windows-mode normal|wsl2
--static-host <host>
--static-user <user>
--static-port <port>
--static-work-root <path>
--browser
--url <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)

View File

@ -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}

View File

@ -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":

View File

@ -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");