@odori/cli 0.0.4 → 0.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@odori/cli",
3
- "version": "0.0.4",
3
+ "version": "0.0.5",
4
4
  "type": "module",
5
5
  "description": "The odori command line: discovery, Studio, component installation, stills, tests, and export jobs.",
6
6
  "license": "Apache-2.0",
@@ -28,7 +28,7 @@
28
28
  "playwright-core": "1.55.0",
29
29
  "tsx": "4.20.5",
30
30
  "vite": "7.3.0",
31
- "odori": "0.0.4"
31
+ "odori": "0.0.5"
32
32
  },
33
33
  "devDependencies": {
34
34
  "@types/node": "22.19.0",
@@ -36,7 +36,7 @@
36
36
  "@types/react-dom": "19.2.3",
37
37
  "tsup": "^8.5.1",
38
38
  "typescript": "5.9.3",
39
- "@odori/registry": "0.0.4"
39
+ "@odori/registry": "0.0.5"
40
40
  },
41
41
  "publishConfig": {
42
42
  "access": "public"
package/src/cli.ts CHANGED
@@ -15,6 +15,19 @@ import {testCommand} from "./commands/test";
15
15
 
16
16
  type Flags = Record<string, string | boolean>;
17
17
 
18
+ /**
19
+ * Flags that are on or off, and therefore never take the next word.
20
+ *
21
+ * Without this list `odori add --force title-reveal` reads "force" as the
22
+ * string "title-reveal": the flag does nothing and the component quietly
23
+ * vanishes from the list, which is two failures for the price of one and
24
+ * neither of them says anything. A switch has no value to take.
25
+ */
26
+ const BOOLEAN_FLAGS = new Set(["force", "dry-run", "json", "no-audio", "no-frame-skip", "no-open", "open", "help", "version"]);
27
+
28
+ /** Commands that used to exist under another name. */
29
+ const RENAMED: Record<string, string> = {still: "frame"};
30
+
18
31
  export const parseArgs = (argv: string[]): {command: string; positionals: string[]; flags: Flags} => {
19
32
  const [command = "help", ...rest] = argv;
20
33
  const positionals: string[] = [];
@@ -31,7 +44,7 @@ export const parseArgs = (argv: string[]): {command: string; positionals: string
31
44
  }
32
45
  const name = token.slice(2);
33
46
  const next = rest[index + 1];
34
- if (next === undefined || next.startsWith("--")) flags[name] = true;
47
+ if (BOOLEAN_FLAGS.has(name) || next === undefined || next.startsWith("--")) flags[name] = true;
35
48
  else {
36
49
  flags[name] = next;
37
50
  index += 1;
@@ -86,7 +99,7 @@ const COMMAND_FLAGS: Record<string, string[]> = {
86
99
  inspect: ["json", "input"],
87
100
  frame: ["at", "output", "input"],
88
101
  test: ["json"],
89
- export: ["output", "input", "concurrency", "preset", "format", "quality", "scale", "no-frame-skip", "retry"],
102
+ export: ["output", "input", "concurrency", "preset", "format", "quality", "scale", "no-audio", "no-frame-skip", "retry"],
90
103
  jobs: [],
91
104
  help: [],
92
105
  };
@@ -168,11 +181,12 @@ const USAGE: Record<string, string> = {
168
181
  check, for CI.`,
169
182
  export: `odori export <id> [--output <path>] [--input <json>] [--concurrency <n>]
170
183
  [--preset <name>] [--format <name>] [--quality <tier>] [--scale <n>]
171
- [--no-frame-skip] [--retry <job>]
184
+ [--no-audio] [--no-frame-skip] [--retry <job>]
172
185
  Render and encode a distributable file. --format is mp4, webm, prores, gif,
173
186
  or png; without it the output's extension decides, and mp4 is the default.
174
187
  --quality is studio, social, or web. --scale multiplies the output size,
175
- 0.25 to 2. A retry keeps the settings its job was created with.`,
188
+ 0.25 to 2. --no-audio writes the picture with no sound. A retry keeps the
189
+ settings its job was created with.`,
176
190
  jobs: `odori jobs
177
191
  List export jobs and their status.`,
178
192
  };
@@ -302,6 +316,7 @@ export const run = async (argv: string[]): Promise<number> => {
302
316
  quality: typeof flags.quality === "string" ? flags.quality : undefined,
303
317
  scale: numberFlag(flags, "scale"),
304
318
  format: typeof flags.format === "string" ? flags.format : undefined,
319
+ audio: flags["no-audio"] === true ? false : undefined,
305
320
  skipUnchangedFrames: flags["no-frame-skip"] === true ? false : undefined,
306
321
  retry: typeof flags.retry === "string" ? flags.retry : undefined,
307
322
  });
@@ -315,6 +330,13 @@ export const run = async (argv: string[]): Promise<number> => {
315
330
  log.info(HELP);
316
331
  return 0;
317
332
  default: {
333
+ // A rename is not a typo, and edit distance will never connect the old
334
+ // name to the new one. Say what happened instead of listing everything.
335
+ const renamed = RENAMED[command];
336
+ if (renamed) {
337
+ log.error(`"odori ${command}" is now "odori ${renamed}".`);
338
+ return 1;
339
+ }
318
340
  const commands = Object.keys(COMMAND_FLAGS).filter((name) => name !== "help");
319
341
  const suggestion = nearest(command, commands);
320
342
  log.error(`Unknown command "${command}".${suggestion ? ` Did you mean "odori ${suggestion}"?` : ""}`);
@@ -30,6 +30,10 @@ export const addCommand = async (names: string[], options: {force?: boolean; dry
30
30
 
31
31
  const queue = [...names.map(normalizeComponentName)];
32
32
  const installed: string[] = [];
33
+ /* Kept so the summary can repeat itself at the end. Installing a library is
34
+ hundreds of lines of output, and a warning at line 40 is a warning nobody
35
+ read. */
36
+ const kept: string[] = [];
33
37
 
34
38
  while (queue.length > 0) {
35
39
  const name = queue.shift()!;
@@ -103,7 +107,8 @@ export const addCommand = async (names: string[], options: {force?: boolean; dry
103
107
  const current = hashString(await readFile(destination, "utf8"));
104
108
  const recorded = provenance[component.name]?.hashes[name];
105
109
  if (current !== recorded) {
106
- log.warn(`${relative(config.root, destination)} was modified locally. Keeping your version. Use --force to replace it.`);
110
+ log.warn(`${relative(config.root, destination)} was modified locally. Keeping your version.`);
111
+ kept.push(relative(config.root, destination));
107
112
  continue;
108
113
  }
109
114
  }
@@ -147,6 +152,12 @@ export const addCommand = async (names: string[], options: {force?: boolean; dry
147
152
  return;
148
153
  }
149
154
  await writeProvenance(config, provenance);
155
+
156
+ if (kept.length > 0) {
157
+ log.warn(`Kept ${kept.length} locally modified ${kept.length === 1 ? "file" : "files"}:`);
158
+ for (const file of kept) log.detail(` ${file}`);
159
+ log.detail(`Run odori diff to see what upstream changed, or odori add <name> --force to replace them.`);
160
+ }
150
161
  log.detail("Run odori dev to preview the installed component fixtures.");
151
162
  };
152
163
 
@@ -174,7 +174,10 @@ export const devCommand = async (options: {port?: number; root?: string; open?:
174
174
  exportDestination(config),
175
175
  `${outputName(video.entry.metadata.id)}${format.extension}`,
176
176
  );
177
- const record = await createJob(config, manifest, output, {format: format.name, quality, scale});
177
+ // Frozen with the job: a retry from the CLI has to produce the
178
+ // same file, silence included.
179
+ const audio = body.audio !== false;
180
+ const record = await createJob(config, manifest, output, {format: format.name, quality, scale, audio});
178
181
  json(response, 202, record.job);
179
182
 
180
183
  void runJob(config, origin, record, video).catch((error) => {
@@ -36,6 +36,7 @@ export const runJob = async (
36
36
  quality?: Quality;
37
37
  scale?: number;
38
38
  format?: VideoFormat;
39
+ audio?: boolean;
39
40
  skipUnchangedFrames?: boolean;
40
41
  signal?: AbortSignal;
41
42
  onProgress?: (job: ExportJob) => void;
@@ -94,6 +95,7 @@ export const runJob = async (
94
95
  quality: options.quality ?? (record.render?.quality as Quality | undefined),
95
96
  scale: options.scale ?? record.render?.scale,
96
97
  format: options.format ?? (record.render?.format ? resolveFormat(record.render.format, record.output) : undefined),
98
+ audio: options.audio ?? record.render?.audio,
97
99
  skipUnchangedFrames: options.skipUnchangedFrames,
98
100
  signal: controller.signal,
99
101
  onTimings: (timings) => {
@@ -151,6 +153,8 @@ export const exportCommand = async (
151
153
  quality?: string;
152
154
  scale?: number;
153
155
  format?: string;
156
+ /** False writes the picture with no audio track. */
157
+ audio?: boolean;
154
158
  skipUnchangedFrames?: boolean;
155
159
  retry?: string;
156
160
  } = {},
@@ -183,6 +187,7 @@ export const exportCommand = async (
183
187
  format: format.name,
184
188
  quality,
185
189
  scale,
190
+ audio: options.audio !== false,
186
191
  ...(options.preset ? {preset: options.preset} : {}),
187
192
  });
188
193
  })();
@@ -201,6 +206,7 @@ export const exportCommand = async (
201
206
  quality: options.retry && options.quality === undefined ? undefined : quality,
202
207
  scale: options.retry && options.scale === undefined ? undefined : scale,
203
208
  format: options.retry ? undefined : format,
209
+ audio: options.audio,
204
210
  skipUnchangedFrames: options.skipUnchangedFrames,
205
211
  onProgress: (next) => {
206
212
  if (next.status === "rendering" || next.status === "encoding") {
@@ -1,5 +1,6 @@
1
- import {mkdir, readFile, writeFile} from "node:fs/promises";
1
+ import {mkdir, readFile, rm, writeFile} from "node:fs/promises";
2
2
  import {existsSync} from "node:fs";
3
+ import {readdir} from "node:fs/promises";
3
4
  import {relative, resolve} from "node:path";
4
5
  import {hashString} from "odori";
5
6
  import {loadConfig, type ResolvedConfig} from "../config";
@@ -28,17 +29,45 @@ export type ComponentStatus = {
28
29
  }>;
29
30
  };
30
31
 
31
- const provenanceFile = (config: ResolvedConfig) => resolve(config.root, config.outDir, "components.json");
32
+ /**
33
+ * The record of what was installed, and at which version.
34
+ *
35
+ * It sits beside the config and is meant to be committed. It used to live in
36
+ * `outDir`, which is generated and gitignored, so it did not survive a clone
37
+ * or a cleared render cache: `odori update` and `odori diff` would report an
38
+ * empty project while the components sat right there in the tree, and the
39
+ * suggested fix - run `odori add` - was the one thing that had already been
40
+ * done. A lockfile is only useful to the next person if it is in the repo.
41
+ */
42
+ export const LOCKFILE = "odori.lock.json";
43
+
44
+ const provenanceFile = (config: ResolvedConfig) => resolve(config.root, LOCKFILE);
45
+
46
+ /** Where it used to live, read once so an existing project keeps its history. */
47
+ const legacyProvenanceFile = (config: ResolvedConfig) =>
48
+ resolve(config.root, config.outDir, "components.json");
32
49
 
33
50
  export const readProvenance = async (config: ResolvedConfig): Promise<Provenance> => {
34
- const file = provenanceFile(config);
51
+ const file = existsSync(provenanceFile(config))
52
+ ? provenanceFile(config)
53
+ : legacyProvenanceFile(config);
35
54
  if (!existsSync(file)) return {};
36
- return JSON.parse(await readFile(file, "utf8")) as Provenance;
55
+ try {
56
+ return JSON.parse(await readFile(file, "utf8")) as Provenance;
57
+ } catch {
58
+ // A truncated or hand-mangled lockfile should cost you the update, not the
59
+ // command: treat it as unknown and let the next install rewrite it.
60
+ log.warn(`${relative(config.root, file)} is not readable JSON. Ignoring it.`);
61
+ return {};
62
+ }
37
63
  };
38
64
 
39
65
  export const writeProvenance = async (config: ResolvedConfig, provenance: Provenance) => {
40
- await mkdir(resolve(config.root, config.outDir), {recursive: true});
66
+ await mkdir(config.root, {recursive: true});
41
67
  await writeFile(provenanceFile(config), `${JSON.stringify(provenance, null, 2)}\n`, "utf8");
68
+ // Moved, not copied: leaving the old one behind means the next reader has to
69
+ // guess which of the two is current.
70
+ await rm(legacyProvenanceFile(config), {force: true});
42
71
  };
43
72
 
44
73
  /**
@@ -109,11 +138,33 @@ const LABELS: Record<ComponentState, string> = {
109
138
  missing: "files missing",
110
139
  };
111
140
 
141
+ /**
142
+ * What to say when there is nothing to compare against.
143
+ *
144
+ * "Run odori add first" is the wrong advice if the components are already on
145
+ * disk: the missing piece is the lockfile, not the install, and telling
146
+ * somebody to do the thing they just did is how a tool loses their trust.
147
+ */
148
+ const explainEmpty = async (config: ResolvedConfig, named: string[]) => {
149
+ if (named.length > 0) {
150
+ log.detail(`${named.join(", ")} ${named.length === 1 ? "is" : "are"} not recorded in ${LOCKFILE}.`);
151
+ return;
152
+ }
153
+ const components = resolve(config.root, config.componentsDir);
154
+ const installed = existsSync(components) ? (await readdir(components)).filter((e) => !e.startsWith(".")) : [];
155
+ if (installed.length === 0) {
156
+ log.detail("No registry components are installed yet. Run odori add first.");
157
+ return;
158
+ }
159
+ log.warn(`${installed.length} components are in ${config.componentsDir} but none are recorded in ${LOCKFILE}.`);
160
+ log.detail("Run odori add <name> to re-record them, or commit the lockfile if a teammate has one.");
161
+ };
162
+
112
163
  export const diffCommand = async (names: string[], options: {full?: boolean} = {}) => {
113
164
  const config = await loadConfig(process.cwd());
114
165
  const statuses = await componentStatus(config, names.length > 0 ? names : undefined);
115
166
  if (statuses.length === 0) {
116
- log.detail("No registry components are installed yet. Run odori add first.");
167
+ await explainEmpty(config, names);
117
168
  return;
118
169
  }
119
170
 
@@ -141,7 +192,7 @@ export const updateCommand = async (names: string[], options: {force?: boolean}
141
192
  const config = await loadConfig(process.cwd());
142
193
  const statuses = await componentStatus(config, names.length > 0 ? names : undefined);
143
194
  if (statuses.length === 0) {
144
- log.detail("No registry components are installed yet. Run odori add first.");
195
+ await explainEmpty(config, names);
145
196
  return;
146
197
  }
147
198
 
package/src/jobs.ts CHANGED
@@ -5,7 +5,7 @@ import type {ExportJob, RenderManifest} from "odori";
5
5
  import type {ResolvedConfig} from "./config";
6
6
 
7
7
  /** How a job should be encoded, frozen with it so a retry cannot drift. */
8
- export type JobRender = {format?: string; quality?: string; scale?: number; preset?: string};
8
+ export type JobRender = {format?: string; quality?: string; scale?: number; preset?: string; audio?: boolean};
9
9
 
10
10
  export type JobRecord = {job: ExportJob; manifest: RenderManifest; output: string; render?: JobRender};
11
11