@odori/cli 0.0.11 → 0.0.12
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/LICENSE +22 -0
- package/dist/{chunk-77R3UC27.js → chunk-FS2BJU5Q.js} +741 -72
- package/dist/cli.js +1 -1
- package/dist/index.d.ts +95 -1
- package/dist/index.js +5 -1
- package/dist/{registry-snapshot-TKH2KAC3.js → registry-snapshot-TSTAA6NT.js} +1815 -245
- package/package.json +17 -20
- package/src/cli.ts +29 -0
- package/src/commands/blocks.ts +188 -0
- package/src/commands/docs.ts +88 -0
- package/src/commands/doctor.ts +38 -0
- package/src/commands/graph.ts +141 -0
- package/src/commands/test.ts +10 -1
- package/src/discovery.ts +8 -2
- package/src/docs-snapshot.json +130 -0
- package/src/docs.ts +54 -0
- package/src/index.ts +2 -0
- package/src/registry-snapshot.json +1647 -206
- package/src/registry-source.ts +139 -0
- package/src/structure.ts +177 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@odori/cli",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.12",
|
|
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",
|
|
@@ -8,7 +8,10 @@
|
|
|
8
8
|
"odori": "./bin/odori.mjs"
|
|
9
9
|
},
|
|
10
10
|
"exports": {
|
|
11
|
-
".":
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"import": "./dist/index.js"
|
|
14
|
+
}
|
|
12
15
|
},
|
|
13
16
|
"files": [
|
|
14
17
|
"bin",
|
|
@@ -16,23 +19,16 @@
|
|
|
16
19
|
"src",
|
|
17
20
|
"studio"
|
|
18
21
|
],
|
|
19
|
-
"scripts": {
|
|
20
|
-
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.studio.json",
|
|
21
|
-
"build": "tsup",
|
|
22
|
-
"snapshot": "tsx scripts/snapshot.ts",
|
|
23
|
-
"verify:package": "tsx scripts/verify-package.ts",
|
|
24
|
-
"prepack": "pnpm snapshot && pnpm build && pnpm verify:package"
|
|
25
|
-
},
|
|
26
22
|
"dependencies": {
|
|
27
23
|
"@puppeteer/browsers": "3.2.0",
|
|
28
24
|
"@vitejs/plugin-react": "5.1.2",
|
|
29
25
|
"ffmpeg-static": "5.3.0",
|
|
30
|
-
"odori": "workspace:*",
|
|
31
26
|
"react": "19.2.3",
|
|
32
27
|
"react-dom": "19.2.3",
|
|
33
28
|
"playwright-core": "1.55.0",
|
|
34
29
|
"tsx": "4.20.5",
|
|
35
|
-
"vite": "7.3.0"
|
|
30
|
+
"vite": "7.3.0",
|
|
31
|
+
"odori": "0.0.12"
|
|
36
32
|
},
|
|
37
33
|
"devDependencies": {
|
|
38
34
|
"@types/node": "22.19.0",
|
|
@@ -40,15 +36,16 @@
|
|
|
40
36
|
"@types/react-dom": "19.2.3",
|
|
41
37
|
"tsup": "^8.5.1",
|
|
42
38
|
"typescript": "5.9.3",
|
|
43
|
-
"@odori/registry": "
|
|
39
|
+
"@odori/registry": "0.0.12"
|
|
44
40
|
},
|
|
45
41
|
"publishConfig": {
|
|
46
|
-
"access": "public"
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
42
|
+
"access": "public"
|
|
43
|
+
},
|
|
44
|
+
"scripts": {
|
|
45
|
+
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.studio.json",
|
|
46
|
+
"build": "tsup",
|
|
47
|
+
"snapshot": "tsx scripts/snapshot.ts",
|
|
48
|
+
"verify:package": "tsx scripts/verify-package.ts",
|
|
49
|
+
"snapshot:docs": "tsx scripts/docs-snapshot.ts"
|
|
53
50
|
}
|
|
54
|
-
}
|
|
51
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -3,6 +3,7 @@ import {log} from "./log";
|
|
|
3
3
|
import {addCommand, registryCommand} from "./commands/add";
|
|
4
4
|
import {diffCommand, updateCommand} from "./commands/update";
|
|
5
5
|
import {devCommand} from "./commands/dev";
|
|
6
|
+
import {docsCommand} from "./commands/docs";
|
|
6
7
|
import {doctorCommand} from "./commands/doctor";
|
|
7
8
|
import {installCommand} from "./binaries";
|
|
8
9
|
import {exportCommand, jobsCommand} from "./commands/exportVideo";
|
|
@@ -12,8 +13,10 @@ import {inspectCommand} from "./commands/inspect";
|
|
|
12
13
|
import {listCommand} from "./commands/list";
|
|
13
14
|
import {newCommand} from "./commands/new";
|
|
14
15
|
import {bedCommand} from "./commands/bed";
|
|
16
|
+
import {blocksCommand} from "./commands/blocks";
|
|
15
17
|
import {narrateCommand} from "./commands/narrate";
|
|
16
18
|
import {frameCommand} from "./commands/frame";
|
|
19
|
+
import {graphCommand} from "./commands/graph";
|
|
17
20
|
import {testCommand} from "./commands/test";
|
|
18
21
|
|
|
19
22
|
type Flags = Record<string, string | boolean>;
|
|
@@ -100,6 +103,7 @@ const COMMAND_FLAGS: Record<string, string[]> = {
|
|
|
100
103
|
diff: ["full"],
|
|
101
104
|
update: ["force"],
|
|
102
105
|
list: [],
|
|
106
|
+
graph: ["json"],
|
|
103
107
|
inspect: ["json", "input"],
|
|
104
108
|
frame: ["at", "output", "input"],
|
|
105
109
|
bed: ["role", "output", "target", "generate", "provider", "seconds"],
|
|
@@ -184,6 +188,11 @@ const USAGE: Record<string, string> = {
|
|
|
184
188
|
add: `odori add <components...> [--force] [--dry-run]
|
|
185
189
|
Install editable component source, fetched from the registry and cached.
|
|
186
190
|
--force replaces local edits. --dry-run lists the files and writes nothing.`,
|
|
191
|
+
blocks: `odori blocks <ls|show|cat|add> [id] [file] [--out <dir>] [--force] [--dry-run] [--json]
|
|
192
|
+
Whole videos, published like components. ls lists them, show names the files
|
|
193
|
+
and components one is built from, cat prints one of those files, and add
|
|
194
|
+
installs the block and everything it imports. --out installs into another
|
|
195
|
+
directory rather than the one the block was authored in.`,
|
|
187
196
|
registry: `odori registry
|
|
188
197
|
List available registry components and cues.`,
|
|
189
198
|
diff: `odori diff [components] [--full]
|
|
@@ -192,6 +201,11 @@ const USAGE: Record<string, string> = {
|
|
|
192
201
|
Apply upstream component changes.`,
|
|
193
202
|
list: `odori list
|
|
194
203
|
Print discovered video ids and formats.`,
|
|
204
|
+
graph: `odori graph [--json]
|
|
205
|
+
Compile the project into .odori/graph.json: every video with its format,
|
|
206
|
+
duration, brand, tags, audio variants, and components, plus the component
|
|
207
|
+
catalog, the audio library, and structure findings. --json prints the same
|
|
208
|
+
document to stdout. Exits non-zero when the structure has errors.`,
|
|
195
209
|
inspect: `odori inspect <id> [--json] [--input <json>]
|
|
196
210
|
Show resolved layout, inputs, scenes, and assets.`,
|
|
197
211
|
frame: `odori frame <id> --at <time> [--output <path>] [--input <json>]
|
|
@@ -221,14 +235,17 @@ const HELP = `odori - build videos like applications
|
|
|
221
235
|
Usage
|
|
222
236
|
odori dev [--port 4300] Discover project resources and start Studio
|
|
223
237
|
odori init Add videos/ and odori.config.ts to a project
|
|
238
|
+
odori docs [page|search <text>] Read the documentation offline
|
|
224
239
|
odori doctor Check everything a render and an encode need
|
|
225
240
|
odori install Download the pinned Chrome and FFmpeg
|
|
226
241
|
odori new <name> Generate a video.tsx entry
|
|
227
242
|
odori add <components...> Install editable component source
|
|
243
|
+
odori blocks <ls|show|add> Browse and install whole videos
|
|
228
244
|
odori registry List available registry components
|
|
229
245
|
odori diff [components] Compare installed components with upstream
|
|
230
246
|
odori update [components] Apply upstream component changes
|
|
231
247
|
odori list Print discovered video ids and formats
|
|
248
|
+
odori graph [--json] Compile the project graph and check its shape
|
|
232
249
|
odori inspect <id> [--json] Show resolved layout, inputs, scenes, assets
|
|
233
250
|
odori frame <id> --at 4s Render one deterministic frame to a PNG
|
|
234
251
|
odori test [id] [--json] Validate contracts and representative frames
|
|
@@ -299,6 +316,8 @@ export const run = async (argv: string[]): Promise<number> => {
|
|
|
299
316
|
case "integrations":
|
|
300
317
|
await integrationsCommand();
|
|
301
318
|
return 0;
|
|
319
|
+
case "docs":
|
|
320
|
+
return await docsCommand(positionals, {json: flags.json === true});
|
|
302
321
|
case "doctor":
|
|
303
322
|
return await doctorCommand();
|
|
304
323
|
case "install":
|
|
@@ -309,6 +328,14 @@ export const run = async (argv: string[]): Promise<number> => {
|
|
|
309
328
|
case "add":
|
|
310
329
|
await addCommand(positionals, {force: flags.force === true, dryRun: flags["dry-run"] === true});
|
|
311
330
|
return 0;
|
|
331
|
+
case "blocks":
|
|
332
|
+
await blocksCommand(positionals, {
|
|
333
|
+
out: typeof flags.out === "string" ? flags.out : undefined,
|
|
334
|
+
force: flags.force === true,
|
|
335
|
+
dryRun: flags["dry-run"] === true,
|
|
336
|
+
json: flags.json === true,
|
|
337
|
+
});
|
|
338
|
+
return 0;
|
|
312
339
|
case "registry":
|
|
313
340
|
await registryCommand();
|
|
314
341
|
return 0;
|
|
@@ -321,6 +348,8 @@ export const run = async (argv: string[]): Promise<number> => {
|
|
|
321
348
|
case "list":
|
|
322
349
|
await listCommand();
|
|
323
350
|
return 0;
|
|
351
|
+
case "graph":
|
|
352
|
+
return await graphCommand({json: flags.json === true});
|
|
324
353
|
case "inspect":
|
|
325
354
|
await inspectCommand(positionals[0] ?? "", {json: flags.json === true, input: parseInput(flags)});
|
|
326
355
|
return 0;
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import {existsSync} from "node:fs";
|
|
2
|
+
import {mkdir, writeFile} from "node:fs/promises";
|
|
3
|
+
import {dirname} from "node:path";
|
|
4
|
+
import {loadConfig} from "../config";
|
|
5
|
+
import {log} from "../log";
|
|
6
|
+
import {
|
|
7
|
+
normalizeComponentName,
|
|
8
|
+
resolveBlock,
|
|
9
|
+
resolveBlocks,
|
|
10
|
+
resolveWithinRoot,
|
|
11
|
+
verifyBlockIntegrity,
|
|
12
|
+
type BlockDocument,
|
|
13
|
+
} from "../registry-source";
|
|
14
|
+
import {addCommand} from "./add";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Blocks from the command line.
|
|
18
|
+
*
|
|
19
|
+
* `odori add` installs a component and `create odori` scaffolds a project, so
|
|
20
|
+
* the largest thing the catalog ships was the one thing a reader had to
|
|
21
|
+
* rebuild by hand: open the page, copy each file, paste it in. `add` closes
|
|
22
|
+
* that, and the other three exist so running it is not a leap — see what a
|
|
23
|
+
* block is, read a file, then take it.
|
|
24
|
+
*
|
|
25
|
+
* Everything a block install must get right is already solved for components,
|
|
26
|
+
* so none of it is rewritten here: resolution and its cache, the integrity
|
|
27
|
+
* hash over the exact bytes, the refusal to write outside the project, and
|
|
28
|
+
* the component install itself, which `add` performs by calling the same
|
|
29
|
+
* command a person would.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/** Where a block's files land, once `--out` has had its say. */
|
|
33
|
+
const rebase = (block: BlockDocument, target: string, out?: string): string => {
|
|
34
|
+
if (!out) return target;
|
|
35
|
+
const home = block.entry.slice(0, block.entry.lastIndexOf("/"));
|
|
36
|
+
// Only the block's own directory moves. A target that somehow sits outside
|
|
37
|
+
// it keeps its place rather than being silently reparented.
|
|
38
|
+
if (!target.startsWith(`${home}/`)) return target;
|
|
39
|
+
return `${out.replace(/\/$/, "")}/${target.slice(home.length + 1)}`;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const findBlock = async (config: Awaited<ReturnType<typeof loadConfig>>, id: string) => {
|
|
43
|
+
const index = await resolveBlocks(config);
|
|
44
|
+
const summary = index.items.find((item) => item.id === id);
|
|
45
|
+
if (!summary) {
|
|
46
|
+
const names = index.items.map((item) => item.id).join(", ");
|
|
47
|
+
throw new Error(`No block named ${JSON.stringify(id)}. Available: ${names}`);
|
|
48
|
+
}
|
|
49
|
+
return {summary, index};
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export const blocksListCommand = async (options: {json?: boolean} = {}) => {
|
|
53
|
+
const config = await loadConfig(process.cwd());
|
|
54
|
+
const {items, origin, detail} = await resolveBlocks(config);
|
|
55
|
+
|
|
56
|
+
if (options.json) {
|
|
57
|
+
log.info(JSON.stringify({origin, items}, null, 2));
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (origin === "cache") log.detail(`blocks: cached copy (offline)`);
|
|
62
|
+
else log.detail(`blocks: ${detail}`);
|
|
63
|
+
log.title(`${items.length} block${items.length === 1 ? "" : "s"}`);
|
|
64
|
+
for (const item of items) {
|
|
65
|
+
log.info(` ${item.id} ${item.title}`);
|
|
66
|
+
log.detail(` ${item.format} · ${item.tags.join(", ")}`);
|
|
67
|
+
}
|
|
68
|
+
log.detail(`\nodori blocks show <id> for its files, odori blocks add <id> to install one.`);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export const blocksShowCommand = async (id: string, options: {json?: boolean} = {}) => {
|
|
72
|
+
const config = await loadConfig(process.cwd());
|
|
73
|
+
const {summary} = await findBlock(config, id);
|
|
74
|
+
|
|
75
|
+
if (options.json) {
|
|
76
|
+
log.info(JSON.stringify(summary, null, 2));
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
log.title(`${summary.title} (${summary.id})`);
|
|
81
|
+
log.info(` ${summary.description}`);
|
|
82
|
+
log.detail(` ${summary.format} · ${summary.tags.join(", ")}`);
|
|
83
|
+
log.title("files");
|
|
84
|
+
for (const file of summary.files) log.info(` ${file.target}${file.target === summary.entry ? " (entry)" : ""}`);
|
|
85
|
+
log.title("components");
|
|
86
|
+
for (const component of summary.components) log.info(` ${component}`);
|
|
87
|
+
log.detail(`\nodori blocks add ${summary.id}`);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export const blocksCatCommand = async (id: string, file: string) => {
|
|
91
|
+
const config = await loadConfig(process.cwd());
|
|
92
|
+
await findBlock(config, id);
|
|
93
|
+
const {block} = await resolveBlock(config, id);
|
|
94
|
+
|
|
95
|
+
// A reader types the name they saw, which may be the whole target or just
|
|
96
|
+
// the file at the end of it.
|
|
97
|
+
const found =
|
|
98
|
+
block.files.find((item) => item.target === file) ??
|
|
99
|
+
block.files.find((item) => item.target.endsWith(`/${file}`));
|
|
100
|
+
if (!found) {
|
|
101
|
+
throw new Error(
|
|
102
|
+
`Block "${id}" has no file ${JSON.stringify(file)}. It ships: ${block.files
|
|
103
|
+
.map((item) => item.target)
|
|
104
|
+
.join(", ")}`,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
log.info(found.content);
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
export const blocksAddCommand = async (
|
|
111
|
+
id: string,
|
|
112
|
+
options: {out?: string; force?: boolean; dryRun?: boolean} = {},
|
|
113
|
+
) => {
|
|
114
|
+
const config = await loadConfig(process.cwd());
|
|
115
|
+
await findBlock(config, id);
|
|
116
|
+
const {block, origin} = await resolveBlock(config, id);
|
|
117
|
+
if (origin === "cache") log.detail("blocks: cached copy (offline)");
|
|
118
|
+
verifyBlockIntegrity(block);
|
|
119
|
+
|
|
120
|
+
const writes = block.files.map((file) => {
|
|
121
|
+
const target = rebase(block, file.target, options.out);
|
|
122
|
+
return {target, destination: resolveWithinRoot(config.root, target), content: file.content};
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// Every refusal is collected before anything is written, so a block never
|
|
126
|
+
// lands half-installed on the first file that already existed.
|
|
127
|
+
const clashes = writes.filter((write) => existsSync(write.destination));
|
|
128
|
+
if (clashes.length > 0 && !options.force) {
|
|
129
|
+
throw new Error(
|
|
130
|
+
`These files already exist:\n${clashes.map((write) => ` ${write.target}`).join("\n")}\n` +
|
|
131
|
+
"Nothing was written. Pass --out to install beside them, or --force to replace them.",
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (options.dryRun) {
|
|
136
|
+
log.title(`${block.title} would write ${writes.length} file${writes.length === 1 ? "" : "s"}`);
|
|
137
|
+
for (const write of writes) log.info(` ${write.target}`);
|
|
138
|
+
if (block.components.length > 0) log.detail(` and install ${block.components.join(", ")}`);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/* The components first: a block's files import them, so a project that
|
|
143
|
+
stops halfway should be missing the video rather than the parts. */
|
|
144
|
+
if (block.components.length > 0) {
|
|
145
|
+
await addCommand(block.components.map(normalizeComponentName), {force: options.force});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
for (const write of writes) {
|
|
149
|
+
await mkdir(dirname(write.destination), {recursive: true});
|
|
150
|
+
await writeFile(write.destination, write.content, "utf8");
|
|
151
|
+
log.info(` ${write.target}`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const entry = rebase(block, block.entry, options.out);
|
|
155
|
+
log.title(`${block.title} installed`);
|
|
156
|
+
log.detail(` entry: ${entry}`);
|
|
157
|
+
/* The id is written inside the entry, not taken from the directory, so a
|
|
158
|
+
block installed beside itself would be discovered twice under one name.
|
|
159
|
+
Better said here than found as a duplicate-id error on the next run. */
|
|
160
|
+
if (options.out) log.detail(` the video id is set in the entry; change it if this project already has "${block.id}".`);
|
|
161
|
+
log.detail(` odori dev, then open the video to watch it.`);
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
/** The one entry point the CLI dispatches to, so `blocks` reads as one word. */
|
|
165
|
+
export const blocksCommand = async (
|
|
166
|
+
positionals: string[],
|
|
167
|
+
flags: {out?: string; force?: boolean; dryRun?: boolean; json?: boolean} = {},
|
|
168
|
+
) => {
|
|
169
|
+
const [action = "ls", ...rest] = positionals;
|
|
170
|
+
switch (action) {
|
|
171
|
+
case "ls":
|
|
172
|
+
case "list":
|
|
173
|
+
return blocksListCommand({json: flags.json});
|
|
174
|
+
case "show":
|
|
175
|
+
if (!rest[0]) throw new Error("Name a block, for example: odori blocks show launch");
|
|
176
|
+
return blocksShowCommand(rest[0], {json: flags.json});
|
|
177
|
+
case "cat":
|
|
178
|
+
if (!rest[0] || !rest[1]) {
|
|
179
|
+
throw new Error("Name a block and a file, for example: odori blocks cat launch video.tsx");
|
|
180
|
+
}
|
|
181
|
+
return blocksCatCommand(rest[0], rest[1]);
|
|
182
|
+
case "add":
|
|
183
|
+
if (!rest[0]) throw new Error("Name a block, for example: odori blocks add launch");
|
|
184
|
+
return blocksAddCommand(rest[0], {out: flags.out, force: flags.force, dryRun: flags.dryRun});
|
|
185
|
+
default:
|
|
186
|
+
throw new Error(`Unknown blocks command ${JSON.stringify(action)}. Try: ls, show, cat, add.`);
|
|
187
|
+
}
|
|
188
|
+
};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import {docPages, findDoc, searchDocs} from "../docs";
|
|
2
|
+
import {log} from "../log";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The documentation, in the terminal the agent is already in.
|
|
6
|
+
*
|
|
7
|
+
* The pages ship inside this package, so this answers with no network and no
|
|
8
|
+
* browser: `odori docs` lists what there is, `odori docs <page>` prints one,
|
|
9
|
+
* and `odori docs search <text>` finds the line that says it. An agent
|
|
10
|
+
* working in an unfamiliar project can read the framework the same way it
|
|
11
|
+
* reads the project.
|
|
12
|
+
*/
|
|
13
|
+
export const docsCommand = async (positionals: string[], options: {json?: boolean} = {}) => {
|
|
14
|
+
const [first, ...rest] = positionals;
|
|
15
|
+
|
|
16
|
+
if (first === "search") {
|
|
17
|
+
const query = rest.join(" ").trim();
|
|
18
|
+
if (!query) {
|
|
19
|
+
log.warn("Usage: odori docs search <text>");
|
|
20
|
+
return 1;
|
|
21
|
+
}
|
|
22
|
+
const hits = searchDocs(query);
|
|
23
|
+
if (options.json) {
|
|
24
|
+
log.info(JSON.stringify(hits.map(({page, line, text}) => ({slug: page.slug, line, text})), null, 2));
|
|
25
|
+
return hits.length > 0 ? 0 : 1;
|
|
26
|
+
}
|
|
27
|
+
if (hits.length === 0) {
|
|
28
|
+
log.warn(`No page mentions ${query}.`);
|
|
29
|
+
return 1;
|
|
30
|
+
}
|
|
31
|
+
log.title(`${hits.length} line${hits.length === 1 ? "" : "s"} mentioning ${query}`);
|
|
32
|
+
for (const hit of hits) {
|
|
33
|
+
log.info(` ${hit.page.slug}:${hit.line}`);
|
|
34
|
+
log.detail(` ${hit.text}`);
|
|
35
|
+
}
|
|
36
|
+
log.detail(`Read one with: odori docs ${hits[0].page.slug}`);
|
|
37
|
+
return 0;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (!first) {
|
|
41
|
+
const pages = docPages();
|
|
42
|
+
if (options.json) {
|
|
43
|
+
log.info(
|
|
44
|
+
JSON.stringify(
|
|
45
|
+
pages.map(({slug, title, description}) => ({slug, title, description})),
|
|
46
|
+
null,
|
|
47
|
+
2,
|
|
48
|
+
),
|
|
49
|
+
);
|
|
50
|
+
return 0;
|
|
51
|
+
}
|
|
52
|
+
log.title(`${pages.length} pages`);
|
|
53
|
+
/* Grouped the way the site files them, because a flat list of eighteen
|
|
54
|
+
slugs is a list to read rather than a shape to recognise. */
|
|
55
|
+
const sections = new Map<string, typeof pages>();
|
|
56
|
+
for (const page of pages) {
|
|
57
|
+
const key = page.section || "";
|
|
58
|
+
sections.set(key, [...(sections.get(key) ?? []), page]);
|
|
59
|
+
}
|
|
60
|
+
for (const [section, group] of sections) {
|
|
61
|
+
if (section) log.info(` ${section}/`);
|
|
62
|
+
for (const page of group) {
|
|
63
|
+
const name = section ? page.slug.slice(section.length + 1) : page.slug;
|
|
64
|
+
log.info(` ${section ? " " : ""}${name.padEnd(section ? 20 : 22)}${page.title}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
log.detail("Read one with: odori docs <page> · search with: odori docs search <text>");
|
|
68
|
+
return 0;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const page = findDoc(first);
|
|
72
|
+
if (!page) {
|
|
73
|
+
log.warn(`No page called ${first}.`);
|
|
74
|
+
log.detail("List them with: odori docs");
|
|
75
|
+
return 1;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (options.json) {
|
|
79
|
+
log.info(JSON.stringify(page, null, 2));
|
|
80
|
+
return 0;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
log.title(page.title);
|
|
84
|
+
if (page.description) log.detail(page.description);
|
|
85
|
+
log.info("");
|
|
86
|
+
log.info(page.body);
|
|
87
|
+
return 0;
|
|
88
|
+
};
|
package/src/commands/doctor.ts
CHANGED
|
@@ -4,6 +4,9 @@ import {existsSync} from "node:fs";
|
|
|
4
4
|
import {createRequire} from "node:module";
|
|
5
5
|
import {relative, resolve} from "node:path";
|
|
6
6
|
import {loadConfig} from "../config";
|
|
7
|
+
import {discoverProject} from "../discovery";
|
|
8
|
+
import {loadVideos} from "../project";
|
|
9
|
+
import {checkStructure} from "../structure";
|
|
7
10
|
import {keySource} from "../keystore";
|
|
8
11
|
import {musicProviders} from "../providers";
|
|
9
12
|
import {CHROME_BUILD, cacheRoot, resolveBrowser, resolveFfmpeg} from "../binaries";
|
|
@@ -167,6 +170,41 @@ export const runChecks = async (root: string): Promise<Check[]> => {
|
|
|
167
170
|
fix: `Add a sibling <name>.preview.tsx with defineComponentPreview so Studio can play it on its own. A component with no fixture only ever renders inside a video.`,
|
|
168
171
|
});
|
|
169
172
|
|
|
173
|
+
/**
|
|
174
|
+
* The filesystem contract, checked the way the graph command checks it. A
|
|
175
|
+
* file one keystroke away from being an entry, a fixture credited to
|
|
176
|
+
* nothing, a sound with no file behind it: each is invisible to discovery
|
|
177
|
+
* by design, which is exactly why doctor has to say it. Loading the videos
|
|
178
|
+
* can itself fail — a missing export, a colliding id — and that failure is
|
|
179
|
+
* this check's answer rather than doctor's crash.
|
|
180
|
+
*/
|
|
181
|
+
if (existsSync(resolve(config.root, config.videosDir))) {
|
|
182
|
+
try {
|
|
183
|
+
const graph = await discoverProject(config);
|
|
184
|
+
const videos = await loadVideos(graph);
|
|
185
|
+
const findings = await checkStructure(config, graph, videos);
|
|
186
|
+
const errors = findings.filter((finding) => finding.level === "error");
|
|
187
|
+
const first = errors[0] ?? findings[0];
|
|
188
|
+
checks.push({
|
|
189
|
+
name: "Structure",
|
|
190
|
+
detail:
|
|
191
|
+
findings.length === 0
|
|
192
|
+
? `${videos.length} video${videos.length === 1 ? "" : "s"}, shape is sound`
|
|
193
|
+
: `${first.file}: ${first.message}${findings.length > 1 ? ` (and ${findings.length - 1} more)` : ""}`,
|
|
194
|
+
ok: errors.length === 0,
|
|
195
|
+
warn: errors.length === 0 && findings.length > 0,
|
|
196
|
+
fix: 'Run "odori graph" for the full report.',
|
|
197
|
+
});
|
|
198
|
+
} catch (error) {
|
|
199
|
+
checks.push({
|
|
200
|
+
name: "Structure",
|
|
201
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
202
|
+
ok: false,
|
|
203
|
+
fix: "A video entry failed to load. Open the file the message names.",
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
170
208
|
// A missing provider key is not a broken project — generation is optional —
|
|
171
209
|
// but doctor is exactly where "why does --generate fail" gets answered.
|
|
172
210
|
for (const provider of Object.values(musicProviders)) {
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import {existsSync} from "node:fs";
|
|
2
|
+
import {mkdir, writeFile} from "node:fs/promises";
|
|
3
|
+
import {join, resolve} from "node:path";
|
|
4
|
+
import {resolveEntryLayout} from "odori";
|
|
5
|
+
import {log} from "../log";
|
|
6
|
+
import {writeGenerated} from "../discovery";
|
|
7
|
+
import {checkStructure, type StructureFinding} from "../structure";
|
|
8
|
+
import {createContext} from "./shared";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The whole project as one JSON document: what the filesystem declares,
|
|
12
|
+
* compiled and answered for. `catalog.json` is what discovery alone can say
|
|
13
|
+
* about the tree; this is what the tree says once every entry has actually
|
|
14
|
+
* been loaded — durations, formats, brands, variants, who uses what — plus
|
|
15
|
+
* every place the filesystem contract is almost met.
|
|
16
|
+
*
|
|
17
|
+
* The point is inspectability without execution. An agent, a CI step, or a
|
|
18
|
+
* teammate's script should be able to ask "what is in this project and is
|
|
19
|
+
* its shape sound" by reading one file, instead of importing modules or
|
|
20
|
+
* driving a browser. The videos themselves stay the source of truth; this is
|
|
21
|
+
* the tree's own testimony, written down where anything can read it.
|
|
22
|
+
*/
|
|
23
|
+
export type GraphArtifact = {
|
|
24
|
+
sourceHash: string;
|
|
25
|
+
generatedAt: string;
|
|
26
|
+
videos: Array<{
|
|
27
|
+
id: string;
|
|
28
|
+
title: string;
|
|
29
|
+
description?: string;
|
|
30
|
+
file: string;
|
|
31
|
+
format: {width: number; height: number; fps: number};
|
|
32
|
+
/** 0 when the composition's scenes decide, which only a browser can total. */
|
|
33
|
+
durationInFrames: number;
|
|
34
|
+
seconds: number | null;
|
|
35
|
+
brand: string;
|
|
36
|
+
tags: string[];
|
|
37
|
+
audioVariants: string[];
|
|
38
|
+
components: string[];
|
|
39
|
+
prepare: boolean;
|
|
40
|
+
}>;
|
|
41
|
+
components: Array<{name: string; file: string; usedBy: string[]}>;
|
|
42
|
+
brands: Array<{name: string; file: string}>;
|
|
43
|
+
audio: Array<{name: string; url: string; file: string; bytes: number}>;
|
|
44
|
+
categories: Array<{path: string; name?: string; order?: number}>;
|
|
45
|
+
findings: StructureFinding[];
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export const buildGraphArtifact = async (root = process.cwd()): Promise<{artifact: GraphArtifact; outDir: string}> => {
|
|
49
|
+
const {config, graph, videos} = await createContext(root);
|
|
50
|
+
// The compiled artifacts stay a set: refreshing the graph refreshes the
|
|
51
|
+
// static imports and the discovery catalog beside it, before the checks
|
|
52
|
+
// run, so this command never reports a staleness it just repaired.
|
|
53
|
+
await writeGenerated(config, graph);
|
|
54
|
+
const findings = await checkStructure(config, graph, videos);
|
|
55
|
+
|
|
56
|
+
const slugOf = new Map(graph.videos.map((video) => [video.file, video.slug]));
|
|
57
|
+
const artifact: GraphArtifact = {
|
|
58
|
+
sourceHash: graph.sourceHash,
|
|
59
|
+
generatedAt: new Date().toISOString(),
|
|
60
|
+
videos: videos.map((video) => {
|
|
61
|
+
const layout = resolveEntryLayout(video.entry);
|
|
62
|
+
const slug = slugOf.get(video.file) ?? video.entry.metadata.id;
|
|
63
|
+
return {
|
|
64
|
+
id: video.entry.metadata.id,
|
|
65
|
+
title: video.entry.metadata.title,
|
|
66
|
+
...(video.entry.metadata.description ? {description: video.entry.metadata.description} : {}),
|
|
67
|
+
file: video.relativeFile,
|
|
68
|
+
format: {width: layout.format.width, height: layout.format.height, fps: layout.format.fps},
|
|
69
|
+
durationInFrames: video.durationInFrames,
|
|
70
|
+
seconds: video.durationInFrames ? Number((video.durationInFrames / layout.format.fps).toFixed(2)) : null,
|
|
71
|
+
brand: layout.brand.name,
|
|
72
|
+
tags: video.entry.metadata.tags ?? [],
|
|
73
|
+
audioVariants: Object.keys(video.entry.metadata.audio?.variants ?? {}),
|
|
74
|
+
components: graph.previews
|
|
75
|
+
.filter((preview) => preview.usedBy?.includes(slug))
|
|
76
|
+
.map((preview) => preview.name)
|
|
77
|
+
.sort(),
|
|
78
|
+
prepare: existsSync(resolve(video.file, "..", "prepare.ts")),
|
|
79
|
+
};
|
|
80
|
+
}),
|
|
81
|
+
components: graph.previews.map((preview) => ({
|
|
82
|
+
name: preview.name,
|
|
83
|
+
file: preview.relativeFile,
|
|
84
|
+
usedBy: preview.usedBy ?? [],
|
|
85
|
+
})),
|
|
86
|
+
brands: graph.brands.map((brand) => ({name: brand.name, file: brand.relativeFile})),
|
|
87
|
+
audio: graph.audio.map((entry) => ({
|
|
88
|
+
name: entry.name,
|
|
89
|
+
url: entry.url,
|
|
90
|
+
file: entry.relativeFile,
|
|
91
|
+
bytes: entry.bytes,
|
|
92
|
+
})),
|
|
93
|
+
categories: graph.categories,
|
|
94
|
+
findings,
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const outDir = resolve(config.root, config.outDir);
|
|
98
|
+
await mkdir(outDir, {recursive: true});
|
|
99
|
+
await writeFile(join(outDir, "graph.json"), `${JSON.stringify(artifact, null, 2)}\n`, "utf8");
|
|
100
|
+
return {artifact, outDir};
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export const graphCommand = async (options: {json?: boolean} = {}): Promise<number> => {
|
|
104
|
+
const {artifact} = await buildGraphArtifact();
|
|
105
|
+
const errors = artifact.findings.filter((finding) => finding.level === "error");
|
|
106
|
+
|
|
107
|
+
if (options.json === true) {
|
|
108
|
+
log.info(JSON.stringify(artifact, null, 2));
|
|
109
|
+
return errors.length === 0 ? 0 : 1;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
log.title(`${artifact.videos.length} video${artifact.videos.length === 1 ? "" : "s"}`);
|
|
113
|
+
for (const video of artifact.videos) {
|
|
114
|
+
const duration = video.seconds === null ? "duration from scenes" : `${video.seconds}s`;
|
|
115
|
+
const extras = [
|
|
116
|
+
video.audioVariants.length > 0 ? `audio: ${video.audioVariants.join(", ")}` : "",
|
|
117
|
+
video.components.length > 0 ? `components: ${video.components.join(", ")}` : "",
|
|
118
|
+
video.prepare ? "prepare.ts" : "",
|
|
119
|
+
].filter(Boolean);
|
|
120
|
+
log.info(` ${video.id} ${video.format.width}x${video.format.height} ${duration} ${video.brand}`);
|
|
121
|
+
if (extras.length > 0) log.detail(` ${extras.join(" · ")}`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (artifact.components.length > 0) {
|
|
125
|
+
log.title(`${artifact.components.length} component${artifact.components.length === 1 ? "" : "s"}`);
|
|
126
|
+
for (const component of artifact.components) {
|
|
127
|
+
log.info(` ${component.name}${component.usedBy.length > 0 ? ` used by ${component.usedBy.join(", ")}` : ""}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (artifact.audio.length > 0) log.title(`${artifact.audio.length} audio file${artifact.audio.length === 1 ? "" : "s"}`);
|
|
131
|
+
for (const entry of artifact.audio) log.info(` ${entry.url} ${(entry.bytes / 1024).toFixed(0)} KB`);
|
|
132
|
+
|
|
133
|
+
if (artifact.findings.length > 0) log.title("Structure");
|
|
134
|
+
for (const finding of artifact.findings) {
|
|
135
|
+
const say = finding.level === "error" ? log.error : log.warn;
|
|
136
|
+
say(`${finding.file}: ${finding.message}`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
log.detail(`Wrote .odori/graph.json (source ${artifact.sourceHash.slice(0, 12)})`);
|
|
140
|
+
return errors.length === 0 ? 0 : 1;
|
|
141
|
+
};
|
package/src/commands/test.ts
CHANGED
|
@@ -2,6 +2,7 @@ import {isOdoriSchema, resolveEntryLayout} from "odori";
|
|
|
2
2
|
import {log} from "../log";
|
|
3
3
|
import {openRenderPage, readAudio, readTimeline, seekTo} from "../render";
|
|
4
4
|
import {checkAudioWindows, checkInstalledContracts} from "../contracts";
|
|
5
|
+
import {checkStructure} from "../structure";
|
|
5
6
|
import {checkDeterminism} from "../determinism";
|
|
6
7
|
import {createContext, targetFor, withServer} from "./shared";
|
|
7
8
|
import type {LoadedVideo} from "../project";
|
|
@@ -309,7 +310,7 @@ const testVideo = async (
|
|
|
309
310
|
};
|
|
310
311
|
|
|
311
312
|
export const testCommand = async (id?: string, options: {json?: boolean} = {}) => {
|
|
312
|
-
const {config, videos} = await createContext();
|
|
313
|
+
const {config, graph, videos} = await createContext();
|
|
313
314
|
const selected = id ? videos.filter((video) => video.entry.metadata.id === id) : videos;
|
|
314
315
|
if (selected.length === 0) throw new Error(id ? `Unknown video "${id}".` : "No videos discovered.");
|
|
315
316
|
|
|
@@ -318,6 +319,14 @@ export const testCommand = async (id?: string, options: {json?: boolean} = {}) =
|
|
|
318
319
|
// about the project, not about a frame.
|
|
319
320
|
failures.push(...(await checkInstalledContracts(config, selected)));
|
|
320
321
|
|
|
322
|
+
// The filesystem contract next, still before a browser: a referenced sound
|
|
323
|
+
// with no file behind it fails here as a fact about the tree, instead of
|
|
324
|
+
// surfacing as unexplained silence in an export. Warnings stay doctor's
|
|
325
|
+
// business; a test only fails on a promise the project breaks.
|
|
326
|
+
for (const finding of await checkStructure(config, graph, videos)) {
|
|
327
|
+
if (finding.level === "error") failures.push({video: finding.file, message: finding.message});
|
|
328
|
+
}
|
|
329
|
+
|
|
321
330
|
// So is a wall clock in a composition. It would render fine here and differ
|
|
322
331
|
// on the next machine, which is the failure mode a test is for.
|
|
323
332
|
for (const finding of await checkDeterminism(config)) {
|