infernoflow 0.18.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 +72 -0
- package/dist/lib/commands/audit.mjs +335 -0
- package/dist/lib/commands/dashboard.mjs +248 -2
- package/dist/lib/commands/export.mjs +239 -0
- package/dist/lib/commands/health.mjs +309 -0
- package/dist/lib/commands/link.mjs +342 -0
- package/dist/lib/commands/monorepo.mjs +427 -0
- package/dist/lib/commands/scout.mjs +291 -0
- package/dist/lib/commands/snapshot.mjs +383 -0
- package/dist/lib/ui/errors.mjs +142 -0
- package/package.json +1 -1
|
@@ -80,6 +80,92 @@ function runCheck(infernoDir) {
|
|
|
80
80
|
}
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
// ── Analytics data loaders ────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
function loadAudit(infernoDir) {
|
|
86
|
+
const p = path.join(infernoDir, "audit.json");
|
|
87
|
+
if (!fs.existsSync(p)) return null;
|
|
88
|
+
try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch { return null; }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function loadLinks(infernoDir) {
|
|
92
|
+
const p = path.join(infernoDir, "links.json");
|
|
93
|
+
if (!fs.existsSync(p)) return [];
|
|
94
|
+
try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch { return []; }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Parse git log for inferno/ directory to build analytics:
|
|
99
|
+
* - capability velocity (caps added/removed per week)
|
|
100
|
+
* - contributor activity (commits per author)
|
|
101
|
+
* - health score trend (from check logs or heuristic via commit frequency)
|
|
102
|
+
*/
|
|
103
|
+
function loadGitAnalytics(cwd, infernoDir) {
|
|
104
|
+
try {
|
|
105
|
+
// Commits touching inferno/ in past 90 days (iso date, author email, subject)
|
|
106
|
+
const raw = execSync(
|
|
107
|
+
`git log --since="90 days ago" --format="%aI|%ae|%s" -- inferno/`,
|
|
108
|
+
{ cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 8000 }
|
|
109
|
+
).trim();
|
|
110
|
+
|
|
111
|
+
if (!raw) return { velocity: [], contributors: [], healthTrend: [] };
|
|
112
|
+
|
|
113
|
+
const commits = raw.split("\n").filter(Boolean).map(line => {
|
|
114
|
+
const [date, email, ...subjectParts] = line.split("|");
|
|
115
|
+
return { date: new Date(date), email: email || "unknown", subject: subjectParts.join("|") };
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// Bucket by ISO week (YYYY-Www)
|
|
119
|
+
function isoWeek(d) {
|
|
120
|
+
const dt = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
|
|
121
|
+
const day = dt.getUTCDay() || 7;
|
|
122
|
+
dt.setUTCDate(dt.getUTCDate() + 4 - day);
|
|
123
|
+
const yearStart = new Date(Date.UTC(dt.getUTCFullYear(), 0, 1));
|
|
124
|
+
const week = Math.ceil((((dt - yearStart) / 86400000) + 1) / 7);
|
|
125
|
+
return `${dt.getUTCFullYear()}-W${String(week).padStart(2, "0")}`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Velocity: commits per week
|
|
129
|
+
const weekMap = new Map();
|
|
130
|
+
for (const c of commits) {
|
|
131
|
+
const w = isoWeek(c.date);
|
|
132
|
+
weekMap.set(w, (weekMap.get(w) || 0) + 1);
|
|
133
|
+
}
|
|
134
|
+
// Fill in the last 13 weeks
|
|
135
|
+
const velocity = [];
|
|
136
|
+
const now = new Date();
|
|
137
|
+
for (let i = 12; i >= 0; i--) {
|
|
138
|
+
const d = new Date(now);
|
|
139
|
+
d.setDate(d.getDate() - i * 7);
|
|
140
|
+
const w = isoWeek(d);
|
|
141
|
+
velocity.push({ week: w, commits: weekMap.get(w) || 0 });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Contributors: unique authors, sorted by commit count
|
|
145
|
+
const authorMap = new Map();
|
|
146
|
+
for (const c of commits) {
|
|
147
|
+
const name = c.email.split("@")[0];
|
|
148
|
+
authorMap.set(name, (authorMap.get(name) || 0) + 1);
|
|
149
|
+
}
|
|
150
|
+
const contributors = [...authorMap.entries()]
|
|
151
|
+
.map(([name, count]) => ({ name, count }))
|
|
152
|
+
.sort((a, b) => b.count - a.count)
|
|
153
|
+
.slice(0, 8);
|
|
154
|
+
|
|
155
|
+
// Health trend: simple heuristic from commit density per week
|
|
156
|
+
// More commits → more drift activity. We mark weeks with >3 commits as "busy" (amber), 0 = stale, else ok
|
|
157
|
+
const healthTrend = velocity.map(v => ({
|
|
158
|
+
week: v.week,
|
|
159
|
+
score: v.commits === 0 ? 40 : v.commits <= 2 ? 75 : v.commits <= 5 ? 90 : 85,
|
|
160
|
+
label: v.commits === 0 ? "stale" : v.commits <= 2 ? "ok" : v.commits <= 5 ? "healthy" : "busy",
|
|
161
|
+
}));
|
|
162
|
+
|
|
163
|
+
return { velocity, contributors, healthTrend };
|
|
164
|
+
} catch {
|
|
165
|
+
return { velocity: [], contributors: [], healthTrend: [] };
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
83
169
|
function gatherData(infernoDir) {
|
|
84
170
|
const caps = loadCapabilities(infernoDir);
|
|
85
171
|
const contract = loadContract(infernoDir);
|
|
@@ -87,19 +173,74 @@ function gatherData(infernoDir) {
|
|
|
87
173
|
const agents = loadAgents(infernoDir);
|
|
88
174
|
const hookLog = loadHookLog(infernoDir);
|
|
89
175
|
const check = runCheck(infernoDir);
|
|
176
|
+
const audit = loadAudit(infernoDir);
|
|
177
|
+
const links = loadLinks(infernoDir);
|
|
90
178
|
const sessions = profile?.recentSessions?.slice(-10) || [];
|
|
91
179
|
const candidates = [
|
|
92
180
|
...(profile?.agentCandidates || []),
|
|
93
181
|
...(profile?.skillCandidates || []),
|
|
94
182
|
];
|
|
183
|
+
const cwd = path.dirname(infernoDir);
|
|
184
|
+
const analytics = loadGitAnalytics(cwd, infernoDir);
|
|
185
|
+
|
|
186
|
+
return { caps, contract, agents, hookLog, check, sessions, candidates, audit, links, analytics, infernoDir };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ── HTML builder ──────────────────────────────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
// ── SVG chart builders ────────────────────────────────────────────────────────
|
|
192
|
+
|
|
193
|
+
function barChart(values, labels, color = "#f97316", height = 80) {
|
|
194
|
+
const W = 600, H = height;
|
|
195
|
+
const n = values.length;
|
|
196
|
+
if (!n) return `<svg width="${W}" height="${H}"></svg>`;
|
|
197
|
+
const max = Math.max(...values, 1);
|
|
198
|
+
const bw = Math.floor(W / n) - 4;
|
|
199
|
+
const bars = values.map((v, i) => {
|
|
200
|
+
const bh = Math.max(2, Math.round((v / max) * (H - 20)));
|
|
201
|
+
const x = i * (W / n) + 2;
|
|
202
|
+
const y = H - bh - 10;
|
|
203
|
+
return `<rect x="${x}" y="${y}" width="${bw}" height="${bh}" fill="${color}" rx="2" opacity="0.85"/>
|
|
204
|
+
<title>${labels[i]}: ${v}</title>`;
|
|
205
|
+
}).join("\n");
|
|
206
|
+
return `<svg viewBox="0 0 ${W} ${H}" width="100%" height="${H}" xmlns="http://www.w3.org/2000/svg">${bars}</svg>`;
|
|
207
|
+
}
|
|
95
208
|
|
|
96
|
-
|
|
209
|
+
function lineChart(values, color = "#3b82f6", height = 80) {
|
|
210
|
+
const W = 600, H = height;
|
|
211
|
+
const n = values.length;
|
|
212
|
+
if (n < 2) return `<svg width="${W}" height="${H}"></svg>`;
|
|
213
|
+
const max = Math.max(...values, 1);
|
|
214
|
+
const min = Math.min(...values, 0);
|
|
215
|
+
const range = max - min || 1;
|
|
216
|
+
const pts = values.map((v, i) => {
|
|
217
|
+
const x = Math.round((i / (n - 1)) * (W - 20)) + 10;
|
|
218
|
+
const y = Math.round(H - 10 - ((v - min) / range) * (H - 20));
|
|
219
|
+
return `${x},${y}`;
|
|
220
|
+
}).join(" ");
|
|
221
|
+
return `<svg viewBox="0 0 ${W} ${H}" width="100%" height="${H}" xmlns="http://www.w3.org/2000/svg">
|
|
222
|
+
<polyline points="${pts}" fill="none" stroke="${color}" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
|
223
|
+
${values.map((v, i) => {
|
|
224
|
+
const [px, py] = pts.split(" ")[i].split(",");
|
|
225
|
+
return `<circle cx="${px}" cy="${py}" r="4" fill="${color}"><title>${v}</title></circle>`;
|
|
226
|
+
}).join("")}
|
|
227
|
+
</svg>`;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function heatRow(name, count, maxCount) {
|
|
231
|
+
const pct = maxCount > 0 ? Math.round((count / maxCount) * 100) : 0;
|
|
232
|
+
const fill = pct > 70 ? "#f97316" : pct > 40 ? "#f59e0b" : pct > 10 ? "#3b82f6" : "#2d3148";
|
|
233
|
+
return `<div class="heat-row">
|
|
234
|
+
<span class="heat-name">${esc(name)}</span>
|
|
235
|
+
<div class="heat-bar-wrap"><div class="heat-bar" style="width:${pct}%;background:${fill}"></div></div>
|
|
236
|
+
<span class="heat-count">${count}</span>
|
|
237
|
+
</div>`;
|
|
97
238
|
}
|
|
98
239
|
|
|
99
240
|
// ── HTML builder ──────────────────────────────────────────────────────────────
|
|
100
241
|
|
|
101
242
|
function buildHtml(data, projectName) {
|
|
102
|
-
const { caps, agents, check, sessions, candidates } = data;
|
|
243
|
+
const { caps, agents, check, sessions, candidates, audit, links, analytics } = data;
|
|
103
244
|
|
|
104
245
|
const statusColor = check?.status === "ok" ? "#22c55e"
|
|
105
246
|
: check?.status === "warning" ? "#f59e0b"
|
|
@@ -153,6 +294,29 @@ function buildHtml(data, projectName) {
|
|
|
153
294
|
`<li class="candidate">${esc(c.name || c.id || "unnamed")}: ${esc(c.description || "")}</li>`
|
|
154
295
|
).join("\n");
|
|
155
296
|
|
|
297
|
+
// ── Analytics ─────────────────────────────────────────────────────────────
|
|
298
|
+
const vel = analytics?.velocity || [];
|
|
299
|
+
const contribs = analytics?.contributors || [];
|
|
300
|
+
const trend = analytics?.healthTrend || [];
|
|
301
|
+
|
|
302
|
+
const velValues = vel.map(v => v.commits);
|
|
303
|
+
const velLabels = vel.map(v => v.week);
|
|
304
|
+
const velChart = barChart(velValues, velLabels, "#f97316", 90);
|
|
305
|
+
|
|
306
|
+
const trendValues = trend.map(t => t.score);
|
|
307
|
+
const trendChart = lineChart(trendValues, "#3b82f6", 80);
|
|
308
|
+
|
|
309
|
+
const maxContrib = contribs.length ? Math.max(...contribs.map(c => c.count)) : 1;
|
|
310
|
+
const heatRows = contribs.length
|
|
311
|
+
? contribs.map(c => heatRow(c.name, c.count, maxContrib)).join("\n")
|
|
312
|
+
: `<div class="empty">No git history in inferno/ yet</div>`;
|
|
313
|
+
|
|
314
|
+
// Audit summary card
|
|
315
|
+
const auditStats = audit?.stats || null;
|
|
316
|
+
const auditHigh = auditStats?.high ?? "—";
|
|
317
|
+
const auditMedium = auditStats?.medium ?? "—";
|
|
318
|
+
const linkedCount = links.length;
|
|
319
|
+
|
|
156
320
|
return `<!DOCTYPE html>
|
|
157
321
|
<html lang="en">
|
|
158
322
|
<head>
|
|
@@ -200,6 +364,22 @@ function buildHtml(data, projectName) {
|
|
|
200
364
|
.session-item:last-child { border-bottom: none; }
|
|
201
365
|
.session-date { font-size: 11px; color: var(--muted); white-space: nowrap; min-width: 140px; }
|
|
202
366
|
.session-cmds { font-size: 12px; color: var(--text); }
|
|
367
|
+
/* Analytics */
|
|
368
|
+
.chart-wrap { padding: 16px 18px; }
|
|
369
|
+
.chart-label { font-size: 11px; color: var(--muted); margin-top: 6px; text-align: center; }
|
|
370
|
+
.analytics-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
|
|
371
|
+
.heat-row { display: flex; align-items: center; gap: 10px; padding: 6px 18px; border-bottom: 1px solid var(--border); }
|
|
372
|
+
.heat-row:last-child { border-bottom: none; }
|
|
373
|
+
.heat-name { min-width: 110px; font-size: 12px; color: var(--text); font-family: monospace; }
|
|
374
|
+
.heat-bar-wrap { flex: 1; height: 10px; background: var(--border); border-radius: 5px; overflow: hidden; }
|
|
375
|
+
.heat-bar { height: 100%; border-radius: 5px; transition: width 0.3s; }
|
|
376
|
+
.heat-count { font-size: 12px; color: var(--muted); min-width: 30px; text-align: right; }
|
|
377
|
+
.audit-tags { display: flex; gap: 8px; padding: 14px 18px; flex-wrap: wrap; }
|
|
378
|
+
.tag { font-size: 12px; padding: 4px 10px; border-radius: 9px; font-weight: 600; }
|
|
379
|
+
.tag-high { background: rgba(239,68,68,0.15); color: #ef4444; }
|
|
380
|
+
.tag-medium { background: rgba(245,158,11,0.15); color: #f59e0b; }
|
|
381
|
+
.tag-low { background: rgba(34,197,94,0.15); color: #22c55e; }
|
|
382
|
+
.tag-link { background: rgba(59,130,246,0.15); color: #3b82f6; }
|
|
203
383
|
footer { text-align: center; color: var(--muted); font-size: 11px; padding: 24px; }
|
|
204
384
|
</style>
|
|
205
385
|
</head>
|
|
@@ -236,6 +416,17 @@ function buildHtml(data, projectName) {
|
|
|
236
416
|
<div class="value">${sessions.length}</div>
|
|
237
417
|
<div class="sub">recent sessions logged</div>
|
|
238
418
|
</div>
|
|
419
|
+
${auditStats ? `
|
|
420
|
+
<div class="card">
|
|
421
|
+
<div class="label">Security surface</div>
|
|
422
|
+
<div class="value" style="color:${auditHigh > 0 ? "var(--red)" : "var(--green)"}">${auditHigh}</div>
|
|
423
|
+
<div class="sub">${auditHigh} high · ${auditMedium} medium risk caps</div>
|
|
424
|
+
</div>` : ""}
|
|
425
|
+
<div class="card">
|
|
426
|
+
<div class="label">Linked tickets</div>
|
|
427
|
+
<div class="value" style="color:var(--blue)">${linkedCount}</div>
|
|
428
|
+
<div class="sub">caps linked to Jira/Linear/GitHub</div>
|
|
429
|
+
</div>
|
|
239
430
|
</div>
|
|
240
431
|
|
|
241
432
|
${issueCount > 0 ? `
|
|
@@ -279,6 +470,61 @@ function buildHtml(data, projectName) {
|
|
|
279
470
|
: `<div class="empty">No session data yet — sessions are logged automatically as you use infernoflow</div>`}
|
|
280
471
|
</section>
|
|
281
472
|
|
|
473
|
+
<!-- Analytics: velocity + health trend -->
|
|
474
|
+
${vel.length > 0 ? `
|
|
475
|
+
<div class="analytics-grid">
|
|
476
|
+
<section>
|
|
477
|
+
<h2>📈 Capability Velocity (13 weeks)</h2>
|
|
478
|
+
<div class="chart-wrap">
|
|
479
|
+
${velChart}
|
|
480
|
+
<div class="chart-label">Commits touching inferno/ per week</div>
|
|
481
|
+
</div>
|
|
482
|
+
</section>
|
|
483
|
+
<section>
|
|
484
|
+
<h2>💚 Health Score Trend</h2>
|
|
485
|
+
<div class="chart-wrap">
|
|
486
|
+
${trendChart}
|
|
487
|
+
<div class="chart-label">Heuristic health score over last 13 weeks</div>
|
|
488
|
+
</div>
|
|
489
|
+
</section>
|
|
490
|
+
</div>` : ""}
|
|
491
|
+
|
|
492
|
+
<!-- Contributor heatmap -->
|
|
493
|
+
${contribs.length > 0 ? `
|
|
494
|
+
<section>
|
|
495
|
+
<h2>👥 Contributor Heatmap (90 days)</h2>
|
|
496
|
+
${heatRows}
|
|
497
|
+
</section>` : ""}
|
|
498
|
+
|
|
499
|
+
<!-- Audit surface map (if audit.json exists) -->
|
|
500
|
+
${auditStats ? `
|
|
501
|
+
<section>
|
|
502
|
+
<h2>🔐 Security Surface (last audit)</h2>
|
|
503
|
+
<div class="audit-tags">
|
|
504
|
+
<span class="tag tag-high">🔴 ${auditStats.high} HIGH</span>
|
|
505
|
+
<span class="tag tag-medium">🟡 ${auditStats.medium} MEDIUM</span>
|
|
506
|
+
<span class="tag tag-low">🟢 ${auditStats.low} LOW</span>
|
|
507
|
+
${linkedCount > 0 ? `<span class="tag tag-link">🔗 ${linkedCount} linked to tickets</span>` : ""}
|
|
508
|
+
</div>
|
|
509
|
+
${audit.capabilities ? `
|
|
510
|
+
<table>
|
|
511
|
+
<thead><tr><th>Severity</th><th>Capability</th><th>Tags</th></tr></thead>
|
|
512
|
+
<tbody>
|
|
513
|
+
${audit.capabilities.filter(c => c.severity === "high" || c.severity === "medium").slice(0, 10).map(c => `
|
|
514
|
+
<tr>
|
|
515
|
+
<td style="color:${c.severity === "high" ? "var(--red)" : "var(--yellow)"}">${c.severity}</td>
|
|
516
|
+
<td><code>${esc(c.id)}</code></td>
|
|
517
|
+
<td>${esc((c.tags || []).join(", "))}</td>
|
|
518
|
+
</tr>`).join("")}
|
|
519
|
+
</tbody>
|
|
520
|
+
</table>` : ""}
|
|
521
|
+
<div style="padding:8px 18px;font-size:11px;color:var(--muted)">Run <code>infernoflow audit</code> to refresh · Last run: ${esc(audit.runAt ? new Date(audit.runAt).toLocaleString() : "unknown")}</div>
|
|
522
|
+
</section>` : `
|
|
523
|
+
<section>
|
|
524
|
+
<h2>🔐 Security Surface</h2>
|
|
525
|
+
<div class="empty">No audit data yet — run <code>infernoflow audit</code> to classify capabilities by security sensitivity</div>
|
|
526
|
+
</section>`}
|
|
527
|
+
|
|
282
528
|
</main>
|
|
283
529
|
<footer>infernoflow dashboard · auto-refreshes every 10s · <a href="/" style="color:var(--muted)">refresh now</a></footer>
|
|
284
530
|
<script>
|
|
@@ -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
|
+
}
|