From 5221cc10bd650067fabaaf8b271dbcd7a83442d8 Mon Sep 17 00:00:00 2001 From: Kurtis Jantzen Date: Thu, 10 Sep 2026 16:00:01 -0700 Subject: [PATCH] Add editable playlist preparation and resumable screen publishing --- src/adapters/protocol.test.ts | 6 +- src/app-output.test.ts | 2 +- src/argv.ts | 2 + src/authoring-input.ts | 44 ++++++++++ src/cli-commands/app.ts | 2 +- src/cli-commands/kv.ts | 4 +- src/cli-commands/media.ts | 11 ++- src/cli-commands/playlist.ts | 27 ++++-- src/cli-commands/screen.ts | 22 +++-- src/cli-errors.ts | 8 +- src/cli.test.ts | 64 +++++++------- src/command-input.ts | 12 +++ src/command-path.ts | 2 +- src/command-tree.ts | 1 + src/commander.test.ts | 6 +- src/commands.ts | 82 ++++++++++++----- src/help.test.ts | 4 +- src/if-match.ts | 2 +- src/localhost-smoke.ts | 26 +++--- src/native-program.test.ts | 2 +- src/playlist-authoring.ts | 62 +++++++++++++ src/playlist-bundle.test.ts | 10 +-- src/playlist-bundle.ts | 6 +- src/playlist-workflows.test.ts | 150 ++++++++++++++++++++++++++++++++ src/program.ts | 2 + src/runtime.ts | 4 + src/screen-publish.ts | 107 +++++++++++++++++++++++ src/server-smoke.ts | 26 +++--- src/validate-invocation.test.ts | 8 +- 29 files changed, 577 insertions(+), 127 deletions(-) create mode 100644 src/authoring-input.ts create mode 100644 src/playlist-authoring.ts create mode 100644 src/playlist-workflows.test.ts create mode 100644 src/screen-publish.ts diff --git a/src/adapters/protocol.test.ts b/src/adapters/protocol.test.ts index 9fe914be..fbfb5f89 100644 --- a/src/adapters/protocol.test.ts +++ b/src/adapters/protocol.test.ts @@ -412,7 +412,7 @@ test("screen observation is optional, read-only, and absent from ScreenPatch", ( // The CLI reads observation from GET /api/v1/screens/{id}. Players PUT the // runtime route; this command surface must not. assert.doesNotMatch(commands, /\/runtime\/v1\/observation/); - assert.match(commands, /screen update requires , --if-match, and --name, --playlist-id, or --timezone/); + assert.match(commands, /screen update requires , --expect-rev, and --name, --playlist-id, or --timezone/); }); test("screen online is required, last_online_at and last_ip are optional, and ScreenPatch cannot write them", () => { @@ -448,7 +448,7 @@ test("screen online is required, last_online_at and last_ip are optional, and Sc assert.match(screenSchema, /maxLength: 45/); assert.match(screenSchema, /ScreenPatch, pairing bodies, session[\s\S]*runtime manifest\s+body cannot write it/); assert.doesNotMatch(commands, /--online|--last-online-at|--last-ip/); - assert.match(commands, /screen update requires , --if-match, and --name, --playlist-id, or --timezone/); + assert.match(commands, /screen update requires , --expect-rev, and --name, --playlist-id, or --timezone/); }); test("published problem codes include payment_required and dependency_timeout", () => { @@ -872,7 +872,7 @@ test("opaque agent comments are optional on GET resources, dedicated comment rou assert.equal(options.has(name), action === "set"); if (action === "set") assert.equal(options.get(name)?.type, "value"); } - assert.equal(options.has("--if-match"), false); + assert.equal(options.has("--expect-rev"), false); assert.equal(options.has("--value-base64"), false); } } diff --git a/src/app-output.test.ts b/src/app-output.test.ts index a9db4e73..d4ebb334 100644 --- a/src/app-output.test.ts +++ b/src/app-output.test.ts @@ -34,7 +34,7 @@ for (const action of ["upload", "update"] as const) { const stdout = new Writable({ write(chunk, _encoding, callback) { output += chunk; callback(); } }); const stderr = new Writable({ write(_chunk, _encoding, callback) { callback(); } }); const argv = ["--json", "app", action, - ...(action === "update" ? ["app_EXISTING", appDir, "--if-match", "7"] : [appDir]), + ...(action === "update" ? ["app_EXISTING", appDir, "--expect-rev", "7"] : [appDir]), ...(noWait ? ["--no-wait"] : ["--poll-ms", "1"]), ]; const code = await run({ diff --git a/src/argv.ts b/src/argv.ts index 75b4a351..3061622e 100644 --- a/src/argv.ts +++ b/src/argv.ts @@ -1,3 +1,4 @@ +import { normalizeRevisionArgs } from "./command-input.js"; import { CommanderError } from "commander"; import { commandInput, createCommandTree } from "./command-tree.js"; import type { ParsedArgs } from "./command-input.js"; @@ -7,6 +8,7 @@ export { flagString, flagBool, flagNumber, type ParsedArgs } from "./command-inp /** Inspect the same native command tree without executing an application handler. */ export function parseArgv(argv: string[]): ParsedArgs & { help?: HelpDocument } { + argv = normalizeRevisionArgs(argv); const tree = createCommandTree(argv); try { tree.root.parse(argv, { from: "user" }); diff --git a/src/authoring-input.ts b/src/authoring-input.ts new file mode 100644 index 00000000..77107f4b --- /dev/null +++ b/src/authoring-input.ts @@ -0,0 +1,44 @@ +import { open } from "node:fs/promises"; +import path from "node:path"; +import type { CliRuntime } from "./runtime.js"; +import { usageError } from "./problems.js"; + +const MAX_INPUT_BYTES = 8 * 1024 * 1024; +export async function readAuthoringText(file: string, runtime: CliRuntime, maxBytes = MAX_INPUT_BYTES): Promise { + try { + if (file !== "-") { + const handle = await open(path.resolve(runtime.cwd(), file), "r"); + try { + if ((await handle.stat()).size > maxBytes) throw usageError("Input exceeds its size limit."); + let size = 0; const chunks: Buffer[] = []; + for await (const chunk of handle.createReadStream({ autoClose: false })) { + size += chunk.length; + if (size > maxBytes) throw usageError("Input exceeds its size limit."); + chunks.push(chunk); + } + return Buffer.concat(chunks).toString("utf8"); + } finally { await handle.close(); } + } + if (!runtime.stdin || runtime.isStdinTty?.()) throw usageError("Pipe input when using '-'."); + let size = 0; const chunks: Buffer[] = []; + for await (const chunk of runtime.stdin) { + const bytes = Buffer.from(chunk); size += bytes.length; + if (size > maxBytes) throw usageError("Input exceeds its size limit."); + chunks.push(bytes); + } + return Buffer.concat(chunks).toString("utf8"); + } catch { throw usageError("Cannot read input; provide a readable file or pipe, within the size limit."); } +} +export async function readAuthoringJson(file: string, runtime: CliRuntime): Promise { + const text = await readAuthoringText(file, runtime); + try { return JSON.parse(text); } catch { throw usageError("Input is not valid JSON."); } +} +/** Exclusive creation keeps a prepared document safe from accidental replacement. */ +export async function writeAuthoringJson(file: string, document: unknown, runtime: CliRuntime): Promise { + const output = path.resolve(runtime.cwd(), file); + try { + const handle = await open(output, "wx", 0o600); + try { await handle.writeFile(JSON.stringify(document, null, 2) + "\n"); } finally { await handle.close(); } + } catch { throw usageError("Cannot create output; choose a new file in an existing directory."); } + return output; +} diff --git a/src/cli-commands/app.ts b/src/cli-commands/app.ts index 1eaa04d3..7f8d34de 100644 --- a/src/cli-commands/app.ts +++ b/src/cli-commands/app.ts @@ -21,7 +21,7 @@ export function registerAppCommands(root: Command, bind: CommandActionBinder): v app.command("update").description("Update an application") .argument("", "Application identifier") .argument("", "Local directory") - .requiredOption("--if-match ", "Require the current resource revision (required)", revision) + .requiredOption("--expect-rev ", "Require the current resource revision (required)", revision) .option("--no-wait", "Return after acceptance without waiting for processing") .option("--poll-ms ", "Set the operation polling interval", positiveInteger("poll-ms")) .action(bind(handleAppUpdate)); diff --git a/src/cli-commands/kv.ts b/src/cli-commands/kv.ts index 1bf27dad..e4502295 100644 --- a/src/cli-commands/kv.ts +++ b/src/cli-commands/kv.ts @@ -18,13 +18,13 @@ export function registerKvCommands(root: Command, bind: CommandActionBinder): vo .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"])) .addOption(new Option("--content-type ", "Declare the content MIME type").conflicts(["jsonValue"])) - .option("--if-match ", "Require the current resource revision", revision) + .option("--expect-rev ", "Require the current resource revision", revision) .action(bind(handleKvSet)); kv.command("delete").description("Delete an application key") .argument("", "Application key") .requiredOption("--application-id ", "Select the application's K/V namespace (required)") - .requiredOption("--if-match ", "Require the current resource revision (required)", revision) + .requiredOption("--expect-rev ", "Require the current resource revision (required)", revision) .action(bind(handleKvDelete)); kv.command("list").description("List application keys") diff --git a/src/cli-commands/media.ts b/src/cli-commands/media.ts index bdda364c..c61d0b16 100644 --- a/src/cli-commands/media.ts +++ b/src/cli-commands/media.ts @@ -1,3 +1,4 @@ +import { usageError } from "../problems.js"; import { positiveInteger, positiveNumber, revision } from "./options.js"; import type { CommandActionBinder } from "./types.js"; import { handleMediaGenerate, handleMediaUpload, handleMediaUploadBatch, handleMediaShow, handleMediaDownload, handleMediaList, handleMediaUpdate, handleMediaDelete } from "../commands.js"; @@ -9,11 +10,15 @@ export function registerMediaCommands(root: Command, bind: CommandActionBinder): const media = root.command("media").description("Generate, upload, and manage images and videos"); addCommandNotes(media.command("generate").description("Generate an image from a prompt") - .requiredOption("--prompt ", "Describe the complete image to generate (required)") + .addOption(new Option("--prompt ", "Describe the complete image to generate").conflicts("promptFile")) + .addOption(new Option("--prompt-file ", "Read the complete prompt from a file or stdin (-)").conflicts("prompt")) .option("--aspect-ratio ", "Choose the generated image aspect ratio") .option("--quality ", "Choose generation quality") .option("--tag ", "Set the media tag") .option("--no-progress", "Suppress stderr progress") + .hook("preAction", (command) => { + if (command.getOptionValue("prompt") === undefined && command.getOptionValue("promptFile") === undefined) throw usageError("Provide --prompt TEXT or --prompt-file FILE."); + }) .action(bind(handleMediaGenerate)), CREDIT_HELP); media.command("upload").description("Upload an image or video") @@ -66,11 +71,11 @@ export function registerMediaCommands(root: Command, bind: CommandActionBinder): .argument("", "Media identifier") .addOption(new Option("--tag ", "Set the media tag").conflicts(["clearTag"])) .addOption(new Option("--clear-tag", "Remove the media tag").conflicts(["tag"])) - .requiredOption("--if-match ", "Require the current resource revision (required)", revision) + .requiredOption("--expect-rev ", "Require the current resource revision (required)", revision) .action(bind(handleMediaUpdate)); media.command("delete").description("Delete media") .argument("", "Media identifier") - .requiredOption("--if-match ", "Require the current resource revision (required)", revision) + .requiredOption("--expect-rev ", "Require the current resource revision (required)", revision) .action(bind(handleMediaDelete)); } diff --git a/src/cli-commands/playlist.ts b/src/cli-commands/playlist.ts index e768c507..ed665eb9 100644 --- a/src/cli-commands/playlist.ts +++ b/src/cli-commands/playlist.ts @@ -1,7 +1,7 @@ import { usageError } from "../problems.js"; import { nonnegativeInteger, positiveInteger, revision } from "./options.js"; import type { CommandActionBinder } from "./types.js"; -import { handlePlaylistValidate, handlePlaylistPreview, handlePlaylistTemplates, handlePlaylistCreate, handlePlaylistUpdate, handlePlaylistExport, handlePlaylistImport, handlePlaylistShow, handlePlaylistList, handlePlaylistDelete } from "../commands.js"; +import { handlePlaylistInit, handlePlaylistValidate, handlePlaylistPreview, handlePlaylistTemplates, handlePlaylistCreate, handlePlaylistUpdate, handlePlaylistExport, handlePlaylistImport, handlePlaylistShow, handlePlaylistList, handlePlaylistDelete } from "../commands.js"; import type { Command } from "commander"; import { addCommandNotes } from "./notes.js"; import { LOOK_AT_THE_CONTACT_SHEET } from "../playlist-preview.js"; @@ -9,6 +9,17 @@ import { LOOK_AT_THE_CONTACT_SHEET } from "../playlist-preview.js"; export function registerPlaylistCommands(root: Command, bind: CommandActionBinder): void { const playlist = root.command("playlist").description("Author, validate, preview, and manage playlists"); + playlist.command("init").description("Prepare an editable full-screen playlist from ready media") + .argument("", "Media identifiers 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")) + .option("--duration-ms ", "Image page duration (default: 8000)", positiveInteger("duration-ms")) + .option("--fit ", "Content fit: contain (default), cover, or fill") + .action(bind(handlePlaylistInit)); + playlist.command("validate").description("Validate a playlist file") .argument("", "Local input file") .option("--lint-only", "Run the command's local validation/lint mode") @@ -32,7 +43,7 @@ export function registerPlaylistCommands(root: Command, bind: CommandActionBinde playlist.command("update").description("Update a playlist") .argument("", "Playlist identifier") .argument("", "Local input file") - .requiredOption("--if-match ", "Require the current resource revision (required)", revision) + .requiredOption("--expect-rev ", "Require the current resource revision (required)", revision) .action(bind(handlePlaylistUpdate)); playlist.command("export").description("Export a playlist bundle") @@ -44,16 +55,18 @@ export function registerPlaylistCommands(root: Command, bind: CommandActionBinde .argument("", "Local directory") .option("--name ", "Set the imported playlist name") .option("--update ", "Update this playlist when importing") - .option("--if-match ", "Require the current resource revision", revision) + .option("--expect-rev ", "Require the current resource revision", revision) .option("--poll-ms ", "Set the operation polling interval", positiveInteger("poll-ms")) .hook("preAction", (command) => { const update = command.getOptionValueSource("update") === "cli"; - const ifMatch = command.getOptionValueSource("ifMatch") === "cli"; - if (update !== ifMatch) throw usageError("playlist import --update and --if-match must be supplied together."); + const ifMatch = command.getOptionValueSource("expectRev") === "cli"; + if (update !== ifMatch) throw usageError("playlist import --update and --expect-rev must be supplied together."); }) .action(bind(handlePlaylistImport)); - playlist.command("show").description("Inspect a playlist") + playlist.command("show").description("Inspect a playlist or write an editable document") + .option("--output ", "Create an editable playlist file; report its revision on stdout") + .option("--editable", "Return an editable document and revision in the JSON envelope") .alias("get") .argument("", "Playlist identifier") .action(bind(handlePlaylistShow)); @@ -63,6 +76,6 @@ export function registerPlaylistCommands(root: Command, bind: CommandActionBinde playlist.command("delete").description("Delete a playlist") .argument("", "Playlist identifier") - .requiredOption("--if-match ", "Require the current resource revision (required)", revision) + .requiredOption("--expect-rev ", "Require the current resource revision (required)", revision) .action(bind(handlePlaylistDelete)); } diff --git a/src/cli-commands/screen.ts b/src/cli-commands/screen.ts index 306409a9..388047fa 100644 --- a/src/cli-commands/screen.ts +++ b/src/cli-commands/screen.ts @@ -1,11 +1,17 @@ import { positiveInteger, revision, toastDuration } from "./options.js"; import type { CommandActionBinder } from "./types.js"; -import { handleScreenPair, handleScreenProvision, handleScreenUpdate, handleScreenList, handleScreenShow, handleScreenAssign, handleScreenSetTimezone, handleScreenArchive, handleScreenUnarchive, handleScreenDelete, handleScreenRotatePublicId, handleScreenToast, handleScreenScreenshot } from "../commands.js"; +import { handleScreenPublish, handleScreenPair, handleScreenProvision, handleScreenUpdate, handleScreenList, handleScreenShow, handleScreenAssign, handleScreenSetTimezone, handleScreenArchive, handleScreenUnarchive, handleScreenDelete, handleScreenRotatePublicId, handleScreenToast, handleScreenScreenshot } from "../commands.js"; import type { Command } from "commander"; export function registerScreenCommands(root: Command, bind: CommandActionBinder): void { const screen = root.command("screen").description("Pair, configure, and inspect screens"); + screen.command("publish").description("Create a prepared playlist and assign it, with resumable recovery") + .argument("", "Screen identifier") + .argument("", "Prepared playlist file, or - for stdin") + .requiredOption("--expect-rev ", "Expected screen revision", revision) + .action(bind(handleScreenPublish)); + screen.command("pair").description("Claim a Player pairing code") .argument("", "Player pairing code") .option("--label