infernoflow 0.19.0 → 0.20.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/dist/bin/infernoflow.mjs +37 -1
- package/dist/lib/commands/export.mjs +239 -0
- package/dist/lib/commands/health.mjs +309 -0
- package/dist/lib/commands/scout.mjs +291 -0
- package/dist/lib/commands/snapshot.mjs +383 -0
- package/package.json +1 -1
package/dist/bin/infernoflow.mjs
CHANGED
|
@@ -45,6 +45,10 @@ const COMMAND_DESCRIPTIONS = {
|
|
|
45
45
|
monorepo: "Manage infernoflow across monorepo packages (init | list | status | diff | sync)",
|
|
46
46
|
link: "Link capabilities to Jira, Linear, or GitHub Issues tickets",
|
|
47
47
|
audit: "Classify capabilities by sensitivity (auth, payment, PII, admin) and generate security surface map",
|
|
48
|
+
scout: "Scan source files for undocumented capabilities not yet in the contract",
|
|
49
|
+
export: "Export contract to OpenAPI, Backstage catalog-info.yaml, CSV, or Markdown",
|
|
50
|
+
snapshot: "Save/diff/restore named snapshots of the capability contract",
|
|
51
|
+
health: "Compute a 0–100 health score across coverage, docs, freshness, completeness, drift",
|
|
48
52
|
};
|
|
49
53
|
|
|
50
54
|
const COMMAND_HANDLERS = {
|
|
@@ -82,7 +86,11 @@ const COMMAND_HANDLERS = {
|
|
|
82
86
|
report: async (args) => (await import("../lib/commands/report.mjs")).reportCommand(args),
|
|
83
87
|
monorepo: async (args) => (await import("../lib/commands/monorepo.mjs")).monorepoCommand(args),
|
|
84
88
|
link: async (args) => (await import("../lib/commands/link.mjs")).linkCommand(args),
|
|
85
|
-
audit:
|
|
89
|
+
audit: async (args) => (await import("../lib/commands/audit.mjs")).auditCommand(args),
|
|
90
|
+
scout: async (args) => (await import("../lib/commands/scout.mjs")).scoutCommand(args),
|
|
91
|
+
export: async (args) => (await import("../lib/commands/export.mjs")).exportCommand(args),
|
|
92
|
+
snapshot: async (args) => (await import("../lib/commands/snapshot.mjs")).snapshotCommand(args),
|
|
93
|
+
health: async (args) => (await import("../lib/commands/health.mjs")).healthCommand(args),
|
|
86
94
|
};
|
|
87
95
|
|
|
88
96
|
function formatCommandsHelp() {
|
|
@@ -279,6 +287,34 @@ ${formatCommandsHelp()}
|
|
|
279
287
|
--fail-on high|medium Exit 1 if unreviewed caps at given severity exist
|
|
280
288
|
--json Machine-readable output
|
|
281
289
|
|
|
290
|
+
${bold("scout options:")}
|
|
291
|
+
--dir <dirs> Comma-separated directories to scan (default: src,lib,app,api,routes)
|
|
292
|
+
--apply Write discovered capabilities to the contract file
|
|
293
|
+
--min-confidence <0-1> Minimum confidence threshold (default: 0.6)
|
|
294
|
+
--json Machine-readable output
|
|
295
|
+
|
|
296
|
+
${bold("export options:")}
|
|
297
|
+
--format openapi|backstage|csv|markdown|json Output format (required)
|
|
298
|
+
--out <path> Output file path (default: project root, auto-named)
|
|
299
|
+
--json Machine-readable summary
|
|
300
|
+
|
|
301
|
+
${bold("snapshot sub-commands:")}
|
|
302
|
+
save <name> Save current contract as a named snapshot
|
|
303
|
+
list List all snapshots
|
|
304
|
+
show <name> Print a snapshot's capabilities
|
|
305
|
+
diff <name1> [<name2>] Diff two snapshots (omit name2 to diff against current)
|
|
306
|
+
restore <name> Overwrite contract with snapshot contents
|
|
307
|
+
delete <name> Delete a snapshot
|
|
308
|
+
|
|
309
|
+
${bold("snapshot options:")}
|
|
310
|
+
--json Machine-readable output
|
|
311
|
+
|
|
312
|
+
${bold("health options:")}
|
|
313
|
+
--fail-below <score> Exit 1 if health score is below this threshold (CI gate)
|
|
314
|
+
--watch Re-run every 30s (live terminal view)
|
|
315
|
+
--interval <secs> Watch interval in seconds (default: 30)
|
|
316
|
+
--json Machine-readable score + breakdown
|
|
317
|
+
|
|
282
318
|
${bold("Machine output:")}
|
|
283
319
|
${gray("status --json")}
|
|
284
320
|
${gray("check --json")}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* infernoflow export
|
|
3
|
+
*
|
|
4
|
+
* Export the capability contract to external formats so it can travel
|
|
5
|
+
* outside the repo — into API docs, service catalogs, spreadsheets, wikis.
|
|
6
|
+
*
|
|
7
|
+
* Formats:
|
|
8
|
+
* openapi OpenAPI 3.1 JSON (stubs for each capability)
|
|
9
|
+
* backstage Backstage catalog-info.yaml
|
|
10
|
+
* csv Spreadsheet-ready CSV
|
|
11
|
+
* markdown Confluence/Notion-ready Markdown table
|
|
12
|
+
* json Clean JSON (normalised, no internal infernoflow fields)
|
|
13
|
+
*
|
|
14
|
+
* Usage:
|
|
15
|
+
* infernoflow export --format openapi
|
|
16
|
+
* infernoflow export --format backstage --out catalog-info.yaml
|
|
17
|
+
* infernoflow export --format csv --out capabilities.csv
|
|
18
|
+
* infernoflow export --format markdown
|
|
19
|
+
* infernoflow export --format json --out contract-export.json
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import * as fs from "node:fs";
|
|
23
|
+
import * as path from "node:path";
|
|
24
|
+
import { done, warn, info, bold, cyan, gray } from "../ui/output.mjs";
|
|
25
|
+
|
|
26
|
+
// ── Contract reader ───────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
function readContract(infernoDir) {
|
|
29
|
+
for (const f of ["contract.json", "capabilities.json"]) {
|
|
30
|
+
const p = path.join(infernoDir, f);
|
|
31
|
+
if (!fs.existsSync(p)) continue;
|
|
32
|
+
try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch {}
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function normaliseCaps(contract) {
|
|
38
|
+
const raw = contract?.capabilities || [];
|
|
39
|
+
return raw.map(c => {
|
|
40
|
+
if (typeof c === "string") return { id: c, description: "", tags: [], status: "active" };
|
|
41
|
+
return {
|
|
42
|
+
id: c.id || c.name || "unknown",
|
|
43
|
+
description: c.description || "",
|
|
44
|
+
tags: c.tags || [],
|
|
45
|
+
status: c.status || "active",
|
|
46
|
+
since: c.since || "",
|
|
47
|
+
owner: c.owner || "",
|
|
48
|
+
};
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function readMeta(infernoDir) {
|
|
53
|
+
const pkgPath = path.join(path.dirname(infernoDir), "package.json");
|
|
54
|
+
try {
|
|
55
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
|
|
56
|
+
return { name: pkg.name || "my-service", version: pkg.version || "0.0.0", description: pkg.description || "" };
|
|
57
|
+
} catch {
|
|
58
|
+
return { name: path.basename(path.dirname(infernoDir)), version: "0.0.0", description: "" };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ── Formatters ────────────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
function toOpenApi(caps, meta) {
|
|
65
|
+
const paths = {};
|
|
66
|
+
for (const cap of caps) {
|
|
67
|
+
const route = `/${cap.id.replace(/_/g, "-")}`;
|
|
68
|
+
paths[route] = {
|
|
69
|
+
get: {
|
|
70
|
+
operationId: cap.id,
|
|
71
|
+
summary: cap.description || cap.id,
|
|
72
|
+
tags: cap.tags.length ? cap.tags : [meta.name],
|
|
73
|
+
parameters: [],
|
|
74
|
+
responses: {
|
|
75
|
+
"200": { description: "Success", content: { "application/json": { schema: { type: "object" } } } },
|
|
76
|
+
"401": { description: "Unauthorized" },
|
|
77
|
+
"500": { description: "Internal Server Error" },
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return JSON.stringify({
|
|
84
|
+
openapi: "3.1.0",
|
|
85
|
+
info: {
|
|
86
|
+
title: meta.name,
|
|
87
|
+
version: meta.version,
|
|
88
|
+
description: meta.description || `Capability contract for ${meta.name}`,
|
|
89
|
+
},
|
|
90
|
+
paths,
|
|
91
|
+
components: { schemas: {}, securitySchemes: {} },
|
|
92
|
+
}, null, 2);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function toBackstage(caps, meta) {
|
|
96
|
+
const tags = [...new Set(caps.flatMap(c => c.tags))].slice(0, 10);
|
|
97
|
+
const lines = [
|
|
98
|
+
`apiVersion: backstage.io/v1alpha1`,
|
|
99
|
+
`kind: Component`,
|
|
100
|
+
`metadata:`,
|
|
101
|
+
` name: ${meta.name}`,
|
|
102
|
+
` description: "${(meta.description || meta.name).replace(/"/g, '\\"')}"`,
|
|
103
|
+
` annotations:`,
|
|
104
|
+
` infernoflow/capability-count: "${caps.length}"`,
|
|
105
|
+
` infernoflow/generated-at: "${new Date().toISOString()}"`,
|
|
106
|
+
tags.length ? ` tags:\n${tags.map(t => ` - ${t}`).join("\n")}` : "",
|
|
107
|
+
`spec:`,
|
|
108
|
+
` type: service`,
|
|
109
|
+
` lifecycle: production`,
|
|
110
|
+
` owner: team-default`,
|
|
111
|
+
` providesApis: []`,
|
|
112
|
+
`---`,
|
|
113
|
+
`# Capabilities`,
|
|
114
|
+
...caps.map(c => [
|
|
115
|
+
`# ${c.id}`,
|
|
116
|
+
`# ${c.description || "(no description)"}`,
|
|
117
|
+
`# tags: ${c.tags.join(", ") || "none"}`,
|
|
118
|
+
`# status: ${c.status}`,
|
|
119
|
+
].join("\n")),
|
|
120
|
+
].filter(Boolean);
|
|
121
|
+
return lines.join("\n") + "\n";
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function toCsv(caps) {
|
|
125
|
+
const header = ["id", "description", "tags", "status", "since", "owner"];
|
|
126
|
+
const rows = caps.map(c => [
|
|
127
|
+
c.id,
|
|
128
|
+
(c.description || "").replace(/"/g, '""'),
|
|
129
|
+
c.tags.join("|"),
|
|
130
|
+
c.status,
|
|
131
|
+
c.since,
|
|
132
|
+
c.owner,
|
|
133
|
+
].map(v => `"${v}"`).join(","));
|
|
134
|
+
return [header.join(","), ...rows].join("\n") + "\n";
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function toMarkdown(caps, meta) {
|
|
138
|
+
const lines = [
|
|
139
|
+
`# ${meta.name} — Capability Contract`,
|
|
140
|
+
``,
|
|
141
|
+
`> Generated by infernoflow on ${new Date().toISOString().slice(0, 10)}`,
|
|
142
|
+
`> ${caps.length} capabilities tracked`,
|
|
143
|
+
``,
|
|
144
|
+
`| Capability | Description | Tags | Status |`,
|
|
145
|
+
`|---|---|---|---|`,
|
|
146
|
+
...caps.map(c =>
|
|
147
|
+
`| \`${c.id}\` | ${c.description || ""} | ${c.tags.join(", ") || "—"} | ${c.status} |`
|
|
148
|
+
),
|
|
149
|
+
``,
|
|
150
|
+
];
|
|
151
|
+
return lines.join("\n");
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function toCleanJson(caps, meta) {
|
|
155
|
+
return JSON.stringify({
|
|
156
|
+
name: meta.name,
|
|
157
|
+
version: meta.version,
|
|
158
|
+
exportedAt: new Date().toISOString(),
|
|
159
|
+
capabilities: caps,
|
|
160
|
+
}, null, 2) + "\n";
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ── Entry ─────────────────────────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
const FORMAT_EXT = {
|
|
166
|
+
openapi: "openapi.json",
|
|
167
|
+
backstage: "catalog-info.yaml",
|
|
168
|
+
csv: "capabilities.csv",
|
|
169
|
+
markdown: "capabilities.md",
|
|
170
|
+
json: "capabilities-export.json",
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
export async function exportCommand(rawArgs) {
|
|
174
|
+
const args = rawArgs.slice(1);
|
|
175
|
+
const jsonMode = args.includes("--json");
|
|
176
|
+
const cwd = process.cwd();
|
|
177
|
+
const infernoDir = path.join(cwd, "inferno");
|
|
178
|
+
|
|
179
|
+
if (!fs.existsSync(infernoDir)) {
|
|
180
|
+
const msg = "inferno/ not found. Run: infernoflow init";
|
|
181
|
+
if (jsonMode) console.log(JSON.stringify({ ok: false, error: msg }));
|
|
182
|
+
else warn(msg);
|
|
183
|
+
process.exit(1);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const fmtIdx = args.indexOf("--format");
|
|
187
|
+
const format = fmtIdx !== -1 ? args[fmtIdx + 1] : null;
|
|
188
|
+
const outIdx = args.indexOf("--out");
|
|
189
|
+
const outArg = outIdx !== -1 ? args[outIdx + 1] : null;
|
|
190
|
+
|
|
191
|
+
const validFormats = Object.keys(FORMAT_EXT);
|
|
192
|
+
|
|
193
|
+
if (!format || !validFormats.includes(format)) {
|
|
194
|
+
const msg = `Usage: infernoflow export --format <${validFormats.join("|")}> [--out <path>]`;
|
|
195
|
+
if (jsonMode) console.log(JSON.stringify({ ok: false, error: msg }));
|
|
196
|
+
else {
|
|
197
|
+
warn(msg);
|
|
198
|
+
console.log();
|
|
199
|
+
console.log(` ${gray("Available formats:")}`);
|
|
200
|
+
console.log(` ${bold("openapi")} — OpenAPI 3.1 JSON with a stub path per capability`);
|
|
201
|
+
console.log(` ${bold("backstage")} — Backstage catalog-info.yaml component definition`);
|
|
202
|
+
console.log(` ${bold("csv")} — Spreadsheet-ready CSV`);
|
|
203
|
+
console.log(` ${bold("markdown")} — Confluence/Notion table`);
|
|
204
|
+
console.log(` ${bold("json")} — Clean normalised JSON`);
|
|
205
|
+
console.log();
|
|
206
|
+
}
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const contract = readContract(infernoDir);
|
|
211
|
+
if (!contract) {
|
|
212
|
+
const msg = "No contract.json or capabilities.json found.";
|
|
213
|
+
if (jsonMode) console.log(JSON.stringify({ ok: false, error: msg }));
|
|
214
|
+
else warn(msg);
|
|
215
|
+
process.exit(1);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const caps = normaliseCaps(contract);
|
|
219
|
+
const meta = readMeta(infernoDir);
|
|
220
|
+
const outPath = outArg || path.join(cwd, FORMAT_EXT[format]);
|
|
221
|
+
|
|
222
|
+
let output;
|
|
223
|
+
switch (format) {
|
|
224
|
+
case "openapi": output = toOpenApi(caps, meta); break;
|
|
225
|
+
case "backstage": output = toBackstage(caps, meta); break;
|
|
226
|
+
case "csv": output = toCsv(caps); break;
|
|
227
|
+
case "markdown": output = toMarkdown(caps, meta); break;
|
|
228
|
+
case "json": output = toCleanJson(caps, meta); break;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
fs.writeFileSync(outPath, output);
|
|
232
|
+
|
|
233
|
+
if (jsonMode) {
|
|
234
|
+
console.log(JSON.stringify({ ok: true, format, file: outPath, capabilities: caps.length }));
|
|
235
|
+
} else {
|
|
236
|
+
done(`Exported ${bold(String(caps.length))} capabilities → ${cyan(path.relative(cwd, outPath))}`);
|
|
237
|
+
console.log();
|
|
238
|
+
}
|
|
239
|
+
}
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* infernoflow health
|
|
3
|
+
*
|
|
4
|
+
* Computes a weighted 0–100 health score for the capability contract.
|
|
5
|
+
* Breaks it down across five dimensions so you know exactly where to improve.
|
|
6
|
+
*
|
|
7
|
+
* Dimensions:
|
|
8
|
+
* Coverage % of capabilities that have descriptions (weight 25)
|
|
9
|
+
* Documentation % with at least one scenario/test (weight 20)
|
|
10
|
+
* Freshness How recently the contract was updated (weight 20)
|
|
11
|
+
* Completeness Version field, owner, tags present (weight 15)
|
|
12
|
+
* Drift risk Open issues from `infernoflow check` (weight 20)
|
|
13
|
+
*
|
|
14
|
+
* Usage:
|
|
15
|
+
* infernoflow health Print score + breakdown
|
|
16
|
+
* infernoflow health --json Machine-readable
|
|
17
|
+
* infernoflow health --fail-below 70 Exit 1 if score < 70 (CI gate)
|
|
18
|
+
* infernoflow health --watch Re-run every 30s (for terminals)
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import * as fs from "node:fs";
|
|
22
|
+
import * as path from "node:path";
|
|
23
|
+
import { execSync } from "node:child_process";
|
|
24
|
+
import { done, warn, info, bold, cyan, gray, green, yellow, red } from "../ui/output.mjs";
|
|
25
|
+
|
|
26
|
+
const WEIGHTS = {
|
|
27
|
+
coverage: 25,
|
|
28
|
+
documentation: 20,
|
|
29
|
+
freshness: 20,
|
|
30
|
+
completeness: 15,
|
|
31
|
+
drift: 20,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// ── Readers ───────────────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
function readContract(infernoDir) {
|
|
37
|
+
for (const f of ["contract.json", "capabilities.json"]) {
|
|
38
|
+
const p = path.join(infernoDir, f);
|
|
39
|
+
if (!fs.existsSync(p)) continue;
|
|
40
|
+
try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch {}
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function readScenarios(infernoDir) {
|
|
46
|
+
const scenDir = path.join(infernoDir, "scenarios");
|
|
47
|
+
if (!fs.existsSync(scenDir)) return [];
|
|
48
|
+
return fs.readdirSync(scenDir)
|
|
49
|
+
.filter(f => f.endsWith(".json"))
|
|
50
|
+
.flatMap(f => {
|
|
51
|
+
try {
|
|
52
|
+
const data = JSON.parse(fs.readFileSync(path.join(scenDir, f), "utf8"));
|
|
53
|
+
return data.capability ? [data.capability] : (data.capabilities || []);
|
|
54
|
+
} catch { return []; }
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function runCheck(cwd) {
|
|
59
|
+
try {
|
|
60
|
+
const out = execSync("npx infernoflow check --json", {
|
|
61
|
+
cwd, encoding: "utf8", timeout: 15_000, stdio: ["ignore", "pipe", "pipe"],
|
|
62
|
+
});
|
|
63
|
+
return JSON.parse(out);
|
|
64
|
+
} catch (err) {
|
|
65
|
+
try { return JSON.parse(err.stdout || "{}"); } catch { return {}; }
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function lastModifiedDaysAgo(infernoDir) {
|
|
70
|
+
const files = ["contract.json", "capabilities.json"]
|
|
71
|
+
.map(f => path.join(infernoDir, f))
|
|
72
|
+
.filter(fs.existsSync);
|
|
73
|
+
if (!files.length) return 999;
|
|
74
|
+
const mtime = Math.max(...files.map(f => fs.statSync(f).mtimeMs));
|
|
75
|
+
return (Date.now() - mtime) / (1000 * 60 * 60 * 24);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── Scorers ───────────────────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
function scoreCoverage(caps) {
|
|
81
|
+
if (!caps.length) return { score: 0, detail: "No capabilities" };
|
|
82
|
+
const withDesc = caps.filter(c => c.description && c.description.length > 5).length;
|
|
83
|
+
const pct = Math.round((withDesc / caps.length) * 100);
|
|
84
|
+
return {
|
|
85
|
+
score: pct,
|
|
86
|
+
detail: `${withDesc}/${caps.length} capabilities have descriptions`,
|
|
87
|
+
pct,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function scoreDocumentation(caps, scenarioCaps) {
|
|
92
|
+
if (!caps.length) return { score: 0, detail: "No capabilities" };
|
|
93
|
+
const scenSet = new Set(scenarioCaps.map(s => String(s).toLowerCase()));
|
|
94
|
+
const withScen = caps.filter(c => scenSet.has(c.id.toLowerCase())).length;
|
|
95
|
+
const pct = Math.round((withScen / caps.length) * 100);
|
|
96
|
+
return {
|
|
97
|
+
score: Math.min(100, pct + (scenSet.size === 0 ? 0 : 10)), // bonus for having any scenarios
|
|
98
|
+
detail: `${withScen}/${caps.length} capabilities have test scenarios`,
|
|
99
|
+
pct,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function scoreFreshness(daysAgo) {
|
|
104
|
+
let score;
|
|
105
|
+
let label;
|
|
106
|
+
if (daysAgo <= 1) { score = 100; label = "updated today"; }
|
|
107
|
+
else if (daysAgo <= 3) { score = 95; label = `updated ${Math.round(daysAgo)}d ago`; }
|
|
108
|
+
else if (daysAgo <= 7) { score = 85; label = `updated ${Math.round(daysAgo)}d ago`; }
|
|
109
|
+
else if (daysAgo <= 14) { score = 70; label = `updated ${Math.round(daysAgo)}d ago`; }
|
|
110
|
+
else if (daysAgo <= 30) { score = 50; label = `updated ${Math.round(daysAgo)}d ago`; }
|
|
111
|
+
else if (daysAgo <= 60) { score = 30; label = `updated ${Math.round(daysAgo)}d ago — stale`; }
|
|
112
|
+
else { score = 10; label = `not updated in ${Math.round(daysAgo)}d — very stale`; }
|
|
113
|
+
return { score, detail: label };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function scoreCompleteness(caps, contract) {
|
|
117
|
+
if (!caps.length) return { score: 0, detail: "No capabilities" };
|
|
118
|
+
const hasVersion = !!(contract?.version || contract?.contractVersion);
|
|
119
|
+
const withTags = caps.filter(c => c.tags?.length).length;
|
|
120
|
+
const withOwner = caps.filter(c => c.owner).length;
|
|
121
|
+
const withSince = caps.filter(c => c.since).length;
|
|
122
|
+
|
|
123
|
+
const tagPct = Math.round((withTags / caps.length) * 100);
|
|
124
|
+
const ownerPct = Math.round((withOwner / caps.length) * 100);
|
|
125
|
+
const sincePct = Math.round((withSince / caps.length) * 100);
|
|
126
|
+
|
|
127
|
+
const score = Math.round(
|
|
128
|
+
(hasVersion ? 20 : 0) +
|
|
129
|
+
tagPct * 0.3 +
|
|
130
|
+
ownerPct * 0.3 +
|
|
131
|
+
sincePct * 0.2
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
score: Math.min(100, score),
|
|
136
|
+
detail: `version: ${hasVersion ? "✓" : "✗"}, tags: ${tagPct}%, owner: ${ownerPct}%, since: ${sincePct}%`,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function scoreDrift(checkResult) {
|
|
141
|
+
const issues = checkResult?.issues || [];
|
|
142
|
+
const warnings = issues.filter(i => (i.severity || i.level || "error") === "warning").length;
|
|
143
|
+
const errors = issues.filter(i => (i.severity || i.level || "error") === "error").length;
|
|
144
|
+
const status = checkResult?.status;
|
|
145
|
+
|
|
146
|
+
if (status === "ok" || (!errors && !warnings)) return { score: 100, detail: "No issues found" };
|
|
147
|
+
|
|
148
|
+
const score = Math.max(0, 100 - (errors * 20) - (warnings * 8));
|
|
149
|
+
return {
|
|
150
|
+
score,
|
|
151
|
+
detail: `${errors} error${errors !== 1 ? "s" : ""}, ${warnings} warning${warnings !== 1 ? "s" : ""}`,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ── Aggregate ─────────────────────────────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
function computeHealth(infernoDir, cwd) {
|
|
158
|
+
const contract = readContract(infernoDir);
|
|
159
|
+
const caps = (contract?.capabilities || []).map(c =>
|
|
160
|
+
typeof c === "string" ? { id: c, description: "", tags: [], owner: "", since: "" } : c
|
|
161
|
+
);
|
|
162
|
+
const scenarioCaps = readScenarios(infernoDir);
|
|
163
|
+
const daysAgo = lastModifiedDaysAgo(infernoDir);
|
|
164
|
+
const checkResult = runCheck(cwd);
|
|
165
|
+
|
|
166
|
+
const dimensions = {
|
|
167
|
+
coverage: scoreCoverage(caps),
|
|
168
|
+
documentation: scoreDocumentation(caps, scenarioCaps),
|
|
169
|
+
freshness: scoreFreshness(daysAgo),
|
|
170
|
+
completeness: scoreCompleteness(caps, contract),
|
|
171
|
+
drift: scoreDrift(checkResult),
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const totalScore = Math.round(
|
|
175
|
+
Object.entries(dimensions).reduce((sum, [key, dim]) => {
|
|
176
|
+
return sum + (dim.score * WEIGHTS[key]) / 100;
|
|
177
|
+
}, 0)
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
return { totalScore, dimensions, caps, daysAgo, checkResult };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ── Renderer ──────────────────────────────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
function scoreColor(score) {
|
|
186
|
+
if (score >= 80) return green;
|
|
187
|
+
if (score >= 60) return yellow;
|
|
188
|
+
return red;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function scoreGrade(score) {
|
|
192
|
+
if (score >= 90) return "A";
|
|
193
|
+
if (score >= 80) return "B";
|
|
194
|
+
if (score >= 70) return "C";
|
|
195
|
+
if (score >= 60) return "D";
|
|
196
|
+
return "F";
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function barOf(score, width = 30) {
|
|
200
|
+
const filled = Math.round((score / 100) * width);
|
|
201
|
+
const empty = width - filled;
|
|
202
|
+
return "█".repeat(filled) + "░".repeat(empty);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function printReport(totalScore, dimensions) {
|
|
206
|
+
const col = scoreColor(totalScore);
|
|
207
|
+
const grade = scoreGrade(totalScore);
|
|
208
|
+
|
|
209
|
+
console.log();
|
|
210
|
+
console.log(` ${bold("🔥 infernoflow health score")}`);
|
|
211
|
+
console.log();
|
|
212
|
+
console.log(` ${col(bold(String(totalScore)))} / 100 ${col(bold(grade))} ${col(barOf(totalScore))}`);
|
|
213
|
+
console.log();
|
|
214
|
+
|
|
215
|
+
const dimNames = {
|
|
216
|
+
coverage: "Coverage ",
|
|
217
|
+
documentation: "Docs/Tests ",
|
|
218
|
+
freshness: "Freshness ",
|
|
219
|
+
completeness: "Completeness ",
|
|
220
|
+
drift: "Drift risk ",
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
for (const [key, dim] of Object.entries(dimensions)) {
|
|
224
|
+
const w = WEIGHTS[key];
|
|
225
|
+
const sc = dim.score;
|
|
226
|
+
const c = scoreColor(sc);
|
|
227
|
+
const bar = barOf(sc, 20);
|
|
228
|
+
const weighted = Math.round((sc * w) / 100);
|
|
229
|
+
console.log(
|
|
230
|
+
` ${bold(dimNames[key])} ${c(String(sc).padStart(3))} ${c(bar)} ${gray(`×${w}% = ${weighted}pts ${dim.detail}`)}`
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
console.log();
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function printTips(totalScore, dimensions) {
|
|
237
|
+
const tips = [];
|
|
238
|
+
|
|
239
|
+
if (dimensions.coverage.score < 70)
|
|
240
|
+
tips.push("Add descriptions to your capabilities in contract.json");
|
|
241
|
+
if (dimensions.documentation.score < 60)
|
|
242
|
+
tips.push("Create scenario files in inferno/scenarios/ for each capability");
|
|
243
|
+
if (dimensions.freshness.score < 70)
|
|
244
|
+
tips.push("Run `infernoflow suggest` to sync recent changes to the contract");
|
|
245
|
+
if (dimensions.completeness.score < 60)
|
|
246
|
+
tips.push("Add version, tags, owner, and since fields to your capabilities");
|
|
247
|
+
if (dimensions.drift.score < 80)
|
|
248
|
+
tips.push("Run `infernoflow check` and fix the reported issues");
|
|
249
|
+
|
|
250
|
+
if (tips.length) {
|
|
251
|
+
console.log(` ${bold("Tips to improve:")}`);
|
|
252
|
+
tips.forEach(t => console.log(` ${yellow("·")} ${t}`));
|
|
253
|
+
console.log();
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ── Entry ─────────────────────────────────────────────────────────────────────
|
|
258
|
+
|
|
259
|
+
export async function healthCommand(rawArgs) {
|
|
260
|
+
const args = rawArgs.slice(1);
|
|
261
|
+
const jsonMode = args.includes("--json");
|
|
262
|
+
const watchMode = args.includes("--watch");
|
|
263
|
+
const cwd = process.cwd();
|
|
264
|
+
const infernoDir = path.join(cwd, "inferno");
|
|
265
|
+
|
|
266
|
+
if (!fs.existsSync(infernoDir)) {
|
|
267
|
+
const msg = "inferno/ not found. Run: infernoflow init";
|
|
268
|
+
if (jsonMode) console.log(JSON.stringify({ ok: false, error: msg }));
|
|
269
|
+
else warn(msg);
|
|
270
|
+
process.exit(1);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const failBelowIdx = args.indexOf("--fail-below");
|
|
274
|
+
const failBelow = failBelowIdx !== -1 ? parseInt(args[failBelowIdx + 1], 10) : null;
|
|
275
|
+
|
|
276
|
+
const intervalIdx = args.indexOf("--interval");
|
|
277
|
+
const intervalSecs = intervalIdx !== -1 ? parseInt(args[intervalIdx + 1], 10) : 30;
|
|
278
|
+
|
|
279
|
+
const runOnce = () => {
|
|
280
|
+
const { totalScore, dimensions } = computeHealth(infernoDir, cwd);
|
|
281
|
+
|
|
282
|
+
if (jsonMode) {
|
|
283
|
+
const dimFlat = Object.fromEntries(
|
|
284
|
+
Object.entries(dimensions).map(([k, v]) => [k, { score: v.score, detail: v.detail, weight: WEIGHTS[k] }])
|
|
285
|
+
);
|
|
286
|
+
console.log(JSON.stringify({ ok: true, score: totalScore, grade: scoreGrade(totalScore), dimensions: dimFlat }));
|
|
287
|
+
} else {
|
|
288
|
+
if (watchMode) process.stdout.write("\x1Bc"); // clear screen
|
|
289
|
+
printReport(totalScore, dimensions);
|
|
290
|
+
if (!watchMode) printTips(totalScore, dimensions);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (failBelow !== null && totalScore < failBelow) {
|
|
294
|
+
if (!jsonMode) console.error(red(` ✗ Score ${totalScore} is below threshold ${failBelow} — failing.\n`));
|
|
295
|
+
process.exit(1);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return totalScore;
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
if (watchMode) {
|
|
302
|
+
if (!jsonMode) info(`Watching health score every ${intervalSecs}s — press Ctrl+C to stop`);
|
|
303
|
+
runOnce();
|
|
304
|
+
setInterval(runOnce, intervalSecs * 1000);
|
|
305
|
+
await new Promise(() => {});
|
|
306
|
+
} else {
|
|
307
|
+
runOnce();
|
|
308
|
+
}
|
|
309
|
+
}
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* infernoflow scout
|
|
3
|
+
*
|
|
4
|
+
* Scans your source files for undocumented capabilities — functions, routes,
|
|
5
|
+
* exports, controllers — that exist in code but are missing from the contract.
|
|
6
|
+
*
|
|
7
|
+
* Smarter than --adopt: it reads your existing contract, diffs against what
|
|
8
|
+
* it finds in source, and only surfaces genuine gaps.
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* infernoflow scout Scan src/, lib/, app/ (auto-detected)
|
|
12
|
+
* infernoflow scout --dir src,api Custom scan directories
|
|
13
|
+
* infernoflow scout --apply Write discovered caps to capabilities.json
|
|
14
|
+
* infernoflow scout --json Machine-readable output
|
|
15
|
+
* infernoflow scout --min-confidence 0.7 Only show high-confidence hits
|
|
16
|
+
*
|
|
17
|
+
* Output:
|
|
18
|
+
* Lists undocumented capabilities with confidence score + source location.
|
|
19
|
+
* With --apply, merges them into the contract file.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import * as fs from "node:fs";
|
|
23
|
+
import * as path from "node:path";
|
|
24
|
+
import { done, warn, info, bold, cyan, gray, green, yellow, red } from "../ui/output.mjs";
|
|
25
|
+
|
|
26
|
+
// ── Pattern library ───────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
const PATTERNS = [
|
|
29
|
+
// Express / Fastify / Hono routes
|
|
30
|
+
{
|
|
31
|
+
name: "http-route",
|
|
32
|
+
regex: /(?:app|router|server)\s*\.\s*(get|post|put|patch|delete|head|options)\s*\(\s*['"`]([^'"`]+)['"`]/gi,
|
|
33
|
+
extract: (m) => ({ id: routeToId(m[2], m[1]), hint: `${m[1].toUpperCase()} ${m[2]}`, confidence: 0.85 }),
|
|
34
|
+
},
|
|
35
|
+
// Next.js / Nuxt API route files (path from filename)
|
|
36
|
+
{
|
|
37
|
+
name: "api-file",
|
|
38
|
+
filePattern: /[/\\](pages[/\\]api|app[/\\]api)[/\\](.+)\.(js|ts|mjs)$/,
|
|
39
|
+
extract: (filePath) => {
|
|
40
|
+
const m = filePath.match(/[/\\](pages[/\\]api|app[/\\]api)[/\\](.+)\.(js|ts|mjs)$/);
|
|
41
|
+
if (!m) return null;
|
|
42
|
+
const route = m[2].replace(/[/\\]index$/, "").replace(/\[([^\]]+)\]/g, ":$1");
|
|
43
|
+
return { id: routeToId("/" + route, "api"), hint: `Next.js API: /${route}`, confidence: 0.9 };
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
// Named exports (functions, classes, consts)
|
|
47
|
+
{
|
|
48
|
+
name: "export",
|
|
49
|
+
regex: /export\s+(?:async\s+)?(?:function|class|const|let)\s+([A-Z][A-Za-z0-9]+)/g,
|
|
50
|
+
extract: (m) => ({ id: camelToId(m[1]), hint: `export ${m[1]}`, confidence: 0.65 }),
|
|
51
|
+
},
|
|
52
|
+
// Default export object keys (controller-style)
|
|
53
|
+
{
|
|
54
|
+
name: "controller-method",
|
|
55
|
+
regex: /^\s{2,}(?:async\s+)?([a-z][A-Za-z0-9]+)\s*[:(]/gm,
|
|
56
|
+
extract: (m) => ({ id: camelToId(m[1]), hint: `method ${m[1]}`, confidence: 0.5 }),
|
|
57
|
+
minLen: 5,
|
|
58
|
+
},
|
|
59
|
+
// GraphQL resolver fields
|
|
60
|
+
{
|
|
61
|
+
name: "graphql-resolver",
|
|
62
|
+
regex: /['"`]?([A-Za-z][A-Za-z0-9]+)['"`]?\s*:\s*(?:async\s+)?\([^)]*\)\s*(?:=>|{)/g,
|
|
63
|
+
extract: (m) => ({ id: camelToId(m[1]), hint: `resolver ${m[1]}`, confidence: 0.6 }),
|
|
64
|
+
},
|
|
65
|
+
// Django / Flask URL patterns
|
|
66
|
+
{
|
|
67
|
+
name: "python-route",
|
|
68
|
+
regex: /(?:path|url|re_path)\s*\(\s*['"`]([^'"`]+)['"`]\s*,\s*([A-Za-z0-9_.]+)/g,
|
|
69
|
+
extract: (m) => ({ id: routeToId(m[1], "view"), hint: `view ${m[2]} @ ${m[1]}`, confidence: 0.8 }),
|
|
70
|
+
},
|
|
71
|
+
// Rails routes
|
|
72
|
+
{
|
|
73
|
+
name: "rails-route",
|
|
74
|
+
regex: /(?:get|post|put|patch|delete)\s+['"`]([^'"`]+)['"`]\s*,\s*to:\s*['"`]([^'"`]+)['"`]/g,
|
|
75
|
+
extract: (m) => ({ id: routeToId(m[1], "rails"), hint: `Rails: ${m[2]}`, confidence: 0.8 }),
|
|
76
|
+
},
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
function routeToId(route, method) {
|
|
82
|
+
return route
|
|
83
|
+
.replace(/^\//, "")
|
|
84
|
+
.replace(/\/:?[^/]+/g, "") // strip params
|
|
85
|
+
.replace(/\//g, "-")
|
|
86
|
+
.replace(/[^a-zA-Z0-9-]/g, "")
|
|
87
|
+
.replace(/-+/g, "-")
|
|
88
|
+
.replace(/^-|-$/g, "")
|
|
89
|
+
.toLowerCase() || method.toLowerCase() + "-root";
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function camelToId(name) {
|
|
93
|
+
return name
|
|
94
|
+
.replace(/([A-Z])/g, "-$1")
|
|
95
|
+
.toLowerCase()
|
|
96
|
+
.replace(/^-/, "")
|
|
97
|
+
.replace(/-+/g, "-");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── File scanner ──────────────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
const SOURCE_EXTENSIONS = new Set([".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx", ".py", ".rb"]);
|
|
103
|
+
const IGNORE_DIRS = new Set(["node_modules", ".git", "dist", "build", ".next", "__pycache__", "vendor", "coverage"]);
|
|
104
|
+
|
|
105
|
+
function* walkFiles(dir) {
|
|
106
|
+
if (!fs.existsSync(dir)) return;
|
|
107
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
108
|
+
for (const e of entries) {
|
|
109
|
+
if (IGNORE_DIRS.has(e.name)) continue;
|
|
110
|
+
const full = path.join(dir, e.name);
|
|
111
|
+
if (e.isDirectory()) {
|
|
112
|
+
yield* walkFiles(full);
|
|
113
|
+
} else if (e.isFile() && SOURCE_EXTENSIONS.has(path.extname(e.name))) {
|
|
114
|
+
yield full;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function scanFile(filePath) {
|
|
120
|
+
const hits = [];
|
|
121
|
+
|
|
122
|
+
// File-pattern rules (e.g. Next.js API files)
|
|
123
|
+
for (const pat of PATTERNS) {
|
|
124
|
+
if (pat.filePattern && pat.filePattern.test(filePath)) {
|
|
125
|
+
const result = pat.extract(filePath);
|
|
126
|
+
if (result) hits.push({ ...result, source: filePath, pattern: pat.name });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
let content;
|
|
131
|
+
try { content = fs.readFileSync(filePath, "utf8"); } catch { return hits; }
|
|
132
|
+
|
|
133
|
+
for (const pat of PATTERNS) {
|
|
134
|
+
if (!pat.regex) continue;
|
|
135
|
+
pat.regex.lastIndex = 0;
|
|
136
|
+
let m;
|
|
137
|
+
while ((m = pat.regex.exec(content)) !== null) {
|
|
138
|
+
const result = pat.extract(m);
|
|
139
|
+
if (!result) continue;
|
|
140
|
+
if (pat.minLen && result.id.length < pat.minLen) continue;
|
|
141
|
+
hits.push({ ...result, source: filePath, pattern: pat.name });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return hits;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ── Gap detection ─────────────────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
function readContract(infernoDir) {
|
|
151
|
+
for (const f of ["capabilities.json", "contract.json"]) {
|
|
152
|
+
const p = path.join(infernoDir, f);
|
|
153
|
+
if (!fs.existsSync(p)) continue;
|
|
154
|
+
try { return { file: f, data: JSON.parse(fs.readFileSync(p, "utf8")) }; } catch {}
|
|
155
|
+
}
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function getKnownIds(contract) {
|
|
160
|
+
const caps = contract?.data?.capabilities || [];
|
|
161
|
+
return new Set(caps.map(c => (typeof c === "string" ? c : c.id || c.name || "").toLowerCase()));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function dedupeHits(hits) {
|
|
165
|
+
const seen = new Map();
|
|
166
|
+
for (const h of hits) {
|
|
167
|
+
const key = h.id;
|
|
168
|
+
const existing = seen.get(key);
|
|
169
|
+
if (!existing || h.confidence > existing.confidence) {
|
|
170
|
+
seen.set(key, h);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return [...seen.values()].sort((a, b) => b.confidence - a.confidence);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ── Apply ─────────────────────────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
function applyToContract(infernoDir, contract, newCaps) {
|
|
179
|
+
const caps = contract.data.capabilities || [];
|
|
180
|
+
const added = [];
|
|
181
|
+
|
|
182
|
+
for (const cap of newCaps) {
|
|
183
|
+
const entry = {
|
|
184
|
+
id: cap.id,
|
|
185
|
+
description: cap.hint,
|
|
186
|
+
since: new Date().toISOString().slice(0, 10),
|
|
187
|
+
source: "scout",
|
|
188
|
+
};
|
|
189
|
+
caps.push(entry);
|
|
190
|
+
added.push(cap.id);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
contract.data.capabilities = caps;
|
|
194
|
+
fs.writeFileSync(
|
|
195
|
+
path.join(infernoDir, contract.file),
|
|
196
|
+
JSON.stringify(contract.data, null, 2) + "\n"
|
|
197
|
+
);
|
|
198
|
+
return added;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ── Entry ─────────────────────────────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
export async function scoutCommand(rawArgs) {
|
|
204
|
+
const args = rawArgs.slice(1);
|
|
205
|
+
const jsonMode = args.includes("--json");
|
|
206
|
+
const apply = args.includes("--apply");
|
|
207
|
+
const cwd = process.cwd();
|
|
208
|
+
const infernoDir = path.join(cwd, "inferno");
|
|
209
|
+
|
|
210
|
+
if (!fs.existsSync(infernoDir)) {
|
|
211
|
+
const msg = "inferno/ not found. Run: infernoflow init";
|
|
212
|
+
if (jsonMode) console.log(JSON.stringify({ ok: false, error: msg }));
|
|
213
|
+
else warn(msg);
|
|
214
|
+
process.exit(1);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Parse --dir flag
|
|
218
|
+
const dirIdx = args.indexOf("--dir");
|
|
219
|
+
const scanDirs = dirIdx !== -1
|
|
220
|
+
? args[dirIdx + 1].split(",").map(d => path.resolve(cwd, d.trim()))
|
|
221
|
+
: ["src", "lib", "app", "api", "pages", "routes", "controllers", "handlers"]
|
|
222
|
+
.map(d => path.join(cwd, d))
|
|
223
|
+
.filter(fs.existsSync);
|
|
224
|
+
|
|
225
|
+
// Parse --min-confidence
|
|
226
|
+
const confIdx = args.indexOf("--min-confidence");
|
|
227
|
+
const minConfidence = confIdx !== -1 ? parseFloat(args[confIdx + 1]) : 0.6;
|
|
228
|
+
|
|
229
|
+
if (!scanDirs.length) {
|
|
230
|
+
const msg = "No source directories found. Use --dir src,lib,api";
|
|
231
|
+
if (jsonMode) console.log(JSON.stringify({ ok: false, error: msg }));
|
|
232
|
+
else warn(msg);
|
|
233
|
+
process.exit(1);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const contract = readContract(infernoDir);
|
|
237
|
+
const knownIds = getKnownIds(contract);
|
|
238
|
+
|
|
239
|
+
if (!jsonMode) {
|
|
240
|
+
info(`Scanning ${scanDirs.map(d => path.relative(cwd, d)).join(", ")} …`);
|
|
241
|
+
console.log();
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Collect hits from all source dirs
|
|
245
|
+
const allHits = [];
|
|
246
|
+
for (const dir of scanDirs) {
|
|
247
|
+
for (const file of walkFiles(dir)) {
|
|
248
|
+
allHits.push(...scanFile(file));
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Filter: unknown, above confidence threshold, non-trivial id
|
|
253
|
+
const candidates = dedupeHits(allHits).filter(h =>
|
|
254
|
+
!knownIds.has(h.id.toLowerCase()) &&
|
|
255
|
+
h.confidence >= minConfidence &&
|
|
256
|
+
h.id.length >= 3 &&
|
|
257
|
+
!["index", "app", "main", "root", "default", "handler"].includes(h.id)
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
if (jsonMode) {
|
|
261
|
+
console.log(JSON.stringify({ ok: true, found: candidates.length, candidates }));
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (!candidates.length) {
|
|
266
|
+
done("No undocumented capabilities found — contract looks complete.");
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
console.log(` ${bold(`${candidates.length} undocumented capability candidate${candidates.length !== 1 ? "s" : ""} found`)}`);
|
|
271
|
+
console.log();
|
|
272
|
+
|
|
273
|
+
const w = Math.max(...candidates.map(c => c.id.length), 10) + 2;
|
|
274
|
+
for (const c of candidates) {
|
|
275
|
+
const confStr = `${Math.round(c.confidence * 100)}%`;
|
|
276
|
+
const confColor = c.confidence >= 0.8 ? green : c.confidence >= 0.65 ? yellow : gray;
|
|
277
|
+
const rel = path.relative(cwd, c.source);
|
|
278
|
+
console.log(` ${bold(c.id.padEnd(w))} ${confColor(confStr.padEnd(5))} ${gray(c.hint)}`);
|
|
279
|
+
console.log(` ${" ".repeat(w + 7)}${gray(rel)}`);
|
|
280
|
+
}
|
|
281
|
+
console.log();
|
|
282
|
+
|
|
283
|
+
if (apply) {
|
|
284
|
+
const added = applyToContract(infernoDir, contract, candidates);
|
|
285
|
+
done(`Added ${bold(String(added.length))} capabilities to ${contract.file}`);
|
|
286
|
+
console.log();
|
|
287
|
+
} else {
|
|
288
|
+
info(`Run with ${cyan("--apply")} to add these to your contract.`);
|
|
289
|
+
console.log();
|
|
290
|
+
}
|
|
291
|
+
}
|
|
@@ -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
|
+
}
|