infernoflow 0.19.0 → 0.21.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.
@@ -0,0 +1,383 @@
1
+ /**
2
+ * infernoflow snapshot
3
+ *
4
+ * Named, timestamped snapshots of the full capability contract.
5
+ * Stored in inferno/snapshots/ — travel with the repo.
6
+ *
7
+ * Like git tags, but for the capability contract specifically.
8
+ *
9
+ * Usage:
10
+ * infernoflow snapshot save <name> Save current contract as a named snapshot
11
+ * infernoflow snapshot list List all snapshots
12
+ * infernoflow snapshot show <name> Print a snapshot's capabilities
13
+ * infernoflow snapshot diff <name1> <name2> Diff two snapshots (or name vs current)
14
+ * infernoflow snapshot restore <name> Overwrite contract with a snapshot
15
+ * infernoflow snapshot delete <name> Delete a snapshot
16
+ * infernoflow snapshot --json Machine-readable output on any subcommand
17
+ *
18
+ * Snapshot file format (inferno/snapshots/<name>.json):
19
+ * {
20
+ * "name": "v1.2-release",
21
+ * "savedAt": "2025-06-01T12:00:00Z",
22
+ * "capabilities": [...],
23
+ * "meta": { "version": "1.2.0", "capabilityCount": 12 }
24
+ * }
25
+ */
26
+
27
+ import * as fs from "node:fs";
28
+ import * as path from "node:path";
29
+ import { done, warn, info, bold, cyan, gray, green, yellow, red } from "../ui/output.mjs";
30
+
31
+ const SNAPSHOTS_DIR = "snapshots";
32
+
33
+ // ── Storage ───────────────────────────────────────────────────────────────────
34
+
35
+ function snapshotsDir(infernoDir) {
36
+ return path.join(infernoDir, SNAPSHOTS_DIR);
37
+ }
38
+
39
+ function snapshotPath(infernoDir, name) {
40
+ return path.join(infernoDir, SNAPSHOTS_DIR, `${name}.json`);
41
+ }
42
+
43
+ function ensureSnapshotsDir(infernoDir) {
44
+ const d = snapshotsDir(infernoDir);
45
+ if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true });
46
+ return d;
47
+ }
48
+
49
+ function listSnapshots(infernoDir) {
50
+ const d = snapshotsDir(infernoDir);
51
+ if (!fs.existsSync(d)) return [];
52
+ return fs.readdirSync(d)
53
+ .filter(f => f.endsWith(".json"))
54
+ .map(f => {
55
+ try { return JSON.parse(fs.readFileSync(path.join(d, f), "utf8")); } catch { return null; }
56
+ })
57
+ .filter(Boolean)
58
+ .sort((a, b) => new Date(b.savedAt) - new Date(a.savedAt));
59
+ }
60
+
61
+ function readSnapshot(infernoDir, name) {
62
+ const p = snapshotPath(infernoDir, name);
63
+ if (!fs.existsSync(p)) return null;
64
+ try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch { return null; }
65
+ }
66
+
67
+ function readContract(infernoDir) {
68
+ for (const f of ["contract.json", "capabilities.json"]) {
69
+ const p = path.join(infernoDir, f);
70
+ if (!fs.existsSync(p)) continue;
71
+ try { return { file: f, data: JSON.parse(fs.readFileSync(p, "utf8")) }; } catch {}
72
+ }
73
+ return null;
74
+ }
75
+
76
+ function normaliseCaps(contract) {
77
+ const raw = contract?.capabilities || contract?.data?.capabilities || contract || [];
78
+ return raw.map(c => (typeof c === "string" ? { id: c } : c));
79
+ }
80
+
81
+ function readPackageVersion(cwd) {
82
+ try { return JSON.parse(fs.readFileSync(path.join(cwd, "package.json"), "utf8")).version || ""; } catch { return ""; }
83
+ }
84
+
85
+ // ── Diff engine ───────────────────────────────────────────────────────────────
86
+
87
+ function diffCaps(capsBefore, capsAfter) {
88
+ const beforeIds = new Map(capsBefore.map(c => [c.id, c]));
89
+ const afterIds = new Map(capsAfter.map(c => [c.id, c]));
90
+
91
+ const added = capsAfter.filter(c => !beforeIds.has(c.id));
92
+ const removed = capsBefore.filter(c => !afterIds.has(c.id));
93
+ const changed = capsAfter.filter(c => {
94
+ const prev = beforeIds.get(c.id);
95
+ if (!prev) return false;
96
+ return JSON.stringify(c) !== JSON.stringify(prev);
97
+ });
98
+
99
+ return { added, removed, changed };
100
+ }
101
+
102
+ // ── Sub-commands ──────────────────────────────────────────────────────────────
103
+
104
+ function subcmdSave(name, infernoDir, cwd, jsonMode) {
105
+ if (!name || name.startsWith("--")) {
106
+ const msg = "Usage: infernoflow snapshot save <name>";
107
+ if (jsonMode) console.log(JSON.stringify({ ok: false, error: msg }));
108
+ else warn(msg);
109
+ return;
110
+ }
111
+
112
+ // Validate name (no spaces, no slashes)
113
+ if (!/^[a-zA-Z0-9._-]+$/.test(name)) {
114
+ const msg = `Invalid snapshot name "${name}" — use letters, digits, dots, dashes, underscores only`;
115
+ if (jsonMode) console.log(JSON.stringify({ ok: false, error: msg }));
116
+ else warn(msg);
117
+ return;
118
+ }
119
+
120
+ const contract = readContract(infernoDir);
121
+ if (!contract) {
122
+ const msg = "No contract found. Run: infernoflow init";
123
+ if (jsonMode) console.log(JSON.stringify({ ok: false, error: msg }));
124
+ else warn(msg);
125
+ return;
126
+ }
127
+
128
+ ensureSnapshotsDir(infernoDir);
129
+
130
+ const caps = normaliseCaps(contract.data);
131
+ const snapshot = {
132
+ name,
133
+ savedAt: new Date().toISOString(),
134
+ capabilities: caps,
135
+ meta: {
136
+ version: readPackageVersion(cwd),
137
+ capabilityCount: caps.length,
138
+ contractFile: contract.file,
139
+ },
140
+ };
141
+
142
+ const p = snapshotPath(infernoDir, name);
143
+ const overwriting = fs.existsSync(p);
144
+ fs.writeFileSync(p, JSON.stringify(snapshot, null, 2) + "\n");
145
+
146
+ if (jsonMode) {
147
+ console.log(JSON.stringify({ ok: true, action: "saved", name, capabilities: caps.length }));
148
+ } else {
149
+ done(`${overwriting ? "Updated" : "Saved"} snapshot ${bold(cyan(name))} — ${bold(String(caps.length))} capabilities`);
150
+ console.log();
151
+ }
152
+ }
153
+
154
+ function subcmdList(infernoDir, jsonMode) {
155
+ const snapshots = listSnapshots(infernoDir);
156
+
157
+ if (jsonMode) {
158
+ console.log(JSON.stringify({ ok: true, snapshots: snapshots.map(s => ({ name: s.name, savedAt: s.savedAt, capabilities: s.meta?.capabilityCount ?? s.capabilities?.length ?? 0 })) }));
159
+ return;
160
+ }
161
+
162
+ if (!snapshots.length) {
163
+ info("No snapshots yet. Use: infernoflow snapshot save <name>");
164
+ return;
165
+ }
166
+
167
+ console.log();
168
+ console.log(` ${bold(`${snapshots.length} snapshot${snapshots.length !== 1 ? "s" : ""}`)}`);
169
+ console.log();
170
+
171
+ const w = Math.max(...snapshots.map(s => s.name.length), 8) + 2;
172
+ for (const s of snapshots) {
173
+ const date = new Date(s.savedAt).toLocaleString();
174
+ const caps = s.meta?.capabilityCount ?? s.capabilities?.length ?? "?";
175
+ console.log(` ${bold(s.name.padEnd(w))} ${gray(date)} ${cyan(String(caps))} caps`);
176
+ }
177
+ console.log();
178
+ }
179
+
180
+ function subcmdShow(name, infernoDir, jsonMode) {
181
+ if (!name || name.startsWith("--")) {
182
+ const msg = "Usage: infernoflow snapshot show <name>";
183
+ if (jsonMode) console.log(JSON.stringify({ ok: false, error: msg }));
184
+ else warn(msg);
185
+ return;
186
+ }
187
+
188
+ const snap = readSnapshot(infernoDir, name);
189
+ if (!snap) {
190
+ const msg = `Snapshot not found: ${name}`;
191
+ if (jsonMode) console.log(JSON.stringify({ ok: false, error: msg }));
192
+ else warn(msg);
193
+ return;
194
+ }
195
+
196
+ if (jsonMode) {
197
+ console.log(JSON.stringify({ ok: true, snapshot: snap }));
198
+ return;
199
+ }
200
+
201
+ console.log();
202
+ console.log(` ${bold("Snapshot:")} ${cyan(snap.name)}`);
203
+ console.log(` ${bold("Saved:")} ${gray(new Date(snap.savedAt).toLocaleString())}`);
204
+ console.log(` ${bold("Version:")} ${gray(snap.meta?.version || "—")}`);
205
+ console.log(` ${bold("Caps:")} ${snap.capabilities.length}`);
206
+ console.log();
207
+
208
+ const caps = normaliseCaps(snap.capabilities);
209
+ for (const c of caps) {
210
+ console.log(` ${cyan("·")} ${bold(c.id)}${c.description ? gray(" " + c.description) : ""}`);
211
+ }
212
+ console.log();
213
+ }
214
+
215
+ function subcmdDiff(nameA, nameB, infernoDir, jsonMode) {
216
+ if (!nameA) {
217
+ const msg = "Usage: infernoflow snapshot diff <name1> [<name2>] (omit name2 to diff against current)";
218
+ if (jsonMode) console.log(JSON.stringify({ ok: false, error: msg }));
219
+ else warn(msg);
220
+ return;
221
+ }
222
+
223
+ const snapA = readSnapshot(infernoDir, nameA);
224
+ if (!snapA) {
225
+ const msg = `Snapshot not found: ${nameA}`;
226
+ if (jsonMode) console.log(JSON.stringify({ ok: false, error: msg }));
227
+ else warn(msg);
228
+ return;
229
+ }
230
+
231
+ let capsBefore = normaliseCaps(snapA.capabilities);
232
+ let capsAfter;
233
+ let labelAfter;
234
+
235
+ if (nameB) {
236
+ const snapB = readSnapshot(infernoDir, nameB);
237
+ if (!snapB) {
238
+ const msg = `Snapshot not found: ${nameB}`;
239
+ if (jsonMode) console.log(JSON.stringify({ ok: false, error: msg }));
240
+ else warn(msg);
241
+ return;
242
+ }
243
+ capsAfter = normaliseCaps(snapB.capabilities);
244
+ labelAfter = nameB;
245
+ } else {
246
+ const contract = readContract(infernoDir);
247
+ if (!contract) {
248
+ const msg = "No current contract found.";
249
+ if (jsonMode) console.log(JSON.stringify({ ok: false, error: msg }));
250
+ else warn(msg);
251
+ return;
252
+ }
253
+ capsAfter = normaliseCaps(contract.data);
254
+ labelAfter = "current";
255
+ }
256
+
257
+ const diff = diffCaps(capsBefore, capsAfter);
258
+
259
+ if (jsonMode) {
260
+ console.log(JSON.stringify({ ok: true, from: nameA, to: labelAfter, ...diff }));
261
+ return;
262
+ }
263
+
264
+ console.log();
265
+ console.log(` ${bold("Diff:")} ${cyan(nameA)} → ${cyan(labelAfter)}`);
266
+ console.log();
267
+
268
+ if (!diff.added.length && !diff.removed.length && !diff.changed.length) {
269
+ console.log(` ${gray("No differences — snapshots are identical")}`);
270
+ console.log();
271
+ return;
272
+ }
273
+
274
+ if (diff.added.length) {
275
+ console.log(` ${green("+")} ${bold(`${diff.added.length} added`)}`);
276
+ diff.added.forEach(c => console.log(` ${green("+")} ${c.id}`));
277
+ console.log();
278
+ }
279
+ if (diff.removed.length) {
280
+ console.log(` ${red("-")} ${bold(`${diff.removed.length} removed`)}`);
281
+ diff.removed.forEach(c => console.log(` ${red("-")} ${c.id}`));
282
+ console.log();
283
+ }
284
+ if (diff.changed.length) {
285
+ console.log(` ${yellow("~")} ${bold(`${diff.changed.length} changed`)}`);
286
+ diff.changed.forEach(c => console.log(` ${yellow("~")} ${c.id}`));
287
+ console.log();
288
+ }
289
+ }
290
+
291
+ function subcmdRestore(name, infernoDir, jsonMode) {
292
+ if (!name || name.startsWith("--")) {
293
+ const msg = "Usage: infernoflow snapshot restore <name>";
294
+ if (jsonMode) console.log(JSON.stringify({ ok: false, error: msg }));
295
+ else warn(msg);
296
+ return;
297
+ }
298
+
299
+ const snap = readSnapshot(infernoDir, name);
300
+ if (!snap) {
301
+ const msg = `Snapshot not found: ${name}`;
302
+ if (jsonMode) console.log(JSON.stringify({ ok: false, error: msg }));
303
+ else warn(msg);
304
+ return;
305
+ }
306
+
307
+ const contract = readContract(infernoDir);
308
+ const contractFile = contract?.file || "capabilities.json";
309
+ const contractPath = path.join(infernoDir, contractFile);
310
+
311
+ const data = contract?.data || {};
312
+ data.capabilities = snap.capabilities;
313
+ fs.writeFileSync(contractPath, JSON.stringify(data, null, 2) + "\n");
314
+
315
+ if (jsonMode) {
316
+ console.log(JSON.stringify({ ok: true, action: "restored", name, capabilities: snap.capabilities.length }));
317
+ } else {
318
+ done(`Restored ${bold(cyan(name))} → ${bold(contractFile)} (${snap.capabilities.length} capabilities)`);
319
+ console.log();
320
+ }
321
+ }
322
+
323
+ function subcmdDelete(name, infernoDir, jsonMode) {
324
+ if (!name || name.startsWith("--")) {
325
+ const msg = "Usage: infernoflow snapshot delete <name>";
326
+ if (jsonMode) console.log(JSON.stringify({ ok: false, error: msg }));
327
+ else warn(msg);
328
+ return;
329
+ }
330
+
331
+ const p = snapshotPath(infernoDir, name);
332
+ if (!fs.existsSync(p)) {
333
+ const msg = `Snapshot not found: ${name}`;
334
+ if (jsonMode) console.log(JSON.stringify({ ok: false, error: msg }));
335
+ else warn(msg);
336
+ return;
337
+ }
338
+
339
+ fs.unlinkSync(p);
340
+
341
+ if (jsonMode) {
342
+ console.log(JSON.stringify({ ok: true, action: "deleted", name }));
343
+ } else {
344
+ done(`Deleted snapshot ${bold(name)}`);
345
+ console.log();
346
+ }
347
+ }
348
+
349
+ // ── Entry ─────────────────────────────────────────────────────────────────────
350
+
351
+ export async function snapshotCommand(rawArgs) {
352
+ const args = rawArgs.slice(1);
353
+ const jsonMode = args.includes("--json");
354
+ const cwd = process.cwd();
355
+ const infernoDir = path.join(cwd, "inferno");
356
+
357
+ if (!fs.existsSync(infernoDir)) {
358
+ const msg = "inferno/ not found. Run: infernoflow init";
359
+ if (jsonMode) console.log(JSON.stringify({ ok: false, error: msg }));
360
+ else warn(msg);
361
+ process.exit(1);
362
+ }
363
+
364
+ const subcmd = args.find(a => !a.startsWith("--"));
365
+
366
+ if (!subcmd || subcmd === "list") {
367
+ return subcmdList(infernoDir, jsonMode);
368
+ }
369
+
370
+ const positional = args.filter(a => !a.startsWith("--"));
371
+
372
+ if (subcmd === "save") return subcmdSave(positional[1], infernoDir, cwd, jsonMode);
373
+ if (subcmd === "show") return subcmdShow(positional[1], infernoDir, jsonMode);
374
+ if (subcmd === "restore") return subcmdRestore(positional[1], infernoDir, jsonMode);
375
+ if (subcmd === "delete") return subcmdDelete(positional[1], infernoDir, jsonMode);
376
+
377
+ if (subcmd === "diff") {
378
+ return subcmdDiff(positional[1], positional[2], infernoDir, jsonMode);
379
+ }
380
+
381
+ warn(`Unknown snapshot sub-command: ${subcmd}`);
382
+ info("Available: save | list | show | diff | restore | delete");
383
+ }