diff --git a/README.md b/README.md index af6e5170..b990ce68 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,16 @@ Commands return JSON envelopes an agent can branch on. Customer surface is content (`app`, `media`, `compose`), playlists, and screens. Full reference: [https://screenrig.ai/docs/cli.md](https://screenrig.ai/docs/cli.md). +Discover commands progressively with `screenrig --help`, `screenrig screen --help`, +and `screenrig screen assign --help`. Deeper groups work the same way: +`screenrig comment show --help`. `screenrig help screen assign` is equivalent. +Add `--json` for structured child command paths, invocation syntax, and option types; +help runs without configuration or authentication. Command-specific options follow +that command, for example `screenrig screen update ID --name Lobby --if-match 1`. +Global options such as `--json` may appear before or after the command. Use +`--name=VALUE` for a value starting with a dash, and `--` before option-like file +names. Duplicate options are rejected. + Choose by what the page is: - Already have the file: `media upload`, then a playlist and `screen assign`. @@ -69,6 +79,11 @@ command. ## Develop +Commander 14 owns argument parsing, command selection, and help rendering while +supporting Node 20.11. The command tree feeds both human and JSON help. The adapter +keeps parser diagnostics inside the CLI error envelope and only forwards explicit +options to handlers, including the existing `no-*` boolean flags. + ```sh npm ci npm run vendor:check diff --git a/package-lock.json b/package-lock.json index 17cd00eb..8955bc7c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,8 @@ "dependencies": { "@napi-rs/canvas": "^1.0.7", "ajv": "8.18.0", - "ajv-formats": "3.0.1" + "ajv-formats": "3.0.1", + "commander": "14.0.3" }, "bin": { "screenrig": "dist/bin.js" @@ -331,6 +332,15 @@ } } }, + "node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", diff --git a/package.json b/package.json index 6bf4db79..82e84196 100644 --- a/package.json +++ b/package.json @@ -95,6 +95,7 @@ "dependencies": { "@napi-rs/canvas": "^1.0.7", "ajv": "8.18.0", - "ajv-formats": "3.0.1" + "ajv-formats": "3.0.1", + "commander": "14.0.3" } } diff --git a/src/adapters/protocol.test.ts b/src/adapters/protocol.test.ts index 076fe4f3..980c3986 100644 --- a/src/adapters/protocol.test.ts +++ b/src/adapters/protocol.test.ts @@ -1,3 +1,4 @@ +import { commandHelp } from "../help.js"; import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { dirname, join } from "node:path"; @@ -859,12 +860,21 @@ test("opaque agent comments are optional on GET resources, dedicated comment rou assert.match(screenComments, /parameters: \[\{ \$ref: "#\/components\/parameters\/IdempotencyKey" \}\]/); assert.doesNotMatch(screenComments, /IfMatch/); - assert.match(commands, /comment show screen /); - assert.match(commands, /comment show playlist \[--page PAGE_ID\]/); - assert.match(commands, /comment set screen \(--json-value JSON \| --file FILE\)/); - assert.match(commands, /comment set playlist \[--page PAGE_ID\] \(--json-value JSON \| --file FILE\)/); - assert.match(commands, /comment delete screen /); - assert.match(commands, /comment delete playlist \[--page PAGE_ID\]/); + for (const action of ["show", "set", "delete"]) { + for (const target of ["screen", "playlist"]) { + const help = commandHelp(["comment", action, target]); + assert.match(help.synopsis[0]!, new RegExp(`comment ${action} ${target} \\[options\\] `)); + const options = new Map(help.options.map((option) => [option.name, option])); + assert.equal(options.has("--page"), target === "playlist"); + if (target === "playlist") assert.equal(options.get("--page")?.type, "value"); + for (const name of ["--json-value", "--file"]) { + assert.equal(options.has(name), action === "set"); + if (action === "set") assert.equal(options.get(name)?.type, "value"); + } + assert.equal(options.has("--if-match"), false); + assert.equal(options.has("--value-base64"), false); + } + } assert.doesNotMatch(commands, /comment\/screen\/:id/); assert.doesNotMatch(commands, /--value-base64.*comment/); }); diff --git a/src/argv.ts b/src/argv.ts index c14ab8f2..eabf4162 100644 --- a/src/argv.ts +++ b/src/argv.ts @@ -1,47 +1,44 @@ -import { BOOLEAN_FLAGS, COMMAND_SPECS, GLOBAL_FLAGS } from "./command-spec.js"; +import { CommanderError } from "commander"; +import { commandPath, createCommandTree, invocationFlags } from "./command-tree.js"; +import { describeHelp, type HelpDocument } from "./help.js"; import { usageError } from "./problems.js"; export interface ParsedArgs { command: string[]; flags: Record; positionals: string[]; + help?: HelpDocument; } export function parseArgv(argv: string[]): ParsedArgs { - const flags: Record = Object.create(null) as Record; - const positionals: string[] = []; - const known = new Set([...GLOBAL_FLAGS, ...COMMAND_SPECS.flatMap((spec) => spec.flags)]); - for (let i = 0; i < argv.length; i += 1) { - const arg = argv[i]!; - if (arg === "--") { - positionals.push(...argv.slice(i + 1)); - break; - } - if (!arg.startsWith("-") || arg === "-") { - positionals.push(arg); - continue; - } - const normalized = arg === "-h" ? "--help" : arg === "-V" ? "--version" : arg; - if (!normalized.startsWith("--")) throw usageError("Unsupported short option. Use --help to discover supported options."); - const eq = normalized.indexOf("="); - const name = normalized.slice(2, eq < 0 ? undefined : eq); - // Do not echo unknown names or values: either can contain credentials. - if (!known.has(name)) throw usageError("Unsupported option. Use --help to discover supported options."); - if (Object.hasOwn(flags, name)) throw usageError(`--${name} may be supplied only once.`); - if (BOOLEAN_FLAGS.has(name)) { - if (eq >= 0) throw usageError(`--${name} takes no value.`); - flags[name] = true; - continue; - } - const value = eq >= 0 ? normalized.slice(eq + 1) : argv[i + 1]; - if (value === undefined || (eq < 0 && value.startsWith("-") && !/^-\d/.test(value))) { - throw usageError(`--${name} requires a value.`); - } - if (value.length === 0 && name !== "value-base64") throw usageError(`--${name} requires a value.`); - flags[name] = value; - if (eq < 0) i += 1; + // Commander handles tokenization, option scope, command paths, aliases, + // positional arity, and option conflicts. It must never exit this process. + const end = argv.indexOf("--"); + const options = end < 0 ? argv : argv.slice(0, end); + for (const spellings of [["--help", "-h"], ["--version", "-V"]]) { + if (options.filter((arg) => spellings.includes(arg)).length > 1) throw usageError(`${spellings[0]} may be supplied only once.`); } - return { command: positionals.slice(0, 2), flags, positionals }; + const tree = createCommandTree(options); + try { + tree.root.parse(argv, { from: "user" }); + } catch (error) { + if (!(error instanceof CommanderError) || !(tree.helpRequested() || tree.versionRequested())) throw error; + } + const selected = tree.selected(); + const flags = invocationFlags(selected); + if (tree.versionRequested()) flags.version = true; + if (tree.helpRequested()) { + flags.help = true; + const help = describeHelp(selected); + return { command: help.path.slice(0, 2), positionals: help.path, flags, help }; + } + // These paired inputs are domain requirements, not parser syntax. + const has = (name: string) => Object.hasOwn(flags, name); + if (has("target-width") !== has("target-height")) throw usageError("Provide both --target-width and --target-height."); + const path = commandPath(selected); + if (path.join(" ") === "playlist import" && has("update") !== has("if-match")) throw usageError("playlist import --update and --if-match must be supplied together."); + const positionals = [...path, ...selected.args]; + return { command: positionals.slice(0, 2), positionals, flags }; } export function flagString(flags: Record, name: string): string | undefined { diff --git a/src/cli.test.ts b/src/cli.test.ts index 507365d6..831c9d9d 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -1,3 +1,4 @@ +import { commandHelp, CREDIT_HELP } from "./help.js"; import assert from "node:assert/strict"; import { createCipheriv, createHash, createPublicKey, diffieHellman, generateKeyPairSync, hkdfSync } from "node:crypto"; import { PassThrough } from "node:stream"; @@ -6,7 +7,7 @@ import { mkdir, open, readFile, rename, chmod, stat, writeFile, rm } from "node: import path from "node:path"; import { fileURLToPath } from "node:url"; import { test } from "node:test"; -import { CLI_VERSION, EVENT_STREAM_BACKOFF_CAP_MS, EVENT_STREAM_BACKOFF_MS, USAGE, formatEventLine } from "./commands.js"; +import { CLI_VERSION, EVENT_STREAM_BACKOFF_CAP_MS, EVENT_STREAM_BACKOFF_MS, formatEventLine } from "./commands.js"; import { run, type CliRuntime } from "./main.js"; import { FakeTransport, memoryBackend } from "./transport/fake.js"; import { ExitCode } from "./exit-codes.js"; @@ -3185,7 +3186,7 @@ test("feedback refuses a --command that could carry an argument value", async () { fs: fsLike }, ); assert.equal(valueless.code, 2, valueless.stdout); - assert.match(JSON.parse(valueless.stdout).error.detail as string, /--command requires a value/); + assert.match(JSON.parse(valueless.stdout).error.detail as string, /--command requires a value|--json may be supplied only once/); // --no-context suppresses the diagnostic envelope entirely. const quiet = await withRuntime( @@ -3589,7 +3590,7 @@ test("media upload warns on a low-information filename without blocking the uplo }); test("customer-facing credit copy is fail-open until 1 Jan 2027", () => { - const documents: Array<[string, string]> = [["USAGE", USAGE]]; + const documents: Array<[string, string]> = [["media generate help", CREDIT_HELP]]; for (const [name, text] of documents) { assert.match(text, /nonnegative/, `${name} must say remaining is nonnegative`); assert.match(text, /credits_low/, `${name} must name the live credits_low warning`); @@ -3611,7 +3612,7 @@ test("customer-facing credit copy is fail-open until 1 Jan 2027", () => { assert.doesNotMatch(text, /\$30 \/ 1M/, `${name} must not name the vendor image-output rate as the price`); assert.doesNotMatch(text, /OpenRouter/, `${name} must not name OpenRouter`); } - assert.match(USAGE, /feedback list \[--kind bug\|feature\]/); + assert.match(commandHelp(["feedback", "list"]).usage, /--kind /); // Maintainer AGENTS.md and README navigation do not duplicate the CLI help // rate card. Behavior and customer command help are validated above. @@ -3942,7 +3943,7 @@ test("screen toast posts the closed write body and does not echo the text", asyn }); test("screen toast defaults omitted --level to info", async () => { - assert.match(USAGE, /screen toast --text TEXT \[--level info\] \[--duration-ms MS\]/); + assert.match(commandHelp(["screen", "toast"]).usage, /screen toast \[options\] /); const transport = memoryBackend(); const configDir = await testTemp("toast-default-level-"); const fsLike = { mkdir, open, rename, rm, chmod, stat, homedir: () => configDir, env: { XDG_CONFIG_HOME: configDir } }; @@ -3991,7 +3992,7 @@ test("screen toast rejects invalid level, text, and duration before calling the try { for (const [argv, detail] of [ [["screen", "toast"], /requires /], - [["screen", "toast", "scr_1", "--level", "info"], /requires /], + [["screen", "toast", "scr_1", "--level", "info"], /requires --text/], [["screen", "toast", "scr_1", "--level", "INFO", "--text", "Lobby closed"], /error, alert, or info/], [["screen", "toast", "scr_1", "--level", "warn", "--text", "Lobby closed"], /error, alert, or info/], [["screen", "toast", "scr_1", "--level", "info", "--text", "a\n\n\nb"], /1 to 120 characters/], @@ -4023,7 +4024,7 @@ test("screen toast rejects invalid level, text, and duration before calling the { fs: fsLike }, ); assert.equal(valuelessText.code, ExitCode.Usage); - assert.match(JSON.parse(valuelessText.stdout).error.detail as string, /--text requires a value/); + assert.match(JSON.parse(valuelessText.stdout).error.detail as string, /--text requires a value|--json may be supplied only once/); const cancel = await withRuntime(["--json", "screen", "toast-cancel", "scr_1"], transport, { fs: fsLike }); assert.equal(cancel.code, ExitCode.Usage); diff --git a/src/command-spec.ts b/src/command-spec.ts index d18f5648..76e2ff60 100644 --- a/src/command-spec.ts +++ b/src/command-spec.ts @@ -1,86 +1,80 @@ -/** Supported invocation shapes shared by argument validation and help discovery. */ +/** Declarative command inputs for the Commander tree. */ export interface CommandSpec { readonly path: readonly string[]; - readonly minArgs: number; - readonly maxArgs: number; + readonly arguments?: string; readonly aliases?: readonly (readonly string[])[]; readonly flags: readonly string[]; + readonly requiredFlags?: readonly string[]; } -export const GLOBAL_FLAGS = ["json", "help", "version", "api-url", "config", "request-id", "idempotency-key", "timeout", "beta-key", "token"] as const; - -export const BOOLEAN_FLAGS = new Set([ - "json", "help", "version", "open-dashboard", "print-url", "yes", "allow-lockout", - "no-wait", "no-transcode", "no-progress", "no-audio", "clear-tag", "safe-area", - "lint-only", "combined", "open", "contact-sheet", "no-context", "repair-config", -]); +export const GLOBAL_FLAGS = ["json", "api-url", "config", "request-id", "idempotency-key", "timeout", "beta-key", "token"] as const; export const COMMAND_SPECS: readonly CommandSpec[] = [ - { path: ["account", "show"], minArgs: 0, maxArgs: 0, flags: [] }, - { path: ["agent", "enroll"], minArgs: 0, maxArgs: 0, flags: ["email", "name", "open-dashboard"] }, - { path: ["agent", "connect"], minArgs: 0, maxArgs: 0, flags: ["name", "print-url"] }, - { path: ["agent", "status"], minArgs: 0, maxArgs: 0, flags: [] }, - { path: ["agent", "disconnect"], minArgs: 0, maxArgs: 0, flags: ["yes", "allow-lockout"] }, - { path: ["dashboard"], minArgs: 0, maxArgs: 0, flags: ["print-url"] }, - { path: ["app", "pack"], minArgs: 1, maxArgs: 1, flags: ["output"] }, - { path: ["app", "upload"], minArgs: 1, maxArgs: 1, flags: ["name", "no-wait", "poll-ms"] }, - { path: ["app", "update"], minArgs: 2, maxArgs: 2, flags: ["if-match", "no-wait", "poll-ms"] }, - { path: ["app", "list"], minArgs: 0, maxArgs: 0, flags: [] }, - { path: ["app", "show"], minArgs: 1, maxArgs: 1, flags: [] }, - { path: ["media", "generate"], minArgs: 0, maxArgs: 0, flags: ["prompt", "aspect-ratio", "quality", "tag"] }, - { path: ["media", "upload"], minArgs: 1, maxArgs: 1, flags: ["content-type", "tag", "no-wait", "poll-ms", "no-transcode", "codec", "max-fps", "max-edge", "webp-quality", "no-progress", "preset", "no-audio"] }, - { path: ["media", "upload-batch"], minArgs: 1, maxArgs: 1, flags: ["state", "concurrency", "no-transcode", "tag", "no-progress", "poll-ms", "codec", "max-fps", "max-edge", "webp-quality", "preset", "no-audio"] }, - { path: ["media", "show"], minArgs: 1, maxArgs: 1, flags: [] }, - { path: ["media", "download"], minArgs: 1, maxArgs: 1, flags: ["output"] }, - { path: ["media", "list"], minArgs: 0, maxArgs: 0, flags: ["tag", "primitive"] }, - { path: ["media", "update"], minArgs: 1, maxArgs: 1, flags: ["tag", "clear-tag", "if-match"] }, - { path: ["media", "delete"], minArgs: 1, maxArgs: 1, flags: ["if-match"] }, - { path: ["compose", "catalog"], minArgs: 0, maxArgs: 0, flags: [] }, - { path: ["compose", "batch"], minArgs: 1, maxArgs: 1, flags: ["output", "only", "target-width", "target-height", "safe-area", "lint-only"] }, - { path: ["compose", "render"], minArgs: 1, maxArgs: 1, flags: ["output", "combined", "target-width", "target-height", "safe-area", "open", "lint-only"] }, - { path: ["playlist", "validate"], minArgs: 1, maxArgs: 1, flags: ["lint-only"] }, - { path: ["playlist", "preview"], minArgs: 1, maxArgs: 1, flags: ["output", "frame-ms", "contact-sheet", "lint-only"] }, - { path: ["playlist", "templates"], minArgs: 0, maxArgs: 0, flags: [] }, - { path: ["playlist", "create"], minArgs: 1, maxArgs: 1, flags: [] }, - { path: ["playlist", "update"], minArgs: 2, maxArgs: 2, flags: ["if-match"] }, - { path: ["playlist", "export"], minArgs: 1, maxArgs: 1, flags: ["output"] }, - { path: ["playlist", "import"], minArgs: 1, maxArgs: 1, flags: ["name", "update", "if-match", "poll-ms"] }, - { path: ["playlist", "show"], aliases: [["playlist", "get"]], minArgs: 1, maxArgs: 1, flags: [] }, - { path: ["playlist", "list"], minArgs: 0, maxArgs: 0, flags: [] }, - { path: ["playlist", "delete"], minArgs: 1, maxArgs: 1, flags: ["if-match"] }, - { path: ["screen", "pair"], minArgs: 1, maxArgs: 1, flags: ["label"] }, - { path: ["screen", "provision"], minArgs: 0, maxArgs: 0, flags: ["open", "print-url", "label"] }, - { path: ["browser", "setup"], minArgs: 0, maxArgs: 0, flags: ["code", "open"] }, - { path: ["screen", "update"], minArgs: 1, maxArgs: 1, flags: ["name", "playlist-id", "timezone", "if-match"] }, - { path: ["screen", "list"], minArgs: 0, maxArgs: 0, flags: ["state"] }, - { path: ["screen", "show"], minArgs: 1, maxArgs: 1, flags: [] }, - { path: ["screen", "assign"], minArgs: 1, maxArgs: 1, flags: ["playlist-id", "if-match"] }, - { path: ["screen", "set-timezone"], minArgs: 1, maxArgs: 1, flags: ["timezone", "if-match"] }, - { path: ["screen", "archive"], minArgs: 1, maxArgs: 1, flags: ["if-match"] }, - { path: ["screen", "unarchive"], minArgs: 1, maxArgs: 1, flags: ["if-match"] }, - { path: ["screen", "delete"], minArgs: 1, maxArgs: 1, flags: ["if-match"] }, - { path: ["screen", "rotate-public-id"], minArgs: 1, maxArgs: 1, flags: ["if-match"] }, - { path: ["screen", "toast"], minArgs: 1, maxArgs: 1, flags: ["text", "level", "duration-ms"] }, - { path: ["screen", "screenshot"], minArgs: 1, maxArgs: 1, flags: ["output", "poll-ms"] }, - { path: ["kv", "get"], minArgs: 1, maxArgs: 1, flags: ["application-id"] }, - { path: ["kv", "set"], minArgs: 1, maxArgs: 1, flags: ["application-id", "json-value", "file", "value-base64", "content-type", "if-match"] }, - { path: ["kv", "delete"], minArgs: 1, maxArgs: 1, flags: ["application-id", "if-match"] }, - { path: ["kv", "list"], minArgs: 0, maxArgs: 0, flags: ["application-id"] }, - { path: ["comment", "show", "screen"], minArgs: 1, maxArgs: 1, flags: [] }, - { path: ["comment", "show", "playlist"], minArgs: 1, maxArgs: 1, flags: ["page"] }, - { path: ["comment", "set", "screen"], minArgs: 1, maxArgs: 1, flags: ["json-value", "file"] }, - { path: ["comment", "set", "playlist"], minArgs: 1, maxArgs: 1, flags: ["page", "json-value", "file"] }, - { path: ["comment", "delete", "screen"], minArgs: 1, maxArgs: 1, flags: [] }, - { path: ["comment", "delete", "playlist"], minArgs: 1, maxArgs: 1, flags: ["page"] }, - { path: ["operations", "get"], minArgs: 1, maxArgs: 1, flags: [] }, - { path: ["operations", "wait"], minArgs: 1, maxArgs: 1, flags: ["poll-ms"] }, - { path: ["operations", "cancel"], minArgs: 1, maxArgs: 1, flags: [] }, - { path: ["events", "list"], minArgs: 0, maxArgs: 0, flags: ["after", "cursor", "limit"] }, - { path: ["events", "follow"], minArgs: 0, maxArgs: 0, flags: ["after", "cursor"] }, - { path: ["playback", "list"], minArgs: 0, maxArgs: 0, flags: ["screen-id", "media-id", "day"] }, - { path: ["feedback", "bug"], minArgs: 1, maxArgs: 1, flags: ["body", "body-file", "command", "no-context"] }, - { path: ["feedback", "feature"], minArgs: 1, maxArgs: 1, flags: ["body", "body-file", "command", "no-context"] }, - { path: ["feedback", "list"], minArgs: 0, maxArgs: 0, flags: ["kind"] }, - { path: ["doctor"], minArgs: 0, maxArgs: 0, flags: ["repair-config"] }, - { path: ["version"], minArgs: 0, maxArgs: 0, flags: [] }, + { path: ["account", "show"], flags: [] }, + { path: ["agent", "enroll"], flags: ["email", "name", "open-dashboard"] }, + { path: ["agent", "connect"], flags: ["name", "print-url"] }, + { path: ["agent", "status"], flags: [] }, + { path: ["agent", "disconnect"], flags: ["yes", "allow-lockout"] }, + { path: ["dashboard"], flags: ["print-url"] }, + { path: ["app", "pack"], arguments: "", flags: ["output"] }, + { path: ["app", "upload"], arguments: "", flags: ["name", "no-wait", "poll-ms"] }, + { path: ["app", "update"], arguments: " ", requiredFlags: ["if-match"], flags: ["if-match", "no-wait", "poll-ms"] }, + { path: ["app", "list"], flags: [] }, + { path: ["app", "show"], arguments: "", flags: [] }, + { path: ["media", "generate"], requiredFlags: ["prompt"], flags: ["prompt", "aspect-ratio", "quality", "tag"] }, + { path: ["media", "upload"], arguments: "", flags: ["content-type", "tag", "no-wait", "poll-ms", "no-transcode", "codec", "max-fps", "max-edge", "webp-quality", "no-progress", "preset", "no-audio"] }, + { path: ["media", "upload-batch"], arguments: "", requiredFlags: ["state"], flags: ["state", "concurrency", "no-transcode", "tag", "no-progress", "poll-ms", "codec", "max-fps", "max-edge", "webp-quality", "preset", "no-audio"] }, + { path: ["media", "show"], arguments: "", flags: [] }, + { path: ["media", "download"], arguments: "", flags: ["output"] }, + { path: ["media", "list"], flags: ["tag", "primitive"] }, + { path: ["media", "update"], arguments: "", requiredFlags: ["if-match"], flags: ["tag", "clear-tag", "if-match"] }, + { path: ["media", "delete"], arguments: "", requiredFlags: ["if-match"], flags: ["if-match"] }, + { path: ["compose", "catalog"], flags: [] }, + { path: ["compose", "batch"], arguments: "", requiredFlags: ["output"], flags: ["output", "only", "target-width", "target-height", "safe-area", "lint-only"] }, + { path: ["compose", "render"], arguments: "", flags: ["output", "combined", "target-width", "target-height", "safe-area", "open", "lint-only"] }, + { path: ["playlist", "validate"], arguments: "", flags: ["lint-only"] }, + { path: ["playlist", "preview"], arguments: "", requiredFlags: ["output"], flags: ["output", "frame-ms", "contact-sheet", "lint-only"] }, + { path: ["playlist", "templates"], flags: [] }, + { path: ["playlist", "create"], arguments: "", flags: [] }, + { path: ["playlist", "update"], arguments: " ", requiredFlags: ["if-match"], flags: ["if-match"] }, + { path: ["playlist", "export"], arguments: "", requiredFlags: ["output"], flags: ["output"] }, + { path: ["playlist", "import"], arguments: "", flags: ["name", "update", "if-match", "poll-ms"] }, + { path: ["playlist", "show"], aliases: [["playlist", "get"]], arguments: "", flags: [] }, + { path: ["playlist", "list"], flags: [] }, + { path: ["playlist", "delete"], arguments: "", requiredFlags: ["if-match"], flags: ["if-match"] }, + { path: ["screen", "pair"], arguments: "", flags: ["label"] }, + { path: ["screen", "provision"], flags: ["open", "print-url", "label"] }, + { path: ["browser", "setup"], requiredFlags: ["code"], flags: ["code", "open"] }, + { path: ["screen", "update"], arguments: "", requiredFlags: ["if-match"], flags: ["name", "playlist-id", "timezone", "if-match"] }, + { path: ["screen", "list"], flags: ["state"] }, + { path: ["screen", "show"], arguments: "", flags: [] }, + { path: ["screen", "assign"], arguments: "", requiredFlags: ["playlist-id", "if-match"], flags: ["playlist-id", "if-match"] }, + { path: ["screen", "set-timezone"], arguments: "", requiredFlags: ["timezone", "if-match"], flags: ["timezone", "if-match"] }, + { path: ["screen", "archive"], arguments: "", requiredFlags: ["if-match"], flags: ["if-match"] }, + { path: ["screen", "unarchive"], arguments: "", requiredFlags: ["if-match"], flags: ["if-match"] }, + { path: ["screen", "delete"], arguments: "", requiredFlags: ["if-match"], flags: ["if-match"] }, + { path: ["screen", "rotate-public-id"], arguments: "", requiredFlags: ["if-match"], flags: ["if-match"] }, + { path: ["screen", "toast"], arguments: "", requiredFlags: ["text"], flags: ["text", "level", "duration-ms"] }, + { path: ["screen", "screenshot"], arguments: "", flags: ["output", "poll-ms"] }, + { path: ["kv", "get"], arguments: "", requiredFlags: ["application-id"], flags: ["application-id"] }, + { path: ["kv", "set"], arguments: "", requiredFlags: ["application-id"], flags: ["application-id", "json-value", "file", "value-base64", "content-type", "if-match"] }, + { path: ["kv", "delete"], arguments: "", requiredFlags: ["application-id", "if-match"], flags: ["application-id", "if-match"] }, + { path: ["kv", "list"], requiredFlags: ["application-id"], flags: ["application-id"] }, + { path: ["comment", "show", "screen"], arguments: "", flags: [] }, + { path: ["comment", "show", "playlist"], arguments: "", flags: ["page"] }, + { path: ["comment", "set", "screen"], arguments: "", flags: ["json-value", "file"] }, + { path: ["comment", "set", "playlist"], arguments: "", flags: ["page", "json-value", "file"] }, + { path: ["comment", "delete", "screen"], arguments: "", flags: [] }, + { path: ["comment", "delete", "playlist"], arguments: "", flags: ["page"] }, + { path: ["operations", "get"], arguments: "", flags: [] }, + { path: ["operations", "wait"], arguments: "", flags: ["poll-ms"] }, + { path: ["operations", "cancel"], arguments: "", flags: [] }, + { path: ["events", "list"], flags: ["after", "cursor", "limit"] }, + { path: ["events", "follow"], flags: ["after", "cursor"] }, + { path: ["playback", "list"], flags: ["screen-id", "media-id", "day"] }, + { path: ["feedback", "bug"], arguments: "", flags: ["body", "body-file", "command", "no-context"] }, + { path: ["feedback", "feature"], arguments: "<title>", flags: ["body", "body-file", "command", "no-context"] }, + { path: ["feedback", "list"], flags: ["kind"] }, + { path: ["doctor"], flags: ["repair-config"] }, + { path: ["version"], flags: [] }, ]; diff --git a/src/command-tree.ts b/src/command-tree.ts new file mode 100644 index 00000000..7994c261 --- /dev/null +++ b/src/command-tree.ts @@ -0,0 +1,207 @@ +import { Command, CommanderError, Option } from "commander"; +import { COMMAND_SPECS, GLOBAL_FLAGS } from "./command-spec.js"; +import { ACTION_SUMMARIES, SUMMARIES } from "./help-text.js"; +import { quotedRevision } from "./if-match.js"; +import { usageError } from "./problems.js"; +import { CLI_VERSION } from "./version.js"; + +const OPTION_HELP: Record<string, [string, string]> = { + json: ["", "Return a JSON envelope"], + "api-url": ["URL", "Override the API origin"], config: ["PATH", "Use this credential/configuration file"], + "request-id": ["ID", "Use a request correlation ID"], "idempotency-key": ["KEY", "Reuse a key when retrying the same write"], + timeout: ["MS", "Set the command timeout in milliseconds"], "beta-key": ["KEY", "Supply an enrollment beta key"], token: ["TOKEN", "Override the stored credential"], + email: ["ADDRESS", "Set the account contact email"], name: ["NAME", "Set the resource or agent name"], + "open-dashboard": ["", "Open the dashboard after enrolling"], "print-url": ["", "Return the browser handoff URL"], + yes: ["", "Confirm this agent's disconnection"], "allow-lockout": ["", "Allow disconnecting the last agent"], + output: ["PATH", "Write the result to this file or directory"], "no-wait": ["", "Return after acceptance without waiting for processing"], + "poll-ms": ["MS", "Set the operation polling interval"], "if-match": ["REVISION", "Require the current resource revision"], + prompt: ["TEXT", "Describe the complete image to generate"], "aspect-ratio": ["RATIO", "Choose the generated image aspect ratio"], + quality: ["low|medium|high", "Choose generation quality"], tag: ["TAG", "Set or filter by a media tag"], + "content-type": ["TYPE", "Declare the content MIME type"], "no-transcode": ["", "Upload accepted source bytes unchanged"], + codec: ["h264|hevc", "Choose the video codec (default: h264)"], "max-fps": ["N", "Limit video frames per second"], + "max-edge": ["PIXELS", "Limit the longest video edge"], "webp-quality": ["1-100", "Set WebP quality"], + "no-progress": ["", "Suppress stderr progress"], preset: ["PRESET", "Use signage-1080p30 or signage-4k30"], "no-audio": ["", "Remove the audio track when transcoding"], + state: ["VALUE", "Select archived screens or an upload-batch state file"], concurrency: ["N", "Set concurrent batch uploads"], + primitive: ["image|video", "Filter by media primitive"], "clear-tag": ["", "Remove the media tag"], + only: ["ID", "Render only this page"], "target-width": ["PX", "Set the physical content width"], "target-height": ["PX", "Set the physical content height"], + "safe-area": ["", "Apply the display safe area"], "lint-only": ["", "Run the command's local validation/lint mode"], combined: ["", "Also render combined page images"], open: ["", "Open the result in its viewer"], + "frame-ms": ["MS", "Select the preview frame time"], "contact-sheet": ["", "Include a contact sheet"], update: ["ID", "Update this playlist when importing"], + label: ["LABEL", "Set the screen label"], code: ["CODE", "Use the browser setup code"], "playlist-id": ["ID", "Select the playlist to assign"], timezone: ["ZONE", "Set an IANA timezone"], + text: ["TEXT", "Set the screen message"], level: ["LEVEL", "Set toast level (default: info)"], "duration-ms": ["MS", "Show the message for 2000–60000 milliseconds"], + "application-id": ["ID", "Select the application's K/V namespace"], "json-value": ["JSON", "Supply a JSON value"], file: ["FILE", "Read the value from a file"], "value-base64": ["BASE64", "Supply canonical base64 bytes (may be empty)"], + page: ["PAGE_ID", "Select playlist page comments"], after: ["CURSOR", "Read events after this cursor"], cursor: ["CURSOR", "Alias for --after"], limit: ["N", "Limit returned events"], + "screen-id": ["ID", "Filter playback by screen"], "media-id": ["ID", "Filter playback by media"], day: ["YYYY-MM-DD", "Filter playback by UTC day"], + body: ["TEXT", "Supply feedback details"], "body-file": ["FILE", "Read feedback details from a file"], command: ["GROUP ACTION", "Identify the command involved"], "no-context": ["", "Omit diagnostic context"], kind: ["bug|feature", "Filter feedback by kind"], + "repair-config": ["", "Repair configuration permissions"], +}; +const NUMERIC_FLAGS = new Set(["timeout", "poll-ms", "target-width", "target-height", "limit", "max-fps", "max-edge", "webp-quality", "duration-ms", "concurrency", "frame-ms"]); +const CONFLICTS: readonly (readonly string[])[] = [ + ["after", "cursor"], ["tag", "clear-tag"], ["body", "body-file"], ["json-value", "file", "value-base64"], ["json-value", "content-type"], + ...["codec", "max-fps", "max-edge", "webp-quality", "preset", "no-audio"].map((flag) => ["no-transcode", flag]), +]; + +/** Canonical command names, excluding the executable and any supplied values. */ +export function commandPath(command: Command): string[] { + return command.parent ? [...commandPath(command.parent), command.name()] : []; +} + +export function findCommand(root: Command, path: readonly string[]): Command | undefined { + let command = root; + for (const word of path) { + const child = command.commands.find((candidate) => candidate.name() === word || candidate.aliases().includes(word)); + if (!child) return undefined; + command = child; + } + return command; +} + +/** Read explicit CLI options only; Commander defaults must not become write flags. */ +export function invocationFlags(command: Command): Record<string, string | boolean> { + const flags = Object.create(null) as Record<string, string | boolean>; + for (let current: Command | null = command; current; current = current.parent) { + for (const option of current.options) { + if (current.getOptionValueSource(option.attributeName()) !== "cli") continue; + flags[option.long!.slice(2)] = option.negate ? true : current.getOptionValue(option.attributeName()); + } + } + return flags; +} + +function optionValue(name: string, value: string, argv: readonly string[]): string { + if ((!value.length && name !== "value-base64") || + (value.startsWith("-") && !/^-\d/.test(value) && !argv.includes(`--${name}=${value}`))) { + throw usageError(`--${name} requires a value. Use --${name}=VALUE for a value starting with a dash.`); + } + if (NUMERIC_FLAGS.has(name)) { + const number = Number(value); + if (name === "duration-ms" && (!Number.isInteger(number) || number < 2000 || number > 60000)) throw usageError("--duration-ms must be a whole number between 2000 and 60000."); + if (!value.trim() || !Number.isFinite(number) || number < 0 || (name !== "max-fps" && !Number.isSafeInteger(number))) throw usageError(`--${name} requires a nonnegative ${name === "max-fps" ? "number" : "whole number"}.`); + if (!["timeout", "frame-ms"].includes(name) && number === 0) throw usageError(`--${name} must be greater than zero.`); + } + if (name === "if-match") quotedRevision(value); + return value; +} + +/** Commander diagnostics can contain raw option names/values. Never expose them. */ +function commandError(error: CommanderError, command: Command): never { + const path = commandPath(command).join(" "); + // Preserve actionable migration hints using only known command/flag names. + const has = (flag: string) => command.args.some((arg) => arg === `--${flag}` || arg.startsWith(`--${flag}=`)); + if (path === "screen" && command.args[0] === "revoke-credential") throw usageError("screen revoke-credential is retired. Archive the screen instead.", { command: "screenrig --json screen archive <id> --if-match REVISION", reason: "Archive hides the screen; it does not unbind the player." }); + if (path.startsWith("comment ")) { + if (command.commands.length) throw usageError("comment commands require screen <id> or playlist <id>."); + if (has("if-match")) throw usageError("comment commands do not take --if-match; last write wins and does not bump revision."); + if (has("page") && command.name() === "screen") throw usageError("comment screen commands do not take --page; use comment playlist <id> --page PAGE_ID."); + if (has("value-base64")) throw usageError("comment set requires --json-value or --file."); + if (error.code === "commander.conflictingOption") throw usageError("comment set requires exactly one of --json-value or --file."); + } + if (path === "media list" && has("kind")) throw usageError("media list uses --primitive image|video, not --kind."); + switch (error.code) { + case "commander.missingArgument": + if (path === "screen set-timezone") throw usageError("screen set-timezone requires <id> --timezone --if-match."); + throw usageError(`${path} requires ${command.registeredArguments.filter((arg) => arg.required).map((arg) => `<${arg.name()}>`).join(" ")}.`); + case "commander.excessArguments": + throw usageError(`${path || "screenrig"} does not accept ${command.registeredArguments.length ? "extra" : "positional"} arguments.`); + case "commander.conflictingOption": { + const supplied = command.options.filter((option) => command.getOptionValueSource(option.attributeName()) === "cli"); + const pair = CONFLICTS.map((group) => supplied.filter((option) => group.includes(option.long!.slice(2)))).find((options) => options.length > 1); + const [first, second] = pair ?? []; + throw usageError(first && second ? `Use only one of ${first.long}, ${second.long}.` : "Conflicting options. See command help."); + } + case "commander.missingMandatoryOptionValue": { + if (path === "screen set-timezone") throw usageError("screen set-timezone requires <id> --timezone --if-match."); + const required = command.options.filter((option) => option.mandatory && command.getOptionValue(option.attributeName()) === undefined); + const positional = command.registeredArguments.filter((arg, index) => arg.required && index >= command.args.length).map((arg) => `<${arg.name()}>`); + throw usageError(`${path} requires ${[...positional, ...required.map((option) => option.long)].join(" ")}.`); + } + case "commander.optionMissingArgument": { + const option = command.options.find((candidate) => error.message.includes(`option '${candidate.flags}'`)); + throw usageError(option ? `${option.long} requires a value.` : "An option requires a value. See command help for its arguments."); + } + default: + throw usageError("Unknown command or unsupported option. See command help for supported arguments.", { command: `screenrig${path ? ` ${path}` : ""} --help`, reason: "List supported commands, arguments, and options." }); + } +} + +export interface CommandTree { + root: Command; + selected: () => Command; + helpRequested: () => boolean; + versionRequested: () => boolean; +} + +/** Build fresh commands per invocation: no shared parsed values or process exits. */ +export function createCommandTree(argv: readonly string[] = []): CommandTree { + let selected: Command; + let helpRequested = false; + let versionRequested = false; + const seen = new Set<string>(); + const configure = (command: Command): Command => command + .allowExcessArguments(false) + .addHelpCommand(false) + .showSuggestionAfterError(false) + .configureOutput({ writeOut() {}, writeErr() {}, outputError() {} }) + .configureHelp({ helpWidth: 100, showGlobalOptions: true }) + .exitOverride((error) => { + selected = command; + if (error.code === "commander.helpDisplayed" || error.code === "commander.help") helpRequested = true; + else if (error.code === "commander.version") versionRequested = true; + else commandError(error, command); + throw error; + }); + const root = configure(new Command("screenrig")).description("Signage and kiosk infrastructure for agents"); + selected = root; + root.version(CLI_VERSION, "-V, --version", "Show CLI and protocol versions"); + const addOption = (command: Command, name: string): void => { + const entry = OPTION_HELP[name]; + if (!entry) throw new Error(`Missing option definition: ${name}`); + const [placeholder, description] = entry; + const option = new Option(`--${name}${placeholder ? ` <${placeholder}>` : ""}`, description); + if (placeholder) option.argParser((value: string) => optionValue(name, value, argv)); + if (name === "token") option.hideHelp(); + command.addOption(option); + command.on(`option:${option.name()}`, () => { + if (seen.has(name)) throw usageError(`--${name} may be supplied only once.`); + seen.add(name); + }); + }; + for (const name of GLOBAL_FLAGS) addOption(root, name); + for (const spec of COMMAND_SPECS) { + let command = root; + for (const word of spec.path) { + let child = command.commands.find((candidate) => candidate.name() === word); + if (!child) { + child = configure(command.createCommand(word)); + command.addCommand(child); + child.description(SUMMARIES[commandPath(child).join(" ")] ?? ACTION_SUMMARIES[word] ?? `Manage ${word} comments`); + } + command = child; + } + if (spec.arguments) command.arguments(spec.arguments); + for (const alias of spec.aliases ?? []) command.alias(alias[alias.length - 1]!); + for (const name of spec.flags) addOption(command, name); + for (const name of spec.requiredFlags ?? []) { + const option = command.options.find((option) => option.long === `--${name}`)!; + option.makeOptionMandatory(); + option.description += " (required)"; + } + for (const group of CONFLICTS) { + const options = command.options.filter((option) => group.includes(option.long!.slice(2))); + for (const option of options) option.conflicts(options.filter((other) => other !== option).map((other) => other.attributeName())); + } + command.action(() => { selected = command; }); + } + const help = configure(new Command("help")).description("Discover commands and their options").argument("[command...]", "Command path to inspect"); + root.addCommand(help); + help.action((path: string[]) => { + const target = findCommand(root, path); + if (!target) throw usageError("Unknown help topic."); + target.help(); + }); + const groups = (command: Command): void => { + if (command.commands.length) command.action(() => command.help()); + command.commands.forEach(groups); + }; + groups(root); + return { root, selected: () => selected, helpRequested: () => helpRequested, versionRequested: () => versionRequested }; +} diff --git a/src/commander.test.ts b/src/commander.test.ts new file mode 100644 index 00000000..cc53a01a --- /dev/null +++ b/src/commander.test.ts @@ -0,0 +1,68 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { test } from "node:test"; +import { parseArgv } from "./argv.js"; +import { commandHelp } from "./help.js"; +import { CliError } from "./problems.js"; + +const usage = (error: unknown) => error instanceof CliError && error.problem.code === "usage_error"; + +test("negated options only become flags when explicitly supplied", () => { + const plain = parseArgv(["media", "upload", "clip.mp4"]); + for (const name of ["no-wait", "no-audio", "no-transcode", "no-progress"]) assert.equal(Object.hasOwn(plain.flags, name), false); + const provided = parseArgv(["media", "upload", "clip.mp4", "--no-wait", "--no-audio", "--no-progress"]); + for (const name of ["no-wait", "no-audio", "no-progress"]) assert.equal(provided.flags[name], true); + assert.equal(Object.hasOwn(parseArgv(["media", "upload", "clip.mp4"]).flags, "no-wait"), false, "parse state must not leak between invocations"); +}); + +test("global options retain their meaning around nested command paths", () => { + for (const argv of [ + ["--json", "--timeout", "5", "screen", "show", "scr_TEST"], + ["screen", "--json", "show", "scr_TEST", "--timeout", "5"], + ["screen", "show", "scr_TEST", "--timeout", "5", "--json"], + ]) { + assert.deepEqual({ ...parseArgv(argv).flags }, { json: true, timeout: "5" }); + assert.deepEqual(parseArgv(argv).positionals, ["screen", "show", "scr_TEST"]); + } +}); + +test("missing option values cannot swallow switches or use text after the terminator", () => { + for (const argv of [ + ["app", "upload", "site", "--name", "--no-wait"], + ["app", "upload", "--name", "--no-wait", "--", "--name=--no-wait"], + ["screen", "list", "--timeout", "--json"], + ["--help", "-h"], ["--version", "-V"], + ]) assert.throws(() => parseArgv(argv), usage); + assert.equal(parseArgv(["app", "upload", "site", "--name=--no-wait"]).flags.name, "--no-wait"); + assert.equal(parseArgv(["app", "pack", "--", "--help"]).positionals[2], "--help"); +}); + +test("command-specific flags are scoped to their command", () => { + assert.throws(() => parseArgv(["screen", "list", "--name", "Lobby"]), usage); + assert.throws(() => parseArgv(["--name", "Lobby", "screen", "update", "scr_TEST", "--if-match", "1"]), usage); + assert.equal(parseArgv(["screen", "update", "scr_TEST", "--name", "Lobby", "--if-match", "1"]).flags.name, "Lobby"); +}); + +test("required options are discoverable and enforced before dispatch", () => { + assert.throws(() => parseArgv(["screen", "assign", "scr_TEST"]), usage); + const help = commandHelp(["screen", "assign"]); + for (const name of ["--playlist-id", "--if-match"]) assert.equal(help.options.find((option) => option.name === name)?.required, true); + assert.doesNotThrow(() => parseArgv(["screen", "assign", "--help"])); +}); + +test("Commander errors use one secret-safe envelope and never raw output", () => { + const bin = fileURLToPath(new URL("bin.js", import.meta.url)); + for (const argv of [["--json", "screen", "list", "--private-secret=value"], ["--json", "screen", "list", "--timeout", "private-secret"]]) { + assert.throws(() => execFileSync(process.execPath, [bin, ...argv], { encoding: "utf8", stdio: "pipe" }), (error: unknown) => { + const result = error as { status: number; stdout: string; stderr: string }; + assert.equal(result.status, 2); + assert.equal(result.stderr, ""); + const envelope = JSON.parse(result.stdout); + assert.equal(envelope.ok, false); + assert.equal(envelope.error.code, "usage_error"); + assert.doesNotMatch(result.stdout, /private-secret/); + return true; + }); + } +}); diff --git a/src/commands.ts b/src/commands.ts index 3f98bbf6..900b233a 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -138,125 +138,10 @@ import { type AgentConnectionConfig, } from "./agent-identity.js"; import { CLI_VERSION } from "./version.js"; -import { validateInvocation } from "./validate-invocation.js"; export { CLI_VERSION }; -export const USAGE = `screenrig — ScreenRig localhost v1 control-plane CLI - -Usage: - screenrig [--json] [--api-url URL] [--config PATH] - [--request-id ID] [--idempotency-key KEY] [--timeout MS] - [--beta-key KEY] - <command> [args] - -Configuration (user config JSON, not flags): - log_socket optional path to an already-listening Unix socket. The CLI - connects as a client and writes one NDJSON operation-log - object per line. Absent or empty keeps current behavior. - Connect or back-pressure drops lines and warns - log_sink_degraded; it does not fail the command. - This is a config field only, not a command-line switch. - -Commands: - account show - agent enroll --email ADDRESS [--name NAME] [--open-dashboard] - agent connect [--name NAME] [--print-url] [--timeout MS] - Approval expires after 24 hours; --timeout defaults to 86400000 ms. - Retry agent connect to resume after an interrupted wait. - agent status - agent disconnect --yes [--allow-lockout] - dashboard [--print-url] - app pack <directory> [--output FILE] - app upload <directory> [--name NAME] [--no-wait] [--poll-ms MS] - app update <id> <directory> --if-match REVISION [--no-wait] [--poll-ms MS] - app list - app show <id> - media generate --prompt TEXT [--aspect-ratio RATIO] [--quality low|medium|high] [--tag TAG] - media upload <file> [--content-type TYPE] [--tag TAG] [--no-wait] [--poll-ms MS] - [--no-transcode] [--codec h264|hevc] [--max-fps N] - [--max-edge PIXELS] [--webp-quality 1-100] [--no-progress] - [--preset signage-1080p30|signage-4k30] [--no-audio] - media upload-batch <manifest.json> --state FILE [--concurrency N] - [--no-transcode] [--tag TAG] [--no-progress] - media show <id> - media download <id> [--output FILE] - media list [--tag TAG] [--primitive image|video] - media update <id> (--tag TAG | --clear-tag) --if-match REVISION - media delete <id> --if-match REVISION - compose catalog - (local regions, enter/motion, fonts, examples; no network) - compose batch <file> --output DIRECTORY [--only ID] [--target-width PX --target-height PX] [--safe-area] - [--lint-only] - (contact sheet; 1 to 2000 pages) - playlist validate <file> [--lint-only] - compose render <file> [--output DIRECTORY] [--combined] [--target-width PX --target-height PX] [--safe-area] - [--open] [--lint-only] - playlist preview <file|id> --output DIR [--frame-ms MS] [--contact-sheet] [--lint-only] - playlist templates - playlist create <file> - playlist update <id> <file> --if-match REVISION - playlist export <id> --output DIRECTORY - playlist import <directory> [--name NAME] [--update ID --if-match REVISION] - playlist show <id> - playlist list - playlist delete <id> --if-match REVISION - screen pair CODE [--label LABEL] - screen provision (--open | --print-url) [--label LABEL] - browser setup --code CODE [--open] - screen update <id> [--name NAME] [--playlist-id ID] [--timezone ZONE] - --if-match REVISION - screen list [--state archived] - screen show <id> - screen assign <id> --playlist-id ID --if-match REVISION - screen set-timezone <id> --timezone ZONE --if-match REVISION - screen archive <id> --if-match REVISION - screen unarchive <id> --if-match REVISION - screen delete <id> --if-match REVISION - screen rotate-public-id <id> --if-match REVISION - screen toast <id> --text TEXT [--level info] [--duration-ms MS] - screen screenshot <id> [--output FILE] [--timeout MS] [--poll-ms MS] - kv get --application-id ID <key> - kv set --application-id ID <key> --json-value JSON [--if-match REVISION] - kv set --application-id ID <key> --file FILE --content-type TYPE [--if-match REVISION] - kv set --application-id ID <key> --value-base64 BASE64 --content-type TYPE [--if-match REVISION] - kv delete --application-id ID <key> --if-match REVISION - kv list --application-id ID - comment show screen <id> - comment show playlist <id> [--page PAGE_ID] - comment set screen <id> (--json-value JSON | --file FILE) - comment set playlist <id> [--page PAGE_ID] (--json-value JSON | --file FILE) - comment delete screen <id> - comment delete playlist <id> [--page PAGE_ID] - operations get <id> - operations wait <id> [--timeout MS] [--poll-ms MS] - operations cancel <id> - events list [--after CURSOR] [--limit N] - events follow [--after CURSOR] [--timeout MS] - playback list [--screen-id ID] [--media-id ID] [--day YYYY-MM-DD] - feedback bug <title> (--body TEXT | --body-file FILE) - [--command "GROUP ACTION"] [--no-context] - feedback feature <title> (--body TEXT | --body-file FILE) - [--command "GROUP ACTION"] [--no-context] - feedback list [--kind bug|feature] - doctor [--repair-config] - version - -Credits: - Remaining is a nonnegative whole number (never negative; empty remaining - displays 0). Below 1000 credits, authenticated responses may warn - credits_low. Until 1 Jan 2027 08:00 UTC (midnight PT), production fails open: - billed commands are not rejected for empty remaining and do not return - HTTP 402. After that instant, remaining below 1 credit is payment_required. - Empty remaining does not stop or shut off screens in this window. - media generate is the exception: it is billed per token. - Text input is $10 / 1M tokens, image input is $16 / 1M, image output is - $60 / 1M. Quality changes how detailed the still is and therefore how - many tokens it uses. Remaining that cannot cover the debit returns - payment_required / 402, including during this window. - -${LOOK_AT_THE_CONTACT_SHEET} -`; +export { ROOT_HELP as USAGE } from "./help.js"; export interface CommandResult { envelope: ReturnType<typeof successEnvelope<unknown>>; @@ -475,24 +360,26 @@ function humanLines(title: string, fields: Array<[string, string | undefined]>): } export async function dispatch(args: ParsedArgs, runtime: CliRuntime): Promise<CommandResult> { - validateInvocation(args); const group = args.positionals[0]; const action = args.positionals[1]; - if (!group || flagBool(args.flags, "help") || group === "help") { + if ((flagBool(args.flags, "version") || group === "version") && !flagBool(args.flags, "help") && group !== "help") { return { - envelope: successEnvelope({ usage: USAGE }), + envelope: successEnvelope({ version: CLI_VERSION, protocol_adapter: TEMPORARY_PROTOCOL_VERSION }), exitCode: ExitCode.Success, - human: USAGE, + human: `screenrig ${CLI_VERSION}`, }; } - if (flagBool(args.flags, "version") || group === "version") { + if (args.help) { + const help = args.help; return { - envelope: successEnvelope({ version: CLI_VERSION, protocol_adapter: TEMPORARY_PROTOCOL_VERSION }), + envelope: successEnvelope(help), exitCode: ExitCode.Success, - human: `screenrig ${CLI_VERSION}`, + human: help.usage, }; } + if (!group) throw usageError("A command is required."); + const repair = flagBool(args.flags, "repair-config"); let resolved = await resolveConfig({ flags: args.flags, fs: { ...runtime.fs, env: runtime.env, homedir: runtime.homedir }, repair }); await attachOperationLogger(runtime, args, resolved); diff --git a/src/compose/cli.test.ts b/src/compose/cli.test.ts index 04642e6d..6d667c29 100644 --- a/src/compose/cli.test.ts +++ b/src/compose/cli.test.ts @@ -3,7 +3,7 @@ import { mkdir, open, readFile, rename, chmod, stat, writeFile, rm } from "node: import path from "node:path"; import { PassThrough } from "node:stream"; import { test } from "node:test"; -import { USAGE } from "../commands.js"; +import { commandHelp } from "../help.js"; import { ExitCode } from "../exit-codes.js"; import { run, type CliRuntime } from "../main.js"; import { testTemp } from "../test-temp.js"; @@ -197,12 +197,23 @@ test("compose render --output foo.png is usage_error naming a directory", async }); test("USAGE documents compose batch page limit and layered render", () => { - assert.match(USAGE, /compose batch <file> --output DIRECTORY \[--only ID\]/); - assert.match(USAGE, /1 to 2000 pages/); - assert.doesNotMatch(USAGE, /1 to 100 pages/); - assert.match(USAGE, /compose render <file> \[--output DIRECTORY\] \[--combined\]/); - assert.doesNotMatch(USAGE, /\[--ink-tight\]/); - assert.match(USAGE, /playlist preview <file\|id> --output DIR \[--frame-ms MS\] \[--contact-sheet\] \[--lint-only\]/); + const batch = commandHelp(["compose", "batch"]); + assert.match(batch.synopsis[0]!, /compose batch \[options\] <file>/); + for (const name of ["--output", "--only"]) { + assert.equal(batch.options.find((option) => option.name === name)?.type, "value"); + } + assert.match(batch.usage, /1 to 2000 pages/); + assert.doesNotMatch(batch.usage, /1 to 100 pages/); + const render = commandHelp(["compose", "render"]); + assert.match(render.synopsis[0]!, /compose render \[options\] <file>/); + assert.equal(render.options.find((option) => option.name === "--output")?.type, "value"); + assert.equal(render.options.find((option) => option.name === "--combined")?.type, "boolean"); + assert.equal(render.options.some((option) => option.name === "--ink-tight"), false); + const preview = commandHelp(["playlist", "preview"]); + assert.match(preview.synopsis[0]!, /playlist preview \[options\] <file\|id>/); + for (const [name, type] of [["--output", "value"], ["--frame-ms", "value"], ["--contact-sheet", "boolean"], ["--lint-only", "boolean"]]) { + assert.equal(preview.options.find((option) => option.name === name)?.type, type); + } }); test("batch render returns ordered results, preview and supports one-page correction", async () => { diff --git a/src/help-text.ts b/src/help-text.ts new file mode 100644 index 00000000..93d77722 --- /dev/null +++ b/src/help-text.ts @@ -0,0 +1,54 @@ +import { LOOK_AT_THE_CONTACT_SHEET } from "./playlist-preview.js"; + +export const CREDIT_HELP = "Credits:\n Remaining is a nonnegative whole number (never negative; empty remaining\n displays 0). Below 1000 credits, authenticated responses may warn\n credits_low. Until 1 Jan 2027 08:00 UTC (midnight PT), production fails open:\n billed commands are not rejected for empty remaining and do not return\n HTTP 402. After that instant, remaining below 1 credit is payment_required.\n Empty remaining does not stop or shut off screens in this window.\n media generate is the exception: it is billed per token.\n Text input is $10 / 1M tokens, image input is $16 / 1M, image output is\n $60 / 1M. Quality changes how detailed the still is and therefore how\n many tokens it uses. Remaining that cannot cover the debit returns\n payment_required / 402, including during this window."; + +export const SUMMARIES: Record<string, string> = { + account: "Inspect your account and credits", + agent: "Enroll, connect, and manage this agent", + dashboard: "Open the account dashboard", + app: "Pack, upload, and inspect applications", + media: "Generate, upload, and manage images and videos", + compose: "Render and inspect local page compositions", + playlist: "Author, validate, preview, and manage playlists", + screen: "Pair, configure, and inspect screens", + browser: "Complete browser Player setup", + kv: "Read and write application key-value data", + comment: "Read and manage screen and playlist comments", + operations: "Inspect, wait for, or cancel operations", + events: "List or follow account events", + playback: "Inspect playback records", + feedback: "Submit bugs and feature requests", + doctor: "Check this CLI installation and configuration", + version: "Show CLI and protocol versions", + help: "Discover commands and their options", +}; + +export const ACTION_SUMMARIES: Record<string, string> = { + enroll: "Create the first agent with an email address", connect: "Connect this installation with approval", + status: "Inspect this agent's connection", disconnect: "Disconnect this agent", + pack: "Pack a local application directory", upload: "Upload a local file or directory", + update: "Update an existing resource", list: "List resources", show: "Inspect a resource", + generate: "Generate an image from a prompt", download: "Download media to a file", + "upload-batch": "Upload a manifest with resumable state", delete: "Delete a resource", + catalog: "Browse local composition capabilities and examples", batch: "Render a batch of compositions", + render: "Render a composition locally", validate: "Validate a playlist file", + preview: "Render playlist previews and a contact sheet", templates: "Browse playlist templates", + create: "Create a playlist from a file", export: "Export a playlist bundle", import: "Import a playlist bundle", + pair: "Claim a Player pairing code", provision: "Create a screen and browser handoff", + setup: "Complete browser setup from a code", assign: "Assign a playlist to a screen", + "set-timezone": "Set a screen's timezone", archive: "Archive a screen", unarchive: "Restore an archived screen", + "rotate-public-id": "Rotate a screen's public identifier", toast: "Show a temporary screen message", + screenshot: "Capture and download a screen screenshot", get: "Read the current value or operation", + set: "Write a value", wait: "Wait for an operation to finish", cancel: "Cancel an operation", + follow: "Stream account events", bug: "Submit a bug report", feature: "Submit a feature request", +}; + +export const NOTES: Record<string, string> = { + "agent connect": "Approval expires after 24 hours; --timeout defaults to 86400000 ms. Retry agent connect to resume after an interrupted wait.", + "media generate": CREDIT_HELP, + "account show": CREDIT_HELP, + "compose render": LOOK_AT_THE_CONTACT_SHEET, + "compose batch": `Render 1 to 2000 pages. ${LOOK_AT_THE_CONTACT_SHEET}`, + "playlist preview": LOOK_AT_THE_CONTACT_SHEET, +}; + diff --git a/src/help.test.ts b/src/help.test.ts new file mode 100644 index 00000000..2fab0540 --- /dev/null +++ b/src/help.test.ts @@ -0,0 +1,93 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; +import { COMMAND_SPECS } from "./command-spec.js"; +import { commandHelp } from "./help.js"; +import { CLI_VERSION } from "./version.js"; + +const bin = fileURLToPath(new URL("./bin.js", import.meta.url)); +function invoke(args: string[]): { data: ReturnType<typeof commandHelp> & { version?: string } } { + return JSON.parse(execFileSync(process.execPath, [bin, "--json", ...args], { + encoding: "utf8", env: { ...process.env, SCREENRIG_CONFIG: "/nonexistent/screenrig-help-config" }, + })); +} + +test("root help is compact and every command is discoverable through immediate children", () => { + const root = commandHelp(); + assert.ok(root.usage.split("\n").length < 35); + assert.doesNotMatch(root.usage, /localhost|log_socket|media upload|screen assign|--token/); + const discovered: string[] = []; + function visit(path: string[]): void { + const help = commandHelp(path); + if (help.kind === "command") { + if (path.join(" ") !== "help") discovered.push(path.join(" ")); + assert.ok(help.synopsis.every((line) => line.startsWith(`screenrig ${path.join(" ")}`))); + return; + } + for (const child of help.commands) { + assert.equal(child.path.length, path.length + 1); + visit(child.path); + } + } + visit([]); + assert.deepEqual(discovered.sort(), COMMAND_SPECS.map((spec) => spec.path.join(" ")).sort()); +}); + +test("help paths, --help, and bare groups work before configuration or authentication", () => { + for (const path of [[], ["screen"], ["screen", "assign"], ["comment"], ["comment", "set"], ["comment", "set", "playlist"]]) { + const flagged = invoke([...path, "--help"]); + assert.deepEqual(flagged, invoke(["help", ...path])); + assert.deepEqual(flagged.data.path, path); + if (flagged.data.kind === "group") assert.deepEqual(flagged, invoke(path)); + } + const action = invoke(["screen", "assign", "--help"]).data; + assert.match(action.usage, /Usage: screenrig screen assign \[options\] <id>/); + for (const name of ["--playlist-id", "--if-match"]) { + const option = action.options.find((item) => item.name === name); + assert.equal(option?.type, "value"); + assert.ok(option?.description); + } + assert.ok(action.options.some((option) => option.name === "--playlist-id" && option.type === "value")); + assert.deepEqual(invoke(["comment", "show", "--help"]).data.commands.map((item) => item.name), ["screen", "playlist"]); +}); + +test("version aliases report version without needing a positional command", () => { + for (const args of [["version"], ["--version"], ["-V"]]) { + assert.equal(invoke(args).data.version, CLI_VERSION); + } + assert.deepEqual(invoke(["version", "--help"]).data.path, ["version"]); +}); + +test("unknown help paths fail rather than silently showing root help", () => { + assert.throws(() => invoke(["help", "screen", "assgin"]), (error: unknown) => { + const failure = error as { status: number; stdout: string }; + assert.equal(failure.status, 2); + const envelope = JSON.parse(failure.stdout); + assert.equal(envelope.ok, false); + assert.equal(envelope.error.code, "usage_error"); + assert.equal(envelope.error.status, 400); + assert.equal(Object.hasOwn(envelope, "data"), false); + return true; + }); +}); + +test("compatibility aliases resolve canonical help", () => { + assert.deepEqual(commandHelp(["playlist", "get"]), commandHelp(["playlist", "show"])); + assert.deepEqual(commandHelp(["playlist", "show"]).aliases, [["playlist", "get"]]); +}); + +test("help appended to an invocation resolves the command without echoing argument values", () => { + assert.deepEqual(invoke(["screen", "assign", "scr_TEST", "--help"]), invoke(["screen", "assign", "--help"])); + assert.deepEqual(invoke(["playlist", "get", "pl_TEST", "--help"]), invoke(["playlist", "show", "--help"])); +}); + +test("negative switches are documented as booleans without value placeholders", () => { + const help = invoke(["media", "upload", "--help"]).data; + for (const name of ["--no-wait", "--no-transcode", "--no-progress", "--no-audio"]) { + const option = help.options.find((item) => item.name === name); + assert.equal(option?.type, "boolean", name); + assert.ok(option?.description, name); + assert.match(help.usage, new RegExp(`${name}\\s+[^<\\s]`)); + } +}); diff --git a/src/help.ts b/src/help.ts new file mode 100644 index 00000000..cc02d102 --- /dev/null +++ b/src/help.ts @@ -0,0 +1,50 @@ +import type { Command, Option } from "commander"; +import { commandPath, createCommandTree, findCommand } from "./command-tree.js"; +import { NOTES } from "./help-text.js"; +import { usageError } from "./problems.js"; +export { CREDIT_HELP } from "./help-text.js"; + +export interface HelpDocument { + path: string[]; + aliases: string[][]; + kind: "group" | "command"; + usage: string; + synopsis: string[]; + commands: Array<{ name: string; path: string[]; kind: "group" | "command"; summary: string; help: string }>; + options: Array<{ name: string; type: "boolean" | "value"; description: string; required: boolean }>; + globalOptions: Array<{ name: string; type: "boolean" | "value"; description: string; required: boolean }>; + notes: string[]; +} + +/** JSON discovery and human help both read the actual Commander command tree. */ +export function describeHelp(command: Command): HelpDocument { + const helper = command.createHelp(); + const path = commandPath(command); + const note = NOTES[path.join(" ")]; + const notes = note ? [note] : []; + const describeOption = (option: Option) => ({ + name: option.long!, type: (option.negate || option.isBoolean()) ? "boolean" as const : "value" as const, description: option.description, required: option.mandatory, + }); + return { + path, + aliases: command.aliases().map((alias) => [...path.slice(0, -1), alias]), + kind: command.commands.length ? "group" : "command", + usage: [command.helpInformation().trimEnd(), ...notes].join("\n\n"), + synopsis: [helper.commandUsage(command)], + commands: helper.visibleCommands(command).map((child) => ({ + name: child.name(), path: commandPath(child), kind: child.commands.length ? "group" as const : "command" as const, + summary: child.description(), help: `screenrig ${commandPath(child).join(" ")} --help`, + })), + options: helper.visibleOptions(command).map(describeOption), + globalOptions: helper.visibleGlobalOptions(command).map(describeOption), + notes, + }; +} + +export function commandHelp(path: readonly string[] = []): HelpDocument { + const command = findCommand(createCommandTree().root, path); + if (!command) throw usageError("Unknown help topic."); + return describeHelp(command); +} + +export const ROOT_HELP = commandHelp().usage; diff --git a/src/log/logger.test.ts b/src/log/logger.test.ts index 3a2c1c91..83c7c663 100644 --- a/src/log/logger.test.ts +++ b/src/log/logger.test.ts @@ -122,7 +122,7 @@ async function listenUnix(socketPath: string): Promise<{ events: LogEvent[]; wai test("USAGE and argv do not define a log-socket flag", () => { assert.doesNotMatch(USAGE, /\[--log-socket/); - assert.match(USAGE, /log_socket/); + assert.doesNotMatch(USAGE, /log_socket/); assert.throws(() => parseArgv(["--log-socket", "/tmp/screenrig.sock", "version"]), (error: unknown) => error instanceof CliError && error.problem.code === "usage_error"); }); diff --git a/src/media-generate.test.ts b/src/media-generate.test.ts index 83034f25..515e27bd 100644 --- a/src/media-generate.test.ts +++ b/src/media-generate.test.ts @@ -3,7 +3,7 @@ import { chmod, mkdir, open, rename, rm, stat } from "node:fs/promises"; import path from "node:path"; import { PassThrough } from "node:stream"; import { test } from "node:test"; -import { USAGE } from "./commands.js"; +import { commandHelp } from "./help.js"; import { writeConfigAtomic, type ConfigFs } from "./config.js"; import { ExitCode } from "./exit-codes.js"; import { run, type CliRuntime } from "./main.js"; @@ -105,12 +105,16 @@ function generateTransport(): FakeTransport { } test("USAGE lists media generate", () => { - assert.match( - USAGE, - /media generate --prompt TEXT \[--aspect-ratio RATIO\] \[--quality low\|medium\|high\] \[--tag TAG\]/, - ); - assert.doesNotMatch(USAGE, /media generate.*--no-wait/); - assert.doesNotMatch(USAGE, /--quality auto/); + const help = commandHelp(["media", "generate"]); + assert.match(help.synopsis[0]!, /media generate \[options\]/); + for (const name of ["--prompt", "--aspect-ratio", "--quality", "--tag"]) { + const option = help.options.find((item) => item.name === name); + assert.equal(option?.type, "value"); + assert.ok(option?.description); + } + assert.match(help.usage, /--quality <low\|medium\|high>/); + assert.equal(help.options.some((option) => option.name === "--no-wait"), false); + assert.doesNotMatch(help.usage, /--quality.*auto/); }); test("media generate stores the still and returns med_… plus usage", async () => { diff --git a/src/media-upload-batch.test.ts b/src/media-upload-batch.test.ts index 2fdf6d55..b37e2bf5 100644 --- a/src/media-upload-batch.test.ts +++ b/src/media-upload-batch.test.ts @@ -6,7 +6,7 @@ import { PassThrough } from "node:stream"; import { test } from "node:test"; import type { MediaUploadDeclaration, Operation } from "./adapters/protocol.js"; import { ApiClient } from "./client.js"; -import { USAGE } from "./commands.js"; +import { commandHelp } from "./help.js"; import { writeConfigAtomic, type ConfigFs } from "./config.js"; import { ExitCode } from "./exit-codes.js"; import { createMemoryLogger } from "./log/logger.js"; @@ -223,8 +223,13 @@ async function writeManifest(dir: string, files: string[]): Promise<string> { } test("USAGE lists media upload-batch", () => { - assert.match(USAGE, /media upload-batch <manifest\.json> --state FILE \[--concurrency N\]/); - assert.match(USAGE, /\[--no-transcode\] \[--tag TAG\] \[--no-progress\]/); + const help = commandHelp(["media", "upload-batch"]); + assert.match(help.synopsis[0]!, /media upload-batch \[options\] <manifest\.json>/); + for (const [name, type] of [["--state", "value"], ["--concurrency", "value"], ["--no-transcode", "boolean"], ["--tag", "value"], ["--no-progress", "boolean"]]) { + const option = help.options.find((item) => item.name === name); + assert.equal(option?.type, type); + assert.ok(option?.description); + } }); test("backoff honours Retry-After and otherwise uses capped equal jitter", () => { diff --git a/src/playlist-bundle.test.ts b/src/playlist-bundle.test.ts index 6f16cc98..21597bce 100644 --- a/src/playlist-bundle.test.ts +++ b/src/playlist-bundle.test.ts @@ -19,7 +19,7 @@ import path from "node:path"; import { test } from "node:test"; import { parseArgv } from "./argv.js"; import { ApiClient } from "./client.js"; -import { USAGE } from "./commands.js"; +import { commandHelp } from "./help.js"; import { run, type CliRuntime } from "./main.js"; import { PLAYLIST_BUNDLE_MANIFEST, @@ -998,15 +998,21 @@ test("command parser and JSON help expose playlist export/import including updat const parsed = parseArgv(["playlist", "import", "./bundle", "--update", "pl_TARGET", "--if-match", "8"]); assert.equal(parsed.flags.update, "pl_TARGET"); assert.equal(parsed.flags["if-match"], "8"); - assert.match(USAGE, /playlist export <id> --output DIRECTORY/); - assert.match(USAGE, /playlist import <directory> \[--name NAME\] \[--update ID --if-match REVISION\]/); + const exportHelp = commandHelp(["playlist", "export"]); + assert.match(exportHelp.synopsis[0]!, /playlist export \[options\] <id>/); + assert.equal(exportHelp.options.find((option) => option.name === "--output")?.type, "value"); + const importHelp = commandHelp(["playlist", "import"]); + assert.match(importHelp.synopsis[0]!, /playlist import \[options\] <directory>/); + for (const name of ["--name", "--update", "--if-match"]) { + assert.equal(importHelp.options.find((option) => option.name === name)?.type, "value"); + } const stdout = new PassThrough(); const stderr = new PassThrough(); const output = collect(stdout); const errors = collect(stderr); const runtime: CliRuntime = { - argv: ["--json", "--help"], env: {}, stdout, stderr, now: () => new Date(), sleep: async () => undefined, + argv: ["--json", "playlist", "export", "--help"], env: {}, stdout, stderr, now: () => new Date(), sleep: async () => undefined, homedir: () => "/tmp", cwd: () => "/tmp", transport: new FakeTransport(), fs: { mkdir, open, rename, rm, chmod, stat, homedir: () => "/tmp", env: {} }, }; diff --git a/src/playlist-preview.test.ts b/src/playlist-preview.test.ts index 2cbdec2a..4a662081 100644 --- a/src/playlist-preview.test.ts +++ b/src/playlist-preview.test.ts @@ -4,7 +4,7 @@ import path from "node:path"; import { PassThrough } from "node:stream"; import { test } from "node:test"; import { loadImage } from "@napi-rs/canvas"; -import { USAGE } from "./commands.js"; +import { commandHelp } from "./help.js"; import { ExitCode } from "./exit-codes.js"; import { run, type CliRuntime } from "./main.js"; import type { ConfigFs } from "./config.js"; @@ -91,9 +91,12 @@ function iframePage(id: string, extra: Record<string, unknown> = {}): Record<str } test("USAGE and README sentence is printed verbatim", () => { - assert.match(USAGE, /playlist preview <file\|id> --output DIR \[--frame-ms MS\] \[--contact-sheet\] \[--lint-only\]/); - assert.ok(USAGE.includes(LOOK_AT_THE_CONTACT_SHEET)); - assert.match(USAGE, /\[--lint-only\]/); + const help = commandHelp(["playlist", "preview"]); + assert.match(help.synopsis[0]!, /playlist preview \[options\] <file\|id>/); + assert.ok(help.usage.includes(LOOK_AT_THE_CONTACT_SHEET)); + for (const [name, type] of [["--output", "value"], ["--frame-ms", "value"], ["--contact-sheet", "boolean"], ["--lint-only", "boolean"]]) { + assert.equal(help.options.find((option) => option.name === name)?.type, type); + } }); test("playlist preview writes N*3 stable files and a 6-column contact sheet", async () => { diff --git a/src/screenshot.test.ts b/src/screenshot.test.ts index 72cfd36c..dcf4011e 100644 --- a/src/screenshot.test.ts +++ b/src/screenshot.test.ts @@ -5,6 +5,7 @@ import path from "node:path"; import { PassThrough } from "node:stream"; import { test } from "node:test"; import { parseArgv } from "./argv.js"; +import { CliError } from "./problems.js"; import { writeConfigAtomic, type ConfigFs } from "./config.js"; import { ExitCode } from "./exit-codes.js"; import { run, type CliRuntime } from "./main.js"; @@ -130,10 +131,8 @@ function screenshotCalls(transport: FakeTransport): TransportRequest[] { } test("parseArgv keeps screen screenshot id and --output as a file path", () => { - const missing = parseArgv(["screen", "screenshot"]); - assert.deepEqual(missing.command, ["screen", "screenshot"]); - assert.equal(missing.positionals[2], undefined); - assert.equal(missing.flags.output, undefined); + assert.throws(() => parseArgv(["screen", "screenshot"]), + (error: unknown) => error instanceof CliError && error.problem.code === "usage_error"); const defaults = parseArgv(["screen", "screenshot", SCREEN_ID]); assert.equal(defaults.positionals[2], SCREEN_ID); diff --git a/src/validate-invocation.test.ts b/src/validate-invocation.test.ts index 8cb9b915..bfe0b7e5 100644 --- a/src/validate-invocation.test.ts +++ b/src/validate-invocation.test.ts @@ -4,7 +4,6 @@ import { parseArgv } from "./argv.js"; import { dispatch } from "./commands.js"; import { CliError } from "./problems.js"; import type { CliRuntime } from "./runtime.js"; -import { validateInvocation } from "./validate-invocation.js"; const invalid = [ ["screen", "update", "scr_TEST", "--name", "Lobby", "--if-match", "1", "--dry-run"], @@ -48,12 +47,12 @@ test("supported values, aliases, empty byte payload, and help paths remain valid ["kv", "set", "key", "--application-id", "app_TEST", "--value-base64=", "--content-type", "text/plain"], ["--help"], ["--version"], ["screen"], ["comment", "show"], ["screen", "assign", "--help"], ["help", "comment", "show", "screen"], ["help", "help"], - ]) assert.doesNotThrow(() => validateInvocation(parseArgv(argv)), JSON.stringify(argv)); + ]) assert.doesNotThrow(() => parseArgv(argv), JSON.stringify(argv)); assert.equal(parseArgv(["app", "pack", "--", "--directory"]).positionals[2], "--directory"); }); test("unknown option names and invalid values never appear in usage errors", () => { for (const argv of [["screen", "list", "--private-secret=value"], ["screen", "list", "--timeout=private-secret"]]) { - assert.throws(() => validateInvocation(parseArgv(argv)), (error: unknown) => error instanceof CliError && !JSON.stringify(error.problem).includes("private-secret")); + assert.throws(() => parseArgv(argv), (error: unknown) => error instanceof CliError && !JSON.stringify(error.problem).includes("private-secret")); } }); diff --git a/src/validate-invocation.ts b/src/validate-invocation.ts deleted file mode 100644 index fbf086b1..00000000 --- a/src/validate-invocation.ts +++ /dev/null @@ -1,63 +0,0 @@ -import type { ParsedArgs } from "./argv.js"; -import { BOOLEAN_FLAGS, COMMAND_SPECS, GLOBAL_FLAGS } from "./command-spec.js"; -import { quotedRevision } from "./if-match.js"; -import { usageError } from "./problems.js"; - -const NUMERIC_FLAGS = new Set(["timeout", "poll-ms", "target-width", "target-height", "limit", "max-fps", "max-edge", "webp-quality", "duration-ms", "concurrency", "frame-ms"]); - -/** Validate before config reads, enrollment, local writes, or transport calls. */ -export function validateInvocation(args: ParsedArgs): void { - const flags = args.flags; - let words = args.positionals[0] === "help" ? args.positionals.slice(1) : args.positionals; - const alias = COMMAND_SPECS.find((candidate) => candidate.aliases?.some((path) => path.every((word, index) => words[index] === word))); - if (alias) words = [...alias.path, ...words.slice(alias.path.length)]; - if (words[0] === "screen" && words[1] === "revoke-credential") throw usageError("screen revoke-credential is retired. Archive the screen instead.", { command: "screenrig --json screen archive <id> --if-match REVISION", reason: "Archive hides the screen; it does not unbind the player." }); - const subtree = COMMAND_SPECS.some((candidate) => words.every((word, index) => candidate.path[index] === word)); - const help = flags.help === true || args.positionals[0] === "help" || words.length === 0 || (subtree && !COMMAND_SPECS.some((candidate) => candidate.path.length === words.length && candidate.path.every((word, index) => words[index] === word))); - const matches = COMMAND_SPECS.filter((spec) => spec.path.every((word, index) => words[index] === word)); - const spec = matches.sort((a, b) => b.path.length - a.path.length)[0]; - if (words.length === 1 && words[0] === "help" && help) words = []; - if (!spec && !(help && (subtree || words.length === 0))) throw usageError(words[0] === "comment" ? "comment commands require screen <id> or playlist <id>." : "Unknown command. Use --help to discover supported commands."); - const allowed = new Set<string>([...GLOBAL_FLAGS, ...(spec?.flags ?? [])]); - for (const [name, value] of Object.entries(flags)) { - if (!allowed.has(name)) { - if (words[0] === "comment" && name === "value-base64") throw usageError("comment set requires --json-value or --file."); - if (words[0] === "comment" && name === "if-match") throw usageError("comment commands do not take --if-match; last write wins and does not bump revision."); - if (words[0] === "comment" && name === "page") throw usageError("comment screen commands do not take --page; use comment playlist <id> --page PAGE_ID."); - if (words[0] === "media" && words[1] === "list" && name === "kind") throw usageError("media list uses --primitive image|video, not --kind."); - throw usageError("Unsupported option for this command. Use --help to discover supported options."); - } - if (BOOLEAN_FLAGS.has(name)) { - if (value !== true) throw usageError(`--${name} takes no value.`); - } else { - if (typeof value !== "string" || (!value.length && name !== "value-base64")) throw usageError(`--${name} requires a value.`); - if (NUMERIC_FLAGS.has(name)) { - const number = Number(value); - if (name === "duration-ms" && (!Number.isInteger(number) || number < 2000 || number > 60000)) throw usageError("--duration-ms must be a whole number between 2000 and 60000."); - if (!value.trim() || !Number.isFinite(number) || number < 0 || (name !== "max-fps" && !Number.isSafeInteger(number))) { - throw usageError(`--${name} requires a nonnegative ${name === "max-fps" ? "number" : "whole number"}.`); - } - if (!["frame-ms", "timeout"].includes(name) && number === 0) throw usageError(`--${name} must be greater than zero.`); - } - } - } - if (spec && words.length > spec.path.length + spec.maxArgs) throw usageError(spec.maxArgs === 0 ? `${spec.path.join(" ")} does not accept positional arguments.` : `${spec.path.join(" ")} does not accept extra arguments; expected at most ${spec.maxArgs}.`); - if (help) return; - if (spec && words.length < spec.path.length + spec.minArgs) throw usageError(words[0] === "screen" && words[1] === "set-timezone" ? "screen set-timezone requires <id> --timezone --if-match." : `${spec.path.join(" ")} requires ${words[0] === "screen" || words[0] === "comment" ? "<id>" : `${spec.minArgs} positional argument(s)`}.`); - if (typeof flags["if-match"] === "string") quotedRevision(flags["if-match"]); - const has = (name: string): boolean => Object.hasOwn(flags, name); - const exclusive = (names: string[]): void => { - if (names.filter(has).length > 1) throw usageError(`Use only one of ${names.map((name) => `--${name}`).join(", ")}.`); - }; - exclusive(["after", "cursor"]); - exclusive(["tag", "clear-tag"]); - exclusive(["body", "body-file"]); - if (words[0] === "comment" && has("json-value") && has("file")) throw usageError("comment set requires exactly one of --json-value or --file."); - exclusive(["json-value", "file", "value-base64"]); - if (has("target-width") !== has("target-height")) throw usageError("Provide both --target-width and --target-height."); - if (has("json-value") && has("content-type")) throw usageError("--json-value always uses application/json; omit --content-type."); - if (has("no-transcode") && ["codec", "max-fps", "max-edge", "webp-quality", "preset", "no-audio"].some(has)) { - throw usageError("Transcode options require transcoding; remove --no-transcode."); - } - if (spec?.path.join(" ") === "playlist import" && has("update") !== has("if-match")) throw usageError("playlist import --update and --if-match must be supplied together."); -}