@glissade/cli 0.30.0 → 0.31.0-pre.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 (3) hide show
  1. package/dist/cli.js +36 -1
  2. package/dist/migrate.js +241 -0
  3. package/package.json +10 -10
package/dist/cli.js CHANGED
@@ -78,6 +78,8 @@ const USAGE = `usage:
78
78
  gs cache verify <scene-module> [--range a..b] [--sample <n>] assert cache hits == cold renders (§3.5)
79
79
  gs mcp <scene-module> start an MCP stdio server for this scene: describe / list_targets / apply_patch / undo / render_frame (the AI-native write layer)
80
80
  gs build [filter...] [--config <glissade.config.ts>] [--explain] content-graph DAG runner: narrate→sfx→loudness→render per scene, runs ONLY the stale subtree
81
+ gs describe [--out <api.json>] [--examples] snapshot THIS engine's describe() API manifest (stdout, or --out to a file) — the input to gs migrate
82
+ gs migrate <baseline-api.json> [--json] diff a saved API manifest against the current engine: moved imports / removed / added / changed, with a suggested fix per breaking item (advisory; never rewrites your files)
81
83
 
82
84
  render options:
83
85
  --out <path> output directory for a PNG sequence, or .mp4/.webm (needs ffmpeg). default: ./out
@@ -168,7 +170,7 @@ narration-lint options (lint the committed *.narration.timing.json + the real ca
168
170
  `;
