Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions src/adapters/protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>, --if-match, and --name, --playlist-id, or --timezone/);
assert.match(commands, /screen update requires <id>, --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", () => {
Expand Down Expand Up @@ -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 <id>, --if-match, and --name, --playlist-id, or --timezone/);
assert.match(commands, /screen update requires <id>, --expect-rev, and --name, --playlist-id, or --timezone/);
});

test("published problem codes include payment_required and dependency_timeout", () => {
Expand Down Expand Up @@ -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);
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/app-output.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
2 changes: 2 additions & 0 deletions src/argv.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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" });
Expand Down
44 changes: 44 additions & 0 deletions src/authoring-input.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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<any> {
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<string> {
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;
}
2 changes: 1 addition & 1 deletion src/cli-commands/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export function registerAppCommands(root: Command, bind: CommandActionBinder): v
app.command("update").description("Update an application")
.argument("<id>", "Application identifier")
.argument("<directory>", "Local directory")
.requiredOption("--if-match <REVISION>", "Require the current resource revision (required)", revision)
.requiredOption("--expect-rev <REVISION>", "Require the current resource revision (required)", revision)
.option("--no-wait", "Return after acceptance without waiting for processing")
.option("--poll-ms <MS>", "Set the operation polling interval", positiveInteger("poll-ms"))
.action(bind(handleAppUpdate));
Expand Down
4 changes: 2 additions & 2 deletions src/cli-commands/kv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,13 @@ export function registerKvCommands(root: Command, bind: CommandActionBinder): vo
.addOption(new Option("--file <FILE>", "Read the value from a file").conflicts(["jsonValue", "valueBase64"]))
.addOption(new Option("--value-base64 <BASE64>", "Supply canonical base64 bytes (may be empty)").conflicts(["jsonValue", "file"]))
.addOption(new Option("--content-type <TYPE>", "Declare the content MIME type").conflicts(["jsonValue"]))
.option("--if-match <REVISION>", "Require the current resource revision", revision)
.option("--expect-rev <REVISION>", "Require the current resource revision", revision)
.action(bind(handleKvSet));

kv.command("delete").description("Delete an application key")
.argument("<key>", "Application key")
.requiredOption("--application-id <ID>", "Select the application's K/V namespace (required)")
.requiredOption("--if-match <REVISION>", "Require the current resource revision (required)", revision)
.requiredOption("--expect-rev <REVISION>", "Require the current resource revision (required)", revision)
.action(bind(handleKvDelete));

kv.command("list").description("List application keys")
Expand Down
11 changes: 8 additions & 3 deletions src/cli-commands/media.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 <TEXT>", "Describe the complete image to generate (required)")
.addOption(new Option("--prompt <TEXT>", "Describe the complete image to generate").conflicts("promptFile"))
.addOption(new Option("--prompt-file <FILE>", "Read the complete prompt from a file or stdin (-)").conflicts("prompt"))
.option("--aspect-ratio <RATIO>", "Choose the generated image aspect ratio")
.option("--quality <low|medium|high>", "Choose generation quality")
.option("--tag <TAG>", "Set the media tag")
.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")
Expand Down Expand Up @@ -66,11 +71,11 @@ export function registerMediaCommands(root: Command, bind: CommandActionBinder):
.argument("<id>", "Media identifier")
.addOption(new Option("--tag <TAG>", "Set the media tag").conflicts(["clearTag"]))
.addOption(new Option("--clear-tag", "Remove the media tag").conflicts(["tag"]))
.requiredOption("--if-match <REVISION>", "Require the current resource revision (required)", revision)
.requiredOption("--expect-rev <REVISION>", "Require the current resource revision (required)", revision)
.action(bind(handleMediaUpdate));

media.command("delete").description("Delete media")
.argument("<id>", "Media identifier")
.requiredOption("--if-match <REVISION>", "Require the current resource revision (required)", revision)
.requiredOption("--expect-rev <REVISION>", "Require the current resource revision (required)", revision)
.action(bind(handleMediaDelete));
}
27 changes: 20 additions & 7 deletions src/cli-commands/playlist.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,25 @@
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";

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-ids...>", "Media identifiers in playback order")
.requiredOption("--name <NAME>", "Playlist name")
.requiredOption("--output <FILE>", "Create an editable playlist file")
.option("--screen <ID>", "Use this screen's reported playback dimensions")
.option("--target-width <PX>", "Override canvas width", positiveInteger("target-width"))
.option("--target-height <PX>", "Override canvas height", positiveInteger("target-height"))
.option("--duration-ms <MS>", "Image page duration (default: 8000)", positiveInteger("duration-ms"))
.option("--fit <FIT>", "Content fit: contain (default), cover, or fill")
.action(bind(handlePlaylistInit));

playlist.command("validate").description("Validate a playlist file")
.argument("<file>", "Local input file")
.option("--lint-only", "Run the command's local validation/lint mode")
Expand All @@ -32,7 +43,7 @@ export function registerPlaylistCommands(root: Command, bind: CommandActionBinde
playlist.command("update").description("Update a playlist")
.argument("<id>", "Playlist identifier")
.argument("<file>", "Local input file")
.requiredOption("--if-match <REVISION>", "Require the current resource revision (required)", revision)
.requiredOption("--expect-rev <REVISION>", "Require the current resource revision (required)", revision)
.action(bind(handlePlaylistUpdate));

playlist.command("export").description("Export a playlist bundle")
Expand All @@ -44,16 +55,18 @@ export function registerPlaylistCommands(root: Command, bind: CommandActionBinde
.argument("<directory>", "Local directory")
.option("--name <NAME>", "Set the imported playlist name")
.option("--update <ID>", "Update this playlist when importing")
.option("--if-match <REVISION>", "Require the current resource revision", revision)
.option("--expect-rev <REVISION>", "Require the current resource revision", revision)
.option("--poll-ms <MS>", "Set the operation polling interval", positiveInteger("poll-ms"))
.hook("preAction", (command) => {
const update = command.getOptionValueSource("update") === "cli";
const ifMatch = command.getOptionValueSource("ifMatch") === "cli";
if (update !== ifMatch) throw usageError("playlist import --update and --if-match must be supplied together.");
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 <FILE>", "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("<id>", "Playlist identifier")
.action(bind(handlePlaylistShow));
Expand All @@ -63,6 +76,6 @@ export function registerPlaylistCommands(root: Command, bind: CommandActionBinde

playlist.command("delete").description("Delete a playlist")
.argument("<id>", "Playlist identifier")
.requiredOption("--if-match <REVISION>", "Require the current resource revision (required)", revision)
.requiredOption("--expect-rev <REVISION>", "Require the current resource revision (required)", revision)
.action(bind(handlePlaylistDelete));
}
22 changes: 14 additions & 8 deletions src/cli-commands/screen.ts
Original file line number Diff line number Diff line change
@@ -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("<id>", "Screen identifier")
.argument("<file>", "Prepared playlist file, or - for stdin")
.requiredOption("--expect-rev <REVISION>", "Expected screen revision", revision)
.action(bind(handleScreenPublish));

screen.command("pair").description("Claim a Player pairing code")
.argument("<code>", "Player pairing code")
.option("--label <LABEL>", "Set the screen label")
Expand All @@ -22,7 +28,7 @@ export function registerScreenCommands(root: Command, bind: CommandActionBinder)
.option("--name <NAME>", "Set the screen name")
.option("--playlist-id <ID>", "Select the playlist to assign")
.option("--timezone <ZONE>", "Set an IANA timezone")
.requiredOption("--if-match <REVISION>", "Require the current resource revision (required)", revision)
.requiredOption("--expect-rev <REVISION>", "Require the current resource revision (required)", revision)
.action(bind(handleScreenUpdate));

screen.command("list").description("List screens")
Expand All @@ -36,33 +42,33 @@ export function registerScreenCommands(root: Command, bind: CommandActionBinder)
screen.command("assign").description("Assign a playlist to a screen")
.argument("<id>", "Screen identifier")
.requiredOption("--playlist-id <ID>", "Select the playlist to assign (required)")
.requiredOption("--if-match <REVISION>", "Require the current resource revision (required)", revision)
.requiredOption("--expect-rev <REVISION>", "Require the current resource revision (required)", revision)
.action(bind(handleScreenAssign));

screen.command("set-timezone").description("Set a screen's timezone")
.argument("<id>", "Screen identifier")
.requiredOption("--timezone <ZONE>", "Set an IANA timezone (required)")
.requiredOption("--if-match <REVISION>", "Require the current resource revision (required)", revision)
.requiredOption("--expect-rev <REVISION>", "Require the current resource revision (required)", revision)
.action(bind(handleScreenSetTimezone));

screen.command("archive").description("Archive a screen")
.argument("<id>", "Screen identifier")
.requiredOption("--if-match <REVISION>", "Require the current resource revision (required)", revision)
.requiredOption("--expect-rev <REVISION>", "Require the current resource revision (required)", revision)
.action(bind(handleScreenArchive));

screen.command("unarchive").description("Restore an archived screen")
.argument("<id>", "Screen identifier")
.requiredOption("--if-match <REVISION>", "Require the current resource revision (required)", revision)
.requiredOption("--expect-rev <REVISION>", "Require the current resource revision (required)", revision)
.action(bind(handleScreenUnarchive));

screen.command("delete").description("Delete a screen")
.argument("<id>", "Screen identifier")
.requiredOption("--if-match <REVISION>", "Require the current resource revision (required)", revision)
.requiredOption("--expect-rev <REVISION>", "Require the current resource revision (required)", revision)
.action(bind(handleScreenDelete));

screen.command("rotate-public-id").description("Rotate a screen's public identifier")
.argument("<id>", "Screen identifier")
.requiredOption("--if-match <REVISION>", "Require the current resource revision (required)", revision)
.requiredOption("--expect-rev <REVISION>", "Require the current resource revision (required)", revision)
.action(bind(handleScreenRotatePublicId));

screen.command("toast").description("Show a temporary screen message")
Expand Down
Loading
Loading