@anvpx/motion-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.
Files changed (197) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +121 -2
  3. package/dist/analyze/command.d.ts +2 -0
  4. package/dist/analyze/command.js +190 -0
  5. package/dist/approval/approval.d.ts +32 -0
  6. package/dist/approval/approval.js +141 -0
  7. package/dist/approval/command.d.ts +2 -0
  8. package/dist/approval/command.js +255 -0
  9. package/dist/approval/gate.d.ts +7 -0
  10. package/dist/approval/gate.js +23 -0
  11. package/dist/approval/hash.d.ts +71 -0
  12. package/dist/approval/hash.js +451 -0
  13. package/dist/args.d.ts +30 -0
  14. package/dist/args.js +53 -0
  15. package/dist/assets/command.d.ts +2 -0
  16. package/dist/assets/command.js +165 -0
  17. package/dist/captions/command.d.ts +2 -0
  18. package/dist/captions/command.js +341 -0
  19. package/dist/captions/cues.d.ts +10 -0
  20. package/dist/captions/cues.js +61 -0
  21. package/dist/catalog/command.d.ts +2 -0
  22. package/dist/catalog/command.js +60 -0
  23. package/dist/catalog/format.d.ts +13 -0
  24. package/dist/catalog/format.js +54 -0
  25. package/dist/cli.d.ts +3 -0
  26. package/dist/cli.js +35 -0
  27. package/dist/commands.d.ts +3 -0
  28. package/dist/commands.js +59 -0
  29. package/dist/compare/command.d.ts +4 -0
  30. package/dist/compare/command.js +144 -0
  31. package/dist/contact/argv.d.ts +24 -0
  32. package/dist/contact/argv.js +42 -0
  33. package/dist/contact/command.d.ts +2 -0
  34. package/dist/contact/command.js +79 -0
  35. package/dist/doctor/checks.d.ts +25 -0
  36. package/dist/doctor/checks.js +571 -0
  37. package/dist/doctor/command.d.ts +4 -0
  38. package/dist/doctor/command.js +88 -0
  39. package/dist/doctor/format.d.ts +7 -0
  40. package/dist/doctor/format.js +41 -0
  41. package/dist/doctor/heal.d.ts +14 -0
  42. package/dist/doctor/heal.js +71 -0
  43. package/dist/doctor/pins.d.ts +8 -0
  44. package/dist/doctor/pins.js +28 -0
  45. package/dist/doctor/skills.d.ts +15 -0
  46. package/dist/doctor/skills.js +58 -0
  47. package/dist/doctor/types.d.ts +36 -0
  48. package/dist/doctor/types.js +1 -0
  49. package/dist/doctor/vendored.d.ts +15 -0
  50. package/dist/doctor/vendored.js +90 -0
  51. package/dist/doctor/version.d.ts +8 -0
  52. package/dist/doctor/version.js +11 -0
  53. package/dist/exit-codes.d.ts +16 -0
  54. package/dist/exit-codes.js +15 -0
  55. package/dist/fit/command.d.ts +5 -0
  56. package/dist/fit/command.js +154 -0
  57. package/dist/fit/solve.d.ts +21 -0
  58. package/dist/fit/solve.js +80 -0
  59. package/dist/fit/target.d.ts +18 -0
  60. package/dist/fit/target.js +47 -0
  61. package/dist/generate3d/command.d.ts +3 -0
  62. package/dist/generate3d/command.js +38 -0
  63. package/dist/generate3d/run.d.ts +39 -0
  64. package/dist/generate3d/run.js +77 -0
  65. package/dist/icon/command.d.ts +2 -0
  66. package/dist/icon/command.js +113 -0
  67. package/dist/index.d.ts +18 -0
  68. package/dist/index.js +16 -0
  69. package/dist/install-mk.d.ts +31 -0
  70. package/dist/install-mk.js +168 -0
  71. package/dist/integration/preview.integration.d.ts +1 -0
  72. package/dist/integration/preview.integration.js +53 -0
  73. package/dist/integration/smoke.integration.d.ts +1 -0
  74. package/dist/integration/smoke.integration.js +91 -0
  75. package/dist/main.d.ts +2 -0
  76. package/dist/main.js +4 -0
  77. package/dist/node-io.d.ts +14 -0
  78. package/dist/node-io.js +152 -0
  79. package/dist/optimize/command.d.ts +12 -0
  80. package/dist/optimize/command.js +110 -0
  81. package/dist/packs/command.d.ts +2 -0
  82. package/dist/packs/command.js +109 -0
  83. package/dist/patch/apply.d.ts +36 -0
  84. package/dist/patch/apply.js +186 -0
  85. package/dist/patch/command.d.ts +2 -0
  86. package/dist/patch/command.js +92 -0
  87. package/dist/plugin/install.d.ts +40 -0
  88. package/dist/plugin/install.js +170 -0
  89. package/dist/preview/command.d.ts +10 -0
  90. package/dist/preview/command.js +758 -0
  91. package/dist/preview/manifest.d.ts +94 -0
  92. package/dist/preview/manifest.js +19 -0
  93. package/dist/preview/pack.d.ts +24 -0
  94. package/dist/preview/pack.js +53 -0
  95. package/dist/preview/plan.d.ts +58 -0
  96. package/dist/preview/plan.js +160 -0
  97. package/dist/preview/provenance.d.ts +23 -0
  98. package/dist/preview/provenance.js +38 -0
  99. package/dist/publish/command.d.ts +2 -0
  100. package/dist/publish/command.js +70 -0
  101. package/dist/publish/player.d.ts +25 -0
  102. package/dist/publish/player.js +108 -0
  103. package/dist/publish/static.d.ts +16 -0
  104. package/dist/publish/static.js +78 -0
  105. package/dist/qc.d.ts +2 -0
  106. package/dist/qc.js +192 -0
  107. package/dist/scene/author.d.ts +16 -0
  108. package/dist/scene/author.js +260 -0
  109. package/dist/scene/command.d.ts +2 -0
  110. package/dist/scene/command.js +110 -0
  111. package/dist/scene/ops.d.ts +23 -0
  112. package/dist/scene/ops.js +151 -0
  113. package/dist/state/command.d.ts +2 -0
  114. package/dist/state/command.js +128 -0
  115. package/dist/state/evidence.d.ts +71 -0
  116. package/dist/state/evidence.js +243 -0
  117. package/dist/storyboard/check.d.ts +21 -0
  118. package/dist/storyboard/check.js +39 -0
  119. package/dist/storyboard/containment.d.ts +24 -0
  120. package/dist/storyboard/containment.js +43 -0
  121. package/dist/storyboard/custom.d.ts +15 -0
  122. package/dist/storyboard/custom.js +133 -0
  123. package/dist/storyboard/edit.d.ts +19 -0
  124. package/dist/storyboard/edit.js +39 -0
  125. package/dist/storyboard/load.d.ts +11 -0
  126. package/dist/storyboard/load.js +52 -0
  127. package/dist/storyboard/report.d.ts +5 -0
  128. package/dist/storyboard/report.js +8 -0
  129. package/dist/template/coerce.d.ts +6 -0
  130. package/dist/template/coerce.js +44 -0
  131. package/dist/template/command.d.ts +5 -0
  132. package/dist/template/command.js +163 -0
  133. package/dist/template/env.d.ts +34 -0
  134. package/dist/template/env.js +38 -0
  135. package/dist/template/fork.d.ts +8 -0
  136. package/dist/template/fork.js +156 -0
  137. package/dist/template/format.d.ts +21 -0
  138. package/dist/template/format.js +161 -0
  139. package/dist/template/list.d.ts +15 -0
  140. package/dist/template/list.js +116 -0
  141. package/dist/template/new.d.ts +6 -0
  142. package/dist/template/new.js +302 -0
  143. package/dist/testing/env.d.ts +8 -0
  144. package/dist/testing/env.js +19 -0
  145. package/dist/testing/fakes.d.ts +33 -0
  146. package/dist/testing/fakes.js +95 -0
  147. package/dist/testing/fixtures.d.ts +8 -0
  148. package/dist/testing/fixtures.js +6 -0
  149. package/dist/testing/probe.d.ts +15 -0
  150. package/dist/testing/probe.js +20 -0
  151. package/dist/testing/project.d.ts +22 -0
  152. package/dist/testing/project.js +26 -0
  153. package/dist/tooling/session.d.ts +40 -0
  154. package/dist/tooling/session.js +106 -0
  155. package/dist/types.d.ts +59 -0
  156. package/dist/types.js +1 -0
  157. package/dist/verify/audio-loudness.d.ts +46 -0
  158. package/dist/verify/audio-loudness.js +186 -0
  159. package/dist/verify/command.d.ts +2 -0
  160. package/dist/verify/command.js +161 -0
  161. package/dist/verify/determinism.d.ts +30 -0
  162. package/dist/verify/determinism.js +59 -0
  163. package/dist/verify/expectation.d.ts +15 -0
  164. package/dist/verify/expectation.js +19 -0
  165. package/dist/verify/media-events.d.ts +61 -0
  166. package/dist/verify/media-events.js +174 -0
  167. package/dist/verify/platform.d.ts +33 -0
  168. package/dist/verify/platform.js +59 -0
  169. package/dist/verify/probe.d.ts +14 -0
  170. package/dist/verify/probe.js +23 -0
  171. package/dist/verify/report.d.ts +15 -0
  172. package/dist/verify/report.js +45 -0
  173. package/dist/verify/run.d.ts +27 -0
  174. package/dist/verify/run.js +53 -0
  175. package/dist/voice/command.d.ts +3 -0
  176. package/dist/voice/command.js +49 -0
  177. package/dist/voice/manifest.d.ts +33 -0
  178. package/dist/voice/manifest.js +69 -0
  179. package/dist/voice/run.d.ts +21 -0
  180. package/dist/voice/run.js +181 -0
  181. package/dist/workflow/build.d.ts +2 -0
  182. package/dist/workflow/build.js +25 -0
  183. package/dist/workflow/draft.d.ts +9 -0
  184. package/dist/workflow/draft.js +201 -0
  185. package/dist/workflow/finish.d.ts +23 -0
  186. package/dist/workflow/finish.js +28 -0
  187. package/dist/workflow/project.d.ts +73 -0
  188. package/dist/workflow/project.js +198 -0
  189. package/dist/workflow/provenance.d.ts +18 -0
  190. package/dist/workflow/provenance.js +36 -0
  191. package/dist/workflow/render.d.ts +31 -0
  192. package/dist/workflow/render.js +705 -0
  193. package/dist/workflow/still.d.ts +5 -0
  194. package/dist/workflow/still.js +62 -0
  195. package/dist/workflow/validate.d.ts +2 -0
  196. package/dist/workflow/validate.js +49 -0
  197. package/package.json +38 -3
