diff --git a/AGENTS.md b/AGENTS.md index 4d299729..76c3eec2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,6 +44,18 @@ does not require that file for standalone contribution. - Preserve unrelated work. Do not commit, push, tag, or publish unless asked. Do not publish npm from a laptop. +## CLI vocabulary and help + +Follow the canonical vocabulary in README.md: `show` inspects resources, `list` +reads collections, K/V uses `get`/`set`, display names use `--name`, and resource +selectors use `--screen-id`, `--app-id`, `--playlist-id`, or `--release-id`. +Keep existing aliases compatible; never rename backend fields to match CLI flags. +Native command definitions own arguments, choices, and validation. Declare option +relationships with `requireOptionGroup` so human help, JSON discovery, and preflight +validation agree. Document stdin only where implemented. Supply a working example +and explain write scope, waiting, and recovery where relevant. Help-only workflow +notes and examples live in `src/cli-commands/guidance.ts`. + ## Media transcoding - `media upload` transcodes by default. ffmpeg and ffprobe are a required diff --git a/README.md b/README.md index 901ff13b..205d2614 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ screenrig version ``` Node.js 20.11 or newer is required. `media upload` additionally requires ffmpeg -and ffprobe; the other commands do not. Run `screenrig doctor` to inspect +and ffprobe, as do batch uploads and playlist preparation from local media files. Run `screenrig doctor` to inspect the optional media toolchain before an upload. This global package is the official developer-shell distribution. It is not the @@ -50,7 +50,7 @@ Use `screenrig help --all` for the complete command inventory, or immediate children. Add `--json` for structured command paths, positional arguments, option choices and defaults, relationships, and examples; help runs without configuration or authentication. Command-specific options follow -that command, for example `screenrig screen update ID --name Lobby --if-match 1`. +that command, for example `screenrig screen update ID --name Lobby --expect-rev 1`. Global options such as `--json` may appear before or after the command. Use `--name=VALUE` for a value starting with a dash, and `--` before option-like file names. Duplicate options are rejected. @@ -62,6 +62,35 @@ Choose by what the page is: - Slide-deck-like experiences: local unbilled `compose render`. - Live video, iframe, or webapp: write playlist primitives. +## Command vocabulary + +Use these canonical spellings in new invocations and examples: + +| Concept | Spelling | Compatibility | +| --- | --- | --- | +| Inspect one resource | `show` (including `operations show`) | `operations get` and `playlist get` remain aliases | +| Read/write a K/V value | `kv get` / `kv set` | Values retain their byte-oriented semantics | +| Display name | `--name` | Screen pair/provision retain `--label` | +| Select a screen | `--screen-id` | Playlist init retains `--screen` | +| Select an application namespace | `--app-id` | K/V retains `--application-id` | +| Select a playlist or release | `--playlist-id` / `--release-id` | IDs stay explicit | +| Guard a revision | `--expect-rev` | `--if-match` remains an alias | +| Read a collection | `list` | `events` and `operations` retain established plural group names | + +The application command group is `app`; use “application” in explanatory prose. +Keep the established `comment ACTION screen|playlist ID` grammar. `--page` and +`--primitive` identify objects within a playlist, rather than account resources. +Use `--after` for an event cursor (`--cursor` remains an alias). `--output` selects +an output path; each command states whether it expects a file or directory. +Durations use milliseconds as stated by `--duration-ms`, `--poll-ms`, and `--timeout`. + +Legacy option spellings share the same value and validation as their canonical +option; supplying both is an error. JSON help exposes compatibility spellings in +`options[].aliases`. Response fields and backend contracts are unchanged. +Option `relationships` describe `exactlyOne`, `atLeastOne`, and `together` groups; +`requires` means the first option requires every remaining option. These same +rules validate invocations before configuration or network access. + ## Screen host and recovery Native players report the shell and hardware they run on. `screen show` prints @@ -102,7 +131,7 @@ for the current revision before an update. `data.operation` contains the observed completed operation when waiting. With `--no-wait` it is `null`: upload acceptance does not establish operation state -or release readiness. Use `operations get ` or +or release readiness. Use `operations show ` or `operations wait ` to observe processing. The envelope's `operation_id` identifies that same operation in either mode. Existing flat accepted fields (`data.id`, `data.release_id`, `data.operation_id`) and @@ -112,21 +141,30 @@ accepted fields (`data.id`, `data.release_id`, `data.operation_id`) and ### Nonblocking agent connection -`screenrig agent connect --no-wait --print-url` starts or resumes a connection, +`screenrig agent connect --print-url` starts or resumes a connection, reads one server status snapshot, and returns a JSON envelope. While approval is -pending, `data.status` is `pending`, `data.approval_url` contains the dashboard +pending, `data.status` is `pending`, `data.request_submitted` is `true`, +`data.connection_complete` is `false`, `data.approval_url` contains the dashboard handoff, and `data.next.command` identifies the resume command. `data.next.argv` supplies its arguments, preserving the selected config and API origin. Open the handoff -for the intended user, then run `screenrig agent connect --no-wait` again. An +for the intended user, then run `screenrig agent connect` again. An approved connection completes credential collection and activation and returns -`data.status: active`. No credential is included in either result. +`data.status: active` and `data.connection_complete: true`. No credential is included in either result. Without `--print-url`, the CLI tries to open the browser and includes the handoff -URL in the pending result only if opening fails. `--no-wait` defaults to a -30-second status-read budget; it does not wait for a person to approve. Normal -`agent connect` retains its existing approval wait of up to 24 hours. `--timeout` -overrides either mode's wait budget. Terminal denial, cancellation, and expiry -remain errors. +URL in the pending result only if opening fails. The default (also `--no-wait`) +reads a status snapshot for at most one second. If none arrives, the request +remains resumable and `data.status_checked` is `false`; this does not assert +that the server still awaits approval. A received pending snapshot sets it to +`true`. Success with pending status means submission succeeded, not connection +completion. + +Use `agent connect --wait` to wait for approval for up to 30 seconds, or +`agent connect --wait --timeout 10000` for an explicit budget in milliseconds +(1–86400000). Reaching the wait budget returns a resumable pending result. +Without `--wait`, `--timeout` can shorten but never extend the one-second +snapshot budget. Approval requests expire after 24 hours. Terminal denial, +cancellation, and expiry remain errors. ### Recovering writes @@ -205,17 +243,46 @@ Report suspected vulnerabilities through the [security policy](SECURITY.md). For ready images and videos, prepare a full-screen playlist in playback order: ```sh -screenrig playlist init med_POSTER med_VIDEO --name "Lobby loop" --screen scr_LOBBY --output lobby.json +screenrig playlist init med_POSTER med_VIDEO --name "Lobby loop" --screen-id scr_LOBBY --output lobby.json screenrig playlist preview lobby.json --output preview --contact-sheet screenrig screen publish scr_LOBBY lobby.json --expect-rev 7 ``` -Inspect the preview before publishing. `playlist init` reads media metadata and -screen observations but makes no remote writes. It creates one page per media ID, -with `contain` fit, a black background, and a 200 ms crossfade. Images last 8000 ms; -videos are muted, do not loop, and advance on completion. Use `--duration-ms` for -image duration and `--fit contain|cover|fill` for content fit. Override the canvas -with both `--target-width` and `--target-height`; these also work without `--screen`. +Inspect the document and preview before publishing. `playlist init` accepts ordered +local image/video files, ready `med_` IDs, pinned `rel_` application releases, and +HTTPS iframe URLs, including mixed inputs: + +```sh +screenrig playlist init ./poster.png med_VIDEO rel_APP https://example.com --name Lobby --screen-id scr_LOBBY --output lobby.json +``` + +Files use the normal media upload/transcode path and wait for readiness; ffmpeg +and ffprobe are required unless `--no-transcode` is used. Preparation uploads +files but does not create a remote playlist or assign a screen. Existing media +must be ready. Release availability and iframe embedding support still need +preview/server and Player verification. Preparation does not fetch iframe URLs. +Each file occurrence has a distinct upload key derived from the invocation's +idempotency key and its input position. For retryable preparation, supply +`--idempotency-key` from the first attempt and reuse it with identical inputs in +the same order, within the server's replay window. Declarations and commits then +reuse their respective per-file keys. Without an explicit key, a new invocation +gets new upload keys. If preparation fails after an upload, that media remains in +the account; inspect `media list` and reuse its ID instead of uploading it again. + +The canonical document contains one full-screen page per input, a black background, +and a 200 ms crossfade. Images use `--fit contain|cover|fill` (default `contain`); +videos are muted, do not loop, and advance on completion. Other pages advance after +`--duration-ms` (default 8000). Applications and iframes use `fill`; applications +are pinned to the supplied release and use timed advancement, without controller +privileges. Edit the document for application-controlled advancement. + +With `--screen-id`, the result includes `screen_id`, `screen_revision`, and a +`publish.argv` array containing the output path and observed revision. It also +returns `preview.argv`. These arrays preserve the selected config/API and paths +with spaces; execute preview, inspect it, then use the publish arguments. A later +screen change still produces a revision conflict. Target metadata stays outside +the playlist file. Override canvas dimensions with both `--target-width` and +`--target-height`; these also work without `--screen-id` (no publish arguments). Unknown or multiple reported surfaces require explicit dimensions. Output files must not already exist. @@ -255,6 +322,30 @@ remain separate. Plain `playlist show` is an inspection response; `--editable` without `--output` returns `data.document`, `data.playlist_id`, and `data.revision`. Updating a playlist affects every screen assigned to it. +After `app update` succeeds, take `data.application.release_id` and preview a +replacement of one explicitly identified application primitive: + +```sh +screenrig playlist replace-release pl_EXISTING --page board-page --primitive board --release-id rel_NEW +``` + +Review `data.previous_release_id`, `data.release_id`, and `data.affected_screens` +(including archived assignments). Then use the returned `data.revision` and +`data.impact` to apply that exact replacement: + +```sh +screenrig playlist replace-release pl_EXISTING --page board-page --primitive board --release-id rel_NEW --apply --expect-rev 4 --expect-impact TOKEN_FROM_PREVIEW +``` + +The CLI preserves the other primitives and playlist settings. Every screen +assigned to this shared playlist receives the new pin; archived screens retain +it for later use. A changed replacement, playlist revision, or observed screen +impact requires a fresh preview and review. Screen assignments can change after +the snapshot; the server atomically checks only the playlist revision. Release +availability and ownership are validated by the server on apply. After writing, +verify screen manifest revisions and playback. Existing pins stay unchanged +until the replacement is applied. + Playlist validate, create, update, preview, and screen publish accept `-` as their input file to read stdin. JSON envelopes are never written into authored files. `--expect-rev` is the preferred revision spelling; `--if-match` remains a compatible diff --git a/src/cli-commands/agent.ts b/src/cli-commands/agent.ts index cb9a7467..a5525cbf 100644 --- a/src/cli-commands/agent.ts +++ b/src/cli-commands/agent.ts @@ -15,8 +15,9 @@ export function registerAgentCommands(root: Command, bind: CommandActionBinder): 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") - .option("--no-wait", "Return pending status instead of waiting for dashboard approval") - .action(bind(handleAgentConnect)), "Approval expires after 24 hours. Default mode waits up to 86400000 ms. --no-wait reads one status snapshot (30000 ms default), returning pending with a resume command or completing an approved connection. --print-url places the handoff URL in the pending result. Retry agent connect to resume after an interrupted wait."); + .option("--wait", "Wait for dashboard approval (30000 ms default; bounded by --timeout)") + .option("--no-wait", "Read a status snapshot for at most 1000 ms (default)") + .action(bind(handleAgentConnect)), "Approval expires after 24 hours. By default, read one status snapshot for at most 1000 ms and return pending with a resume command, or complete an approved connection. --wait opts into a 30000 ms approval wait; --timeout bounds that wait (1–86400000 ms). Without --wait, --timeout can shorten but never extend the snapshot budget. Pending means the request was submitted, not that approval or activation completed. --print-url places the handoff URL in the pending result. Retry agent connect to resume after an interrupted wait."); agent.command("status").description("Inspect this agent's connection") .action(bind(handleAgentStatus)); diff --git a/src/cli-commands/aliases.ts b/src/cli-commands/aliases.ts new file mode 100644 index 00000000..5fd2caa7 --- /dev/null +++ b/src/cli-commands/aliases.ts @@ -0,0 +1,22 @@ +import { type Command, Option } from "commander"; + +const handlers = new WeakMap(); +const aliases = new WeakMap(); + +/** Both spellings share one Commander value and validation; handlers keep their contract. */ +export function addValueAlias(command: Command, canonical: string, legacy: string, description: string, required = false): void { + const placeholder = canonical === "--name" ? "NAME" : "ID"; + const option = new Option(`${canonical} <${placeholder}>`, `${description} (${legacy} alias)`); + if (required) option.makeOptionMandatory(); + const alias = new Option(`${legacy} <${placeholder}>`, `Alias for ${canonical}`).hideHelp(); + alias.attributeName = () => option.attributeName(); + handlers.set(option, legacy.slice(2)); + handlers.set(alias, legacy.slice(2)); + aliases.set(option, [legacy]); + command.addOption(option).addOption(alias); +} + +export function handlerOptionName(option: Option): string { + return handlers.get(option) ?? (option.long === "--expect-rev" ? "if-match" : option.long!.slice(2)); +} +export function optionAliases(option: Option): string[] { return aliases.get(option) ?? []; } diff --git a/src/cli-commands/comment.ts b/src/cli-commands/comment.ts index 984dd51c..dcdf9731 100644 --- a/src/cli-commands/comment.ts +++ b/src/cli-commands/comment.ts @@ -1,3 +1,4 @@ +import { requireOptionGroup } from "./notes.js"; import type { CommandActionBinder } from "./types.js"; import { handleCommentShowScreen, handleCommentShowPlaylist, handleCommentSetScreen, handleCommentSetPlaylist, handleCommentDeleteScreen, handleCommentDeletePlaylist } from "../commands.js"; import { type Command, Option } from "commander"; @@ -41,4 +42,6 @@ export function registerCommentCommands(root: Command, bind: CommandActionBinder .argument("", "Screen or playlist identifier") .option("--page ", "Select playlist page comments") .action(bind(handleCommentDeletePlaylist)); + for (const command of commentSet.commands) requireOptionGroup(command, "exactlyOne", ["--json-value", "--file"]); + for (const action of comment.commands) for (const command of action.commands) command.registeredArguments[0]!.description = command.name() === "screen" ? "Screen identifier" : "Playlist identifier"; } diff --git a/src/cli-commands/feedback.ts b/src/cli-commands/feedback.ts index 92988f90..0019fabc 100644 --- a/src/cli-commands/feedback.ts +++ b/src/cli-commands/feedback.ts @@ -1,3 +1,4 @@ +import { requireOptionGroup } from "./notes.js"; import type { CommandActionBinder } from "./types.js"; import { handleFeedbackBug, handleFeedbackFeature, handleFeedbackList } from "../commands.js"; import { type Command, Option } from "commander"; @@ -22,6 +23,7 @@ export function registerFeedbackCommands(root: Command, bind: CommandActionBinde .action(bind(handleFeedbackFeature)); feedback.command("list").description("List submitted feedback") - .option("--kind ", "Filter feedback by kind") + .addOption(new Option("--kind ", "Filter feedback by kind").choices(["bug", "feature"])) .action(bind(handleFeedbackList)); + for (const command of feedback.commands.filter(command => command.name() !== "list")) requireOptionGroup(command, "exactlyOne", ["--body", "--body-file"]); } diff --git a/src/cli-commands/guidance.ts b/src/cli-commands/guidance.ts new file mode 100644 index 00000000..7eb4ea31 --- /dev/null +++ b/src/cli-commands/guidance.ts @@ -0,0 +1,93 @@ +import type { Command } from "commander"; +import { findCommand } from "../command-path.js"; +import { addCommandExamples, addCommandNotes, commandExamples } from "./notes.js"; + +/** Help-only workflow guidance. Syntax and validation belong to the native commands. */ +export function registerGuidance(root: Command): void { + const examples: Record = { + "account show": [""], + "agent enroll": ["--email operator@example.com --name Lobby-agent"], + "agent connect": ["--print-url", "--wait --timeout 10000"], + "agent status": [""], + "agent disconnect": ["--yes"], + "dashboard": [""], + "app pack": ["./lobby-app --output lobby-app.tar.gz"], + "app upload": ['./lobby-app --name "Lobby app"'], + "app update": ["app_APP ./lobby-app --expect-rev 3"], + "app list": [""], "app show": ["app_APP"], + "media upload": ["./poster.png --tag Lobby"], + "media upload-batch": ["uploads.json --state uploads-state.json"], + "media show": ["med_MEDIA"], "media download": ["med_MEDIA --output poster.webp"], + "media list": ["--tag Lobby --primitive image"], + "media update": ["med_MEDIA --tag Lobby --expect-rev 2"], + "media delete": ["med_MEDIA --expect-rev 2"], + "compose catalog": [""], "compose render": ["slide.json --output rendered"], + "compose batch": ["slides.json --output rendered"], + "playlist validate": ["lobby.json"], "playlist preview": ["lobby.json --output preview --contact-sheet"], + "playlist templates": [""], "playlist create": ["lobby.json"], + "playlist update": ["pl_PLAYLIST lobby.json --expect-rev 4"], + "playlist export": ["pl_PLAYLIST --output lobby-bundle"], + "playlist import": ['lobby-bundle --name "Lobby copy"', "lobby-bundle --update pl_PLAYLIST --expect-rev 4"], + "playlist list": [""], "playlist delete": ["pl_PLAYLIST --expect-rev 4"], + "screen publish": ["scr_SCREEN lobby.json --expect-rev 7"], + "screen pair": ['234567 --name "Lobby screen"'], + "screen provision": ['--open --name "Lobby screen"'], + "screen update": ['scr_SCREEN --name "Lobby screen" --expect-rev 7'], + "screen list": ["", "--state archived"], "screen show": ["scr_SCREEN"], + "screen assign": ["scr_SCREEN --playlist-id pl_PLAYLIST --expect-rev 7"], + "screen set-timezone": ["scr_SCREEN --timezone America/Los_Angeles --expect-rev 7"], + "screen archive": ["scr_SCREEN --expect-rev 7"], "screen unarchive": ["scr_SCREEN --expect-rev 8"], + "screen delete": ["scr_SCREEN --expect-rev 8"], "screen rotate-public-id": ["scr_SCREEN --expect-rev 7"], + "screen toast": ['scr_SCREEN --text "Welcome" --duration-ms 5000'], + "screen screenshot": ["scr_SCREEN --output screenshot.webp"], + "browser setup": ["--code SETUP_CODE --open"], + "kv get": ["greeting --app-id app_APP"], + "kv set": ['greeting --app-id app_APP --json-value \'{"message":"Welcome"}\'', "greeting --app-id app_APP --file greeting.txt --content-type text/plain"], + "kv list": ["--app-id app_APP"], "kv delete": ["greeting --app-id app_APP --expect-rev 2"], + "comment show screen": ["scr_SCREEN"], "comment show playlist": ["pl_PLAYLIST --page page_1"], + "comment set screen": ["scr_SCREEN --file comments.json"], + "comment set playlist": ["pl_PLAYLIST --page page_1 --file comments.json"], + "comment delete screen": ["scr_SCREEN"], "comment delete playlist": ["pl_PLAYLIST --page page_1"], + "operations show": ["op_OPERATION"], "operations wait": ["op_OPERATION --timeout 120000"], + "operations cancel": ["op_OPERATION"], + "events list": ["--limit 20"], "events follow": ["--timeout 30000"], + "playback list": ["--screen-id scr_SCREEN"], + "feedback bug": ['"Preview failed" --body-file report.md'], + "feedback feature": ['"New workflow" --body-file request.md'], "feedback list": ["--kind bug"], + "doctor": [""], "version": [""], "recovery list": [""], + "recovery show": ["RECOVERY_ID"], "recovery reconcile": ["RECOVERY_ID"], + }; + for (const [path, suffixes] of Object.entries(examples)) { + const command = findCommand(root, path.split(" ")); + if (!command) throw new Error(`Unknown example command ${path}`); + if (!commandExamples(command).length) addCommandExamples(command, ...suffixes.map(suffix => `screenrig ${path}${suffix ? ` ${suffix}` : ""}`)); + } + const notes: Record = { + "agent enroll": "For a new account, supply the user's contact email with --email. Existing accounts use agent connect. An already enrolled installation can reuse its saved enrollment.", + "app pack": "Local and unauthenticated. Pack an already-built static directory with a root index.html. app upload packs the directory itself; packing first is optional.", + "app upload": "Creates a new application and its first immutable release. Packs the built directory and waits for processing by default (120000 ms, polling every 1000 ms). With --no-wait, use operations wait on the returned operation_id before using the release.", + "app update": "Creates a new immutable release for the same application and preserves its K/V. Existing playlists remain pinned to their previous release. Use playlist replace-release to preview and apply an explicit replacement. Wait defaults: 120000 ms, polling every 1000 ms.", + "media upload": "Transcodes by default and requires ffmpeg and ffprobe. --no-transcode uploads accepted bytes unchanged. Waits for processing by default (120000 ms, polling every 1000 ms). For multiple files use media upload-batch with a manifest and state file.", + "media upload-batch": "Reuse the manifest and state file to resume. Default concurrency is 4 (range 1–8). Transcodes by default; ffmpeg and ffprobe are required unless --no-transcode is used. Upload polling defaults to 1000 ms.", + "playlist validate": "Local schema and semantic validation; no authentication required. --lint-only is accepted for compatibility: this command always validates locally. Authorization and readiness still require server checks.", + "playlist create": "Creates a new remote playlist without assigning screens. Supply a canonical {name, pages} document, not an inspection envelope. Names are unique per account. Use playlist init to prepare a document or screen publish to create and assign it.", + "playlist update": "Replaces the authored document and affects every assigned screen. First use playlist show ID --output FILE; edit that document and supply the returned playlist revision. Output files must not already exist.", + "playlist show": "Plain show is an inspection response. --editable returns data.document and revision; --output writes the editable document to a new file and reports the revision on stdout. When both are supplied, --output takes precedence.", + "playlist export": "The output directory must not exist. Exports referenced images and videos; application primitives cannot be exported. Dynamic selectors are snapshotted to explicit media IDs.", + "playlist import": "Creates a new playlist by default. For a copy in the same account, choose a different --name. Updating an existing playlist requires --update and --expect-rev together and affects all its assigned screens.", + "screen publish": "Creates a new playlist and assigns it; does not update an existing playlist by name. Use the screen revision from screen show or playlist init --screen-id. Inspect the prepared document and preview first. Repeat identical input to resume an ambiguous failure. After a revision conflict inspect the screen and follow the returned recovery instructions. Assignment readback does not prove playback; request and inspect screen screenshot separately.", + "screen assign": "Assigns an existing playlist. Obtain the expected screen revision from screen show. Scheduled playlists require the screen timezone. Use screen update to set timezone and assignment together.", + "screen screenshot": "Requests, waits for, and downloads a screenshot. Default wait is 35000 ms and polling interval is 500 ms. Stdout reports the saved path, never image bytes. Inspect the downloaded image to assess playback.", + "kv get": "Returns bytes as data.value_base64, including JSON values. Decode base64 and parse JSON only when data.content_type indicates JSON. kv list omits values.", + "kv set": "--json-value encodes JSON directly. --file reads a local file (not stdin); --value-base64 accepts canonical padded base64, including empty bytes. These two byte modes require --content-type. Supply --expect-rev when updating an existing key.", + "comment set": "Replaces comments with a JSON value from --json-value or a local --file (not stdin). Last write wins; comments do not take --expect-rev or bump the resource revision.", + "operations wait": "Wait defaults to 120000 ms, polling every 1000 ms. --timeout sets the wait budget. Use the operation_id returned by an accepted asynchronous command.", + "events follow": "Writes NDJSON: one JSON envelope per event on stdout. --after resumes from a cursor; --cursor is a compatibility alias. --timeout is in milliseconds; zero or omitted leaves the stream unbounded.", + }; + for (const [path, note] of Object.entries(notes)) { + const command = findCommand(root, path.split(" ")); + if (!command) throw new Error(`Unknown guidance command ${path}`); + addCommandNotes(command, note); + if (path === "comment set") for (const child of command.commands) addCommandNotes(child, note); + } +} diff --git a/src/cli-commands/index.ts b/src/cli-commands/index.ts index 6e7f0064..86834a9d 100644 --- a/src/cli-commands/index.ts +++ b/src/cli-commands/index.ts @@ -1,3 +1,4 @@ +import { registerGuidance } from "./guidance.js"; import { registerGlobalOptions } from "./globals.js"; import type { CommandActionBinder } from "./types.js"; import type { Command } from "commander"; @@ -40,4 +41,5 @@ export function registerCommands(root: Command, bind: CommandActionBinder): void registerDoctorCommands(root, bind); registerVersionCommands(root, bind); registerRecoveryCommands(root, bind); + registerGuidance(root); } diff --git a/src/cli-commands/kv.ts b/src/cli-commands/kv.ts index e4502295..815394be 100644 --- a/src/cli-commands/kv.ts +++ b/src/cli-commands/kv.ts @@ -1,3 +1,5 @@ +import { addValueAlias } from "./aliases.js"; +import { requireOptionGroup } from "./notes.js"; import { revision } from "./options.js"; import type { CommandActionBinder } from "./types.js"; import { handleKvGet, handleKvSet, handleKvDelete, handleKvList } from "../commands.js"; @@ -8,12 +10,10 @@ export function registerKvCommands(root: Command, bind: CommandActionBinder): vo kv.command("get").description("Read an application key") .argument("", "Application key") - .requiredOption("--application-id ", "Select the application's K/V namespace (required)") .action(bind(handleKvGet)); kv.command("set").description("Write a value") .argument("", "Application key") - .requiredOption("--application-id ", "Select the application's K/V namespace (required)") .addOption(new Option("--json-value ", "Supply a JSON value").conflicts(["file", "valueBase64", "contentType"])) .addOption(new Option("--file ", "Read the value from a file").conflicts(["jsonValue", "valueBase64"])) .addOption(new Option("--value-base64 ", "Supply canonical base64 bytes (may be empty)").conflicts(["jsonValue", "file"])) @@ -23,11 +23,14 @@ export function registerKvCommands(root: Command, bind: CommandActionBinder): vo kv.command("delete").description("Delete an application key") .argument("", "Application key") - .requiredOption("--application-id ", "Select the application's K/V namespace (required)") .requiredOption("--expect-rev ", "Require the current resource revision (required)", revision) .action(bind(handleKvDelete)); kv.command("list").description("List application keys") - .requiredOption("--application-id ", "Select the application's K/V namespace (required)") .action(bind(handleKvList)); + for (const command of kv.commands) addValueAlias(command, "--app-id", "--application-id", "Select the application's K/V namespace", true); + const set = kv.commands.find(command => command.name() === "set")!; + requireOptionGroup(set, "exactlyOne", ["--json-value", "--file", "--value-base64"]); + requireOptionGroup(set, "requires", ["--file", "--content-type"]); + requireOptionGroup(set, "requires", ["--value-base64", "--content-type"]); } diff --git a/src/cli-commands/media.ts b/src/cli-commands/media.ts index a2efcffc..9d6732c4 100644 --- a/src/cli-commands/media.ts +++ b/src/cli-commands/media.ts @@ -79,4 +79,5 @@ export function registerMediaCommands(root: Command, bind: CommandActionBinder): .argument("", "Media identifier") .requiredOption("--expect-rev ", "Require the current resource revision (required)", revision) .action(bind(handleMediaDelete)); + requireOptionGroup(media.commands.find(command => command.name() === "update")!, "exactlyOne", ["--tag", "--clear-tag"]); } diff --git a/src/cli-commands/notes.ts b/src/cli-commands/notes.ts index a44d1628..b3afa153 100644 --- a/src/cli-commands/notes.ts +++ b/src/cli-commands/notes.ts @@ -14,7 +14,7 @@ export function commandNotes(command: Command): string[] { } export interface OptionRelationship { - kind: "exactlyOne" | "together"; + kind: "exactlyOne" | "together" | "atLeastOne" | "requires"; options: string[]; } const relationships = new WeakMap(); @@ -30,11 +30,18 @@ export function requireOptionGroup(command: Command, kind: OptionRelationship["k relationships.set(command, [...(relationships.get(command) ?? []), { kind, options: names }]); const message = kind === "exactlyOne" ? `Provide exactly one of ${names.join(" or ")}.` + : kind === "atLeastOne" ? `Provide at least one of ${names.join(" or ")}.` + : kind === "requires" ? `${names[0]} requires ${names.slice(1).join(" and ")}.` : `Provide ${names.join(" and ")} together.`; addCommandNotes(command, message); command.hook("preAction", () => { const count = options.filter((option) => command.getOptionValueSource(option.attributeName()) === "cli").length; - if (kind === "exactlyOne" ? count !== 1 : count !== 0 && count !== options.length) throw usageError(message); + const supplied = (index: number) => command.getOptionValueSource(options[index]!.attributeName()) === "cli"; + const invalid = kind === "exactlyOne" ? count !== 1 + : kind === "atLeastOne" ? count === 0 + : kind === "requires" ? supplied(0) && options.slice(1).some((_, index) => !supplied(index + 1)) + : count !== 0 && count !== options.length; + if (invalid) throw usageError(message); }); } diff --git a/src/cli-commands/operations.ts b/src/cli-commands/operations.ts index dbed92eb..69ed3365 100644 --- a/src/cli-commands/operations.ts +++ b/src/cli-commands/operations.ts @@ -6,7 +6,7 @@ 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") + operations.command("show").alias("get").description("Inspect an operation") .argument("", "Operation identifier") .action(bind(handleOperationsGet)); diff --git a/src/cli-commands/playlist.ts b/src/cli-commands/playlist.ts index 3b464535..1e9d5bd3 100644 --- a/src/cli-commands/playlist.ts +++ b/src/cli-commands/playlist.ts @@ -1,6 +1,7 @@ +import { addValueAlias } from "./aliases.js"; import { nonnegativeInteger, positiveInteger, revision } from "./options.js"; import type { CommandActionBinder } from "./types.js"; -import { handlePlaylistInit, handlePlaylistValidate, handlePlaylistPreview, handlePlaylistTemplates, handlePlaylistCreate, handlePlaylistUpdate, handlePlaylistExport, handlePlaylistImport, handlePlaylistShow, handlePlaylistList, handlePlaylistDelete } from "../commands.js"; +import { handlePlaylistReplaceRelease, handlePlaylistInit, handlePlaylistValidate, handlePlaylistPreview, handlePlaylistTemplates, handlePlaylistCreate, handlePlaylistUpdate, handlePlaylistExport, handlePlaylistImport, handlePlaylistShow, handlePlaylistList, handlePlaylistDelete } from "../commands.js"; import { type Command, Option } from "commander"; import { addCommandNotes, addCommandExamples, requireOptionGroup } from "./notes.js"; import { LOOK_AT_THE_CONTACT_SHEET } from "../playlist-preview.js"; @@ -8,28 +9,31 @@ 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"); - const init = playlist.command("init").description("Prepare an editable full-screen playlist from ready media") - .argument("", "Media identifiers in playback order") + const init = playlist.command("init").description("Prepare an editable playlist from files, ready media, application releases, or HTTPS URLs") + .argument("", "Local image/video files, med_ IDs, rel_ IDs, or HTTPS URLs in playback order") .requiredOption("--name ", "Playlist name") .requiredOption("--output ", "Create an editable playlist file") - .option("--screen ", "Use this screen's reported playback dimensions") .option("--target-width ", "Override canvas width", positiveInteger("target-width")) .option("--target-height ", "Override canvas height", positiveInteger("target-height")) - .addOption(new Option("--duration-ms ", "Image page duration").argParser(positiveInteger("duration-ms")).default(8000)) + .addOption(new Option("--duration-ms ", "Non-video page duration").argParser(positiveInteger("duration-ms")).default(8000)) .addOption(new Option("--fit ", "Content fit").choices(["contain", "cover", "fill"]).default("contain")) + .option("--no-transcode", "Upload accepted file bytes unchanged") + .option("--no-progress", "Suppress upload progress on stderr") + .option("--poll-ms ", "Upload polling interval", positiveInteger("poll-ms")) .action(bind(handlePlaylistInit)); + addValueAlias(init, "--screen-id", "--screen", "Use this screen’s identity, revision, and reported dimensions"); requireOptionGroup(init, "together", ["--target-width", "--target-height"]); - addCommandNotes(init, "Provide --screen for reported dimensions, or both target dimensions to set the canvas explicitly."); - addCommandExamples(init, 'screenrig playlist init med_IMAGE --name Lobby --screen scr_SCREEN --output lobby.json', + addCommandNotes(init, "Provide --screen-id for target identity, revision, and reported dimensions, or both target dimensions. Files upload and wait for readiness. Release IDs are pinned; preview/server validation checks availability. Inspect the document and preview before publishing."); + addCommandExamples(init, 'screenrig playlist init med_IMAGE --name Lobby --screen-id scr_SCREEN --output lobby.json', 'screenrig playlist init med_IMAGE med_VIDEO --name Lobby --target-width 1920 --target-height 1080 --output lobby.json'); playlist.command("validate").description("Validate a playlist file") - .argument("", "Local input file") + .argument("", "Playlist JSON file, or - for stdin") .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("", "Playlist file or playlist identifier") + .argument("", "Playlist JSON file, playlist identifier, or - for stdin") .requiredOption("--output ", "Write preview files to this directory (required)") .option("--frame-ms ", "Select the preview frame time", nonnegativeInteger("frame-ms")) .option("--contact-sheet", "Include a contact sheet") @@ -40,15 +44,29 @@ export function registerPlaylistCommands(root: Command, bind: CommandActionBinde .action(bind(handlePlaylistTemplates)); playlist.command("create").description("Create a playlist from a file") - .argument("", "Local input file") + .argument("", "Playlist JSON file, or - for stdin") .action(bind(handlePlaylistCreate)); playlist.command("update").description("Update a playlist") .argument("", "Playlist identifier") - .argument("", "Local input file") + .argument("", "Playlist JSON file, or - for stdin") .requiredOption("--expect-rev ", "Require the current resource revision (required)", revision) .action(bind(handlePlaylistUpdate)); + const replaceRelease = playlist.command("replace-release").description("Preview or apply one application release replacement and its shared-screen impact") + .argument("", "Playlist identifier") + .requiredOption("--page ", "Exact page identifier") + .requiredOption("--primitive ", "Exact application primitive identifier within the page") + .requiredOption("--release-id ", "New immutable release identifier") + .option("--apply", "Apply the reviewed replacement") + .option("--expect-rev ", "Playlist revision from the preview", revision) + .option("--expect-impact ", "Impact token from the preview") + .action(bind(handlePlaylistReplaceRelease)); + requireOptionGroup(replaceRelease, "together", ["--apply", "--expect-rev", "--expect-impact"]); + addCommandNotes(replaceRelease, "Defaults to a read-only preview including active and archived assigned screens. Apply requires the preview revision and impact token. Changed impact requires a fresh review. Screen assignments are a snapshot; only playlist revision is checked atomically. Server validates release availability and ownership on apply."); + addCommandExamples(replaceRelease, 'screenrig playlist replace-release pl_PLAYLIST --page board-page --primitive board --release-id rel_NEW', + 'screenrig playlist replace-release pl_PLAYLIST --page board-page --primitive board --release-id rel_NEW --apply --expect-rev 4 --expect-impact TOKEN'); + playlist.command("export").description("Export a playlist bundle") .argument("", "Playlist identifier") .requiredOption("--output ", "Write the exported bundle to this directory (required)") diff --git a/src/cli-commands/screen.ts b/src/cli-commands/screen.ts index 211b0a1b..a6c7a005 100644 --- a/src/cli-commands/screen.ts +++ b/src/cli-commands/screen.ts @@ -1,13 +1,14 @@ -import { addCommandExamples, addCommandNotes } from "./notes.js"; +import { addValueAlias } from "./aliases.js"; +import { addCommandExamples, addCommandNotes, requireOptionGroup } from "./notes.js"; import { positiveInteger, revision, toastDuration } from "./options.js"; import type { CommandActionBinder } from "./types.js"; import { handleScreenPublish, handleScreenPair, handleScreenProvision, handleScreenUpdate, handleScreenList, handleScreenShow, handleScreenAssign, handleScreenSetTimezone, handleScreenArchive, handleScreenUnarchive, handleScreenDelete, handleScreenRotatePublicId, handleScreenRecover, handleScreenToast, handleScreenScreenshot } from "../commands.js"; -import type { Command } from "commander"; +import { type Command, Option } from "commander"; export function registerScreenCommands(root: Command, bind: CommandActionBinder): void { const screen = root.command("screen").description("Pair, configure, and inspect screens"); - screen.command("publish").description("Create a prepared playlist and assign it, with resumable recovery") + screen.command("publish").description("Create a new playlist from a prepared file and assign it to a screen") .argument("", "Screen identifier") .argument("", "Prepared playlist file, or - for stdin") .requiredOption("--expect-rev ", "Expected screen revision", revision) @@ -15,13 +16,11 @@ export function registerScreenCommands(root: Command, bind: CommandActionBinder) screen.command("pair").description("Claim a Player pairing code") .argument("", "Player pairing code") - .option("--label