diff --git a/README.md b/README.md index b990ce68..2b1f5441 100644 --- a/README.md +++ b/README.md @@ -79,10 +79,17 @@ 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. +Commander 14 supports Node 20.11 and owns parsing, command selection, and help. +Add commands in `src/cli-commands/`: each group registers native Commander commands +with their arguments, options, descriptions, validation hooks, and bound handlers. +There is no separate command schema or path-based dispatcher. Shared option +parsers live beside these modules; human and JSON help read the registered tree. + +`src/program.ts` awaits Commander actions with `parseAsync`. The output boundary +keeps one CLI envelope per invocation, and shared handler setup handles config, +authentication, and logging. Only explicitly supplied options reach handlers, +including the existing `no-*` switches. `parseArgv` is an inspection helper for +tests; execution runs through Commander actions. ```sh npm ci diff --git a/src/argv.ts b/src/argv.ts index eabf4162..75b4a351 100644 --- a/src/argv.ts +++ b/src/argv.ts @@ -1,60 +1,23 @@ import { CommanderError } from "commander"; -import { commandPath, createCommandTree, invocationFlags } from "./command-tree.js"; +import { commandInput, createCommandTree } from "./command-tree.js"; +import type { ParsedArgs } from "./command-input.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 { flagString, flagBool, flagNumber, type ParsedArgs } from "./command-input.js"; -export function parseArgv(argv: string[]): ParsedArgs { - // 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.`); - } - const tree = createCommandTree(options); +/** Inspect the same native command tree without executing an application handler. */ +export function parseArgv(argv: string[]): ParsedArgs & { help?: HelpDocument } { + const tree = createCommandTree(argv); 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; + const args = commandInput(tree.selected()); + if (tree.versionRequested()) args.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 { - const value = flags[name]; - return typeof value === "string" && value.length > 0 ? value : undefined; -} - -export function flagBool(flags: Record, name: string): boolean { - return flags[name] === true; -} - -export function flagNumber(flags: Record, name: string): number | undefined { - const value = flagString(flags, name); - if (value === undefined) { - return undefined; + const help = describeHelp(tree.selected()); + return { command: help.path.slice(0, 2), positionals: help.path, flags: { ...args.flags, help: true }, help }; } - const n = Number(value); - return Number.isFinite(n) ? n : undefined; + return args; } diff --git a/src/cli-commands/account.ts b/src/cli-commands/account.ts new file mode 100644 index 00000000..0a2329e2 --- /dev/null +++ b/src/cli-commands/account.ts @@ -0,0 +1,12 @@ +import type { CommandActionBinder } from "./types.js"; +import { handleAccountShow } from "../commands.js"; +import type { Command } from "commander"; +import { addCommandNotes } from "./notes.js"; +import { CREDIT_HELP } from "../help-text.js"; + +export function registerAccountCommands(root: Command, bind: CommandActionBinder): void { + const account = root.command("account").description("Inspect your account and credits"); + + addCommandNotes(account.command("show").description("Inspect your account and credits") + .action(bind(handleAccountShow)), CREDIT_HELP); +} diff --git a/src/cli-commands/agent.ts b/src/cli-commands/agent.ts new file mode 100644 index 00000000..724fa590 --- /dev/null +++ b/src/cli-commands/agent.ts @@ -0,0 +1,27 @@ +import type { CommandActionBinder } from "./types.js"; +import { handleAgentEnroll, handleAgentConnect, handleAgentStatus, handleAgentDisconnect } from "../commands.js"; +import type { Command } from "commander"; +import { addCommandNotes } from "./notes.js"; + +export function registerAgentCommands(root: Command, bind: CommandActionBinder): void { + const agent = root.command("agent").description("Enroll, connect, and manage this agent"); + + agent.command("enroll").description("Create the first agent with an email address") + .option("--email
", "Set the account contact email") + .option("--name ", "Set this agent installation name") + .option("--open-dashboard", "Open the dashboard after enrolling") + .action(bind(handleAgentEnroll)); + + addCommandNotes(agent.command("connect").description("Connect this installation with approval") + .option("--name ", "Set this agent installation name") + .option("--print-url", "Return the browser handoff URL") + .action(bind(handleAgentConnect)), "Approval expires after 24 hours; --timeout defaults to 86400000 ms. Retry agent connect to resume after an interrupted wait."); + + agent.command("status").description("Inspect this agent's connection") + .action(bind(handleAgentStatus)); + + agent.command("disconnect").description("Disconnect this agent") + .option("--yes", "Confirm this agent's disconnection") + .option("--allow-lockout", "Allow disconnecting the last agent") + .action(bind(handleAgentDisconnect)); +} diff --git a/src/cli-commands/app.ts b/src/cli-commands/app.ts new file mode 100644 index 00000000..1eaa04d3 --- /dev/null +++ b/src/cli-commands/app.ts @@ -0,0 +1,35 @@ +import { positiveInteger, revision } from "./options.js"; +import type { CommandActionBinder } from "./types.js"; +import { handleAppPack, handleAppUpload, handleAppUpdate, handleAppList, handleAppShow } from "../commands.js"; +import type { Command } from "commander"; + +export function registerAppCommands(root: Command, bind: CommandActionBinder): void { + const app = root.command("app").description("Pack, upload, and inspect applications"); + + app.command("pack").description("Pack a local application directory") + .argument("", "Local directory") + .option("--output ", "Write the application package to this file") + .action(bind(handleAppPack)); + + app.command("upload").description("Upload an application directory") + .argument("", "Local directory") + .option("--name ", "Set the application name") + .option("--no-wait", "Return after acceptance without waiting for processing") + .option("--poll-ms ", "Set the operation polling interval", positiveInteger("poll-ms")) + .action(bind(handleAppUpload)); + + app.command("update").description("Update an application") + .argument("", "Application identifier") + .argument("", "Local directory") + .requiredOption("--if-match ", "Require the current resource revision (required)", revision) + .option("--no-wait", "Return after acceptance without waiting for processing") + .option("--poll-ms ", "Set the operation polling interval", positiveInteger("poll-ms")) + .action(bind(handleAppUpdate)); + + app.command("list").description("List applications") + .action(bind(handleAppList)); + + app.command("show").description("Inspect an application") + .argument("", "Application identifier") + .action(bind(handleAppShow)); +} diff --git a/src/cli-commands/browser.ts b/src/cli-commands/browser.ts new file mode 100644 index 00000000..c275af06 --- /dev/null +++ b/src/cli-commands/browser.ts @@ -0,0 +1,12 @@ +import type { CommandActionBinder } from "./types.js"; +import { handleBrowserSetup } from "../commands.js"; +import type { Command } from "commander"; + +export function registerBrowserCommands(root: Command, bind: CommandActionBinder): void { + const browser = root.command("browser").description("Complete browser Player setup"); + + browser.command("setup").description("Complete browser setup from a code") + .requiredOption("--code ", "Use the browser setup code (required)") + .option("--open", "Open the result in its viewer") + .action(bind(handleBrowserSetup)); +} diff --git a/src/cli-commands/comment.ts b/src/cli-commands/comment.ts new file mode 100644 index 00000000..984dd51c --- /dev/null +++ b/src/cli-commands/comment.ts @@ -0,0 +1,44 @@ +import type { CommandActionBinder } from "./types.js"; +import { handleCommentShowScreen, handleCommentShowPlaylist, handleCommentSetScreen, handleCommentSetPlaylist, handleCommentDeleteScreen, handleCommentDeletePlaylist } from "../commands.js"; +import { type Command, Option } from "commander"; + +export function registerCommentCommands(root: Command, bind: CommandActionBinder): void { + const comment = root.command("comment").description("Read and manage screen and playlist comments"); + + const commentShow = comment.command("show").description("Read screen or playlist comments"); + + commentShow.command("screen").description("Read screen comments") + .argument("", "Screen or playlist identifier") + .action(bind(handleCommentShowScreen)); + + commentShow.command("playlist").description("Read playlist comments") + .argument("", "Screen or playlist identifier") + .option("--page ", "Select playlist page comments") + .action(bind(handleCommentShowPlaylist)); + + const commentSet = comment.command("set").description("Replace screen or playlist comments"); + + commentSet.command("screen").description("Replace screen comments") + .argument("", "Screen or playlist identifier") + .addOption(new Option("--json-value ", "Supply a JSON value").conflicts(["file"])) + .addOption(new Option("--file ", "Read the value from a file").conflicts(["jsonValue"])) + .action(bind(handleCommentSetScreen)); + + commentSet.command("playlist").description("Replace playlist comments") + .argument("", "Screen or playlist identifier") + .option("--page ", "Select playlist page comments") + .addOption(new Option("--json-value ", "Supply a JSON value").conflicts(["file"])) + .addOption(new Option("--file ", "Read the value from a file").conflicts(["jsonValue"])) + .action(bind(handleCommentSetPlaylist)); + + const commentDelete = comment.command("delete").description("Delete screen or playlist comments"); + + commentDelete.command("screen").description("Delete screen comments") + .argument("", "Screen or playlist identifier") + .action(bind(handleCommentDeleteScreen)); + + commentDelete.command("playlist").description("Delete playlist comments") + .argument("", "Screen or playlist identifier") + .option("--page ", "Select playlist page comments") + .action(bind(handleCommentDeletePlaylist)); +} diff --git a/src/cli-commands/compose.ts b/src/cli-commands/compose.ts new file mode 100644 index 00000000..59321162 --- /dev/null +++ b/src/cli-commands/compose.ts @@ -0,0 +1,43 @@ +import { usageError } from "../problems.js"; +import { positiveInteger } from "./options.js"; +import type { CommandActionBinder } from "./types.js"; +import { handleComposeCatalog, handleComposeBatch, handleComposeRender } from "../commands.js"; +import type { Command } from "commander"; +import { addCommandNotes } from "./notes.js"; +import { LOOK_AT_THE_CONTACT_SHEET } from "../playlist-preview.js"; + +export function registerComposeCommands(root: Command, bind: CommandActionBinder): void { + const compose = root.command("compose").description("Render and inspect local page compositions"); + + compose.command("catalog").description("Browse local composition capabilities and examples") + .action(bind(handleComposeCatalog)); + + addCommandNotes(compose.command("batch").description("Render a batch of compositions") + .argument("", "Local input file") + .requiredOption("--output ", "Write rendered files to this directory (required)") + .option("--only ", "Render only this page") + .option("--target-width ", "Set the physical content width", positiveInteger("target-width")) + .option("--target-height ", "Set the physical content height", positiveInteger("target-height")) + .option("--safe-area", "Apply the display safe area") + .option("--lint-only", "Run the command's local validation/lint mode") + .hook("preAction", requireTargetDimensions) + .action(bind(handleComposeBatch)), `Render 1 to 2000 pages. ${LOOK_AT_THE_CONTACT_SHEET}`); + + addCommandNotes(compose.command("render").description("Render a composition locally") + .argument("", "Local input file") + .option("--output ", "Write rendered files to this directory") + .option("--combined", "Also render combined page images") + .option("--target-width ", "Set the physical content width", positiveInteger("target-width")) + .option("--target-height ", "Set the physical content height", positiveInteger("target-height")) + .option("--safe-area", "Apply the display safe area") + .option("--open", "Open the result in its viewer") + .option("--lint-only", "Run the command's local validation/lint mode") + .hook("preAction", requireTargetDimensions) + .action(bind(handleComposeRender)), LOOK_AT_THE_CONTACT_SHEET); +} + +function requireTargetDimensions(command: Command): void { + const width = command.getOptionValueSource("targetWidth") === "cli"; + const height = command.getOptionValueSource("targetHeight") === "cli"; + if (width !== height) throw usageError("Provide both --target-width and --target-height."); +} diff --git a/src/cli-commands/dashboard.ts b/src/cli-commands/dashboard.ts new file mode 100644 index 00000000..96fa73bd --- /dev/null +++ b/src/cli-commands/dashboard.ts @@ -0,0 +1,9 @@ +import type { CommandActionBinder } from "./types.js"; +import { handleDashboard } from "../commands.js"; +import type { Command } from "commander"; + +export function registerDashboardCommands(root: Command, bind: CommandActionBinder): void { + root.command("dashboard").description("Open the account dashboard") + .option("--print-url", "Return the browser handoff URL") + .action(bind(handleDashboard)); +} diff --git a/src/cli-commands/doctor.ts b/src/cli-commands/doctor.ts new file mode 100644 index 00000000..5ec980f1 --- /dev/null +++ b/src/cli-commands/doctor.ts @@ -0,0 +1,9 @@ +import type { CommandActionBinder } from "./types.js"; +import { handleDoctor } from "../commands.js"; +import type { Command } from "commander"; + +export function registerDoctorCommands(root: Command, bind: CommandActionBinder): void { + root.command("doctor").description("Check this CLI installation and configuration") + .option("--repair-config", "Repair configuration permissions") + .action(bind(handleDoctor)); +} diff --git a/src/cli-commands/events.ts b/src/cli-commands/events.ts new file mode 100644 index 00000000..56ebf7e4 --- /dev/null +++ b/src/cli-commands/events.ts @@ -0,0 +1,19 @@ +import { positiveInteger } from "./options.js"; +import type { CommandActionBinder } from "./types.js"; +import { handleEventsList, handleEventsFollow } from "../commands.js"; +import { type Command, Option } from "commander"; + +export function registerEventsCommands(root: Command, bind: CommandActionBinder): void { + const events = root.command("events").description("List or follow account events"); + + events.command("list").description("List account events") + .addOption(new Option("--after ", "Read events after this cursor").conflicts(["cursor"])) + .addOption(new Option("--cursor ", "Alias for --after").conflicts(["after"])) + .option("--limit ", "Limit returned events", positiveInteger("limit")) + .action(bind(handleEventsList)); + + events.command("follow").description("Stream account events") + .addOption(new Option("--after ", "Read events after this cursor").conflicts(["cursor"])) + .addOption(new Option("--cursor ", "Alias for --after").conflicts(["after"])) + .action(bind(handleEventsFollow)); +} diff --git a/src/cli-commands/feedback.ts b/src/cli-commands/feedback.ts new file mode 100644 index 00000000..92988f90 --- /dev/null +++ b/src/cli-commands/feedback.ts @@ -0,0 +1,27 @@ +import type { CommandActionBinder } from "./types.js"; +import { handleFeedbackBug, handleFeedbackFeature, handleFeedbackList } from "../commands.js"; +import { type Command, Option } from "commander"; + +export function registerFeedbackCommands(root: Command, bind: CommandActionBinder): void { + const feedback = root.command("feedback").description("Submit bugs and feature requests"); + + feedback.command("bug").description("Submit a bug report") + .argument("", "Feedback title") + .addOption(new Option("--body <TEXT>", "Supply feedback details").conflicts(["bodyFile"])) + .addOption(new Option("--body-file <FILE>", "Read feedback details from a file").conflicts(["body"])) + .option("--command <GROUP ACTION>", "Identify the command involved") + .option("--no-context", "Omit diagnostic context") + .action(bind(handleFeedbackBug)); + + feedback.command("feature").description("Submit a feature request") + .argument("<title>", "Feedback title") + .addOption(new Option("--body <TEXT>", "Supply feedback details").conflicts(["bodyFile"])) + .addOption(new Option("--body-file <FILE>", "Read feedback details from a file").conflicts(["body"])) + .option("--command <GROUP ACTION>", "Identify the command involved") + .option("--no-context", "Omit diagnostic context") + .action(bind(handleFeedbackFeature)); + + feedback.command("list").description("List submitted feedback") + .option("--kind <bug|feature>", "Filter feedback by kind") + .action(bind(handleFeedbackList)); +} diff --git a/src/cli-commands/globals.ts b/src/cli-commands/globals.ts new file mode 100644 index 00000000..c915bb4c --- /dev/null +++ b/src/cli-commands/globals.ts @@ -0,0 +1,14 @@ +import { type Command, Option } from "commander"; +import { nonnegativeInteger } from "./options.js"; + +export function registerGlobalOptions(root: Command): void { + root + .option("--json", "Return a JSON envelope") + .option("--api-url <URL>", "Override the API origin") + .option("--config <PATH>", "Use this credential/configuration file") + .option("--request-id <ID>", "Use a request correlation ID") + .option("--idempotency-key <KEY>", "Reuse a key when retrying the same write") + .option("--timeout <MS>", "Set the command timeout in milliseconds", nonnegativeInteger("timeout")) + .option("--beta-key <KEY>", "Supply an enrollment beta key") + .addOption(new Option("--token <TOKEN>", "Unsupported credential override; use agent enrollment or connection").hideHelp()); +} diff --git a/src/cli-commands/index.ts b/src/cli-commands/index.ts new file mode 100644 index 00000000..dfb31e36 --- /dev/null +++ b/src/cli-commands/index.ts @@ -0,0 +1,41 @@ +import { registerGlobalOptions } from "./globals.js"; +import type { CommandActionBinder } from "./types.js"; +import type { Command } from "commander"; +import { registerAccountCommands } from "./account.js"; +import { registerAgentCommands } from "./agent.js"; +import { registerDashboardCommands } from "./dashboard.js"; +import { registerAppCommands } from "./app.js"; +import { registerMediaCommands } from "./media.js"; +import { registerComposeCommands } from "./compose.js"; +import { registerPlaylistCommands } from "./playlist.js"; +import { registerScreenCommands } from "./screen.js"; +import { registerBrowserCommands } from "./browser.js"; +import { registerKvCommands } from "./kv.js"; +import { registerCommentCommands } from "./comment.js"; +import { registerOperationsCommands } from "./operations.js"; +import { registerEventsCommands } from "./events.js"; +import { registerPlaybackCommands } from "./playback.js"; +import { registerFeedbackCommands } from "./feedback.js"; +import { registerDoctorCommands } from "./doctor.js"; +import { registerVersionCommands } from "./version.js"; + +export function registerCommands(root: Command, bind: CommandActionBinder): void { + registerGlobalOptions(root); + registerAccountCommands(root, bind); + registerAgentCommands(root, bind); + registerDashboardCommands(root, bind); + registerAppCommands(root, bind); + registerMediaCommands(root, bind); + registerComposeCommands(root, bind); + registerPlaylistCommands(root, bind); + registerScreenCommands(root, bind); + registerBrowserCommands(root, bind); + registerKvCommands(root, bind); + registerCommentCommands(root, bind); + registerOperationsCommands(root, bind); + registerEventsCommands(root, bind); + registerPlaybackCommands(root, bind); + registerFeedbackCommands(root, bind); + registerDoctorCommands(root, bind); + registerVersionCommands(root, bind); +} diff --git a/src/cli-commands/kv.ts b/src/cli-commands/kv.ts new file mode 100644 index 00000000..1bf27dad --- /dev/null +++ b/src/cli-commands/kv.ts @@ -0,0 +1,33 @@ +import { revision } from "./options.js"; +import type { CommandActionBinder } from "./types.js"; +import { handleKvGet, handleKvSet, handleKvDelete, handleKvList } from "../commands.js"; +import { type Command, Option } from "commander"; + +export function registerKvCommands(root: Command, bind: CommandActionBinder): void { + const kv = root.command("kv").description("Read and write application key-value data"); + + kv.command("get").description("Read an application key") + .argument("<key>", "Application key") + .requiredOption("--application-id <ID>", "Select the application's K/V namespace (required)") + .action(bind(handleKvGet)); + + kv.command("set").description("Write a value") + .argument("<key>", "Application key") + .requiredOption("--application-id <ID>", "Select the application's K/V namespace (required)") + .addOption(new Option("--json-value <JSON>", "Supply a JSON value").conflicts(["file", "valueBase64", "contentType"])) + .addOption(new Option("--file <FILE>", "Read the value from a file").conflicts(["jsonValue", "valueBase64"])) + .addOption(new Option("--value-base64 <BASE64>", "Supply canonical base64 bytes (may be empty)").conflicts(["jsonValue", "file"])) + .addOption(new Option("--content-type <TYPE>", "Declare the content MIME type").conflicts(["jsonValue"])) + .option("--if-match <REVISION>", "Require the current resource revision", revision) + .action(bind(handleKvSet)); + + kv.command("delete").description("Delete an application key") + .argument("<key>", "Application key") + .requiredOption("--application-id <ID>", "Select the application's K/V namespace (required)") + .requiredOption("--if-match <REVISION>", "Require the current resource revision (required)", revision) + .action(bind(handleKvDelete)); + + kv.command("list").description("List application keys") + .requiredOption("--application-id <ID>", "Select the application's K/V namespace (required)") + .action(bind(handleKvList)); +} diff --git a/src/cli-commands/media.ts b/src/cli-commands/media.ts new file mode 100644 index 00000000..ce4d3ac5 --- /dev/null +++ b/src/cli-commands/media.ts @@ -0,0 +1,75 @@ +import { positiveInteger, positiveNumber, revision } from "./options.js"; +import type { CommandActionBinder } from "./types.js"; +import { handleMediaGenerate, handleMediaUpload, handleMediaUploadBatch, handleMediaShow, handleMediaDownload, handleMediaList, handleMediaUpdate, handleMediaDelete } from "../commands.js"; +import { type Command, Option } from "commander"; +import { addCommandNotes } from "./notes.js"; +import { CREDIT_HELP } from "../help-text.js"; + +export function registerMediaCommands(root: Command, bind: CommandActionBinder): void { + const media = root.command("media").description("Generate, upload, and manage images and videos"); + + addCommandNotes(media.command("generate").description("Generate an image from a prompt") + .requiredOption("--prompt <TEXT>", "Describe the complete image to generate (required)") + .option("--aspect-ratio <RATIO>", "Choose the generated image aspect ratio") + .option("--quality <low|medium|high>", "Choose generation quality") + .option("--tag <TAG>", "Set the media tag") + .action(bind(handleMediaGenerate)), CREDIT_HELP); + + media.command("upload").description("Upload an image or video") + .argument("<file>", "Local input file") + .option("--content-type <TYPE>", "Declare the content MIME type") + .option("--tag <TAG>", "Set the media tag") + .option("--no-wait", "Return after acceptance without waiting for processing") + .option("--poll-ms <MS>", "Set the operation polling interval", positiveInteger("poll-ms")) + .addOption(new Option("--no-transcode", "Upload accepted source bytes unchanged").conflicts(["codec", "maxFps", "maxEdge", "webpQuality", "preset", "audio"])) + .addOption(new Option("--codec <h264|hevc>", "Choose the video codec (default: h264)").conflicts(["transcode"])) + .addOption(new Option("--max-fps <N>", "Limit video frames per second").argParser(positiveNumber("max-fps")).conflicts(["transcode"])) + .addOption(new Option("--max-edge <PIXELS>", "Limit the longest video edge").argParser(positiveInteger("max-edge")).conflicts(["transcode"])) + .addOption(new Option("--webp-quality <1-100>", "Set WebP quality").argParser(positiveInteger("webp-quality")).conflicts(["transcode"])) + .option("--no-progress", "Suppress stderr progress") + .addOption(new Option("--preset <PRESET>", "Use signage-1080p30 or signage-4k30").conflicts(["transcode"])) + .addOption(new Option("--no-audio", "Remove the audio track when transcoding").conflicts(["transcode"])) + .action(bind(handleMediaUpload)); + + media.command("upload-batch").description("Upload a manifest with resumable state") + .argument("<manifest.json>", "Upload manifest file") + .requiredOption("--state <FILE>", "Persist resumable upload state in this file (required)") + .option("--concurrency <N>", "Set concurrent batch uploads", positiveInteger("concurrency")) + .addOption(new Option("--no-transcode", "Upload accepted source bytes unchanged").conflicts(["codec", "maxFps", "maxEdge", "webpQuality", "preset", "audio"])) + .option("--tag <TAG>", "Set the media tag") + .option("--no-progress", "Suppress stderr progress") + .option("--poll-ms <MS>", "Set the operation polling interval", positiveInteger("poll-ms")) + .addOption(new Option("--codec <h264|hevc>", "Choose the video codec (default: h264)").conflicts(["transcode"])) + .addOption(new Option("--max-fps <N>", "Limit video frames per second").argParser(positiveNumber("max-fps")).conflicts(["transcode"])) + .addOption(new Option("--max-edge <PIXELS>", "Limit the longest video edge").argParser(positiveInteger("max-edge")).conflicts(["transcode"])) + .addOption(new Option("--webp-quality <1-100>", "Set WebP quality").argParser(positiveInteger("webp-quality")).conflicts(["transcode"])) + .addOption(new Option("--preset <PRESET>", "Use signage-1080p30 or signage-4k30").conflicts(["transcode"])) + .addOption(new Option("--no-audio", "Remove the audio track when transcoding").conflicts(["transcode"])) + .action(bind(handleMediaUploadBatch)); + + media.command("show").description("Inspect media") + .argument("<id>", "Media identifier") + .action(bind(handleMediaShow)); + + media.command("download").description("Download media to a file") + .argument("<id>", "Media identifier") + .option("--output <PATH>", "Write downloaded media to this file") + .action(bind(handleMediaDownload)); + + media.command("list").description("List media") + .option("--tag <TAG>", "Filter media by tag") + .option("--primitive <image|video>", "Filter by media primitive") + .action(bind(handleMediaList)); + + media.command("update").description("Update a media tag") + .argument("<id>", "Media identifier") + .addOption(new Option("--tag <TAG>", "Set the media tag").conflicts(["clearTag"])) + .addOption(new Option("--clear-tag", "Remove the media tag").conflicts(["tag"])) + .requiredOption("--if-match <REVISION>", "Require the current resource revision (required)", revision) + .action(bind(handleMediaUpdate)); + + media.command("delete").description("Delete media") + .argument("<id>", "Media identifier") + .requiredOption("--if-match <REVISION>", "Require the current resource revision (required)", revision) + .action(bind(handleMediaDelete)); +} diff --git a/src/cli-commands/notes.ts b/src/cli-commands/notes.ts new file mode 100644 index 00000000..81e771bc --- /dev/null +++ b/src/cli-commands/notes.ts @@ -0,0 +1,13 @@ +import type { Command } from "commander"; + +const notes = new WeakMap<Command, string[]>(); + +/** Attach native help text and retain the same note for structured discovery. */ +export function addCommandNotes(command: Command, note: string): void { + command.addHelpText("after", `\n${note}`); + notes.set(command, [...(notes.get(command) ?? []), note]); +} + +export function commandNotes(command: Command): string[] { + return [...(notes.get(command) ?? [])]; +} diff --git a/src/cli-commands/operations.ts b/src/cli-commands/operations.ts new file mode 100644 index 00000000..dbed92eb --- /dev/null +++ b/src/cli-commands/operations.ts @@ -0,0 +1,21 @@ +import { positiveInteger } from "./options.js"; +import type { CommandActionBinder } from "./types.js"; +import { handleOperationsGet, handleOperationsWait, handleOperationsCancel } from "../commands.js"; +import type { Command } from "commander"; + +export function registerOperationsCommands(root: Command, bind: CommandActionBinder): void { + const operations = root.command("operations").description("Inspect, wait for, or cancel operations"); + + operations.command("get").description("Inspect an operation") + .argument("<id>", "Operation identifier") + .action(bind(handleOperationsGet)); + + operations.command("wait").description("Wait for an operation to finish") + .argument("<id>", "Operation identifier") + .option("--poll-ms <MS>", "Set the operation polling interval", positiveInteger("poll-ms")) + .action(bind(handleOperationsWait)); + + operations.command("cancel").description("Cancel an operation") + .argument("<id>", "Operation identifier") + .action(bind(handleOperationsCancel)); +} diff --git a/src/cli-commands/options.ts b/src/cli-commands/options.ts new file mode 100644 index 00000000..11bd0ff1 --- /dev/null +++ b/src/cli-commands/options.ts @@ -0,0 +1,45 @@ +import { quotedRevision } from "../if-match.js"; +import { usageError } from "../problems.js"; + +export function nonnegativeInteger(name: string): (value: string) => string { + return (value) => { + const number = Number(value); + if (!value.trim() || !Number.isSafeInteger(number) || number < 0) { + throw usageError(`--${name} requires a nonnegative whole number.`); + } + return value; + }; +} + +export function positiveInteger(name: string): (value: string) => string { + const parse = nonnegativeInteger(name); + return (value) => { + parse(value); + if (Number(value) === 0) throw usageError(`--${name} must be greater than zero.`); + return value; + }; +} + +export function positiveNumber(name: string): (value: string) => string { + return (value) => { + const number = Number(value); + if (!value.trim() || !Number.isFinite(number) || number < 0) { + throw usageError(`--${name} requires a nonnegative number.`); + } + if (number === 0) throw usageError(`--${name} must be greater than zero.`); + return value; + }; +} + +export function toastDuration(value: string): string { + const number = Number(value); + if (!Number.isInteger(number) || number < 2000 || number > 60000) { + throw usageError("--duration-ms must be a whole number between 2000 and 60000."); + } + return value; +} + +export function revision(value: string): string { + quotedRevision(value); + return value; +} diff --git a/src/cli-commands/playback.ts b/src/cli-commands/playback.ts new file mode 100644 index 00000000..77e17783 --- /dev/null +++ b/src/cli-commands/playback.ts @@ -0,0 +1,13 @@ +import type { CommandActionBinder } from "./types.js"; +import { handlePlaybackList } from "../commands.js"; +import type { Command } from "commander"; + +export function registerPlaybackCommands(root: Command, bind: CommandActionBinder): void { + const playback = root.command("playback").description("Inspect playback records"); + + playback.command("list").description("List playback records") + .option("--screen-id <ID>", "Filter playback by screen") + .option("--media-id <ID>", "Filter playback by media") + .option("--day <YYYY-MM-DD>", "Filter playback by UTC day") + .action(bind(handlePlaybackList)); +} diff --git a/src/cli-commands/playlist.ts b/src/cli-commands/playlist.ts new file mode 100644 index 00000000..e768c507 --- /dev/null +++ b/src/cli-commands/playlist.ts @@ -0,0 +1,68 @@ +import { usageError } from "../problems.js"; +import { nonnegativeInteger, positiveInteger, revision } from "./options.js"; +import type { CommandActionBinder } from "./types.js"; +import { handlePlaylistValidate, handlePlaylistPreview, handlePlaylistTemplates, handlePlaylistCreate, handlePlaylistUpdate, handlePlaylistExport, handlePlaylistImport, handlePlaylistShow, handlePlaylistList, handlePlaylistDelete } from "../commands.js"; +import type { Command } from "commander"; +import { addCommandNotes } from "./notes.js"; +import { LOOK_AT_THE_CONTACT_SHEET } from "../playlist-preview.js"; + +export function registerPlaylistCommands(root: Command, bind: CommandActionBinder): void { + const playlist = root.command("playlist").description("Author, validate, preview, and manage playlists"); + + playlist.command("validate").description("Validate a playlist file") + .argument("<file>", "Local input file") + .option("--lint-only", "Run the command's local validation/lint mode") + .action(bind(handlePlaylistValidate)); + + addCommandNotes(playlist.command("preview").description("Render playlist previews and a contact sheet") + .argument("<file|id>", "Playlist file or playlist identifier") + .requiredOption("--output <PATH>", "Write preview files to this directory (required)") + .option("--frame-ms <MS>", "Select the preview frame time", nonnegativeInteger("frame-ms")) + .option("--contact-sheet", "Include a contact sheet") + .option("--lint-only", "Run the command's local validation/lint mode") + .action(bind(handlePlaylistPreview)), LOOK_AT_THE_CONTACT_SHEET); + + playlist.command("templates").description("Browse playlist templates") + .action(bind(handlePlaylistTemplates)); + + playlist.command("create").description("Create a playlist from a file") + .argument("<file>", "Local input file") + .action(bind(handlePlaylistCreate)); + + playlist.command("update").description("Update a playlist") + .argument("<id>", "Playlist identifier") + .argument("<file>", "Local input file") + .requiredOption("--if-match <REVISION>", "Require the current resource revision (required)", revision) + .action(bind(handlePlaylistUpdate)); + + playlist.command("export").description("Export a playlist bundle") + .argument("<id>", "Playlist identifier") + .requiredOption("--output <PATH>", "Write the exported bundle to this directory (required)") + .action(bind(handlePlaylistExport)); + + playlist.command("import").description("Import a playlist bundle") + .argument("<directory>", "Local directory") + .option("--name <NAME>", "Set the imported playlist name") + .option("--update <ID>", "Update this playlist when importing") + .option("--if-match <REVISION>", "Require the current resource revision", revision) + .option("--poll-ms <MS>", "Set the operation polling interval", positiveInteger("poll-ms")) + .hook("preAction", (command) => { + const update = command.getOptionValueSource("update") === "cli"; + const ifMatch = command.getOptionValueSource("ifMatch") === "cli"; + if (update !== ifMatch) throw usageError("playlist import --update and --if-match must be supplied together."); + }) + .action(bind(handlePlaylistImport)); + + playlist.command("show").description("Inspect a playlist") + .alias("get") + .argument("<id>", "Playlist identifier") + .action(bind(handlePlaylistShow)); + + playlist.command("list").description("List playlists") + .action(bind(handlePlaylistList)); + + playlist.command("delete").description("Delete a playlist") + .argument("<id>", "Playlist identifier") + .requiredOption("--if-match <REVISION>", "Require the current resource revision (required)", revision) + .action(bind(handlePlaylistDelete)); +} diff --git a/src/cli-commands/screen.ts b/src/cli-commands/screen.ts new file mode 100644 index 00000000..306409a9 --- /dev/null +++ b/src/cli-commands/screen.ts @@ -0,0 +1,80 @@ +import { positiveInteger, revision, toastDuration } from "./options.js"; +import type { CommandActionBinder } from "./types.js"; +import { handleScreenPair, handleScreenProvision, handleScreenUpdate, handleScreenList, handleScreenShow, handleScreenAssign, handleScreenSetTimezone, handleScreenArchive, handleScreenUnarchive, handleScreenDelete, handleScreenRotatePublicId, handleScreenToast, handleScreenScreenshot } from "../commands.js"; +import type { Command } from "commander"; + +export function registerScreenCommands(root: Command, bind: CommandActionBinder): void { + const screen = root.command("screen").description("Pair, configure, and inspect screens"); + + screen.command("pair").description("Claim a Player pairing code") + .argument("<code>", "Player pairing code") + .option("--label <LABEL>", "Set the screen label") + .action(bind(handleScreenPair)); + + screen.command("provision").description("Create a screen and browser handoff") + .option("--open", "Open the browser Player handoff") + .option("--print-url", "Return the browser handoff URL") + .option("--label <LABEL>", "Set the screen label") + .action(bind(handleScreenProvision)); + + screen.command("update").description("Update a screen") + .argument("<id>", "Screen identifier") + .option("--name <NAME>", "Set the screen name") + .option("--playlist-id <ID>", "Select the playlist to assign") + .option("--timezone <ZONE>", "Set an IANA timezone") + .requiredOption("--if-match <REVISION>", "Require the current resource revision (required)", revision) + .action(bind(handleScreenUpdate)); + + screen.command("list").description("List screens") + .option("--state <archived>", "List archived screens") + .action(bind(handleScreenList)); + + screen.command("show").description("Inspect a screen") + .argument("<id>", "Screen identifier") + .action(bind(handleScreenShow)); + + screen.command("assign").description("Assign a playlist to a screen") + .argument("<id>", "Screen identifier") + .requiredOption("--playlist-id <ID>", "Select the playlist to assign (required)") + .requiredOption("--if-match <REVISION>", "Require the current resource revision (required)", revision) + .action(bind(handleScreenAssign)); + + screen.command("set-timezone").description("Set a screen's timezone") + .argument("<id>", "Screen identifier") + .requiredOption("--timezone <ZONE>", "Set an IANA timezone (required)") + .requiredOption("--if-match <REVISION>", "Require the current resource revision (required)", revision) + .action(bind(handleScreenSetTimezone)); + + screen.command("archive").description("Archive a screen") + .argument("<id>", "Screen identifier") + .requiredOption("--if-match <REVISION>", "Require the current resource revision (required)", revision) + .action(bind(handleScreenArchive)); + + screen.command("unarchive").description("Restore an archived screen") + .argument("<id>", "Screen identifier") + .requiredOption("--if-match <REVISION>", "Require the current resource revision (required)", revision) + .action(bind(handleScreenUnarchive)); + + screen.command("delete").description("Delete a screen") + .argument("<id>", "Screen identifier") + .requiredOption("--if-match <REVISION>", "Require the current resource revision (required)", revision) + .action(bind(handleScreenDelete)); + + screen.command("rotate-public-id").description("Rotate a screen's public identifier") + .argument("<id>", "Screen identifier") + .requiredOption("--if-match <REVISION>", "Require the current resource revision (required)", revision) + .action(bind(handleScreenRotatePublicId)); + + screen.command("toast").description("Show a temporary screen message") + .argument("<id>", "Screen identifier") + .requiredOption("--text <TEXT>", "Set the screen message (required)") + .option("--level <LEVEL>", "Set toast level (default: info)") + .option("--duration-ms <MS>", "Show the message for 2000–60000 milliseconds", toastDuration) + .action(bind(handleScreenToast)); + + screen.command("screenshot").description("Capture and download a screen screenshot") + .argument("<id>", "Screen identifier") + .option("--output <PATH>", "Write the screenshot to this file") + .option("--poll-ms <MS>", "Set the operation polling interval", positiveInteger("poll-ms")) + .action(bind(handleScreenScreenshot)); +} diff --git a/src/cli-commands/types.ts b/src/cli-commands/types.ts new file mode 100644 index 00000000..b0c078af --- /dev/null +++ b/src/cli-commands/types.ts @@ -0,0 +1,3 @@ +import type { CommandHandler } from "../commands.js"; + +export type CommandActionBinder = (handler: CommandHandler) => (...args: unknown[]) => void | Promise<void>; diff --git a/src/cli-commands/version.ts b/src/cli-commands/version.ts new file mode 100644 index 00000000..e6352577 --- /dev/null +++ b/src/cli-commands/version.ts @@ -0,0 +1,8 @@ +import type { CommandActionBinder } from "./types.js"; +import { handleVersion } from "../commands.js"; +import type { Command } from "commander"; + +export function registerVersionCommands(root: Command, bind: CommandActionBinder): void { + root.command("version").description("Show CLI and protocol versions") + .action(bind(handleVersion)); +} diff --git a/src/cli-errors.ts b/src/cli-errors.ts new file mode 100644 index 00000000..70c9412a --- /dev/null +++ b/src/cli-errors.ts @@ -0,0 +1,41 @@ +import type { Command, CommanderError } from "commander"; +import { commandPath } from "./command-path.js"; +import { usageError } from "./problems.js"; + +/** Commander diagnostics can contain raw option names/values. Never expose them. */ +export 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": + throw usageError("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." }); + } +} + diff --git a/src/command-input.ts b/src/command-input.ts new file mode 100644 index 00000000..b7ea1c1a --- /dev/null +++ b/src/command-input.ts @@ -0,0 +1,24 @@ +/** Explicit command input passed from Commander to the application handlers. */ +export interface ParsedArgs { + command: string[]; + flags: Record<string, string | boolean>; + positionals: string[]; +} + +export function flagString(flags: Record<string, string | boolean>, name: string): string | undefined { + const value = flags[name]; + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +export function flagBool(flags: Record<string, string | boolean>, name: string): boolean { + return flags[name] === true; +} + +export function flagNumber(flags: Record<string, string | boolean>, name: string): number | undefined { + const value = flagString(flags, name); + if (value === undefined) { + return undefined; + } + const n = Number(value); + return Number.isFinite(n) ? n : undefined; +} diff --git a/src/command-path.ts b/src/command-path.ts new file mode 100644 index 00000000..df70fdd0 --- /dev/null +++ b/src/command-path.ts @@ -0,0 +1,29 @@ +import type { Command } from "commander"; + +/** 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; +} + diff --git a/src/command-spec.ts b/src/command-spec.ts deleted file mode 100644 index 76e2ff60..00000000 --- a/src/command-spec.ts +++ /dev/null @@ -1,80 +0,0 @@ -/** Declarative command inputs for the Commander tree. */ -export interface CommandSpec { - readonly path: readonly string[]; - readonly arguments?: string; - readonly aliases?: readonly (readonly string[])[]; - readonly flags: readonly string[]; - readonly requiredFlags?: readonly string[]; -} - -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"], 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: "<directory>", flags: ["output"] }, - { path: ["app", "upload"], arguments: "<directory>", flags: ["name", "no-wait", "poll-ms"] }, - { path: ["app", "update"], arguments: "<id> <directory>", requiredFlags: ["if-match"], flags: ["if-match", "no-wait", "poll-ms"] }, - { path: ["app", "list"], flags: [] }, - { path: ["app", "show"], arguments: "<id>", flags: [] }, - { path: ["media", "generate"], requiredFlags: ["prompt"], flags: ["prompt", "aspect-ratio", "quality", "tag"] }, - { path: ["media", "upload"], arguments: "<file>", 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: "<manifest.json>", 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: "<id>", flags: [] }, - { path: ["media", "download"], arguments: "<id>", flags: ["output"] }, - { path: ["media", "list"], flags: ["tag", "primitive"] }, - { path: ["media", "update"], arguments: "<id>", requiredFlags: ["if-match"], flags: ["tag", "clear-tag", "if-match"] }, - { path: ["media", "delete"], arguments: "<id>", requiredFlags: ["if-match"], flags: ["if-match"] }, - { path: ["compose", "catalog"], flags: [] }, - { path: ["compose", "batch"], arguments: "<file>", requiredFlags: ["output"], flags: ["output", "only", "target-width", "target-height", "safe-area", "lint-only"] }, - { path: ["compose", "render"], arguments: "<file>", flags: ["output", "combined", "target-width", "target-height", "safe-area", "open", "lint-only"] }, - { path: ["playlist", "validate"], arguments: "<file>", flags: ["lint-only"] }, - { path: ["playlist", "preview"], arguments: "<file|id>", requiredFlags: ["output"], flags: ["output", "frame-ms", "contact-sheet", "lint-only"] }, - { path: ["playlist", "templates"], flags: [] }, - { path: ["playlist", "create"], arguments: "<file>", flags: [] }, - { path: ["playlist", "update"], arguments: "<id> <file>", requiredFlags: ["if-match"], flags: ["if-match"] }, - { path: ["playlist", "export"], arguments: "<id>", requiredFlags: ["output"], flags: ["output"] }, - { path: ["playlist", "import"], arguments: "<directory>", flags: ["name", "update", "if-match", "poll-ms"] }, - { path: ["playlist", "show"], aliases: [["playlist", "get"]], arguments: "<id>", flags: [] }, - { path: ["playlist", "list"], flags: [] }, - { path: ["playlist", "delete"], arguments: "<id>", requiredFlags: ["if-match"], flags: ["if-match"] }, - { path: ["screen", "pair"], arguments: "<code>", flags: ["label"] }, - { path: ["screen", "provision"], flags: ["open", "print-url", "label"] }, - { path: ["browser", "setup"], requiredFlags: ["code"], flags: ["code", "open"] }, - { path: ["screen", "update"], arguments: "<id>", requiredFlags: ["if-match"], flags: ["name", "playlist-id", "timezone", "if-match"] }, - { path: ["screen", "list"], flags: ["state"] }, - { path: ["screen", "show"], arguments: "<id>", flags: [] }, - { path: ["screen", "assign"], arguments: "<id>", requiredFlags: ["playlist-id", "if-match"], flags: ["playlist-id", "if-match"] }, - { path: ["screen", "set-timezone"], arguments: "<id>", requiredFlags: ["timezone", "if-match"], flags: ["timezone", "if-match"] }, - { path: ["screen", "archive"], arguments: "<id>", requiredFlags: ["if-match"], flags: ["if-match"] }, - { path: ["screen", "unarchive"], arguments: "<id>", requiredFlags: ["if-match"], flags: ["if-match"] }, - { path: ["screen", "delete"], arguments: "<id>", requiredFlags: ["if-match"], flags: ["if-match"] }, - { path: ["screen", "rotate-public-id"], arguments: "<id>", requiredFlags: ["if-match"], flags: ["if-match"] }, - { path: ["screen", "toast"], arguments: "<id>", requiredFlags: ["text"], flags: ["text", "level", "duration-ms"] }, - { path: ["screen", "screenshot"], arguments: "<id>", flags: ["output", "poll-ms"] }, - { path: ["kv", "get"], arguments: "<key>", requiredFlags: ["application-id"], flags: ["application-id"] }, - { path: ["kv", "set"], arguments: "<key>", requiredFlags: ["application-id"], flags: ["application-id", "json-value", "file", "value-base64", "content-type", "if-match"] }, - { path: ["kv", "delete"], arguments: "<key>", requiredFlags: ["application-id", "if-match"], flags: ["application-id", "if-match"] }, - { path: ["kv", "list"], requiredFlags: ["application-id"], flags: ["application-id"] }, - { path: ["comment", "show", "screen"], arguments: "<id>", flags: [] }, - { path: ["comment", "show", "playlist"], arguments: "<id>", flags: ["page"] }, - { path: ["comment", "set", "screen"], arguments: "<id>", flags: ["json-value", "file"] }, - { path: ["comment", "set", "playlist"], arguments: "<id>", flags: ["page", "json-value", "file"] }, - { path: ["comment", "delete", "screen"], arguments: "<id>", flags: [] }, - { path: ["comment", "delete", "playlist"], arguments: "<id>", flags: ["page"] }, - { path: ["operations", "get"], arguments: "<id>", flags: [] }, - { path: ["operations", "wait"], arguments: "<id>", flags: ["poll-ms"] }, - { path: ["operations", "cancel"], arguments: "<id>", 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: "<title>", 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 index 7994c261..32b60641 100644 --- a/src/command-tree.ts +++ b/src/command-tree.ts @@ -1,207 +1,96 @@ -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 { Command, Option } from "commander"; +import type { ParsedArgs } from "./command-input.js"; +import type { CommandHandler } from "./commands.js"; +import { commandError } from "./cli-errors.js"; +import { commandPath, findCommand, invocationFlags } from "./command-path.js"; +import { registerCommands } from "./cli-commands/index.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]), -]; +export { commandPath, findCommand, invocationFlags } from "./command-path.js"; -/** 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; +export interface CommandTree { + root: Command; + selected: () => Command; + helpRequested: () => boolean; + versionRequested: () => boolean; } -/** 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; +export function commandInput(command: Command): ParsedArgs { + const positionals = [...commandPath(command), ...command.args]; + return { command: positionals.slice(0, 2), positionals, flags: invocationFlags(command) }; } -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.`); +/** Shared transport safeguards, independent of the individual command definitions. */ +function protectOption(command: Command, option: Option, argv: readonly string[], seen: Set<string>): void { + const name = option.long!.slice(2); + if (option.required || option.optional) { + const parser = option.parseArg; + option.argParser((value: string, previous: unknown) => { + // An option-looking token is usually a missing value. Explicit '=' permits it. + 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.`); + } + return parser ? parser(value, previous) : value; + }); } - if (name === "if-match") quotedRevision(value); - return value; + command.on(`option:${option.name()}`, () => { + if (seen.has(name)) throw usageError(`--${name} may be supplied only once.`); + seen.add(name); + }); } -/** 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."); +/** Fresh native commands per invocation; actions return their asynchronous work. */ +export function createCommandTree( + argv: readonly string[] = [], + execute?: (handler: CommandHandler, args: ParsedArgs) => Promise<void>, +): CommandTree { + const end = argv.indexOf("--"); + const tokens = end < 0 ? argv : argv.slice(0, end); + for (const spellings of [["--help", "-h"], ["--version", "-V"]]) { + if (tokens.filter((arg) => spellings.includes(arg)).length > 1) throw usageError(`${spellings[0]} may be supplied only once.`); } - 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; + const root = new Command("screenrig") + .description("Signage and kiosk infrastructure for agents") + .version(CLI_VERSION, "-V, --version", "Show CLI and protocol versions"); + let selected = root; 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); + + registerCommands(root, (handler) => (...values: unknown[]) => { + selected = values[values.length - 1] as Command; + return execute?.(handler, commandInput(selected)); + }); + root.command("help [command...]") + .description("Discover commands and their options") + .action((path: string[]) => { + const target = findCommand(root, path); + if (!target) throw usageError("Unknown help topic."); + target.help(); }); - }; - 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())); + + const configure = (command: Command): void => { + 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; + }); + for (const option of command.options) { + // Commander handles the built-in version option before emitting option events. + if (option.long !== "--version") protectOption(command, option, tokens, seen); } - 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); + command.commands.forEach(configure); }; - groups(root); + configure(root); return { root, selected: () => selected, helpRequested: () => helpRequested, versionRequested: () => versionRequested }; } diff --git a/src/commands.ts b/src/commands.ts index 900b233a..3b65657c 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -45,7 +45,7 @@ import { type ScreenToastWrite, } from "./adapters/protocol.js"; import { SDK_PROTOCOL_VERSION } from "./adapters/sdk-injection.js"; -import { flagBool, flagNumber, flagString, type ParsedArgs } from "./argv.js"; +import { flagBool, flagNumber, flagString, type ParsedArgs } from "./command-input.js"; import { ApiClient, requireToken } from "./client.js"; import { preserveLogSocket, @@ -141,8 +141,6 @@ import { CLI_VERSION } from "./version.js"; export { CLI_VERSION }; -export { ROOT_HELP as USAGE } from "./help.js"; - export interface CommandResult { envelope: ReturnType<typeof successEnvelope<unknown>>; exitCode: ExitCode; @@ -194,9 +192,7 @@ async function composeRender(args: ParsedArgs, runtime: CliRuntime): Promise<Com reason: "Inspect the fail-closed compose catalog, then compose render a spec.", }); } - if (args.positionals.length > 3) { - throw usageError("compose render accepts one spec file."); - } + if (file.includes("\0")) { throw usageError("compose render spec path must not contain a NUL byte."); } @@ -359,236 +355,197 @@ function humanLines(title: string, fields: Array<[string, string | undefined]>): return lines.join("\n"); } -export async function dispatch(args: ParsedArgs, runtime: CliRuntime): Promise<CommandResult> { - const group = args.positionals[0]; - const action = args.positionals[1]; - if ((flagBool(args.flags, "version") || group === "version") && !flagBool(args.flags, "help") && group !== "help") { - return { - envelope: successEnvelope({ version: CLI_VERSION, protocol_adapter: TEMPORARY_PROTOCOL_VERSION }), - exitCode: ExitCode.Success, - human: `screenrig ${CLI_VERSION}`, - }; - } - if (args.help) { - const help = args.help; - return { - envelope: successEnvelope(help), - exitCode: ExitCode.Success, - human: help.usage, - }; - } +export type CommandHandler = (args: ParsedArgs, runtime: CliRuntime) => Promise<CommandResult>; +type ResolvedCommandConfig = Awaited<ReturnType<typeof resolveConfig>>; + +/** Prepare configuration, logging and credential guidance once for a bound leaf. */ +function commandHandler( + handler: (args: ParsedArgs, runtime: CliRuntime, resolved: ResolvedCommandConfig) => Promise<CommandResult>, + authenticated = true, +): CommandHandler { + return async (args, runtime) => { + const repair = flagBool(args.flags, "repair-config"); + const resolved = await resolveConfig({ flags: args.flags, fs: { ...runtime.fs, env: runtime.env, homedir: runtime.homedir }, repair }); + await attachOperationLogger(runtime, args, resolved); + if (authenticated && !resolved.token) { + if (resolved.agentConnection) { + throw notEnrolledError("This installation has a pending agent connection and no active credential.", { + command: "screenrig agent connect", + reason: "Resume the passkey-approved connection before running account commands.", + }); + } + if (resolved.lastAgent) { + throw notEnrolledError("This installation is disconnected and cannot run account commands.", { + command: "screenrig agent connect", + reason: "Connect a new independently revocable agent through dashboard passkey approval.", + }); + } + throw notEnrolledError("This installation is not enrolled. Enrollment is an explicit step and is never a side effect of another command.", { + command: resolved.enrollment?.email + ? "screenrig agent enroll" + : "screenrig agent enroll --email ADDRESS", + reason: resolved.enrollment?.email + ? "Resume the exact pending enrollment before running pairing or another account command." + : "Create the first agent with unverified contact metadata, then retry the original command.", + }); + } + return handler(args, runtime, resolved); + }; +} - if (!group) throw usageError("A command is required."); +export const handleVersion: CommandHandler = async () => { + return { + envelope: successEnvelope({ version: CLI_VERSION, protocol_adapter: TEMPORARY_PROTOCOL_VERSION }), + exitCode: ExitCode.Success, + human: `screenrig ${CLI_VERSION}`, + }; - 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); +}; - if (group === "compose" && action === "catalog") { - if (args.positionals.length > 2) { - throw usageError("compose catalog does not accept positional arguments."); - } - return loggerOf(runtime).withLocal({ op: "compose.catalog", message: "compose catalog" }, async () => { - const catalog = composeCatalog(); - return { - envelope: successEnvelope(catalog), - exitCode: ExitCode.Success, - human: formatComposeCatalog(catalog), - }; - }); - } - if (group === "playlist" && action === "validate") { - const file = args.positionals[2]; - if (!file || args.positionals.length !== 3) throw usageError("playlist validate requires one JSON file."); - let parsed: Record<string, unknown>; - try { parsed = JSON.parse(await readFile(path.resolve(runtime.cwd(), file), "utf8")); } - catch { throw usageError("Cannot read playlist JSON."); } - const body = parsed && typeof parsed === "object" && Array.isArray(parsed.pages) ? { ...parsed, pages: expandPlaylistPages(parsed.pages) } : parsed; - assertPlaylistValid(body); - const lint = playlistLint(body); - return { - envelope: successEnvelope({ valid: true, scope: "local_schema_and_semantics", server_checks: PLAYLIST_SERVER_CHECKS, lint }), - exitCode: ExitCode.Success, - human: [ - "Playlist passed local canonical validation. Reference authorization and runtime readiness require server checks.", - ...lint.map((item) => `lint: ${item.page_id} ${item.code} ${item.id}`), - LOOK_AT_THE_CONTACT_SHEET, - ].join("\n"), - }; - } - if (group === "playlist" && action === "preview") { - return playlistPreviewCommand(args, runtime, resolved); - } - if (group === "compose" && action === "batch") { - const file = args.positionals[2]; - requireFlagValue(args, "output", "./rendered"); - const output = flagString(args.flags, "output"); - if (!file || !output || args.positionals.length !== 3) throw usageError("compose batch requires one JSON file and --output DIRECTORY."); - try { - rejectImageLikeOutput(path.resolve(runtime.cwd(), output), "compose batch"); - } catch (error) { rethrowCompose(error); } - requireFlagValue(args, "target-width", "3840"); requireFlagValue(args, "target-height", "2160"); requireFlagValue(args, "only", "page-id"); - const tw = flagString(args.flags, "target-width"), th = flagString(args.flags, "target-height"); - if ((tw === undefined) !== (th === undefined)) throw usageError("Provide both target dimensions."); - const target = tw !== undefined && th !== undefined ? { width: Number(tw), height: Number(th) } : undefined; - let result; - try { - result = await composeBatch(path.resolve(runtime.cwd(), file), path.resolve(runtime.cwd(), output), { - target, - safeArea: flagBool(args.flags, "safe-area"), - only: flagString(args.flags, "only"), - lintOnly: flagBool(args.flags, "lint-only"), - }); - } - catch (error) { rethrowCompose(error); } - const warnings = result.pages.flatMap((page) => (page.warnings ?? []).map((warning) => ({ ...warning, message: `${page.id}: ${warning.message}` }))); - if (result.failed) throw new CliError(makeProblem("usage_error", "Some pages could not render", 400, `${result.failed} page(s) failed. Successful outputs are retained. See ${result.manifest} and ${result.preview}.`, { errors: result.pages.filter((page) => page.status === "failed").map((page) => ({ page_id: page.id, ...page.error })) }), ExitCode.Usage, warnings); - return { - envelope: successEnvelope(result, { warnings }), - exitCode: ExitCode.Success, - human: [ - `Rendered ${result.rendered} page(s). Preview: ${result.preview}. Details: ${result.manifest}.`, - ...result.lint.map((item) => `lint: ${item.page_id} ${item.code} ${item.id}`), - LOOK_AT_THE_CONTACT_SHEET, - ].join("\n"), - }; - } - if (group === "compose" && action === "render") { - return composeRender(args, runtime); - } - if (group === "playlist" && action === "templates") { - if (args.positionals.length > 2) { - throw usageError("playlist templates does not accept positional arguments."); - } - const catalog = playlistTemplateCatalog(); +export const handleComposeCatalog = commandHandler(async (args, runtime, resolved) => { + + return loggerOf(runtime).withLocal({ op: "compose.catalog", message: "compose catalog" }, async () => { + const catalog = composeCatalog(); return { envelope: successEnvelope(catalog), exitCode: ExitCode.Success, - human: formatTemplateCatalog(catalog), + human: formatComposeCatalog(catalog), }; - } - if (group === "compose") { - throw usageError("Unknown compose command. Use compose catalog or compose render.", { - command: "screenrig --json compose catalog", - reason: "List the fail-closed compose catalog.", - }); - } + }); +}, false); - if (group === "doctor") { - return doctor(args, runtime, resolved); - } - if (group === "app" && action === "pack") { - return appPack(args, runtime); - } - if (group === "agent" && action === "status") { - return agentStatus(args, runtime, resolved); - } - if (group === "agent" && action === "connect") { - return loggerOf(runtime).withLocal({ op: "agent.connect", message: "agent connect" }, () => - agentConnect(args, runtime, resolved), - ); - } - if (group === "agent" && action === "enroll") { - return loggerOf(runtime).withLocal({ op: "agent.enroll", message: "agent enroll" }, () => - agentEnroll(args, runtime, resolved), - ); - } - if (group === "agent" && action === "disconnect") { - return agentDisconnect(args, runtime, resolved); - } - if (isAuthenticatedCommand(group, action) && !resolved.token) { - if (resolved.agentConnection) { - throw notEnrolledError("This installation has a pending agent connection and no active credential.", { - command: "screenrig agent connect", - reason: "Resume the passkey-approved connection before running account commands.", - }); - } - if (resolved.lastAgent) { - throw notEnrolledError("This installation is disconnected and cannot run account commands.", { - command: "screenrig agent connect", - reason: "Connect a new independently revocable agent through dashboard passkey approval.", - }); - } - throw notEnrolledError("This installation is not enrolled. Enrollment is an explicit step and is never a side effect of another command.", { - command: resolved.enrollment?.email - ? "screenrig agent enroll" - : "screenrig agent enroll --email ADDRESS", - reason: resolved.enrollment?.email - ? "Resume the exact pending enrollment before running pairing or another account command." - : "Create the first agent with unverified contact metadata, then retry the original command.", - }); - } - if (group === "account" && action === "show") { - return accountShow(args, runtime, resolved); - } - if (group === "dashboard") { - return dashboardCommand(args, runtime, resolved); - } - if (group === "app" && (action === "upload" || action === "update")) { - return loggerOf(runtime).withLocal({ op: `app.${action}`, message: `app ${action}` }, () => - appUpload(args, runtime, resolved), - ); - } - if (group === "app" && action === "list") { - return simpleGet(args, runtime, resolved, "/api/v1/applications", "Applications"); - } - if (group === "app" && action === "show") { - const id = args.positionals[2]; - if (!id) throw usageError("app show requires an application id."); - return simpleGet(args, runtime, resolved, `/api/v1/applications/${id}`, "Application"); - } - if (group === "media") { - return mediaCommand(args, runtime, resolved, action); - } - if (group === "playlist") { - return playlistCommand(args, runtime, resolved, action); - } - if (group === "screen") { - return screenCommand(args, runtime, resolved, action); - } - if (group === "browser" && action === "setup") { - return browserSetupCommand(args, runtime, resolved); - } - if (group === "kv") { - return kvCommand(args, runtime, resolved, action); - } - if (group === "comment") { - return commentCommand(args, runtime, resolved, action); - } - if (group === "operations" && action === "get") { - return operationsGet(args, runtime, resolved); - } - if (group === "operations" && action === "wait") { - return operationsWait(args, runtime, resolved); - } - if (group === "operations" && action === "cancel") { - return operationsCancel(args, runtime, resolved); - } - if (group === "events" && action === "list") { - return eventsList(args, runtime, resolved); - } - if (group === "events" && action === "follow") { - return loggerOf(runtime).withLocal({ op: "events.follow", message: "events follow" }, () => - eventsFollow(args, runtime, resolved), - ); - } - if (group === "playback" && action === "list") { - return playbackList(args, runtime, resolved); - } - if (group === "feedback") { - return feedbackCommand(args, runtime, resolved, action); - } - if (group === "agent") { - throw usageError("Unknown agent command. Use agent enroll, connect, status, or disconnect.", { - command: "screenrig --help", - reason: "List implemented agent identity commands.", +export const handlePlaylistValidate = commandHandler(async (args, runtime, resolved) => { + const file = args.positionals[2]; + if (!file) throw usageError("playlist validate requires one JSON file."); + let parsed: Record<string, unknown>; + try { parsed = JSON.parse(await readFile(path.resolve(runtime.cwd(), file), "utf8")); } + catch { throw usageError("Cannot read playlist JSON."); } + const body = parsed && typeof parsed === "object" && Array.isArray(parsed.pages) ? { ...parsed, pages: expandPlaylistPages(parsed.pages) } : parsed; + assertPlaylistValid(body); + const lint = playlistLint(body); + return { + envelope: successEnvelope({ valid: true, scope: "local_schema_and_semantics", server_checks: PLAYLIST_SERVER_CHECKS, lint }), + exitCode: ExitCode.Success, + human: [ + "Playlist passed local canonical validation. Reference authorization and runtime readiness require server checks.", + ...lint.map((item) => `lint: ${item.page_id} ${item.code} ${item.id}`), + LOOK_AT_THE_CONTACT_SHEET, + ].join("\n"), + }; +}, false); + +export const handlePlaylistPreview = commandHandler(playlistPreviewCommand, false); + +export const handleComposeBatch = commandHandler(async (args, runtime, resolved) => { + const file = args.positionals[2]; + requireFlagValue(args, "output", "./rendered"); + const output = flagString(args.flags, "output"); + if (!file || !output) throw usageError("compose batch requires one JSON file and --output DIRECTORY."); + try { + rejectImageLikeOutput(path.resolve(runtime.cwd(), output), "compose batch"); + } catch (error) { rethrowCompose(error); } + requireFlagValue(args, "target-width", "3840"); requireFlagValue(args, "target-height", "2160"); requireFlagValue(args, "only", "page-id"); + const tw = flagString(args.flags, "target-width"), th = flagString(args.flags, "target-height"); + if ((tw === undefined) !== (th === undefined)) throw usageError("Provide both target dimensions."); + const target = tw !== undefined && th !== undefined ? { width: Number(tw), height: Number(th) } : undefined; + let result; + try { + result = await composeBatch(path.resolve(runtime.cwd(), file), path.resolve(runtime.cwd(), output), { + target, + safeArea: flagBool(args.flags, "safe-area"), + only: flagString(args.flags, "only"), + lintOnly: flagBool(args.flags, "lint-only"), }); } - throw usageError(`Unknown command: ${args.positionals.join(" ")}`, { - command: "screenrig --help", - reason: "List implemented commands.", - }); -} + catch (error) { rethrowCompose(error); } + const warnings = result.pages.flatMap((page) => (page.warnings ?? []).map((warning) => ({ ...warning, message: `${page.id}: ${warning.message}` }))); + if (result.failed) throw new CliError(makeProblem("usage_error", "Some pages could not render", 400, `${result.failed} page(s) failed. Successful outputs are retained. See ${result.manifest} and ${result.preview}.`, { errors: result.pages.filter((page) => page.status === "failed").map((page) => ({ page_id: page.id, ...page.error })) }), ExitCode.Usage, warnings); + return { + envelope: successEnvelope(result, { warnings }), + exitCode: ExitCode.Success, + human: [ + `Rendered ${result.rendered} page(s). Preview: ${result.preview}. Details: ${result.manifest}.`, + ...result.lint.map((item) => `lint: ${item.page_id} ${item.code} ${item.id}`), + LOOK_AT_THE_CONTACT_SHEET, + ].join("\n"), + }; +}, false); + +export const handleComposeRender = commandHandler(composeRender, false); + +export const handlePlaylistTemplates = commandHandler(async (args, runtime, resolved) => { + + const catalog = playlistTemplateCatalog(); + return { + envelope: successEnvelope(catalog), + exitCode: ExitCode.Success, + human: formatTemplateCatalog(catalog), + }; +}, false); + +export const handleDoctor = commandHandler(doctor, false); + +export const handleAppPack = commandHandler(appPack, false); + +export const handleAgentStatus = commandHandler(agentStatus, false); + +export const handleAgentConnect = commandHandler(async (args, runtime, resolved) => { + return loggerOf(runtime).withLocal({ op: "agent.connect", message: "agent connect" }, () => + agentConnect(args, runtime, resolved), + ); +}, false); + +export const handleAgentEnroll = commandHandler(async (args, runtime, resolved) => { + return loggerOf(runtime).withLocal({ op: "agent.enroll", message: "agent enroll" }, () => + agentEnroll(args, runtime, resolved), + ); +}, false); + +export const handleAgentDisconnect = commandHandler(agentDisconnect, false); + +export const handleAccountShow = commandHandler(accountShow); + +export const handleDashboard = commandHandler(dashboardCommand); + +export const handleAppUpload = commandHandler(async (args, runtime, resolved) => { + return loggerOf(runtime).withLocal({ op: "app.upload", message: "app upload" }, () => + appUpload(args, runtime, resolved, false), + ); +}, true); + +export const handleAppUpdate = commandHandler(async (args, runtime, resolved) => { + return loggerOf(runtime).withLocal({ op: "app.update", message: "app update" }, () => + appUpload(args, runtime, resolved, true), + ); +}, true); + +export const handleAppList = commandHandler(async (args, runtime, resolved) => { + return simpleGet(args, runtime, resolved, "/api/v1/applications", "Applications"); +}, true); + +export const handleAppShow = commandHandler(async (args, runtime, resolved) => { + const id = args.positionals[2]; + if (!id) throw usageError("app show requires an application id."); + return simpleGet(args, runtime, resolved, `/api/v1/applications/${id}`, "Application"); +}, true); + +export const handleBrowserSetup = commandHandler(browserSetupCommand); + +export const handleOperationsGet = commandHandler(operationsGet); + +export const handleOperationsWait = commandHandler(operationsWait); + +export const handleOperationsCancel = commandHandler(operationsCancel); + +export const handleEventsList = commandHandler(eventsList); + +export const handleEventsFollow = commandHandler(async (args, runtime, resolved) => { + return loggerOf(runtime).withLocal({ op: "events.follow", message: "events follow" }, () => + eventsFollow(args, runtime, resolved), + ); +}, true); + +export const handlePlaybackList = commandHandler(playbackList); function safeAgentSummary(agent: Agent) { return { @@ -611,9 +568,7 @@ async function agentStatus( runtime: CliRuntime, resolved: Awaited<ReturnType<typeof resolveConfig>>, ): Promise<CommandResult> { - if (args.positionals.length > 2) { - throw usageError("agent status does not accept positional arguments."); - } + if (resolved.agentConnection) { const connection = resolved.agentConnection; const data = { @@ -700,7 +655,7 @@ async function agentEnroll( runtime: CliRuntime, resolved: Awaited<ReturnType<typeof resolveConfig>>, ): Promise<CommandResult> { - if (args.positionals.length !== 2) throw usageError("agent enroll does not accept positional arguments."); + requireFlagValue(args, "name", "Office MacBook Codex"); const name = flagString(args.flags, "name"); if (name && name.length > 80) throw usageError("agent enroll --name is at most 80 characters."); @@ -1023,7 +978,7 @@ async function agentConnect( runtime: CliRuntime, resolved: Awaited<ReturnType<typeof resolveConfig>>, ): Promise<CommandResult> { - if (args.positionals.length !== 2) throw usageError("agent connect does not accept positional arguments."); + requireFlagValue(args, "name", "Office MacBook Codex"); requireFlagValue(args, "timeout", "86400000"); const name = flagString(args.flags, "name"); @@ -1187,7 +1142,7 @@ async function agentDisconnect( resolved: Awaited<ReturnType<typeof resolveConfig>>, ): Promise<CommandResult> { const invokedName = "agent disconnect"; - if (args.positionals.length !== 2) throw usageError(`${invokedName} does not accept positional arguments.`); + if (!flagBool(args.flags, "yes")) { throw usageError(`${invokedName} requires --yes. It revokes only this agent and preserves the account, screens, content, and other agents.`, { command: "screenrig agent disconnect --yes", @@ -1282,25 +1237,6 @@ async function agentDisconnect( }; } -function isAuthenticatedCommand(group: string, action: string | undefined): boolean { - const actions: Record<string, ReadonlySet<string | undefined>> = { - account: new Set(["show"]), - dashboard: new Set([undefined]), - app: new Set(["upload", "list", "show"]), - media: new Set(["upload", "show", "download", "list", "delete", "update"]), - playlist: new Set(["create", "update", "export", "import", "show", "get", "list", "delete"]), - screen: new Set(["pair", "provision", "update", "list", "show", "assign", "set-timezone", "archive", "unarchive", "delete", "rotate-public-id", "toast", "screenshot"]), - browser: new Set(["setup"]), - kv: new Set(["get", "set", "delete", "list"]), - comment: new Set(["show", "set", "delete"]), - operations: new Set(["get", "wait", "cancel"]), - events: new Set(["list", "follow"]), - playback: new Set(["list"]), - feedback: new Set(["bug", "feature", "list"]), - }; - return actions[group]?.has(action) ?? false; -} - async function browserSetupCommand( args: ParsedArgs, runtime: CliRuntime, @@ -1382,12 +1318,7 @@ async function dashboardCommand( runtime: CliRuntime, resolved: Awaited<ReturnType<typeof resolveConfig>>, ): Promise<CommandResult> { - if (args.positionals.length > 1) { - throw usageError("dashboard does not accept positional arguments.", { - command: "screenrig dashboard", - reason: "Mint one single-use dashboard link for the enrolled account and open it.", - }); - } + const printMode = flagBool(args.flags, "print-url"); const token = requireToken(resolved.token); const client = clientFor(runtime, args, resolved.apiUrl, token); @@ -1612,13 +1543,12 @@ async function appPack(args: ParsedArgs, runtime: CliRuntime): Promise<CommandRe }; } -async function appUpload(args: ParsedArgs, runtime: CliRuntime, resolved: Awaited<ReturnType<typeof resolveConfig>>): Promise<CommandResult> { - const update = args.positionals[1] === "update"; +async function appUpload(args: ParsedArgs, runtime: CliRuntime, resolved: Awaited<ReturnType<typeof resolveConfig>>, update: boolean): Promise<CommandResult> { const id = update ? args.positionals[2] : undefined; const dir = args.positionals[update ? 3 : 2]; requireFlagValue(args, "if-match", "1"); const revision = flagString(args.flags, "if-match"); - if (!dir || args.positionals.length !== (update ? 4 : 3) || (update && (!id || !revision))) { + if (!dir || (update && (!id || !revision))) { throw usageError(update ? "app update requires <id> <directory> --if-match REVISION." : "app upload requires one directory."); } if (update && args.flags.name !== undefined) throw usageError("app update preserves the application name; omit --name."); @@ -1720,7 +1650,6 @@ function mediaTagFromArgs(args: ParsedArgs): string | undefined { return tag; } - function mediaPrimitiveFromArgs(args: ParsedArgs): "image" | "video" | undefined { requireFlagValue(args, "primitive", "image"); const primitive = flagString(args.flags, "primitive"); @@ -1745,63 +1674,80 @@ function screenListStateFromArgs(args: ParsedArgs): "archived" | undefined { return state; } -async function mediaCommand( - args: ParsedArgs, - runtime: CliRuntime, - resolved: Awaited<ReturnType<typeof resolveConfig>>, - action: string | undefined, -): Promise<CommandResult> { - if (action === "list") { - if (Object.hasOwn(args.flags, "kind")) { - throw usageError("media list uses --primitive image|video, not --kind."); - } - return simpleGet(args, runtime, resolved, "/api/v1/media", "Media", { - tag: mediaTagFromArgs(args), - primitive: mediaPrimitiveFromArgs(args), - }); +export const handleMediaList = commandHandler(async (args, runtime, resolved) => { + + if (Object.hasOwn(args.flags, "kind")) { + throw usageError("media list uses --primitive image|video, not --kind."); } + return simpleGet(args, runtime, resolved, "/api/v1/media", "Media", { + tag: mediaTagFromArgs(args), + primitive: mediaPrimitiveFromArgs(args), + }); +}, true); + +export const handleMediaShow = commandHandler(async (args, runtime, resolved) => { + + const id = args.positionals[2]; + if (!id) throw usageError("media show requires an id."); + return simpleGet(args, runtime, resolved, `/api/v1/media/${id}`, "Media"); +}, true); + +export const handleMediaUpdate = commandHandler(async (args, runtime, resolved) => { const token = requireToken(resolved.token); const client = clientFor(runtime, args, resolved.apiUrl, token); - if (action === "show") { - const id = args.positionals[2]; - if (!id) throw usageError("media show requires an id."); - return simpleGet(args, runtime, resolved, `/api/v1/media/${id}`, "Media"); - } - if (action === "update") { - return mediaUpdate(args, client); - } - if (action === "download") { - return loggerOf(runtime).withLocal({ op: "media.download", message: "media download" }, () => - mediaDownload(args, runtime, client), - ); - } - if (action === "delete") { - const id = args.positionals[2]; - const revision = flagString(args.flags, "if-match"); - if (!id || !revision) throw usageError("media delete requires <id> and --if-match."); - const response = await client.call({ - method: "DELETE", - path: `/api/v1/media/${id}`, - idempotent: true, - headers: { "if-match": quotedRevision(revision) }, - }); - return { envelope: jsonBody(response, client.requestId), exitCode: ExitCode.Success, human: `Deleted media ${id}` }; - } - if (action === "generate") { - return loggerOf(runtime).withLocal({ op: "media.generate", message: "media generate" }, () => - mediaGenerate(args, runtime, client, resolved), - ); - } - if (action === "upload") { - return loggerOf(runtime).withLocal({ op: "media.upload", message: "media upload" }, () => - mediaUpload(args, runtime, client), - ); - } - if (action === "upload-batch") { - return mediaUploadBatch(args, runtime, client, resolved); - } - throw usageError("Unknown media command."); -} + + return mediaUpdate(args, client); +}, true); + +export const handleMediaDownload = commandHandler(async (args, runtime, resolved) => { + const token = requireToken(resolved.token); + const client = clientFor(runtime, args, resolved.apiUrl, token); + + return loggerOf(runtime).withLocal({ op: "media.download", message: "media download" }, () => + mediaDownload(args, runtime, client), + ); +}, true); + +export const handleMediaDelete = commandHandler(async (args, runtime, resolved) => { + const token = requireToken(resolved.token); + const client = clientFor(runtime, args, resolved.apiUrl, token); + + const id = args.positionals[2]; + const revision = flagString(args.flags, "if-match"); + if (!id || !revision) throw usageError("media delete requires <id> and --if-match."); + const response = await client.call({ + method: "DELETE", + path: `/api/v1/media/${id}`, + idempotent: true, + headers: { "if-match": quotedRevision(revision) }, + }); + return { envelope: jsonBody(response, client.requestId), exitCode: ExitCode.Success, human: `Deleted media ${id}` }; +}, true); + +export const handleMediaGenerate = commandHandler(async (args, runtime, resolved) => { + const token = requireToken(resolved.token); + const client = clientFor(runtime, args, resolved.apiUrl, token); + + return loggerOf(runtime).withLocal({ op: "media.generate", message: "media generate" }, () => + mediaGenerate(args, runtime, client, resolved), + ); +}, true); + +export const handleMediaUpload = commandHandler(async (args, runtime, resolved) => { + const token = requireToken(resolved.token); + const client = clientFor(runtime, args, resolved.apiUrl, token); + + return loggerOf(runtime).withLocal({ op: "media.upload", message: "media upload" }, () => + mediaUpload(args, runtime, client), + ); +}, true); + +export const handleMediaUploadBatch = commandHandler(async (args, runtime, resolved) => { + const token = requireToken(resolved.token); + const client = clientFor(runtime, args, resolved.apiUrl, token); + + return mediaUploadBatch(args, runtime, client, resolved); +}, true); const GENERATE_ASPECT_RATIOS = ["1:1", "16:9", "9:16", "4:3", "3:4", "3:2", "2:3"] as const; const GENERATE_QUALITIES = ["low", "medium", "high"] as const; @@ -2065,9 +2011,7 @@ async function mediaDownload(args: ParsedArgs, runtime: CliRuntime, client: ApiC if (!id || !MEDIA_ID_PATTERN.test(id)) { throw usageError("media download requires <id> starting with med_."); } - if (args.positionals.length !== 3) { - throw usageError("media download takes exactly one media id."); - } + const metadataResponse = await client.call({ method: "GET", path: `/api/v1/media/${id}` }); const media = mediaRecordFromBody(metadataResponse.body, id); const extension = MEDIA_CONTENT_EXTENSIONS[media.content_type.toLowerCase()]; @@ -2301,7 +2245,7 @@ async function mediaUploadBatch( resolved: Awaited<ReturnType<typeof resolveConfig>>, ): Promise<CommandResult> { const manifest = args.positionals[2]; - if (!manifest || args.positionals.length !== 3) { + if (!manifest) { throw usageError("media upload-batch requires one manifest JSON file."); } requireFlagValue(args, "state", "./upload-state.json"); @@ -2463,25 +2407,21 @@ async function readFeedbackBody(args: ParsedArgs, runtime: CliRuntime): Promise< } } -async function feedbackCommand( - args: ParsedArgs, - runtime: CliRuntime, - resolved: Awaited<ReturnType<typeof resolveConfig>>, - action: string | undefined, -): Promise<CommandResult> { +export const handleFeedbackList = commandHandler(async (args, runtime, resolved) => { + const client = clientFor(runtime, args, resolved.apiUrl, requireToken(resolved.token)); + return feedbackList(args, client); +}); + +export const handleFeedbackBug = commandHandler((args, runtime, resolved) => submitFeedback(args, runtime, resolved, "bug")); +export const handleFeedbackFeature = commandHandler((args, runtime, resolved) => submitFeedback(args, runtime, resolved, "feature")); + +async function submitFeedback(args: ParsedArgs, runtime: CliRuntime, resolved: ResolvedCommandConfig, kind: FeedbackKind): Promise<CommandResult> { const token = requireToken(resolved.token); const client = clientFor(runtime, args, resolved.apiUrl, token); - if (action === "list") { - return feedbackList(args, client); - } - if (action !== "bug" && action !== "feature") { - throw usageError("Unknown feedback command; use feedback bug, feedback feature, or feedback list."); - } - const title = args.positionals[2]?.trim(); if (!title) { - throw usageError(`feedback ${action} requires a title.`); + throw usageError(`feedback ${kind} requires a title.`); } if (title.length > FEEDBACK_TITLE_MAX) { throw usageError(`A feedback title is at most ${FEEDBACK_TITLE_MAX} characters.`); @@ -2502,7 +2442,7 @@ async function feedbackCommand( // retry safe rather than duplicating a report. const response = await client.call({ method: "POST", - path: FEEDBACK_PATHS[action], + path: FEEDBACK_PATHS[kind], idempotent: true, body: payload, }); @@ -2510,7 +2450,7 @@ async function feedbackCommand( return { envelope: jsonBody(response, client.requestId), exitCode: ExitCode.Success, - human: humanLines(action === "bug" ? "Bug report submitted" : "Feature request submitted", [ + human: humanLines(kind === "bug" ? "Bug report submitted" : "Feature request submitted", [ ["id", submission?.id], ["kind", submission?.kind], ["title", submission?.title], @@ -2561,7 +2501,7 @@ async function playlistPreviewCommand( requireFlagValue(args, "output", "./preview"); requireFlagValue(args, "frame-ms", "1000"); const output = flagString(args.flags, "output"); - if (!target || !output || args.positionals.length !== 3) { + if (!target || !output) { throw usageError("playlist preview requires <file|id> and --output DIR."); } if (target.includes("\0") || output.includes("\0")) { @@ -2617,124 +2557,144 @@ async function playlistPreviewCommand( }; } -async function playlistCommand( - args: ParsedArgs, - runtime: CliRuntime, - resolved: Awaited<ReturnType<typeof resolveConfig>>, - action: string | undefined, -): Promise<CommandResult> { +export const handlePlaylistList = commandHandler(async (args, runtime, resolved) => { + + return simpleGet(args, runtime, resolved, "/api/v1/playlists", "Playlists"); +}, true); + +export const handlePlaylistShow = commandHandler(async (args, runtime, resolved) => { + + const id = args.positionals[2]; + if (!id) throw usageError("playlist get requires an id."); + return simpleGet(args, runtime, resolved, `/api/v1/playlists/${id}`, "Playlist"); +}, true); + +export const handlePlaylistExport = commandHandler(async (args, runtime, resolved) => { const token = requireToken(resolved.token); const client = clientFor(runtime, args, resolved.apiUrl, token); - if (action === "list") return simpleGet(args, runtime, resolved, "/api/v1/playlists", "Playlists"); - if (action === "get" || action === "show") { - const id = args.positionals[2]; - if (!id) throw usageError("playlist get requires an id."); - return simpleGet(args, runtime, resolved, `/api/v1/playlists/${id}`, "Playlist"); - } - if (action === "export") { - requireFlagValue(args, "output", "./playlist-bundle"); - const id = args.positionals[2]; - const output = flagString(args.flags, "output"); - if (!id || !output || args.positionals.length !== 3) { - throw usageError("playlist export requires <id> --output <directory>."); - } - const result = await exportPlaylistBundle({ playlistId: id, outputDirectory: path.resolve(runtime.cwd(), output), client }); - return { - envelope: successEnvelope(result, { request_id: client.requestId }), - exitCode: ExitCode.Success, - human: humanLines("Playlist exported", [ - ["playlist_id", result.playlist_id], - ["directory", result.directory], - ["media_count", String(result.media_count)], - ["media_bytes", String(result.media_bytes)], - ]), - }; - } - if (action === "import") { - requireFlagValue(args, "update", "pl_01"); - requireFlagValue(args, "if-match", "1"); - requireFlagValue(args, "name", "Lobby loop (copy)"); - const directory = args.positionals[2]; - if (!directory || args.positionals.length !== 3) throw usageError("playlist import requires one <directory>."); - const updateId = flagString(args.flags, "update"); - const ifMatch = flagString(args.flags, "if-match"); - const name = flagString(args.flags, "name"); - const result = await importPlaylistBundle({ - directory: path.resolve(runtime.cwd(), directory), - client, - runtime, - updateId, - ifMatch, - name, - timeoutMs: flagNumber(args.flags, "timeout"), - pollMs: flagNumber(args.flags, "poll-ms"), - beforePlaylistWrite: async (playlist, targetId) => { - if (targetId) await assertAssignedScreensHaveZone(client, targetId, playlist.pages); - }, - }); - return { - envelope: successEnvelope(result, { request_id: client.requestId }), - exitCode: ExitCode.Success, - human: humanLines(`Playlist ${result.mode === "create" ? "imported" : "updated from bundle"}`, [ - ["source_playlist_id", result.source_playlist_id], - ["directory", result.directory], - ["media_reused", String(result.media.reused)], - ["media_uploaded", String(result.media.uploaded)], - ]), - }; + + requireFlagValue(args, "output", "./playlist-bundle"); + const id = args.positionals[2]; + const output = flagString(args.flags, "output"); + if (!id || !output) { + throw usageError("playlist export requires <id> --output <directory>."); } - if (action === "create" || action === "update") { - const id = action === "update" ? args.positionals[2] : undefined; - const file = action === "update" ? args.positionals[3] : args.positionals[2]; - const ifMatch = flagString(args.flags, "if-match"); - if (!file || (action === "update" && (!id || !ifMatch))) { - throw usageError(`playlist ${action} requires ${action === "update" ? "<id> <file> --if-match" : "<file>"}.`); - } - let parsed: Record<string, unknown>; - try { - parsed = JSON.parse(await readFile(path.resolve(runtime.cwd(), file), "utf8")) as Record<string, unknown>; - } catch (err) { - throw usageError(`Cannot read playlist JSON: ${err instanceof Error ? err.message : "invalid JSON"}`); - } - if (typeof parsed.name !== "string" || !Array.isArray(parsed.pages)) { - throw usageError("Playlist JSON must contain string name and array pages."); - } - const extra = Object.keys(parsed).filter((key) => key !== "name" && key !== "pages"); - if (extra.length > 0) { - throw usageError(`Playlist JSON contains unsupported fields: ${extra.join(", ")}.`); - } - const pages = expandPlaylistPages(parsed.pages); - const body = { name: parsed.name, pages }; - assertPlaylistValid(body); - // A create has no assigned screen yet, so there is nothing to check. An - // update can add a schedule to a playlist screens are already running. - if (action === "update" && id) { - await assertAssignedScreensHaveZone(client, id, pages); - } - const response = await client.call({ - method: action === "create" ? "POST" : "PUT", - path: action === "create" ? "/api/v1/playlists" : `/api/v1/playlists/${id}`, - idempotent: true, - headers: ifMatch ? { "if-match": quotedRevision(ifMatch) } : undefined, - body, - }); - return { envelope: jsonBody(response, client.requestId), exitCode: ExitCode.Success, human: `Playlist ${action}d.` }; - } - if (action === "delete") { - const id = args.positionals[2]; - const ifMatch = flagString(args.flags, "if-match"); - if (!id || !ifMatch) throw usageError("playlist delete requires <id> and --if-match."); - const response = await client.call({ - method: "DELETE", - path: `/api/v1/playlists/${id}`, - idempotent: true, - headers: { "if-match": quotedRevision(ifMatch) }, - }); - return { envelope: jsonBody(response, client.requestId), exitCode: ExitCode.Success, human: `Deleted playlist ${id}` }; + const result = await exportPlaylistBundle({ playlistId: id, outputDirectory: path.resolve(runtime.cwd(), output), client }); + return { + envelope: successEnvelope(result, { request_id: client.requestId }), + exitCode: ExitCode.Success, + human: humanLines("Playlist exported", [ + ["playlist_id", result.playlist_id], + ["directory", result.directory], + ["media_count", String(result.media_count)], + ["media_bytes", String(result.media_bytes)], + ]), + }; +}, true); + +export const handlePlaylistImport = commandHandler(async (args, runtime, resolved) => { + const token = requireToken(resolved.token); + const client = clientFor(runtime, args, resolved.apiUrl, token); + + requireFlagValue(args, "update", "pl_01"); + requireFlagValue(args, "if-match", "1"); + requireFlagValue(args, "name", "Lobby loop (copy)"); + const directory = args.positionals[2]; + if (!directory) throw usageError("playlist import requires one <directory>."); + const updateId = flagString(args.flags, "update"); + const ifMatch = flagString(args.flags, "if-match"); + const name = flagString(args.flags, "name"); + const result = await importPlaylistBundle({ + directory: path.resolve(runtime.cwd(), directory), + client, + runtime, + updateId, + ifMatch, + name, + timeoutMs: flagNumber(args.flags, "timeout"), + pollMs: flagNumber(args.flags, "poll-ms"), + beforePlaylistWrite: async (playlist, targetId) => { + if (targetId) await assertAssignedScreensHaveZone(client, targetId, playlist.pages); + }, + }); + return { + envelope: successEnvelope(result, { request_id: client.requestId }), + exitCode: ExitCode.Success, + human: humanLines(`Playlist ${result.mode === "create" ? "imported" : "updated from bundle"}`, [ + ["source_playlist_id", result.source_playlist_id], + ["directory", result.directory], + ["media_reused", String(result.media.reused)], + ["media_uploaded", String(result.media.uploaded)], + ]), + }; +}, true); + +async function playlistCreateUpdateAction(args: ParsedArgs, runtime: CliRuntime, resolved: ResolvedCommandConfig, action: "create" | "update"): Promise<CommandResult> { + const token = requireToken(resolved.token); + const client = clientFor(runtime, args, resolved.apiUrl, token); + + const id = action === "update" ? args.positionals[2] : undefined; + const file = action === "update" ? args.positionals[3] : args.positionals[2]; + const ifMatch = flagString(args.flags, "if-match"); + if (!file || (action === "update" && (!id || !ifMatch))) { + throw usageError(`playlist ${action} requires ${action === "update" ? "<id> <file> --if-match" : "<file>"}.`); + } + let parsed: Record<string, unknown>; + try { + parsed = JSON.parse(await readFile(path.resolve(runtime.cwd(), file), "utf8")) as Record<string, unknown>; + } catch (err) { + throw usageError(`Cannot read playlist JSON: ${err instanceof Error ? err.message : "invalid JSON"}`); } - throw usageError("Unknown playlist command."); + if (typeof parsed.name !== "string" || !Array.isArray(parsed.pages)) { + throw usageError("Playlist JSON must contain string name and array pages."); + } + const extra = Object.keys(parsed).filter((key) => key !== "name" && key !== "pages"); + if (extra.length > 0) { + throw usageError(`Playlist JSON contains unsupported fields: ${extra.join(", ")}.`); + } + const pages = expandPlaylistPages(parsed.pages); + const body = { name: parsed.name, pages }; + assertPlaylistValid(body); + // A create has no assigned screen yet, so there is nothing to check. An + // update can add a schedule to a playlist screens are already running. + if (action === "update" && id) { + await assertAssignedScreensHaveZone(client, id, pages); + } + const response = await client.call({ + method: action === "create" ? "POST" : "PUT", + path: action === "create" ? "/api/v1/playlists" : `/api/v1/playlists/${id}`, + idempotent: true, + headers: ifMatch ? { "if-match": quotedRevision(ifMatch) } : undefined, + body, + }); + return { envelope: jsonBody(response, client.requestId), exitCode: ExitCode.Success, human: `Playlist ${action}d.` }; + } +export const handlePlaylistCreate = commandHandler(async (args, runtime, resolved) => { + return playlistCreateUpdateAction(args, runtime, resolved, "create"); +}, true); + +export const handlePlaylistUpdate = commandHandler(async (args, runtime, resolved) => { + return playlistCreateUpdateAction(args, runtime, resolved, "update"); +}, true); + +export const handlePlaylistDelete = commandHandler(async (args, runtime, resolved) => { + const token = requireToken(resolved.token); + const client = clientFor(runtime, args, resolved.apiUrl, token); + + const id = args.positionals[2]; + const ifMatch = flagString(args.flags, "if-match"); + if (!id || !ifMatch) throw usageError("playlist delete requires <id> and --if-match."); + const response = await client.call({ + method: "DELETE", + path: `/api/v1/playlists/${id}`, + idempotent: true, + headers: { "if-match": quotedRevision(ifMatch) }, + }); + return { envelope: jsonBody(response, client.requestId), exitCode: ExitCode.Success, human: `Deleted playlist ${id}` }; +}, true); + /** * A page schedule is civil, so it means nothing without a zone to read it in. * The server carries that zone on the screen and refuses assignment, playlist @@ -2800,251 +2760,287 @@ async function assertAssignedScreensHaveZone(client: ApiClient, playlistId: stri } } -async function screenCommand( - args: ParsedArgs, - runtime: CliRuntime, - resolved: Awaited<ReturnType<typeof resolveConfig>>, - action: string | undefined, -): Promise<CommandResult> { - if (action === "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. There is no account unbind.", - }); - } +export const handleScreenList = commandHandler(async (args, runtime, resolved) => { + + return simpleGet(args, runtime, resolved, "/api/v1/screens", "Screens", { + state: screenListStateFromArgs(args), + }); +}, true); + +export const handleScreenProvision = commandHandler(async (args, runtime, resolved) => { const token = requireToken(resolved.token); const client = clientFor(runtime, args, resolved.apiUrl, token); - if (action === "list") { - return simpleGet(args, runtime, resolved, "/api/v1/screens", "Screens", { - state: screenListStateFromArgs(args), - }); - } - if (action === "provision") { - const openMode = flagBool(args.flags, "open"); - const printMode = flagBool(args.flags, "print-url"); - if (openMode === printMode) { - throw usageError("screen provision requires exactly one of --open or --print-url."); - } - const label = flagString(args.flags, "label"); - const enrollmentRuntime = { - fs: { ...runtime.fs, env: runtime.env, homedir: runtime.homedir }, - now: runtime.now, - sleep: runtime.sleep, - }; - const retry = await provisionRetryState({ - resolved, - runtime: enrollmentRuntime, - ...(label ? { label } : {}), - ...(flagString(args.flags, "idempotency-key") ? { requestedKey: flagString(args.flags, "idempotency-key") } : {}), - }); - const request: ProvisionScreen = { ...(label ? { label } : {}) }; - let response; - try { - response = await client.call({ - method: "POST", - path: "/api/v1/screens/provision", - idempotent: true, - idempotencyKey: retry.idempotency_key, - body: request, - }); - } catch (error) { - if (error instanceof CliError && error.problem.code === "provisioning_expired") { - await clearProvisionRetryState(resolved, enrollmentRuntime, retry.idempotency_key); - } - throw error; - } - requirePrivateNoStore(response.headers, "Browser provisioning response"); - const provisioned = response.body as ScreenProvisioning; - if (!provisioned.screen?.id || !provisioned.screen.public_id || !provisioned.expires_at || Number.isNaN(Date.parse(provisioned.expires_at))) { - throw usageError("Browser provisioning response does not match the generated ScreenProvisioning contract."); - } - const urls = validateProvisioningUrls(provisioned); - const opened = openMode ? await (runtime.openUrl?.(urls.provisioningUrl) ?? Promise.resolve(false)) : false; - if (printMode || opened) await clearProvisionRetryState(resolved, enrollmentRuntime, retry.idempotency_key); - const data = { - screen_id: provisioned.screen.id, - public_url: urls.publicUrl, - expires_at: provisioned.expires_at, - ...(openMode ? { opened } : { provisioning_url: urls.provisioningUrl }), - }; - return { - envelope: successEnvelope(data, { request_id: client.requestId }), - exitCode: ExitCode.Success, - human: humanLines(openMode ? "Browser provisioning" : "Sensitive one-time browser provisioning URL", [ - ["screen_id", provisioned.screen.id], - ["public_url", urls.publicUrl], - ["expires_at", provisioned.expires_at], - ...(openMode ? [["opened", opened ? "true" : "false"] as [string, string]] : [["provisioning_url", urls.provisioningUrl] as [string, string]]), - ]), - }; + + const openMode = flagBool(args.flags, "open"); + const printMode = flagBool(args.flags, "print-url"); + if (openMode === printMode) { + throw usageError("screen provision requires exactly one of --open or --print-url."); } - if (action === "pair") { - const rawCode = args.positionals[2]; - if (!rawCode) throw usageError("screen pair requires CODE."); - const code = rawCode.toUpperCase(); - if (!/^[23456789ABCDEFGHJKMNPQRSTUVWXYZ]{6}$/.test(code)) { - throw usageError("screen pair CODE must be six characters from 23456789ABCDEFGHJKMNPQRSTUVWXYZ."); - } - const label = flagString(args.flags, "label"); - const request: PairScreen = { code, ...(label ? { label } : {}) }; - const response = await client.call({ + const label = flagString(args.flags, "label"); + const enrollmentRuntime = { + fs: { ...runtime.fs, env: runtime.env, homedir: runtime.homedir }, + now: runtime.now, + sleep: runtime.sleep, + }; + const retry = await provisionRetryState({ + resolved, + runtime: enrollmentRuntime, + ...(label ? { label } : {}), + ...(flagString(args.flags, "idempotency-key") ? { requestedKey: flagString(args.flags, "idempotency-key") } : {}), + }); + const request: ProvisionScreen = { ...(label ? { label } : {}) }; + let response; + try { + response = await client.call({ method: "POST", - path: "/api/v1/screens/pair", + path: "/api/v1/screens/provision", idempotent: true, + idempotencyKey: retry.idempotency_key, body: request, }); - requirePrivateNoStore(response.headers, "Screen pairing response"); - const claim = response.body as PairingClaim; - if (!claim.screen?.id || !claim.screen.label || !claim.public_url) { - throw usageError("Screen pairing response does not match the generated PairingClaim contract."); - } - return { - envelope: jsonBody(response, client.requestId), - exitCode: ExitCode.Success, - human: humanLines("Screen paired", [ - ["code", code], - ["screen_id", claim.screen.id], - ["label", claim.screen.label], - ["state", claim.screen.state], - ]), - }; - } - if (action === "show") { - const id = args.positionals[2]; - if (!id) throw usageError("screen show requires an id."); - return simpleGet(args, runtime, resolved, `/api/v1/screens/${id}`, "Screen"); - } - if (action === "update") { - const id = args.positionals[2]; - const ifMatch = flagString(args.flags, "if-match"); - const name = flagString(args.flags, "name"); - const playlistId = flagString(args.flags, "playlist-id"); - const timezone = flagString(args.flags, "timezone"); - if (!id || !ifMatch || (!name && !playlistId && !timezone)) { - throw usageError("screen update requires <id>, --if-match, and --name, --playlist-id, or --timezone."); - } - // A patch that sets both a playlist and a timezone satisfies the schedule - // rule in one request, so only check when the patch leaves the screen - // without one. - let playlist: unknown; - if (playlistId && !timezone) { - playlist = await assertScheduledPlaylistHasZone(client, id, playlistId); - } else if (playlistId) { - // This read exists only for advisory aspect warnings. A missing warning - // must not block a patch that supplies the required timezone itself. - try { - playlist = (await client.call({ method: "GET", path: `/api/v1/playlists/${playlistId}` })).body; - } catch { - playlist = undefined; - } + } catch (error) { + if (error instanceof CliError && error.problem.code === "provisioning_expired") { + await clearProvisionRetryState(resolved, enrollmentRuntime, retry.idempotency_key); } - const body: ScreenPatch = { - ...(name ? { name } : {}), - ...(playlistId ? { playlist_id: playlistId } : {}), - ...(timezone ? { timezone } : {}), - }; - const response = await client.call({ method: "PATCH", path: `/api/v1/screens/${id}`, idempotent: true, headers: { "if-match": quotedRevision(ifMatch) }, body }); - const warnings = playlistId ? aspectMismatchWarnings(id, response.body, playlist) : []; - return { - envelope: jsonBody(response, client.requestId, undefined, warnings), - exitCode: ExitCode.Success, - human: [`Updated screen ${id}`, ...warnings.map((warning) => `warning: ${warning.message}`)].join("\n"), - }; - } - if (action === "assign") { - const id = args.positionals[2]; - const playlistId = flagString(args.flags, "playlist-id"); - const ifMatch = flagString(args.flags, "if-match"); - if (!id || !playlistId || !ifMatch) throw usageError("screen assign requires <id> --playlist-id --if-match."); - const playlist = await assertScheduledPlaylistHasZone(client, id, playlistId); - const body: ScreenPatch = { playlist_id: playlistId }; - const response = await client.call({ - method: "PATCH", - path: `/api/v1/screens/${id}`, - idempotent: true, - headers: { "if-match": quotedRevision(ifMatch) }, - body, - }); - const warnings = aspectMismatchWarnings(id, response.body, playlist); - return { - envelope: jsonBody(response, client.requestId, undefined, warnings), - exitCode: ExitCode.Success, - human: [`Assigned playlist ${playlistId} to ${id}`, ...warnings.map((warning) => `warning: ${warning.message}`)].join("\n"), - }; + throw error; } - if (action === "set-timezone") { - const id = args.positionals[2]; - const timezone = flagString(args.flags, "timezone"); - const ifMatch = flagString(args.flags, "if-match"); - if (!id || !timezone || !ifMatch) throw usageError("screen set-timezone requires <id> --timezone --if-match."); - // The zone database belongs to the server, which validates the identifier - // against it. Sending the value unchanged keeps one authority for what a - // real zone is, so the CLI never carries a list that can go stale. - const body: ScreenPatch = { timezone }; - const response = await client.call({ - method: "PATCH", - path: `/api/v1/screens/${id}`, - idempotent: true, - headers: { "if-match": quotedRevision(ifMatch) }, - body, - }); - return { envelope: jsonBody(response, client.requestId), exitCode: ExitCode.Success, human: `Set timezone ${timezone} on ${id}` }; + requirePrivateNoStore(response.headers, "Browser provisioning response"); + const provisioned = response.body as ScreenProvisioning; + if (!provisioned.screen?.id || !provisioned.screen.public_id || !provisioned.expires_at || Number.isNaN(Date.parse(provisioned.expires_at))) { + throw usageError("Browser provisioning response does not match the generated ScreenProvisioning contract."); } - if (action === "archive" || action === "unarchive") { - const id = args.positionals[2]; - const revision = flagString(args.flags, "if-match"); - if (!id || !revision) throw usageError(`screen ${action} requires <id> and --if-match.`); - const response = await client.call({ - method: "POST", - path: `/api/v1/screens/${id}/${action}`, - idempotent: true, - headers: { "if-match": quotedRevision(revision) }, - }); - return { - envelope: jsonBody(response, client.requestId), - exitCode: ExitCode.Success, - human: action === "archive" ? `Archived screen ${id}` : `Unarchived screen ${id}`, - }; - } - if (action === "delete") { - const id = args.positionals[2]; - const ifMatch = flagString(args.flags, "if-match"); - if (!id || !ifMatch) throw usageError("screen delete requires <id> and --if-match."); - const response = await client.call({ - method: "DELETE", - path: `/api/v1/screens/${id}`, - idempotent: true, - headers: { "if-match": quotedRevision(ifMatch) }, - }); - return { envelope: jsonBody(response, client.requestId), exitCode: ExitCode.Success, human: `Deleted screen ${id}` }; - } - if (action === "rotate-public-id") { - const id = args.positionals[2]; - const revision = flagString(args.flags, "if-match"); - if (!id || !revision) throw usageError("screen rotate-public-id requires <id> and --if-match."); - const response = await client.call({ - method: "POST", - path: `/api/v1/screens/${id}/public-id/rotate`, - idempotent: true, - headers: { "if-match": quotedRevision(revision) }, - }); - return { - envelope: jsonBody(response, client.requestId), - exitCode: ExitCode.Success, - human: `Rotated public id for ${id}`, - }; + const urls = validateProvisioningUrls(provisioned); + const opened = openMode ? await (runtime.openUrl?.(urls.provisioningUrl) ?? Promise.resolve(false)) : false; + if (printMode || opened) await clearProvisionRetryState(resolved, enrollmentRuntime, retry.idempotency_key); + const data = { + screen_id: provisioned.screen.id, + public_url: urls.publicUrl, + expires_at: provisioned.expires_at, + ...(openMode ? { opened } : { provisioning_url: urls.provisioningUrl }), + }; + return { + envelope: successEnvelope(data, { request_id: client.requestId }), + exitCode: ExitCode.Success, + human: humanLines(openMode ? "Browser provisioning" : "Sensitive one-time browser provisioning URL", [ + ["screen_id", provisioned.screen.id], + ["public_url", urls.publicUrl], + ["expires_at", provisioned.expires_at], + ...(openMode ? [["opened", opened ? "true" : "false"] as [string, string]] : [["provisioning_url", urls.provisioningUrl] as [string, string]]), + ]), + }; +}, true); + +export const handleScreenPair = commandHandler(async (args, runtime, resolved) => { + const token = requireToken(resolved.token); + const client = clientFor(runtime, args, resolved.apiUrl, token); + + const rawCode = args.positionals[2]; + if (!rawCode) throw usageError("screen pair requires CODE."); + const code = rawCode.toUpperCase(); + if (!/^[23456789ABCDEFGHJKMNPQRSTUVWXYZ]{6}$/.test(code)) { + throw usageError("screen pair CODE must be six characters from 23456789ABCDEFGHJKMNPQRSTUVWXYZ."); } - if (action === "toast") { - return screenToast(args, client); + const label = flagString(args.flags, "label"); + const request: PairScreen = { code, ...(label ? { label } : {}) }; + const response = await client.call({ + method: "POST", + path: "/api/v1/screens/pair", + idempotent: true, + body: request, + }); + requirePrivateNoStore(response.headers, "Screen pairing response"); + const claim = response.body as PairingClaim; + if (!claim.screen?.id || !claim.screen.label || !claim.public_url) { + throw usageError("Screen pairing response does not match the generated PairingClaim contract."); } - if (action === "screenshot") { - return loggerOf(runtime).withLocal({ op: "screenshot.capture", message: "screen screenshot" }, () => - screenScreenshot(args, runtime, client), - ); + return { + envelope: jsonBody(response, client.requestId), + exitCode: ExitCode.Success, + human: humanLines("Screen paired", [ + ["code", code], + ["screen_id", claim.screen.id], + ["label", claim.screen.label], + ["state", claim.screen.state], + ]), + }; +}, true); + +export const handleScreenShow = commandHandler(async (args, runtime, resolved) => { + + const id = args.positionals[2]; + if (!id) throw usageError("screen show requires an id."); + return simpleGet(args, runtime, resolved, `/api/v1/screens/${id}`, "Screen"); +}, true); + +export const handleScreenUpdate = commandHandler(async (args, runtime, resolved) => { + const token = requireToken(resolved.token); + const client = clientFor(runtime, args, resolved.apiUrl, token); + + const id = args.positionals[2]; + const ifMatch = flagString(args.flags, "if-match"); + const name = flagString(args.flags, "name"); + const playlistId = flagString(args.flags, "playlist-id"); + const timezone = flagString(args.flags, "timezone"); + if (!id || !ifMatch || (!name && !playlistId && !timezone)) { + throw usageError("screen update requires <id>, --if-match, and --name, --playlist-id, or --timezone."); + } + // A patch that sets both a playlist and a timezone satisfies the schedule + // rule in one request, so only check when the patch leaves the screen + // without one. + let playlist: unknown; + if (playlistId && !timezone) { + playlist = await assertScheduledPlaylistHasZone(client, id, playlistId); + } else if (playlistId) { + // This read exists only for advisory aspect warnings. A missing warning + // must not block a patch that supplies the required timezone itself. + try { + playlist = (await client.call({ method: "GET", path: `/api/v1/playlists/${playlistId}` })).body; + } catch { + playlist = undefined; + } } - throw usageError("Unknown screen command."); + const body: ScreenPatch = { + ...(name ? { name } : {}), + ...(playlistId ? { playlist_id: playlistId } : {}), + ...(timezone ? { timezone } : {}), + }; + const response = await client.call({ method: "PATCH", path: `/api/v1/screens/${id}`, idempotent: true, headers: { "if-match": quotedRevision(ifMatch) }, body }); + const warnings = playlistId ? aspectMismatchWarnings(id, response.body, playlist) : []; + return { + envelope: jsonBody(response, client.requestId, undefined, warnings), + exitCode: ExitCode.Success, + human: [`Updated screen ${id}`, ...warnings.map((warning) => `warning: ${warning.message}`)].join("\n"), + }; +}, true); + +export const handleScreenAssign = commandHandler(async (args, runtime, resolved) => { + const token = requireToken(resolved.token); + const client = clientFor(runtime, args, resolved.apiUrl, token); + + const id = args.positionals[2]; + const playlistId = flagString(args.flags, "playlist-id"); + const ifMatch = flagString(args.flags, "if-match"); + if (!id || !playlistId || !ifMatch) throw usageError("screen assign requires <id> --playlist-id --if-match."); + const playlist = await assertScheduledPlaylistHasZone(client, id, playlistId); + const body: ScreenPatch = { playlist_id: playlistId }; + const response = await client.call({ + method: "PATCH", + path: `/api/v1/screens/${id}`, + idempotent: true, + headers: { "if-match": quotedRevision(ifMatch) }, + body, + }); + const warnings = aspectMismatchWarnings(id, response.body, playlist); + return { + envelope: jsonBody(response, client.requestId, undefined, warnings), + exitCode: ExitCode.Success, + human: [`Assigned playlist ${playlistId} to ${id}`, ...warnings.map((warning) => `warning: ${warning.message}`)].join("\n"), + }; +}, true); + +export const handleScreenSetTimezone = commandHandler(async (args, runtime, resolved) => { + const token = requireToken(resolved.token); + const client = clientFor(runtime, args, resolved.apiUrl, token); + + const id = args.positionals[2]; + const timezone = flagString(args.flags, "timezone"); + const ifMatch = flagString(args.flags, "if-match"); + if (!id || !timezone || !ifMatch) throw usageError("screen set-timezone requires <id> --timezone --if-match."); + // The zone database belongs to the server, which validates the identifier + // against it. Sending the value unchanged keeps one authority for what a + // real zone is, so the CLI never carries a list that can go stale. + const body: ScreenPatch = { timezone }; + const response = await client.call({ + method: "PATCH", + path: `/api/v1/screens/${id}`, + idempotent: true, + headers: { "if-match": quotedRevision(ifMatch) }, + body, + }); + return { envelope: jsonBody(response, client.requestId), exitCode: ExitCode.Success, human: `Set timezone ${timezone} on ${id}` }; +}, true); + +async function screenArchiveUnarchiveAction(args: ParsedArgs, runtime: CliRuntime, resolved: ResolvedCommandConfig, action: "archive" | "unarchive"): Promise<CommandResult> { + const token = requireToken(resolved.token); + const client = clientFor(runtime, args, resolved.apiUrl, token); + + const id = args.positionals[2]; + const revision = flagString(args.flags, "if-match"); + if (!id || !revision) throw usageError(`screen ${action} requires <id> and --if-match.`); + const response = await client.call({ + method: "POST", + path: `/api/v1/screens/${id}/${action}`, + idempotent: true, + headers: { "if-match": quotedRevision(revision) }, + }); + return { + envelope: jsonBody(response, client.requestId), + exitCode: ExitCode.Success, + human: action === "archive" ? `Archived screen ${id}` : `Unarchived screen ${id}`, + }; + } +export const handleScreenArchive = commandHandler(async (args, runtime, resolved) => { + return screenArchiveUnarchiveAction(args, runtime, resolved, "archive"); +}, true); + +export const handleScreenUnarchive = commandHandler(async (args, runtime, resolved) => { + return screenArchiveUnarchiveAction(args, runtime, resolved, "unarchive"); +}, true); + +export const handleScreenDelete = commandHandler(async (args, runtime, resolved) => { + const token = requireToken(resolved.token); + const client = clientFor(runtime, args, resolved.apiUrl, token); + + const id = args.positionals[2]; + const ifMatch = flagString(args.flags, "if-match"); + if (!id || !ifMatch) throw usageError("screen delete requires <id> and --if-match."); + const response = await client.call({ + method: "DELETE", + path: `/api/v1/screens/${id}`, + idempotent: true, + headers: { "if-match": quotedRevision(ifMatch) }, + }); + return { envelope: jsonBody(response, client.requestId), exitCode: ExitCode.Success, human: `Deleted screen ${id}` }; +}, true); + +export const handleScreenRotatePublicId = commandHandler(async (args, runtime, resolved) => { + const token = requireToken(resolved.token); + const client = clientFor(runtime, args, resolved.apiUrl, token); + + const id = args.positionals[2]; + const revision = flagString(args.flags, "if-match"); + if (!id || !revision) throw usageError("screen rotate-public-id requires <id> and --if-match."); + const response = await client.call({ + method: "POST", + path: `/api/v1/screens/${id}/public-id/rotate`, + idempotent: true, + headers: { "if-match": quotedRevision(revision) }, + }); + return { + envelope: jsonBody(response, client.requestId), + exitCode: ExitCode.Success, + human: `Rotated public id for ${id}`, + }; +}, true); + +export const handleScreenToast = commandHandler(async (args, runtime, resolved) => { + const token = requireToken(resolved.token); + const client = clientFor(runtime, args, resolved.apiUrl, token); + + return screenToast(args, client); +}, true); + +export const handleScreenScreenshot = commandHandler(async (args, runtime, resolved) => { + const token = requireToken(resolved.token); + const client = clientFor(runtime, args, resolved.apiUrl, token); + + return loggerOf(runtime).withLocal({ op: "screenshot.capture", message: "screen screenshot" }, () => + screenScreenshot(args, runtime, client), + ); +}, true); + async function screenToast(args: ParsedArgs, client: ApiClient): Promise<CommandResult> { const id = args.positionals[2]; if (args.flags.level === true) { @@ -3302,62 +3298,72 @@ async function screenScreenshot(args: ParsedArgs, runtime: CliRuntime, client: A }; } -async function kvCommand( - args: ParsedArgs, - runtime: CliRuntime, - resolved: Awaited<ReturnType<typeof resolveConfig>>, - action: string | undefined, -): Promise<CommandResult> { +export const handleKvList = commandHandler(async (args, runtime, resolved) => { + const applicationId = flagString(args.flags, "application-id"); + if (!applicationId) throw usageError("kv commands require --application-id."); + const key = args.positionals[2]; + + return simpleGet(args, runtime, resolved, `/api/v1/applications/${applicationId}/kv`, "K/V"); +}, true); + +export const handleKvGet = commandHandler(async (args, runtime, resolved) => { + const applicationId = flagString(args.flags, "application-id"); + if (!applicationId) throw usageError("kv commands require --application-id."); + const key = args.positionals[2]; + + if (!key) throw usageError("kv get requires a key."); + return simpleGet(args, runtime, resolved, `/api/v1/applications/${applicationId}/kv/${encodeURIComponent(key)}`, "K/V"); +}, true); + +export const handleKvSet = commandHandler(async (args, runtime, resolved) => { const applicationId = flagString(args.flags, "application-id"); if (!applicationId) throw usageError("kv commands require --application-id."); const token = requireToken(resolved.token); const client = clientFor(runtime, args, resolved.apiUrl, token); const key = args.positionals[2]; - if (action === "list") { - return simpleGet(args, runtime, resolved, `/api/v1/applications/${applicationId}/kv`, "K/V"); - } - if (action === "get") { - if (!key) throw usageError("kv get requires a key."); - return simpleGet(args, runtime, resolved, `/api/v1/applications/${applicationId}/kv/${encodeURIComponent(key)}`, "K/V"); - } - if (action === "set") { - if (!key) throw usageError("kv set requires a key."); - const body = await kvWriteFromArgs(args, runtime.cwd()); - const revision = flagString(args.flags, "if-match"); - const response = await client.call({ - method: "PUT", - path: `/api/v1/applications/${applicationId}/kv/${encodeURIComponent(key)}`, - idempotent: true, - headers: revision ? { "if-match": quotedRevision(revision) } : undefined, - body, - }); - const entry = response.body as KVEntry; - return { - envelope: jsonBody(response, client.requestId), - exitCode: ExitCode.Success, - human: humanLines("K/V value set", [ - ["key", entry.key], - ["content_type", entry.content_type], - ["bytes", String(entry.bytes)], - ["sha256", entry.sha256], - ["revision", String(entry.revision)], - ]), - }; - } - if (action === "delete") { - if (!key) throw usageError("kv delete requires a key."); - const ifMatch = flagString(args.flags, "if-match"); - if (!ifMatch) throw usageError("kv delete requires --if-match."); - const response = await client.call({ - method: "DELETE", - path: `/api/v1/applications/${applicationId}/kv/${encodeURIComponent(key)}`, - idempotent: true, - headers: { "if-match": quotedRevision(ifMatch) }, - }); - return { envelope: jsonBody(response, client.requestId), exitCode: ExitCode.Success, human: `Deleted ${key}` }; - } - throw usageError("Unknown kv command."); -} + + if (!key) throw usageError("kv set requires a key."); + const body = await kvWriteFromArgs(args, runtime.cwd()); + const revision = flagString(args.flags, "if-match"); + const response = await client.call({ + method: "PUT", + path: `/api/v1/applications/${applicationId}/kv/${encodeURIComponent(key)}`, + idempotent: true, + headers: revision ? { "if-match": quotedRevision(revision) } : undefined, + body, + }); + const entry = response.body as KVEntry; + return { + envelope: jsonBody(response, client.requestId), + exitCode: ExitCode.Success, + human: humanLines("K/V value set", [ + ["key", entry.key], + ["content_type", entry.content_type], + ["bytes", String(entry.bytes)], + ["sha256", entry.sha256], + ["revision", String(entry.revision)], + ]), + }; +}, true); + +export const handleKvDelete = commandHandler(async (args, runtime, resolved) => { + const applicationId = flagString(args.flags, "application-id"); + if (!applicationId) throw usageError("kv commands require --application-id."); + const token = requireToken(resolved.token); + const client = clientFor(runtime, args, resolved.apiUrl, token); + const key = args.positionals[2]; + + if (!key) throw usageError("kv delete requires a key."); + const ifMatch = flagString(args.flags, "if-match"); + if (!ifMatch) throw usageError("kv delete requires --if-match."); + const response = await client.call({ + method: "DELETE", + path: `/api/v1/applications/${applicationId}/kv/${encodeURIComponent(key)}`, + idempotent: true, + headers: { "if-match": quotedRevision(ifMatch) }, + }); + return { envelope: jsonBody(response, client.requestId), exitCode: ExitCode.Success, human: `Deleted ${key}` }; +}, true); function commentPath(target: "screen" | "playlist", id: string, pageId: string | undefined): string { if (target === "screen") { @@ -3369,26 +3375,12 @@ function commentPath(target: "screen" | "playlist", id: string, pageId: string | return `/api/v1/comment/playlist/${encodeURIComponent(id)}`; } -async function commentCommand( - args: ParsedArgs, - runtime: CliRuntime, - resolved: Awaited<ReturnType<typeof resolveConfig>>, - action: string | undefined, -): Promise<CommandResult> { - if (action !== "show" && action !== "set" && action !== "delete") { - throw usageError("Unknown comment command."); - } - const target = args.positionals[2]; +function commentTarget(args: ParsedArgs, target: "screen" | "playlist") { const id = args.positionals[3]; - if (target !== "screen" && target !== "playlist") { - throw usageError("comment commands require screen <id> or playlist <id>."); - } if (!id || (target === "screen" && !isScreenId(id))) { - throw usageError(`comment ${action} ${target} requires <id>.`); - } - if (args.positionals.length > 4) { - throw usageError(`comment ${action} ${target} does not accept extra arguments.`); + throw usageError(`comment ${target} requires <id>.`); } + if (Object.hasOwn(args.flags, "if-match")) { throw usageError("comment commands do not take --if-match; last write wins and does not bump revision."); } @@ -3401,28 +3393,48 @@ async function commentCommand( throw usageError("--page must be a playlist page id: a letter, then up to 63 letters, digits, underscores, or hyphens."); } const pathName = commentPath(target, id, pageId); - if (action === "show") { - return simpleGet(args, runtime, resolved, pathName, "Comments"); - } - const token = requireToken(resolved.token); - const client = clientFor(runtime, args, resolved.apiUrl, token); - if (action === "set") { - const body = await commentsWriteFromArgs(args, runtime.cwd()); - const response = await client.call({ - method: "PUT", - path: pathName, - idempotent: true, - body, - }); - return { - envelope: jsonBody(response, client.requestId), - exitCode: ExitCode.Success, - human: humanLines("Comments set", [ - [target, id], - ["page", pageId], - ]), - }; - } + + return { id, pageId, pathName }; +} + +async function showComment(args: ParsedArgs, runtime: CliRuntime, resolved: ResolvedCommandConfig, target: "screen" | "playlist"): Promise<CommandResult> { + const { pathName } = commentTarget(args, target); + return simpleGet(args, runtime, resolved, pathName, "Comments"); + +} + +export const handleCommentShowScreen = commandHandler((args, runtime, resolved) => showComment(args, runtime, resolved, "screen")); +export const handleCommentShowPlaylist = commandHandler((args, runtime, resolved) => showComment(args, runtime, resolved, "playlist")); + +async function setComment(args: ParsedArgs, runtime: CliRuntime, resolved: ResolvedCommandConfig, target: "screen" | "playlist"): Promise<CommandResult> { + const { id, pageId, pathName } = commentTarget(args, target); + const client = clientFor(runtime, args, resolved.apiUrl, requireToken(resolved.token)); + + const body = await commentsWriteFromArgs(args, runtime.cwd()); + const response = await client.call({ + method: "PUT", + path: pathName, + idempotent: true, + body, + }); + return { + envelope: jsonBody(response, client.requestId), + exitCode: ExitCode.Success, + human: humanLines("Comments set", [ + [target, id], + ["page", pageId], + ]), + }; + +} + +export const handleCommentSetScreen = commandHandler((args, runtime, resolved) => setComment(args, runtime, resolved, "screen")); +export const handleCommentSetPlaylist = commandHandler((args, runtime, resolved) => setComment(args, runtime, resolved, "playlist")); + +async function deleteComment(args: ParsedArgs, runtime: CliRuntime, resolved: ResolvedCommandConfig, target: "screen" | "playlist"): Promise<CommandResult> { + const { id, pageId, pathName } = commentTarget(args, target); + const client = clientFor(runtime, args, resolved.apiUrl, requireToken(resolved.token)); + const response = await client.call({ method: "DELETE", path: pathName, @@ -3435,8 +3447,12 @@ async function commentCommand( ? `Deleted comments on playlist ${id} page ${pageId}` : `Deleted comments on ${target} ${id}`, }; + } +export const handleCommentDeleteScreen = commandHandler((args, runtime, resolved) => deleteComment(args, runtime, resolved, "screen")); +export const handleCommentDeletePlaylist = commandHandler((args, runtime, resolved) => deleteComment(args, runtime, resolved, "playlist")); + async function operationsGet(args: ParsedArgs, runtime: CliRuntime, resolved: Awaited<ReturnType<typeof resolveConfig>>): Promise<CommandResult> { const id = args.positionals[2]; if (!id) throw usageError("operations get requires an id."); diff --git a/src/help-text.ts b/src/help-text.ts index 93d77722..0f12e891 100644 --- a/src/help-text.ts +++ b/src/help-text.ts @@ -1,54 +1 @@ -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 index 79d0b50e..a38b63a9 100644 --- a/src/help.test.ts +++ b/src/help.test.ts @@ -2,7 +2,6 @@ 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 { createCommandTree, findCommand } from "./command-tree.js"; import { commandHelp, leafCommandPaths } from "./help.js"; import { CLI_VERSION } from "./version.js"; @@ -34,7 +33,75 @@ test("root help is compact and every command is discoverable through immediate c } } visit([]); - assert.deepEqual(discovered.sort(), COMMAND_SPECS.map((spec) => spec.path.join(" ")).sort()); + assert.deepEqual(discovered.sort(), [ + "account show", + "agent enroll", + "agent connect", + "agent status", + "agent disconnect", + "dashboard", + "app pack", + "app upload", + "app update", + "app list", + "app show", + "media generate", + "media upload", + "media upload-batch", + "media show", + "media download", + "media list", + "media update", + "media delete", + "compose catalog", + "compose batch", + "compose render", + "playlist validate", + "playlist preview", + "playlist templates", + "playlist create", + "playlist update", + "playlist export", + "playlist import", + "playlist show", + "playlist list", + "playlist delete", + "screen pair", + "screen provision", + "browser setup", + "screen update", + "screen list", + "screen show", + "screen assign", + "screen set-timezone", + "screen archive", + "screen unarchive", + "screen delete", + "screen rotate-public-id", + "screen toast", + "screen screenshot", + "kv get", + "kv set", + "kv delete", + "kv list", + "comment show screen", + "comment show playlist", + "comment set screen", + "comment set playlist", + "comment delete screen", + "comment delete playlist", + "operations get", + "operations wait", + "operations cancel", + "events list", + "events follow", + "playback list", + "feedback bug", + "feedback feature", + "feedback list", + "doctor", + "version", + ].sort()); const inventory = leafCommandPaths(createCommandTree().root); assert.ok(inventory.includes("help")); for (const path of inventory) { @@ -61,10 +128,8 @@ test("human root --help includes every taught leaf command path", () => { const text = execFileSync(process.execPath, [bin, "--help"], { encoding: "utf8", env: { ...process.env, SCREENRIG_CONFIG: "/nonexistent/screenrig-help-config" }, }); - for (const spec of COMMAND_SPECS) { - const path = spec.path.join(" "); + for (const path of leafCommandPaths(createCommandTree().root)) { assert.ok(text.includes(path), path); - for (const alias of spec.aliases ?? []) assert.ok(text.includes(alias.join(" ")), alias.join(" ")); } }); diff --git a/src/help.ts b/src/help.ts index 94dd0ade..c61be5cc 100644 --- a/src/help.ts +++ b/src/help.ts @@ -1,6 +1,6 @@ import type { Command, Option } from "commander"; import { commandPath, createCommandTree, findCommand } from "./command-tree.js"; -import { NOTES } from "./help-text.js"; +import { commandNotes } from "./cli-commands/notes.js"; import { usageError } from "./problems.js"; export { CREDIT_HELP } from "./help-text.js"; @@ -43,8 +43,7 @@ function commandInventory(command: Command): string | undefined { export function describeHelp(command: Command): HelpDocument { const helper = command.createHelp(); const path = commandPath(command); - const note = NOTES[path.join(" ")]; - const notes = note ? [note] : []; + const notes = commandNotes(command); const inventory = commandInventory(command); const describeOption = (option: Option) => ({ name: option.long!, type: (option.negate || option.isBoolean()) ? "boolean" as const : "value" as const, description: option.description, required: option.mandatory, @@ -70,5 +69,3 @@ export function commandHelp(path: readonly string[] = []): HelpDocument { 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 83c7c663..9143a4c8 100644 --- a/src/log/logger.test.ts +++ b/src/log/logger.test.ts @@ -7,7 +7,7 @@ import { test } from "node:test"; import { fileURLToPath } from "node:url"; import { ApiClient } from "../client.js"; import { parseArgv } from "../argv.js"; -import { USAGE } from "../commands.js"; +import { commandHelp } from "../help.js"; import { preserveLogSocket, readConfigFile, resolveConfig, writeConfigAtomic, type ConfigFs } from "../config.js"; import { ensureCredential } from "../enrollment.js"; import { run, type CliRuntime } from "../main.js"; @@ -121,8 +121,8 @@ 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.doesNotMatch(USAGE, /log_socket/); + assert.doesNotMatch(commandHelp().usage, /\[--log-socket/); + assert.doesNotMatch(commandHelp().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/main.ts b/src/main.ts index 68a5b3bf..fe03d891 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,5 +1,4 @@ -import { parseArgv } from "./argv.js"; -import { dispatch } from "./commands.js"; +import { executeCommand } from "./program.js"; import { applyCreditsLowToSuccess, observedCreditsRemaining } from "./credits.js"; import { errorEnvelope, type Warning } from "./envelope.js"; import { ExitCode } from "./exit-codes.js"; @@ -31,11 +30,10 @@ function applyLogSinkToSuccess<T extends { envelope: { warnings: Warning[] }; hu export async function run(runtime: CliRuntime = processRuntime()): Promise<number> { const json = runtime.argv.includes("--json"); try { - const args = parseArgv(runtime.argv); - const dispatched = applyCreditsLowToSuccess(await dispatch(args, runtime), observedCreditsRemaining(runtime)); + const dispatched = applyCreditsLowToSuccess(await executeCommand(runtime.argv, runtime), observedCreditsRemaining(runtime)); runtime.logger?.endRun(); const result = applyLogSinkToSuccess(dispatched, runtime.logger?.droppedLines() ?? 0); - if (json || args.flags.json === true) { + if (json) { if (result.human) { runtime.stdout.write(`${JSON.stringify(result.envelope)}\n`); } diff --git a/src/native-program.test.ts b/src/native-program.test.ts new file mode 100644 index 00000000..cb262b3c --- /dev/null +++ b/src/native-program.test.ts @@ -0,0 +1,105 @@ +import assert from "node:assert/strict"; +import { chmod, mkdir, open, readdir, rename, rm, stat } from "node:fs/promises"; +import path from "node:path"; +import { PassThrough } from "node:stream"; +import { setImmediate } from "node:timers/promises"; +import { test } from "node:test"; +import { writeConfigAtomic, type ConfigFs } from "./config.js"; +import { ExitCode } from "./exit-codes.js"; +import { run, type CliRuntime } from "./main.js"; +import { networkError } from "./problems.js"; +import { testTemp } from "./test-temp.js"; +import { FakeTransport } from "./transport/fake.js"; + +async function runtimeFor(argv: string[], transport: FakeTransport, authenticated: boolean) { + const configDir = await testTemp("native-program-"); + const fs: ConfigFs = { mkdir, open, rename, rm, chmod, stat, homedir: () => configDir, env: { XDG_CONFIG_HOME: configDir } }; + if (authenticated) { + await writeConfigAtomic(path.join(configDir, "screenrig", "config.json"), { + api_url: "https://api.screenrig.ai", + token: "sr_live_tokidAAAAAAAAAAAAAAAA_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + }, fs); + } + const stdout = new PassThrough(); + const stderr = new PassThrough(); + let output = ""; + let errors = ""; + stdout.on("data", (chunk: Buffer) => { output += chunk.toString("utf8"); }); + stderr.on("data", (chunk: Buffer) => { errors += chunk.toString("utf8"); }); + const runtime: CliRuntime = { + argv: ["--json", ...argv], env: fs.env, stdout, stderr, fs, transport, + now: () => new Date("2026-09-10T12:00:00Z"), sleep: async () => undefined, + homedir: fs.homedir, cwd: () => configDir, + }; + return { + runtime, configDir, output: () => output, errors: () => errors, + async dispose() { + stdout.end(); + stderr.end(); + await rm(configDir, { recursive: true, force: true }); + }, + }; +} + +for (const outcome of ["success", "rejection"] as const) { + test(`native action waits for deferred transport ${outcome} and emits one envelope`, { timeout: 5000 }, async () => { + let started!: () => void; + const requested = new Promise<void>((resolve) => { started = resolve; }); + let release!: () => void; + const responseReady = new Promise<void>((resolve) => { release = resolve; }); + const transport = new FakeTransport().on("GET", "/api/v1/account", async () => { + started(); + await responseReady; + if (outcome === "rejection") throw networkError("Deferred transport failed."); + return { status: 200, headers: {}, body: { id: "acc_TEST", revision: 1, credit_remaining: 2000 } }; + }); + const fixture = await runtimeFor(["account", "show"], transport, true); + let settled = false; + const pending = run(fixture.runtime).then((code) => { settled = true; return code; }); + try { + assert.equal(await Promise.race([requested.then(() => "requested"), pending.then(() => "completed")]), "requested"); + await setImmediate(); + assert.equal(settled, false, "run must await the action's transport promise"); + assert.equal(fixture.output(), "", "no envelope may be emitted before the action finishes"); + release(); + const code = await pending; + assert.equal(code, outcome === "success" ? ExitCode.Success : ExitCode.Network); + const lines = fixture.output().trim().split("\n"); + assert.equal(lines.length, 1, fixture.output()); + const envelope = JSON.parse(lines[0]!); + assert.equal(envelope.ok, outcome === "success"); + if (outcome === "success") assert.equal(envelope.data.id, "acc_TEST"); + else assert.equal(envelope.error.code, "transport_error"); + assert.equal(fixture.errors(), ""); + assert.equal(transport.calls.length, 1); + } finally { + release(); + await pending; + await fixture.dispose(); + } + }); +} + +test("unauthenticated native mutation actions reject before local preparation or transport", async () => { + for (const argv of [ + ["app", "update", "app_TEST", "missing-app", "--if-match", "1"], + ["media", "generate", "--prompt", "A sample information board"], + ["media", "upload-batch", "missing-manifest.json", "--state", "resume.json"], + ]) { + const transport = new FakeTransport(); + const fixture = await runtimeFor(argv, transport, false); + fixture.runtime.cwd = () => { assert.fail("unauthenticated action started local preparation"); }; + fixture.runtime.runProcess = async () => { assert.fail("unauthenticated action started a child process"); }; + fixture.runtime.signedRawPut = async () => { assert.fail("unauthenticated action started an upload"); }; + try { + assert.equal(await run(fixture.runtime), ExitCode.Auth, fixture.output()); + const envelope = JSON.parse(fixture.output()); + assert.equal(envelope.error.code, "not_enrolled"); + assert.equal(envelope.error.next.command, "screenrig agent enroll --email ADDRESS"); + assert.equal(transport.calls.length, 0); + assert.deepEqual(await readdir(fixture.configDir), [], "authentication failure must not create retry or enrollment state"); + } finally { + await fixture.dispose(); + } + } +}); diff --git a/src/program.ts b/src/program.ts new file mode 100644 index 00000000..c874a781 --- /dev/null +++ b/src/program.ts @@ -0,0 +1,27 @@ +import { CommanderError } from "commander"; +import { createCommandTree } from "./command-tree.js"; +import { handleVersion, type CommandResult } from "./commands.js"; +import { describeHelp } from "./help.js"; +import { successEnvelope } from "./envelope.js"; +import { ExitCode } from "./exit-codes.js"; +import type { CliRuntime } from "./runtime.js"; + +/** Run one native Commander action and return its result to the output boundary. */ +export async function executeCommand(argv: string[], runtime: CliRuntime): Promise<CommandResult> { + let result: CommandResult | undefined; + const tree = createCommandTree(argv, async (handler, args) => { + result = await handler(args, runtime); + }); + try { + await tree.root.parseAsync(argv, { from: "user" }); + } catch (error) { + if (!(error instanceof CommanderError) || !(tree.helpRequested() || tree.versionRequested())) throw error; + } + if (tree.helpRequested()) { + const help = describeHelp(tree.selected()); + return { envelope: successEnvelope(help), exitCode: ExitCode.Success, human: help.usage }; + } + if (tree.versionRequested()) return handleVersion({ command: ["version"], positionals: ["version"], flags: {} }, runtime); + if (!result) throw new Error("Command completed without a result."); + return result; +} diff --git a/src/validate-invocation.test.ts b/src/validate-invocation.test.ts index bfe0b7e5..e90e6a82 100644 --- a/src/validate-invocation.test.ts +++ b/src/validate-invocation.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import { parseArgv } from "./argv.js"; -import { dispatch } from "./commands.js"; +import { executeCommand } from "./program.js"; import { CliError } from "./problems.js"; import type { CliRuntime } from "./runtime.js"; @@ -33,7 +33,7 @@ const invalid = [ for (const [index, argv] of invalid.entries()) { test(`invalid invocation ${index + 1} is rejected before accessing runtime`, async () => { const runtime = new Proxy({} as CliRuntime, { get() { assert.fail("invalid invocation accessed runtime"); } }); - await assert.rejects(async () => dispatch(parseArgv(argv), runtime), (error: unknown) => error instanceof CliError && error.problem.code === "usage_error"); + await assert.rejects(async () => executeCommand(argv, runtime), (error: unknown) => error instanceof CliError && error.problem.code === "usage_error"); }); }