animader-cli 0.0.0-stage → 0.1.1

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 (71) hide show
  1. package/LICENSE +121 -0
  2. package/adapters/build-node.mjs +265 -0
  3. package/adapters/devserver.mjs +481 -0
  4. package/adapters/devsession.mjs +619 -0
  5. package/adapters/export-node.mjs +603 -0
  6. package/adapters/export-run.mjs +126 -0
  7. package/adapters/generations.mjs +183 -0
  8. package/adapters/http.mjs +294 -0
  9. package/adapters/launch.mjs +83 -0
  10. package/adapters/node-fs.mjs +80 -0
  11. package/adapters/pngine.mjs +87 -0
  12. package/adapters/snapshot.mjs +143 -0
  13. package/adapters/target.mjs +104 -0
  14. package/adapters/watch.mjs +355 -0
  15. package/adapters/webaudio.mjs +102 -0
  16. package/bin/animader.mjs +31 -0
  17. package/hello/join.js +46 -0
  18. package/hello/run.js +259 -0
  19. package/lib/build.mjs +126 -0
  20. package/lib/buildloop.mjs +254 -0
  21. package/lib/contract.mjs +371 -0
  22. package/lib/errors.mjs +51 -0
  23. package/lib/export.mjs +445 -0
  24. package/lib/new.mjs +304 -0
  25. package/lib/pipeline.mjs +247 -0
  26. package/lib/receipt.mjs +331 -0
  27. package/lib/rmtree.mjs +49 -0
  28. package/lib/routes.mjs +170 -0
  29. package/lib/score.mjs +434 -0
  30. package/lib/show.mjs +458 -0
  31. package/lib/soundtrack.mjs +67 -0
  32. package/lib/vendor.mjs +99 -0
  33. package/lib/wav.mjs +197 -0
  34. package/package.json +47 -4
  35. package/player/boot.mjs +792 -0
  36. package/player/controller.mjs +366 -0
  37. package/player/devchannel.mjs +306 -0
  38. package/player/index.html +35 -0
  39. package/player/page.mjs +134 -0
  40. package/player/player.css +393 -0
  41. package/player/release.mjs +76 -0
  42. package/src/commands/build.mjs +201 -0
  43. package/src/commands/dev.mjs +214 -0
  44. package/src/commands/doctor.mjs +348 -0
  45. package/src/commands/export.mjs +158 -0
  46. package/src/commands/new.mjs +164 -0
  47. package/src/commands/project.mjs +46 -0
  48. package/src/config.mjs +110 -0
  49. package/src/router.mjs +149 -0
  50. package/src/toolchain.mjs +691 -0
  51. package/starters/circle/README.md +50 -0
  52. package/starters/circle/animader.json +4 -0
  53. package/starters/circle/gitignore +9 -0
  54. package/starters/circle/kernels/circle.wgsl +32 -0
  55. package/starters/circle/program.sjon +34 -0
  56. package/starters/circle/timeline.piece.sjon +26 -0
  57. package/starters/ember/bake/spans.bin +0 -0
  58. package/starters/ember/kernels/curl.wgsl +10 -0
  59. package/starters/ember/kernels/ember.wgsl +10 -0
  60. package/starters/ember/kernels/hash.wgsl +12 -0
  61. package/starters/ember/kernels/simplex.wgsl +27 -0
  62. package/starters/ember/program.sjon +328 -0
  63. package/starters/ember/timeline.piece.sjon +34 -0
  64. package/toolchain/licenses/animader.txt +121 -0
  65. package/toolchain/licenses/esbuild.txt +21 -0
  66. package/toolchain/licenses/pacer.txt +121 -0
  67. package/toolchain/licenses/pngine.txt +121 -0
  68. package/toolchain/manifest.json +212 -0
  69. package/toolchain/prepare.mjs +446 -0
  70. package/toolchain/uniform-type-name.js +184 -0
  71. package/README.md +0 -3
