@nextcommerce/campaigns-os 1.43.1 → 1.43.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/AGENTS.md +4 -2
  2. package/CHANGELOG.md +451 -0
  3. package/README.md +2 -2
  4. package/contracts/agent-relevant-change-policy.v1.json +5 -0
  5. package/contracts/effects.v1.json +8 -8
  6. package/contracts/release-ledger.json +672 -0
  7. package/contracts/supported-surface.json +2 -2
  8. package/docs/build-packet.md +64 -2
  9. package/docs/campaigns-os-build-flow.md +1 -0
  10. package/docs/design-source-package.md +89 -15
  11. package/docs/effects.md +16 -4
  12. package/docs/local-setup.md +1 -1
  13. package/docs/orientation-contract-reference.md +1 -1
  14. package/docs/progress-snapshots.md +10 -6
  15. package/docs/qa-and-test-orders.md +131 -7
  16. package/docs/release-ledger-authoring-guide.md +6 -4
  17. package/docs/runtime-readiness.md +1 -1
  18. package/docs/skills-revision.md +10 -10
  19. package/package.json +1 -1
  20. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  21. package/skills/campaign-readback-classification/SKILL.md +3 -3
  22. package/skills/campaign-run-evidence/SKILL.md +3 -3
  23. package/skills/contribution-intake/SKILL.md +3 -3
  24. package/skills/next-campaigns-build/SKILL.md +4 -4
  25. package/skills/next-campaigns-os/SKILL.md +3 -3
  26. package/skills/next-campaigns-os-setup/SKILL.md +3 -3
  27. package/skills/next-campaigns-polish/SKILL.md +3 -3
  28. package/skills/next-campaigns-qa/SKILL.md +3 -3
  29. package/skills.json +10 -10
  30. package/src/build-brief.mjs +6 -4
  31. package/src/built-script-syntax.mjs +480 -0
  32. package/src/campaigns-api-key.mjs +99 -0
  33. package/src/cli-helpers.mjs +118 -0
  34. package/src/cli.mjs +1211 -7495
  35. package/src/design-source-package.mjs +1 -1
  36. package/src/design-source-publication.mjs +898 -0
  37. package/src/diagnostic.mjs +2 -1
  38. package/src/directory-lock.mjs +270 -0
  39. package/src/doctor/checks.mjs +4415 -0
  40. package/src/doctor/inspect.mjs +636 -0
  41. package/src/doctor/next-step.mjs +731 -0
  42. package/src/install-invocation.mjs +29 -0
  43. package/src/invocation.mjs +179 -0
  44. package/src/private-template-source.mjs +1 -1
  45. package/src/progress-node.mjs +6 -35
  46. package/src/proof-policy.mjs +1 -1
  47. package/src/qa-analytics-correctness.mjs +3 -0
  48. package/src/qa-binding-evidence.mjs +76 -11
  49. package/src/qa-browser.mjs +778 -77
  50. package/src/qa-build-scope.mjs +47 -0
  51. package/src/qa-node.mjs +218 -13
  52. package/src/source-html-intake.mjs +1 -1
  53. package/src/source-html-manifest.mjs +9 -2
  54. package/src/stage-ledger.mjs +28 -0
  55. package/src/target-lock.mjs +54 -0
  56. package/src/template-brand-contract.mjs +17 -1
