From ff104e6107a48d4c2ddcdf6ccdc3126cd8be87cb Mon Sep 17 00:00:00 2001 From: Kurtis Jantzen Date: Fri, 11 Sep 2026 12:18:15 -0700 Subject: [PATCH 1/5] Extend playlist preparation to files, releases, and URLs --- README.md | 36 +++++++++++++--- src/cli-commands/playlist.ts | 11 +++-- src/commands.ts | 75 ++++++++++++++++++++++++++++------ src/help.test.ts | 2 +- src/playlist-authoring.ts | 18 ++++++-- src/playlist-workflows.test.ts | 65 ++++++++++++++++++++++++++++- 6 files changed, 179 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 1547557f..9367c5c7 100644 --- a/README.md +++ b/README.md @@ -180,12 +180,36 @@ 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 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. +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`, 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` (no publish arguments). Unknown or multiple reported surfaces require explicit dimensions. Output files must not already exist. diff --git a/src/cli-commands/playlist.ts b/src/cli-commands/playlist.ts index 3b464535..a18d589c 100644 --- a/src/cli-commands/playlist.ts +++ b/src/cli-commands/playlist.ts @@ -8,18 +8,21 @@ 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)); requireOptionGroup(init, "together", ["--target-width", "--target-height"]); - addCommandNotes(init, "Provide --screen for reported dimensions, or both target dimensions to set the canvas explicitly."); + addCommandNotes(init, "Provide --screen 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 scr_SCREEN --output lobby.json', 'screenrig playlist init med_IMAGE med_VIDEO --name Lobby --target-width 1920 --target-height 1080 --output lobby.json'); diff --git a/src/commands.ts b/src/commands.ts index f413b721..ddf069fe 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -1,6 +1,6 @@ import { publishScreen } from "./screen-publish.js"; import { readAuthoringJson, readAuthoringText, writeAuthoringJson } from "./authoring-input.js"; -import { editablePlaylist, initializePlaylist, targetDimensions } from "./playlist-authoring.js"; +import { editablePlaylist, preparePlaylist, targetDimensions } from "./playlist-authoring.js"; import { WriteRecovery } from "./write-recovery.js"; import { createHash } from "node:crypto"; import { open, readFile, rename, rm, stat, writeFile } from "node:fs/promises"; @@ -4092,23 +4092,74 @@ async function doctor( export type { Operation }; +function sanitizedPreparationApiUrl(value: string): string { + const url = new URL(value); url.username = ""; url.password = ""; url.search = ""; url.hash = ""; + return url.toString().replace(/\/$/, ""); +} + export const handlePlaylistInit = commandHandler(async (args, runtime, resolved) => { const client = clientFor(runtime, args, resolved.apiUrl, requireToken(resolved.token)); const screenId = flagString(args.flags, "screen"); const width = flagNumber(args.flags, "target-width"), height = flagNumber(args.flags, "target-height"); const screen = screenId ? (await client.call({ method: "GET", path: `/api/v1/screens/${encodeURIComponent(screenId)}` })).body : undefined; const dimensions = targetDimensions(screen, width, height); - const media = []; - for (const id of args.positionals.slice(2)) { - if (!/^med_[A-Za-z0-9_-]+$/.test(id)) throw usageError("playlist init requires media identifiers."); - const response = await client.call({ method: "GET", path: `/api/v1/media/${id}` }); - const record = response.body as Record; - if (record.id !== id) throw usageError("Media response identity did not match."); - media.push(record); - } - const document = initializePlaylist({ name: flagString(args.flags, "name")!, media, ...dimensions, durationMs: flagNumber(args.flags, "duration-ms") ?? 8000, fit: flagString(args.flags, "fit") ?? "contain" }); - const output = await writeAuthoringJson(flagString(args.flags, "output")!, document, runtime); - return { envelope: successEnvelope({ output, ...dimensions, page_count: media.length }, { request_id: client.requestId }), exitCode: ExitCode.Success, human: `Playlist prepared at ${output}` }; + const target = screen as { id: string; revision: number } | undefined; + if (screenId && (!target || target.id !== screenId || !Number.isSafeInteger(target.revision) || target.revision < 1)) { + throw usageError("Screen response has invalid identity or revision."); + } + const content: Record[] = []; + const files = new Map(); + const warnings: Warning[] = []; + for (const input of args.positionals.slice(2)) { + if (/^med_[A-Za-z0-9_-]+$/.test(input)) { + const record = (await client.call({ method: "GET", path: `/api/v1/media/${input}` })).body as Record; + if (record?.id !== input) throw usageError("Media response identity did not match."); + content.push(record); + } else if (/^rel_[A-Za-z0-9_-]+$/.test(input)) { + content.push({ primitive: "application", release_id: input }); + } else if (/^[a-z][a-z0-9+.-]*:/i.test(input)) { + let url: URL; + try { url = new URL(input); } catch { throw usageError("Provide a valid HTTPS iframe URL."); } + if (url.protocol !== "https:" || url.username || url.password) throw usageError("Iframe URLs must use HTTPS without credentials."); + content.push({ primitive: "iframe", src: url.href, title: url.hostname.slice(0, 200) }); + } else { + const sourcePath = path.resolve(runtime.cwd(), input); + try { if (!(await stat(sourcePath)).isFile()) throw new Error(); } + catch { throw usageError("Each input must be a readable image/video file, media ID, release ID, or HTTPS URL."); } + files.set(content.length, sourcePath); + content.push({ primitive: "image", state: "ready", id: "med_PENDING" }); + } + } + const options = { name: flagString(args.flags, "name")!, content, ...dimensions, durationMs: flagNumber(args.flags, "duration-ms") ?? 8000, fit: flagString(args.flags, "fit") ?? "contain" }; + // Validate the entire authoring shape and reserve the output before uploading. + preparePlaylist(options); + const output = path.resolve(runtime.cwd(), flagString(args.flags, "output")!); + let handle; + try { handle = await open(output, "wx", 0o600); } + catch { throw usageError("Cannot create output; choose a new file in an existing directory."); } + try { + for (const [index, sourcePath] of files) { + const uploaded = await loggerOf(runtime).withLocal({ op: "media.upload", message: "media upload" }, () => uploadMediaFile({ runtime, client, sourcePath, + transcodeOptions: transcodeOptionsFromArgs(args), noTranscode: flagBool(args.flags, "no-transcode"), + reporter: progressReporterFor(args, runtime), noWait: false, + timeoutMs: flagNumber(args.flags, "timeout") ?? 120_000, pollMs: flagNumber(args.flags, "poll-ms") ?? 1000 })); + warnings.push(...uploaded.warnings); + if (!uploaded.mediaId) throw usageError("Upload completed without a media identifier."); + const record = (await client.call({ method: "GET", path: `/api/v1/media/${encodeURIComponent(uploaded.mediaId)}` })).body as Record; + if (record?.id !== uploaded.mediaId) throw usageError("Media response identity did not match."); + content[index] = record; + } + await handle.writeFile(JSON.stringify(preparePlaylist(options), null, 2) + "\n"); + } catch (error) { + await handle.close(); await rm(output, { force: true }); throw error; + } + await handle.close(); + const context = ["--config", resolved.configPath, "--api-url", sanitizedPreparationApiUrl(resolved.apiUrl)]; + return { envelope: successEnvelope({ output, ...dimensions, page_count: content.length, + ...(target ? { screen_id: target.id, screen_revision: target.revision } : {}), + preview: { argv: ["playlist", "preview", output, "--output", `${output}.preview`, "--contact-sheet", ...context] }, + ...(target ? { publish: { argv: ["screen", "publish", target.id, output, "--expect-rev", String(target.revision), ...context], reason: "Inspect the prepared document and preview before publishing." } } : {}), + }, { request_id: client.requestId, warnings }), exitCode: ExitCode.Success, human: `Playlist prepared at ${output}` }; }); export const handleScreenPublish = commandHandler(async (args, runtime, resolved) => { diff --git a/src/help.test.ts b/src/help.test.ts index 9d22aadd..9cb040e3 100644 --- a/src/help.test.ts +++ b/src/help.test.ts @@ -200,7 +200,7 @@ test("negative switches are documented as booleans without value placeholders", test("structured discovery exposes positional cardinality, choices, defaults and examples", () => { const init = invoke(["playlist", "init", "--help"]).data; - assert.deepEqual(init.arguments, [{ name: "media-ids", description: "Media identifiers in playback order", required: true, variadic: true }]); + assert.deepEqual(init.arguments, [{ name: "inputs", description: "Local image/video files, med_ IDs, rel_ IDs, or HTTPS URLs in playback order", required: true, variadic: true }]); assert.deepEqual(init.options.find((option) => option.name === "--fit")?.choices, ["contain", "cover", "fill"]); assert.equal(init.options.find((option) => option.name === "--duration-ms")?.default, 8000); assert.ok(init.examples.length > 0); diff --git a/src/playlist-authoring.ts b/src/playlist-authoring.ts index 25067f23..e4f1b5cb 100644 --- a/src/playlist-authoring.ts +++ b/src/playlist-authoring.ts @@ -31,8 +31,16 @@ export function editablePlaylist(value: unknown): RecordValue { export function initializePlaylist(options: { name: string; media: RecordValue[]; width: number; height: number; durationMs: number; fit: string; }): RecordValue { - const document = { name: options.name, pages: options.media.map((media, index) => { - if (media.state !== "ready" || !["image", "video"].includes(media.primitive)) throw usageError("Every media item must be a ready image or video."); + return preparePlaylist({ ...options, content: options.media }); +} + +/** Build the backend-owned write document; target metadata stays outside it. */ +export function preparePlaylist(options: { + name: string; content: RecordValue[]; width: number; height: number; durationMs: number; fit: string; +}): RecordValue { + const document = { name: options.name, pages: options.content.map((media, index) => { + const live = media.primitive === "iframe" || media.primitive === "application"; + if (!live && (media.state !== "ready" || !["image", "video"].includes(media.primitive))) throw usageError("Every media item must be a ready image or video."); const video = media.primitive === "video"; return { id: `page_${index + 1}`, @@ -40,8 +48,10 @@ export function initializePlaylist(options: { transition: { type: "crossfade", duration_ms: 200 }, advance: video ? { mode: "media_end" } : { mode: "duration", after_ms: options.durationMs }, primitives: [{ id: "content", primitive: media.primitive, - selector: { by: "id", media_id: media.id }, - rect: { x: 0, y: 0, width: options.width, height: options.height }, layer: 0, content_fit: options.fit, + ...(media.primitive === "iframe" ? { src: media.src, title: media.title } + : media.primitive === "application" ? { release_id: media.release_id } + : { selector: { by: "id", media_id: media.id } }), + rect: { x: 0, y: 0, width: options.width, height: options.height }, layer: 0, content_fit: live ? "fill" : options.fit, ...(video ? { muted: true, loop: false } : {}), }], }; diff --git a/src/playlist-workflows.test.ts b/src/playlist-workflows.test.ts index 4156069a..e557a257 100644 --- a/src/playlist-workflows.test.ts +++ b/src/playlist-workflows.test.ts @@ -4,7 +4,7 @@ import { mkdtemp, writeFile, readFile, rm } from "node:fs/promises"; import { Writable, Readable } from "node:stream"; import { run, processRuntime } from "./main.js"; import { initializePlaylist, editablePlaylist, targetDimensions } from "./playlist-authoring.js"; -import { FakeTransport } from "./transport/fake.js"; +import { FakeTransport, memoryBackend } from "./transport/fake.js"; import { parseArgv } from "./argv.js"; import { publishScreen } from "./screen-publish.js"; import { ApiClient } from "./client.js"; @@ -178,3 +178,66 @@ test("publish recovery arguments preserve paths with spaces without exposing URL return true; }); }); + +test("mixed preparation carries target revision without publishing and guards uploads", async t => { + const dir = await mkdtemp('/tmp/prepare mixed-'); t.after(() => rm(dir, {recursive:true,force:true})); + const config = dir + '/config.json'; + await writeFile(config, JSON.stringify({api_url:'https://api.screenrig.ai',token:'test-token'}), {mode:0o600}); + let mediaState = "ready", targetId = "scr_TEST"; + const transport = new FakeTransport() + .on('GET','/api/v1/screens/scr_TEST',()=>response({id:targetId,revision:7,observation:{surfaces:[{width:1080,height:1920}]}})) + .on('GET','/api/v1/media/med_IMAGE',()=>response({...media[0],state:mediaState})); + async function invoke(inputs:string[], output='prepared.json') { + let out=''; const code = await run({...processRuntime(),argv:['--config',config,'playlist','init',...inputs,'--name','Lobby','--screen','scr_TEST','--output',output],env:{},cwd:()=>dir,transport, + stdout:new Writable({write(c,e,d){out+=c;d();}}),stderr:new Writable({write(c,e,d){d();}})}); + return {code,body:JSON.parse(out)}; + } + const result = await invoke(['med_IMAGE','rel_TEST','https://example.com/board']); + assert.equal(result.code,0,JSON.stringify(result.body)); + assert.equal(result.body.data.screen_id,'scr_TEST'); assert.equal(result.body.data.screen_revision,7); + const doc = JSON.parse(await readFile(dir+'/prepared.json','utf8')); + assert.deepEqual(Object.keys(doc).sort(),['name','pages']); + assert.deepEqual(doc.pages.map((p:any)=>p.primitives[0].primitive),['image','application','iframe']); + assert.equal(doc.pages[1].primitives[0].release_id,'rel_TEST'); + assert.equal(doc.pages[1].primitives[0].content_fit,'fill'); + assert.deepEqual(doc.pages[1].advance,{mode:'duration',after_ms:8000}); + assert.equal(doc.pages[2].primitives[0].src,'https://example.com/board'); + const publish=parseArgv(result.body.data.publish.argv); + assert.equal(publish.flags['if-match'],'7'); assert.equal(publish.positionals[3],dir+'/prepared.json'); + assert.equal(parseArgv(result.body.data.preview.argv).positionals[2],dir+'/prepared.json'); + assert.equal((await invoke(['https://user:secret@example.com'],'bad.json')).code,2); + assert.equal((await invoke(['http://example.com'],'bad.json')).code,2); + assert.equal((await invoke(['med_IMAGE'])).code,2); + mediaState = 'processing'; + assert.equal((await invoke(['med_IMAGE'],'not-ready.json')).code,2); + targetId = 'scr_OTHER'; + assert.equal((await invoke(['rel_TEST'],'wrong-target.json')).code,2); + assert.equal(transport.calls.some(c=>c.method!=='GET'),false); +}); + +test("file preparation uses upload readiness and protects existing output before remote writes", async t => { + const dir=await mkdtemp('/tmp/prepare-upload-'); t.after(()=>rm(dir,{recursive:true,force:true})); + const config=dir+'/config.json'; await writeFile(config,JSON.stringify({api_url:'https://api.screenrig.ai',token:'test-token'}),{mode:0o600}); + await writeFile(dir+'/poster.mp4',Buffer.from([0,0,0,24,102,116,121,112])); + const transport=memoryBackend(); + let puts=0; + async function invoke() { + let out='';const code=await run({...processRuntime(),argv:['--config',config,'playlist','init','./poster.mp4','rel_TEST','--name','Lobby','--output','prepared.json','--target-width','1920','--target-height','1080','--no-transcode','--no-progress'],env:{},cwd:()=>dir,transport,sleep:async()=>{},signedRawPut:async()=>{puts++;return {status:200};}, + stdout:new Writable({write(c,e,d){out+=c;d();}}),stderr:new Writable({write(c,e,d){d();}})}); + return {code,body:JSON.parse(out)}; + } + const result=await invoke();assert.equal(result.code,0,JSON.stringify(result.body)); + const doc=JSON.parse(await readFile(dir+'/prepared.json','utf8')); + assert.equal(doc.pages[0].primitives[0].selector.media_id,'med_AAAAAAAAAAAAAAAAAAAAAAAA'); + assert.equal(doc.pages[1].primitives[0].release_id,'rel_TEST'); + assert.equal(result.body.data.publish,undefined); assert.equal(puts,1); + assert.ok(transport.calls.some(c=>c.path.startsWith('/api/v1/operations/'))); + assert.equal(transport.calls.some(c=>c.path==='/api/v1/playlists'),false); + const writes=transport.calls.filter(c=>c.method!=='GET').length; + assert.equal((await invoke()).code,2);assert.equal(puts,1); + assert.equal(transport.calls.filter(c=>c.method!=='GET').length,writes); + await rm(dir+'/prepared.json'); + await rm(dir+'/poster.mp4'); + assert.equal((await invoke()).code,2); + await assert.rejects(readFile(dir+'/prepared.json')); +}); From 773602f0b2c26c800a7da3d937b822f5883a43d3 Mon Sep 17 00:00:00 2001 From: Kurtis Jantzen Date: Fri, 11 Sep 2026 12:25:52 -0700 Subject: [PATCH 2/5] Add reviewed playlist release replacement workflow --- README.md | 24 +++++++++++ src/cli-commands/playlist.ts | 15 ++++++- src/commands.ts | 12 ++++++ src/help.test.ts | 1 + src/playlist-release.test.ts | 77 ++++++++++++++++++++++++++++++++++++ src/playlist-release.ts | 61 ++++++++++++++++++++++++++++ 6 files changed, 189 insertions(+), 1 deletion(-) create mode 100644 src/playlist-release.test.ts create mode 100644 src/playlist-release.ts diff --git a/README.md b/README.md index 9367c5c7..fb1dce3f 100644 --- a/README.md +++ b/README.md @@ -249,6 +249,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/playlist.ts b/src/cli-commands/playlist.ts index a18d589c..0a16848a 100644 --- a/src/cli-commands/playlist.ts +++ b/src/cli-commands/playlist.ts @@ -1,6 +1,6 @@ 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"; @@ -52,6 +52,19 @@ export function registerPlaylistCommands(root: Command, bind: CommandActionBinde .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)); + 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/commands.ts b/src/commands.ts index ddf069fe..01dc7171 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -1,3 +1,4 @@ +import { replacePlaylistRelease } from "./playlist-release.js"; import { publishScreen } from "./screen-publish.js"; import { readAuthoringJson, readAuthoringText, writeAuthoringJson } from "./authoring-input.js"; import { editablePlaylist, preparePlaylist, targetDimensions } from "./playlist-authoring.js"; @@ -2744,6 +2745,17 @@ export const handlePlaylistUpdate = commandHandler(async (args, runtime, resolve return playlistCreateUpdateAction(args, runtime, resolved, "update"); }, true); +export const handlePlaylistReplaceRelease = commandHandler(async (args, runtime, resolved) => { + const client = clientFor(runtime, args, resolved.apiUrl, requireToken(resolved.token)); + const result = await replacePlaylistRelease({ client, apiUrl: resolved.apiUrl, + playlistId: args.positionals[2]!, pageId: flagString(args.flags, "page")!, + primitiveId: flagString(args.flags, "primitive")!, releaseId: flagString(args.flags, "release-id")!, + apply: flagBool(args.flags, "apply"), revision: flagString(args.flags, "if-match"), + impact: flagString(args.flags, "expect-impact"), + }); + return { envelope: successEnvelope(result, { request_id: client.requestId }), exitCode: ExitCode.Success, human: JSON.stringify(result, null, 2) }; +}, true); + export const handlePlaylistDelete = commandHandler(async (args, runtime, resolved) => { const token = requireToken(resolved.token); const client = clientFor(runtime, args, resolved.apiUrl, token); diff --git a/src/help.test.ts b/src/help.test.ts index 9cb040e3..4c56c5a9 100644 --- a/src/help.test.ts +++ b/src/help.test.ts @@ -62,6 +62,7 @@ test("root help is compact and every command is discoverable through immediate c "playlist init", "playlist validate", "playlist preview", + "playlist replace-release", "playlist templates", "playlist create", "playlist update", diff --git a/src/playlist-release.test.ts b/src/playlist-release.test.ts new file mode 100644 index 00000000..d47e4a58 --- /dev/null +++ b/src/playlist-release.test.ts @@ -0,0 +1,77 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { ApiClient } from "./client.js"; +import { FakeTransport } from "./transport/fake.js"; +import { preparePlaylist } from "./playlist-authoring.js"; +import { replacePlaylistRelease } from "./playlist-release.js"; +import { parseArgv } from "./argv.js"; + +const response = (body: unknown) => ({ status: 200, headers: {}, body }); +function fixture() { + const document = preparePlaylist({ name: "Board", content: [{ primitive: "application", release_id: "rel_OLD" }, { primitive: "application", release_id: "rel_OLD" }], width: 1920, height: 1080, durationMs: 8000, fit: "fill" }); + document.pages[0].primitives[0].application_id = "app_BOARD"; + document.pages[0].visibility = { enabled: true, from: "2026-09-10T09:00" }; + let revision = 4; + let failure = false; + let malformed = false; + const screens = [ { id: "scr_A", label: "Lobby", state: "active", revision: 3, playlist_id: "pl_BOARD" }, { id: "scr_B", label: "Stored", state: "archived", revision: 7, playlist_id: "pl_BOARD" }, { id: "scr_C", label: "Other", state: "active", revision: 2, playlist_id: "pl_OTHER" } ]; + const transport = new FakeTransport() + .on("GET", "/api/v1/playlists/pl_BOARD", () => response({ ...document, id: "pl_BOARD", revision })) + .on("GET", "/api/v1/screens", req => response(malformed ? {} : { items: screens.filter(s => (s.state === "archived") === (req.query?.state === "archived")) })) + .on("PUT", "/api/v1/playlists/pl_BOARD", req => failure ? { status: 409, headers: {}, body: { code: "revision_conflict", title: "Conflict", status: 409, detail: "Playlist changed", current_revision: 5 } } : response({ ...(req.body as object), id: "pl_BOARD", revision: 5 })); + const options = { client: new ApiClient({ transport }), apiUrl: "https://api.screenrig.ai", playlistId: "pl_BOARD", pageId: "page_1", primitiveId: "content", releaseId: "rel_NEW", apply: false }; + return { document, transport, options, screens, changeRevision: () => revision++, failWrite: () => failure = true, malformedList: () => malformed = true }; +} + +test("preview is read-only and apply changes only the explicitly scoped pin", async () => { + const f = fixture(); + const preview = await replacePlaylistRelease(f.options); + assert.equal(preview.applied, false); + assert.deepEqual(preview.affected_screens.map(s => s.id), ["scr_A", "scr_B"]); + assert.equal(preview.previous_release_id, "rel_OLD"); + assert.ok(f.transport.calls.every(c => c.method === "GET")); + const applied = await replacePlaylistRelease({ ...f.options, apply: true, revision: String(preview.revision), impact: preview.impact }); + assert.equal(applied.applied, true); + const writes = f.transport.calls.filter(c => c.method === "PUT"); + assert.equal(writes.length, 1); + assert.equal(writes[0]!.headers?.["if-match"], '"4"'); + assert.ok(writes[0]!.headers?.["idempotency-key"]); + const expected = structuredClone(f.document); + expected.pages[0].primitives[0].release_id = "rel_NEW"; + assert.deepEqual(writes[0]!.body, expected); + assert.equal(f.document.pages[0].primitives[0].release_id, "rel_OLD"); +}); + +for (const change of ["revision", "assignment", "release", "page", "malformed"] as const) test(`apply refuses changed ${change} before any write`, async () => { + const f = fixture(); + const preview = await replacePlaylistRelease(f.options); + if (change === "revision") f.changeRevision(); + if (change === "assignment") f.screens[2]!.playlist_id = "pl_BOARD"; + if (change === "malformed") f.malformedList(); + await assert.rejects(() => replacePlaylistRelease({ ...f.options, apply: true, revision: "4", impact: preview.impact, + ...(change === "release" ? { releaseId: "rel_DIFFERENT" } : {}), ...(change === "page" ? { pageId: "page_2" } : {}) })); + assert.ok(f.transport.calls.every(c => c.method === "GET")); +}); + +test("server revision race is surfaced without a retry", async () => { + const f = fixture(); const preview = await replacePlaylistRelease(f.options); f.failWrite(); + await assert.rejects(() => replacePlaylistRelease({ ...f.options, apply: true, revision: "4", impact: preview.impact }), (error: any) => error.problem.code === "revision_conflict"); + assert.equal(f.transport.calls.filter(c => c.method === "PUT").length, 1); +}); + +test("missing review, wrong target and no-op never write", async () => { + for (const override of [{ apply: true }, { revision: "4" }, { pageId: "missing" }, { primitiveId: "missing" }, { releaseId: "rel_OLD" }]) { + const f = fixture(); + await assert.rejects(() => replacePlaylistRelease({ ...f.options, ...override })); + assert.ok(f.transport.calls.every(c => c.method === "GET")); + } + const f = fixture(); f.document.pages[0].primitives[0] = { id: "content", primitive: "iframe", src: "https://example.com", title: "Example", rect: { x: 0, y: 0, width: 1920, height: 1080 }, layer: 0, content_fit: "fill" }; + await assert.rejects(() => replacePlaylistRelease(f.options), /application/); +}); + +test("CLI exposes explicit targets and revision aliases", () => { + const argv = ["playlist", "replace-release", "pl_BOARD", "--page", "page_1", "--primitive", "content", "--release-id", "rel_NEW"]; + assert.equal(parseArgv(argv).flags.page, "page_1"); + assert.equal(parseArgv([...argv, "--apply", "--expect-rev", "4", "--expect-impact", "token"]).flags["if-match"], "4"); + assert.throws(() => parseArgv(["playlist", "replace-release", "pl_BOARD"])); +}); diff --git a/src/playlist-release.ts b/src/playlist-release.ts new file mode 100644 index 00000000..87080e5d --- /dev/null +++ b/src/playlist-release.ts @@ -0,0 +1,61 @@ +import { createHash } from "node:crypto"; +import { ApiClient } from "./client.js"; +import { editablePlaylist } from "./playlist-authoring.js"; +import { assertPlaylistValid } from "./playlist-validate.js"; +import { quotedRevision } from "./if-match.js"; +import { CliError, makeProblem, usageError } from "./problems.js"; +import type { Screen } from "./adapters/protocol.js"; + +/** Review is a snapshot; the existing API atomically guards playlist revision only. */ +export async function replacePlaylistRelease(options: { + client: ApiClient; apiUrl: string; playlistId: string; pageId: string; + primitiveId: string; releaseId: string; apply: boolean; + revision?: string; impact?: string; +}) { + const { client, playlistId, pageId, primitiveId, releaseId } = options; + if (options.apply ? !options.revision || !options.impact : options.revision || options.impact) { + throw usageError("Preview without write flags first. Apply requires --apply, --expect-rev and --expect-impact from that preview."); + } + const response = await client.call({ method: "GET", path: `/api/v1/playlists/${playlistId}` }); + const resource = response.body as { id: string; revision: number }; + if (resource?.id !== playlistId || !Number.isSafeInteger(resource.revision) || resource.revision < 1) throw usageError("Playlist response has invalid identity or revision."); + if (options.apply && options.revision !== String(resource.revision)) { + throw new CliError(makeProblem("revision_conflict", "Playlist changed", 409, "Preview the replacement again before applying.", { current_revision: resource.revision })); + } + const document = editablePlaylist(response.body); + const pages = document.pages.filter((page: { id: string }) => page.id === pageId); + if (pages.length !== 1) throw usageError("Page ID must identify exactly one page in this playlist."); + const primitives = pages[0].primitives.filter((primitive: { id: string }) => primitive.id === primitiveId); + if (primitives.length !== 1 || primitives[0].primitive !== "application") throw usageError("Primitive ID must identify exactly one application on the selected page."); + const primitive = primitives[0]; + const previousRelease = primitive.release_id; + if (previousRelease === releaseId) throw usageError("The selected primitive already pins this release."); + primitive.release_id = releaseId; + assertPlaylistValid(document); + const screens: Pick[] = []; + for (const query of [undefined, { state: "archived" }]) { + const listed = await client.call({ method: "GET", path: "/api/v1/screens", query }); + const items = (listed.body as { items?: Screen[] })?.items; + if (!Array.isArray(items)) throw usageError("Cannot determine affected screens: invalid screen list."); + for (const screen of items) { + if (screen.playlist_id !== playlistId) continue; + if (typeof screen.id !== "string" || !Number.isSafeInteger(screen.revision) || screen.revision < 1 || !["active", "pairing_pending", "archived"].includes(screen.state)) throw usageError("Cannot determine affected screens: invalid screen identity, state or revision."); + if (screens.some(item => item.id === screen.id)) throw usageError("Screen state changed while listing impact. Preview again."); + screens.push({ id: screen.id, label: screen.label, state: screen.state, revision: screen.revision }); + } + } + screens.sort((a, b) => a.id.localeCompare(b.id)); + const review = { + playlist_id: playlistId, playlist_name: document.name, revision: resource.revision, + page_id: pageId, primitive_id: primitiveId, + previous_release_id: previousRelease, release_id: releaseId, + affected_screens: screens, + consequence: "Every assigned screen uses this shared playlist. Archived screens retain the new pin for later use. Screen assignments may change after this snapshot; only the playlist revision is checked atomically. Release availability and ownership are checked by the server on apply.", + }; + const impact = createHash("sha256").update(JSON.stringify({ api_url: options.apiUrl, review, document })).digest("hex"); + if (!options.apply) return { ...review, impact, applied: false }; + if (options.impact !== impact) throw usageError("Replacement or affected screens changed. Preview again and review the new impact before applying."); + const updated = await client.call({ method: "PUT", path: `/api/v1/playlists/${playlistId}`, idempotent: true, + headers: { "if-match": quotedRevision(options.revision!) }, body: document }); + return { ...review, impact, applied: true, playlist: updated.body }; +} From 3e571d0e43cf740728208086d24df46d7ff073b1 Mon Sep 17 00:00:00 2001 From: Kurtis Jantzen Date: Fri, 11 Sep 2026 12:37:24 -0700 Subject: [PATCH 3/5] Make agent connect return promptly by default --- README.md | 27 +++++++++++++++-------- src/cli-commands/agent.ts | 5 +++-- src/cli.test.ts | 45 +++++++++++++++++++++++++++++++++++---- src/command-path.ts | 3 +++ src/commander.test.ts | 8 +++++++ src/commands.ts | 22 +++++++++++-------- 6 files changed, 86 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index fb1dce3f..107d2cae 100644 --- a/README.md +++ b/README.md @@ -82,21 +82,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 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.test.ts b/src/cli.test.ts index 8897c175..aa4d40b4 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -511,6 +511,38 @@ test("agent connect rejects waits beyond one day before network access", async ( await rm(result.configDir, { recursive: true, force: true }); }); +for (const waitFlags of [[], ["--no-wait", "--timeout", "30000"], ["--wait", "--timeout", "10"]]) { + test(`agent connect ${waitFlags.join(" ")} returns resumable pending when the snapshot stalls`, async () => { + const transport = new FakeTransport(); + transport.on("POST", "/api/v1/agent-connections", () => ({ + status: 201, + headers: { "cache-control": "private, no-store", "referrer-policy": "no-referrer" }, + body: { + connection_id: "acn_AAAAAAAAAAAAAAAAAAAAAAAA", + connection_token: `sac_${"C".repeat(43)}`, + approval_url: "https://dashboard.screenrig.ai/agents/connect/acn_AAAAAAAAAAAAAAAAAAAAAAAA", + expires_at: "2026-08-15T17:00:00.000Z", + }, + })); + transport.streamHandler = async (req) => { + const started = Date.now(); + await new Promise((resolve) => req.signal!.addEventListener("abort", () => resolve(), { once: true })); + assert.ok(Date.now() - started < 2500, "snapshot must not inherit a 30-second wait"); + throw Object.assign(new Error("Aborted"), { name: "AbortError" }); + }; + const result = await withRuntime(["agent", "connect", "--print-url", ...waitFlags], transport); + assert.equal(result.code, 0, result.stdout); + const data = JSON.parse(result.stdout).data; + assert.equal(data.status, "pending"); + assert.equal(data.request_submitted, true); + assert.equal(data.connection_complete, false); + assert.equal(data.status_checked, false); + assert.ok(data.next.argv.includes("--config")); + assert.equal(transport.calls.filter(call => call.method === "POST").length, 1); + await rm(result.configDir, { recursive: true, force: true }); + }); +} + test("agent connect resumes after its cached approval expiry when approved while offline", async () => { const transport = new FakeTransport(); let sealed: ReturnType | undefined; @@ -552,11 +584,11 @@ test("agent connect resumes after its cached approval expiry when approved while body: { agent: { ...sealed!.pendingAgent, state: "active", connected_at: "2026-08-14T17:00:01.000Z" }, connection_ready: true }, })); transport.queueStream(Object.assign(new Error("Interrupted"), { name: "AbortError" })); - const interrupted = await withRuntime(["--json", "agent", "connect"], transport, { openUrl: async () => true }); + const interrupted = await withRuntime(["--json", "agent", "connect", "--wait"], transport, { openUrl: async () => true }); assert.equal(JSON.parse(interrupted.stdout).error.code, "timeout"); const resumedFs = { mkdir, open, rename, rm, chmod, stat, homedir: () => interrupted.configDir, env: { XDG_CONFIG_HOME: interrupted.configDir } }; const opened: string[] = []; - const resumed = await withRuntime(["--json", "agent", "connect", "--name", "Office Codex", "--timeout", "86400000"], transport, { + const resumed = await withRuntime(["--json", "agent", "connect", "--name", "Office Codex", "--wait", "--timeout", "86400000"], transport, { fs: resumedFs, now: () => new Date("2026-08-16T05:00:00.000Z"), openUrl: async (url) => { opened.push(url); return true; }, @@ -566,6 +598,7 @@ test("agent connect resumes after its cached approval expiry when approved while assert.equal(result.code, 0, result.stdout); assert.deepEqual(opened, ["https://dashboard.screenrig.ai/agents/connect/acn_AAAAAAAAAAAAAAAAAAAAAAAA"]); assert.equal(JSON.parse(result.stdout).data.status, "active"); + assert.equal(JSON.parse(result.stdout).data.connection_complete, true); assert.equal(result.stderr, ""); assert.doesNotMatch(result.stdout, /sr_live_|sac_|ciphertext|nonce|private|authorization/i); const configPath = path.join(result.configDir, "screenrig", "config.json"); @@ -579,7 +612,7 @@ test("agent connect resumes after its cached approval expiry when approved while await rm(result.configDir, { recursive: true, force: true }); }); -test("agent connect --no-wait returns a pending handoff and resumes the same approved connection", async () => { +test("agent connect defaults to a snapshot, returns a pending handoff and resumes the same approved connection", async () => { const transport = new FakeTransport(); let sealed: ReturnType | undefined; const connectionToken = `sac_${"C".repeat(43)}`; @@ -623,10 +656,13 @@ test("agent connect --no-wait returns a pending handoff and resumes the same app connection_id: "acn_AAAAAAAAAAAAAAAAAAAAAAAA", name: "Office Codex", agent_type: "cli", status: "pending", expires_at: "2026-08-15T17:00:00.000Z", created_at: "2026-08-14T17:00:00.000Z", })}\n\n`] }); - const interrupted = await withRuntime(["agent", "connect", "--no-wait", "--print-url"], transport, { openUrl: async () => { throw new Error("print-url must not open browser"); } }); + const interrupted = await withRuntime(["agent", "connect", "--print-url"], transport, { openUrl: async () => { throw new Error("print-url must not open browser"); } }); assert.equal(interrupted.code, 0, interrupted.stdout); const pending = JSON.parse(interrupted.stdout).data; assert.equal(pending.status, "pending"); + assert.equal(pending.request_submitted, true); + assert.equal(pending.connection_complete, false); + assert.equal(pending.status_checked, true); assert.equal(pending.approval_url, "https://dashboard.screenrig.ai/agents/connect/acn_AAAAAAAAAAAAAAAAAAAAAAAA"); assert.equal(pending.next.command, "screenrig agent connect --no-wait"); assert.deepEqual(pending.next.argv, ["agent", "connect", "--no-wait", "--config", @@ -646,6 +682,7 @@ test("agent connect --no-wait returns a pending handoff and resumes the same app assert.equal(result.code, 0, result.stdout); assert.deepEqual(opened, [], "approved snapshots must not reopen the approval page"); assert.equal(JSON.parse(result.stdout).data.status, "active"); + assert.equal(JSON.parse(result.stdout).data.connection_complete, true); assert.equal(result.stderr, ""); assert.doesNotMatch(result.stdout, /sr_live_|sac_|ciphertext|nonce|private|authorization/i); const configPath = path.join(result.configDir, "screenrig", "config.json"); diff --git a/src/command-path.ts b/src/command-path.ts index bc578b06..931075fe 100644 --- a/src/command-path.ts +++ b/src/command-path.ts @@ -21,6 +21,9 @@ export function invocationFlags(command: Command): Record }); } }); + +test("agent connect waits only when explicitly requested", () => { + assert.equal(parseArgv(["agent", "connect"]).flags.wait, undefined); + assert.equal(parseArgv(["agent", "connect", "--wait"]).flags.wait, true); + assert.equal(parseArgv(["agent", "connect", "--wait"]).flags["no-wait"], undefined); + assert.equal(parseArgv(["agent", "connect", "--no-wait"]).flags.wait, undefined); + assert.equal(parseArgv(["agent", "connect", "--no-wait"]).flags["no-wait"], true); +}); diff --git a/src/commands.ts b/src/commands.ts index 01dc7171..f1b4d855 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -833,7 +833,7 @@ async function waitForAgentConnectionApproval( connection: AgentConnectionConfig, timeoutMs: number, returnPending = false, -): Promise { +): Promise { if (!connection.connection_id || !connection.connection_token) throw configError("Pending agent connection authority is incomplete."); const transport = transportFor(runtime, resolved.apiUrl); const controller = new AbortController(); @@ -874,6 +874,7 @@ async function waitForAgentConnectionApproval( } } catch (err) { if ((err as Error).name === "AbortError") { + if (controller.signal.aborted || returnPending) return latest; throw timeoutError("Timed out waiting for dashboard approval. Retry agent connect to resume the same request."); } throw err; @@ -881,6 +882,7 @@ async function waitForAgentConnectionApproval( clearTimeout(timer); } if (latest?.status === "pending" || !latest) { + if (returnPending || latest || controller.signal.aborted) return latest; throw timeoutError("Agent connection stream ended before approval. Retry agent connect to resume the same request."); } return latest; @@ -1049,7 +1051,7 @@ async function agentConnect( let pendingToken = current?.token; let pendingAgentId = connection.pending_agent_id; - const noWait = flagBool(args.flags, "no-wait"); + const noWait = !flagBool(args.flags, "wait"); let opened = false; let printed = false; if (!(pendingToken && pendingAgentId)) { @@ -1069,8 +1071,8 @@ async function agentConnect( } }; if (!noWait) await handoff(); - const timeoutMs = requestedTimeout ?? (noWait ? 30_000 : 86_400_000); - let status: AgentConnection; + const timeoutMs = noWait ? Math.min(requestedTimeout ?? 1000, 1000) : requestedTimeout ?? 30_000; + let status: AgentConnection | undefined; try { status = await waitForAgentConnectionApproval(args, runtime, resolved, connection, timeoutMs, noWait); } catch (err) { @@ -1085,19 +1087,19 @@ async function agentConnect( } throw err; } - if (status.status === "pending") { - await handoff(); + if (!status || status.status === "pending") { + if (!opened && !printed) await handoff(); const next = { command: "screenrig agent connect --no-wait", argv: ["agent", "connect", "--no-wait", "--config", resolved.configPath, "--api-url", resolved.apiUrl], reason: "Complete dashboard approval, then resume. Use argv to preserve this configuration and API origin.", }; return { - envelope: successEnvelope({ status: "pending", connection_id: connection.connection_id, - expires_at: status.expires_at, opened, approval_url_printed: printed, + envelope: successEnvelope({ status: "pending", request_submitted: true, connection_complete: false, status_checked: status !== undefined, connection_id: connection.connection_id, + expires_at: status?.expires_at ?? connection.expires_at, opened, approval_url_printed: printed, ...(printed ? { approval_url: connection.approval_url } : {}), next }), exitCode: ExitCode.Success, - human: humanLines("Agent approval pending", [["status", "pending"], ["connection_id", connection.connection_id], + human: humanLines("Agent approval pending", [["status", "pending"], ["connection_complete", "false"], ["connection_id", connection.connection_id], ["approval_url", printed ? connection.approval_url : undefined], ["next", next.command]]), }; } @@ -1177,6 +1179,8 @@ async function agentConnect( return { envelope: successEnvelope({ status: "active", + request_submitted: true, + connection_complete: true, agent: safeAgentSummary(agent), connection_id: connection.connection_id, opened, From b0654610d5cbf05fa5f1d03e9a4d8d36c3836f0a Mon Sep 17 00:00:00 2001 From: Kurtis Jantzen Date: Fri, 11 Sep 2026 12:43:57 -0700 Subject: [PATCH 4/5] Scope playlist upload idempotency keys per file --- README.md | 9 ++++- src/commands.ts | 7 +++- src/playlist-workflows.test.ts | 73 ++++++++++++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 107d2cae..9ee33a77 100644 --- a/README.md +++ b/README.md @@ -202,8 +202,13 @@ 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. -If preparation fails after an upload, that media remains in the account; inspect -`media list` and reuse its ID instead of uploading it again. +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`); diff --git a/src/commands.ts b/src/commands.ts index f1b4d855..05f256b4 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -4155,7 +4155,12 @@ export const handlePlaylistInit = commandHandler(async (args, runtime, resolved) catch { throw usageError("Cannot create output; choose a new file in an existing directory."); } try { for (const [index, sourcePath] of files) { - const uploaded = await loggerOf(runtime).withLocal({ op: "media.upload", message: "media upload" }, () => uploadMediaFile({ runtime, client, sourcePath, + // Scope each occurrence to the invocation key, including repeated files. + // An identical retry with --idempotency-key reproduces both declare and commit keys. + const idempotencyKey = createHash("sha256") + .update(JSON.stringify(["screenrig.playlist.init.upload", client.idempotencyKey, index])) + .digest("base64url"); + const uploaded = await loggerOf(runtime).withLocal({ op: "media.upload", message: "media upload" }, () => uploadMediaFile({ runtime, client, sourcePath, idempotencyKey, transcodeOptions: transcodeOptionsFromArgs(args), noTranscode: flagBool(args.flags, "no-transcode"), reporter: progressReporterFor(args, runtime), noWait: false, timeoutMs: flagNumber(args.flags, "timeout") ?? 120_000, pollMs: flagNumber(args.flags, "poll-ms") ?? 1000 })); diff --git a/src/playlist-workflows.test.ts b/src/playlist-workflows.test.ts index e557a257..abb4de63 100644 --- a/src/playlist-workflows.test.ts +++ b/src/playlist-workflows.test.ts @@ -241,3 +241,76 @@ test("file preparation uses upload readiness and protects existing output before assert.equal((await invoke()).code,2); await assert.rejects(readFile(dir+'/prepared.json')); }); + +for (const sameFile of [false, true]) for (const explicitKey of [false, true]) { + test(`file preparation isolates ${sameFile ? 'identical' : 'different'} declarations with ${explicitKey ? 'retry-stable explicit' : 'generated'} keys`, async t => { + const dir = await mkdtemp('/tmp/prepare-idempotency-'); + t.after(() => rm(dir, { recursive: true, force: true })); + const config = dir + '/config.json'; + await writeFile(config, JSON.stringify({ api_url: 'https://api.screenrig.ai', token: 'test-token' }), { mode: 0o600 }); + await writeFile(dir + '/first.mp4', Buffer.from([0, 0, 0, 24, 102, 116, 121, 112])); + await writeFile(dir + '/second.mp4', Buffer.from([0, 0, 0, 24, 102, 116, 121, 112, 1])); + const declarations = new Map }>(); + const commits = new Map }>(); + let interrupted = false; + const conflict = () => ({ status: 409, headers: {}, body: { code: 'idempotency_conflict', title: 'Conflict', status: 409 } }); + const transport = new FakeTransport() + .on('POST', '/api/v1/media/uploads', req => { + const key = req.headers?.['idempotency-key']; assert.ok(key); + const body = JSON.stringify(req.body); + const cached = declarations.get(key); + if (cached) return cached.body === body ? cached.response : conflict(); + const id = String(declarations.size + 1); + const accepted = { status: 201, headers: { 'cache-control': 'private, no-store' }, body: { + id: `upload_${id}`, operation: { id: `op_${id}` }, method: 'PUT', headers: {}, + upload_url: `https://storage.example.invalid/upload/${id}`, expires_at: '2099-01-01T00:00:00Z', + } }; + declarations.set(key, { body, response: accepted }); + // Simulate acceptance followed by a lost response on the second file. + if (explicitKey && id === '2' && !interrupted) { interrupted = true; throw networkError('Lost declaration response'); } + return accepted; + }) + .on('POST', /^\/api\/v1\/media\/uploads\/upload_\d+\/commit$/, req => { + const key = req.headers?.['idempotency-key']; assert.ok(key); + const body = JSON.stringify(req.body), cached = commits.get(key); + if (cached) return cached.path === req.path && cached.body === body ? cached.response : conflict(); + const id = req.path.split('/').at(-2)!.slice('upload_'.length); + const accepted = response({ id: `op_${id}`, state: 'succeeded', result: { media_id: `med_${id}` } }); + commits.set(key, { path: req.path, body, response: accepted }); + return accepted; + }) + .on('GET', /^\/api\/v1\/operations\/op_\d+$/, req => { + const id = req.path.split('/').at(-1)!.slice('op_'.length); + return response({ id: `op_${id}`, state: 'succeeded', result: { media_id: `med_${id}` } }); + }) + .on('GET', /^\/api\/v1\/media\/med_\d+$/, req => response({ id: req.path.split('/').at(-1), state: 'ready', primitive: 'video' })); + async function invoke() { + let out = ''; + const code = await run({ ...processRuntime(), argv: ['--config', config, + ...(explicitKey ? ['--idempotency-key', 'prepare-retry-test'] : []), + 'playlist', 'init', './first.mp4', sameFile ? './first.mp4' : './second.mp4', + '--name', 'Lobby', '--output', 'prepared.json', '--target-width', '1920', '--target-height', '1080', '--no-transcode', '--no-progress'], + env: {}, cwd: () => dir, transport, sleep: async () => {}, signedRawPut: async () => ({ status: 200 }), + stdout: new Writable({ write(c, e, done) { out += c; done(); } }), stderr: new Writable({ write(c, e, done) { done(); } }), + }); + return { code, body: JSON.parse(out) }; + } + if (explicitKey) { + const failed = await invoke(); assert.notEqual(failed.code, 0); + assert.equal(interrupted, true, JSON.stringify(failed.body)); + await assert.rejects(readFile(dir + '/prepared.json')); + } + const result = await invoke(); assert.equal(result.code, 0, JSON.stringify(result.body)); + assert.equal(declarations.size, 2); assert.equal(commits.size, 2); + assert.equal(new Set([...declarations.keys(), ...commits.keys()]).size, 4); + const doc = JSON.parse(await readFile(dir + '/prepared.json', 'utf8')); + assert.deepEqual(doc.pages.map((p: any) => p.primitives[0].selector.media_id), ['med_1', 'med_2']); + const keys = transport.calls.filter(c => c.path === '/api/v1/media/uploads').map(c => c.headers!['idempotency-key']); + assert.equal(keys.length, explicitKey ? 4 : 2); + if (explicitKey) { + assert.deepEqual(keys.slice(0, 2), keys.slice(2)); + const commitKeys = transport.calls.filter(c => c.path.endsWith('/commit')).map(c => c.headers!['idempotency-key']); + assert.equal(commitKeys[0], commitKeys[1]); + } + }); +} From d63ecadca550fbf8b5b9e3a4bf0089a0d9dbe68d Mon Sep 17 00:00:00 2001 From: Kurtis Jantzen Date: Fri, 11 Sep 2026 12:59:40 -0700 Subject: [PATCH 5/5] Complete CLI help and standardize command vocabulary --- AGENTS.md | 12 +++++ README.md | 43 +++++++++++++--- src/cli-commands/aliases.ts | 22 ++++++++ src/cli-commands/comment.ts | 3 ++ src/cli-commands/feedback.ts | 4 +- src/cli-commands/guidance.ts | 93 ++++++++++++++++++++++++++++++++++ src/cli-commands/index.ts | 2 + src/cli-commands/kv.ts | 11 ++-- src/cli-commands/media.ts | 1 + src/cli-commands/notes.ts | 11 +++- src/cli-commands/operations.ts | 2 +- src/cli-commands/playlist.ts | 16 +++--- src/cli-commands/screen.ts | 15 +++--- src/command-path.ts | 3 +- src/command-tree.ts | 5 +- src/commands.ts | 10 ++-- src/help.test.ts | 65 +++++++++++++++++++++++- src/help.ts | 3 ++ 18 files changed, 284 insertions(+), 37 deletions(-) create mode 100644 src/cli-commands/aliases.ts create mode 100644 src/cli-commands/guidance.ts diff --git a/AGENTS.md b/AGENTS.md index 1d1c4b58..6f5fc9f4 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 9ee33a77..a7a32513 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. + ## Application command results `app upload` and `app update` return the same JSON data paths with or without @@ -72,7 +101,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 @@ -184,7 +213,7 @@ 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 ``` @@ -194,7 +223,7 @@ local image/video files, ready `med_` IDs, pinned `rel_` application releases, a HTTPS iframe URLs, including mixed inputs: ```sh -screenrig playlist init ./poster.png med_VIDEO rel_APP https://example.com --name Lobby --screen scr_LOBBY --output lobby.json +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 @@ -217,13 +246,13 @@ videos are muted, do not loop, and advance on completion. Other pages advance af are pinned to the supplied release and use timed advancement, without controller privileges. Edit the document for application-controlled advancement. -With `--screen`, the result includes `screen_id`, `screen_revision`, and a +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` (no publish arguments). +`--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. 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 0a16848a..1e9d5bd3 100644 --- a/src/cli-commands/playlist.ts +++ b/src/cli-commands/playlist.ts @@ -1,3 +1,4 @@ +import { addValueAlias } from "./aliases.js"; import { nonnegativeInteger, positiveInteger, revision } from "./options.js"; import type { CommandActionBinder } from "./types.js"; import { handlePlaylistReplaceRelease, handlePlaylistInit, handlePlaylistValidate, handlePlaylistPreview, handlePlaylistTemplates, handlePlaylistCreate, handlePlaylistUpdate, handlePlaylistExport, handlePlaylistImport, handlePlaylistShow, handlePlaylistList, handlePlaylistDelete } from "../commands.js"; @@ -12,7 +13,6 @@ export function registerPlaylistCommands(root: Command, bind: CommandActionBinde .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 ", "Non-video page duration").argParser(positiveInteger("duration-ms")).default(8000)) @@ -21,18 +21,19 @@ export function registerPlaylistCommands(root: Command, bind: CommandActionBinde .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 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 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") @@ -43,12 +44,12 @@ 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)); @@ -61,6 +62,7 @@ export function registerPlaylistCommands(root: Command, bind: CommandActionBinde .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'); diff --git a/src/cli-commands/screen.ts b/src/cli-commands/screen.ts index 388047fa..05425577 100644 --- a/src/cli-commands/screen.ts +++ b/src/cli-commands/screen.ts @@ -1,12 +1,14 @@ +import { addValueAlias } from "./aliases.js"; +import { 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, 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) @@ -14,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