animader-cli 0.0.0-stage → 0.1.0
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 +261 -0
- package/adapters/devserver.mjs +481 -0
- package/adapters/devsession.mjs +619 -0
- package/adapters/export-node.mjs +601 -0
- package/adapters/export-run.mjs +126 -0
- package/adapters/generations.mjs +211 -0
- package/adapters/http.mjs +294 -0
- package/adapters/launch.mjs +83 -0
- package/adapters/node-fs.mjs +68 -0
- package/adapters/pngine.mjs +87 -0
- package/adapters/snapshot.mjs +129 -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/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
package/lib/new.mjs
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
// Fresh-project authoring decisions. Portable: paths, starter metadata and the
|
|
2
|
+
// exact write order live here; filesystem access and argv/process wiring come
|
|
3
|
+
// from host adapters. The starter is the same tracked fixture the demo already
|
|
4
|
+
// proves, so a new project needs no prepared vendor bundle or native producer.
|
|
5
|
+
//
|
|
6
|
+
// Honesty boundary: the emptiness check is point-in-time, not a lock. Files are
|
|
7
|
+
// written one at a time with the adapter's atomic per-file write; a write-phase
|
|
8
|
+
// failure leaves complete files behind and names them. The adapter removes only
|
|
9
|
+
// the failed write's own temporary file; an empty parent directory may remain.
|
|
10
|
+
// There is no rollback and no transaction claim.
|
|
11
|
+
|
|
12
|
+
import { PROJECT_FILES } from './contract.mjs';
|
|
13
|
+
import { DemoError, EXIT_CODES, projectError, usageError } from './errors.mjs';
|
|
14
|
+
import { createTestTone } from './wav.mjs';
|
|
15
|
+
|
|
16
|
+
const EMBER_STARTER = new URL('../starters/ember/', import.meta.url).href;
|
|
17
|
+
const CIRCLE_STARTER = new URL('../starters/circle/', import.meta.url).href;
|
|
18
|
+
|
|
19
|
+
const STARTER_FILES = [
|
|
20
|
+
{ path: PROJECT_FILES.program, source: 'program.sjon' },
|
|
21
|
+
{ path: PROJECT_FILES.timeline, source: 'timeline.piece.sjon' },
|
|
22
|
+
{ path: 'kernels/curl.wgsl', source: 'kernels/curl.wgsl' },
|
|
23
|
+
{ path: 'kernels/ember.wgsl', source: 'kernels/ember.wgsl' },
|
|
24
|
+
{ path: 'kernels/hash.wgsl', source: 'kernels/hash.wgsl' },
|
|
25
|
+
{ path: 'kernels/simplex.wgsl', source: 'kernels/simplex.wgsl' },
|
|
26
|
+
{ path: 'bake/spans.bin', source: 'bake/spans.bin' },
|
|
27
|
+
{ path: PROJECT_FILES.soundtrack, generate: 'test-tone' },
|
|
28
|
+
].map((entry) => Object.freeze(entry));
|
|
29
|
+
|
|
30
|
+
// The tutorial's end-of-step-6 circle: one kernel, an eight-second path
|
|
31
|
+
// with beat pulses, a complete timeline, plus the project config, the
|
|
32
|
+
// generated-output ignore entries and a README. `gitignore` is the tracked
|
|
33
|
+
// source of the target's `.gitignore` so git does not interpret it here.
|
|
34
|
+
const CIRCLE_FILES = [
|
|
35
|
+
{ path: 'animader.json', source: 'animader.json' },
|
|
36
|
+
{ path: PROJECT_FILES.program, source: 'program.sjon' },
|
|
37
|
+
{ path: PROJECT_FILES.timeline, source: 'timeline.piece.sjon' },
|
|
38
|
+
{ path: 'kernels/circle.wgsl', source: 'kernels/circle.wgsl' },
|
|
39
|
+
{ path: '.gitignore', source: 'gitignore' },
|
|
40
|
+
{ path: 'README.md', source: 'README.md' },
|
|
41
|
+
{ path: PROJECT_FILES.soundtrack, generate: 'test-tone' },
|
|
42
|
+
].map((entry) => Object.freeze(entry));
|
|
43
|
+
|
|
44
|
+
export const STARTERS = Object.freeze({
|
|
45
|
+
circle: Object.freeze({
|
|
46
|
+
name: 'circle',
|
|
47
|
+
sourceRoot: CIRCLE_STARTER,
|
|
48
|
+
files: Object.freeze(CIRCLE_FILES),
|
|
49
|
+
}),
|
|
50
|
+
ember: Object.freeze({
|
|
51
|
+
name: 'ember',
|
|
52
|
+
sourceRoot: EMBER_STARTER,
|
|
53
|
+
files: Object.freeze(STARTER_FILES),
|
|
54
|
+
}),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
export const STARTER_NAMES = Object.freeze(Object.keys(STARTERS));
|
|
58
|
+
|
|
59
|
+
function starterList() {
|
|
60
|
+
return STARTER_NAMES.join(', ');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function inFolder(folder, name) {
|
|
64
|
+
return `${String(folder).replace(/[\\/]+$/, '')}/${name}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Parse the fresh-project argv without touching the filesystem.
|
|
69
|
+
*
|
|
70
|
+
* @param {string[]} argv
|
|
71
|
+
* @returns {{help: boolean, folder: string|null, starter: string}}
|
|
72
|
+
*/
|
|
73
|
+
export function parseNewArgv(argv) {
|
|
74
|
+
if (!Array.isArray(argv)) {
|
|
75
|
+
throw usageError('invalid_argv', 'the CLI adapter must pass an argument array', 'Call node demo/new.mjs <folder> [--starter ember]');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
let folder = null;
|
|
79
|
+
let starter = 'ember';
|
|
80
|
+
let help = false;
|
|
81
|
+
let endOptions = false;
|
|
82
|
+
|
|
83
|
+
for (let index = 0; index < argv.length; index++) {
|
|
84
|
+
const argument = String(argv[index]);
|
|
85
|
+
if (!endOptions && argument === '--') {
|
|
86
|
+
endOptions = true;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (!endOptions && (argument === '--help' || argument === '-h')) {
|
|
90
|
+
help = true;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (!endOptions && argument.startsWith('--')) {
|
|
94
|
+
const equals = argument.indexOf('=');
|
|
95
|
+
const name = equals === -1 ? argument : argument.slice(0, equals);
|
|
96
|
+
const inline = equals === -1 ? undefined : argument.slice(equals + 1);
|
|
97
|
+
if (name === '--starter') {
|
|
98
|
+
if (inline !== undefined) {
|
|
99
|
+
starter = inline;
|
|
100
|
+
} else {
|
|
101
|
+
if (index + 1 >= argv.length) {
|
|
102
|
+
throw usageError('missing_option_value', `${name} needs a value`, `Pass ${name} ${starterList()}`);
|
|
103
|
+
}
|
|
104
|
+
index++;
|
|
105
|
+
starter = String(argv[index]);
|
|
106
|
+
}
|
|
107
|
+
} else {
|
|
108
|
+
throw usageError(
|
|
109
|
+
'unknown_option',
|
|
110
|
+
`unknown option ${name}`,
|
|
111
|
+
`The supported option is --starter (available: ${starterList()}); use --help for usage.`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (!endOptions && argument.startsWith('-') && argument !== '-') {
|
|
117
|
+
throw usageError(
|
|
118
|
+
'unknown_option',
|
|
119
|
+
`unknown option ${argument}`,
|
|
120
|
+
`The supported option is --starter (available: ${starterList()}); use --help for usage.`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
if (folder !== null) {
|
|
124
|
+
throw usageError('extra_argument', `extra folder argument ${JSON.stringify(argument)}`, 'Pass exactly one project folder');
|
|
125
|
+
}
|
|
126
|
+
folder = argument;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (help) return { help: true, folder, starter };
|
|
130
|
+
if (folder === null || folder === '') {
|
|
131
|
+
throw usageError(
|
|
132
|
+
'missing_folder',
|
|
133
|
+
'exactly one project folder is required',
|
|
134
|
+
'Run: node demo/new.mjs <folder> [--starter ember]',
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
if (!STARTER_NAMES.includes(starter)) {
|
|
138
|
+
throw usageError(
|
|
139
|
+
'unknown_starter',
|
|
140
|
+
`unknown starter ${JSON.stringify(starter)}`,
|
|
141
|
+
`Available starters: ${starterList()}`,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
return { help: false, folder, starter };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function newUsageText() {
|
|
148
|
+
return [
|
|
149
|
+
'animader demo new',
|
|
150
|
+
'',
|
|
151
|
+
'Usage:',
|
|
152
|
+
' node demo/new.mjs <folder> [--starter ember]',
|
|
153
|
+
'',
|
|
154
|
+
'Create an editable project from a tracked starter. The folder must not',
|
|
155
|
+
'exist, or must be an empty directory.',
|
|
156
|
+
'',
|
|
157
|
+
'Files:',
|
|
158
|
+
` ${PROJECT_FILES.program}`,
|
|
159
|
+
` ${PROJECT_FILES.timeline}`,
|
|
160
|
+
` ${PROJECT_FILES.soundtrack} generated 8.0 s labeled test tone; replace with your audio`,
|
|
161
|
+
' (later, one of soundtrack.wav, soundtrack.ogg or',
|
|
162
|
+
' soundtrack.opus — exactly one may exist at a time)',
|
|
163
|
+
' kernels/{curl,ember,hash,simplex}.wgsl',
|
|
164
|
+
' bake/spans.bin',
|
|
165
|
+
'',
|
|
166
|
+
'Options:',
|
|
167
|
+
` --starter NAME starter to copy (default: ember; available: ${starterList()})`,
|
|
168
|
+
" -- end options; the next argument is the folder even if it starts with '-'",
|
|
169
|
+
' -h, --help this text',
|
|
170
|
+
'',
|
|
171
|
+
'A folder symlink is followed. A target inside the tracked starter source is refused.',
|
|
172
|
+
].join('\n');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function shellQuote(value) {
|
|
176
|
+
return `'${String(value).replace(/'/g, "'\\''")}'`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function targetInspectionFailure(error, folder) {
|
|
180
|
+
return new DemoError('target_inspection_failed', `could not inspect ${folder}: ${error?.message ?? String(error)}`, {
|
|
181
|
+
exitCode: EXIT_CODES.project,
|
|
182
|
+
remedy: 'Check the folder path and its permissions; no files were written.',
|
|
183
|
+
cause: error,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function requireEmptyTarget({ folder, io }) {
|
|
188
|
+
let stats;
|
|
189
|
+
try {
|
|
190
|
+
stats = await io.statFile(folder);
|
|
191
|
+
} catch (error) {
|
|
192
|
+
throw targetInspectionFailure(error, folder);
|
|
193
|
+
}
|
|
194
|
+
if (stats === null) return;
|
|
195
|
+
|
|
196
|
+
if (!stats.isDirectory) {
|
|
197
|
+
throw projectError(
|
|
198
|
+
'project_folder_not_empty',
|
|
199
|
+
`${folder} exists but is not a directory`,
|
|
200
|
+
'Pass a folder that does not exist or an empty directory; no files were written.',
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
let entries;
|
|
205
|
+
try {
|
|
206
|
+
entries = await io.listDir(folder);
|
|
207
|
+
} catch (error) {
|
|
208
|
+
throw targetInspectionFailure(error, folder);
|
|
209
|
+
}
|
|
210
|
+
if (entries.length > 0) {
|
|
211
|
+
const first = [...entries].sort()[0];
|
|
212
|
+
throw projectError(
|
|
213
|
+
'project_folder_not_empty',
|
|
214
|
+
`the project folder is not empty: ${folder} (first conflicting entry: ${first})`,
|
|
215
|
+
'Choose a folder that does not exist or an empty directory; no files were written.',
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Create one editable project from a frozen starter.
|
|
222
|
+
*
|
|
223
|
+
* @param {Object} options
|
|
224
|
+
* @param {string} options.folder
|
|
225
|
+
* @param {string} options.starter
|
|
226
|
+
* @param {Object} options.io
|
|
227
|
+
* @param {(path: string|URL) => Promise<null|{isFile: boolean, isDirectory: boolean, size: number}>} options.io.statFile
|
|
228
|
+
* @param {(path: string|URL) => Promise<Uint8Array>} options.io.readFile
|
|
229
|
+
* @param {(path: string, bytes: Uint8Array) => Promise<void>} options.io.writeFile
|
|
230
|
+
* @param {(path: string) => Promise<string[]>} options.io.listDir
|
|
231
|
+
* @param {(bytes: Uint8Array) => string} options.io.hashBytes
|
|
232
|
+
*/
|
|
233
|
+
export async function createProject({ folder, starter, io }) {
|
|
234
|
+
if (typeof folder !== 'string' || folder === '') {
|
|
235
|
+
throw projectError('missing_folder', 'the project folder is empty', 'Pass the folder to create.');
|
|
236
|
+
}
|
|
237
|
+
if (!STARTER_NAMES.includes(starter)) {
|
|
238
|
+
throw usageError('unknown_starter', `unknown starter ${JSON.stringify(starter)}`, `Available starters: ${starterList()}`);
|
|
239
|
+
}
|
|
240
|
+
if (!io
|
|
241
|
+
|| typeof io.statFile !== 'function'
|
|
242
|
+
|| typeof io.readFile !== 'function'
|
|
243
|
+
|| typeof io.writeFile !== 'function'
|
|
244
|
+
|| typeof io.listDir !== 'function'
|
|
245
|
+
|| typeof io.hashBytes !== 'function') {
|
|
246
|
+
throw new TypeError('createProject needs io.statFile, io.readFile, io.writeFile, io.listDir and io.hashBytes');
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
await requireEmptyTarget({ folder, io });
|
|
250
|
+
|
|
251
|
+
const definition = STARTERS[starter];
|
|
252
|
+
const staged = [];
|
|
253
|
+
for (const entry of definition.files) {
|
|
254
|
+
let bytes;
|
|
255
|
+
try {
|
|
256
|
+
bytes = entry.generate === 'test-tone'
|
|
257
|
+
? createTestTone()
|
|
258
|
+
: await io.readFile(new URL(entry.source, definition.sourceRoot));
|
|
259
|
+
} catch (error) {
|
|
260
|
+
throw new DemoError(
|
|
261
|
+
'starter_source_unreadable',
|
|
262
|
+
`could not read starter source ${entry.source ?? entry.path}: ${error?.message ?? String(error)}`,
|
|
263
|
+
{
|
|
264
|
+
exitCode: EXIT_CODES.project,
|
|
265
|
+
remedy: 'Restore the tracked starter source under the animader-cli package (npm/cli/starters); no files were written.',
|
|
266
|
+
cause: error,
|
|
267
|
+
},
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
if (!bytes || typeof bytes.byteLength !== 'number' || bytes.byteLength === 0) {
|
|
271
|
+
throw new DemoError('starter_source_empty', `starter source ${entry.source ?? entry.path} is empty`, {
|
|
272
|
+
exitCode: EXIT_CODES.project,
|
|
273
|
+
remedy: 'Restore the tracked starter source under the animader-cli package (npm/cli/starters); no files were written.',
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
staged.push({ path: entry.path, bytes, sha256: io.hashBytes(bytes) });
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const created = [];
|
|
280
|
+
for (const file of staged) {
|
|
281
|
+
try {
|
|
282
|
+
await io.writeFile(inFolder(folder, file.path), file.bytes);
|
|
283
|
+
} catch (error) {
|
|
284
|
+
const complete = created.length > 0 ? created.map((entry) => entry.path).join(', ') : 'none';
|
|
285
|
+
throw new DemoError(
|
|
286
|
+
'project_write_failed',
|
|
287
|
+
`writing ${file.path} failed: ${error?.message ?? String(error)}. Complete files already written: ${complete}`,
|
|
288
|
+
{
|
|
289
|
+
exitCode: EXIT_CODES.project,
|
|
290
|
+
remedy: 'The complete files listed above remain on disk and are not rolled back. Inspect the folder and remove it yourself before retrying.',
|
|
291
|
+
cause: error,
|
|
292
|
+
},
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
created.push({ path: file.path, bytes: file.bytes.byteLength, sha256: file.sha256 });
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const contract = {};
|
|
299
|
+
for (const [seat, name] of Object.entries(PROJECT_FILES)) {
|
|
300
|
+
const file = created.find((entry) => entry.path === name);
|
|
301
|
+
contract[seat] = { name, bytes: file.bytes, sha256: file.sha256 };
|
|
302
|
+
}
|
|
303
|
+
return { folder, starter: definition.name, files: created, contract };
|
|
304
|
+
}
|
package/lib/pipeline.mjs
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
// The shared full-build pipeline (plan section 4): the export's own stages
|
|
2
|
+
// — graphics bake, program reflection, score derivation and Pacer packing —
|
|
3
|
+
// composed once, plus the playable-by-this-host audit. `animader build`,
|
|
4
|
+
// `animader dev`'s preflight and the release export all call
|
|
5
|
+
// `buildProject`; each caller owns the input snapshot the stages run on and
|
|
6
|
+
// the paths the artifacts land at. There is no second implementation and no
|
|
7
|
+
// child process here: the bake is `bakeProject`, the join is the score
|
|
8
|
+
// module's own `reflectedJoin`, and the audit IS `loadPackedShow` — the
|
|
9
|
+
// same load the release player runs — so a successful `pacer pack` alone
|
|
10
|
+
// can never be reported as a playable build.
|
|
11
|
+
//
|
|
12
|
+
// Every producer is an injected port (`producers.deriveScore`,
|
|
13
|
+
// `reflectPayload`, `packScore`, `pacerRtWasm`, `loadPacerRt`), so this
|
|
14
|
+
// module carries no `node:` import, no process state and no DOM. The audit
|
|
15
|
+
// decision itself is not injectable: it always runs, and a `ShowError` it
|
|
16
|
+
// answers keeps its own code, stage, document and diagnostics in the
|
|
17
|
+
// `DemoError` the CLI reports — the host's identity, never a generic
|
|
18
|
+
// "build failed".
|
|
19
|
+
//
|
|
20
|
+
// Ownership: stage order, the audit-before-receipt rule, the receipt
|
|
21
|
+
// assembly, the drift check between an inspection and its snapshot. WASM
|
|
22
|
+
// bytes, native processes and filesystem IO belong to the adapters.
|
|
23
|
+
|
|
24
|
+
import { bakeProject } from './build.mjs';
|
|
25
|
+
import { buildError } from './errors.mjs';
|
|
26
|
+
import { loadPackedShow, reflectedJoin, ShowError } from './score.mjs';
|
|
27
|
+
import { makeScoreBuildReceipt, serializeScoreBuildReceipt } from './receipt.mjs';
|
|
28
|
+
|
|
29
|
+
/** The stages a full build walks, in order. */
|
|
30
|
+
export const BUILD_STAGES = Object.freeze(['bake', 'derive', 'reflect', 'join', 'pack', 'audit', 'receipt']);
|
|
31
|
+
|
|
32
|
+
/** The conservative input-coverage method this toolchain records today. */
|
|
33
|
+
export const INPUT_SNAPSHOT_METHOD = 'input-snapshot';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* What the receipt's input rows mean when the compiler answers no
|
|
37
|
+
* dependency metadata of its own: every authored file of the filtered
|
|
38
|
+
* input snapshot the build consumed, with the generated output tree, VCS
|
|
39
|
+
* and dependency directories and owned export trees excluded. No second
|
|
40
|
+
* SJON reader exists to narrow this set.
|
|
41
|
+
*/
|
|
42
|
+
export const INPUT_SNAPSHOT_NOTE = 'every file of the filtered input snapshot the build consumed (generated out/, .git, node_modules and owned export trees excluded); the compiler answers no dependency metadata of its own, so this conservative set stands in for it';
|
|
43
|
+
|
|
44
|
+
const AUDIT_REMEDY = 'Fix the timeline so the score is playable by this host: every score target must be one the reflected program declares, and automation must stay on the registries this show hosts. This audit is the same load the player runs; it is never skipped.';
|
|
45
|
+
|
|
46
|
+
function wrapShowError(error, stageLabel) {
|
|
47
|
+
if (!(error instanceof ShowError)) return error;
|
|
48
|
+
return buildError(error.code, `${error.message} (${stageLabel} failed at stage '${error.stage}')`, AUDIT_REMEDY);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The playable-by-this-host audit: load the exact packed bytes through
|
|
53
|
+
* `loadPackedShow` with the playback-only runtime and the baked program's
|
|
54
|
+
* own reflection. A load the host refuses — an unknown target, an
|
|
55
|
+
* unsupported automation shape, a metadata gap — answers a `DemoError`
|
|
56
|
+
* carrying the host's own error code and message.
|
|
57
|
+
*
|
|
58
|
+
* @param {Object} options
|
|
59
|
+
* @param {string} [options.documentName] the artifact name failures keep
|
|
60
|
+
* @param {Uint8Array} options.bytes the packed 'PACB' score
|
|
61
|
+
* @param {Object<string, {type: string, elemCount: number}>} options.reflection
|
|
62
|
+
* @param {Uint8Array} options.pacerRtWasm
|
|
63
|
+
* @param {Function} options.loadPacerRt the `PacerRt.load` shape
|
|
64
|
+
*/
|
|
65
|
+
export async function auditPackedScore({ documentName = 'score.pacb', bytes, reflection, pacerRtWasm, loadPacerRt }) {
|
|
66
|
+
try {
|
|
67
|
+
return await loadPackedShow({
|
|
68
|
+
document: { name: documentName, bytes },
|
|
69
|
+
dep: { pacerRtWasm, reflection },
|
|
70
|
+
factories: { loadPacerRt },
|
|
71
|
+
});
|
|
72
|
+
} catch (error) {
|
|
73
|
+
const wrapped = wrapShowError(error, `the playable-by-this-host audit of ${documentName}`);
|
|
74
|
+
if (wrapped === error) throw error;
|
|
75
|
+
wrapped.cause = error;
|
|
76
|
+
throw wrapped;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Refuse a snapshot whose inspected rows drifted from the inspection the
|
|
82
|
+
* caller started from. One implementation for the export's and the build's
|
|
83
|
+
* drift checks; `what` names the failing operation in the error identity.
|
|
84
|
+
*
|
|
85
|
+
* @param {{original: {files: Object}, copied: {files: Object}, what: string}} options
|
|
86
|
+
*/
|
|
87
|
+
export async function assertProjectSnapshotsMatch({ original, copied, what }) {
|
|
88
|
+
for (const [seat, row] of Object.entries(original?.files ?? {})) {
|
|
89
|
+
const actual = copied?.files?.[seat];
|
|
90
|
+
if (!row || actual?.name !== row.name || actual?.sha256 !== row.sha256 || actual?.bytes !== row.bytes) {
|
|
91
|
+
throw buildError(
|
|
92
|
+
`${what}_project_drift`,
|
|
93
|
+
`${row?.name ?? seat} changed while the ${what} was preparing`,
|
|
94
|
+
`Re-run the ${what} from a quiet project folder.`,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function portCheck(producers) {
|
|
101
|
+
if (!producers
|
|
102
|
+
|| typeof producers.deriveScore !== 'function'
|
|
103
|
+
|| typeof producers.reflectPayload !== 'function'
|
|
104
|
+
|| typeof producers.packScore !== 'function'
|
|
105
|
+
|| typeof producers.pacerRtWasm !== 'function'
|
|
106
|
+
|| typeof producers.loadPacerRt !== 'function') {
|
|
107
|
+
throw new TypeError('buildProject needs producers.deriveScore, reflectPayload, packScore, pacerRtWasm and loadPacerRt (the real or fake build ports)');
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Walk the full build over one consistent input snapshot: bake the
|
|
113
|
+
* program, derive the score from the snapshot's timeline, reflect the
|
|
114
|
+
* baked payload, join exactly the targets the program declares, pack the
|
|
115
|
+
* score with the real pacer CLI, audit the packed bytes through the
|
|
116
|
+
* playback runtime, then — only after the audit passes — write the
|
|
117
|
+
* score-build receipt. A failed stage throws before any later stage runs,
|
|
118
|
+
* and a failed audit never leaves a receipt claiming a validated score.
|
|
119
|
+
*
|
|
120
|
+
* @param {Object} options
|
|
121
|
+
* @param {{folder: string, files: Object}} options.project the snapshot's inspection
|
|
122
|
+
* @param {number} options.seconds resolved host show length
|
|
123
|
+
* @param {{folder: string, outDir: string, payload: string, score: string, receipt: string,
|
|
124
|
+
* workDir: string, projectName: string, payloadRelative: string,
|
|
125
|
+
* scoreRelative: string, receiptRelative: string}} options.paths
|
|
126
|
+
* @param {Object} options.io
|
|
127
|
+
* @param {Object} options.native the pngine adapter
|
|
128
|
+
* @param {Object} options.runtime `{ verify }` over the prepared bundle
|
|
129
|
+
* @param {(bytes: Uint8Array) => string} options.hashBytes
|
|
130
|
+
* @param {Array<{path: string, bytes: number, sha256: string}>} options.inputFiles
|
|
131
|
+
* the consumed-input rows the receipt records
|
|
132
|
+
* @param {object} options.producers the build ports
|
|
133
|
+
* @returns {Promise<{stages: string[], payloadBytes: Uint8Array, payloadSha256: string,
|
|
134
|
+
* scoreBytes: Uint8Array, scoreSha256: string, reflection: Object,
|
|
135
|
+
* join: Array<{name: string, ncomp: number}>, receipt: object}>}
|
|
136
|
+
*/
|
|
137
|
+
export async function buildProject({
|
|
138
|
+
project,
|
|
139
|
+
seconds,
|
|
140
|
+
paths,
|
|
141
|
+
io,
|
|
142
|
+
native,
|
|
143
|
+
runtime,
|
|
144
|
+
hashBytes,
|
|
145
|
+
inputFiles,
|
|
146
|
+
producers,
|
|
147
|
+
}) {
|
|
148
|
+
portCheck(producers);
|
|
149
|
+
|
|
150
|
+
// bake: the shared graphics bake (verify, clear stale, validate, bake).
|
|
151
|
+
const baked = await bakeProject({
|
|
152
|
+
project,
|
|
153
|
+
paths,
|
|
154
|
+
staleFiles: [paths.payload, paths.score, paths.receipt],
|
|
155
|
+
io,
|
|
156
|
+
native,
|
|
157
|
+
runtime,
|
|
158
|
+
hashBytes,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// derive: the piece boundary derives the score from the snapshot's own
|
|
162
|
+
// timeline text; the pipeline never repairs or invents one.
|
|
163
|
+
const timelineBytes = await io.readFile(`${paths.folder}/${project.files.timeline.name}`);
|
|
164
|
+
const timelineText = new TextDecoder().decode(timelineBytes);
|
|
165
|
+
let scoreText;
|
|
166
|
+
try {
|
|
167
|
+
scoreText = await producers.deriveScore(timelineText);
|
|
168
|
+
} catch (error) {
|
|
169
|
+
if (error?.code === 'derive_failed' || error?.code === 'piece_host_failed') throw error;
|
|
170
|
+
throw buildError(
|
|
171
|
+
'derive_failed',
|
|
172
|
+
`the piece boundary refused the derivation: ${error?.message ?? String(error)}`,
|
|
173
|
+
'Run the same timeline through the dev player; the build never invents or repairs a score.',
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
if (typeof scoreText !== 'string' || scoreText === '') {
|
|
177
|
+
throw buildError('derive_failed', 'the piece boundary answered an empty derived score', 'Inspect the timeline; the build refuses an empty score.');
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// reflect: the baked program's own uniform table, never a second reader.
|
|
181
|
+
const reflection = await producers.reflectPayload(baked.bytes);
|
|
182
|
+
|
|
183
|
+
// join: exactly the targets the reflection declares, in its table order.
|
|
184
|
+
let join;
|
|
185
|
+
try {
|
|
186
|
+
join = reflectedJoin(reflection, project.files.program.name);
|
|
187
|
+
} catch (error) {
|
|
188
|
+
const wrapped = wrapShowError(error, `the score join for ${project.files.program.name}`);
|
|
189
|
+
wrapped.cause = error;
|
|
190
|
+
throw wrapped;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// pack: the real pacer CLI compiles the derived score against that one
|
|
194
|
+
// registry; a refusal (an unknown or ill-typed target) is its own error.
|
|
195
|
+
const packed = await producers.packScore({ scoreText, reflection, outPath: paths.score, workDir: paths.workDir });
|
|
196
|
+
if (!(packed?.bytes instanceof Uint8Array) || packed.bytes.byteLength === 0) {
|
|
197
|
+
throw buildError('pack_failed', 'the pacer pack answered no packed score bytes', 'Inspect the pacer CLI run; a successful pack lands a PACB file.');
|
|
198
|
+
}
|
|
199
|
+
if (!packed.tool || typeof packed.tool.name !== 'string' || packed.tool.name === '' || !/^[0-9a-f]{64}$/.test(String(packed.tool.sha256 ?? ''))) {
|
|
200
|
+
throw buildError('pack_failed', 'the pacer pack answered no tool identity (name and sha256)', 'The pack port must name the CLI it ran; the receipt records it.');
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// audit: load the exact packed bytes through the playback runtime with
|
|
204
|
+
// the baked program's reflection. Never skipped, never preceded by the
|
|
205
|
+
// receipt.
|
|
206
|
+
const pacerRtWasm = await producers.pacerRtWasm();
|
|
207
|
+
await auditPackedScore({
|
|
208
|
+
documentName: paths.scoreRelative,
|
|
209
|
+
bytes: packed.bytes,
|
|
210
|
+
reflection,
|
|
211
|
+
pacerRtWasm,
|
|
212
|
+
loadPacerRt: producers.loadPacerRt,
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// receipt: proof of a validated score, written only after the audit.
|
|
216
|
+
const receipt = makeScoreBuildReceipt({
|
|
217
|
+
project,
|
|
218
|
+
projectName: paths.projectName,
|
|
219
|
+
seconds,
|
|
220
|
+
outputs: {
|
|
221
|
+
payload: { name: paths.payloadRelative, bytes: baked.bytes.byteLength, sha256: baked.sha256 },
|
|
222
|
+
score: { name: paths.scoreRelative, bytes: packed.bytes.byteLength, sha256: hashBytes(packed.bytes) },
|
|
223
|
+
},
|
|
224
|
+
inputs: {
|
|
225
|
+
method: INPUT_SNAPSHOT_METHOD,
|
|
226
|
+
note: INPUT_SNAPSHOT_NOTE,
|
|
227
|
+
files: inputFiles,
|
|
228
|
+
},
|
|
229
|
+
runtime: baked.runtime,
|
|
230
|
+
toolchain: {
|
|
231
|
+
pngine: baked.identity,
|
|
232
|
+
pacer: { name: packed.tool.name, sha256: packed.tool.sha256 },
|
|
233
|
+
},
|
|
234
|
+
});
|
|
235
|
+
await io.writeFile(paths.receipt, serializeScoreBuildReceipt(receipt));
|
|
236
|
+
|
|
237
|
+
return {
|
|
238
|
+
stages: [...BUILD_STAGES],
|
|
239
|
+
payloadBytes: baked.bytes,
|
|
240
|
+
payloadSha256: baked.sha256,
|
|
241
|
+
scoreBytes: packed.bytes,
|
|
242
|
+
scoreSha256: hashBytes(packed.bytes),
|
|
243
|
+
reflection,
|
|
244
|
+
join,
|
|
245
|
+
receipt,
|
|
246
|
+
};
|
|
247
|
+
}
|