@@ -0,0 +1,348 @@
1
+ // The `animader doctor` command: host checks (node against the package
2
+ // engines, platform and architecture, and the artifacts the dev command
3
+ // needs in this source workspace) and, with a folder, the shared
4
+ // animader.json resolution plus the demo's real project inspection. Every
5
+ // failed check prints its problem and an actionable remedy, naming the file
6
+ // it belongs to. Doctor never claims WebGPU, audio decoding or playback:
7
+ // those need a real browser test, and its own output says so.
8
+ //
9
+ // Contributor-mode artifact paths default to the workspace roots
10
+ // (toolchain/prepare.mjs) and demo/vendor, exactly what the dev command
11
+ // uses in contributor mode. Installed mode (stage 2 of the
12
+ // installed-toolchain plan) resolves them through the toolchain descriptor.
13
+
14
+ import { join } from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
16
+
17
+ import pkg from '../../package.json' with { type: 'json' };
18
+ import { createNodeIo } from '../../adapters/node-fs.mjs';
19
+ import { inspectProject } from '../../lib/contract.mjs';
20
+ import { DemoError, projectError, usageError } from '../../lib/errors.mjs';
21
+ import { resolveToolchain } from '../toolchain.mjs';
22
+ import { PROJECT_CONFIG_FORMAT, PROJECT_CONFIG_NAME, resolveProjectConfig } from '../config.mjs';
23
+
24
+ // Contributor-workspace defaults for doctor's artifact checks: the vendored
25
+ // runtime under the checkout's demo/ and the package's own player page.
26
+ // Installed mode (stage 2 of the installed-toolchain plan) replaces these
27
+ // with the toolchain descriptor's package-resolved artifacts.
28
+ const REPO_ROOT = fileURLToPath(new URL('../../../../', import.meta.url));
29
+ const PKG_ROOT = fileURLToPath(new URL('../../', import.meta.url));
30
+
31
+ const INSPECTED_INPUTS_SUMMARY = 'program.sjon, timeline.piece.sjon, exactly one recognized soundtrack, out/ usable';
32
+
33
+ /**
34
+ * Parse the `animader doctor` argv without touching the filesystem.
35
+ *
36
+ * @param {string[]} argv
37
+ * @returns {{help: boolean, folder: string|null, secondsRaw: string|undefined}}
38
+ */
39
+ export function parseDoctorArgv(argv) {
40
+ if (!Array.isArray(argv)) {
41
+ throw usageError('invalid_argv', 'the CLI adapter must pass an argument array', 'Call animader doctor [folder] [--seconds S]');
42
+ }
43
+
44
+ let folder = null;
45
+ let secondsRaw;
46
+ let help = false;
47
+ let endOptions = false;
48
+
49
+ for (let index = 0; index < argv.length; index++) {
50
+ const argument = String(argv[index]);
51
+ if (!endOptions && argument === '--') {
52
+ endOptions = true;
53
+ continue;
54
+ }
55
+ if (!endOptions && (argument === '--help' || argument === '-h')) {
56
+ help = true;
57
+ continue;
58
+ }
59
+ if (!endOptions && argument.startsWith('--')) {
60
+ const equals = argument.indexOf('=');
61
+ const name = equals === -1 ? argument : argument.slice(0, equals);
62
+ const inline = equals === -1 ? undefined : argument.slice(equals + 1);
63
+ if (name === '--seconds') {
64
+ if (inline !== undefined) {
65
+ secondsRaw = inline;
66
+ } else {
67
+ if (index + 1 >= argv.length) {
68
+ throw usageError('missing_option_value', `${name} needs a value`, 'Pass --seconds <n>, for example --seconds 8');
69
+ }
70
+ index++;
71
+ secondsRaw = String(argv[index]);
72
+ }
73
+ } else {
74
+ throw usageError(
75
+ 'unknown_option',
76
+ `unknown option ${name}`,
77
+ 'The supported options are --seconds and --help; use --help for usage.',
78
+ );
79
+ }
80
+ continue;
81
+ }
82
+ if (!endOptions && argument.startsWith('-') && argument !== '-') {
83
+ throw usageError(
84
+ 'unknown_option',
85
+ `unknown option ${argument}`,
86
+ 'The supported options are --seconds and --help; use --help for usage.',
87
+ );
88
+ }
89
+ if (folder !== null) {
90
+ throw usageError('extra_argument', `extra folder argument ${JSON.stringify(argument)}`, 'Pass at most one project folder');
91
+ }
92
+ folder = argument;
93
+ }
94
+
95
+ return { help, folder, secondsRaw };
96
+ }
97
+
98
+ export function doctorUsageText() {
99
+ return [
100
+ 'animader doctor — report host and project health',
101
+ '',
102
+ 'Usage:',
103
+ ' animader doctor [folder] [--seconds S]',
104
+ '',
105
+ 'Without a folder, doctor checks the host: the node version against the',
106
+ "package engines, the platform and architecture, and whether the",
107
+ 'artifacts the dev command needs are present in this source workspace.',
108
+ 'With a folder, it also resolves animader.json and inspects the real',
109
+ 'project inputs.',
110
+ '',
111
+ 'Options:',
112
+ ' --seconds S explicit host duration for a project whose config does',
113
+ ' not set one (precedence over animader.json)',
114
+ ' -h, --help this text',
115
+ '',
116
+ 'Doctor never claims WebGPU, audio decoding or playback: those need a',
117
+ 'real browser test.',
118
+ ].join('\n');
119
+ }
120
+
121
+ function versionTriple(text, pattern) {
122
+ const match = pattern.exec(String(text).trim());
123
+ if (!match) return null;
124
+ // A range's absent minor/patch (`>=22`) means zero, not NaN: Number(undefined)
125
+ // is NaN, and any comparison against NaN is false, which made doctor reject
126
+ // exactly the versions the range admits.
127
+ return [Number(match[1]), Number(match[2] ?? 0), Number(match[3] ?? 0)];
128
+ }
129
+
130
+ /**
131
+ * Compare a node version against an engines range. Understands the
132
+ * `>=major[.minor[.patch]]` form this package ships. Returns true, false,
133
+ * or null when either side is not comparable.
134
+ */
135
+ export function satisfiesNodeRange(nodeVersion, range) {
136
+ const floor = versionTriple(range, /^>=(\d+)(?:\.(\d+))?(?:\.(\d+))?$/);
137
+ const current = versionTriple(nodeVersion, /^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/);
138
+ if (!floor || !current) return null;
139
+ for (let index = 0; index < 3; index++) {
140
+ if (current[index] !== floor[index]) return current[index] > floor[index];
141
+ }
142
+ return true;
143
+ }
144
+
145
+ async function statIsFile(io, path) {
146
+ const stats = await io.statFile(path);
147
+ return stats !== null && stats.isFile;
148
+ }
149
+
150
+ /**
151
+ * Run `animader doctor`. Prints one line per check (`ok: …`), a `fail: …`
152
+ * line plus an indented remedy for each failed check, and always closes
153
+ * with the browser-boundary note. Throws the named `doctor_failed` project
154
+ * error when any check failed, so the process exits non-zero.
155
+ *
156
+ * @param {string[]} argv
157
+ * @param {{out: (line: string) => void, io?: object, env?: object,
158
+ * nodeVersion?: string, platform?: string, arch?: string,
159
+ * engines?: object, pnginePath?: string, vendorProvenance?: string,
160
+ * vendorRoot?: string, playerIndex?: string, toolchain?: object}} context
161
+ */
162
+ export async function run(argv, context) {
163
+ const args = parseDoctorArgv(argv);
164
+ if (args.help) {
165
+ context.out(doctorUsageText());
166
+ return;
167
+ }
168
+
169
+ const io = context.io ?? createNodeIo();
170
+ const out = context.out;
171
+ const env = context.env ?? process.env;
172
+ const nodeVersion = context.nodeVersion ?? process.version;
173
+ const platform = context.platform ?? process.platform;
174
+ const arch = context.arch ?? process.arch;
175
+ const engines = context.engines ?? pkg.engines ?? {};
176
+ const toolchain = context.toolchain ?? await resolveToolchain({ env });
177
+ const pnginePath = context.pnginePath ?? toolchain.pngine.cli;
178
+ const vendorProvenance = context.vendorProvenance
179
+ ?? join(context.vendorRoot ?? join(REPO_ROOT, 'demo', 'vendor'), 'provenance.json');
180
+ const playerIndex = context.playerIndex ?? join(PKG_ROOT, 'player', 'index.html');
181
+
182
+ const results = [];
183
+ const check = (ok, text, remedy) => results.push({ ok, text, remedy });
184
+
185
+ // --- host checks -------------------------------------------------------
186
+ const satisfied = satisfiesNodeRange(nodeVersion, engines.node);
187
+ if (satisfied === null) {
188
+ check(
189
+ false,
190
+ `node ${nodeVersion} cannot be compared against engines node ${JSON.stringify(engines.node ?? null)}`,
191
+ `Set engines.node to a ">=major[.minor[.patch]]" range this doctor understands; ${JSON.stringify(engines.node ?? null)} is not one.`,
192
+ );
193
+ } else if (satisfied) {
194
+ check(true, `node ${nodeVersion} satisfies engines node ${JSON.stringify(engines.node)}`);
195
+ } else {
196
+ check(
197
+ false,
198
+ `node ${nodeVersion} does not satisfy engines node ${JSON.stringify(engines.node)}`,
199
+ 'Use Node 22 or newer; the supported baseline is the CLI package\'s engines field.',
200
+ );
201
+ }
202
+ check(
203
+ true,
204
+ toolchain.mode === 'installed'
205
+ ? `platform ${platform} on ${arch} (installed toolchain target ${toolchain.target.os}-${toolchain.target.arch})`
206
+ : `platform ${platform} on ${arch} (the release matrix is a release-time gate, not a contributor-mode one)`,
207
+ );
208
+
209
+ // The toolchain identity and its artifacts. Contributor mode checks the
210
+ // workspace's own files (the pngine CLI path, the prepared bundle, the
211
+ // package's player page); installed mode resolves every artifact through
212
+ // the toolchain descriptor and verifies it against the release manifest,
213
+ // so a missing or tampered dependency names the package to install.
214
+ check(true, `toolchain ${toolchain.identity}`);
215
+ if (toolchain.mode === 'installed') {
216
+ try {
217
+ const verified = await toolchain.verifyRuntime();
218
+ check(true, `installed runtime verified: ${verified.files.length} files, digest ${verified.digest}`);
219
+ } catch (error) {
220
+ check(false, error.message, error.remedy);
221
+ }
222
+ try {
223
+ const tools = await toolchain.verifyTools();
224
+ for (const row of tools.tools) {
225
+ check(true, `${row.name} present at ${row.path} (${row.bytes} bytes, sha256 ${row.sha256})`);
226
+ }
227
+ } catch (error) {
228
+ check(false, error.message, error.remedy);
229
+ }
230
+ try {
231
+ const esbuild = toolchain.esbuild.require();
232
+ const pinned = toolchain.versions.esbuild;
233
+ if (typeof esbuild.version === 'string' && pinned !== null && esbuild.version !== pinned) {
234
+ check(
235
+ false,
236
+ `esbuild ${esbuild.version} is installed but the release manifest pins ${pinned}`,
237
+ 'Reinstall animader-cli so its pinned esbuild dependency is installed exactly.',
238
+ );
239
+ } else {
240
+ check(true, `esbuild ${esbuild.version} matches the pinned ${pinned ?? esbuild.version}`);
241
+ }
242
+ } catch (error) {
243
+ check(false, error.message, error.remedy);
244
+ }
245
+ } else {
246
+ if (await statIsFile(io, pnginePath)) {
247
+ check(true, `pngine CLI present at ${pnginePath}`);
248
+ } else {
249
+ check(
250
+ false,
251
+ `pngine CLI not found at ${pnginePath}`,
252
+ 'Build pngine in its checkout or point PNGINE at the binary; the dev command needs it.',
253
+ );
254
+ }
255
+
256
+ if (await statIsFile(io, vendorProvenance)) {
257
+ check(true, `prepared runtime bundle present at ${vendorProvenance}`);
258
+ } else {
259
+ check(
260
+ false,
261
+ `prepared runtime bundle not found at ${vendorProvenance}`,
262
+ 'Run node demo/prepare.mjs to fill demo/vendor from the built producers.',
263
+ );
264
+ }
265
+ }
266
+
267
+ if (await statIsFile(io, playerIndex)) {
268
+ check(true, `player page present at ${playerIndex}`);
269
+ } else {
270
+ check(
271
+ false,
272
+ `player page not found at ${playerIndex}`,
273
+ 'Restore the tracked demo/player page files; the dev server serves them.',
274
+ );
275
+ }
276
+
277
+ // --- project checks ----------------------------------------------------
278
+ if (args.folder !== null && args.folder !== '') {
279
+ const folder = args.folder;
280
+ let stats = null;
281
+ try {
282
+ stats = await io.statFile(folder);
283
+ } catch (error) {
284
+ check(false, `could not inspect the project folder ${folder}: ${error?.message ?? String(error)}`, 'Check the folder path and its permissions.');
285
+ }
286
+ if (stats === null) {
287
+ check(false, `the project folder does not exist: ${folder}`, 'Pass the folder that holds the project files (created by animader new).');
288
+ } else if (!stats.isDirectory) {
289
+ check(false, `the project folder is not a directory: ${folder}`, 'Pass the project folder itself, not a file.');
290
+ } else {
291
+ const configPath = `${String(folder).replace(/[\\/]+$/, '')}/${PROJECT_CONFIG_NAME}`;
292
+ const configStats = await io.statFile(configPath);
293
+ if (configStats !== null && !configStats.isFile) {
294
+ check(false, `${PROJECT_CONFIG_NAME} is not a regular file in ${folder}`, `Replace ${configPath} with a regular file.`);
295
+ } else {
296
+ let configBytes = null;
297
+ if (configStats !== null) {
298
+ try {
299
+ configBytes = await io.readFile(configPath);
300
+ } catch (error) {
301
+ check(false, `could not read ${configPath}: ${error?.message ?? String(error)}`, 'Check the file\'s permissions.');
302
+ }
303
+ }
304
+ if (configBytes !== null || configStats === null) {
305
+ try {
306
+ const resolved = await resolveProjectConfig({ folder, configBytes, overrideSeconds: args.secondsRaw });
307
+ check(
308
+ true,
309
+ configStats !== null
310
+ ? `${PROJECT_CONFIG_NAME} resolves: ${PROJECT_CONFIG_FORMAT}, seconds ${resolved.seconds}`
311
+ : `${PROJECT_CONFIG_NAME} is absent; --seconds ${resolved.seconds} supplies the host duration`,
312
+ );
313
+ } catch (error) {
314
+ check(false, error.message, error.remedy);
315
+ }
316
+ }
317
+ }
318
+
319
+ try {
320
+ await inspectProject({ folder, io });
321
+ check(true, `project inputs (${INSPECTED_INPUTS_SUMMARY})`);
322
+ } catch (error) {
323
+ if (error instanceof DemoError && error.remedy) check(false, error.message, error.remedy);
324
+ else check(false, `could not inspect ${folder}: ${error?.message ?? String(error)}`, 'Check the folder\'s permissions and contents.');
325
+ }
326
+ }
327
+ }
328
+
329
+ // --- output and verdict -------------------------------------------------
330
+ for (const result of results) {
331
+ if (result.ok) {
332
+ out(`ok: ${result.text}`);
333
+ } else {
334
+ out(`fail: ${result.text}`);
335
+ if (result.remedy) out(` remedy: ${result.remedy}`);
336
+ }
337
+ }
338
+ out('note: WebGPU, audio decoding and playback are not checked here; they need a real browser test.');
339
+
340
+ const failures = results.filter((result) => !result.ok).length;
341
+ if (failures > 0) {
342
+ throw projectError(
343
+ 'doctor_failed',
344
+ `${failures} of ${results.length} checks failed`,
345
+ 'Fix the failing checks above and run animader doctor again.',
346
+ );
347
+ }
348
+ }
@@ -0,0 +1,158 @@
1
+ // The `animader export` command: its argv grammar and path rules. The work
2
+ // is the demo's real export operation (runExport): inspect the project,
3
+ // resolve --out from the invocation directory, refuse outputs that alias
4
+ // authored inputs, and run the staged release export over the real
5
+ // producers. The output folder must be fresh; there is no --force.
6
+ //
7
+ // The export operation is the package's own adapters/export-run.mjs
8
+ // (installed-toolchain plan, stage 1).
9
+
10
+ import { createNodeIo } from '../../adapters/node-fs.mjs';
11
+ import { usageError } from '../../lib/errors.mjs';
12
+ import { runExport } from '../../adapters/export-run.mjs';
13
+ import { resolveToolchain } from '../toolchain.mjs';
14
+ import { commandFolder, resolveConfiguredSeconds } from './project.mjs';
15
+
16
+ /**
17
+ * Parse the `animader export` argv without touching the filesystem.
18
+ *
19
+ * @param {string[]} argv
20
+ * @returns {{help: boolean, folder: string|null, secondsRaw: string|undefined,
21
+ * out: string|null}}
22
+ */
23
+ export function parseExportCommandArgv(argv) {
24
+ if (!Array.isArray(argv)) {
25
+ throw usageError('invalid_argv', 'the CLI adapter must pass an argument array', 'Call animader export [folder] --out OUT [--seconds S]');
26
+ }
27
+
28
+ let folder = null;
29
+ let secondsRaw;
30
+ let out = null;
31
+ let help = false;
32
+ let endOptions = false;
33
+
34
+ for (let index = 0; index < argv.length; index++) {
35
+ const argument = String(argv[index]);
36
+ if (!endOptions && argument === '--') {
37
+ endOptions = true;
38
+ continue;
39
+ }
40
+ if (!endOptions && (argument === '--help' || argument === '-h')) {
41
+ help = true;
42
+ continue;
43
+ }
44
+ if (!endOptions && argument.startsWith('--')) {
45
+ const equals = argument.indexOf('=');
46
+ const name = equals === -1 ? argument : argument.slice(0, equals);
47
+ const inline = equals === -1 ? undefined : argument.slice(equals + 1);
48
+ const takeValue = () => {
49
+ if (inline !== undefined) return inline;
50
+ if (index + 1 >= argv.length) {
51
+ throw usageError('missing_option_value', `${name} needs a value`, `Pass ${name} <value>`);
52
+ }
53
+ index++;
54
+ return String(argv[index]);
55
+ };
56
+ if (name === '--seconds') {
57
+ secondsRaw = takeValue();
58
+ } else if (name === '--out') {
59
+ out = takeValue();
60
+ } else {
61
+ throw usageError(
62
+ 'unknown_option',
63
+ `unknown option ${name}`,
64
+ 'The supported options are --out, --seconds and --help; an existing target is refused.',
65
+ );
66
+ }
67
+ continue;
68
+ }
69
+ if (!endOptions && argument.startsWith('-') && argument !== '-') {
70
+ throw usageError(
71
+ 'unknown_option',
72
+ `unknown option ${argument}`,
73
+ 'The supported options are --out, --seconds and --help; an existing target is refused.',
74
+ );
75
+ }
76
+ if (folder !== null) {
77
+ throw usageError('extra_argument', `extra folder argument ${JSON.stringify(argument)}`, 'Pass at most one project folder');
78
+ }
79
+ folder = argument;
80
+ }
81
+
82
+ if (help) {
83
+ return { help: true, folder, secondsRaw, out };
84
+ }
85
+ if (out === null || out === '') {
86
+ throw usageError('missing_out', '--out is required', 'Pass the release folder, for example --out dist');
87
+ }
88
+ return { help: false, folder, secondsRaw, out };
89
+ }
90
+
91
+ export function exportCommandUsageText() {
92
+ return [
93
+ 'animader export — write a standalone release to a fresh output folder',
94
+ '',
95
+ 'Usage:',
96
+ ' animader export [folder] --out OUT [--seconds S]',
97
+ '',
98
+ 'The folder is the project folder relative to the invocation directory',
99
+ '(default: the current directory; parent folders are never searched).',
100
+ 'A relative --out resolves from the invocation directory, not from the',
101
+ 'project: `animader export ./my-demo --out ./release` writes ./release.',
102
+ 'The resolved output path is printed.',
103
+ '',
104
+ 'The output folder must not exist (including a symlink); this slice has',
105
+ 'no --force and never overwrites. The release is staged beside OUT and',
106
+ 'one rename publishes it; an existing OUT.next staging path is refused,',
107
+ 'never deleted. Outputs that alias authored project files (the project',
108
+ 'folder itself, its parents, or paths inside authored folders) are',
109
+ 'refused.',
110
+ '',
111
+ 'Options:',
112
+ ' --out OUT required release folder; it must not exist',
113
+ ` --seconds S explicit host duration, a positive number at most 600`,
114
+ ' (wins over animader.json only after it validates)',
115
+ ' -h, --help this text',
116
+ '',
117
+ 'A successful export also writes a detached OUT.size.json beside OUT.',
118
+ ].join('\n');
119
+ }
120
+
121
+ /**
122
+ * Run `animader export` through the demo's export operation. The context may
123
+ * inject `producer` (the whole release producer) or `producers` (the build
124
+ * stage ports inside prepareRelease) for tests; the default is the real
125
+ * vendored producer chain, which fails loudly where the prepared runtime is
126
+ * absent.
127
+ *
128
+ * @param {string[]} argv
129
+ * @param {{out: (line: string) => void, err: (line: string) => void,
130
+ * io?: object, producer?: object, producers?: object,
131
+ * toolchain?: object}} context
132
+ */
133
+ export async function run(argv, context) {
134
+ const args = parseExportCommandArgv(argv);
135
+ if (args.help) {
136
+ context.out(exportCommandUsageText());
137
+ return;
138
+ }
139
+
140
+ const folder = commandFolder(args);
141
+ const { seconds } = await resolveConfiguredSeconds({
142
+ folder,
143
+ secondsRaw: args.secondsRaw,
144
+ io: context.io ?? createNodeIo(),
145
+ });
146
+
147
+ await runExport({
148
+ folder,
149
+ seconds,
150
+ out: args.out,
151
+ title: 'animader export',
152
+ say: context.out,
153
+ warn: context.err,
154
+ producer: context.producer,
155
+ producers: context.producers,
156
+ toolchain: context.toolchain ?? await resolveToolchain(),
157
+ });
158
+ }
@@ -0,0 +1,164 @@
1
+ // The `animader new` command: its argv grammar and short success output.
2
+ // The creation decisions (starter registry, emptiness refusal, atomic
3
+ // per-file writes, partial-write honesty) are the demo's portable
4
+ // lib/new.mjs; symlink resolution and the starter-source refusal are the
5
+ // demo's target adapter. There is no server and no package installation as
6
+ // a side effect.
7
+ //
8
+ // The starter sources and portable creation decisions are the package's own
9
+ // lib/new.mjs and starters/ (installed-toolchain plan, stage 1).
10
+
11
+ import { fileURLToPath } from 'node:url';
12
+
13
+ import { createNodeIo } from '../../adapters/node-fs.mjs';
14
+ import { refuseStarterSource, resolveTargetFolder } from '../../adapters/target.mjs';
15
+ import { PROJECT_FILES } from '../../lib/contract.mjs';
16
+ import { usageError } from '../../lib/errors.mjs';
17
+ import { STARTER_NAMES, createProject, shellQuote } from '../../lib/new.mjs';
18
+
19
+ const DEFAULT_STARTER = 'circle';
20
+
21
+ function starterList() {
22
+ return STARTER_NAMES.join(', ');
23
+ }
24
+
25
+ /**
26
+ * Parse the `animader new` argv without touching the filesystem. The
27
+ * grammar matches the demo's `new`, with circle as the default starter.
28
+ *
29
+ * @param {string[]} argv
30
+ * @returns {{help: boolean, folder: string|null, starter: string}}
31
+ */
32
+ export function parseNewCommandArgv(argv) {
33
+ if (!Array.isArray(argv)) {
34
+ throw usageError('invalid_argv', 'the CLI adapter must pass an argument array', 'Call animader new <folder> [--starter circle|ember]');
35
+ }
36
+
37
+ let folder = null;
38
+ let starter = DEFAULT_STARTER;
39
+ let help = false;
40
+ let endOptions = false;
41
+
42
+ for (let index = 0; index < argv.length; index++) {
43
+ const argument = String(argv[index]);
44
+ if (!endOptions && argument === '--') {
45
+ endOptions = true;
46
+ continue;
47
+ }
48
+ if (!endOptions && (argument === '--help' || argument === '-h')) {
49
+ help = true;
50
+ continue;
51
+ }
52
+ if (!endOptions && argument.startsWith('--')) {
53
+ const equals = argument.indexOf('=');
54
+ const name = equals === -1 ? argument : argument.slice(0, equals);
55
+ const inline = equals === -1 ? undefined : argument.slice(equals + 1);
56
+ if (name === '--starter') {
57
+ if (inline !== undefined) {
58
+ starter = inline;
59
+ } else {
60
+ if (index + 1 >= argv.length) {
61
+ throw usageError('missing_option_value', `${name} needs a value`, `Pass ${name} ${starterList()}`);
62
+ }
63
+ index++;
64
+ starter = String(argv[index]);
65
+ }
66
+ } else {
67
+ throw usageError(
68
+ 'unknown_option',
69
+ `unknown option ${name}`,
70
+ `The supported option is --starter (available: ${starterList()}); use --help for usage.`,
71
+ );
72
+ }
73
+ continue;
74
+ }
75
+ if (!endOptions && argument.startsWith('-') && argument !== '-') {
76
+ throw usageError(
77
+ 'unknown_option',
78
+ `unknown option ${argument}`,
79
+ `The supported option is --starter (available: ${starterList()}); use --help for usage.`,
80
+ );
81
+ }
82
+ if (folder !== null) {
83
+ throw usageError('extra_argument', `extra folder argument ${JSON.stringify(argument)}`, 'Pass exactly one project folder');
84
+ }
85
+ folder = argument;
86
+ }
87
+
88
+ if (help) return { help: true, folder, starter };
89
+ if (folder === null || folder === '') {
90
+ throw usageError(
91
+ 'missing_folder',
92
+ 'exactly one project folder is required',
93
+ 'Run: animader new <folder> [--starter circle|ember]',
94
+ );
95
+ }
96
+ if (!STARTER_NAMES.includes(starter)) {
97
+ throw usageError(
98
+ 'unknown_starter',
99
+ `unknown starter ${JSON.stringify(starter)}`,
100
+ `Available starters: ${starterList()}`,
101
+ );
102
+ }
103
+ return { help: false, folder, starter };
104
+ }
105
+
106
+ export function newCommandUsageText() {
107
+ return [
108
+ 'animader new — create an editable project from a tracked starter',
109
+ '',
110
+ 'Usage:',
111
+ ' animader new <folder> [--starter circle|ember]',
112
+ '',
113
+ 'The folder must not exist, or must be an empty directory. The default',
114
+ 'starter is circle: one circle kernel, an eight-second path with beat',
115
+ 'pulses, a complete timeline and the labeled test tone. The ember',
116
+ 'starter stays available explicitly.',
117
+ '',
118
+ 'Files:',
119
+ ` ${PROJECT_FILES.program}`,
120
+ ` ${PROJECT_FILES.timeline}`,
121
+ ' animader.json',
122
+ ' kernels/circle.wgsl',
123
+ ` ${PROJECT_FILES.soundtrack} generated 8.0 s labeled test tone; replace with your audio`,
124
+ ' .gitignore',
125
+ ' README.md',
126
+ '',
127
+ 'Options:',
128
+ ` --starter NAME starter to copy (default: circle; available: ${starterList()})`,
129
+ " -- end options; the next argument is the folder even if it starts with '-'",
130
+ ' -h, --help this text',
131
+ '',
132
+ 'A folder symlink is followed. A target inside the tracked starter',
133
+ 'source is refused.',
134
+ ].join('\n');
135
+ }
136
+
137
+ /**
138
+ * Run `animader new`. Short success output only: the created location, the
139
+ * working next command, and the first file to edit. Hashes and tool
140
+ * details stay in diagnostics, not creation output.
141
+ *
142
+ * @param {string[]} argv
143
+ * @param {{out: (line: string) => void, io?: object}} context
144
+ */
145
+ export async function run(argv, context) {
146
+ const args = parseNewCommandArgv(argv);
147
+ if (args.help) {
148
+ context.out(newCommandUsageText());
149
+ return;
150
+ }
151
+
152
+ const folder = await resolveTargetFolder(args.folder);
153
+ await refuseStarterSource(folder, args.starter);
154
+
155
+ const result = await createProject({
156
+ folder,
157
+ starter: args.starter,
158
+ io: context.io ?? createNodeIo(),
159
+ });
160
+
161
+ context.out(`created: ${result.folder} (${result.starter} starter)`);
162
+ context.out(`next: animader dev ${shellQuote(result.folder)}`);
163
+ context.out(`first file to edit: ${PROJECT_FILES.program} (README.md explains the three authored files)`);
164
+ }