@odori/cli 0.0.2

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.
Files changed (70) hide show
  1. package/LICENSE +22 -0
  2. package/bin/odori.mjs +39 -0
  3. package/dist/chunk-7XJL2BYO.js +3552 -0
  4. package/dist/cli.d.ts +10 -0
  5. package/dist/cli.js +10 -0
  6. package/dist/index.d.ts +622 -0
  7. package/dist/index.js +156 -0
  8. package/dist/registry-snapshot-NIH2JMQ6.js +3559 -0
  9. package/package.json +50 -0
  10. package/src/audio-mix.ts +133 -0
  11. package/src/binaries.ts +241 -0
  12. package/src/brand-file.ts +94 -0
  13. package/src/chunk-cache.ts +85 -0
  14. package/src/chunks.ts +78 -0
  15. package/src/cli.ts +319 -0
  16. package/src/commands/add.ts +151 -0
  17. package/src/commands/dev.ts +160 -0
  18. package/src/commands/doctor.ts +162 -0
  19. package/src/commands/exportVideo.ts +198 -0
  20. package/src/commands/init.ts +56 -0
  21. package/src/commands/inspect.ts +72 -0
  22. package/src/commands/list.ts +22 -0
  23. package/src/commands/new.ts +126 -0
  24. package/src/commands/shared.ts +96 -0
  25. package/src/commands/still.ts +40 -0
  26. package/src/commands/test.ts +265 -0
  27. package/src/commands/update.ts +183 -0
  28. package/src/config.ts +84 -0
  29. package/src/contracts.ts +159 -0
  30. package/src/cues.ts +141 -0
  31. package/src/determinism.ts +82 -0
  32. package/src/diff.ts +71 -0
  33. package/src/discovery.ts +216 -0
  34. package/src/formats.ts +119 -0
  35. package/src/index.ts +58 -0
  36. package/src/integrity.ts +101 -0
  37. package/src/jobs.ts +151 -0
  38. package/src/log.ts +17 -0
  39. package/src/open.ts +32 -0
  40. package/src/paths.ts +12 -0
  41. package/src/prepare-cache.ts +58 -0
  42. package/src/project.ts +196 -0
  43. package/src/registry-snapshot.json +3431 -0
  44. package/src/registry-source.ts +269 -0
  45. package/src/render.ts +627 -0
  46. package/src/server.ts +307 -0
  47. package/studio/index.html +41 -0
  48. package/studio/src/Studio.tsx +192 -0
  49. package/studio/src/components/AudioClip.tsx +64 -0
  50. package/studio/src/components/CanvasStage.tsx +79 -0
  51. package/studio/src/components/CommandPalette.tsx +129 -0
  52. package/studio/src/components/Diagnostics.tsx +93 -0
  53. package/studio/src/components/ExportPanel.tsx +234 -0
  54. package/studio/src/components/InputControls.tsx +110 -0
  55. package/studio/src/components/Thumbnail.tsx +71 -0
  56. package/studio/src/components/Transport.tsx +237 -0
  57. package/studio/src/components/Waveform.tsx +114 -0
  58. package/studio/src/components/Wordmark.tsx +449 -0
  59. package/studio/src/components/ui.tsx +138 -0
  60. package/studio/src/lib/mix-loudness.ts +52 -0
  61. package/studio/src/main.tsx +34 -0
  62. package/studio/src/shortcuts.ts +27 -0
  63. package/studio/src/studio.css +1232 -0
  64. package/studio/src/theme.ts +61 -0
  65. package/studio/src/views/AssetsView.tsx +111 -0
  66. package/studio/src/views/BrandsView.tsx +139 -0
  67. package/studio/src/views/ComponentsView.tsx +285 -0
  68. package/studio/src/views/HomeView.tsx +122 -0
  69. package/studio/src/views/VideosView.tsx +343 -0
  70. package/studio/src/virtual.d.ts +25 -0
