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.
- package/dist/bin/infernoflow.mjs +61 -1
- package/dist/lib/commands/adoptWizard.mjs +320 -0
- package/dist/lib/commands/export.mjs +239 -0
- package/dist/lib/commands/health.mjs +309 -0
- package/dist/lib/commands/init.mjs +70 -0
- package/dist/lib/commands/scout.mjs +291 -0
- package/dist/lib/commands/snapshot.mjs +383 -0
- package/dist/lib/commands/vibe.mjs +365 -0
- package/dist/lib/templates/index.mjs +131 -0
- package/package.json +3 -2
package/dist/bin/infernoflow.mjs
CHANGED
|
@@ -45,6 +45,12 @@ 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",
|
|
52
|
+
vibe: "Vibe coding mode — watches files, auto-syncs contract, regenerates context on every save",
|
|
53
|
+
adopt: "Interactive wizard to adopt infernoflow in an existing project (detect → review → wire up)",
|
|
48
54
|
};
|
|
49
55
|
|
|
50
56
|
const COMMAND_HANDLERS = {
|
|
@@ -82,7 +88,13 @@ const COMMAND_HANDLERS = {
|
|
|
82
88
|
report: async (args) => (await import("../lib/commands/report.mjs")).reportCommand(args),
|
|
83
89
|
monorepo: async (args) => (await import("../lib/commands/monorepo.mjs")).monorepoCommand(args),
|
|
84
90
|
link: async (args) => (await import("../lib/commands/link.mjs")).linkCommand(args),
|
|
85
|
-
audit:
|
|
91
|
+
audit: async (args) => (await import("../lib/commands/audit.mjs")).auditCommand(args),
|
|
92
|
+
scout: async (args) => (await import("../lib/commands/scout.mjs")).scoutCommand(args),
|
|
93
|
+
export: async (args) => (await import("../lib/commands/export.mjs")).exportCommand(args),
|
|
94
|
+
snapshot: async (args) => (await import("../lib/commands/snapshot.mjs")).snapshotCommand(args),
|
|
95
|
+
health: async (args) => (await import("../lib/commands/health.mjs")).healthCommand(args),
|
|
96
|
+
vibe: async (args) => (await import("../lib/commands/vibe.mjs")).vibeCommand(args),
|
|
97
|
+
adopt: async (args) => (await import("../lib/commands/adoptWizard.mjs")).adoptWizardCommand(args),
|
|
86
98
|
};
|
|
87
99
|
|
|
88
100
|
function formatCommandsHelp() {
|
|
@@ -279,6 +291,54 @@ ${formatCommandsHelp()}
|
|
|
279
291
|
--fail-on high|medium Exit 1 if unreviewed caps at given severity exist
|
|
280
292
|
--json Machine-readable output
|
|
281
293
|
|
|
294
|
+
${bold("vibe options:")}
|
|
295
|
+
--dir <dirs> Comma-separated directories to watch (default: auto-detected)
|
|
296
|
+
--no-suggest Disable automatic contract sync on file save
|
|
297
|
+
--no-context Disable CONTEXT.md regeneration
|
|
298
|
+
--interval <secs> Debounce seconds between saves (default: 4)
|
|
299
|
+
--port <n> Also run a mini status dashboard on localhost:<n>
|
|
300
|
+
--silent Suppress all terminal output (pure background mode)
|
|
301
|
+
|
|
302
|
+
${bold("adopt options:")}
|
|
303
|
+
--dir <dirs> Source directories to scan (default: src,lib,app,api,routes,controllers)
|
|
304
|
+
--yes, -y Auto-approve all candidates (non-interactive)
|
|
305
|
+
--json Machine-readable output, implies --yes
|
|
306
|
+
|
|
307
|
+
${bold("init --template options:")}
|
|
308
|
+
--template rest-api REST API (Express/Fastify/Hono) starter
|
|
309
|
+
--template nextjs Next.js fullstack app starter
|
|
310
|
+
--template cli CLI tool (Node.js/Python) starter
|
|
311
|
+
--template graphql GraphQL API (Apollo/Pothos) starter
|
|
312
|
+
--template monorepo Monorepo workspace starter
|
|
313
|
+
|
|
314
|
+
${bold("scout options:")}
|
|
315
|
+
--dir <dirs> Comma-separated directories to scan (default: src,lib,app,api,routes)
|
|
316
|
+
--apply Write discovered capabilities to the contract file
|
|
317
|
+
--min-confidence <0-1> Minimum confidence threshold (default: 0.6)
|
|
318
|
+
--json Machine-readable output
|
|
319
|
+
|
|
320
|
+
${bold("export options:")}
|
|
321
|
+
--format openapi|backstage|csv|markdown|json Output format (required)
|
|
322
|
+
--out <path> Output file path (default: project root, auto-named)
|
|
323
|
+
--json Machine-readable summary
|
|
324
|
+
|
|
325
|
+
${bold("snapshot sub-commands:")}
|
|
326
|
+
save <name> Save current contract as a named snapshot
|
|
327
|
+
list List all snapshots
|
|
328
|
+
show <name> Print a snapshot's capabilities
|
|
329
|
+
diff <name1> [<name2>] Diff two snapshots (omit name2 to diff against current)
|
|
330
|
+
restore <name> Overwrite contract with snapshot contents
|
|
331
|
+
delete <name> Delete a snapshot
|
|
332
|
+
|
|
333
|
+
${bold("snapshot options:")}
|
|
334
|
+
--json Machine-readable output
|
|
335
|
+
|
|
336
|
+
${bold("health options:")}
|
|
337
|
+
--fail-below <score> Exit 1 if health score is below this threshold (CI gate)
|
|
338
|
+
--watch Re-run every 30s (live terminal view)
|
|
339
|
+
--interval <secs> Watch interval in seconds (default: 30)
|
|
340
|
+
--json Machine-readable score + breakdown
|
|
341
|
+
|
|
282
342
|
${bold("Machine output:")}
|
|
283
343
|
${gray("status --json")}
|
|
284
344
|
${gray("check --json")}
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* infernoflow adopt
|
|
3
|
+
*
|
|
4
|
+
* Interactive adoption wizard for existing projects.
|
|
5
|
+
* Takes a project from zero to fully wired in one guided flow.
|
|
6
|
+
*
|
|
7
|
+
* Steps:
|
|
8
|
+
* 1. Detect project type, framework, language
|
|
9
|
+
* 2. Scout capabilities in source files
|
|
10
|
+
* 3. Interactive approve / rename / skip each candidate
|
|
11
|
+
* 4. Write capabilities.json + contract.json
|
|
12
|
+
* 5. Generate starter scenarios
|
|
13
|
+
* 6. Install git hooks
|
|
14
|
+
* 7. Regenerate CONTEXT.md
|
|
15
|
+
* 8. Show health score and next steps
|
|
16
|
+
*
|
|
17
|
+
* Usage:
|
|
18
|
+
* infernoflow adopt Full interactive wizard
|
|
19
|
+
* infernoflow adopt --yes Auto-approve all (non-interactive)
|
|
20
|
+
* infernoflow adopt --dir src Custom source directory
|
|
21
|
+
* infernoflow adopt --json Machine-readable, implies --yes
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import * as fs from "node:fs";
|
|
25
|
+
import * as path from "node:path";
|
|
26
|
+
import * as readline from "node:readline";
|
|
27
|
+
import { spawnSync } from "node:child_process";
|
|
28
|
+
import { done, warn, info, bold, cyan, gray, green, yellow, red } from "../ui/output.mjs";
|
|
29
|
+
|
|
30
|
+
// ── Detection ─────────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
function detectProject(cwd) {
|
|
33
|
+
const has = (...files) => files.some(f => fs.existsSync(path.join(cwd, f)));
|
|
34
|
+
const read = (f) => { try { return JSON.parse(fs.readFileSync(path.join(cwd, f), "utf8")); } catch { return {}; } };
|
|
35
|
+
|
|
36
|
+
const pkg = has("package.json") ? read("package.json") : {};
|
|
37
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
38
|
+
|
|
39
|
+
let framework = "unknown", lang = "javascript", type = "backend";
|
|
40
|
+
|
|
41
|
+
if (has("tsconfig.json")) lang = "typescript";
|
|
42
|
+
if (has("Pipfile","pyproject.toml","requirements.txt")) lang = "python";
|
|
43
|
+
if (has("Gemfile")) lang = "ruby";
|
|
44
|
+
if (has("go.mod")) lang = "go";
|
|
45
|
+
if (has("Cargo.toml")) lang = "rust";
|
|
46
|
+
|
|
47
|
+
if (deps["next"]) { framework = "nextjs"; type = "fullstack"; }
|
|
48
|
+
else if (deps["nuxt"]) { framework = "nuxt"; type = "fullstack"; }
|
|
49
|
+
else if (deps["react"]) { framework = "react"; type = "frontend"; }
|
|
50
|
+
else if (deps["vue"]) { framework = "vue"; type = "frontend"; }
|
|
51
|
+
else if (deps["@angular/core"]) { framework = "angular"; type = "frontend"; }
|
|
52
|
+
else if (deps["express"]) { framework = "express"; type = "backend"; }
|
|
53
|
+
else if (deps["fastify"]) { framework = "fastify"; type = "backend"; }
|
|
54
|
+
else if (deps["hono"]) { framework = "hono"; type = "backend"; }
|
|
55
|
+
else if (deps["@nestjs/core"]) { framework = "nestjs"; type = "backend"; }
|
|
56
|
+
else if (deps["graphql"]) { framework = "graphql"; type = "backend"; }
|
|
57
|
+
else if (has("config/routes.rb")) { framework = "rails"; type = "fullstack"; }
|
|
58
|
+
else if (has("manage.py")) { framework = "django"; type = "backend"; }
|
|
59
|
+
|
|
60
|
+
if (pkg.bin || has("bin/")) type = "cli";
|
|
61
|
+
if (has("nx.json","turbo.json","pnpm-workspace.yaml")) type = "monorepo";
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
name: pkg.name || path.basename(cwd),
|
|
65
|
+
version: pkg.version || "0.1.0",
|
|
66
|
+
description: pkg.description || "",
|
|
67
|
+
lang, framework, type,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ── Scout ─────────────────────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
const SRC_EXTS = new Set([".js",".mjs",".cjs",".ts",".tsx",".jsx",".py",".rb"]);
|
|
74
|
+
const SKIP_DIRS = new Set(["node_modules",".git","dist","build",".next","__pycache__","vendor","coverage","inferno"]);
|
|
75
|
+
|
|
76
|
+
const PATTERNS = [
|
|
77
|
+
{
|
|
78
|
+
regex: /(?:app|router|server)\s*\.\s*(get|post|put|patch|delete)\s*\(\s*['"`]([^'"`]+)['"`]/gi,
|
|
79
|
+
extract: m => ({ id: routeId(m[2], m[1]), hint: `${m[1].toUpperCase()} ${m[2]}`, confidence: 0.85 }),
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
filePattern: /[/\\](pages[/\\]api|app[/\\]api)[/\\](.+)\.(js|ts|mjs)$/,
|
|
83
|
+
extract: fp => {
|
|
84
|
+
const m = fp.match(/[/\\](pages[/\\]api|app[/\\]api)[/\\](.+)\.(js|ts|mjs)$/);
|
|
85
|
+
if (!m) return null;
|
|
86
|
+
const r = m[2].replace(/[/\\]index$/, "").replace(/\[([^\]]+)\]/g, ":$1");
|
|
87
|
+
return { id: routeId("/" + r, "api"), hint: `Next.js API: /${r}`, confidence: 0.9 };
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
regex: /export\s+(?:async\s+)?(?:function|class|const)\s+([A-Z][A-Za-z0-9]+)/g,
|
|
92
|
+
extract: m => ({ id: camelId(m[1]), hint: `export ${m[1]}`, confidence: 0.65 }),
|
|
93
|
+
},
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
function routeId(r, method) {
|
|
97
|
+
const id = r.replace(/^\//, "").replace(/\/:?[^/]+/g, "").replace(/\//g, "-")
|
|
98
|
+
.replace(/[^a-zA-Z0-9-]/g, "").replace(/-+/g, "-").replace(/^-|-$/g, "").toLowerCase();
|
|
99
|
+
return id || method.toLowerCase() + "-root";
|
|
100
|
+
}
|
|
101
|
+
function camelId(name) {
|
|
102
|
+
return name.replace(/([A-Z])/g, "-$1").toLowerCase().replace(/^-/, "").replace(/-+/g, "-");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function* walk(dir) {
|
|
106
|
+
if (!fs.existsSync(dir)) return;
|
|
107
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
108
|
+
if (SKIP_DIRS.has(e.name)) continue;
|
|
109
|
+
const full = path.join(dir, e.name);
|
|
110
|
+
if (e.isDirectory()) yield* walk(full);
|
|
111
|
+
else if (e.isFile() && SRC_EXTS.has(path.extname(e.name))) yield full;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function scout(dirs, cwd) {
|
|
116
|
+
const seen = new Map();
|
|
117
|
+
const add = (id, r, file) => {
|
|
118
|
+
if (!seen.has(id) || r.confidence > seen.get(id).confidence)
|
|
119
|
+
seen.set(id, { ...r, source: path.relative(cwd, file) });
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
for (const dir of dirs) {
|
|
123
|
+
for (const file of walk(dir)) {
|
|
124
|
+
for (const pat of PATTERNS) {
|
|
125
|
+
if (pat.filePattern && pat.filePattern.test(file)) {
|
|
126
|
+
const r = pat.extract(file);
|
|
127
|
+
if (r && r.id.length >= 3) add(r.id, r, file);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
let content;
|
|
131
|
+
try { content = fs.readFileSync(file, "utf8"); } catch { continue; }
|
|
132
|
+
for (const pat of PATTERNS) {
|
|
133
|
+
if (!pat.regex) continue;
|
|
134
|
+
pat.regex.lastIndex = 0;
|
|
135
|
+
let m;
|
|
136
|
+
while ((m = pat.regex.exec(content)) !== null) {
|
|
137
|
+
const r = pat.extract(m);
|
|
138
|
+
if (r && r.id.length >= 3 && !["index","app","main","root","default","handler"].includes(r.id))
|
|
139
|
+
add(r.id, r, file);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return [...seen.values()].filter(c => c.confidence >= 0.5).sort((a, b) => b.confidence - a.confidence);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ── Interactive review ────────────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
function ask(rl, q) { return new Promise(r => rl.question(q, r)); }
|
|
150
|
+
|
|
151
|
+
async function review(candidates, autoYes) {
|
|
152
|
+
if (autoYes) return candidates.filter(c => c.confidence >= 0.6);
|
|
153
|
+
|
|
154
|
+
const approved = [];
|
|
155
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
156
|
+
|
|
157
|
+
console.log();
|
|
158
|
+
console.log(` ${bold("Review candidates")} ${cyan("y")} approve · ${yellow("e")} edit · ${red("n")} skip · ${gray("a")} approve all · ${gray("q")} stop`);
|
|
159
|
+
console.log();
|
|
160
|
+
|
|
161
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
162
|
+
const c = candidates[i];
|
|
163
|
+
const conf = `${Math.round(c.confidence * 100)}%`;
|
|
164
|
+
const col = c.confidence >= 0.8 ? green : c.confidence >= 0.65 ? yellow : gray;
|
|
165
|
+
process.stdout.write(
|
|
166
|
+
` [${i + 1}/${candidates.length}] ${bold(c.id.padEnd(30))} ${col(conf)} ${gray(c.hint)}\n` +
|
|
167
|
+
` ${gray(c.source)}\n > `
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
const ans = (await ask(rl, "")).trim().toLowerCase();
|
|
171
|
+
if (ans === "q") break;
|
|
172
|
+
if (ans === "a") {
|
|
173
|
+
for (let j = i; j < candidates.length; j++) approved.push(candidates[j]);
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
if (ans === "n") { console.log(` ${gray("skipped")}\n`); continue; }
|
|
177
|
+
|
|
178
|
+
let id = c.id;
|
|
179
|
+
if (ans === "e") {
|
|
180
|
+
const newId = (await ask(rl, ` New id (${c.id}): `)).trim();
|
|
181
|
+
if (newId && /^[a-z0-9-_]+$/.test(newId)) id = newId;
|
|
182
|
+
}
|
|
183
|
+
approved.push({ ...c, id });
|
|
184
|
+
console.log(` ${green("✔")} ${bold(id)}\n`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
rl.close();
|
|
188
|
+
return approved;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ── Writers ───────────────────────────────────────────────────────────────────
|
|
192
|
+
|
|
193
|
+
function writeFiles(infernoDir, approved, project) {
|
|
194
|
+
// contract.json
|
|
195
|
+
const cp = path.join(infernoDir, "contract.json");
|
|
196
|
+
const existing = fs.existsSync(cp) ? JSON.parse(fs.readFileSync(cp, "utf8")) : {};
|
|
197
|
+
fs.writeFileSync(cp, JSON.stringify({
|
|
198
|
+
...existing,
|
|
199
|
+
policyId: existing.policyId || project.name.replace(/[^a-zA-Z0-9-]/g, "-"),
|
|
200
|
+
policyVersion: existing.policyVersion || 1,
|
|
201
|
+
capabilities: approved.map(c => c.id),
|
|
202
|
+
}, null, 2) + "\n");
|
|
203
|
+
|
|
204
|
+
// capabilities.json
|
|
205
|
+
fs.writeFileSync(path.join(infernoDir, "capabilities.json"), JSON.stringify({
|
|
206
|
+
capabilities: approved.map(c => ({
|
|
207
|
+
id: c.id, description: c.hint,
|
|
208
|
+
since: new Date().toISOString().slice(0, 10), source: "adopt",
|
|
209
|
+
})),
|
|
210
|
+
}, null, 2) + "\n");
|
|
211
|
+
|
|
212
|
+
// Starter scenarios
|
|
213
|
+
const scenDir = path.join(infernoDir, "scenarios");
|
|
214
|
+
if (!fs.existsSync(scenDir)) fs.mkdirSync(scenDir, { recursive: true });
|
|
215
|
+
for (const c of approved) {
|
|
216
|
+
const sp = path.join(scenDir, `${c.id}.json`);
|
|
217
|
+
if (fs.existsSync(sp)) continue;
|
|
218
|
+
fs.writeFileSync(sp, JSON.stringify({
|
|
219
|
+
id: `${c.id}-happy-path`, capability: c.id,
|
|
220
|
+
description: `Happy path for ${c.description || c.id}`,
|
|
221
|
+
steps: [{ action: "invoke", target: c.id, input: {} }, { action: "assert", field: "status", value: "success" }],
|
|
222
|
+
capabilitiesCovered: [c.id],
|
|
223
|
+
}, null, 2) + "\n");
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function runCli(args, cwd) {
|
|
228
|
+
return spawnSync("infernoflow", args, {
|
|
229
|
+
cwd, encoding: "utf8", timeout: 30_000,
|
|
230
|
+
stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, NO_COLOR: "1" },
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ── Entry ─────────────────────────────────────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
export async function adoptWizardCommand(rawArgs) {
|
|
237
|
+
const args = rawArgs.slice(1);
|
|
238
|
+
const autoYes = args.includes("--yes") || args.includes("-y") || args.includes("--json");
|
|
239
|
+
const jsonMode = args.includes("--json");
|
|
240
|
+
const cwd = process.cwd();
|
|
241
|
+
|
|
242
|
+
const dirIdx = args.indexOf("--dir");
|
|
243
|
+
const dirs = dirIdx !== -1
|
|
244
|
+
? args[dirIdx + 1].split(",").map(d => path.resolve(cwd, d.trim()))
|
|
245
|
+
: ["src","lib","app","api","pages","routes","controllers","services","handlers"]
|
|
246
|
+
.map(d => path.join(cwd, d)).filter(d => fs.existsSync(d));
|
|
247
|
+
if (!dirs.length) dirs.push(cwd);
|
|
248
|
+
|
|
249
|
+
const project = detectProject(cwd);
|
|
250
|
+
const infernoDir = path.join(cwd, "inferno");
|
|
251
|
+
if (!fs.existsSync(infernoDir)) {
|
|
252
|
+
fs.mkdirSync(infernoDir, { recursive: true });
|
|
253
|
+
fs.mkdirSync(path.join(infernoDir, "scenarios"), { recursive: true });
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (!jsonMode) {
|
|
257
|
+
console.log();
|
|
258
|
+
console.log(` ${bold("🔥 infernoflow adopt")} ${gray("— existing project wizard")}`);
|
|
259
|
+
console.log();
|
|
260
|
+
console.log(` ${bold("Project:")} ${cyan(project.name)} v${project.version}`);
|
|
261
|
+
console.log(` ${bold("Language:")} ${project.lang} ${bold("Framework:")} ${project.framework} ${bold("Type:")} ${project.type}`);
|
|
262
|
+
console.log();
|
|
263
|
+
info(`Scanning ${dirs.map(d => path.relative(cwd, d) || ".").join(", ")} …`);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const candidates = scout(dirs, cwd);
|
|
267
|
+
|
|
268
|
+
if (!candidates.length) {
|
|
269
|
+
if (jsonMode) { console.log(JSON.stringify({ ok: false, error: "No capabilities detected." })); return; }
|
|
270
|
+
warn("No capabilities detected. Use --dir src,lib,api");
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (!jsonMode) {
|
|
275
|
+
console.log();
|
|
276
|
+
console.log(` ${bold(`${candidates.length} capability candidates detected`)}`);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const approved = await review(candidates, autoYes);
|
|
280
|
+
|
|
281
|
+
if (!approved.length) {
|
|
282
|
+
if (jsonMode) { console.log(JSON.stringify({ ok: false, error: "No capabilities approved." })); return; }
|
|
283
|
+
warn("Nothing approved — exiting.");
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
writeFiles(infernoDir, approved, project);
|
|
288
|
+
runCli(["context"], cwd);
|
|
289
|
+
|
|
290
|
+
// Offer hooks in interactive mode
|
|
291
|
+
let hooksInstalled = false;
|
|
292
|
+
if (!autoYes) {
|
|
293
|
+
const rl3 = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
294
|
+
const ans = (await ask(rl3, `\n Install git hooks for auto-sync? ${gray("[Y/n]")} `)).trim().toLowerCase();
|
|
295
|
+
rl3.close();
|
|
296
|
+
if (ans !== "n") { runCli(["setup", "--yes"], cwd); hooksInstalled = true; }
|
|
297
|
+
} else {
|
|
298
|
+
runCli(["setup", "--yes"], cwd); hooksInstalled = true;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (jsonMode) {
|
|
302
|
+
console.log(JSON.stringify({ ok: true, project, detected: candidates.length, approved: approved.length, capabilities: approved.map(c => c.id), hooksInstalled }));
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
console.log();
|
|
307
|
+
done(`${bold(String(approved.length))} capabilities adopted`);
|
|
308
|
+
console.log();
|
|
309
|
+
console.log(` ${green("✔")} ${bold("inferno/contract.json")} written`);
|
|
310
|
+
console.log(` ${green("✔")} ${bold("inferno/capabilities.json")} written`);
|
|
311
|
+
console.log(` ${green("✔")} ${bold("inferno/scenarios/")} scaffolded`);
|
|
312
|
+
console.log(` ${green("✔")} ${bold("inferno/CONTEXT.md")} generated`);
|
|
313
|
+
if (hooksInstalled) console.log(` ${green("✔")} Git hooks installed`);
|
|
314
|
+
console.log();
|
|
315
|
+
console.log(` ${bold("Next steps:")}`);
|
|
316
|
+
console.log(` ${cyan("1.")} Paste ${bold("inferno/CONTEXT.md")} into Claude or Cursor`);
|
|
317
|
+
console.log(` ${cyan("2.")} Run ${bold("infernoflow vibe")} to keep everything in sync automatically`);
|
|
318
|
+
console.log(` ${cyan("3.")} Run ${bold("infernoflow health")} to see your starting score`);
|
|
319
|
+
console.log();
|
|
320
|
+
}
|
|
@@ -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
|
+
}
|