@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.
- package/LICENSE +21 -0
- package/README.md +121 -2
- package/dist/analyze/command.d.ts +2 -0
- package/dist/analyze/command.js +190 -0
- package/dist/approval/approval.d.ts +32 -0
- package/dist/approval/approval.js +141 -0
- package/dist/approval/command.d.ts +2 -0
- package/dist/approval/command.js +255 -0
- package/dist/approval/gate.d.ts +7 -0
- package/dist/approval/gate.js +23 -0
- package/dist/approval/hash.d.ts +71 -0
- package/dist/approval/hash.js +451 -0
- package/dist/args.d.ts +30 -0
- package/dist/args.js +53 -0
- package/dist/assets/command.d.ts +2 -0
- package/dist/assets/command.js +165 -0
- package/dist/captions/command.d.ts +2 -0
- package/dist/captions/command.js +341 -0
- package/dist/captions/cues.d.ts +10 -0
- package/dist/captions/cues.js +61 -0
- package/dist/catalog/command.d.ts +2 -0
- package/dist/catalog/command.js +60 -0
- package/dist/catalog/format.d.ts +13 -0
- package/dist/catalog/format.js +54 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +35 -0
- package/dist/commands.d.ts +3 -0
- package/dist/commands.js +59 -0
- package/dist/compare/command.d.ts +4 -0
- package/dist/compare/command.js +144 -0
- package/dist/contact/argv.d.ts +24 -0
- package/dist/contact/argv.js +42 -0
- package/dist/contact/command.d.ts +2 -0
- package/dist/contact/command.js +79 -0
- package/dist/doctor/checks.d.ts +25 -0
- package/dist/doctor/checks.js +571 -0
- package/dist/doctor/command.d.ts +4 -0
- package/dist/doctor/command.js +88 -0
- package/dist/doctor/format.d.ts +7 -0
- package/dist/doctor/format.js +41 -0
- package/dist/doctor/heal.d.ts +14 -0
- package/dist/doctor/heal.js +71 -0
- package/dist/doctor/pins.d.ts +8 -0
- package/dist/doctor/pins.js +28 -0
- package/dist/doctor/skills.d.ts +15 -0
- package/dist/doctor/skills.js +58 -0
- package/dist/doctor/types.d.ts +36 -0
- package/dist/doctor/types.js +1 -0
- package/dist/doctor/vendored.d.ts +15 -0
- package/dist/doctor/vendored.js +90 -0
- package/dist/doctor/version.d.ts +8 -0
- package/dist/doctor/version.js +11 -0
- package/dist/exit-codes.d.ts +16 -0
- package/dist/exit-codes.js +15 -0
- package/dist/fit/command.d.ts +5 -0
- package/dist/fit/command.js +154 -0
- package/dist/fit/solve.d.ts +21 -0
- package/dist/fit/solve.js +80 -0
- package/dist/fit/target.d.ts +18 -0
- package/dist/fit/target.js +47 -0
- package/dist/generate3d/command.d.ts +3 -0
- package/dist/generate3d/command.js +38 -0
- package/dist/generate3d/run.d.ts +39 -0
- package/dist/generate3d/run.js +77 -0
- package/dist/icon/command.d.ts +2 -0
- package/dist/icon/command.js +113 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +16 -0
- package/dist/install-mk.d.ts +31 -0
- package/dist/install-mk.js +168 -0
- package/dist/integration/preview.integration.d.ts +1 -0
- package/dist/integration/preview.integration.js +53 -0
- package/dist/integration/smoke.integration.d.ts +1 -0
- package/dist/integration/smoke.integration.js +91 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +4 -0
- package/dist/node-io.d.ts +14 -0
- package/dist/node-io.js +152 -0
- package/dist/optimize/command.d.ts +12 -0
- package/dist/optimize/command.js +110 -0
- package/dist/packs/command.d.ts +2 -0
- package/dist/packs/command.js +109 -0
- package/dist/patch/apply.d.ts +36 -0
- package/dist/patch/apply.js +186 -0
- package/dist/patch/command.d.ts +2 -0
- package/dist/patch/command.js +92 -0
- package/dist/plugin/install.d.ts +40 -0
- package/dist/plugin/install.js +170 -0
- package/dist/preview/command.d.ts +10 -0
- package/dist/preview/command.js +758 -0
- package/dist/preview/manifest.d.ts +94 -0
- package/dist/preview/manifest.js +19 -0
- package/dist/preview/pack.d.ts +24 -0
- package/dist/preview/pack.js +53 -0
- package/dist/preview/plan.d.ts +58 -0
- package/dist/preview/plan.js +160 -0
- package/dist/preview/provenance.d.ts +23 -0
- package/dist/preview/provenance.js +38 -0
- package/dist/publish/command.d.ts +2 -0
- package/dist/publish/command.js +70 -0
- package/dist/publish/player.d.ts +25 -0
- package/dist/publish/player.js +108 -0
- package/dist/publish/static.d.ts +16 -0
- package/dist/publish/static.js +78 -0
- package/dist/qc.d.ts +2 -0
- package/dist/qc.js +192 -0
- package/dist/scene/author.d.ts +16 -0
- package/dist/scene/author.js +260 -0
- package/dist/scene/command.d.ts +2 -0
- package/dist/scene/command.js +110 -0
- package/dist/scene/ops.d.ts +23 -0
- package/dist/scene/ops.js +151 -0
- package/dist/state/command.d.ts +2 -0
- package/dist/state/command.js +128 -0
- package/dist/state/evidence.d.ts +71 -0
- package/dist/state/evidence.js +243 -0
- package/dist/storyboard/check.d.ts +21 -0
- package/dist/storyboard/check.js +39 -0
- package/dist/storyboard/containment.d.ts +24 -0
- package/dist/storyboard/containment.js +43 -0
- package/dist/storyboard/custom.d.ts +15 -0
- package/dist/storyboard/custom.js +133 -0
- package/dist/storyboard/edit.d.ts +19 -0
- package/dist/storyboard/edit.js +39 -0
- package/dist/storyboard/load.d.ts +11 -0
- package/dist/storyboard/load.js +52 -0
- package/dist/storyboard/report.d.ts +5 -0
- package/dist/storyboard/report.js +8 -0
- package/dist/template/coerce.d.ts +6 -0
- package/dist/template/coerce.js +44 -0
- package/dist/template/command.d.ts +5 -0
- package/dist/template/command.js +163 -0
- package/dist/template/env.d.ts +34 -0
- package/dist/template/env.js +38 -0
- package/dist/template/fork.d.ts +8 -0
- package/dist/template/fork.js +156 -0
- package/dist/template/format.d.ts +21 -0
- package/dist/template/format.js +161 -0
- package/dist/template/list.d.ts +15 -0
- package/dist/template/list.js +116 -0
- package/dist/template/new.d.ts +6 -0
- package/dist/template/new.js +302 -0
- package/dist/testing/env.d.ts +8 -0
- package/dist/testing/env.js +19 -0
- package/dist/testing/fakes.d.ts +33 -0
- package/dist/testing/fakes.js +95 -0
- package/dist/testing/fixtures.d.ts +8 -0
- package/dist/testing/fixtures.js +6 -0
- package/dist/testing/probe.d.ts +15 -0
- package/dist/testing/probe.js +20 -0
- package/dist/testing/project.d.ts +22 -0
- package/dist/testing/project.js +26 -0
- package/dist/tooling/session.d.ts +40 -0
- package/dist/tooling/session.js +106 -0
- package/dist/types.d.ts +59 -0
- package/dist/types.js +1 -0
- package/dist/verify/audio-loudness.d.ts +46 -0
- package/dist/verify/audio-loudness.js +186 -0
- package/dist/verify/command.d.ts +2 -0
- package/dist/verify/command.js +161 -0
- package/dist/verify/determinism.d.ts +30 -0
- package/dist/verify/determinism.js +59 -0
- package/dist/verify/expectation.d.ts +15 -0
- package/dist/verify/expectation.js +19 -0
- package/dist/verify/media-events.d.ts +61 -0
- package/dist/verify/media-events.js +174 -0
- package/dist/verify/platform.d.ts +33 -0
- package/dist/verify/platform.js +59 -0
- package/dist/verify/probe.d.ts +14 -0
- package/dist/verify/probe.js +23 -0
- package/dist/verify/report.d.ts +15 -0
- package/dist/verify/report.js +45 -0
- package/dist/verify/run.d.ts +27 -0
- package/dist/verify/run.js +53 -0
- package/dist/voice/command.d.ts +3 -0
- package/dist/voice/command.js +49 -0
- package/dist/voice/manifest.d.ts +33 -0
- package/dist/voice/manifest.js +69 -0
- package/dist/voice/run.d.ts +21 -0
- package/dist/voice/run.js +181 -0
- package/dist/workflow/build.d.ts +2 -0
- package/dist/workflow/build.js +25 -0
- package/dist/workflow/draft.d.ts +9 -0
- package/dist/workflow/draft.js +201 -0
- package/dist/workflow/finish.d.ts +23 -0
- package/dist/workflow/finish.js +28 -0
- package/dist/workflow/project.d.ts +73 -0
- package/dist/workflow/project.js +198 -0
- package/dist/workflow/provenance.d.ts +18 -0
- package/dist/workflow/provenance.js +36 -0
- package/dist/workflow/render.d.ts +31 -0
- package/dist/workflow/render.js +705 -0
- package/dist/workflow/still.d.ts +5 -0
- package/dist/workflow/still.js +62 -0
- package/dist/workflow/validate.d.ts +2 -0
- package/dist/workflow/validate.js +49 -0
- package/package.json +38 -3
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
import { runCli } from "../cli.js";
|
|
6
|
+
import { nodeIo } from "../node-io.js";
|
|
7
|
+
const OUT = fileURLToPath(new URL("../../dist/.out/template-flow", import.meta.url));
|
|
8
|
+
const SB = join(OUT, "storyboard.json");
|
|
9
|
+
const PACK = join(OUT, "out", "storyboard.preview");
|
|
10
|
+
const BUDGET_SEC = 90;
|
|
11
|
+
const MIN_PNG_BYTES = 10_000;
|
|
12
|
+
async function mk(...args) {
|
|
13
|
+
const out = [];
|
|
14
|
+
const err = [];
|
|
15
|
+
const io = { ...nodeIo(), stdout: (t) => out.push(t), stderr: (t) => err.push(t) };
|
|
16
|
+
const began = performance.now();
|
|
17
|
+
const code = await runCli(args, io);
|
|
18
|
+
const seconds = Math.round((performance.now() - began) / 100) / 10;
|
|
19
|
+
return { code, out: out.join(""), err: err.join(""), seconds };
|
|
20
|
+
}
|
|
21
|
+
/** The real protocol on the built-in explain/architecture example: new, preview, gate, approve. */
|
|
22
|
+
describe("template flow with the real tools", () => {
|
|
23
|
+
rmSync(OUT, { recursive: true, force: true });
|
|
24
|
+
mkdirSync(OUT, { recursive: true });
|
|
25
|
+
it("mk new writes a valid storyboard with a vendored theme", async () => {
|
|
26
|
+
const made = await mk("new", "explain/architecture", "--out", SB, "--set", "title=How Smart Pricing is wired", "--set", "nodes=Market data|Pricing engine|Rules|Listing price", "--set", "edges=Market data>Pricing engine\\|signals|Rules>Pricing engine\\|guardrails|Pricing engine>Listing price");
|
|
27
|
+
expect(made.code, made.err).toBe(0);
|
|
28
|
+
expect((await mk("validate", SB)).code).toBe(0);
|
|
29
|
+
const { theme } = JSON.parse(readFileSync(SB, "utf8")).meta;
|
|
30
|
+
expect(existsSync(join(OUT, "themes", `${theme}.json`))).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
it(`mk preview builds the pack within ${BUDGET_SEC} s`, async () => {
|
|
33
|
+
const preview = await mk("preview", SB);
|
|
34
|
+
expect(preview.code, preview.err).toBe(0);
|
|
35
|
+
expect(preview.seconds).toBeLessThanOrEqual(BUDGET_SEC);
|
|
36
|
+
expect(statSync(join(PACK, "contact.png")).size).toBeGreaterThan(MIN_PNG_BYTES);
|
|
37
|
+
const manifest = JSON.parse(readFileSync(join(PACK, "preview.json"), "utf8"));
|
|
38
|
+
expect(manifest.clip.status, JSON.stringify(manifest.clip)).toBe("ok");
|
|
39
|
+
expect(statSync(join(PACK, "clip.mp4")).size).toBeGreaterThan(0);
|
|
40
|
+
expect(manifest.stills.length).toBeGreaterThanOrEqual(4);
|
|
41
|
+
});
|
|
42
|
+
it("render --gated exits 4 until approved, then renders; an edit re-arms it", async () => {
|
|
43
|
+
expect((await mk("render", SB, "--gated")).code).toBe(4);
|
|
44
|
+
expect((await mk("approve", SB, "--note", "integration test")).code).toBe(0);
|
|
45
|
+
const rendered = await mk("render", SB, "--gated", "--verify");
|
|
46
|
+
expect(rendered.code, rendered.err).toBe(0);
|
|
47
|
+
expect(rendered.out).toContain("PASS");
|
|
48
|
+
const edited = readFileSync(SB, "utf8").replaceAll("Rules", "Guardrails");
|
|
49
|
+
expect(edited).not.toBe(readFileSync(SB, "utf8"));
|
|
50
|
+
writeFileSync(SB, edited);
|
|
51
|
+
expect((await mk("render", SB, "--gated")).code).toBe(4);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { describe, expect, it } from "vitest";
|
|
6
|
+
import { runCli } from "../cli.js";
|
|
7
|
+
import { nodeIo } from "../node-io.js";
|
|
8
|
+
const path = (relative) => fileURLToPath(new URL(relative, import.meta.url));
|
|
9
|
+
const FIXTURE = path("../../../build/fixtures/demo.storyboard.json");
|
|
10
|
+
const OUT = path("../../dist/.out/smoke");
|
|
11
|
+
const MIN_PNG_BYTES = 10_000;
|
|
12
|
+
async function mk(...args) {
|
|
13
|
+
const out = [];
|
|
14
|
+
const err = [];
|
|
15
|
+
const io = { ...nodeIo(), stdout: (t) => out.push(t), stderr: (t) => err.push(t) };
|
|
16
|
+
const began = performance.now();
|
|
17
|
+
const code = await runCli(args, io);
|
|
18
|
+
const seconds = Math.round((performance.now() - began) / 100) / 10;
|
|
19
|
+
return { code, out: out.join(""), err: err.join(""), seconds };
|
|
20
|
+
}
|
|
21
|
+
const probe = (file) => {
|
|
22
|
+
const json = execFileSync("ffprobe", [
|
|
23
|
+
"-v",
|
|
24
|
+
"error",
|
|
25
|
+
"-select_streams",
|
|
26
|
+
"v:0",
|
|
27
|
+
"-show_entries",
|
|
28
|
+
"stream=codec_name,width,height,r_frame_rate:format=duration",
|
|
29
|
+
"-of",
|
|
30
|
+
"json",
|
|
31
|
+
file,
|
|
32
|
+
], { encoding: "utf8" });
|
|
33
|
+
return JSON.parse(json);
|
|
34
|
+
};
|
|
35
|
+
describe("real mk end to end on the demo fixture", () => {
|
|
36
|
+
rmSync(OUT, { recursive: true, force: true });
|
|
37
|
+
mkdirSync(OUT, { recursive: true });
|
|
38
|
+
const report = {};
|
|
39
|
+
it("validate then build", async () => {
|
|
40
|
+
expect((await mk("validate", FIXTURE)).code).toBe(0);
|
|
41
|
+
const built = await mk("build", FIXTURE, "--out", OUT);
|
|
42
|
+
expect(built.code, built.err).toBe(0);
|
|
43
|
+
expect(built.out).toMatch(/manifest sha256 [a-f0-9]{64}/);
|
|
44
|
+
});
|
|
45
|
+
it("still --at 3,10 writes two PNGs", async () => {
|
|
46
|
+
const still = await mk("still", FIXTURE, "--at", "3,10", "--out", OUT);
|
|
47
|
+
expect(still.code, still.err).toBe(0);
|
|
48
|
+
expect(still.out.match(/\.png/g)).toHaveLength(2);
|
|
49
|
+
for (const name of ["frame-00-at-3s.png", "frame-01-at-10s.png"]) {
|
|
50
|
+
expect(statSync(`${OUT}/stills/${name}`).size, name).toBeGreaterThan(MIN_PNG_BYTES);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
it("draft is 960x540 at 15 fps", async () => {
|
|
54
|
+
const draft = await mk("draft", FIXTURE, "--out", OUT);
|
|
55
|
+
expect(draft.code, draft.err).toBe(0);
|
|
56
|
+
report.draft = { seconds: draft.seconds, ...probe(`${OUT}/draft.mp4`) };
|
|
57
|
+
expect(JSON.stringify(report.draft)).toContain('"width":960');
|
|
58
|
+
});
|
|
59
|
+
it("verify PASSes the real draft against the storyboard", async () => {
|
|
60
|
+
const verified = await mk("verify", `${OUT}/draft.mp4`, "--storyboard", FIXTURE, "--draft");
|
|
61
|
+
expect(verified.out, verified.err).toMatch(/^PASS draft[.]mp4/);
|
|
62
|
+
expect(verified.code).toBe(0);
|
|
63
|
+
report.verifyDraft = { bytes: Buffer.byteLength(verified.out), seconds: verified.seconds };
|
|
64
|
+
});
|
|
65
|
+
it("verify FAILs (exit 5) when the draft is checked as a final render", async () => {
|
|
66
|
+
const verified = await mk("verify", `${OUT}/draft.mp4`, "--storyboard", FIXTURE);
|
|
67
|
+
expect(verified.code).toBe(5);
|
|
68
|
+
expect(verified.out).toContain("resolution-mismatch");
|
|
69
|
+
});
|
|
70
|
+
it("draft writes a render manifest that matches the build manifest and the mp4", async () => {
|
|
71
|
+
const manifest = JSON.parse(readFileSync(`${OUT}/render-manifest.json`, "utf8"));
|
|
72
|
+
const sha = (file) => createHash("sha256").update(readFileSync(file)).digest("hex");
|
|
73
|
+
expect(manifest).toMatchObject({ kind: "draft", verify: "not-run" });
|
|
74
|
+
expect(manifest.output.sha256).toBe(sha(`${OUT}/draft.mp4`));
|
|
75
|
+
expect(manifest.manifestSha256).toBe(sha(`${OUT}/manifest.json`));
|
|
76
|
+
expect(manifest.tools.ffmpeg).toMatch(/^[0-9]+[.][0-9]+/);
|
|
77
|
+
});
|
|
78
|
+
it("render is 1920x1080 at 30 fps H.264", async () => {
|
|
79
|
+
const render = await mk("render", FIXTURE, "--out", OUT);
|
|
80
|
+
expect(render.code, render.err).toBe(0);
|
|
81
|
+
expect(existsSync(`${OUT}/final.mp4`)).toBe(true);
|
|
82
|
+
report.render = { seconds: render.seconds, ...probe(`${OUT}/final.mp4`) };
|
|
83
|
+
expect(JSON.stringify(report.render)).toContain('"codec_name":"h264"');
|
|
84
|
+
expect(JSON.stringify(report.render)).toContain('"width":1920');
|
|
85
|
+
const verified = await mk("verify", `${OUT}/final.mp4`, "--storyboard", FIXTURE);
|
|
86
|
+
expect(verified.out, verified.err).toMatch(/^PASS final[.]mp4/);
|
|
87
|
+
expect(verified.code).toBe(0);
|
|
88
|
+
report.verifyFinal = { bytes: Buffer.byteLength(verified.out), seconds: verified.seconds };
|
|
89
|
+
writeFileSync(`${OUT}/smoke-report.json`, `${JSON.stringify(report, null, 2)}\n`);
|
|
90
|
+
});
|
|
91
|
+
});
|
package/dist/main.d.ts
ADDED
package/dist/main.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Host, Io, Runner } from "./types.ts";
|
|
2
|
+
/** Throws when an argument could be interpreted by cmd.exe instead of passed through verbatim. */
|
|
3
|
+
export declare function assertCmdSafe(args: readonly string[]): void;
|
|
4
|
+
export declare function launchPlan(cmd: string, args: readonly string[], platform?: string): [string, string[]];
|
|
5
|
+
export declare const runCommand: Runner;
|
|
6
|
+
/**
|
|
7
|
+
* Where the root manifest may live, in order: the directory being run in (a consumer
|
|
8
|
+
* install has only its own package.json), then the workspace root next to the sources.
|
|
9
|
+
*/
|
|
10
|
+
export declare function rootPackageJsonCandidates(cwd?: string): string[];
|
|
11
|
+
/** First candidate that reads and parses; undefined when none does, never a throw. */
|
|
12
|
+
export declare function readRootPackageJson(candidates?: readonly string[]): Promise<unknown>;
|
|
13
|
+
export declare function nodeHost(): Host;
|
|
14
|
+
export declare function nodeIo(): Io;
|
package/dist/node-io.js
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { access, copyFile, mkdir, readdir, readFile, rename, rm, writeFile, } from "node:fs/promises";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
8
|
+
/** Renders print a line per frame batch; the Node default of 1 MB would kill them. */
|
|
9
|
+
const MAX_OUTPUT_BYTES = 64 * 1024 * 1024;
|
|
10
|
+
/**
|
|
11
|
+
* Windows invariant: `pnpm` and `npx` are .cmd shims, and Node cannot spawn those directly
|
|
12
|
+
* without a shell. They go through `cmd.exe /d /s /c` and nothing else does (never `shell: true`).
|
|
13
|
+
* cmd.exe re-parses the command line, so every argument on that path is checked by
|
|
14
|
+
* `assertCmdSafe`; anything cmd treats as syntax is refused instead of escaped.
|
|
15
|
+
*/
|
|
16
|
+
const WINDOWS_SHIMS = new Set(["pnpm", "npx"]);
|
|
17
|
+
/** cmd.exe metacharacters (& | < > ^ " %), line breaks and NUL. */
|
|
18
|
+
const CMD_UNSAFE = /[&|<>^"%\r\n\0]/;
|
|
19
|
+
/** Throws when an argument could be interpreted by cmd.exe instead of passed through verbatim. */
|
|
20
|
+
export function assertCmdSafe(args) {
|
|
21
|
+
const bad = args.find((arg) => CMD_UNSAFE.test(arg));
|
|
22
|
+
if (bad !== undefined) {
|
|
23
|
+
throw new Error(`argument ${JSON.stringify(bad)} contains a cmd.exe metacharacter (& | < > ^ " %) or a line break`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/** Spawn failures (ENOENT, timeout) have no numeric exit code; report them as 127. */
|
|
27
|
+
function toResult(error, stdout, stderr) {
|
|
28
|
+
if (!error)
|
|
29
|
+
return { code: 0, stdout, stderr };
|
|
30
|
+
if (typeof error.code === "number")
|
|
31
|
+
return { code: error.code, stdout, stderr };
|
|
32
|
+
return { code: 127, stdout, stderr: error.message };
|
|
33
|
+
}
|
|
34
|
+
export function launchPlan(cmd, args, platform = process.platform) {
|
|
35
|
+
if (platform === "win32" && WINDOWS_SHIMS.has(cmd)) {
|
|
36
|
+
assertCmdSafe([cmd, ...args]);
|
|
37
|
+
return ["cmd.exe", ["/d", "/s", "/c", cmd, ...args]];
|
|
38
|
+
}
|
|
39
|
+
return [cmd, [...args]];
|
|
40
|
+
}
|
|
41
|
+
export const runCommand = (cmd, args, options = {}) => new Promise((resolve) => {
|
|
42
|
+
let plan;
|
|
43
|
+
try {
|
|
44
|
+
plan = launchPlan(cmd, args);
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
resolve({
|
|
48
|
+
code: 127,
|
|
49
|
+
stdout: "",
|
|
50
|
+
stderr: error instanceof Error ? error.message : String(error),
|
|
51
|
+
});
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const [file, fileArgs] = plan;
|
|
55
|
+
execFile(file, fileArgs, {
|
|
56
|
+
cwd: options.cwd,
|
|
57
|
+
env: { ...process.env, ...options.env },
|
|
58
|
+
timeout: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
59
|
+
maxBuffer: MAX_OUTPUT_BYTES,
|
|
60
|
+
windowsHide: true,
|
|
61
|
+
}, (error, stdout, stderr) => resolve(toResult(error, stdout, stderr)));
|
|
62
|
+
});
|
|
63
|
+
async function exists(path) {
|
|
64
|
+
try {
|
|
65
|
+
await access(path);
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
async function sha256File(path) {
|
|
73
|
+
try {
|
|
74
|
+
return createHash("sha256")
|
|
75
|
+
.update(await readFile(path))
|
|
76
|
+
.digest("hex");
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Where the root manifest may live, in order: the directory being run in (a consumer
|
|
84
|
+
* install has only its own package.json), then the workspace root next to the sources.
|
|
85
|
+
*/
|
|
86
|
+
export function rootPackageJsonCandidates(cwd = process.cwd()) {
|
|
87
|
+
return [
|
|
88
|
+
join(cwd, "package.json"),
|
|
89
|
+
fileURLToPath(new URL("../../../package.json", import.meta.url)),
|
|
90
|
+
];
|
|
91
|
+
}
|
|
92
|
+
/** First candidate that reads and parses; undefined when none does, never a throw. */
|
|
93
|
+
export async function readRootPackageJson(candidates = rootPackageJsonCandidates()) {
|
|
94
|
+
for (const path of candidates) {
|
|
95
|
+
try {
|
|
96
|
+
return JSON.parse(await readFile(path, "utf8"));
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
// Not there (or not JSON): try the next candidate.
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
export function nodeHost() {
|
|
105
|
+
return {
|
|
106
|
+
platform: process.platform,
|
|
107
|
+
nodeVersion: process.versions.node,
|
|
108
|
+
homeDir: homedir(),
|
|
109
|
+
env: process.env,
|
|
110
|
+
listDir: (path) => readdir(path),
|
|
111
|
+
async mkdir(path) {
|
|
112
|
+
await mkdir(path, { recursive: true });
|
|
113
|
+
},
|
|
114
|
+
readRootPackageJson,
|
|
115
|
+
readText: (path) => readFile(path, "utf8"),
|
|
116
|
+
async writeText(path, text) {
|
|
117
|
+
await mkdir(dirname(path), { recursive: true });
|
|
118
|
+
await writeFile(path, text, "utf8");
|
|
119
|
+
},
|
|
120
|
+
async writeTextAtomic(path, text) {
|
|
121
|
+
await mkdir(dirname(path), { recursive: true });
|
|
122
|
+
const temp = `${path}.${process.pid}.tmp`;
|
|
123
|
+
try {
|
|
124
|
+
await writeFile(temp, text, "utf8");
|
|
125
|
+
await rename(temp, path);
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
await rm(temp, { force: true });
|
|
129
|
+
throw error;
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
exists,
|
|
133
|
+
async copyFile(from, to) {
|
|
134
|
+
await mkdir(dirname(to), { recursive: true });
|
|
135
|
+
await copyFile(from, to);
|
|
136
|
+
},
|
|
137
|
+
async renameFile(from, to) {
|
|
138
|
+
await mkdir(dirname(to), { recursive: true });
|
|
139
|
+
await rename(from, to);
|
|
140
|
+
},
|
|
141
|
+
sha256File,
|
|
142
|
+
remove: (path) => rm(path, { force: true }),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
export function nodeIo() {
|
|
146
|
+
return {
|
|
147
|
+
stdout: (text) => process.stdout.write(text),
|
|
148
|
+
stderr: (text) => process.stderr.write(text),
|
|
149
|
+
run: runCommand,
|
|
150
|
+
host: nodeHost(),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Command } from "../types.ts";
|
|
2
|
+
/** Passthrough validator: GLB magic, version 2, declared length matches bytes. */
|
|
3
|
+
export declare const validateGlb: (bytes: Uint8Array) => {
|
|
4
|
+
ok: true;
|
|
5
|
+
bytes: number;
|
|
6
|
+
} | {
|
|
7
|
+
ok: false;
|
|
8
|
+
message: string;
|
|
9
|
+
};
|
|
10
|
+
/** `mk optimize-model`; `make` wrapper keeps the fit-command test pattern. */
|
|
11
|
+
export declare const makeOptimizeModelCommand: () => Command;
|
|
12
|
+
export declare const optimizeModelCommand: Command;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { parseFlags, requirePositional, textFlag, usageError } from "../args.js";
|
|
4
|
+
import { EXIT } from "../exit-codes.js";
|
|
5
|
+
const USAGE = `mk optimize-model <model.glb> [--out <file>]
|
|
6
|
+
|
|
7
|
+
Validate a binary glTF model and write an optimized copy (offline passthrough).
|
|
8
|
+
No native deps: checks the GLB magic, version and length, de-duplicates nothing
|
|
9
|
+
yet, and reports in/out byte sizes deterministically. Meshopt-class compression
|
|
10
|
+
is out of scope until a free, Windows-available, non-native tool is approved.
|
|
11
|
+
--out <file> output path (default: overwrite the input in place)
|
|
12
|
+
Prints one line: optimize-model ok | in N bytes -> out M bytes | <path>
|
|
13
|
+
`;
|
|
14
|
+
const FLAGS = {
|
|
15
|
+
out: { type: "string" },
|
|
16
|
+
};
|
|
17
|
+
const NAME = "optimize-model";
|
|
18
|
+
/** Passthrough validator: GLB magic, version 2, declared length matches bytes. */
|
|
19
|
+
export const validateGlb = (bytes) => {
|
|
20
|
+
if (bytes.length < 12 ||
|
|
21
|
+
bytes[0] !== 0x67 ||
|
|
22
|
+
bytes[1] !== 0x6c ||
|
|
23
|
+
bytes[2] !== 0x54 ||
|
|
24
|
+
bytes[3] !== 0x46) {
|
|
25
|
+
return { ok: false, message: "not a GLB model (missing glTF magic)" };
|
|
26
|
+
}
|
|
27
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
28
|
+
const version = view.getUint32(4, true);
|
|
29
|
+
if (version !== 2)
|
|
30
|
+
return { ok: false, message: `unsupported glTF version ${version}; need 2` };
|
|
31
|
+
const total = view.getUint32(8, true);
|
|
32
|
+
if (total !== bytes.length) {
|
|
33
|
+
return {
|
|
34
|
+
ok: false,
|
|
35
|
+
message: `GLB length mismatch: header says ${total} bytes, file has ${bytes.length}`,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
return { ok: true, bytes: bytes.length };
|
|
39
|
+
};
|
|
40
|
+
const fail = (message, io) => usageError(NAME, USAGE, message, io).code;
|
|
41
|
+
/** Input path plus output path, or an exit code on bad usage. */
|
|
42
|
+
const targetOf = (parsed, io) => {
|
|
43
|
+
const input = requirePositional(NAME, USAGE, parsed.positionals, "<model.glb>", io);
|
|
44
|
+
if (typeof input !== "string")
|
|
45
|
+
return input.code;
|
|
46
|
+
if (parsed.positionals.length > 1)
|
|
47
|
+
return fail(`unexpected argument "${parsed.positionals[1]}"`, io);
|
|
48
|
+
return { input, out: textFlag(parsed.flags, "out") ?? input };
|
|
49
|
+
};
|
|
50
|
+
/** File bytes, or an exit code when unreadable. */
|
|
51
|
+
const readOf = async (io, input) => {
|
|
52
|
+
// Byte-correct path: raw bytes off disk (readText utf8-decodes binary GLB).
|
|
53
|
+
try {
|
|
54
|
+
const buf = await readFile(input);
|
|
55
|
+
return { bytes: new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength), viaFs: true };
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// In-memory fakes (tests) fall through to Host.
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
const text = await io.host.readText(input);
|
|
62
|
+
return { bytes: Uint8Array.from(Buffer.from(text, "binary")), viaFs: false };
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return fail(`cannot read "${input}"`, io);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
const report = (io, bytes, out) => {
|
|
69
|
+
io.stdout(`optimize-model ok | in ${bytes} bytes -> out ${bytes} bytes (saved 0%) | ${out}\n`);
|
|
70
|
+
};
|
|
71
|
+
/** Byte-preserving write: real FS when read came off disk, Host for fakes. */
|
|
72
|
+
const writeOf = async (io, out, bytes, viaFs) => {
|
|
73
|
+
if (viaFs) {
|
|
74
|
+
await mkdir(dirname(out), { recursive: true });
|
|
75
|
+
const temp = `${out}.${process.pid}.tmp`;
|
|
76
|
+
try {
|
|
77
|
+
await writeFile(temp, bytes);
|
|
78
|
+
await rename(temp, out);
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
await rm(temp, { force: true });
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
await io.host.writeTextAtomic(out, Buffer.from(bytes).toString("binary"));
|
|
87
|
+
};
|
|
88
|
+
/** `mk optimize-model`; `make` wrapper keeps the fit-command test pattern. */
|
|
89
|
+
export const makeOptimizeModelCommand = () => ({
|
|
90
|
+
name: NAME,
|
|
91
|
+
summary: "validate a GLB model and write an optimized copy with a size report",
|
|
92
|
+
async run(args, io) {
|
|
93
|
+
const parsed = parseFlags(NAME, USAGE, args, FLAGS, io);
|
|
94
|
+
if (parsed.kind === "exit")
|
|
95
|
+
return parsed.code;
|
|
96
|
+
const target = targetOf(parsed, io);
|
|
97
|
+
if (typeof target === "number")
|
|
98
|
+
return target;
|
|
99
|
+
const read = await readOf(io, target.input);
|
|
100
|
+
if (typeof read === "number")
|
|
101
|
+
return read;
|
|
102
|
+
const checked = validateGlb(read.bytes);
|
|
103
|
+
if (!checked.ok)
|
|
104
|
+
return fail(checked.message, io);
|
|
105
|
+
await writeOf(io, target.out, read.bytes, read.viaFs);
|
|
106
|
+
report(io, checked.bytes, target.out);
|
|
107
|
+
return EXIT.OK;
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
export const optimizeModelCommand = makeOptimizeModelCommand();
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { basename, dirname, join } from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { parseFlags, requirePositional, usageError } from "../args.js";
|
|
4
|
+
import { EXIT } from "../exit-codes.js";
|
|
5
|
+
const USAGE = `mk packs [show <id>] [--json]
|
|
6
|
+
|
|
7
|
+
List qualified style packs, or show one pack's guide.
|
|
8
|
+
Resolves the installed packs payload first, repo fallback.
|
|
9
|
+
--json one compact JSON line instead of text
|
|
10
|
+
`;
|
|
11
|
+
const FLAGS = {
|
|
12
|
+
json: { type: "boolean" },
|
|
13
|
+
};
|
|
14
|
+
const ROW = /^\| ([a-z0-9-]+) \| .*? \| ([^\s|]+\.md) \|/gm;
|
|
15
|
+
const parseIndex = (text) => [...text.matchAll(ROW)].map((m) => ({ id: m[1] ?? "", guide: m[2] ?? "" }));
|
|
16
|
+
/** Installed payload first (`packages/core/packs`), then repo checkouts walking up from cwd. */
|
|
17
|
+
async function findRoot(io) {
|
|
18
|
+
const fromModule = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "packs");
|
|
19
|
+
const roots = [fromModule, join(process.cwd(), "docs", "packs")];
|
|
20
|
+
let dir = process.cwd();
|
|
21
|
+
for (let i = 0; i < 8; i++) {
|
|
22
|
+
dir = dirname(dir);
|
|
23
|
+
roots.push(join(dir, "docs", "packs"));
|
|
24
|
+
}
|
|
25
|
+
for (const root of roots) {
|
|
26
|
+
if (await io.host.exists(join(root, "INDEX.md")))
|
|
27
|
+
return root;
|
|
28
|
+
}
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
async function readSummary(io, root, id) {
|
|
32
|
+
try {
|
|
33
|
+
const parsed = JSON.parse(await io.host.readText(join(root, id, "pack.json")));
|
|
34
|
+
const summary = typeof parsed === "object" && parsed !== null && "summary" in parsed
|
|
35
|
+
? parsed.summary
|
|
36
|
+
: undefined;
|
|
37
|
+
return typeof summary === "string" ? summary : "";
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return "";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
async function loadRows(io) {
|
|
44
|
+
const root = await findRoot(io);
|
|
45
|
+
if (!root)
|
|
46
|
+
return {
|
|
47
|
+
code: usageError("packs", USAGE, "no packs payload found (installed packs/ or repo docs/packs)", io).code,
|
|
48
|
+
};
|
|
49
|
+
const rows = parseIndex(await io.host.readText(join(root, "INDEX.md")));
|
|
50
|
+
if (rows.length === 0)
|
|
51
|
+
return { code: usageError("packs", USAGE, "pack index lists no packs", io).code };
|
|
52
|
+
return { root, rows };
|
|
53
|
+
}
|
|
54
|
+
async function runList(io, root, rows, json) {
|
|
55
|
+
const items = await Promise.all(rows.map(async (row) => ({ id: row.id, summary: await readSummary(io, root, row.id) })));
|
|
56
|
+
if (json)
|
|
57
|
+
io.stdout(`${JSON.stringify(items)}\n`);
|
|
58
|
+
else
|
|
59
|
+
for (const item of items)
|
|
60
|
+
io.stdout(item.summary ? `${item.id} — ${item.summary}\n` : `${item.id}\n`);
|
|
61
|
+
return EXIT.OK;
|
|
62
|
+
}
|
|
63
|
+
async function runShow(io, root, rows, json, positionals) {
|
|
64
|
+
const id = requirePositional("packs", USAGE, positionals, "<id>", io);
|
|
65
|
+
if (typeof id !== "string")
|
|
66
|
+
return id.code;
|
|
67
|
+
const row = rows.find((r) => r.id === id);
|
|
68
|
+
if (!row) {
|
|
69
|
+
const known = rows.map((r) => r.id).join("|");
|
|
70
|
+
return usageError("packs", USAGE, `unknown pack "${id}"; use one of: ${known}`, io).code;
|
|
71
|
+
}
|
|
72
|
+
const guideFile = join(root, row.id, basename(row.guide));
|
|
73
|
+
let guide;
|
|
74
|
+
try {
|
|
75
|
+
guide = await io.host.readText(guideFile);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return usageError("packs", USAGE, `guide missing for pack "${id}" (${guideFile})`, io).code;
|
|
79
|
+
}
|
|
80
|
+
const summary = await readSummary(io, root, row.id);
|
|
81
|
+
if (json)
|
|
82
|
+
io.stdout(`${JSON.stringify({ id, summary, guide: row.guide, text: guide })}\n`);
|
|
83
|
+
else
|
|
84
|
+
io.stdout(guide.endsWith("\n") ? guide : `${guide}\n`);
|
|
85
|
+
return EXIT.OK;
|
|
86
|
+
}
|
|
87
|
+
export const packsCommand = {
|
|
88
|
+
name: "packs",
|
|
89
|
+
summary: "list style packs, or show one pack guide; installed payload first, repo fallback",
|
|
90
|
+
async run(args, io) {
|
|
91
|
+
const parsed = parseFlags("packs", USAGE, args, FLAGS, io);
|
|
92
|
+
if (parsed.kind === "exit")
|
|
93
|
+
return parsed.code;
|
|
94
|
+
const { flags, positionals } = parsed;
|
|
95
|
+
const loaded = await loadRows(io);
|
|
96
|
+
if ("code" in loaded)
|
|
97
|
+
return loaded.code;
|
|
98
|
+
const [sub, ...rest] = positionals;
|
|
99
|
+
if (sub === undefined || sub === "list") {
|
|
100
|
+
if (rest.length > 0)
|
|
101
|
+
return usageError("packs", USAGE, "list takes no id", io).code;
|
|
102
|
+
return runList(io, loaded.root, loaded.rows, flags.json === true);
|
|
103
|
+
}
|
|
104
|
+
if (sub !== "show")
|
|
105
|
+
return usageError("packs", USAGE, `unknown action "${sub}"; use: mk packs [show <id>]`, io)
|
|
106
|
+
.code;
|
|
107
|
+
return runShow(io, loaded.root, loaded.rows, flags.json === true, rest);
|
|
108
|
+
},
|
|
109
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** The RFC 6902 subset `mk patch` supports. */
|
|
2
|
+
export type PatchOp = {
|
|
3
|
+
op: "add" | "replace" | "test";
|
|
4
|
+
path: string;
|
|
5
|
+
value: unknown;
|
|
6
|
+
} | {
|
|
7
|
+
op: "remove";
|
|
8
|
+
path: string;
|
|
9
|
+
} | {
|
|
10
|
+
op: "move";
|
|
11
|
+
from: string;
|
|
12
|
+
path: string;
|
|
13
|
+
};
|
|
14
|
+
export type ParsedOps = {
|
|
15
|
+
ok: true;
|
|
16
|
+
ops: PatchOp[];
|
|
17
|
+
} | {
|
|
18
|
+
ok: false;
|
|
19
|
+
message: string;
|
|
20
|
+
};
|
|
21
|
+
export type PatchResult = {
|
|
22
|
+
ok: true;
|
|
23
|
+
value: unknown;
|
|
24
|
+
changed: string[];
|
|
25
|
+
} | {
|
|
26
|
+
ok: false;
|
|
27
|
+
index: number;
|
|
28
|
+
message: string;
|
|
29
|
+
};
|
|
30
|
+
/** Checks the shape of an ops document; every message names the op index. */
|
|
31
|
+
export declare function parseOps(input: unknown): ParsedOps;
|
|
32
|
+
/**
|
|
33
|
+
* Applies ops in order to a copy of `doc`; the input is never mutated. Stops at the first op that
|
|
34
|
+
* cannot apply and names its index. Pure: validation of the result is the caller's job.
|
|
35
|
+
*/
|
|
36
|
+
export declare function applyPatch(doc: unknown, ops: readonly PatchOp[]): PatchResult;
|