Skip to content

Commit bb92aa8

Browse files
authored
Merge pull request #17 from screenrig/feat/cli-publish-reconciliation
Guide publish conflicts through explicit reconciliation
2 parents 3374b4e + 7bc9681 commit bb92aa8

14 files changed

Lines changed: 243 additions & 62 deletions

‎README.md‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,10 @@ The customer surface is content (`app`, `media`, `compose`), playlists, and scre
4545
Discover commands progressively with `screenrig --help`, `screenrig screen --help`,
4646
and `screenrig screen assign --help`. Deeper groups work the same way:
4747
`screenrig comment show --help`. `screenrig help screen assign` is equivalent.
48-
Add `--json` for structured child command paths, invocation syntax, and option types;
48+
Use `screenrig help --all` for the complete command inventory, or
49+
`screenrig help --all screen` for one group's descendants. Normal help lists
50+
immediate children. Add `--json` for structured command paths, positional arguments,
51+
option choices and defaults, relationships, and examples;
4952
help runs without configuration or authentication. Command-specific options follow
5053
that command, for example `screenrig screen update ID --name Lobby --if-match 1`.
5154
Global options such as `--json` may appear before or after the command. Use
@@ -199,7 +202,11 @@ repeat the identical command and input with the same config to resume. If playli
199202
creation succeeded before assignment failed, the error identifies the created
200203
playlist. Do not delete it or start another create to recover. A revision conflict
201204
requires inspecting the screen and reconciling the intended assignment; an already
202-
created playlist can be assigned explicitly with `screen assign`. Publishing never
205+
created playlist can be assigned explicitly with `screen assign`. Assignment-conflict
206+
guidance includes an inspection command and a separate assignment template with
207+
`<REVIEWED_REVISION>`. Replace that placeholder only after inspecting the screen and
208+
deciding the assignment is still intended. Structured argument arrays preserve the
209+
selected configuration and API origin without requiring shell parsing. Publishing never
203210
silently refreshes the expected revision. Unfinished recovery stops after the
204211
24-hour server idempotency window; inspect and reconcile before making more writes.
205212

‎src/argv.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export function parseArgv(argv: string[]): ParsedArgs & { help?: HelpDocument }
1818
const args = commandInput(tree.selected());
1919
if (tree.versionRequested()) args.flags.version = true;
2020
if (tree.helpRequested()) {
21-
const help = describeHelp(tree.selected());
21+
const help = describeHelp(tree.selected(), tree.inventoryRequested());
2222
return { command: help.path.slice(0, 2), positionals: help.path, flags: { ...args.flags, help: true }, help };
2323
}
2424
return args;

‎src/cli-commands/compose.ts‎

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
1-
import { usageError } from "../problems.js";
21
import { positiveInteger } from "./options.js";
32
import type { CommandActionBinder } from "./types.js";
43
import { handleComposeCatalog, handleComposeBatch, handleComposeRender } from "../commands.js";
54
import type { Command } from "commander";
6-
import { addCommandNotes } from "./notes.js";
5+
import { addCommandNotes, requireOptionGroup } from "./notes.js";
76
import { LOOK_AT_THE_CONTACT_SHEET } from "../playlist-preview.js";
87

