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,80 @@
1
+ // Node filesystem adapter. Portable decisions receive these functions; this
2
+ // file is the only place that touches node:fs for the project folder, the
3
+ // prepared bundle and the generated output.
4
+
5
+ import { createHash } from 'node:crypto';
6
+ import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';
7
+ import { dirname } from 'node:path';
8
+
9
+ import { removeTree as retryingRemoveTree } from '../lib/rmtree.mjs';
10
+
11
+ export function hashBytes(bytes) {
12
+ return createHash('sha256').update(bytes).digest('hex');
13
+ }
14
+
15
+ export async function hashFile(path) {
16
+ return hashBytes(await readFile(path));
17
+ }
18
+
19
+ export async function statFile(path) {
20
+ try {
21
+ const info = await stat(path);
22
+ return { isFile: info.isFile(), isDirectory: info.isDirectory(), size: info.size };
23
+ } catch (error) {
24
+ if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return null;
25
+ throw error;
26
+ }
27
+ }
28
+
29
+ export async function readFileBytes(path) {
30
+ return readFile(path);
31
+ }
32
+
33
+ export async function listDir(path) {
34
+ return (await readdir(path)).sort();
35
+ }
36
+
37
+ export async function ensureDir(path) {
38
+ await mkdir(path, { recursive: true });
39
+ }
40
+
41
+ export async function removeFile(path) {
42
+ await rm(path, { force: true });
43
+ }
44
+
45
+ /**
46
+ * The one recursive tree removal for work-tree disposal: `fsp.rm` under the
47
+ * shared transient-code retry (lib/rmtree.mjs). Every owner of a throwaway
48
+ * tree — the dev generation store, the full build's snapshot work tree, the
49
+ * export's work tree — disposes through this, never a bare rm.
50
+ */
51
+ export async function removeTree(path) {
52
+ await retryingRemoveTree(path, { remove: (target) => rm(target, { recursive: true, force: true }) });
53
+ }
54
+
55
+ export async function writeFileAtomic(path, data) {
56
+ await mkdir(dirname(path), { recursive: true });
57
+ const temporary = `${path}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
58
+ // The temporary file is owned by this write. Clean it up for a failed write
59
+ // as well as a failed rename; parent directories already created may remain.
60
+ try {
61
+ await writeFile(temporary, data);
62
+ await rename(temporary, path);
63
+ } catch (error) {
64
+ await rm(temporary, { force: true });
65
+ throw error;
66
+ }
67
+ }
68
+
69
+ export function createNodeIo() {
70
+ return {
71
+ hashBytes,
72
+ hashFile,
73
+ statFile,
74
+ readFile: readFileBytes,
75
+ listDir,
76
+ ensureDir,
77
+ removeFile,
78
+ writeFile: writeFileAtomic,
79
+ };
80
+ }
@@ -0,0 +1,87 @@
1
+ // Native pngine adapter. It runs the two checks this subslice owns, with an
2
+ // argv array and no shell interpolation:
3
+ //
4
+ // pngine validate <program>
5
+ // pngine render <program> -o <payload>
6
+ //
7
+ // It does not run pacer, compile a score, infer targets from diagnostics or
8
+ // retry an unknown target. Those decisions belong to the browser's semantic
9
+ // READY path in the next slice.
10
+
11
+ import { execFile } from 'node:child_process';
12
+ import { promisify } from 'node:util';
13
+
14
+ import { DemoError } from '../lib/errors.mjs';
15
+ import { hashFile, statFile } from './node-fs.mjs';
16
+
17
+ const execFileAsync = promisify(execFile);
18
+ const MAX_DIAGNOSTIC = 2000;
19
+
20
+ function diagnostic(error) {
21
+ const text = String(error?.stderr ?? error?.message ?? error ?? '').trim();
22
+ if (text === '') return 'no diagnostic output';
23
+ return text.length > MAX_DIAGNOSTIC ? `…${text.slice(-MAX_DIAGNOSTIC)}` : text;
24
+ }
25
+
26
+ /**
27
+ * @param {Object} options
28
+ * @param {string} options.binary absolute pngine CLI path
29
+ * @param {Function} [options.exec] injected for tests; argv array, never a shell
30
+ * @param {number} [options.timeout]
31
+ */
32
+ export function createPngineNative({ binary, exec = execFileAsync, timeout = 120000 }) {
33
+ if (typeof binary !== 'string' || binary === '') throw new TypeError('pngine binary path is required');
34
+ let identityPromise;
35
+
36
+ async function run(phase, args, { cwd }) {
37
+ try {
38
+ return await exec(binary, args, {
39
+ cwd,
40
+ timeout,
41
+ maxBuffer: 8 * 1024 * 1024,
42
+ windowsHide: true,
43
+ });
44
+ } catch (error) {
45
+ const missing = error?.code === 'ENOENT';
46
+ throw new DemoError(
47
+ 'native_pngine_failed',
48
+ missing
49
+ ? `pngine is missing at ${binary}`
50
+ : `pngine ${phase} failed for ${args[1]} (exit ${error?.code ?? 'unknown'}): ${diagnostic(error)}`,
51
+ {
52
+ exitCode: 4,
53
+ remedy: missing
54
+ ? "Set PNGINE to the pngine CLI binary and build it if needed; the demo never installs it."
55
+ : `Run the same command by hand from the project folder with PNGINE=${binary}; no score target is inferred or retried.`,
56
+ cause: error,
57
+ },
58
+ );
59
+ }
60
+ }
61
+
62
+ return {
63
+ async identity() {
64
+ if (identityPromise === undefined) {
65
+ identityPromise = (async () => {
66
+ const info = await statFile(binary);
67
+ if (info === null || !info.isFile) {
68
+ throw new DemoError('missing_pngine', `pngine is missing at ${binary}`, {
69
+ exitCode: 4,
70
+ remedy: "Build pngine or set PNGINE to its CLI binary; ANIMADER_PNGINE names the checkout root.",
71
+ });
72
+ }
73
+ return { name: 'pngine', sha256: await hashFile(binary) };
74
+ })();
75
+ }
76
+ return identityPromise;
77
+ },
78
+
79
+ async validate({ cwd, program }) {
80
+ return run('validate', ['validate', program], { cwd });
81
+ },
82
+
83
+ async bake({ cwd, program, output }) {
84
+ return run('render', ['render', program, '-o', output], { cwd });
85
+ },
86
+ };
87
+ }
@@ -0,0 +1,143 @@
1
+ // The input snapshot: one consistent copy of the authored project, taken
2
+ // outside the source tree, that each build-shaped caller (the release
3
+ // export, `animader build`, dev's preflight) owns for itself. The filter is
4
+ // the one policy: generated `out/`, the caller's own output/staging trees
5
+ // and detached reports when they sit inside the project, `.git`,
6
+ // `node_modules` and legacy `.animader-export-work-*` debris never enter a
7
+ // snapshot, and every excluded root is excluded as a whole subtree — no
8
+ // file under it enters the copy or the receipt's rows either; `.gitignore`
9
+ // is data for humans and git, never an asset allowlist.
10
+ // `listSnapshotInputFiles` hashes the filtered snapshot into the receipt's
11
+ // consumed-input rows — the conservative coverage the build receipt records
12
+ // when the compiler answers no dependency metadata of its own.
13
+
14
+ import { promises as fsp } from 'node:fs';
15
+ import { tmpdir } from 'node:os';
16
+ import { isAbsolute, join, relative, resolve, sep } from 'node:path';
17
+ import { createHash } from 'node:crypto';
18
+
19
+ import { GENERATED_DIR } from '../lib/contract.mjs';
20
+
21
+ function hashBytes(bytes) {
22
+ return createHash('sha256').update(bytes).digest('hex');
23
+ }
24
+
25
+ /** Directories never copied into an input snapshot, at any depth. */
26
+ export const SNAPSHOT_PRUNED_NAMES = Object.freeze(['.git', 'node_modules']);
27
+ /** Leftovers of the pre-fix work-folder placement, never snapshot input. */
28
+ export const LEGACY_WORK_PREFIX = '.animader-export-work-';
29
+
30
+ /**
31
+ * Root-relative paths of the caller's own output, staging and size-report
32
+ * locations, but only those that sit inside the project folder. The
33
+ * project's own generated directory is always among them — the project
34
+ * root is known at copy time and `GENERATED_DIR` is a contract constant —
35
+ * whether or not the caller named any paths at all. These are owned
36
+ * generated trees and reports, not authored resources, so they never enter
37
+ * an input snapshot. The exclusion is this fixed list — the `.gitignore`
38
+ * file is data for humans and git, never an asset allowlist.
39
+ */
40
+ function snapshotExclusions({ projectRoot, paths }) {
41
+ const insideProject = (path) => {
42
+ if (!path) return null;
43
+ const rel = relative(projectRoot, resolve(path));
44
+ if (rel === '' || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) return null;
45
+ return rel.split(sep).join('/');
46
+ };
47
+ const excluded = new Set([GENERATED_DIR]);
48
+ for (const path of [paths?.outDir, paths?.stageDir, paths?.sizePath]) {
49
+ const rel = insideProject(path);
50
+ if (rel !== null) excluded.add(rel);
51
+ }
52
+ return excluded;
53
+ }
54
+
55
+ export function snapshotFilter({ projectRoot, paths }) {
56
+ const excluded = snapshotExclusions({ projectRoot, paths });
57
+ return (source) => {
58
+ const rel = relative(projectRoot, source);
59
+ if (rel === '') return true;
60
+ const segments = rel.split(sep);
61
+ if (segments.some((name) => SNAPSHOT_PRUNED_NAMES.includes(name))) return false;
62
+ if (segments.some((name) => name.startsWith(LEGACY_WORK_PREFIX))) return false;
63
+ // Any excluded root-relative path is a subtree: a file or a deeper
64
+ // directory under it is excluded with it, in the copy filter and in the
65
+ // receipt's hashing walk alike — the same shape the watcher's filter
66
+ // keeps. An exact-path match alone would let a row under `out/` through
67
+ // whenever the walk meets a tree that still carries one.
68
+ for (let depth = 1; depth <= segments.length; depth += 1) {
69
+ if (excluded.has(segments.slice(0, depth).join('/'))) return false;
70
+ }
71
+ return true;
72
+ };
73
+ }
74
+
75
+ /**
76
+ * The input snapshot: a fresh copy of the authored project OUTSIDE
77
+ * the source tree, so an in-project output (`animader export --out dist`
78
+ * from the project root) never asks Node to copy a directory into its own
79
+ * subtree (ERR_FS_CP_EINVAL). The copy keeps every authored file at its
80
+ * relative path and excludes only the generated and caller-owned trees and
81
+ * reports plus VCS/dependency directories — each as a whole subtree: the
82
+ * project's own generated `out/` (a contract constant, named by the caller
83
+ * or not), the output, its `<out>.next` staging directory and detached
84
+ * `<out>.size.json` when they sit inside the project, `.git`,
85
+ * `node_modules`, and legacy `.animader-export-work-*` debris. `.gitignore`
86
+ * is not consulted.
87
+ *
88
+ * @param {Object} options
89
+ * @param {{folder: string}} options.project the inspected source project
90
+ * @param {{outDir: string, stageDir: string, sizePath: string}} options.paths
91
+ * @param {string} [options.workRoot] parent for the work folder; the
92
+ * default is the OS temporary directory, always outside the source tree
93
+ * @returns {Promise<{work: string, workProject: string}>} `work` is owned by
94
+ * the caller of this invocation and must be removed by it
95
+ */
96
+ export async function snapshotProject({ project, paths, workRoot }) {
97
+ const root = workRoot ?? tmpdir();
98
+ await fsp.mkdir(root, { recursive: true });
99
+ const work = await fsp.mkdtemp(join(root, 'animader-export-work-'));
100
+ const workProject = join(work, 'project');
101
+ await fsp.cp(project.folder, workProject, {
102
+ recursive: true,
103
+ filter: snapshotFilter({ projectRoot: project.folder, paths }),
104
+ });
105
+ return { work, workProject };
106
+ }
107
+
108
+ /**
109
+ * The receipt's consumed-input rows: every authored file of the filtered
110
+ * snapshot, hashed, sorted by path. This is the conservative method the
111
+ * score-build receipt records (`inputs.method: input-snapshot`); no second
112
+ * SJON reader is written to guess includes, so shader files and other
113
+ * consumed resources are covered by hashing the whole filtered copy.
114
+ *
115
+ * @param {{workProject: string, paths?: {outDir: string, stageDir: string, sizePath: string}}} options
116
+ * @returns {Promise<Array<{path: string, bytes: number, sha256: string}>>}
117
+ */
118
+ export async function listSnapshotInputFiles({ workProject, paths = {} }) {
119
+ const include = snapshotFilter({ projectRoot: workProject, paths });
120
+ const rows = [];
121
+
122
+ async function walk(folder) {
123
+ for (const entry of (await fsp.readdir(folder, { withFileTypes: true })).sort((a, b) => (a.name < b.name ? -1 : 1))) {
124
+ const path = join(folder, entry.name);
125
+ if (entry.isDirectory()) {
126
+ await walk(path);
127
+ continue;
128
+ }
129
+ if (!entry.isFile()) continue;
130
+ if (!include(path)) continue;
131
+ const bytes = await fsp.readFile(path);
132
+ rows.push({
133
+ path: path.slice(workProject.length + 1).split('\\').join('/'),
134
+ bytes: bytes.byteLength,
135
+ sha256: hashBytes(bytes),
136
+ });
137
+ }
138
+ }
139
+
140
+ await walk(workProject);
141
+ rows.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
142
+ return rows;
143
+ }
@@ -0,0 +1,104 @@
1
+ // Target-path adapter for fresh-project creation: resolve every component
2
+ // and symlink of the target folder, and refuse a target inside a tracked
3
+ // starter source. This is the Node filesystem half of the guarantee; the
4
+ // emptiness check and the write order stay in lib/new.mjs. The demo entry
5
+ // point and the animader CLI share these helpers so both enforce the same
6
+ // refusals.
7
+
8
+ import { lstat, readlink, realpath } from 'node:fs/promises';
9
+ import { dirname, isAbsolute, join, relative, sep } from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+
12
+ import { DemoError, EXIT_CODES } from '../lib/errors.mjs';
13
+ import { STARTERS } from '../lib/new.mjs';
14
+
15
+ const MAX_LINKS = 64;
16
+
17
+ // Resolve every existing component and every symlink, including a dangling
18
+ // link whose target does not exist yet. Missing components are resolved
19
+ // lexically from their nearest resolved ancestor, so a target that does not
20
+ // exist cannot hide a symlink alias into the pinned starter source.
21
+ async function canonicalPath(path) {
22
+ const absolute = isAbsolute(path) ? path : `${process.cwd()}/${path}`;
23
+ const pending = absolute.split('/');
24
+ const resolved = [];
25
+ let links = 0;
26
+
27
+ for (let index = 0; index < pending.length; index++) {
28
+ const segment = pending[index];
29
+ if (segment === '' || segment === '.') continue;
30
+ if (segment === '..') {
31
+ resolved.pop();
32
+ continue;
33
+ }
34
+
35
+ const candidate = `/${[...resolved, segment].join('/')}`;
36
+ let info;
37
+ try {
38
+ info = await lstat(candidate);
39
+ } catch (error) {
40
+ if (error.code === 'ENOENT' || error.code === 'ENOTDIR') {
41
+ resolved.push(segment);
42
+ continue;
43
+ }
44
+ throw error;
45
+ }
46
+
47
+ if (info.isSymbolicLink()) {
48
+ links++;
49
+ if (links > MAX_LINKS) {
50
+ throw Object.assign(new Error(`too many symbolic links while resolving ${path}`), { code: 'ELOOP' });
51
+ }
52
+ const target = await readlink(candidate);
53
+ if (isAbsolute(target)) resolved.length = 0;
54
+ pending.splice(index + 1, 0, ...target.split('/'));
55
+ continue;
56
+ }
57
+ resolved.push(segment);
58
+ }
59
+
60
+ return `/${resolved.join('/')}`;
61
+ }
62
+
63
+ function inside(parent, candidate) {
64
+ const rel = relative(parent, candidate);
65
+ return rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
66
+ }
67
+
68
+ export async function refuseStarterSource(folder, starter) {
69
+ let sourceRoot;
70
+ try {
71
+ sourceRoot = await realpath(fileURLToPath(STARTERS[starter].sourceRoot));
72
+ } catch (error) {
73
+ throw new DemoError('starter_source_unreadable', `could not resolve the ${starter} starter source: ${error?.message ?? String(error)}`, {
74
+ exitCode: EXIT_CODES.project,
75
+ remedy: 'Restore the tracked starter source; no files were written.',
76
+ cause: error,
77
+ });
78
+ }
79
+ if (inside(sourceRoot, folder)) {
80
+ throw new DemoError('target_inside_starter', `${folder} is inside the tracked ${starter} starter source (${sourceRoot})`, {
81
+ exitCode: EXIT_CODES.project,
82
+ remedy: `Choose a folder outside ${sourceRoot}; no files were written.`,
83
+ });
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Resolve the requested target folder through every symlink, mapping a
89
+ * resolution failure to the shared named project error.
90
+ *
91
+ * @param {string} folder
92
+ * @returns {Promise<string>}
93
+ */
94
+ export async function resolveTargetFolder(folder) {
95
+ try {
96
+ return await canonicalPath(folder);
97
+ } catch (error) {
98
+ throw new DemoError('target_inspection_failed', `could not resolve ${folder}: ${error?.message ?? String(error)}`, {
99
+ exitCode: EXIT_CODES.project,
100
+ remedy: 'Check the folder path and its permissions; no files were written.',
101
+ cause: error,
102
+ });
103
+ }
104
+ }