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.
- package/LICENSE +121 -0
- package/adapters/build-node.mjs +265 -0
- package/adapters/devserver.mjs +481 -0
- package/adapters/devsession.mjs +619 -0
- package/adapters/export-node.mjs +603 -0
- package/adapters/export-run.mjs +126 -0
- package/adapters/generations.mjs +183 -0
- package/adapters/http.mjs +294 -0
- package/adapters/launch.mjs +83 -0
- package/adapters/node-fs.mjs +80 -0
- package/adapters/pngine.mjs +87 -0
- package/adapters/snapshot.mjs +143 -0
- package/adapters/target.mjs +104 -0
- package/adapters/watch.mjs +355 -0
- package/adapters/webaudio.mjs +102 -0
- package/bin/animader.mjs +31 -0
- package/hello/join.js +46 -0
- package/hello/run.js +259 -0
- package/lib/build.mjs +126 -0
- package/lib/buildloop.mjs +254 -0
- package/lib/contract.mjs +371 -0
- package/lib/errors.mjs +51 -0
- package/lib/export.mjs +445 -0
- package/lib/new.mjs +304 -0
- package/lib/pipeline.mjs +247 -0
- package/lib/receipt.mjs +331 -0
- package/lib/rmtree.mjs +49 -0
- package/lib/routes.mjs +170 -0
- package/lib/score.mjs +434 -0
- package/lib/show.mjs +458 -0
- package/lib/soundtrack.mjs +67 -0
- package/lib/vendor.mjs +99 -0
- package/lib/wav.mjs +197 -0
- package/package.json +47 -4
- package/player/boot.mjs +792 -0
- package/player/controller.mjs +366 -0
- package/player/devchannel.mjs +306 -0
- package/player/index.html +35 -0
- package/player/page.mjs +134 -0
- package/player/player.css +393 -0
- package/player/release.mjs +76 -0
- package/src/commands/build.mjs +201 -0
- package/src/commands/dev.mjs +214 -0
- package/src/commands/doctor.mjs +348 -0
- package/src/commands/export.mjs +158 -0
- package/src/commands/new.mjs +164 -0
- package/src/commands/project.mjs +46 -0
- package/src/config.mjs +110 -0
- package/src/router.mjs +149 -0
- package/src/toolchain.mjs +691 -0
- package/starters/circle/README.md +50 -0
- package/starters/circle/animader.json +4 -0
- package/starters/circle/gitignore +9 -0
- package/starters/circle/kernels/circle.wgsl +32 -0
- package/starters/circle/program.sjon +34 -0
- package/starters/circle/timeline.piece.sjon +26 -0
- package/starters/ember/bake/spans.bin +0 -0
- package/starters/ember/kernels/curl.wgsl +10 -0
- package/starters/ember/kernels/ember.wgsl +10 -0
- package/starters/ember/kernels/hash.wgsl +12 -0
- package/starters/ember/kernels/simplex.wgsl +27 -0
- package/starters/ember/program.sjon +328 -0
- package/starters/ember/timeline.piece.sjon +34 -0
- package/toolchain/licenses/animader.txt +121 -0
- package/toolchain/licenses/esbuild.txt +21 -0
- package/toolchain/licenses/pacer.txt +121 -0
- package/toolchain/licenses/pngine.txt +121 -0
- package/toolchain/manifest.json +212 -0
- package/toolchain/prepare.mjs +446 -0
- package/toolchain/uniform-type-name.js +184 -0
- 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
|
+
}
|