@@ -0,0 +1,898 @@
1
+ // Design Source Package publication for prepare-build, start and build: the
2
+ // lock's critical section, collisions, evidence re-checks and publication order.
3
+ import { randomUUID } from "node:crypto";
4
+ import { accessSync, constants as fsConstants, existsSync, linkSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
5
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
6
+ import { writeThemeArtifacts } from "./brand-theme.mjs";
7
+ import { cloneJson, filesystemPathsMatch, isObject, isNonEmptyString, optionalString, readJson, resolveFromFile, sha256File } from "./cli-helpers.mjs";
8
+ import { DESIGN_SOURCE_PACKAGE_REL_PATH, createDesignSourcePackageArtifactReference, hashSerializedDesignSourcePackage, serializeDesignSourcePackage, synthesizeHtmlFunnelDesignSourcePackage, validateDesignSourcePackage } from "./design-source-package.mjs";
9
+ import { stampDoctorProducer } from "./doctor-sidecar.mjs";
10
+ import { ASSEMBLY_REPORT_STAGE_KEYS } from "./orchestration-stage-contract.mjs";
11
+ import { targetLockPath, withTargetLock } from "./target-lock.mjs";
12
+ import { resolveTemplateFamilyDesignSource } from "./template-reference.mjs";
13
+
14
+ // Stage keys whose seed states prepare-build itself rewrites on every run
15
+ // (createInitialAssemblyReportStages): prepare_build is re-derived from this
16
+ // run's readiness result, and setup from scaffold detection — so their seed
17
+ // states never count as accumulated agent evidence. setup's seed state may
18
+ // legitimately be "skipped" (scaffold already present); every other stage is
19
+ // seeded "pending" with empty ledger arrays.
20
+ function assemblyReportStagesWithEvidence(existingReport) {
21
+ const stages = existingReport?.stages;
22
+ if (!isObject(stages)) return [];
23
+ const withEvidence = [];
24
+ for (const key of ASSEMBLY_REPORT_STAGE_KEYS) {
25
+ if (key === "prepare_build") continue;
26
+ const stage = stages[key];
27
+ if (!isObject(stage)) continue;
28
+ const seedStatuses = key === "setup" ? ["pending", "skipped"] : ["pending"];
29
+ const nonSeedStatus = !seedStatuses.includes(optionalString(stage.status, "pending"));
30
+ const recordedContent =
31
+ ["inputs", "outputs", "commands", "blockers", "warnings", "evidence"].some(
32
+ (field) => Array.isArray(stage[field]) && stage[field].length > 0,
33
+ )
34
+ || (isObject(stage.evidence) && Object.keys(stage.evidence).length > 0);
35
+ if (nonSeedStatus || recordedContent) withEvidence.push(key);
36
+ }
37
+ return withEvidence;
38
+ }
39
+
40
+ // INV-4 guard (packet 02): prepare-build/start regenerate the assembly report
41
+ // from scratch (createAssemblyReport resets every stage), so an unconditional
42
+ // write silently destroys agent-recorded stage evidence. Mirror the
43
+ // runSessionStart pattern: refuse unless --force, and with --force announce
44
+ // exactly which stage keys are cleared. The guard keys on evidence, not file
45
+ // existence — a report whose stages are all still at their seed states (a real
46
+ // re-prepare before any work) regenerates freely with no flag.
47
+ //
48
+ // withDesignSourcePublication runs it twice inside the target lock: once
49
+ // before reading its inputs, and again (with `announced`, the keys the first
50
+ // call returned) right before the report is renamed into place. Stage
51
+ // producers commit the report without that lock, so evidence can land while
52
+ // the run is working; the second call refuses it without --force and, with
53
+ // --force, names any stage it had not already announced.
54
+ function guardAssemblyReportOverwrite(reportPath, args, { announced = null } = {}) {
55
+ if (!existsSync(reportPath)) return [];
56
+ let existingReport = null;
57
+ try {
58
+ existingReport = readJson(reportPath);
59
+ } catch {
60
+ return []; // An unreadable report carries no provable evidence; keep today's regeneration path.
61
+ }
62
+ const stageKeys = assemblyReportStagesWithEvidence(existingReport);
63
+ if (stageKeys.length === 0) return [];
64
+ if (args.force !== true) {
65
+ throw new Error(
66
+ `Assembly report at ${reportPath} ${announced ? "gained stage evidence while prepare-build was running" : "already carries stage evidence"} (${stageKeys.join(", ")}). `
67
+ + `Rerunning prepare-build/start/build would reset ${stageKeys.length === 1 ? "this stage" : "these stages"} to pending and destroy that evidence. `
68
+ + `Pass --force to overwrite (destructive).`,
69
+ );
70
+ }
71
+ const unannounced = announced ? stageKeys.filter((key) => !announced.includes(key)) : stageKeys;
72
+ if (unannounced.length > 0) {
73
+ console.warn(
74
+ `[campaigns-os prepare-build] --force: overwriting assembly report at ${reportPath}; clearing stage evidence for: ${unannounced.join(", ")}.`,
75
+ );
76
+ }
77
+ return stageKeys;
78
+ }
79
+
80
+ function artifactRelativePath(artifactPath, targetPath) {
81
+ const rel = relative(dirname(resolve(artifactPath)), resolve(targetPath)).replaceAll("\\", "/");
82
+ return rel || ".";
83
+ }
84
+
85
+ function canonicalPrepareBuildOutputPath(path, label) {
86
+ const absolute = resolve(path);
87
+ const missingSegments = [];
88
+ let cursor = absolute;
89
+ let realpathRetries = 0;
90
+ while (true) {
91
+ let stats;
92
+ try {
93
+ stats = lstatSync(cursor);
94
+ } catch (error) {
95
+ if (error?.code !== "ENOENT") throw error;
96
+ const parent = dirname(cursor);
97
+ if (parent === cursor) return absolute;
98
+ missingSegments.unshift(basename(cursor));
99
+ cursor = parent;
100
+ continue;
101
+ }
102
+
103
+ // Even a dangling leaf symlink becomes writable after an earlier output
104
+ // creates its target. Reject the alias itself instead of relying on stat,
105
+ // which follows symlinks and cannot see a dangling one.
106
+ if (missingSegments.length === 0 && stats.isSymbolicLink()) {
107
+ throw new Error(`Prepare-build output path collision: ${label} at ${absolute} is a symbolic-link alias. Choose a regular, distinct output path; the Design Source Package path is fixed.`);
108
+ }
109
+
110
+ try {
111
+ return resolve(realpathSync(cursor), ...missingSegments);
112
+ } catch {
113
+ // The entry can be replaced between lstat and realpath: a prepare-build
114
+ // holding the target lock moves a stale DSP aside and links its
115
+ // replacement while this run is still before the lock. Look again from
116
+ // the top a few times; an entry that never resolves, such as a dangling
117
+ // symlink, is an alias.
118
+ if (realpathRetries++ < 3) {
119
+ missingSegments.length = 0;
120
+ cursor = absolute;
121
+ continue;
122
+ }
123
+ throw new Error(`Prepare-build output path collision: ${label} at ${absolute} traverses an unresolved filesystem alias. Choose a regular, distinct output path; the Design Source Package path is fixed.`);
124
+ }
125
+ }
126
+ }
127
+
128
+ function prepareBuildPathIdentity(path) {
129
+ return resolve(path).normalize("NFC").toLowerCase();
130
+ }
131
+
132
+ // A reserved tree is a directory no output may be written into: the target
133
+ // lock, whose release removes the directory recursively (#501). The staging,
134
+ // tomb and recovery siblings the lock creates and removes
135
+ // (src/directory-lock.mjs) are reserved with it; other names that merely
136
+ // start with the lock's name are ordinary outputs.
137
+ const PREPARE_BUILD_LOCK_SIBLING_SUFFIXES = [".staging-", ".recovery-staging-", ".released-", ".abandoned-"];
138
+
139
+ function prepareBuildReservedTreeContains(reservedIdentity, pathIdentity) {
140
+ return pathIdentity === reservedIdentity
141
+ || pathIdentity.startsWith(`${reservedIdentity}${sep}`)
142
+ || PREPARE_BUILD_LOCK_SIBLING_SUFFIXES.some((suffix) => pathIdentity.startsWith(`${reservedIdentity}${suffix}`));
143
+ }
144
+
145
+ function assertOutsidePrepareBuildReservedTrees(label, absolute, canonical, reservedTrees) {
146
+ for (const reserved of reservedTrees) {
147
+ if (
148
+ prepareBuildReservedTreeContains(reserved.identity, prepareBuildPathIdentity(absolute))
149
+ || prepareBuildReservedTreeContains(reserved.canonicalIdentity, prepareBuildPathIdentity(canonical))
150
+ ) {
151
+ throw new Error(`Prepare-build output path collision: ${label} at ${absolute} is inside the ${reserved.label} at ${reserved.path}, which is removed when the lock is released. Choose an output path outside it; the Design Source Package path is fixed.`);
152
+ }
153
+ }
154
+ }
155
+
156
+ function assertDistinctPrepareBuildOutputPaths(entries) {
157
+ const seenPaths = new Map();
158
+ const seenCanonicalPaths = new Map();
159
+ const seenFiles = new Map();
160
+ const reservedTrees = entries
161
+ .filter(([, , kind]) => kind?.reservedTree)
162
+ .map(([label, path]) => {
163
+ const absolute = resolve(path);
164
+ return {
165
+ label,
166
+ path: absolute,
167
+ identity: prepareBuildPathIdentity(absolute),
168
+ canonicalIdentity: prepareBuildPathIdentity(canonicalPrepareBuildOutputPath(absolute, label)),
169
+ };
170
+ });
171
+ const outputs = entries.filter(([, , kind]) => !kind?.reservedTree);
172
+ for (const [label, path] of outputs) {
173
+ const absolute = resolve(path);
174
+ const pathIdentity = prepareBuildPathIdentity(absolute);
175
+ const priorPath = seenPaths.get(pathIdentity);
176
+ if (priorPath) {
177
+ throw new Error(`Prepare-build output path collision: ${priorPath.label} at ${priorPath.path} and ${label} at ${absolute} are identical after conservative case folding. Choose distinct output paths; the Design Source Package path is fixed.`);
178
+ }
179
+ seenPaths.set(pathIdentity, { label, path: absolute });
180
+
181
+ const canonical = canonicalPrepareBuildOutputPath(absolute, label);
182
+ const canonicalIdentity = prepareBuildPathIdentity(canonical);
183
+ assertOutsidePrepareBuildReservedTrees(label, absolute, canonical, reservedTrees);
184
+ const priorCanonical = seenCanonicalPaths.get(canonicalIdentity);
185
+ if (priorCanonical) {
186
+ throw new Error(`Prepare-build output path collision: ${priorCanonical.label} at ${priorCanonical.path} and ${label} at ${absolute} resolve through filesystem aliases to ${canonical}. Choose distinct output paths; the Design Source Package path is fixed.`);
187
+ }
188
+ seenCanonicalPaths.set(canonicalIdentity, { label, path: absolute });
189
+
190
+ // A lexical comparison is insufficient once an output already exists:
191
+ // hard links can name the fixed DSP (or another sidecar) through a
192
+ // different path, and writeFileSync would follow that alias. Symlink
193
+ // aliases are already caught by the canonical-path check above, so
194
+ // device + inode only need to identify hard links — files with
195
+ // nlink > 1. Recording an identity for nlink === 1 files is unsound
196
+ // here: the stat calls are not atomic across outputs, and a concurrent
197
+ // prepare-build against the same target can unlink one output and let
198
+ // the kernel recycle its inode for another, making two distinct files
199
+ // momentarily share dev:ino.
200
+ let identity = null;
201
+ try {
202
+ const stats = statSync(absolute);
203
+ if (stats.nlink > 1) identity = `${stats.dev}:${stats.ino}`;
204
+ } catch {
205
+ // Missing output paths have no filesystem identity yet; the normalized
206
+ // absolute-path check above is authoritative until they are created.
207
+ }
208
+ if (identity) {
209
+ const priorFile = seenFiles.get(identity);
210
+ if (priorFile) {
211
+ throw new Error(`Prepare-build output path collision: ${priorFile.label} at ${priorFile.path} and ${label} at ${absolute} are aliases for the same filesystem object. Choose distinct output paths; the Design Source Package path is fixed.`);
212
+ }
213
+ seenFiles.set(identity, { label, path: absolute });
214
+ }
215
+ }
216
+ }
217
+
218
+ function preflightPrepareBuildOutputTargets(outputs, collisionOutputs) {
219
+ assertDistinctPrepareBuildOutputPaths(collisionOutputs);
220
+ for (const [label, path] of outputs) {
221
+ const absolute = resolve(path);
222
+ let stats = null;
223
+ try {
224
+ stats = lstatSync(absolute);
225
+ } catch (error) {
226
+ if (error?.code !== "ENOENT") {
227
+ throw new Error(`Prepare-build could not inspect ${label} output target at ${absolute}: ${error.message}`, { cause: error });
228
+ }
229
+ }
230
+ if (stats && !stats.isFile()) {
231
+ const kind = stats.isDirectory() ? "directory" : stats.isSymbolicLink() ? "symbolic link" : "non-regular filesystem entry";
232
+ throw new Error(`Prepare-build output target is invalid: ${label} at ${absolute} must be absent or a regular file, but found a ${kind}.`);
233
+ }
234
+
235
+ // Atomic rename needs write + search permission on the destination's
236
+ // directory, while mkdirSync for a missing directory needs those rights
237
+ // on its nearest existing ancestor. Validate that capability before the
238
+ // fixed DSP or theme artifacts can be published.
239
+ let ancestor = dirname(absolute);
240
+ while (true) {
241
+ try {
242
+ const ancestorStats = statSync(ancestor);
243
+ if (!ancestorStats.isDirectory()) {
244
+ throw new Error(`nearest existing ancestor ${ancestor} is not a directory`);
245
+ }
246
+ try {
247
+ accessSync(ancestor, fsConstants.W_OK | fsConstants.X_OK);
248
+ } catch (error) {
249
+ const code = error?.code ? ` (${error.code})` : "";
250
+ throw new Error(`Prepare-build output target is not writable: ${label} at ${absolute} has non-writable ancestor ${ancestor}${code}: ${error.message}`, { cause: error });
251
+ }
252
+ break;
253
+ } catch (error) {
254
+ if (error?.code !== "ENOENT") throw error;
255
+ const parent = dirname(ancestor);
256
+ if (parent === ancestor) throw error;
257
+ ancestor = parent;
258
+ }
259
+ }
260
+ }
261
+ }
262
+
263
+ function publishPrepareBuildJsonOutputs(outputs, collisionOutputs, { beforePublish = null } = {}) {
264
+ preflightPrepareBuildOutputTargets(
265
+ outputs.map(({ label, path }) => [label, path]),
266
+ collisionOutputs,
267
+ );
268
+ const staged = [];
269
+ try {
270
+ for (const output of outputs) {
271
+ const path = resolve(output.path);
272
+ mkdirSync(dirname(path), { recursive: true });
273
+ const tmp = join(dirname(path), `.${basename(path)}.${randomUUID()}.tmp`);
274
+ writeFileSync(tmp, `${JSON.stringify(output.value, null, 2)}\n`, { flag: "wx" });
275
+ staged.push({ ...output, path, tmp });
276
+ }
277
+
278
+ // Revalidate after all staging writes. A swap before this point is
279
+ // rejected; a symlink or hard-link swap after it is safely replaced as a
280
+ // directory entry by renameSync rather than followed and truncated.
281
+ preflightPrepareBuildOutputTargets(
282
+ outputs.map(({ label, path }) => [label, path]),
283
+ collisionOutputs,
284
+ );
285
+ // Last check before anything is renamed into place; a refusal here leaves
286
+ // every destination as it was.
287
+ beforePublish?.();
288
+ for (const output of staged) renameSync(output.tmp, output.path);
289
+ } finally {
290
+ for (const output of staged) rmSync(output.tmp, { force: true });
291
+ }
292
+ }
293
+
294
+ function preparedDesignSourcePackageProblem(value, currentPageScope, currentHtmlFunnelScope) {
295
+ const validation = validateDesignSourcePackage(value, {
296
+ currentPageScope,
297
+ currentHtmlFunnelScope,
298
+ });
299
+ if (validation.ok) return null;
300
+ return validation.errors
301
+ .map((error) => `[${error.code}] ${error.path}: ${error.message}`)
302
+ .join("; ");
303
+ }
304
+
305
+ function assertValidPreparedDesignSourcePackage(value, path, currentPageScope, currentHtmlFunnelScope) {
306
+ const detail = preparedDesignSourcePackageProblem(value, currentPageScope, currentHtmlFunnelScope);
307
+ if (detail == null) return;
308
+ throw new Error(`Design Source Package at ${path} is invalid, stale, or contradictory: ${detail}`);
309
+ }
310
+
311
+ // Which prepare-build lineage produced the Design Source Package now on disk.
312
+ // The previous Assembly Report at this run's report path is the only record of
313
+ // it: its design_source_package reference carries the exact-byte sha256 of the
314
+ // package that run bound to, and `origin: "synthesized"` when prepare-build
315
+ // wrote those bytes itself (or reused bytes it had itself written). A package
316
+ // counts as producer-synthesized only when that report says so AND the bytes on
317
+ // disk still hash to what it recorded, so a hand edit, a package dropped in by
318
+ // an operator, a report from another path, and a report written before `origin`
319
+ // existed all fall to "not provably ours" and are never overwritten.
320
+ //
321
+ // The package is published before the report that records it, so a run that
322
+ // fails or dies between the two would leave its own package unrecorded (#501).
323
+ // The pending provenance record beside the package closes that gap: it names
324
+ // the sha256 of the bytes a run is about to publish, plus the stale package's
325
+ // when a --force run is replacing one it proved its own, so a regeneration
326
+ // that fails or dies part way leaves whichever package ends up on disk
327
+ // provable. It is written before the package goes out, loses the candidate
328
+ // hash if the run adopts another writer's package instead of publishing, and
329
+ // is removed once a report has recorded the package. A retry that finds it
330
+ // still accepts only bytes that hash to one of its entries.
331
+ function readPriorDesignSourceProvenance(reportPath, packagePath) {
332
+ const synthesized = [];
333
+ const pending = readJsonIfExistsQuietly(pendingDesignSourceProvenancePath(packagePath));
334
+ // All or nothing: a record with any entry that is not a digest (a torn or
335
+ // hand-edited file) is no proof, never a partial one.
336
+ if (isObject(pending) && pending.origin === DESIGN_SOURCE_PACKAGE_ORIGIN_SYNTHESIZED
337
+ && Array.isArray(pending.sha256) && pending.sha256.length > 0
338
+ && pending.sha256.every((entry) => typeof entry === "string" && /^sha256:[0-9a-f]{64}$/.test(entry))) {
339
+ synthesized.push(...pending.sha256);
340
+ }
341
+ let report = null;
342
+ try {
343
+ report = readJson(reportPath);
344
+ } catch {
345
+ // No readable report: only a pending record can vouch for the package.
346
+ }
347
+ const ref = report?.design_source_package;
348
+ if (isObject(ref) && ref.origin === DESIGN_SOURCE_PACKAGE_ORIGIN_SYNTHESIZED && isNonEmptyString(ref.sha256)) {
349
+ const recordedPath = resolveFromFile(reportPath, ref.path);
350
+ if (recordedPath && filesystemPathsMatch(recordedPath, packagePath)) synthesized.push(ref.sha256);
351
+ }
352
+ return synthesized.length ? { synthesized } : null;
353
+ }
354
+
355
+ function readJsonIfExistsQuietly(path) {
356
+ try {
357
+ return JSON.parse(readFileSync(path, "utf8"));
358
+ } catch {
359
+ return null;
360
+ }
361
+ }
362
+
363
+ function pendingDesignSourceProvenancePath(designSourcePackagePath) {
364
+ return join(dirname(designSourcePackagePath), `.${basename(designSourcePackagePath)}.pending-provenance.json`);
365
+ }
366
+
367
+ // Written (staged, then renamed into place) before a synthesized package is
368
+ // published, so the claim survives a crash between that publication and the
369
+ // report's. An empty list removes the record.
370
+ function recordPendingDesignSourceProvenance(designSourcePackagePath, sha256) {
371
+ const pendingPath = pendingDesignSourceProvenancePath(designSourcePackagePath);
372
+ if (!sha256.length) {
373
+ rmSync(pendingPath, { force: true });
374
+ return;
375
+ }
376
+ const stagedPath = `${pendingPath}.${randomUUID()}.tmp`;
377
+ try {
378
+ writeFileSync(stagedPath, `${JSON.stringify({ origin: DESIGN_SOURCE_PACKAGE_ORIGIN_SYNTHESIZED, sha256 })}\n`, { flag: "wx" });
379
+ renameSync(stagedPath, pendingPath);
380
+ } finally {
381
+ rmSync(stagedPath, { force: true });
382
+ }
383
+ }
384
+
385
+ const DESIGN_SOURCE_PACKAGE_ORIGIN_SYNTHESIZED = "synthesized";
386
+ const DESIGN_SOURCE_PACKAGE_ORIGIN_ADOPTED = "adopted";
387
+
388
+ function sourceMaterialFile(sourceRoot, path) {
389
+ if (typeof path !== "string" || !path.trim()) return null;
390
+ const root = resolve(sourceRoot);
391
+ const fullPath = resolve(root, path);
392
+ const rel = relative(root, fullPath);
393
+ if (rel.startsWith("..") || isAbsolute(rel)) return null;
394
+ try {
395
+ const stats = statSync(fullPath);
396
+ if (!stats.isFile()) return null;
397
+ return { bytes: stats.size, sha256: sha256File(fullPath) };
398
+ } catch {
399
+ return null;
400
+ }
401
+ }
402
+
403
+ function manifestPagesForResolvedMappings(pages, mappings) {
404
+ if (!Array.isArray(pages)) return pages;
405
+ const groups = new Map();
406
+ for (const page of pages) {
407
+ if (!isObject(page) || !isNonEmptyString(page.page_id)) continue;
408
+ const group = groups.get(page.page_id) || [];
409
+ group.push(page);
410
+ groups.set(page.page_id, group);
411
+ }
412
+ const resolvedPathByPageId = new Map(
413
+ mappings
414
+ .filter((mapping) => isObject(mapping) && isNonEmptyString(mapping.page_id) && isNonEmptyString(mapping.path))
415
+ .map((mapping) => [mapping.page_id, mapping.path]),
416
+ );
417
+ const resolvedDuplicate = new Map();
418
+ for (const [pageId, group] of groups) {
419
+ if (group.length < 2) continue;
420
+ const resolvedPath = resolvedPathByPageId.get(pageId);
421
+ const matches = resolvedPath
422
+ ? group.filter((page) => page.path === resolvedPath)
423
+ : [];
424
+ resolvedDuplicate.set(pageId, matches.length === 1 ? matches[0] : null);
425
+ }
426
+
427
+ return pages.filter((page) => {
428
+ if (!isObject(page) || !isNonEmptyString(page.page_id)) return true;
429
+ if (!resolvedDuplicate.has(page.page_id)) return true;
430
+ return resolvedDuplicate.get(page.page_id) === page;
431
+ });
432
+ }
433
+
434
+ function createCurrentHtmlFunnelScope({
435
+ packagePath,
436
+ activePages,
437
+ mappings,
438
+ manifestResult,
439
+ sourceAssetCrawl,
440
+ templateFamily,
441
+ templateStockPageIds = [],
442
+ commerceCatalog,
443
+ sourceRoot,
444
+ mapId,
445
+ publicRouteSlug,
446
+ }) {
447
+ const manifest = manifestResult.manifest ? cloneJson(manifestResult.manifest) : null;
448
+ if (manifest) {
449
+ // Source intake owns duplicate-page resolution and records its blocker.
450
+ // Feed the strict DSP synthesizer only the unique record that agrees with
451
+ // that resolved mapping; an unmatched duplicate group remains ambiguous
452
+ // and contributes no page record. Raw manifest bytes/provenance are still
453
+ // retained through manifest.sha256 below.
454
+ manifest.pages = manifestPagesForResolvedMappings(manifest.pages, mappings);
455
+ }
456
+ const crawl = isObject(sourceAssetCrawl) ? cloneJson(sourceAssetCrawl) : null;
457
+ const materialPaths = new Set([
458
+ ...mappings.map((mapping) => mapping?.path),
459
+ ...(manifest?.files || []).map((file) => file?.path),
460
+ ...(manifest?.pages || []).map((page) => page?.path),
461
+ ...(crawl?.scanned_files || []).map((file) => file?.path),
462
+ ...(crawl?.references || []).map((ref) => ref?.source_path),
463
+ ].filter((path) => typeof path === "string" && path.trim()));
464
+ const materialFiles = new Map(
465
+ [...materialPaths].map((path) => [path, sourceMaterialFile(sourceRoot, path)]),
466
+ );
467
+ const currentMappings = mappings.map((mapping) => ({
468
+ ...mapping,
469
+ ...(mapping?.path ? { source_hash: materialFiles.get(mapping.path)?.sha256 || null } : {}),
470
+ }));
471
+
472
+ if (manifest) {
473
+ // The hash of the bytes source intake parsed, not a second read of the
474
+ // file, which an edit mid-run could have changed since (#501).
475
+ manifest.sha256 = manifestResult.sha256 || null;
476
+ manifest.files = (manifest.files || []).map((file) => ({
477
+ ...file,
478
+ sha256: materialFiles.get(file.path)?.sha256 || null,
479
+ }));
480
+ manifest.pages = (manifest.pages || []).map((page) => ({
481
+ ...page,
482
+ ...(page.path ? { source_hash: materialFiles.get(page.path)?.sha256 || null } : {}),
483
+ }));
484
+ }
485
+
486
+ if (crawl) {
487
+ const scannedByPath = new Map((crawl.scanned_files || []).map((file) => [file.path, file]));
488
+ for (const [path, material] of materialFiles) {
489
+ if (!material) continue;
490
+ const existing = scannedByPath.get(path);
491
+ scannedByPath.set(path, {
492
+ ...(existing || { path, kind: "asset" }),
493
+ bytes: material.bytes,
494
+ sha256: material.sha256,
495
+ });
496
+ }
497
+ crawl.scanned_files = [...scannedByPath.values()].sort((left, right) =>
498
+ String(left.path).localeCompare(String(right.path)));
499
+ }
500
+
501
+ return {
502
+ activePages,
503
+ mappings: currentMappings,
504
+ manifest,
505
+ manifestPath: manifest && manifestResult.path
506
+ ? artifactRelativePath(packagePath, manifestResult.path)
507
+ : null,
508
+ sourceAssetCrawl: crawl,
509
+ templateFamily: resolveTemplateFamilyDesignSource(commerceCatalog, templateFamily),
510
+ templateStockPageIds: [...templateStockPageIds],
511
+ packageId: `${mapId}:design-source`,
512
+ campaignMapId: mapId,
513
+ campaignSlug: publicRouteSlug,
514
+ sourceRoot: artifactRelativePath(packagePath, sourceRoot),
515
+ };
516
+ }
517
+
518
+ function prepareDesignSourcePackage({
519
+ path,
520
+ activePages,
521
+ mappings,
522
+ manifestResult,
523
+ sourceAssetCrawl,
524
+ templateFamily,
525
+ templateStockPageIds = [],
526
+ commerceCatalog,
527
+ sourceRoot,
528
+ mapId,
529
+ publicRouteSlug,
530
+ priorProvenance = null,
531
+ force = false,
532
+ }) {
533
+ const currentPageScope = {
534
+ activePages,
535
+ mappings,
536
+ campaignMapId: mapId,
537
+ campaignSlug: publicRouteSlug,
538
+ };
539
+ const currentHtmlFunnelScope = createCurrentHtmlFunnelScope({
540
+ packagePath: path,
541
+ activePages,
542
+ mappings,
543
+ manifestResult,
544
+ sourceAssetCrawl,
545
+ templateFamily,
546
+ templateStockPageIds,
547
+ commerceCatalog,
548
+ sourceRoot,
549
+ mapId,
550
+ publicRouteSlug,
551
+ });
552
+ let value;
553
+ let rawBytes;
554
+ let mode;
555
+
556
+ const readExisting = () => {
557
+ let existingBytes;
558
+ try {
559
+ const stats = lstatSync(path);
560
+ if (!stats.isFile()) {
561
+ const kind = stats.isDirectory()
562
+ ? "directory"
563
+ : stats.isSymbolicLink()
564
+ ? "symbolic link"
565
+ : "non-regular filesystem entry";
566
+ throw new Error(`expected a regular file, but found a ${kind}`);
567
+ }
568
+ existingBytes = readFileSync(path);
569
+ } catch (error) {
570
+ const code = error?.code ? ` (${error.code})` : "";
571
+ throw new Error(`Could not read Design Source Package artifact at ${path}${code}: ${error.message}`, { cause: error });
572
+ }
573
+
574
+ let existingValue;
575
+ try {
576
+ existingValue = JSON.parse(existingBytes.toString("utf8"));
577
+ } catch (error) {
578
+ throw new Error(`Design Source Package at ${path} is not valid JSON: ${error.message}`, { cause: error });
579
+ }
580
+ const existingSha256 = hashSerializedDesignSourcePackage(existingBytes);
581
+ const synthesizedHere = Array.isArray(priorProvenance?.synthesized)
582
+ && priorProvenance.synthesized.includes(existingSha256);
583
+ let problem = preparedDesignSourcePackageProblem(existingValue, currentPageScope, currentHtmlFunnelScope);
584
+ if (problem == null && existingValue.source_kind !== "html_funnel") {
585
+ problem = `declares source_kind ${JSON.stringify(existingValue.source_kind)}; html_funnel prepare-build requires "html_funnel".`;
586
+ }
587
+ if (problem != null) {
588
+ // A package an earlier prepare-build synthesized, unchanged since, is
589
+ // this producer's own stale output: --force regenerates it from the
590
+ // current inputs. Anything else is left for the operator to reconcile.
591
+ if (synthesizedHere && force) return { stale: true, sha256: existingSha256 };
592
+ const recovery = synthesizedHere
593
+ ? "It was synthesized by an earlier prepare-build and is unchanged since, so rerun with --force to regenerate it from the current inputs "
594
+ + "(--force also resets any stage evidence the Assembly Report carries)."
595
+ : "prepare-build cannot show it synthesized these bytes (no Assembly Report records them as its own output, or the package changed since), so it will not overwrite it. "
596
+ + `Reconcile the package with the current inputs, or, if no downstream stage has consumed it, delete ${path} and rerun prepare-build to synthesize a fresh one.`;
597
+ throw new Error(`Design Source Package at ${path} is invalid, stale, or contradictory: ${problem} ${recovery}`);
598
+ }
599
+ return {
600
+ value: existingValue,
601
+ rawBytes: existingBytes,
602
+ origin: synthesizedHere ? DESIGN_SOURCE_PACKAGE_ORIGIN_SYNTHESIZED : DESIGN_SOURCE_PACKAGE_ORIGIN_ADOPTED,
603
+ };
604
+ };
605
+
606
+ const synthesizeCurrent = () => {
607
+ const synthesized = synthesizeHtmlFunnelDesignSourcePackage(currentHtmlFunnelScope);
608
+ assertValidPreparedDesignSourcePackage(synthesized, path, currentPageScope, currentHtmlFunnelScope);
609
+ return { value: synthesized, rawBytes: Buffer.from(serializeDesignSourcePackage(synthesized), "utf8") };
610
+ };
611
+
612
+ let origin = DESIGN_SOURCE_PACKAGE_ORIGIN_SYNTHESIZED;
613
+
614
+ // Synthesize the current package and fully write it to a same-directory
615
+ // staging file. Nothing at `path` is touched yet, so a failure here (inputs
616
+ // that no longer validate, a read-only target, a full disk) leaves any
617
+ // existing package exactly as it was.
618
+ const stageSynthesized = () => {
619
+ ({ value, rawBytes } = synthesizeCurrent());
620
+ mkdirSync(dirname(path), { recursive: true });
621
+ const stagedPath = join(dirname(path), `.${basename(path)}.${randomUUID()}.tmp`);
622
+ try {
623
+ writeFileSync(stagedPath, rawBytes, { flag: "wx" });
624
+ } catch (error) {
625
+ rmSync(stagedPath, { force: true });
626
+ throw error;
627
+ }
628
+ return stagedPath;
629
+ };
630
+
631
+ // withDesignSourcePublication holds the target lock around this whole
632
+ // decision, so no other publication (prepare-build, start or build) lands
633
+ // here meanwhile. A package that still appears at
634
+ // `path` (an operator, or a tool that does not take the lock) is
635
+ // authoritative: validate and reuse its exact bytes rather than overwrite.
636
+ const reuseWinner = () => {
637
+ const winner = readExisting();
638
+ if (winner.stale) {
639
+ throw new Error(`Design Source Package at ${path} appeared while prepare-build was publishing and does not match this run's inputs; if it is a stale package an earlier prepare-build synthesized, rerun prepare-build with --force.`);
640
+ }
641
+ ({ value, rawBytes, origin } = winner);
642
+ mode = "reused";
643
+ };
644
+
645
+ const publishStaged = (stagedPath) => {
646
+ try {
647
+ // Linking a fully written same-directory staging file is the Node
648
+ // primitive that combines atomic visibility with no-replace
649
+ // publication. A plain rename fallback is intentionally unsafe here:
650
+ // it could replace a package that appeared after the absence check.
651
+ linkSync(stagedPath, path);
652
+ mode = "emitted";
653
+ } catch (error) {
654
+ // EEXIST means a package appeared at the publication seam; for any
655
+ // other link failure, prefer validating such a package before failing
656
+ // closed.
657
+ if (error?.code !== "EEXIST" && !existsSync(path)) {
658
+ throw new Error(
659
+ `Could not atomically publish Design Source Package artifact at ${path}${error?.code ? ` (${error.code})` : ""}: `
660
+ + `exclusive hard-link publication failed; refusing an unsafe rename fallback that could overwrite a concurrent winner: ${error.message}`,
661
+ { cause: error },
662
+ );
663
+ }
664
+ // Another writer's package is at the path, not this run's bytes: the
665
+ // pending record stops vouching for the candidate before it is judged.
666
+ recordPendingDesignSourceProvenance(path, pendingCarried);
667
+ reuseWinner();
668
+ }
669
+ };
670
+
671
+ // --force over this producer's own stale package. The replacement is
672
+ // already staged. The stale bytes are claimed by moving them aside and
673
+ // re-hashed there, so an edit that landed after they were judged stale is
674
+ // caught instead of discarded; the replacement then goes out through the
675
+ // same no-replace link as a fresh emit. The claimed bytes are deleted only
676
+ // once they are proven to be the stale bytes and the replacement is out, or
677
+ // once they are back in place after a failure; otherwise they are kept and
678
+ // named.
679
+ const replaceStale = (stagedPath, staleSha256) => {
680
+ const retiredPath = join(dirname(path), `.${basename(path)}.${randomUUID()}.stale`);
681
+ try {
682
+ renameSync(path, retiredPath);
683
+ } catch (error) {
684
+ // Removed since it was read: there is nothing left to replace.
685
+ if (error?.code === "ENOENT") return publishStaged(stagedPath);
686
+ throw new Error(`Could not claim the stale Design Source Package at ${path} for regeneration${error?.code ? ` (${error.code})` : ""}: ${error.message}`, { cause: error });
687
+ }
688
+ // Put the claimed bytes back without replacing anything that has appeared
689
+ // at `path` since. True only when `path` is now those very bytes (the
690
+ // same file), not merely occupied by another package.
691
+ const putBack = () => {
692
+ try {
693
+ linkSync(retiredPath, path);
694
+ } catch (error) {
695
+ if (error?.code !== "EEXIST") return false;
696
+ }
697
+ try {
698
+ const atPath = statSync(path);
699
+ const retired = statSync(retiredPath);
700
+ return atPath.ino === retired.ino && atPath.dev === retired.dev;
701
+ } catch {
702
+ return false;
703
+ }
704
+ };
705
+ try {
706
+ if (hashSerializedDesignSourcePackage(readFileSync(retiredPath)) !== staleSha256) {
707
+ throw new Error(`Design Source Package at ${path} changed after prepare-build judged it stale, so it was not replaced. Rerun prepare-build to judge the current package.`);
708
+ }
709
+ publishStaged(stagedPath);
710
+ } catch (error) {
711
+ if (putBack()) {
712
+ rmSync(retiredPath, { force: true });
713
+ throw error;
714
+ }
715
+ throw new Error(`${error.message} The Design Source Package moved aside for regeneration could not be put back and is kept at ${retiredPath}.`, { cause: error });
716
+ }
717
+ rmSync(retiredPath, { force: true });
718
+ };
719
+
720
+ const existing = existsSync(path) ? readExisting() : null;
721
+ // The stale package a --force run replaces is provably the producer's own;
722
+ // its hash stays in the pending record until the replacement is recorded,
723
+ // so a failed or interrupted replacement that leaves it in place (put back,
724
+ // or never moved) can still be regenerated.
725
+ const pendingCarried = existing?.stale ? [existing.sha256] : [];
726
+ if (existing && !existing.stale) {
727
+ ({ value, rawBytes, origin } = existing);
728
+ mode = "reused";
729
+ } else {
730
+ const stagedPath = stageSynthesized();
731
+ try {
732
+ recordPendingDesignSourceProvenance(path, [hashSerializedDesignSourcePackage(rawBytes), ...pendingCarried]);
733
+ if (existing?.stale) replaceStale(stagedPath, existing.sha256);
734
+ else publishStaged(stagedPath);
735
+ } finally {
736
+ rmSync(stagedPath, { force: true });
737
+ }
738
+ if (existing?.stale && mode === "emitted") {
739
+ mode = "regenerated";
740
+ console.warn(
741
+ `[campaigns-os prepare-build] --force: regenerated the stale Design Source Package at ${path} that an earlier prepare-build synthesized.`,
742
+ );
743
+ }
744
+ }
745
+
746
+ const referenceIdentity = createDesignSourcePackageArtifactReference(value, {
747
+ path: ".",
748
+ serialized: rawBytes,
749
+ });
750
+
751
+ return {
752
+ path,
753
+ value,
754
+ rawBytes,
755
+ mode,
756
+ origin,
757
+ referenceFor(artifactPath) {
758
+ return {
759
+ ...referenceIdentity,
760
+ path: artifactRelativePath(artifactPath, path),
761
+ };
762
+ },
763
+ };
764
+ }
765
+
766
+ const PUBLICATION_STEPS = ["package", "writeTheme", "publish", "publishDoctorOutput"];
767
+
768
+ // Runs `fn(publication)` inside the target lock; the handle's steps run in
769
+ // PUBLICATION_STEPS order, and `package` and `publish` run once.
770
+ export async function withDesignSourcePublication({ targetRepo, outputs, force = false, publishSpec = null }, fn) {
771
+ const { packetPath, contextPath, reportPath, doctorOutPath, briefPath } = outputs;
772
+ const designSourcePackagePath = resolve(targetRepo, DESIGN_SOURCE_PACKAGE_REL_PATH);
773
+ const paths = Object.freeze({ packetPath, contextPath, reportPath, doctorOutPath, briefPath, designSourcePackagePath });
774
+ const prepareBuildOutputPaths = [
775
+ ["Build Packet", packetPath],
776
+ ["Build Context", contextPath],
777
+ ["Assembly Report", reportPath],
778
+ ["Doctor Output", doctorOutPath],
779
+ ["Campaign Build Brief", briefPath],
780
+ ];
781
+ const prepareBuildThemeOutputPaths = [
782
+ ["Theme Report", resolve(targetRepo, ".campaign-runtime/theme/theme-report.json")],
783
+ ["Brand Theme CSS", resolve(targetRepo, ".campaign-runtime/theme/brand-theme.css")],
784
+ ];
785
+ const prepareBuildCollisionPaths = [
786
+ ...prepareBuildOutputPaths,
787
+ ...prepareBuildThemeOutputPaths,
788
+ ["Design Source Package", designSourcePackagePath],
789
+ ["prepare-build target lock directory", targetLockPath(targetRepo), { reservedTree: true }],
790
+ ["Design Source Package pending provenance record", pendingDesignSourceProvenancePath(designSourcePackagePath)],
791
+ ];
792
+ assertDistinctPrepareBuildOutputPaths(prepareBuildCollisionPaths);
793
+ // One prepare-build at a time per target, from reading its inputs through
794
+ // the packet, context and report that record them. Stage writers take the
795
+ // same lock around their Assembly Report edits (commitAssemblyReport), so
796
+ // no stage evidence lands between the pre-publish re-check and the rename;
797
+ // they enter directly when prepare-build itself reaches them. The lock is taken before
798
+ // the stage-evidence guard and before the CampaignSpec, manifest, mappings
799
+ // and asset crawl are read, so the evidence check, the input snapshot whose
800
+ // hashes the Design Source Package records, and the publication are one
801
+ // critical section: a run that waited here sees exactly what the last
802
+ // completed run left, not what was on disk when it started waiting.
803
+ return withTargetLock(targetRepo, () => {
804
+ // The fetched spec goes into the shared cache file inside the critical
805
+ // section: the spec this run reads, hashes and records is the one it
806
+ // fetched, and a run waiting here cannot replace it meanwhile.
807
+ publishSpec?.();
808
+ let announcedStageEvidence = guardAssemblyReportOverwrite(reportPath, { force });
809
+ // Stage producers commit the report without this lock, so evidence can land
810
+ // while the run works. Re-check before each write that is not rolled back:
811
+ // the theme artifacts, then the JSON outputs. --force names a stage once.
812
+ const recheckStageEvidence = () => {
813
+ const found = guardAssemblyReportOverwrite(reportPath, { force }, { announced: announcedStageEvidence });
814
+ announcedStageEvidence = [...new Set([...announcedStageEvidence, ...found])];
815
+ };
816
+ let completed = 0;
817
+ const enter = (index, { once = false } = {}) => {
818
+ if (completed < index) throw new TypeError(`Design Source Package publication: ${PUBLICATION_STEPS[index]}() cannot run before ${PUBLICATION_STEPS[completed]}().`);
819
+ if (once && completed > index) throw new TypeError(`Design Source Package publication: ${PUBLICATION_STEPS[index]}() already ran.`);
820
+ };
821
+ let designSourcePackage = null;
822
+ return fn(Object.freeze({
823
+ paths,
824
+ package(inputs) {
825
+ enter(0, { once: true });
826
+ // Reject deterministic destination failures before publishing the fixed
827
+ // DSP. The DSP intentionally precedes the theme and JSON artifacts that
828
+ // reference it; a later I/O race may therefore leave a valid DSP for a
829
+ // subsequent run to validate and reuse. Never roll it back here because a
830
+ // concurrent prepare may already have consumed its exact bytes.
831
+ preflightPrepareBuildOutputTargets(
832
+ [...prepareBuildOutputPaths, ...prepareBuildThemeOutputPaths],
833
+ prepareBuildCollisionPaths,
834
+ );
835
+ designSourcePackage = prepareDesignSourcePackage({
836
+ ...inputs,
837
+ path: designSourcePackagePath,
838
+ priorProvenance: readPriorDesignSourceProvenance(reportPath, designSourcePackagePath),
839
+ force,
840
+ });
841
+ completed = 1;
842
+ return designSourcePackage;
843
+ },
844
+ writeTheme(inspection, { writeCss }) {
845
+ enter(1);
846
+ // Inspection can take time, so reject aliases or invalid target types again
847
+ // before publication. The shared theme writer then stages each artifact in
848
+ // its destination directory and atomically replaces the final entry, making
849
+ // a post-preflight symlink or hard-link swap safe without rolling back DSP.
850
+ preflightPrepareBuildOutputTargets(
851
+ prepareBuildThemeOutputPaths,
852
+ prepareBuildCollisionPaths,
853
+ );
854
+ // The theme files are replaced in place, not staged with the JSON outputs,
855
+ // so evidence that has landed by now refuses the run before any is written.
856
+ recheckStageEvidence();
857
+ const written = writeThemeArtifacts(inspection, { writeReport: true, writeCss, force, packetPath });
858
+ completed = Math.max(completed, 2);
859
+ return written;
860
+ },
861
+ publish({ packet, brief, context, report }) {
862
+ enter(2, { once: true });
863
+ // Before the report goes out, narrow the pending record to exactly what the
864
+ // report will say: the package's own hash when it is synthesized, nothing
865
+ // when it is adopted. A candidate this run or an earlier failed one never
866
+ // published cannot then outlive the report, even if the run dies between
867
+ // publishing the report and removing the record below.
868
+ recordPendingDesignSourceProvenance(
869
+ designSourcePackagePath,
870
+ designSourcePackage.origin === DESIGN_SOURCE_PACKAGE_ORIGIN_SYNTHESIZED
871
+ ? [hashSerializedDesignSourcePackage(designSourcePackage.rawBytes)]
872
+ : [],
873
+ );
874
+ publishPrepareBuildJsonOutputs([
875
+ { label: "Build Packet", path: packetPath, value: packet },
876
+ { label: "Campaign Build Brief", path: briefPath, value: brief },
877
+ { label: "Build Context", path: contextPath, value: context },
878
+ { label: "Assembly Report", path: reportPath, value: report },
879
+ ], prepareBuildCollisionPaths, {
880
+ beforePublish: recheckStageEvidence,
881
+ });
882
+ // The report now records the package and its origin, so a pending
883
+ // provenance record has served its purpose.
884
+ rmSync(pendingDesignSourceProvenancePath(designSourcePackagePath), { force: true });
885
+ completed = 3;
886
+ },
887
+ publishDoctorOutput(doctor, { command }) {
888
+ enter(3);
889
+ // Through the intake's own collision-checked writer, so the sidecar is
890
+ // stamped with the intake command that ran doctor (`start` or `build`,
891
+ // #312) without a second write path for it.
892
+ publishPrepareBuildJsonOutputs([
893
+ { label: "Doctor Output", path: doctorOutPath, value: stampDoctorProducer(doctor, command) },
894
+ ], prepareBuildCollisionPaths);
895
+ },
896
+ }));
897
+ }, { command: "prepare-build" });
898
+ }