infernoflow 0.27.0 → 0.29.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 +17 -0
- package/dist/lib/commands/explain.mjs +373 -0
- package/dist/lib/commands/scaffold.mjs +489 -0
- package/package.json +1 -1
package/dist/bin/infernoflow.mjs
CHANGED
|
@@ -61,6 +61,8 @@ const COMMAND_DESCRIPTIONS = {
|
|
|
61
61
|
thaw: "Reset a capability to experimental (liquid) — free to evolve",
|
|
62
62
|
why: "Given a file or function name — show which capability it serves, scenarios, stability, and git history",
|
|
63
63
|
impact: "Blast radius analysis — see every cap, scenario, and risk level affected before you change anything",
|
|
64
|
+
scaffold: "Generate a new capability — source skeleton, contract registration, and placeholder scenario in one command",
|
|
65
|
+
explain: "AI narrative about a capability — what it does, why it exists, what's risky, and what to test",
|
|
64
66
|
};
|
|
65
67
|
|
|
66
68
|
const COMMAND_HANDLERS = {
|
|
@@ -115,6 +117,8 @@ const COMMAND_HANDLERS = {
|
|
|
115
117
|
thaw: async (args) => (await import("../lib/commands/stability.mjs")).thawCommand(args),
|
|
116
118
|
why: async (args) => (await import("../lib/commands/why.mjs")).whyCommand(args),
|
|
117
119
|
impact: async (args) => (await import("../lib/commands/impact.mjs")).impactCommand(args),
|
|
120
|
+
scaffold: async (args) => (await import("../lib/commands/scaffold.mjs")).scaffoldCommand(args),
|
|
121
|
+
explain: async (args) => (await import("../lib/commands/explain.mjs")).explainCommand(args),
|
|
118
122
|
};
|
|
119
123
|
|
|
120
124
|
function formatCommandsHelp() {
|
|
@@ -405,6 +409,19 @@ ${formatCommandsHelp()}
|
|
|
405
409
|
--check Exit 1 if risk level is HIGH or CRITICAL (CI gate)
|
|
406
410
|
--json Machine-readable output
|
|
407
411
|
|
|
412
|
+
${bold("scaffold options:")}
|
|
413
|
+
infernoflow scaffold <cap-id> Generate a new capability skeleton
|
|
414
|
+
--dir <path> Output directory for the source file (default: auto-detected)
|
|
415
|
+
--lang ts|js|py|go Language override (default: auto-detected from project)
|
|
416
|
+
--description "..." Capability description to embed in the file
|
|
417
|
+
--dry-run Preview what would be generated without writing files
|
|
418
|
+
--json Machine-readable output including generated code
|
|
419
|
+
|
|
420
|
+
${bold("explain options:")}
|
|
421
|
+
infernoflow explain <cap-id> AI narrative: what it does, risk, what to test
|
|
422
|
+
--dry-run Print the AI prompt only — no API call made
|
|
423
|
+
--json Machine-readable output (narrative, stability, scenarios)
|
|
424
|
+
|
|
408
425
|
${bold("Machine output:")}
|
|
409
426
|
${gray("status --json")}
|
|
410
427
|
${gray("check --json")}
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* infernoflow explain
|
|
3
|
+
*
|
|
4
|
+
* AI-generated narrative about a capability — what it does, why it exists,
|
|
5
|
+
* what's risky about it, and what to test before changing it.
|
|
6
|
+
*
|
|
7
|
+
* Synthesises: stability level, git history, scenarios, callers, services,
|
|
8
|
+
* source files — then calls the AI provider for a 3-5 sentence human narrative.
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* infernoflow explain user-auth
|
|
12
|
+
* infernoflow explain payment-process --dry-run (print prompt only)
|
|
13
|
+
* infernoflow explain user-auth --json
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import * as fs from "node:fs";
|
|
17
|
+
import * as path from "node:path";
|
|
18
|
+
import { execSync } from "node:child_process";
|
|
19
|
+
import { bold, cyan, gray, green, yellow, red } from "../ui/output.mjs";
|
|
20
|
+
|
|
21
|
+
// ── helpers ───────────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
function loadJson(p) {
|
|
24
|
+
try { return JSON.parse(fs.readFileSync(p, "utf8")); }
|
|
25
|
+
catch { return null; }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function runGit(cmd, cwd) {
|
|
29
|
+
try {
|
|
30
|
+
return execSync(cmd, { cwd, encoding: "utf8", stdio: ["pipe","pipe","pipe"] }).trim();
|
|
31
|
+
} catch { return ""; }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const LEVEL_ICON = { frozen: "🧊", stable: "〰️ ", experimental: "🌊" };
|
|
35
|
+
const LEVEL_COLOR = { frozen: red, stable: yellow, experimental: green };
|
|
36
|
+
|
|
37
|
+
function stability(cap) { return cap?.stability || "experimental"; }
|
|
38
|
+
|
|
39
|
+
// ── git helpers ───────────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
function getFirstCommit(filePath, cwd) {
|
|
42
|
+
if (!filePath) return null;
|
|
43
|
+
const rel = path.relative(cwd, path.resolve(cwd, filePath));
|
|
44
|
+
const log = runGit(
|
|
45
|
+
`git log --follow --format="%h|%aI|%ae|%s" -- ${JSON.stringify(rel)}`, cwd
|
|
46
|
+
);
|
|
47
|
+
if (!log) return null;
|
|
48
|
+
const lines = log.split("\n").filter(Boolean);
|
|
49
|
+
if (!lines.length) return null;
|
|
50
|
+
const [hash, date, author, ...subjectParts] = lines[lines.length - 1].split("|");
|
|
51
|
+
return {
|
|
52
|
+
hash: hash?.trim(),
|
|
53
|
+
date: date?.trim() ? new Date(date.trim()).toLocaleDateString() : "",
|
|
54
|
+
author: author?.trim(),
|
|
55
|
+
subject: subjectParts.join("|").trim(),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function getRecentHistory(filePath, cwd, limit = 5) {
|
|
60
|
+
if (!filePath) return [];
|
|
61
|
+
const rel = path.relative(cwd, path.resolve(cwd, filePath));
|
|
62
|
+
const log = runGit(
|
|
63
|
+
`git log --follow --format="%h|%aI|%ae|%s" -${limit} -- ${JSON.stringify(rel)}`, cwd
|
|
64
|
+
);
|
|
65
|
+
if (!log) return [];
|
|
66
|
+
return log.split("\n").filter(Boolean).map(line => {
|
|
67
|
+
const [hash, date, author, ...subjectParts] = line.split("|");
|
|
68
|
+
return {
|
|
69
|
+
hash: hash?.trim(),
|
|
70
|
+
date: date?.trim() ? new Date(date.trim()).toLocaleDateString() : "",
|
|
71
|
+
author: author?.trim(),
|
|
72
|
+
subject: subjectParts.join("|").trim(),
|
|
73
|
+
};
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── scenario finder ───────────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
function findScenarios(capId, infernoDir) {
|
|
80
|
+
const dir = path.join(infernoDir, "scenarios");
|
|
81
|
+
if (!fs.existsSync(dir)) return [];
|
|
82
|
+
const found = [];
|
|
83
|
+
for (const f of fs.readdirSync(dir)) {
|
|
84
|
+
if (!f.endsWith(".json")) continue;
|
|
85
|
+
try {
|
|
86
|
+
const s = JSON.parse(fs.readFileSync(path.join(dir, f), "utf8"));
|
|
87
|
+
const covered = s.capabilitiesCovered || s.capabilities || [];
|
|
88
|
+
if (covered.some(c => c.toLowerCase() === capId.toLowerCase())) {
|
|
89
|
+
found.push(s);
|
|
90
|
+
}
|
|
91
|
+
} catch {}
|
|
92
|
+
}
|
|
93
|
+
return found;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ── prompt builder ────────────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
function buildPrompt(capId, cap, scanEntry, graph, allCaps, scenarios, firstCommit, recentHistory) {
|
|
99
|
+
const level = stability(cap);
|
|
100
|
+
const files = scanEntry?.codeAnalysis?.sourceFiles || [];
|
|
101
|
+
const functions = scanEntry?.codeAnalysis?.functions || [];
|
|
102
|
+
const services = scanEntry?.codeAnalysis?.services || [];
|
|
103
|
+
const throws_ = scanEntry?.codeAnalysis?.throws || [];
|
|
104
|
+
const calls = scanEntry?.codeAnalysis?.calls || [];
|
|
105
|
+
const deps = graph?.deps?.[capId] || [];
|
|
106
|
+
const dependents = graph?.dependents?.[capId] || [];
|
|
107
|
+
|
|
108
|
+
const lines = [
|
|
109
|
+
`You are a senior engineer writing a brief, plain-English explanation of a software capability for a teammate who is about to modify it.`,
|
|
110
|
+
``,
|
|
111
|
+
`Write 3–5 sentences covering:`,
|
|
112
|
+
` 1. What this capability does and why it exists`,
|
|
113
|
+
` 2. The most important thing to know before changing it (stability, callers, risk)`,
|
|
114
|
+
` 3. What to test or verify after any modification`,
|
|
115
|
+
``,
|
|
116
|
+
`Be concrete and direct. Do not use bullet points. Do not repeat the capability ID verbatim in every sentence.`,
|
|
117
|
+
``,
|
|
118
|
+
`=== Capability: ${capId} ===`,
|
|
119
|
+
`Name: ${cap.name || cap.title || capId}`,
|
|
120
|
+
`Description: ${cap.description || "(none provided)"}`,
|
|
121
|
+
`Stability: ${level}`,
|
|
122
|
+
];
|
|
123
|
+
|
|
124
|
+
if (files.length) lines.push(`Source files: ${files.join(", ")}`);
|
|
125
|
+
if (functions.length) lines.push(`Functions: ${functions.join(", ")}`);
|
|
126
|
+
if (services.length) lines.push(`External services used: ${services.join(", ")}`);
|
|
127
|
+
if (throws_.length) lines.push(`Can throw: ${throws_.join(", ")}`);
|
|
128
|
+
if (calls.length) lines.push(`Internal calls: ${calls.join(", ")}`);
|
|
129
|
+
|
|
130
|
+
if (deps.length) {
|
|
131
|
+
const depDetails = deps.map(d => {
|
|
132
|
+
const dc = allCaps.find(c => c.id === d);
|
|
133
|
+
return `${d} (${stability(dc)})`;
|
|
134
|
+
});
|
|
135
|
+
lines.push(`Calls capabilities: ${depDetails.join(", ")}`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (dependents.length) {
|
|
139
|
+
const depDetails = dependents.map(d => {
|
|
140
|
+
const dc = allCaps.find(c => c.id === d);
|
|
141
|
+
return `${d} (${stability(dc)})`;
|
|
142
|
+
});
|
|
143
|
+
lines.push(`Called by capabilities: ${depDetails.join(", ")}`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (scenarios.length) {
|
|
147
|
+
lines.push(`Test scenarios: ${scenarios.map(s => s.scenarioId || s.description || "unnamed").join(", ")}`);
|
|
148
|
+
} else {
|
|
149
|
+
lines.push(`Test scenarios: none registered`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (firstCommit) {
|
|
153
|
+
lines.push(`First introduced: ${firstCommit.date} by ${firstCommit.author} — "${firstCommit.subject}"`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (recentHistory.length) {
|
|
157
|
+
lines.push(`Recent changes:`);
|
|
158
|
+
for (const h of recentHistory.slice(0, 3)) {
|
|
159
|
+
lines.push(` ${h.date} — ${h.subject}`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (level === "frozen") {
|
|
164
|
+
lines.push(`IMPORTANT: This capability is FROZEN. Any modification requires explicit approval.`);
|
|
165
|
+
} else if (level === "stable") {
|
|
166
|
+
lines.push(`NOTE: This capability is STABLE. Prefer additive changes; avoid breaking the public API.`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return lines.join("\n");
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ── AI caller ─────────────────────────────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
async function callAI(prompt, cwd) {
|
|
175
|
+
try {
|
|
176
|
+
const { callAI: call } = await import("../ai/providerRouter.mjs");
|
|
177
|
+
return await call(prompt, cwd);
|
|
178
|
+
} catch {
|
|
179
|
+
// Provider not available — return a structured fallback
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ── fallback narrative (no AI) ────────────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
function buildFallback(capId, cap, scanEntry, graph, allCaps, scenarios) {
|
|
187
|
+
const level = stability(cap);
|
|
188
|
+
const name = cap.name || cap.title || capId;
|
|
189
|
+
const services = scanEntry?.codeAnalysis?.services || [];
|
|
190
|
+
const dependents = graph?.dependents?.[capId] || [];
|
|
191
|
+
const deps = graph?.deps?.[capId] || [];
|
|
192
|
+
|
|
193
|
+
const parts = [];
|
|
194
|
+
|
|
195
|
+
// What it does
|
|
196
|
+
if (cap.description && cap.description !== "(none provided)") {
|
|
197
|
+
parts.push(`${name} — ${cap.description}.`);
|
|
198
|
+
} else {
|
|
199
|
+
parts.push(`${name} handles the ${capId} flow within this system.`);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// External services
|
|
203
|
+
if (services.length) {
|
|
204
|
+
parts.push(`It integrates with ${services.join(" and ")}.`);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Dependencies
|
|
208
|
+
if (deps.length) {
|
|
209
|
+
parts.push(`It depends on: ${deps.join(", ")}.`);
|
|
210
|
+
}
|
|
211
|
+
if (dependents.length) {
|
|
212
|
+
const frozenDeps = dependents.filter(d => stability(allCaps.find(c => c.id === d)) === "frozen");
|
|
213
|
+
if (frozenDeps.length) {
|
|
214
|
+
parts.push(`⚠️ ${frozenDeps.join(", ")} depend${frozenDeps.length === 1 ? "s" : ""} on this — changing it may break frozen capabilities.`);
|
|
215
|
+
} else {
|
|
216
|
+
parts.push(`${dependents.join(", ")} depend${dependents.length === 1 ? "s" : ""} on this capability.`);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Stability advice
|
|
221
|
+
if (level === "frozen") {
|
|
222
|
+
parts.push(`This capability is FROZEN — do not modify without explicit instruction.`);
|
|
223
|
+
} else if (level === "stable") {
|
|
224
|
+
parts.push(`This capability is stable — prefer additive changes and avoid breaking the existing API surface.`);
|
|
225
|
+
} else {
|
|
226
|
+
parts.push(`This capability is experimental — free to refactor as needed.`);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Test advice
|
|
230
|
+
if (scenarios.length) {
|
|
231
|
+
parts.push(`Before shipping changes, run the registered scenarios: ${scenarios.map(s => s.scenarioId || "unnamed").join(", ")}.`);
|
|
232
|
+
} else {
|
|
233
|
+
parts.push(`No test scenarios are registered — consider adding one before making changes.`);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return parts.join(" ");
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ── printer ───────────────────────────────────────────────────────────────────
|
|
240
|
+
|
|
241
|
+
function printExplain(capId, cap, narrative, provider, dryRun) {
|
|
242
|
+
const level = stability(cap);
|
|
243
|
+
const icon = LEVEL_ICON[level] || "🌊";
|
|
244
|
+
const color = LEVEL_COLOR[level] || green;
|
|
245
|
+
|
|
246
|
+
console.log();
|
|
247
|
+
console.log(bold(` ${icon} ${color(capId)}`));
|
|
248
|
+
if (cap.name || cap.title) console.log(gray(` ${cap.name || cap.title}`));
|
|
249
|
+
console.log();
|
|
250
|
+
|
|
251
|
+
if (dryRun) {
|
|
252
|
+
console.log(yellow(" [dry-run] Prompt only — no AI call made"));
|
|
253
|
+
console.log();
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Word-wrap narrative at ~80 chars
|
|
258
|
+
const words = narrative.split(" ");
|
|
259
|
+
let line = " ";
|
|
260
|
+
const lines = [];
|
|
261
|
+
for (const word of words) {
|
|
262
|
+
if (line.length + word.length > 82) { lines.push(line); line = " " + word; }
|
|
263
|
+
else line += (line === " " ? "" : " ") + word;
|
|
264
|
+
}
|
|
265
|
+
if (line.trim()) lines.push(line);
|
|
266
|
+
|
|
267
|
+
for (const l of lines) console.log(l);
|
|
268
|
+
console.log();
|
|
269
|
+
|
|
270
|
+
if (provider) {
|
|
271
|
+
console.log(gray(` ── via ${provider}`));
|
|
272
|
+
} else {
|
|
273
|
+
console.log(gray(" ── (AI provider not configured — showing structural summary)"));
|
|
274
|
+
console.log(gray(" Run: infernoflow setup to connect an AI provider"));
|
|
275
|
+
}
|
|
276
|
+
console.log();
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ── entry point ───────────────────────────────────────────────────────────────
|
|
280
|
+
|
|
281
|
+
export async function explainCommand(rawArgs) {
|
|
282
|
+
const args = (rawArgs || []).slice(1);
|
|
283
|
+
const dryRun = args.includes("--dry-run");
|
|
284
|
+
const jsonMode = args.includes("--json");
|
|
285
|
+
|
|
286
|
+
const capId = args.find(a => !a.startsWith("--"));
|
|
287
|
+
|
|
288
|
+
if (!capId) {
|
|
289
|
+
console.error(red("✗ Usage: infernoflow explain <capability-id> [--dry-run] [--json]"));
|
|
290
|
+
console.error(gray(" Example: infernoflow explain user-auth"));
|
|
291
|
+
process.exit(1);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const cwd = process.cwd();
|
|
295
|
+
const infernoDir = path.join(cwd, "inferno");
|
|
296
|
+
|
|
297
|
+
// Load capabilities
|
|
298
|
+
let allCaps = [];
|
|
299
|
+
const rawCaps = loadJson(path.join(infernoDir, "capabilities.json"));
|
|
300
|
+
if (rawCaps) allCaps = Array.isArray(rawCaps) ? rawCaps : (rawCaps.capabilities || []);
|
|
301
|
+
|
|
302
|
+
const cap = allCaps.find(c => c.id === capId);
|
|
303
|
+
if (!cap) {
|
|
304
|
+
console.error(red(`✗ Capability "${capId}" not found in capabilities.json`));
|
|
305
|
+
console.error(gray(" Run: infernoflow stability — to list all capability IDs"));
|
|
306
|
+
process.exit(1);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Load scan + graph
|
|
310
|
+
const scanData = loadJson(path.join(infernoDir, "scan.json"));
|
|
311
|
+
const graph = loadJson(path.join(infernoDir, "graph.json"));
|
|
312
|
+
const scanEntry = scanData?.capabilities?.find(c => c.id === capId);
|
|
313
|
+
|
|
314
|
+
// Git history
|
|
315
|
+
const files = scanEntry?.codeAnalysis?.sourceFiles || [];
|
|
316
|
+
const firstCommit = getFirstCommit(files[0], cwd);
|
|
317
|
+
const recentHistory = getRecentHistory(files[0], cwd);
|
|
318
|
+
|
|
319
|
+
// Scenarios
|
|
320
|
+
const scenarios = findScenarios(capId, infernoDir);
|
|
321
|
+
|
|
322
|
+
// Build prompt
|
|
323
|
+
const prompt = buildPrompt(capId, cap, scanEntry, graph, allCaps, scenarios, firstCommit, recentHistory);
|
|
324
|
+
|
|
325
|
+
if (dryRun && !jsonMode) {
|
|
326
|
+
console.log(gray(`\n infernoflow explain → ${bold(capId)}`));
|
|
327
|
+
console.log(gray(" ──────────────────────────────────────────────────────────────"));
|
|
328
|
+
printExplain(capId, cap, "", null, true);
|
|
329
|
+
console.log(bold(" Prompt that would be sent to AI:"));
|
|
330
|
+
console.log();
|
|
331
|
+
console.log(prompt.split("\n").map(l => " " + l).join("\n"));
|
|
332
|
+
console.log();
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Call AI
|
|
337
|
+
let narrative = null;
|
|
338
|
+
let provider = null;
|
|
339
|
+
|
|
340
|
+
if (!dryRun) {
|
|
341
|
+
try {
|
|
342
|
+
const result = await callAI(prompt, cwd);
|
|
343
|
+
if (result?.text) {
|
|
344
|
+
narrative = result.text.trim();
|
|
345
|
+
provider = result.provider;
|
|
346
|
+
}
|
|
347
|
+
} catch {}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Fallback if no AI
|
|
351
|
+
if (!narrative) {
|
|
352
|
+
narrative = buildFallback(capId, cap, scanEntry, graph, allCaps, scenarios);
|
|
353
|
+
provider = null;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (jsonMode) {
|
|
357
|
+
console.log(JSON.stringify({
|
|
358
|
+
capId,
|
|
359
|
+
name: cap.name || cap.title,
|
|
360
|
+
stability: stability(cap),
|
|
361
|
+
narrative,
|
|
362
|
+
provider: provider || "fallback",
|
|
363
|
+
sourceFiles: files,
|
|
364
|
+
scenarios: scenarios.map(s => s.scenarioId || s.description),
|
|
365
|
+
firstCommit,
|
|
366
|
+
}, null, 2));
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
console.log(gray(`\n infernoflow explain → ${bold(capId)}`));
|
|
371
|
+
console.log(gray(" ──────────────────────────────────────────────────────────────"));
|
|
372
|
+
printExplain(capId, cap, narrative, provider, false);
|
|
373
|
+
}
|
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* infernoflow scaffold
|
|
3
|
+
*
|
|
4
|
+
* Generate a new capability — source file skeleton + contract registration
|
|
5
|
+
* + placeholder scenario — pre-wired to the project's detected patterns.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* infernoflow scaffold payment-refund
|
|
9
|
+
* infernoflow scaffold payment-refund --dir src/payments
|
|
10
|
+
* infernoflow scaffold payment-refund --lang ts --dry-run
|
|
11
|
+
* infernoflow scaffold payment-refund --json
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import * as fs from "node:fs";
|
|
15
|
+
import * as path from "node:path";
|
|
16
|
+
import { bold, cyan, gray, green, yellow, red } from "../ui/output.mjs";
|
|
17
|
+
|
|
18
|
+
// ── helpers ───────────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
function loadJson(p) {
|
|
21
|
+
try { return JSON.parse(fs.readFileSync(p, "utf8")); }
|
|
22
|
+
catch { return null; }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function saveJson(p, data) {
|
|
26
|
+
fs.writeFileSync(p, JSON.stringify(data, null, 2) + "\n");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ── name derivers ─────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
/** payment-refund → PaymentRefund */
|
|
32
|
+
function toPascalCase(id) {
|
|
33
|
+
return id.split(/[-_]/).map(w => w.charAt(0).toUpperCase() + w.slice(1)).join("");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** payment-refund → paymentRefund */
|
|
37
|
+
function toCamelCase(id) {
|
|
38
|
+
const p = toPascalCase(id);
|
|
39
|
+
return p.charAt(0).toLowerCase() + p.slice(1);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** payment-refund → "Payment Refund" */
|
|
43
|
+
function toTitle(id) {
|
|
44
|
+
return id.split(/[-_]/).map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Derive a primary function name from the cap ID.
|
|
49
|
+
* payment-refund → refundPayment, user-auth → authenticateUser
|
|
50
|
+
*/
|
|
51
|
+
function primaryFnName(id) {
|
|
52
|
+
const parts = id.split(/[-_]/);
|
|
53
|
+
if (parts.length === 1) return toCamelCase(id);
|
|
54
|
+
|
|
55
|
+
// Common verb patterns: if second word is a verb-like term, flip order
|
|
56
|
+
const VERBS = ["auth", "login", "logout", "register", "refresh", "validate",
|
|
57
|
+
"verify", "process", "refund", "charge", "send", "fetch",
|
|
58
|
+
"create", "update", "delete", "get", "list", "search",
|
|
59
|
+
"sync", "import", "export", "scan", "check", "notify"];
|
|
60
|
+
|
|
61
|
+
const last = parts[parts.length - 1];
|
|
62
|
+
const first = parts[0];
|
|
63
|
+
|
|
64
|
+
// If last part looks like a noun and first part looks verb-like, use as-is
|
|
65
|
+
if (VERBS.includes(first)) {
|
|
66
|
+
// auth-user → authenticateUser style (expand verb)
|
|
67
|
+
const verbExpand = { auth: "authenticate", get: "get", list: "list",
|
|
68
|
+
send: "send", check: "check", notify: "notify" };
|
|
69
|
+
const verb = verbExpand[first] || first;
|
|
70
|
+
const rest = parts.slice(1).map((w, i) =>
|
|
71
|
+
i === 0 ? w.charAt(0).toUpperCase() + w.slice(1) : w
|
|
72
|
+
).join("");
|
|
73
|
+
return verb + rest.charAt(0).toUpperCase() + rest.slice(1);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Default: flip last+first — payment-refund → refundPayment
|
|
77
|
+
if (VERBS.includes(last)) {
|
|
78
|
+
const noun = parts.slice(0, -1).map((w, i) =>
|
|
79
|
+
i === 0 ? w.charAt(0).toUpperCase() + w.slice(1) : w
|
|
80
|
+
).join("");
|
|
81
|
+
return last + noun;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return toCamelCase(id);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── language detector ─────────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
function detectLang(scan, profile, cwd) {
|
|
90
|
+
// 1. From scan source files
|
|
91
|
+
if (scan?.capabilities?.length) {
|
|
92
|
+
const files = scan.capabilities.flatMap(c => c.codeAnalysis?.sourceFiles || []);
|
|
93
|
+
const exts = files.map(f => path.extname(f));
|
|
94
|
+
if (exts.filter(e => e === ".ts").length > exts.filter(e => e === ".js").length) return "ts";
|
|
95
|
+
if (exts.includes(".py")) return "py";
|
|
96
|
+
if (exts.includes(".go")) return "go";
|
|
97
|
+
if (exts.some(e => e === ".js" || e === ".mjs")) return "js";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// 2. From profile
|
|
101
|
+
const lang = profile?.language || profile?.lang;
|
|
102
|
+
if (lang) return lang.toLowerCase().replace("javascript", "js").replace("typescript", "ts");
|
|
103
|
+
|
|
104
|
+
// 3. From project files
|
|
105
|
+
if (fs.existsSync(path.join(cwd, "tsconfig.json"))) return "ts";
|
|
106
|
+
if (fs.existsSync(path.join(cwd, "pyproject.toml"))) return "py";
|
|
107
|
+
if (fs.existsSync(path.join(cwd, "go.mod"))) return "go";
|
|
108
|
+
|
|
109
|
+
return "js";
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function detectSrcDir(scan, cwd) {
|
|
113
|
+
if (!scan?.capabilities?.length) return null;
|
|
114
|
+
const files = scan.capabilities.flatMap(c => c.codeAnalysis?.sourceFiles || []);
|
|
115
|
+
if (!files.length) return null;
|
|
116
|
+
|
|
117
|
+
// Count dir prefixes
|
|
118
|
+
const dirCount = {};
|
|
119
|
+
for (const f of files) {
|
|
120
|
+
const dir = path.dirname(f).split("/")[0];
|
|
121
|
+
dirCount[dir] = (dirCount[dir] || 0) + 1;
|
|
122
|
+
}
|
|
123
|
+
const top = Object.entries(dirCount).sort((a, b) => b[1] - a[1])[0];
|
|
124
|
+
return top ? top[0] : null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function detectServices(scan) {
|
|
128
|
+
if (!scan?.capabilities?.length) return [];
|
|
129
|
+
const all = scan.capabilities.flatMap(c => c.codeAnalysis?.services || []);
|
|
130
|
+
return [...new Set(all)];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ── code generators ───────────────────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
function generateTs(id, name, description, fn, services) {
|
|
136
|
+
const pascal = toPascalCase(id);
|
|
137
|
+
const errorName = `${pascal}Error`;
|
|
138
|
+
const imports = buildImports("ts", services);
|
|
139
|
+
|
|
140
|
+
return `/**
|
|
141
|
+
* ${name}
|
|
142
|
+
*
|
|
143
|
+
* ${description}
|
|
144
|
+
*
|
|
145
|
+
* @capability ${id}
|
|
146
|
+
* @stability experimental
|
|
147
|
+
*/
|
|
148
|
+
${imports}
|
|
149
|
+
|
|
150
|
+
// ── errors ────────────────────────────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
export class ${errorName} extends Error {
|
|
153
|
+
constructor(message: string, public readonly code?: string) {
|
|
154
|
+
super(message);
|
|
155
|
+
this.name = "${errorName}";
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ── types ─────────────────────────────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
export interface ${pascal}Input {
|
|
162
|
+
// TODO: define input fields
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export interface ${pascal}Result {
|
|
166
|
+
// TODO: define result fields
|
|
167
|
+
success: boolean;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ── implementation ────────────────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* ${fn} — primary entry point for ${name}.
|
|
174
|
+
* TODO: implement this function.
|
|
175
|
+
*/
|
|
176
|
+
export async function ${fn}(input: ${pascal}Input): Promise<${pascal}Result> {
|
|
177
|
+
// TODO: implement
|
|
178
|
+
throw new ${errorName}("Not implemented yet");
|
|
179
|
+
}
|
|
180
|
+
`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function generateJs(id, name, description, fn, services) {
|
|
184
|
+
const pascal = toPascalCase(id);
|
|
185
|
+
const errorName = `${pascal}Error`;
|
|
186
|
+
const imports = buildImports("js", services);
|
|
187
|
+
|
|
188
|
+
return `/**
|
|
189
|
+
* ${name}
|
|
190
|
+
*
|
|
191
|
+
* ${description}
|
|
192
|
+
*
|
|
193
|
+
* @capability ${id}
|
|
194
|
+
* @stability experimental
|
|
195
|
+
*/
|
|
196
|
+
${imports}
|
|
197
|
+
|
|
198
|
+
// ── errors ────────────────────────────────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
export class ${errorName} extends Error {
|
|
201
|
+
constructor(message, code) {
|
|
202
|
+
super(message);
|
|
203
|
+
this.name = "${errorName}";
|
|
204
|
+
this.code = code;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ── implementation ────────────────────────────────────────────────────────────
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* ${fn} — primary entry point for ${name}.
|
|
212
|
+
* TODO: implement this function.
|
|
213
|
+
*
|
|
214
|
+
* @param {object} input
|
|
215
|
+
* @returns {Promise<object>}
|
|
216
|
+
*/
|
|
217
|
+
export async function ${fn}(input = {}) {
|
|
218
|
+
// TODO: implement
|
|
219
|
+
throw new ${errorName}("Not implemented yet");
|
|
220
|
+
}
|
|
221
|
+
`;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function generatePy(id, name, description, fn) {
|
|
225
|
+
const cls = toPascalCase(id);
|
|
226
|
+
|
|
227
|
+
return `"""
|
|
228
|
+
${name}
|
|
229
|
+
|
|
230
|
+
${description}
|
|
231
|
+
|
|
232
|
+
capability: ${id}
|
|
233
|
+
stability: experimental
|
|
234
|
+
"""
|
|
235
|
+
|
|
236
|
+
from typing import Any
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
class ${cls}Error(Exception):
|
|
240
|
+
"""Raised when ${name} operations fail."""
|
|
241
|
+
def __init__(self, message: str, code: str | None = None):
|
|
242
|
+
super().__init__(message)
|
|
243
|
+
self.code = code
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
async def ${fn.replace(/([A-Z])/g, '_$1').toLowerCase().replace(/^_/, '')}(input: dict[str, Any]) -> dict[str, Any]:
|
|
247
|
+
"""Primary entry point for ${name}.
|
|
248
|
+
|
|
249
|
+
TODO: implement this function.
|
|
250
|
+
"""
|
|
251
|
+
raise ${cls}Error("Not implemented yet")
|
|
252
|
+
`;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function generateGo(id, name, description, fn) {
|
|
256
|
+
const pkg = id.split("-")[0];
|
|
257
|
+
|
|
258
|
+
return `// Package ${pkg} implements ${name}.
|
|
259
|
+
//
|
|
260
|
+
// ${description}
|
|
261
|
+
//
|
|
262
|
+
// capability: ${id}
|
|
263
|
+
// stability: experimental
|
|
264
|
+
package ${pkg}
|
|
265
|
+
|
|
266
|
+
import "errors"
|
|
267
|
+
|
|
268
|
+
// Err${toPascalCase(id)} is returned when ${name} operations fail.
|
|
269
|
+
var Err${toPascalCase(id)} = errors.New("${id}: operation failed")
|
|
270
|
+
|
|
271
|
+
// ${toPascalCase(fn)} is the primary entry point for ${name}.
|
|
272
|
+
// TODO: implement this function.
|
|
273
|
+
func ${toPascalCase(fn)}(input map[string]any) (map[string]any, error) {
|
|
274
|
+
\treturn nil, Err${toPascalCase(id)}
|
|
275
|
+
}
|
|
276
|
+
`;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function buildImports(lang, services) {
|
|
280
|
+
if (!services.length) return "";
|
|
281
|
+
const lines = [];
|
|
282
|
+
if (lang === "ts" || lang === "js") {
|
|
283
|
+
// Suggest common client imports for detected services
|
|
284
|
+
const serviceImports = {
|
|
285
|
+
stripe: `// import Stripe from 'stripe';`,
|
|
286
|
+
postgres: `// import { Pool } from 'pg';`,
|
|
287
|
+
mysql: `// import mysql from 'mysql2/promise';`,
|
|
288
|
+
redis: `// import { createClient } from 'redis';`,
|
|
289
|
+
s3: `// import { S3Client } from '@aws-sdk/client-s3';`,
|
|
290
|
+
sendgrid: `// import sgMail from '@sendgrid/mail';`,
|
|
291
|
+
twilio: `// import twilio from 'twilio';`,
|
|
292
|
+
openai: `// import OpenAI from 'openai';`,
|
|
293
|
+
};
|
|
294
|
+
for (const svc of services) {
|
|
295
|
+
const imp = serviceImports[svc.toLowerCase()];
|
|
296
|
+
if (imp) lines.push(imp);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return lines.length ? lines.join("\n") + "\n" : "";
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ── scenario generator ────────────────────────────────────────────────────────
|
|
303
|
+
|
|
304
|
+
function generateScenario(id, name, fn) {
|
|
305
|
+
return {
|
|
306
|
+
scenarioId: `${id}-happy-path`,
|
|
307
|
+
description: `Happy path for ${name}`,
|
|
308
|
+
capabilitiesCovered: [id],
|
|
309
|
+
createdAt: new Date().toISOString(),
|
|
310
|
+
steps: [
|
|
311
|
+
{ step: 1, action: `Call ${fn} with valid input`, expected: "Returns success result" },
|
|
312
|
+
{ step: 2, action: `Call ${fn} with invalid input`, expected: "Throws appropriate error" },
|
|
313
|
+
],
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// ── printer ───────────────────────────────────────────────────────────────────
|
|
318
|
+
|
|
319
|
+
function printResult({ id, filePath, scenarioPath, lang, fn, dryRun }) {
|
|
320
|
+
console.log();
|
|
321
|
+
console.log(bold(` 🌊 ${green(id)}`));
|
|
322
|
+
console.log(gray(" stability: experimental — free to evolve"));
|
|
323
|
+
console.log();
|
|
324
|
+
console.log(gray(" Generated:"));
|
|
325
|
+
console.log(` ${green("+")} ${cyan(filePath)} ${gray(`(${lang} source skeleton)`)}`);
|
|
326
|
+
console.log(` ${green("+")} ${cyan("inferno/capabilities.json")} ${gray("(capability registered)")}`);
|
|
327
|
+
console.log(` ${green("+")} ${cyan(scenarioPath)} ${gray("(placeholder scenario)")}`);
|
|
328
|
+
console.log();
|
|
329
|
+
if (dryRun) {
|
|
330
|
+
console.log(yellow(" [dry-run] — no files were written"));
|
|
331
|
+
} else {
|
|
332
|
+
console.log(gray(" Next steps:"));
|
|
333
|
+
console.log(gray(` 1. Implement ${fn}() in ${filePath}`));
|
|
334
|
+
console.log(gray(` 2. Run: infernoflow scan — to extract call graph`));
|
|
335
|
+
console.log(gray(` 3. Run: infernoflow graph — to see dependencies`));
|
|
336
|
+
console.log(gray(` 4. Run: infernoflow check — to validate contract`));
|
|
337
|
+
}
|
|
338
|
+
console.log();
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ── entry point ───────────────────────────────────────────────────────────────
|
|
342
|
+
|
|
343
|
+
export async function scaffoldCommand(rawArgs) {
|
|
344
|
+
const args = (rawArgs || []).slice(1);
|
|
345
|
+
const dryRun = args.includes("--dry-run");
|
|
346
|
+
const jsonMode = args.includes("--json");
|
|
347
|
+
|
|
348
|
+
const langIdx = args.indexOf("--lang");
|
|
349
|
+
const langArg = langIdx !== -1 ? args[langIdx + 1] : null;
|
|
350
|
+
|
|
351
|
+
const dirIdx = args.indexOf("--dir");
|
|
352
|
+
const dirArg = dirIdx !== -1 ? args[dirIdx + 1] : null;
|
|
353
|
+
|
|
354
|
+
const descIdx = args.indexOf("--description");
|
|
355
|
+
const descArg = descIdx !== -1 ? args[descIdx + 1] : null;
|
|
356
|
+
|
|
357
|
+
// Cap ID: first non-flag arg (skip values after --lang, --dir, --description)
|
|
358
|
+
const skipIdxs = new Set([langIdx + 1, dirIdx + 1, descIdx + 1].filter(i => i > 0));
|
|
359
|
+
const capId = args.find((a, i) => !a.startsWith("--") && !skipIdxs.has(i));
|
|
360
|
+
|
|
361
|
+
if (!capId) {
|
|
362
|
+
console.error(red("✗ Usage: infernoflow scaffold <capability-id> [--dir <src>] [--lang ts|js|py|go] [--dry-run] [--json]"));
|
|
363
|
+
console.error(gray(" Example: infernoflow scaffold payment-refund"));
|
|
364
|
+
process.exit(1);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Validate cap ID format
|
|
368
|
+
if (!/^[a-z][a-z0-9-]*$/.test(capId)) {
|
|
369
|
+
console.error(red(`✗ Invalid capability ID: "${capId}"`));
|
|
370
|
+
console.error(gray(" Use lowercase kebab-case: payment-refund, user-auth, etc."));
|
|
371
|
+
process.exit(1);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const cwd = process.cwd();
|
|
375
|
+
const infernoDir = path.join(cwd, "inferno");
|
|
376
|
+
|
|
377
|
+
// Load context
|
|
378
|
+
const capsPath = path.join(infernoDir, "capabilities.json");
|
|
379
|
+
if (!fs.existsSync(capsPath)) {
|
|
380
|
+
console.error(red("✗ inferno/capabilities.json not found — run `infernoflow init` first."));
|
|
381
|
+
process.exit(1);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
let allCaps = [];
|
|
385
|
+
const rawCaps = loadJson(capsPath);
|
|
386
|
+
if (rawCaps) allCaps = Array.isArray(rawCaps) ? rawCaps : (rawCaps.capabilities || []);
|
|
387
|
+
|
|
388
|
+
// Check duplicate
|
|
389
|
+
if (allCaps.some(c => c.id === capId)) {
|
|
390
|
+
console.error(red(`✗ Capability "${capId}" already exists in capabilities.json`));
|
|
391
|
+
console.error(gray(" Use a different ID, or run: infernoflow why " + capId));
|
|
392
|
+
process.exit(1);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const scan = loadJson(path.join(infernoDir, "scan.json"));
|
|
396
|
+
const profile = loadJson(path.join(infernoDir, "developer-profile.json"));
|
|
397
|
+
|
|
398
|
+
// Detect language
|
|
399
|
+
const lang = langArg || detectLang(scan, profile, cwd);
|
|
400
|
+
|
|
401
|
+
// Detect output directory
|
|
402
|
+
const srcDir = dirArg || detectSrcDir(scan, cwd) || "src";
|
|
403
|
+
const ext = { ts: ".ts", js: ".js", py: ".py", go: ".go" }[lang] || ".js";
|
|
404
|
+
|
|
405
|
+
// Derive names
|
|
406
|
+
const name = toTitle(capId);
|
|
407
|
+
const description = descArg || `TODO: describe ${name}`;
|
|
408
|
+
const fn = primaryFnName(capId);
|
|
409
|
+
const services = detectServices(scan);
|
|
410
|
+
|
|
411
|
+
// Generate code
|
|
412
|
+
let code;
|
|
413
|
+
if (lang === "ts") code = generateTs(capId, name, description, fn, services);
|
|
414
|
+
else if (lang === "py") code = generatePy(capId, name, description, fn);
|
|
415
|
+
else if (lang === "go") code = generateGo(capId, name, description, fn);
|
|
416
|
+
else code = generateJs(capId, name, description, fn, services);
|
|
417
|
+
|
|
418
|
+
// File path: srcDir/capId (replace dashes with nothing) + ext
|
|
419
|
+
// e.g. payment-refund → src/paymentRefund.ts
|
|
420
|
+
const fileName = toCamelCase(capId) + ext;
|
|
421
|
+
const filePath = path.join(srcDir, fileName);
|
|
422
|
+
const absFile = path.join(cwd, filePath);
|
|
423
|
+
|
|
424
|
+
// Scenario path
|
|
425
|
+
const scenarioPath = path.join("inferno", "scenarios", `${capId}.json`);
|
|
426
|
+
const absScenario = path.join(cwd, scenarioPath);
|
|
427
|
+
const scenario = generateScenario(capId, name, fn);
|
|
428
|
+
|
|
429
|
+
// New capability entry
|
|
430
|
+
const newCap = {
|
|
431
|
+
id: capId,
|
|
432
|
+
name,
|
|
433
|
+
description,
|
|
434
|
+
stability: "experimental",
|
|
435
|
+
since: new Date().toISOString().slice(0, 10),
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
if (jsonMode) {
|
|
439
|
+
const out = {
|
|
440
|
+
capId,
|
|
441
|
+
name,
|
|
442
|
+
stability: "experimental",
|
|
443
|
+
lang,
|
|
444
|
+
filePath,
|
|
445
|
+
scenarioPath,
|
|
446
|
+
primaryFn: fn,
|
|
447
|
+
dryRun,
|
|
448
|
+
code,
|
|
449
|
+
};
|
|
450
|
+
console.log(JSON.stringify(out, null, 2));
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
console.log(gray(`\n infernoflow scaffold → ${bold(capId)}`));
|
|
455
|
+
console.log(gray(" ──────────────────────────────────────────────────────────────"));
|
|
456
|
+
|
|
457
|
+
if (!dryRun) {
|
|
458
|
+
// Create source directory if needed
|
|
459
|
+
const absDir = path.dirname(absFile);
|
|
460
|
+
if (!fs.existsSync(absDir)) fs.mkdirSync(absDir, { recursive: true });
|
|
461
|
+
|
|
462
|
+
// Write source file
|
|
463
|
+
if (fs.existsSync(absFile)) {
|
|
464
|
+
console.error(red(` ✗ File already exists: ${filePath}`));
|
|
465
|
+
console.error(gray(" Delete it first or choose a different --dir"));
|
|
466
|
+
process.exit(1);
|
|
467
|
+
}
|
|
468
|
+
fs.writeFileSync(absFile, code, "utf8");
|
|
469
|
+
|
|
470
|
+
// Register capability
|
|
471
|
+
allCaps.push(newCap);
|
|
472
|
+
saveJson(capsPath, allCaps);
|
|
473
|
+
|
|
474
|
+
// Write scenario
|
|
475
|
+
const scenDir = path.join(cwd, "inferno", "scenarios");
|
|
476
|
+
if (!fs.existsSync(scenDir)) fs.mkdirSync(scenDir, { recursive: true });
|
|
477
|
+
if (!fs.existsSync(absScenario)) {
|
|
478
|
+
saveJson(absScenario, scenario);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Show code preview
|
|
483
|
+
const previewLines = code.split("\n").slice(0, 12).map(l => " " + l).join("\n");
|
|
484
|
+
console.log(gray("\n Preview:"));
|
|
485
|
+
console.log(gray(previewLines));
|
|
486
|
+
console.log(gray(" ..."));
|
|
487
|
+
|
|
488
|
+
printResult({ id: capId, filePath, scenarioPath, lang, fn, dryRun });
|
|
489
|
+
}
|