169
171
  async function main() {
170
172
  const [command, ...rest] = process.argv.slice(2);
171
- if (command !== "render" && command !== "diff" && command !== "verify-determinism" && command !== "dev" && command !== "import" && command !== "narrate" && command !== "narration-lint" && command !== "sfx" && command !== "prepare" && command !== "measure-loudness" && command !== "fonts" && command !== "cache" && command !== "mcp" && command !== "build") {
173
+ if (command !== "render" && command !== "diff" && command !== "verify-determinism" && command !== "dev" && command !== "import" && command !== "narrate" && command !== "narration-lint" && command !== "sfx" && command !== "prepare" && command !== "measure-loudness" && command !== "fonts" && command !== "cache" && command !== "mcp" && command !== "build" && command !== "describe" && command !== "migrate") {
172
174
  console.error(USAGE);
173
175
  process.exit(command === void 0 || command === "help" || command === "--help" ? 0 : 1);
174
176
  }
@@ -240,6 +242,39 @@ async function main() {
240
242
  }
241
243
  return;
242
244
  }
245
+ if (command === "describe") {
246
+ const { flags: df } = parseArgs(rest);
247
+ const { describe } = await import("@glissade/scene/describe");
248
+ if (df.has("examples")) await import("@glissade/scene/examples");
249
+ const manifest = describe(df.has("examples") ? { examples: true } : {});
250
+ const json = `${JSON.stringify(manifest, null, 2)}\n`;
251
+ const outPath = df.get("out");
252
+ if (outPath) {
253
+ const { writeFileSync } = await import("node:fs");
254
+ writeFileSync(outPath, json);
255
+ process.stderr.write(`gs describe: wrote ${manifest.version} API manifest → ${outPath}\n`);
256
+ } else process.stdout.write(json);
257
+ return;
258
+ }
259
+ if (command === "migrate") {
260
+ const { positional: mp, flags: mf } = parseArgs(rest);
261
+ const baselinePath = mp[0];
262
+ if (!baselinePath) fail(`gs migrate needs <baseline-api.json> (a manifest from an older 'gs describe --out')\n${USAGE}`);
263
+ const { readFileSync } = await import("node:fs");
264
+ const { describe } = await import("@glissade/scene/describe");
265
+ const { diffManifests, formatReport } = await import("./migrate.js");
266
+ let baseline;
267
+ try {
268
+ baseline = JSON.parse(readFileSync(baselinePath, "utf8"));
269
+ } catch (err) {
270
+ fail(`could not read baseline manifest '${baselinePath}': ${err instanceof Error ? err.message : String(err)}`);
271
+ }
272
+ if (typeof baseline.version !== "string" || baseline.nodes === void 0) fail(`'${baselinePath}' is not a describe() API manifest (missing version/nodes) — did you point at the right file?`);
273
+ const report = diffManifests(baseline, describe());
274
+ if (mf.has("json")) process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
275
+ else process.stdout.write(`${formatReport(report)}\n`);
276
+ return;
277
+ }
243
278
  const { positional, flags } = parseArgs(rest);
244
279
  const modulePath = positional[0];
245
280
  if (!modulePath) fail(`missing ${command === "import" ? "<lottie.json|asset.svg>" : "<scene-module>"}\n${USAGE}`);
@@ -0,0 +1,241 @@
1
+ //#region src/migrate.ts
2
+ /** Stable-sorted keys of a plain record (deterministic report order — no Object.keys insertion-order surprises). */
3
+ function keys(obj) {
4
+ return obj ? Object.keys(obj).sort() : [];
5
+ }
6
+ /** Compare one node's prop against its old shape; push any material changes. */
7
+ function diffProp(nodeName, propName, from, to, out) {
8
+ const id = `${nodeName}.${propName}`;
9
+ if (from.type !== to.type) out.push({
10
+ kind: "changed",
11
+ category: "prop",
12
+ name: id,
13
+ breaking: true,
14
+ detail: `value type ${from.type} → ${to.type}`,
15
+ action: `a Track on ${id} now expects a ${to.type} value — VERIFY every keyframe`
16
+ });
17
+ if (from.animatable && !to.animatable) out.push({
18
+ kind: "changed",
19
+ category: "prop",
20
+ name: id,
21
+ breaking: true,
22
+ detail: `no longer animatable (was driven by target '${from.target ?? id}')`,
23
+ action: `remove or re-home the Track targeting '${from.target ?? id}'`
24
+ });
25
+ else if (!from.animatable && to.animatable) out.push({
26
+ kind: "changed",
27
+ category: "prop",
28
+ name: id,
29
+ breaking: false,
30
+ detail: `now animatable via target '${to.target ?? id}'`
31
+ });
32
+ }
33
+ /**
34
+ * Diff two describe() manifests into a structured, deterministic migration
35
+ * report. Pure: same (from, to) → same report, in stable name order. This is the
36
+ * whole engine — the CLI just loads the two manifests and formats this.
37
+ */
38
+ function diffManifests(from, to) {
39
+ const out = [];
40
+ for (const name of keys(from.nodes)) {
41
+ const a = from.nodes[name];
42
+ const b = to.nodes[name];
43
+ if (a === void 0) continue;
44
+ if (b === void 0) {
45
+ out.push({
46
+ kind: "removed",
47
+ category: "node",
48
+ name,
49
+ breaking: true,
50
+ detail: `node type removed (was imported from ${a.subpath ?? "@glissade/scene"})`,
51
+ action: `this node no longer exists — replace it or pin the last engine that had it`
52
+ });
53
+ continue;
54
+ }
55
+ if ((a.subpath ?? "") !== (b.subpath ?? "")) {
56
+ const toPath = b.subpath ?? "@glissade/scene";
57
+ out.push({
58
+ kind: "moved",
59
+ category: "node",
60
+ name,
61
+ breaking: true,
62
+ detail: `import moved ${a.subpath ?? "@glissade/scene"} → ${toPath}`,
63
+ action: `import { ${name} } from '${toPath}'`
64
+ });
65
+ }
66
+ for (const p of keys(a.props)) {
67
+ const pa = a.props[p];
68
+ const pb = b.props[p];
69
+ if (pa === void 0) continue;
70
+ if (pb === void 0) {
71
+ out.push({
72
+ kind: "removed",
73
+ category: "prop",
74
+ name: `${name}.${p}`,
75
+ breaking: true,
76
+ detail: `prop removed`,
77
+ action: `remove '${p}' from ${name}(...) — it's no longer a recognized prop`
78
+ });
79
+ continue;
80
+ }
81
+ diffProp(name, p, pa, pb, out);
82
+ }
83
+ for (const p of keys(b.props)) if (a.props[p] === void 0) out.push({
84
+ kind: "added",
85
+ category: "prop",
86
+ name: `${name}.${p}`,
87
+ breaking: false,
88
+ detail: `new prop (${b.props[p]?.animatable ? "animatable" : "construction-only"})`
89
+ });
90
+ }
91
+ for (const name of keys(to.nodes)) if (from.nodes[name] === void 0) {
92
+ const sp = to.nodes[name]?.subpath ?? "@glissade/scene";
93
+ out.push({
94
+ kind: "added",
95
+ category: "node",
96
+ name,
97
+ breaking: false,
98
+ detail: `new node type (import from ${sp})`
99
+ });
100
+ }
101
+ const fromHelpers = new Map(from.helpers.map((h) => [h.name, h]));
102
+ const toHelpers = new Map(to.helpers.map((h) => [h.name, h]));
103
+ for (const name of [...fromHelpers.keys()].sort()) {
104
+ const a = fromHelpers.get(name);
105
+ const b = toHelpers.get(name);
106
+ if (b === void 0) {
107
+ out.push({
108
+ kind: "removed",
109
+ category: "helper",
110
+ name,
111
+ breaking: true,
112
+ detail: `helper removed (was imported from ${a.import})`,
113
+ action: `this helper no longer exists — replace it or pin the last engine that had it`
114
+ });
115
+ continue;
116
+ }
117
+ if (a.import !== b.import) out.push({
118
+ kind: "moved",
119
+ category: "helper",
120
+ name,
121
+ breaking: true,
122
+ detail: `import moved ${a.import} → ${b.import}`,
123
+ action: `import { ${name} } from '${b.import}'`
124
+ });
125
+ if (a.usage !== b.usage) out.push({
126
+ kind: "changed",
127
+ category: "helper",
128
+ name,
129
+ breaking: true,
130
+ detail: `signature changed: ${a.usage} → ${b.usage}`,
131
+ action: `update call sites of ${name}(...) to the new shape — VERIFY`
132
+ });
133
+ }
134
+ for (const name of [...toHelpers.keys()].sort()) if (!fromHelpers.has(name)) out.push({
135
+ kind: "added",
136
+ category: "helper",
137
+ name,
138
+ breaking: false,
139
+ detail: `new helper (import from ${toHelpers.get(name).import})`
140
+ });
141
+ const fromB = new Map(from.builder.methods.map((m) => [m.name, m]));
142
+ const toB = new Map(to.builder.methods.map((m) => [m.name, m]));
143
+ for (const name of [...fromB.keys()].sort()) {
144
+ const a = fromB.get(name);
145
+ const b = toB.get(name);
146
+ if (b === void 0) {
147
+ out.push({
148
+ kind: "removed",
149
+ category: "builder",
150
+ name: `tl.${name}`,
151
+ breaking: true,
152
+ detail: `builder method removed`,
153
+ action: `tl.${name}(...) no longer exists — replace it`
154
+ });
155
+ continue;
156
+ }
157
+ if (a.signature !== b.signature) out.push({
158
+ kind: "changed",
159
+ category: "builder",
160
+ name: `tl.${name}`,
161
+ breaking: true,
162
+ detail: `signature changed: ${a.signature} → ${b.signature}`,
163
+ action: `update tl.${name}(...) call sites to the new signature — VERIFY`
164
+ });
165
+ }
166
+ for (const name of [...toB.keys()].sort()) if (!fromB.has(name)) out.push({
167
+ kind: "added",
168
+ category: "builder",
169
+ name: `tl.${name}`,
170
+ breaking: false,
171
+ detail: `new builder method`
172
+ });
173
+ diffStringSet("valueType", from.valueTypes, to.valueTypes, out);
174
+ diffStringSet("easing", from.easings, to.easings, out);
175
+ diffStringSet("subpath", keys(from.subpaths), keys(to.subpaths), out);
176
+ const breaking = out.filter((c) => c.breaking).length;
177
+ return {
178
+ from: from.version,
179
+ to: to.version,
180
+ changes: out,
181
+ summary: {
182
+ breaking,
183
+ additive: out.length - breaking,
184
+ total: out.length
185
+ }
186
+ };
187
+ }
188
+ /** Diff two flat string lists (value types / easings / subpath entries): removed = breaking, added = additive. */
189
+ function diffStringSet(category, from, to, out) {
190
+ const toSet = new Set(to);
191
+ const fromSet = new Set(from);
192
+ for (const name of [...from].sort()) if (!toSet.has(name)) out.push({
193
+ kind: "removed",
194
+ category,
195
+ name,
196
+ breaking: true,
197
+ detail: `${category} removed`,
198
+ action: `'${name}' is no longer a registered ${category} — replace it`
199
+ });
200
+ for (const name of [...to].sort()) if (!fromSet.has(name)) out.push({
201
+ kind: "added",
202
+ category,
203
+ name,
204
+ breaking: false,
205
+ detail: `new ${category}`
206
+ });
207
+ }
208
+ const KIND_MARK = {
209
+ moved: "→",
210
+ removed: "✗",
211
+ changed: "~",
212
+ added: "+"
213
+ };
214
+ /** Render a report as grouped, human-readable text (the default CLI output). */
215
+ function formatReport(r) {
216
+ const lines = [];
217
+ lines.push(`gs migrate: ${r.from} → ${r.to}`);
218
+ if (r.changes.length === 0) {
219
+ lines.push(" no API changes — nothing to migrate. ✓");
220
+ return lines.join("\n");
221
+ }
222
+ lines.push(` ${r.summary.breaking} breaking · ${r.summary.additive} additive · ${r.summary.total} total`);
223
+ const breaking = r.changes.filter((c) => c.breaking);
224
+ const additive = r.changes.filter((c) => !c.breaking);
225
+ if (breaking.length > 0) {
226
+ lines.push("");
227
+ lines.push("BREAKING — action needed:");
228
+ for (const c of breaking) {
229
+ lines.push(` ${KIND_MARK[c.kind]} [${c.category}] ${c.name}: ${c.detail}`);
230
+ if (c.action !== void 0) lines.push(` ↳ ${c.action}`);
231
+ }
232
+ }
233
+ if (additive.length > 0) {
234
+ lines.push("");
235
+ lines.push("ADDITIVE — new in this engine:");
236
+ for (const c of additive) lines.push(` ${KIND_MARK[c.kind]} [${c.category}] ${c.name}: ${c.detail}`);
237
+ }
238
+ return lines.join("\n");
239
+ }
240
+ //#endregion
241
+ export { diffManifests, formatReport };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glissade/cli",
3
- "version": "0.30.0",
3
+ "version": "0.31.0-pre.0",
4
4
  "description": "glissade CLI: headless rendering via backend-skia (+ FFmpeg mux).",
5
5
  "license": "Apache-2.0",
6
6
  "engines": {
@@ -28,15 +28,15 @@
28
28
  "@napi-rs/canvas": "^0.1.65",
29
29
  "esbuild": "0.28.0",
30
30
  "jiti": "^2.4.2",
31
- "@glissade/backend-skia": "0.30.0",
32
- "@glissade/core": "0.30.0",
33
- "@glissade/interact": "0.30.0",
34
- "@glissade/lottie": "0.30.0",
35
- "@glissade/narrate": "0.30.0",
36
- "@glissade/player": "0.30.0",
37
- "@glissade/scene": "0.30.0",
38
- "@glissade/sfx": "0.30.0",
39
- "@glissade/svg": "0.30.0"
31
+ "@glissade/backend-skia": "0.31.0-pre.0",
32
+ "@glissade/core": "0.31.0-pre.0",
33
+ "@glissade/interact": "0.31.0-pre.0",
34
+ "@glissade/lottie": "0.31.0-pre.0",
35
+ "@glissade/narrate": "0.31.0-pre.0",
36
+ "@glissade/player": "0.31.0-pre.0",
37
+ "@glissade/scene": "0.31.0-pre.0",
38
+ "@glissade/sfx": "0.31.0-pre.0",
39
+ "@glissade/svg": "0.31.0-pre.0"
40
40
  },
41
41
  "repository": {
42
42
  "type": "git",