98
export function registerComposeCommands(root: Command, bind: CommandActionBinder): void {
@@ -20,7 +19,6 @@ export function registerComposeCommands(root: Command, bind: CommandActionBinder
2019
.option("--target-height <PX>", "Set the physical content height", positiveInteger("target-height"))
2120
.option("--safe-area", "Apply the display safe area")
2221
.option("--lint-only", "Run the command's local validation/lint mode")
23-
.hook("preAction", requireTargetDimensions)
2422
.action(bind(handleComposeBatch)), `Render 1 to 2000 pages. ${LOOK_AT_THE_CONTACT_SHEET}`);
2523

2624
addCommandNotes(compose.command("render").description("Render a composition locally")
@@ -32,12 +30,8 @@ export function registerComposeCommands(root: Command, bind: CommandActionBinder
3230
.option("--safe-area", "Apply the display safe area")
3331
.option("--open", "Open the result in its viewer")
3432
.option("--lint-only", "Run the command's local validation/lint mode")
35-
.hook("preAction", requireTargetDimensions)
3633
.action(bind(handleComposeRender)), LOOK_AT_THE_CONTACT_SHEET);
37-
}
38-
39-
function requireTargetDimensions(command: Command): void {
40-
const width = command.getOptionValueSource("targetWidth") === "cli";
41-
const height = command.getOptionValueSource("targetHeight") === "cli";
42-
if (width !== height) throw usageError("Provide both --target-width and --target-height.");
34+
for (const command of compose.commands.filter((command) => ["batch", "render"].includes(command.name()))) {
35+
requireOptionGroup(command, "together", ["--target-width", "--target-height"]);
36+
}
4337
}

‎src/cli-commands/media.ts‎

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
1-
import { usageError } from "../problems.js";
21
import { positiveInteger, positiveNumber, revision } from "./options.js";
32
import type { CommandActionBinder } from "./types.js";
43
import { handleMediaGenerate, handleMediaUpload, handleMediaUploadBatch, handleMediaShow, handleMediaDownload, handleMediaList, handleMediaUpdate, handleMediaDelete } from "../commands.js";
54
import { type Command, Option } from "commander";
6-
import { addCommandNotes } from "./notes.js";
5+
import { addCommandNotes, addCommandExamples, requireOptionGroup } from "./notes.js";
76
import { CREDIT_HELP } from "../help-text.js";
87

98
export function registerMediaCommands(root: Command, bind: CommandActionBinder): void {
@@ -12,14 +11,16 @@ export function registerMediaCommands(root: Command, bind: CommandActionBinder):
1211
addCommandNotes(media.command("generate").description("Generate an image from a prompt")
1312
.addOption(new Option("--prompt <TEXT>", "Describe the complete image to generate").conflicts("promptFile"))
1413
.addOption(new Option("--prompt-file <FILE>", "Read the complete prompt from a file or stdin (-)").conflicts("prompt"))
15-
.option("--aspect-ratio <RATIO>", "Choose the generated image aspect ratio")
16-
.option("--quality <low|medium|high>", "Choose generation quality")
14+
.addOption(new Option("--aspect-ratio <RATIO>", "Choose the generated image aspect ratio").choices(["1:1", "16:9", "9:16", "4:3", "3:4", "3:2", "2:3"]).default("16:9"))
15+
.addOption(new Option("--quality <low|medium|high>", "Choose generation quality").choices(["low", "medium", "high"]).default("medium"))
1716
.option("--tag <TAG>", "Set the media tag")
1817
.option("--no-progress", "Suppress stderr progress")
19-
.hook("preAction", (command) => {
20-
if (command.getOptionValue("prompt") === undefined && command.getOptionValue("promptFile") === undefined) throw usageError("Provide --prompt TEXT or --prompt-file FILE.");
21-
})
2218
.action(bind(handleMediaGenerate)), CREDIT_HELP);
19+
const generate = media.commands.find((command) => command.name() === "generate")!;
20+
requireOptionGroup(generate, "exactlyOne", ["--prompt", "--prompt-file"]);
21+
addCommandExamples(generate,
22+
'screenrig media generate --prompt "A full-screen welcome sign"',
23+
'screenrig media generate --prompt-file prompt.txt --aspect-ratio 9:16');
2324

2425
media.command("upload").description("Upload an image or video")
2526
.argument("<file>", "Local input file")
@@ -28,12 +29,12 @@ export function registerMediaCommands(root: Command, bind: CommandActionBinder):
2829
.option("--no-wait", "Return after acceptance without waiting for processing")
2930
.option("--poll-ms <MS>", "Set the operation polling interval", positiveInteger("poll-ms"))
3031
.addOption(new Option("--no-transcode", "Upload accepted source bytes unchanged").conflicts(["codec", "maxFps", "maxEdge", "webpQuality", "preset", "audio"]))
31-
.addOption(new Option("--codec <h264|hevc>", "Choose the video codec (default: h264)").conflicts(["transcode"]))
32+
.addOption(new Option("--codec <h264|hevc>", "Choose the video codec").choices(["h264", "hevc"]).default("h264").conflicts(["transcode"]))
3233
.addOption(new Option("--max-fps <N>", "Limit video frames per second").argParser(positiveNumber("max-fps")).conflicts(["transcode"]))
3334
.addOption(new Option("--max-edge <PIXELS>", "Limit the longest video edge").argParser(positiveInteger("max-edge")).conflicts(["transcode"]))
3435
.addOption(new Option("--webp-quality <1-100>", "Set WebP quality").argParser(positiveInteger("webp-quality")).conflicts(["transcode"]))
3536
.option("--no-progress", "Suppress stderr progress")
36-
.addOption(new Option("--preset <PRESET>", "Use signage-1080p30 or signage-4k30").conflicts(["transcode"]))
37+
.addOption(new Option("--preset <PRESET>", "Choose a transcoding preset").choices(["signage-1080p30", "signage-4k30"]).conflicts(["transcode"]))
3738
.addOption(new Option("--no-audio", "Remove the audio track when transcoding").conflicts(["transcode"]))
3839
.action(bind(handleMediaUpload));
3940

@@ -45,11 +46,11 @@ export function registerMediaCommands(root: Command, bind: CommandActionBinder):
4546
.option("--tag <TAG>", "Set the media tag")
4647
.option("--no-progress", "Suppress stderr progress")
4748
.option("--poll-ms <MS>", "Set the operation polling interval", positiveInteger("poll-ms"))
48-
.addOption(new Option("--codec <h264|hevc>", "Choose the video codec (default: h264)").conflicts(["transcode"]))
49+
.addOption(new Option("--codec <h264|hevc>", "Choose the video codec").choices(["h264", "hevc"]).default("h264").conflicts(["transcode"]))
4950
.addOption(new Option("--max-fps <N>", "Limit video frames per second").argParser(positiveNumber("max-fps")).conflicts(["transcode"]))
5051
.addOption(new Option("--max-edge <PIXELS>", "Limit the longest video edge").argParser(positiveInteger("max-edge")).conflicts(["transcode"]))
5152
.addOption(new Option("--webp-quality <1-100>", "Set WebP quality").argParser(positiveInteger("webp-quality")).conflicts(["transcode"]))
52-
.addOption(new Option("--preset <PRESET>", "Use signage-1080p30 or signage-4k30").conflicts(["transcode"]))
53+
.addOption(new Option("--preset <PRESET>", "Choose a transcoding preset").choices(["signage-1080p30", "signage-4k30"]).conflicts(["transcode"]))
5354
.addOption(new Option("--no-audio", "Remove the audio track when transcoding").conflicts(["transcode"]))
5455
.action(bind(handleMediaUploadBatch));
5556

@@ -64,7 +65,7 @@ export function registerMediaCommands(root: Command, bind: CommandActionBinder):
6465

6566
media.command("list").description("List media")
6667
.option("--tag <TAG>", "Filter media by tag")
67-
.option("--primitive <image|video>", "Filter by media primitive")
68+
.addOption(new Option("--primitive <image|video>", "Filter by media primitive").choices(["image", "video"]))
6869
.action(bind(handleMediaList));
6970

7071
media.command("update").description("Update a media tag")

‎src/cli-commands/notes.ts‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { Command } from "commander";
2+
import { usageError } from "../problems.js";
23

34
const notes = new WeakMap<Command, string[]>();
45

@@ -11,3 +12,41 @@ export function addCommandNotes(command: Command, note: string): void {
1112
export function commandNotes(command: Command): string[] {
1213
return [...(notes.get(command) ?? [])];
1314
}
15+
16+
export interface OptionRelationship {
17+
kind: "exactlyOne" | "together";
18+
options: string[];
19+
}
20+
const relationships = new WeakMap<Command, OptionRelationship[]>();
21+
const examples = new WeakMap<Command, string[]>();
22+
23+
/** One definition drives both preflight validation and discovery. */
24+
export function requireOptionGroup(command: Command, kind: OptionRelationship["kind"], names: string[]): void {
25+
const options = names.map((name) => {
26+
const option = command.options.find((candidate) => candidate.long === name);
27+
if (!option) throw new Error(`Unknown option ${name}`);
28+
return option;
29+
});
30+
relationships.set(command, [...(relationships.get(command) ?? []), { kind, options: names }]);
31+
const message = kind === "exactlyOne"
32+
? `Provide exactly one of ${names.join(" or ")}.`
33+
: `Provide ${names.join(" and ")} together.`;
34+
addCommandNotes(command, message);
35+
command.hook("preAction", () => {
36+
const count = options.filter((option) => command.getOptionValueSource(option.attributeName()) === "cli").length;
37+
if (kind === "exactlyOne" ? count !== 1 : count !== 0 && count !== options.length) throw usageError(message);
38+
});
39+
}
40+
41+
export function commandRelationships(command: Command): OptionRelationship[] {
42+
return relationships.get(command) ?? [];
43+
}
44+
45+
export function addCommandExamples(command: Command, ...commands: string[]): void {
46+
examples.set(command, [...(examples.get(command) ?? []), ...commands]);
47+
command.addHelpText("after", `\nExamples:\n${commands.map((example) => ` ${example}`).join("\n")}`);
48+
}
49+
50+
export function commandExamples(command: Command): string[] {
51+
return examples.get(command) ?? [];
52+
}

‎src/cli-commands/playlist.ts‎

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,27 @@
1-
import { usageError } from "../problems.js";
21
import { nonnegativeInteger, positiveInteger, revision } from "./options.js";
32
import type { CommandActionBinder } from "./types.js";
43
import { handlePlaylistInit, handlePlaylistValidate, handlePlaylistPreview, handlePlaylistTemplates, handlePlaylistCreate, handlePlaylistUpdate, handlePlaylistExport, handlePlaylistImport, handlePlaylistShow, handlePlaylistList, handlePlaylistDelete } from "../commands.js";
5-
import type { Command } from "commander";
6-
import { addCommandNotes } from "./notes.js";
4+
import { type Command, Option } from "commander";
5+
import { addCommandNotes, addCommandExamples, requireOptionGroup } from "./notes.js";
76
import { LOOK_AT_THE_CONTACT_SHEET } from "../playlist-preview.js";
87

98
export function registerPlaylistCommands(root: Command, bind: CommandActionBinder): void {
109
const playlist = root.command("playlist").description("Author, validate, preview, and manage playlists");
1110

12-
playlist.command("init").description("Prepare an editable full-screen playlist from ready media")
11+
const init = playlist.command("init").description("Prepare an editable full-screen playlist from ready media")
1312
.argument("<media-ids...>", "Media identifiers in playback order")
1413
.requiredOption("--name <NAME>", "Playlist name")
1514
.requiredOption("--output <FILE>", "Create an editable playlist file")
1615
.option("--screen <ID>", "Use this screen's reported playback dimensions")
1716
.option("--target-width <PX>", "Override canvas width", positiveInteger("target-width"))
1817
.option("--target-height <PX>", "Override canvas height", positiveInteger("target-height"))
19-
.option("--duration-ms <MS>", "Image page duration (default: 8000)", positiveInteger("duration-ms"))
20-
.option("--fit <FIT>", "Content fit: contain (default), cover, or fill")
18+
.addOption(new Option("--duration-ms <MS>", "Image page duration").argParser(positiveInteger("duration-ms")).default(8000))
19+
.addOption(new Option("--fit <FIT>", "Content fit").choices(["contain", "cover", "fill"]).default("contain"))
2120
.action(bind(handlePlaylistInit));
21+
requireOptionGroup(init, "together", ["--target-width", "--target-height"]);
22+
addCommandNotes(init, "Provide --screen for reported dimensions, or both target dimensions to set the canvas explicitly.");
23+
addCommandExamples(init, 'screenrig playlist init med_IMAGE --name Lobby --screen scr_SCREEN --output lobby.json',
24+
'screenrig playlist init med_IMAGE med_VIDEO --name Lobby --target-width 1920 --target-height 1080 --output lobby.json');
2225

2326
playlist.command("validate").description("Validate a playlist file")
2427
.argument("<file>", "Local input file")
@@ -51,25 +54,23 @@ export function registerPlaylistCommands(root: Command, bind: CommandActionBinde
5154
.requiredOption("--output <PATH>", "Write the exported bundle to this directory (required)")
5255
.action(bind(handlePlaylistExport));
5356

54-
playlist.command("import").description("Import a playlist bundle")
57+
const importCommand = playlist.command("import").description("Import a playlist bundle")
5558
.argument("<directory>", "Local directory")
5659
.option("--name <NAME>", "Set the imported playlist name")
5760
.option("--update <ID>", "Update this playlist when importing")
5861
.option("--expect-rev <REVISION>", "Require the current resource revision", revision)
5962
.option("--poll-ms <MS>", "Set the operation polling interval", positiveInteger("poll-ms"))
60-
.hook("preAction", (command) => {
61-
const update = command.getOptionValueSource("update") === "cli";
62-
const ifMatch = command.getOptionValueSource("expectRev") === "cli";
63-
if (update !== ifMatch) throw usageError("playlist import --update and --expect-rev must be supplied together.");
64-
})
6563
.action(bind(handlePlaylistImport));
64+
requireOptionGroup(importCommand, "together", ["--update", "--expect-rev"]);
6665

67-
playlist.command("show").description("Inspect a playlist or write an editable document")
66+
const show = playlist.command("show").description("Inspect a playlist or write an editable document")
6867
.option("--output <FILE>", "Create an editable playlist file; report its revision on stdout")
6968
.option("--editable", "Return an editable document and revision in the JSON envelope")
7069
.alias("get")
7170
.argument("<id>", "Playlist identifier")
7271
.action(bind(handlePlaylistShow));
72+
addCommandExamples(show, 'screenrig playlist show pl_PLAYLIST --output playlist.json',
73+
'screenrig playlist show pl_PLAYLIST --editable');
7374

7475
playlist.command("list").description("List playlists")
7576
.action(bind(handlePlaylistList));

‎src/cli-errors.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,15 @@ export function commandError(error: CommanderError, command: Command): never {
1717
}
1818
if (path === "media list" && has("kind")) throw usageError("media list uses --primitive image|video, not --kind.");
1919
switch (error.code) {
20+
case "commander.invalidArgument": {
21+
const option = command.options.find((candidate) => candidate.argChoices && error.message.startsWith(`error: option '${candidate.flags}'`));
22+
if (option?.argChoices) {
23+
const choices = option.argChoices;
24+
const allowed = choices.length > 2 ? `${choices.slice(0, -1).join(", ")}, or ${choices.at(-1)}` : choices.join(" or ");
25+
throw usageError(`${option.long} must be ${allowed}.`);
26+
}
27+
throw usageError("Invalid argument. See command help for supported values.");
28+
}
2029
case "commander.missingArgument":
2130
if (path === "screen set-timezone") throw usageError("screen set-timezone requires <id> --timezone --expect-rev.");
2231
throw usageError(`${path} requires ${command.registeredArguments.filter((arg) => arg.required).map((arg) => `<${arg.name()}>`).join(" ")}.`);
@@ -38,4 +47,3 @@ export function commandError(error: CommanderError, command: Command): never {
3847
throw usageError("Unknown command or unsupported option. See command help for supported arguments.", { command: `screenrig${path ? ` ${path}` : ""} --help`, reason: "List supported commands, arguments, and options." });
3948
}
4049
}
41-

‎src/command-tree.ts‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export interface CommandTree {
1414
selected: () => Command;
1515
helpRequested: () => boolean;
1616
versionRequested: () => boolean;
17+
inventoryRequested: () => boolean;
1718
}
1819

1920
export function commandInput(command: Command): ParsedArgs {
@@ -60,15 +61,18 @@ export function createCommandTree(
6061
let selected = root;
6162
let helpRequested = false;
6263
let versionRequested = false;
64+
let inventoryRequested = false;
6365
const seen = new Set<string>();
6466

6567
registerCommands(root, (handler) => (...values: unknown[]) => {
6668
selected = values[values.length - 1] as Command;
6769
return execute?.(handler, commandInput(selected));
6870
});
6971
root.command("help [command...]")
70-
.description("Discover commands and their options")
71-
.action((path: string[]) => {
72+
.description("Discover commands; --all lists every command")
73+
.option("--all", "Include all descendant command paths")
74+
.action((path: string[], options: { all?: boolean }) => {
75+
inventoryRequested = options.all === true;
7276
const target = findCommand(root, path);
7377
if (!target) throw usageError("Unknown help topic.");
7478
target.help();
@@ -96,5 +100,5 @@ export function createCommandTree(
96100
command.commands.forEach(configure);
97101
};
98102
configure(root);
99-
return { root, selected: () => selected, helpRequested: () => helpRequested, versionRequested: () => versionRequested };
103+
return { root, selected: () => selected, helpRequested: () => helpRequested, versionRequested: () => versionRequested, inventoryRequested: () => inventoryRequested };
100104
}

0 commit comments

Comments
 (0)