@@ -0,0 +1,243 @@
1
+ import { createHash } from "node:crypto";
2
+ import { join } from "node:path";
3
+ import { validateStoryboardFull } from "@anvpx/motion-build";
4
+ import { approvalState } from "../approval/approval.js";
5
+ import { defaultOut, previewManifestPath } from "../preview/manifest.js";
6
+ import { checkStoryboard } from "../storyboard/check.js";
7
+ import { readJsonFile } from "../storyboard/load.js";
8
+ import { formatFindings } from "../storyboard/report.js";
9
+ import { RENDER_MANIFEST } from "../workflow/provenance.js";
10
+ /** `<render>/manifest.json`: the build manifest, the only artifact that names its storyboard. */
11
+ const BUILD_MANIFEST = "manifest.json";
12
+ const sha256 = (text) => createHash("sha256").update(text).digest("hex");
13
+ const NEXT = {
14
+ storyboard: "preview",
15
+ previewed: "approval",
16
+ approved: "render",
17
+ rendered: "verify",
18
+ verified: null,
19
+ };
20
+ const previewEvidence = (facts) => {
21
+ if (facts.previewSha256 === undefined || facts.currentHash === undefined)
22
+ return "missing";
23
+ return facts.previewSha256 === facts.currentHash ? "current" : "stale";
24
+ };
25
+ /** Current only when the output holds AND the build manifest attributes it to this storyboard. */
26
+ const renderEvidence = (render, canonical) => {
27
+ if (render === undefined)
28
+ return "missing";
29
+ if (render.diskSha256 !== render.recordedSha256)
30
+ return "stale";
31
+ return render.buildStoryboardSha256 === canonical ? "current" : "stale";
32
+ };
33
+ const verifyEvidence = (render, supported) => {
34
+ if (render === undefined)
35
+ return "unknown";
36
+ if (render.verify === "PASS")
37
+ return supported ? "PASS" : "unknown";
38
+ return render.verify === "not-run" ? "not-run" : "unknown";
39
+ };
40
+ const renderProblems = (facts, supported) => {
41
+ const render = facts.render;
42
+ if (render === undefined)
43
+ return [];
44
+ const problems = [];
45
+ if (render.diskSha256 === undefined) {
46
+ problems.push({
47
+ code: "render-output-missing",
48
+ message: `${render.file} is recorded but not in the render dir`,
49
+ });
50
+ }
51
+ else if (render.diskSha256 !== render.recordedSha256) {
52
+ problems.push({
53
+ code: "render-output-mismatch",
54
+ message: `${render.file} sha256 differs from the render manifest`,
55
+ });
56
+ }
57
+ // Unreadable approval inputs leave the approval state unknown, not "not approved".
58
+ if (facts.unreadable !== undefined)
59
+ return problems;
60
+ if (facts.approval !== "approved") {
61
+ problems.push({
62
+ code: "render-unapproved",
63
+ message: "a render manifest exists without a current approval",
64
+ });
65
+ }
66
+ if (render.verify === "PASS" && !supported) {
67
+ problems.push({
68
+ code: "verify-claim-unsupported",
69
+ message: "verify PASS is recorded but the render evidence does not hold",
70
+ });
71
+ }
72
+ return problems;
73
+ };
74
+ const stateOf = (facts, preview, render, verify) => {
75
+ const approved = facts.approval === "approved";
76
+ if (approved && render === "current")
77
+ return verify === "PASS" ? "verified" : "rendered";
78
+ if (approved)
79
+ return "approved";
80
+ return preview === "current" ? "previewed" : "storyboard";
81
+ };
82
+ /** Derives the state, the next missing artifact and every overclaim from already-read facts. */
83
+ export function deriveState(facts) {
84
+ const preview = previewEvidence(facts);
85
+ const render = renderEvidence(facts.render, facts.storyboardCanonicalSha256);
86
+ const supported = render === "current" && facts.approval === "approved";
87
+ const verify = verifyEvidence(facts.render, supported);
88
+ const problems = renderProblems(facts, supported);
89
+ if (facts.unreadable !== undefined) {
90
+ problems.push({ code: "inputs-unreadable", message: facts.unreadable });
91
+ }
92
+ const state = stateOf(facts, preview, render, verify);
93
+ const next = NEXT[state];
94
+ return {
95
+ state,
96
+ next,
97
+ missing: next === null ? [] : [next],
98
+ problems,
99
+ evidence: {
100
+ storyboard: facts.storyboardSha256,
101
+ preview,
102
+ approval: facts.approval,
103
+ render,
104
+ verify,
105
+ },
106
+ };
107
+ }
108
+ const HEX64 = /^[a-f0-9]{64}$/;
109
+ const BASENAME = /^[^\\/]+$/;
110
+ const ABSOLUTE = /(?:[A-Za-z]:)?[\\/][^\s:;,"']+/g;
111
+ /** Keeps an error reason short and machine-independent: no absolute paths. */
112
+ const sanitize = (text) => {
113
+ const line = text.split("\n")[0] ?? "";
114
+ return line.replace(ABSOLUTE, "<path>").slice(0, 120);
115
+ };
116
+ const recordedPreviewSha = (value) => {
117
+ const row = value;
118
+ if (typeof row !== "object" || row === null)
119
+ return undefined;
120
+ const sha = row.inputManifestSha256;
121
+ return typeof sha === "string" && HEX64.test(sha) ? sha : undefined;
122
+ };
123
+ /** The storyboard a build manifest compiled. Unknown or older shapes are "cannot attribute". */
124
+ const recordedBuildSha = (value) => {
125
+ const row = value;
126
+ if (typeof row !== "object" || row === null)
127
+ return undefined;
128
+ const sha = row.storyboardSha256;
129
+ return typeof sha === "string" && HEX64.test(sha) ? sha : undefined;
130
+ };
131
+ /** Present and parsed, absent, or present but unreadable (which exits 2, never "missing"). */
132
+ async function readEvidenceJson(host, path, label) {
133
+ const loaded = await readJsonFile(path, host);
134
+ if (loaded.ok)
135
+ return { kind: "value", value: loaded.value };
136
+ if (loaded.kind === "syntax" || !(await host.exists(path)))
137
+ return { kind: "missing" };
138
+ return { kind: "unreadable", message: `${label} is unreadable` };
139
+ }
140
+ /** A render manifest shape we understand; unknown or older shapes are simply not evidence. */
141
+ async function parseRender(host, dir, value, buildStoryboardSha256) {
142
+ const row = value;
143
+ if (typeof row !== "object" || row === null || row.schema !== 1)
144
+ return undefined;
145
+ const output = row.output;
146
+ if (typeof output !== "object" || output === null)
147
+ return undefined;
148
+ const file = output.file;
149
+ const sha = output.sha256;
150
+ if (typeof file !== "string" || !BASENAME.test(file) || file === "." || file === "..") {
151
+ return undefined;
152
+ }
153
+ if (typeof sha !== "string" || !HEX64.test(sha))
154
+ return undefined;
155
+ return {
156
+ file,
157
+ recordedSha256: sha,
158
+ diskSha256: await host.sha256File(join(dir, file)),
159
+ ...(buildStoryboardSha256 === undefined ? {} : { buildStoryboardSha256 }),
160
+ verify: typeof row.verify === "string" ? row.verify : "unknown",
161
+ };
162
+ }
163
+ /** Tolerant by design: an absent or unparsable build manifest means "cannot attribute", not an error. */
164
+ async function readBuildStoryboardSha(host, dir) {
165
+ const loaded = await readJsonFile(join(dir, BUILD_MANIFEST), host);
166
+ return loaded.ok ? recordedBuildSha(loaded.value) : undefined;
167
+ }
168
+ const SAME_APPROVAL_FLAGS = "pass the same --assets/--ledger/--audio/--music flags used with `mk approve`";
169
+ async function readApproval(io, storyboard, options) {
170
+ try {
171
+ const state = await approvalState(io.host, storyboard, options);
172
+ return { approval: state.state, currentHash: state.hash };
173
+ }
174
+ catch (error) {
175
+ return {
176
+ approval: "missing",
177
+ unreadable: `approval inputs cannot be read: ${sanitize(error.message)}; ${SAME_APPROVAL_FLAGS}`,
178
+ };
179
+ }
180
+ }
181
+ async function loadStoryboard(io, storyboard) {
182
+ const loaded = await readJsonFile(storyboard, io.host);
183
+ if (!loaded.ok)
184
+ return { ok: false, message: loaded.message };
185
+ const report = checkStoryboard(loaded.value);
186
+ if (report.errors.length > 0) {
187
+ const findings = formatFindings(report.errors.slice(0, 3));
188
+ return { ok: false, message: `storyboard is invalid; run \`mk validate\`.\n${findings}` };
189
+ }
190
+ const board = validateStoryboardFull(loaded.value);
191
+ // checkStoryboard ran the same validator; this turns the impossible into a typed refusal.
192
+ if (!board.ok)
193
+ return { ok: false, message: "storyboard is invalid; run `mk validate`." };
194
+ const fileSha256 = await io.host.sha256File(storyboard);
195
+ if (fileSha256 === undefined)
196
+ return { ok: false, message: `cannot read ${storyboard}` };
197
+ // A build hashes `JSON.stringify(board)`: canonical, so whitespace and key order are not identity.
198
+ return { ok: true, sha256: fileSha256, canonicalSha256: sha256(JSON.stringify(board.value)) };
199
+ }
200
+ async function readPreview(io, storyboard, options) {
201
+ const out = options.out ?? defaultOut(storyboard);
202
+ const preview = await readEvidenceJson(io.host, previewManifestPath(out, storyboard), "preview manifest");
203
+ if (preview.kind === "unreadable")
204
+ return { ok: false, message: preview.message };
205
+ const sha256 = preview.kind === "value" ? recordedPreviewSha(preview.value) : undefined;
206
+ return sha256 === undefined ? { ok: true } : { ok: true, sha256 };
207
+ }
208
+ async function readRender(io, storyboard, options) {
209
+ const dir = options.render ?? join(defaultOut(storyboard), "project");
210
+ const json = await readEvidenceJson(io.host, join(dir, RENDER_MANIFEST), "render manifest");
211
+ if (json.kind === "unreadable")
212
+ return { ok: false, message: json.message };
213
+ if (json.kind !== "value")
214
+ return { ok: true };
215
+ const buildSha = await readBuildStoryboardSha(io.host, dir);
216
+ const facts = await parseRender(io.host, dir, json.value, buildSha);
217
+ return facts === undefined ? { ok: true } : { ok: true, facts };
218
+ }
219
+ /** Reads every artifact once; validation failures and unreadable inputs are exit-2 values. */
220
+ export async function gatherEvidence(io, storyboard, options = {}) {
221
+ const board = await loadStoryboard(io, storyboard);
222
+ if (!board.ok)
223
+ return board;
224
+ const approval = await readApproval(io, storyboard, options.approval ?? {});
225
+ const preview = await readPreview(io, storyboard, options);
226
+ if (!preview.ok)
227
+ return preview;
228
+ const render = await readRender(io, storyboard, options);
229
+ if (!render.ok)
230
+ return render;
231
+ return {
232
+ ok: true,
233
+ facts: {
234
+ storyboardSha256: board.sha256,
235
+ storyboardCanonicalSha256: board.canonicalSha256,
236
+ approval: approval.approval,
237
+ ...(approval.currentHash === undefined ? {} : { currentHash: approval.currentHash }),
238
+ ...(approval.unreadable === undefined ? {} : { unreadable: approval.unreadable }),
239
+ ...(preview.sha256 === undefined ? {} : { previewSha256: preview.sha256 }),
240
+ ...(render.facts === undefined ? {} : { render: render.facts }),
241
+ },
242
+ };
243
+ }
@@ -0,0 +1,21 @@
1
+ import { type LintFinding, type PlatformMarginLookup } from "@anvpx/motion-build";
2
+ export type Finding = LintFinding;
3
+ export interface CheckReport {
4
+ errors: Finding[];
5
+ warnings: Finding[];
6
+ /** Total duration in seconds; present once the storyboard validates. */
7
+ total?: number;
8
+ /** Frame size and rate; present once the storyboard validates. */
9
+ meta?: {
10
+ width: number;
11
+ height: number;
12
+ fps: number;
13
+ };
14
+ }
15
+ /** A finding about the document itself (path "" is the JSON root). */
16
+ export declare const rootError: (message: string) => Finding;
17
+ /** Schema + per-scene validation, timing resolution and lint (unless `lint: false`), with no side effects. */
18
+ export declare function checkStoryboard(value: unknown, options?: {
19
+ lint?: boolean;
20
+ platforms?: PlatformMarginLookup;
21
+ }): CheckReport;
@@ -0,0 +1,39 @@
1
+ import { bundledThemeDocs, lintStoryboard, resolveTimeline, sceneWords, tryResolveTheme, validateStoryboardFull, } from "@anvpx/motion-build";
2
+ const asFinding = (error) => ({ severity: "error", ...error });
3
+ /** A finding about the document itself (path "" is the JSON root). */
4
+ export const rootError = (message) => ({ severity: "error", path: "", message });
5
+ const failure = (errors) => ({
6
+ errors: errors.map(asFinding),
7
+ warnings: [],
8
+ });
9
+ const themeInput = (name) => {
10
+ const theme = tryResolveTheme(name, bundledThemeDocs());
11
+ return theme === undefined ? {} : { theme };
12
+ };
13
+ /** Schema + per-scene validation, timing resolution and lint (unless `lint: false`), with no side effects. */
14
+ export function checkStoryboard(value, options = {}) {
15
+ const valid = validateStoryboardFull(value);
16
+ if (!valid.ok)
17
+ return failure(valid.errors);
18
+ const board = valid.value;
19
+ const timeline = resolveTimeline(board);
20
+ if (!timeline.ok)
21
+ return failure(timeline.errors);
22
+ const words = board.scenes.map((scene) => sceneWords(scene.props, scene.type));
23
+ const lint = options.lint === false
24
+ ? { errors: [], warnings: [] }
25
+ : lintStoryboard({
26
+ storyboard: board,
27
+ timeline: timeline.value,
28
+ words,
29
+ ...themeInput(board.meta.theme),
30
+ ...(options.platforms === undefined ? {} : { platforms: options.platforms }),
31
+ });
32
+ const { width, height, fps } = board.meta;
33
+ return {
34
+ errors: [...lint.errors],
35
+ warnings: [...lint.warnings],
36
+ total: timeline.value.total,
37
+ meta: { width, height, fps },
38
+ };
39
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Real-path containment for authored scenes.
3
+ *
4
+ * `props.dir` is checked textually by `resolveSceneDir`, but a symlink or NTFS
5
+ * junction points outside the project while still looking local. Loading such a
6
+ * scene would read outside files, and hashing it would record them as local
7
+ * approval inputs. Every path that reaches an authored scene therefore gets a
8
+ * real-path check; the check fails closed when a real path exists and leaves the
9
+ * allowed root.
10
+ *
11
+ * Fake/off-disk hosts have no realpath: an unresolvable path is treated as "not
12
+ * escaped" and left to the existing existence checks, because there is nothing
13
+ * for the real filesystem to resolve.
14
+ */
15
+ /** True when `target` is strictly inside `root` (not equal, not a sibling prefix). */
16
+ export declare const insideReal: (root: string, target: string) => boolean;
17
+ export interface SceneContainment {
18
+ /** False when the scene directory itself resolves outside the storyboard directory. */
19
+ readonly dirInside: boolean;
20
+ /** Scene-relative files whose real path leaves the scene directory. */
21
+ readonly escapedFiles: readonly string[];
22
+ }
23
+ /** Real-path containment for a scene directory plus the files it is about to read. */
24
+ export declare const sceneContainment: (storyboardDir: string, sceneDir: string, files: readonly string[]) => SceneContainment;
@@ -0,0 +1,43 @@
1
+ import { realpathSync } from "node:fs";
2
+ import { isAbsolute, join, relative } from "node:path";
3
+ /**
4
+ * Real-path containment for authored scenes.
5
+ *
6
+ * `props.dir` is checked textually by `resolveSceneDir`, but a symlink or NTFS
7
+ * junction points outside the project while still looking local. Loading such a
8
+ * scene would read outside files, and hashing it would record them as local
9
+ * approval inputs. Every path that reaches an authored scene therefore gets a
10
+ * real-path check; the check fails closed when a real path exists and leaves the
11
+ * allowed root.
12
+ *
13
+ * Fake/off-disk hosts have no realpath: an unresolvable path is treated as "not
14
+ * escaped" and left to the existing existence checks, because there is nothing
15
+ * for the real filesystem to resolve.
16
+ */
17
+ /** True when `target` is strictly inside `root` (not equal, not a sibling prefix). */
18
+ export const insideReal = (root, target) => {
19
+ const rel = relative(root, target);
20
+ return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
21
+ };
22
+ const realPath = (path) => {
23
+ try {
24
+ return realpathSync(path);
25
+ }
26
+ catch {
27
+ return undefined;
28
+ }
29
+ };
30
+ /** Real-path containment for a scene directory plus the files it is about to read. */
31
+ export const sceneContainment = (storyboardDir, sceneDir, files) => {
32
+ const rootReal = realPath(storyboardDir);
33
+ const dirReal = realPath(sceneDir);
34
+ if (rootReal === undefined || dirReal === undefined) {
35
+ return { dirInside: true, escapedFiles: [] };
36
+ }
37
+ const dirInside = insideReal(rootReal, dirReal);
38
+ const escapedFiles = files.filter((file) => {
39
+ const fileReal = realPath(join(sceneDir, file));
40
+ return fileReal !== undefined && !insideReal(dirReal, fileReal);
41
+ });
42
+ return { dirInside, escapedFiles };
43
+ };
@@ -0,0 +1,15 @@
1
+ import { type CustomSceneSource, type LintFinding } from "@anvpx/motion-build";
2
+ import type { Host } from "../types.ts";
3
+ /** True when the storyboard references an authored scene; cheap, no disk access. */
4
+ export declare const hasCustomScenes: (board: unknown) => boolean;
5
+ export interface CustomReadResult {
6
+ /** Authored scene sources by scene id; only the entries that read cleanly. */
7
+ readonly sources: Readonly<Record<string, CustomSceneSource>>;
8
+ readonly findings: readonly LintFinding[];
9
+ }
10
+ /**
11
+ * Reads every authored scene beside the storyboard, refuses directories that escape it
12
+ * (textually or through a symlink/junction), and runs the determinism lint. The build
13
+ * stays pure: this is the only place that touches disk.
14
+ */
15
+ export declare function readCustomScenes(board: unknown, storyboardPath: string, host: Host): Promise<CustomReadResult>;
@@ -0,0 +1,133 @@
1
+ import { dirname, join, resolve } from "node:path";
2
+ import { CUSTOM_SCENE_TYPE, HELPER_LIMITS, lintCustomSource, readRelativeClosure, resolveSceneDir, scanVendorImports, } from "@anvpx/motion-build";
3
+ import { sceneContainment } from "./containment.js";
4
+ /** Files an authored scene must ship; `style.css` is optional. */
5
+ const REQUIRED = ["scene.js", "fragment.html"];
6
+ const OPTIONAL_CSS = "style.css";
7
+ const customEntries = (board) => {
8
+ const scenes = board.scenes;
9
+ if (!Array.isArray(scenes))
10
+ return [];
11
+ const entries = [];
12
+ scenes.forEach((scene, index) => {
13
+ const entry = scene;
14
+ if (entry.type !== CUSTOM_SCENE_TYPE)
15
+ return;
16
+ entries.push({
17
+ index,
18
+ id: typeof entry.id === "string" ? entry.id : `#${index}`,
19
+ dir: typeof entry.props?.dir === "string" ? entry.props.dir : "",
20
+ });
21
+ });
22
+ return entries;
23
+ };
24
+ /** True when the storyboard references an authored scene; cheap, no disk access. */
25
+ export const hasCustomScenes = (board) => customEntries(board).length > 0;
26
+ const error = (path, message, suggestion) => ({
27
+ severity: "error",
28
+ path,
29
+ message,
30
+ ...(suggestion === undefined ? {} : { suggestion }),
31
+ });
32
+ /** Runs the determinism probes on one helper, under its own scene-relative path. */
33
+ const lintHelper = (text, dirName, path) => lintCustomSource({ js: text }, dirName).map((finding) => ({
34
+ ...finding,
35
+ path: finding.path.replace(/scene\.js:/, `${path}:`),
36
+ }));
37
+ /**
38
+ * Reads every authored scene beside the storyboard, refuses directories that escape it
39
+ * (textually or through a symlink/junction), and runs the determinism lint. The build
40
+ * stays pure: this is the only place that touches disk.
41
+ */
42
+ export async function readCustomScenes(board, storyboardPath, host) {
43
+ const entries = customEntries(board);
44
+ const sources = {};
45
+ const findings = [];
46
+ const baseDir = dirname(resolve(storyboardPath));
47
+ for (const entry of entries) {
48
+ const where = `/scenes/${entry.index}/props/dir`;
49
+ const resolved = resolveSceneDir(baseDir, entry.dir);
50
+ if (!resolved.ok) {
51
+ findings.push(error(where, resolved.message));
52
+ continue;
53
+ }
54
+ const dirName = `scenes/${entry.id}`;
55
+ const missing = [];
56
+ for (const file of REQUIRED) {
57
+ if (!(await host.exists(join(resolved.dir, file))))
58
+ missing.push(file);
59
+ }
60
+ if (missing.length > 0) {
61
+ findings.push(error(where, `missing ${missing.join(", ")}`, `add them under ${entry.dir}`));
62
+ continue;
63
+ }
64
+ // Textual containment is not enough: a symlink or junction can leave the project.
65
+ const containment = sceneContainment(baseDir, resolved.dir, [...REQUIRED, OPTIONAL_CSS]);
66
+ if (!containment.dirInside) {
67
+ findings.push(error(where, "scene directory resolves outside the storyboard directory (symlink/junction)", "replace the link with a real directory inside the project"));
68
+ continue;
69
+ }
70
+ const escaped = new Set(containment.escapedFiles);
71
+ for (const file of escaped) {
72
+ findings.push(error(`${dirName}/${file}`, "scene file resolves outside the scene directory (symlink/junction)", "replace the link with a real file inside the scene directory"));
73
+ }
74
+ if (escaped.size > 0)
75
+ continue;
76
+ try {
77
+ const js = await host.readText(join(resolved.dir, "scene.js"));
78
+ const html = await host.readText(join(resolved.dir, "fragment.html"));
79
+ const cssPath = join(resolved.dir, OPTIONAL_CSS);
80
+ const css = (await host.exists(cssPath)) ? await host.readText(cssPath) : undefined;
81
+ // A bare import only resolves if the build wrote an import map entry for it.
82
+ const vendor = scanVendorImports(js);
83
+ for (const name of vendor.unknown) {
84
+ findings.push(error(`${dirName}/scene.js`, `cannot import "${name}": the emitted page vendors no such library`, "import three or postprocessing, or use a relative path (./, ../) inside the scene"));
85
+ }
86
+ // Relative helpers are part of the scene: package the closure and lint it too.
87
+ // Each helper is containment-checked before its bytes are read.
88
+ const helperEscapes = [];
89
+ const closure = await readRelativeClosure(js, async (path) => {
90
+ const check = sceneContainment(baseDir, resolved.dir, [path]);
91
+ if (!check.dirInside || check.escapedFiles.length > 0) {
92
+ helperEscapes.push(path);
93
+ return undefined;
94
+ }
95
+ const full = join(resolved.dir, path);
96
+ return (await host.exists(full)) ? host.readText(full) : undefined;
97
+ });
98
+ for (const path of helperEscapes) {
99
+ findings.push(error(`${dirName}/${path}`, "helper resolves outside the scene directory (symlink/junction)", "keep the real file inside the scene directory"));
100
+ }
101
+ for (const specifier of closure.escaping) {
102
+ findings.push(error(`${dirName}/scene.js`, `relative import "${specifier}" escapes the scene directory`, "keep helpers inside the scene directory (./lib/format.js)"));
103
+ }
104
+ for (const path of closure.missing) {
105
+ if (helperEscapes.includes(path))
106
+ continue;
107
+ findings.push(error(`${dirName}/scene.js`, `cannot import "./${path}": no such file beside the scene`, "add the file, or inline the code"));
108
+ }
109
+ if (closure.overflow) {
110
+ findings.push(error(`${dirName}/scene.js`, `scene helper closure exceeds ${HELPER_LIMITS.files} files or ${HELPER_LIMITS.chars} characters`, "keep the scene to a few small helpers"));
111
+ }
112
+ for (const file of closure.files) {
113
+ findings.push(...lintHelper(file.text, dirName, file.path));
114
+ }
115
+ // A scene whose helper cannot be read safely is not emitted at all.
116
+ if (helperEscapes.length > 0)
117
+ continue;
118
+ const source = {
119
+ js,
120
+ html,
121
+ ...(css === undefined ? {} : { css }),
122
+ ...(vendor.libs.length === 0 ? {} : { vendor: vendor.libs }),
123
+ ...(closure.files.length === 0 ? {} : { files: closure.files }),
124
+ };
125
+ findings.push(...lintCustomSource(source, dirName));
126
+ sources[entry.id] = source;
127
+ }
128
+ catch (cause) {
129
+ findings.push(error(where, `cannot read scene files: ${cause.message}`));
130
+ }
131
+ }
132
+ return { sources, findings };
133
+ }
@@ -0,0 +1,19 @@
1
+ import type { Io } from "../types.ts";
2
+ import { type CheckReport } from "./check.ts";
3
+ /** What an edit produced: the new document and how to describe it once it is written. */
4
+ export type Edit = {
5
+ ok: true;
6
+ value: unknown;
7
+ lines(report: CheckReport): string[];
8
+ } | {
9
+ ok: false;
10
+ message: string;
11
+ };
12
+ /** `scenes 5 | total 25s | 0 errors, 1 warnings`: the second line every edit prints. */
13
+ export declare const statusLine: (report: CheckReport, scenes: number) => string;
14
+ export declare const round1: (value: number) => number;
15
+ /**
16
+ * The shared edit pipeline: load, transform, validate the result with the full validator, write
17
+ * atomically. An invalid result is never written: the pointer errors are printed and the exit is 2.
18
+ */
19
+ export declare function editStoryboard(name: string, path: string, io: Io, transform: (doc: unknown) => Edit): Promise<number>;
@@ -0,0 +1,39 @@
1
+ import { EXIT } from "../exit-codes.js";
2
+ import { checkStoryboard } from "./check.js";
3
+ import { readJsonFile } from "./load.js";
4
+ import { formatFinding } from "./report.js";
5
+ const MAX_ERROR_LINES = 2;
6
+ const refusal = (name, report) => {
7
+ const shown = report.errors.slice(0, MAX_ERROR_LINES).map(formatFinding);
8
+ const more = report.errors.length - shown.length;
9
+ const head = `${name} refused, nothing written: ${report.errors.length} errors`;
10
+ const tail = more > 0 ? ` (+${more} more; run mk validate)` : "";
11
+ return `${[head, ...shown].join("\n")}${tail}\n`;
12
+ };
13
+ /** `scenes 5 | total 25s | 0 errors, 1 warnings`: the second line every edit prints. */
14
+ export const statusLine = (report, scenes) => `scenes ${scenes} | total ${round1(report.total ?? 0)}s | ${report.errors.length} errors, ${report.warnings.length} warnings`;
15
+ export const round1 = (value) => Math.round(value * 10) / 10;
16
+ /**
17
+ * The shared edit pipeline: load, transform, validate the result with the full validator, write
18
+ * atomically. An invalid result is never written: the pointer errors are printed and the exit is 2.
19
+ */
20
+ export async function editStoryboard(name, path, io, transform) {
21
+ const loaded = await readJsonFile(path, io.host);
22
+ if (!loaded.ok) {
23
+ io.stderr(`mk ${name}: ${loaded.message}\n`);
24
+ return EXIT.USAGE;
25
+ }
26
+ const edit = transform(loaded.value);
27
+ if (!edit.ok) {
28
+ io.stderr(`mk ${name}: ${edit.message}\n`);
29
+ return EXIT.USAGE;
30
+ }
31
+ const report = checkStoryboard(edit.value);
32
+ if (report.errors.length > 0) {
33
+ io.stderr(refusal(name, report));
34
+ return EXIT.USAGE;
35
+ }
36
+ await io.host.writeTextAtomic(path, `${JSON.stringify(edit.value, null, 2)}\n`);
37
+ io.stdout(`${edit.lines(report).join("\n")}\n`);
38
+ return EXIT.OK;
39
+ }
@@ -0,0 +1,11 @@
1
+ import type { Host } from "../types.ts";
2
+ export type LoadResult = {
3
+ ok: true;
4
+ value: unknown;
5
+ } | {
6
+ ok: false;
7
+ kind: "io" | "syntax";
8
+ message: string;
9
+ };
10
+ /** Reads and parses a JSON file; I/O and syntax failures are reported as values. */
11
+ export declare function readJsonFile(path: string, host: Host): Promise<LoadResult>;
@@ -0,0 +1,52 @@
1
+ const POSITION = /at position (\d+)/;
2
+ const POSITION_SUFFIX = /\s+in JSON at position \d+(?: \(line \d+ column \d+\))?/;
3
+ /** True while `text[0, length)` is still the start of some valid document. */
4
+ function isLivePrefix(text, length) {
5
+ try {
6
+ JSON.parse(text.slice(0, length));
7
+ return true;
8
+ }
9
+ catch (error) {
10
+ const message = error.message;
11
+ if (message.includes("end of JSON input"))
12
+ return true;
13
+ const position = POSITION.exec(message)?.[1];
14
+ return position !== undefined && Number(position) >= length;
15
+ }
16
+ }
17
+ /** Offset of the first character that makes the text invalid (binary search over prefixes). */
18
+ function errorOffset(text) {
19
+ let live = 0;
20
+ let dead = text.length;
21
+ while (dead - live > 1) {
22
+ const middle = Math.floor((live + dead) / 2);
23
+ if (isLivePrefix(text, middle))
24
+ live = middle;
25
+ else
26
+ dead = middle;
27
+ }
28
+ return live;
29
+ }
30
+ function syntaxMessage(text, error) {
31
+ const reason = error.message.replace(POSITION_SUFFIX, "");
32
+ const before = text.slice(0, errorOffset(text)).split("\n");
33
+ const line = before.length;
34
+ const col = (before.at(-1)?.length ?? 0) + 1;
35
+ return `invalid JSON at line ${line}, col ${col}: ${reason}`;
36
+ }
37
+ /** Reads and parses a JSON file; I/O and syntax failures are reported as values. */
38
+ export async function readJsonFile(path, host) {
39
+ let text;
40
+ try {
41
+ text = await host.readText(path);
42
+ }
43
+ catch (error) {
44
+ return { ok: false, kind: "io", message: `cannot read ${path}: ${error.message}` };
45
+ }
46
+ try {
47
+ return { ok: true, value: JSON.parse(text) };
48
+ }
49
+ catch (error) {
50
+ return { ok: false, kind: "syntax", message: syntaxMessage(text, error) };
51
+ }
52
+ }
@@ -0,0 +1,5 @@
1
+ import type { Finding } from "./check.ts";
2
+ /** `E /scenes/1/props/items/0: message -> suggestion`; the JSON root prints as `/`. */
3
+ export declare function formatFinding(finding: Finding): string;
4
+ export declare const formatFindings: (findings: readonly Finding[]) => string;
5
+ export declare const summaryLine: (errors: number, warnings: number) => string;
@@ -0,0 +1,8 @@
1
+ /** `E /scenes/1/props/items/0: message -> suggestion`; the JSON root prints as `/`. */
2
+ export function formatFinding(finding) {
3
+ const level = finding.severity === "error" ? "E" : "W";
4
+ const suggestion = finding.suggestion === undefined ? "" : ` -> ${finding.suggestion}`;
5
+ return `${level} ${finding.path || "/"}: ${finding.message}${suggestion}`;
6
+ }
7
+ export const formatFindings = (findings) => findings.map((finding) => `${formatFinding(finding)}\n`).join("");
8
+ export const summaryLine = (errors, warnings) => `${errors} errors, ${warnings} warnings\n`;