@@ -0,0 +1,269 @@
1
+ import {createHash} from "node:crypto";
2
+ import {existsSync} from "node:fs";
3
+ import {mkdir, readFile, writeFile} from "node:fs/promises";
4
+ import {dirname, resolve} from "node:path";
5
+ import {cacheRoot} from "./binaries";
6
+ import type {ResolvedConfig} from "./config";
7
+
8
+ /**
9
+ * Where components come from.
10
+ *
11
+ * They used to come from a dependency, which meant a new component required a
12
+ * new CLI. The registry is the product, so it is fetched from where it is
13
+ * published and cached beside the pinned binaries — one download per machine,
14
+ * outside any project.
15
+ *
16
+ * Resolution has three steps and reports which one answered, because "why is
17
+ * this component missing" and "why is this component old" have the same
18
+ * symptom and different fixes:
19
+ *
20
+ * network → cache → the snapshot built into this package
21
+ *
22
+ * The snapshot is the floor. A machine with no network, a locked-down runner,
23
+ * and a first run behind a proxy all still install something, and `odori test`
24
+ * never reaches for the network at all.
25
+ */
26
+
27
+ /**
28
+ * The shape of a registry entry, defined here rather than imported.
29
+ *
30
+ * `@odori/registry` is a development dependency: it generates the snapshot at
31
+ * build time and is not installed alongside the published CLI. Importing even
32
+ * a type from it is fine, but importing a *value* would leave a bare import in
33
+ * the bundle that no consumer can resolve, so the boundary is drawn at the
34
+ * package: the CLI describes what it reads, and the generator produces it.
35
+ */
36
+ export type ComponentContract = {
37
+ aspectRatios: string[];
38
+ recommendedDurationInFrames: number;
39
+ minimumDurationInFrames: number;
40
+ entranceFrames: number;
41
+ exitFrames: number;
42
+ contentLimits: Record<string, number>;
43
+ reducedMotion: string;
44
+ requires: {fonts: string[]; audio: string[]};
45
+ /** Cues only: whether the score is one repeatable phrase. */
46
+ loops?: boolean;
47
+ };
48
+
49
+ export type RegistryKind = "component" | "cue";
50
+
51
+ export type RegistryComponent = {
52
+ name: string;
53
+ /** Components render pixels. Cues render samples. Both install as source. */
54
+ kind?: RegistryKind;
55
+ /** Present on cues: the brand name it registers, and the factory to call. */
56
+ cue?: {name: string; export: string};
57
+ namespaced: string;
58
+ family: string;
59
+ description: string;
60
+ files: string[];
61
+ registryDependencies: string[];
62
+ contract: ComponentContract;
63
+ };
64
+
65
+ /** `@odori/title-reveal` and `title-reveal` name the same component. */
66
+ export const normalizeComponentName = (name: string): string => name.replace(/^@odori\//, "");
67
+
68
+ export type RegistryOrigin = "network" | "cache" | "bundled";
69
+
70
+ export type RegistrySource = {
71
+ items: RegistryComponent[];
72
+ origin: RegistryOrigin;
73
+ /** Where it came from, for `odori registry` and for an error message. */
74
+ detail: string;
75
+ };
76
+
77
+ /** A published item document, which is shadcn's shape plus our `meta`. */
78
+ type RegistryItemDocument = {
79
+ name: string;
80
+ title?: string;
81
+ description?: string;
82
+ registryDependencies?: string[];
83
+ files: Array<{path: string; content: string; target: string}>;
84
+ meta?: {
85
+ kind?: string;
86
+ family?: string;
87
+ namespaced?: string;
88
+ contract?: RegistryComponent["contract"];
89
+ cue?: {name: string; export: string};
90
+ integrity?: string;
91
+ };
92
+ };
93
+
94
+ const DEFAULT_URL = "https://odori.dev/r/v1";
95
+
96
+ export const registryUrl = (config: ResolvedConfig): string =>
97
+ (config.registryUrl ?? process.env.ODORI_REGISTRY ?? DEFAULT_URL).replace(/\/$/, "");
98
+
99
+ /** One directory per origin URL, so two registries never share a cache. */
100
+ const cacheDir = (url: string): string =>
101
+ resolve(cacheRoot(), "registry", createHash("sha256").update(url).digest("hex").slice(0, 16));
102
+
103
+ /**
104
+ * The index, as the CLI's own type.
105
+ *
106
+ * A published item is a superset of what a registry row used to be, so the
107
+ * mapping is mechanical — everything the CLI needs is either at the top level
108
+ * or in `meta`, which is exactly why `meta` was used rather than a parallel
109
+ * document.
110
+ */
111
+ const toComponent = (item: RegistryItemDocument): RegistryComponent => ({
112
+ name: item.name,
113
+ namespaced: item.meta?.namespaced ?? `@odori/${item.name}`,
114
+ kind: (item.meta?.kind as RegistryComponent["kind"]) ?? "component",
115
+ ...(item.meta?.cue ? {cue: item.meta.cue} : {}),
116
+ family: item.meta?.family ?? "Uncategorized",
117
+ description: item.description ?? "",
118
+ files: item.files.map((file) => file.path.split("/").pop() ?? file.path),
119
+ registryDependencies: item.registryDependencies ?? [],
120
+ contract: item.meta?.contract as RegistryComponent["contract"],
121
+ });
122
+
123
+ /**
124
+ * The snapshot compiled into this package. Generated; see scripts/snapshot.ts.
125
+ *
126
+ * Loaded through `unknown` because TypeScript reads the JSON's literal shape,
127
+ * which is narrower than the document type it satisfies.
128
+ */
129
+ const snapshotItems = async (): Promise<RegistryItemDocument[]> => {
130
+ try {
131
+ const loaded = (await import("./registry-snapshot.json", {with: {type: "json"}})) as unknown as {
132
+ default: {items: RegistryItemDocument[]};
133
+ };
134
+ return loaded.default.items;
135
+ } catch {
136
+ // Committed, and regenerated by `pnpm snapshot`. Reaching this means the
137
+ // network and the cache both failed too, so say all three rather than
138
+ // letting a module-resolution stack stand in for the explanation.
139
+ throw new Error(
140
+ "No registry available: the network and the cache both failed, and this CLI has no snapshot built into it. " +
141
+ "Run `pnpm snapshot` in packages/odori-cli, or set registryUrl to a reachable registry.",
142
+ );
143
+ }
144
+ };
145
+
146
+ const bundled = async (): Promise<RegistryComponent[]> => (await snapshotItems()).map(toComponent);
147
+
148
+ const fetchJson = async (url: string, timeoutMs = 8000): Promise<unknown> => {
149
+ const controller = new AbortController();
150
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
151
+ try {
152
+ const response = await fetch(url, {signal: controller.signal, headers: {accept: "application/json"}});
153
+ if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
154
+ return await response.json();
155
+ } finally {
156
+ clearTimeout(timer);
157
+ }
158
+ };
159
+
160
+ /**
161
+ * The registry, from the best source available.
162
+ *
163
+ * `allowNetwork` is false for anything that validates a project rather than
164
+ * changing it. `odori test` must not depend on a connection to tell you
165
+ * whether your video is correct.
166
+ */
167
+ export const resolveRegistry = async (
168
+ config: ResolvedConfig,
169
+ options: {allowNetwork?: boolean} = {},
170
+ ): Promise<RegistrySource> => {
171
+ const url = registryUrl(config);
172
+ const cache = resolve(cacheDir(url), "registry.json");
173
+
174
+ if (options.allowNetwork !== false) {
175
+ try {
176
+ const index = (await fetchJson(`${url}/registry.json`)) as {items: RegistryItemDocument[]};
177
+ if (!Array.isArray(index.items)) throw new Error("the index has no items array");
178
+ await mkdir(dirname(cache), {recursive: true});
179
+ await writeFile(cache, JSON.stringify(index), "utf8");
180
+ return {items: index.items.map(toComponent), origin: "network", detail: url};
181
+ } catch {
182
+ // Fall through. A registry that is unreachable is not an error until
183
+ // there is also nothing cached and nothing bundled.
184
+ }
185
+ }
186
+
187
+ if (existsSync(cache)) {
188
+ try {
189
+ const index = JSON.parse(await readFile(cache, "utf8")) as {items: RegistryItemDocument[]};
190
+ return {items: index.items.map(toComponent), origin: "cache", detail: cache};
191
+ } catch {
192
+ // A corrupt cache is a cache miss, not a failure.
193
+ }
194
+ }
195
+
196
+ return {items: await bundled(), origin: "bundled", detail: "the copy built into this CLI"};
197
+ };
198
+
199
+ /**
200
+ * One item, with its file contents.
201
+ *
202
+ * The index deliberately omits contents — inlining every component's source
203
+ * would make it a megabyte to answer "what is available" — so installing
204
+ * fetches the item document, and falls back to the snapshot when it cannot.
205
+ */
206
+ export const resolveItem = async (
207
+ config: ResolvedConfig,
208
+ name: string,
209
+ options: {allowNetwork?: boolean} = {},
210
+ ): Promise<{item: RegistryItemDocument; origin: RegistryOrigin}> => {
211
+ const url = registryUrl(config);
212
+ const cache = resolve(cacheDir(url), `${name}.json`);
213
+
214
+ if (options.allowNetwork !== false) {
215
+ try {
216
+ const item = (await fetchJson(`${url}/${name}.json`)) as RegistryItemDocument;
217
+ if (item?.name !== name) throw new Error(`the document at ${url}/${name}.json is for "${item?.name}"`);
218
+ await mkdir(dirname(cache), {recursive: true});
219
+ await writeFile(cache, JSON.stringify(item), "utf8");
220
+ return {item, origin: "network"};
221
+ } catch {
222
+ // As above: try the disk before giving up.
223
+ }
224
+ }
225
+
226
+ if (existsSync(cache)) {
227
+ try {
228
+ return {item: JSON.parse(await readFile(cache, "utf8")) as RegistryItemDocument, origin: "cache"};
229
+ } catch {
230
+ // Corrupt cache, treated as absent.
231
+ }
232
+ }
233
+
234
+ const item = (await snapshotItems()).find((entry) => entry.name === name);
235
+ if (!item) throw new Error(`No component named "${name}" in the registry at ${url}, in the cache, or in this CLI.`);
236
+ return {item, origin: "bundled"};
237
+ };
238
+
239
+ /**
240
+ * Recompute the hash the registry published and compare.
241
+ *
242
+ * This is what makes fetching source over the network defensible: the bytes
243
+ * about to be written are the bytes the registry said it was serving. It is
244
+ * not a signature — a compromised origin can publish a matching hash — but it
245
+ * catches truncation, a stale proxy, and a corrupted cache, which are the
246
+ * failures that actually happen.
247
+ */
248
+ export const verifyIntegrity = (item: RegistryItemDocument): void => {
249
+ const expected = item.meta?.integrity;
250
+ if (!expected) return;
251
+
252
+ const hash = createHash("sha256");
253
+ for (const file of [...item.files].sort((left, right) => left.path.localeCompare(right.path))) {
254
+ hash.update(file.path);
255
+ hash.update("\0");
256
+ hash.update(file.content);
257
+ hash.update("\0");
258
+ }
259
+ const actual = `sha256-${hash.digest("base64")}`;
260
+ if (actual === expected) return;
261
+
262
+ throw new Error(
263
+ `The files for "${item.name}" do not match the hash the registry published.\n` +
264
+ ` expected ${expected}\n received ${actual}\n` +
265
+ "Nothing was written. This is a truncated download, a stale proxy, or a tampered document.",
266
+ );
267
+ };
268
+
269
+ export type {RegistryItemDocument};