infernoflow 0.17.0 → 0.19.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 +54 -0
- package/dist/lib/commands/audit.mjs +335 -0
- package/dist/lib/commands/dashboard.mjs +248 -2
- package/dist/lib/commands/link.mjs +342 -0
- package/dist/lib/commands/monorepo.mjs +427 -0
- package/dist/lib/commands/notify.mjs +258 -0
- package/dist/lib/commands/report.mjs +320 -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,342 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* infernoflow link
|
|
3
|
+
*
|
|
4
|
+
* Link capabilities to tickets in Jira, Linear, or GitHub Issues.
|
|
5
|
+
* Stored in inferno/links.json — travels with the repo.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* infernoflow link --jira PROJ-123 --capability CreateTask
|
|
9
|
+
* infernoflow link --linear LIN-456 --capability FilterByTag
|
|
10
|
+
* infernoflow link --github 78 --capability ExportToCsv
|
|
11
|
+
* infernoflow link list Show all links
|
|
12
|
+
* infernoflow link status Show which caps have open tickets
|
|
13
|
+
* infernoflow link remove --capability CreateTask
|
|
14
|
+
* infernoflow link --json Machine-readable
|
|
15
|
+
*
|
|
16
|
+
* Config (inferno/integrations.json):
|
|
17
|
+
* {
|
|
18
|
+
* "jira": { "baseUrl": "https://myorg.atlassian.net", "token": "...", "email": "..." },
|
|
19
|
+
* "linear": { "apiKey": "lin_api_..." },
|
|
20
|
+
* "github": { "repo": "owner/repo", "token": "ghp_..." }
|
|
21
|
+
* }
|
|
22
|
+
*
|
|
23
|
+
* Env vars (override config):
|
|
24
|
+
* JIRA_BASE_URL, JIRA_TOKEN, JIRA_EMAIL
|
|
25
|
+
* LINEAR_API_KEY
|
|
26
|
+
* GITHUB_TOKEN, GITHUB_REPOSITORY
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import * as fs from "node:fs";
|
|
30
|
+
import * as path from "node:path";
|
|
31
|
+
import * as https from "node:https";
|
|
32
|
+
import { done, warn, info, bold, cyan, gray, green, yellow, red } from "../ui/output.mjs";
|
|
33
|
+
|
|
34
|
+
const LINKS_FILE = "links.json";
|
|
35
|
+
const CONFIG_FILE = "integrations.json";
|
|
36
|
+
|
|
37
|
+
// ── Storage ───────────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
function readLinks(infernoDir) {
|
|
40
|
+
const p = path.join(infernoDir, LINKS_FILE);
|
|
41
|
+
if (!fs.existsSync(p)) return [];
|
|
42
|
+
try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch { return []; }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function writeLinks(infernoDir, links) {
|
|
46
|
+
fs.writeFileSync(path.join(infernoDir, LINKS_FILE), JSON.stringify(links, null, 2) + "\n");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function readIntegrationConfig(infernoDir) {
|
|
50
|
+
const p = path.join(infernoDir, CONFIG_FILE);
|
|
51
|
+
if (!fs.existsSync(p)) return {};
|
|
52
|
+
try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch { return {}; }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ── HTTP helper ───────────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
function httpsGet(url, headers = {}) {
|
|
58
|
+
return new Promise((resolve, reject) => {
|
|
59
|
+
const parsed = new URL(url);
|
|
60
|
+
https.get({
|
|
61
|
+
hostname: parsed.hostname,
|
|
62
|
+
path: parsed.pathname + (parsed.search || ""),
|
|
63
|
+
headers: { "User-Agent": "infernoflow-cli", "Accept": "application/json", ...headers },
|
|
64
|
+
}, (res) => {
|
|
65
|
+
let data = "";
|
|
66
|
+
res.on("data", d => (data += d));
|
|
67
|
+
res.on("end", () => {
|
|
68
|
+
try { resolve({ status: res.statusCode, body: JSON.parse(data) }); }
|
|
69
|
+
catch { resolve({ status: res.statusCode, body: data }); }
|
|
70
|
+
});
|
|
71
|
+
}).on("error", reject);
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ── Ticket fetchers ───────────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
async function fetchJiraTicket(ticketId, config) {
|
|
78
|
+
const base = process.env.JIRA_BASE_URL || config.jira?.baseUrl;
|
|
79
|
+
const token = process.env.JIRA_TOKEN || config.jira?.token;
|
|
80
|
+
const email = process.env.JIRA_EMAIL || config.jira?.email;
|
|
81
|
+
if (!base || !token) return null;
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
const creds = Buffer.from(`${email}:${token}`).toString("base64");
|
|
85
|
+
const resp = await httpsGet(`${base}/rest/api/3/issue/${ticketId}`, {
|
|
86
|
+
"Authorization": `Basic ${creds}`,
|
|
87
|
+
});
|
|
88
|
+
if (resp.status === 200) {
|
|
89
|
+
return {
|
|
90
|
+
id: ticketId,
|
|
91
|
+
title: resp.body.fields?.summary || ticketId,
|
|
92
|
+
status: resp.body.fields?.status?.name || "unknown",
|
|
93
|
+
url: `${base}/browse/${ticketId}`,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
} catch {}
|
|
97
|
+
return { id: ticketId, title: ticketId, status: "unknown", url: null };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function fetchLinearTicket(ticketId, config) {
|
|
101
|
+
const apiKey = process.env.LINEAR_API_KEY || config.linear?.apiKey;
|
|
102
|
+
if (!apiKey) return null;
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
const query = JSON.stringify({
|
|
106
|
+
query: `{ issue(id: "${ticketId}") { title state { name } url } }`
|
|
107
|
+
});
|
|
108
|
+
const resp = await new Promise((resolve, reject) => {
|
|
109
|
+
const body = query;
|
|
110
|
+
const req = https.request({
|
|
111
|
+
hostname: "api.linear.app",
|
|
112
|
+
path: "/graphql",
|
|
113
|
+
method: "POST",
|
|
114
|
+
headers: { "Content-Type": "application/json", "Authorization": apiKey, "Content-Length": Buffer.byteLength(body) },
|
|
115
|
+
}, (res) => {
|
|
116
|
+
let data = "";
|
|
117
|
+
res.on("data", d => (data += d));
|
|
118
|
+
res.on("end", () => resolve(JSON.parse(data)));
|
|
119
|
+
});
|
|
120
|
+
req.on("error", reject);
|
|
121
|
+
req.write(body);
|
|
122
|
+
req.end();
|
|
123
|
+
});
|
|
124
|
+
const issue = resp.data?.issue;
|
|
125
|
+
if (issue) return { id: ticketId, title: issue.title, status: issue.state?.name || "unknown", url: issue.url };
|
|
126
|
+
} catch {}
|
|
127
|
+
return { id: ticketId, title: ticketId, status: "unknown", url: null };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function fetchGithubIssue(issueNum, config) {
|
|
131
|
+
const token = process.env.GITHUB_TOKEN || config.github?.token;
|
|
132
|
+
const repo = process.env.GITHUB_REPOSITORY || config.github?.repo;
|
|
133
|
+
if (!repo) return null;
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
const headers = { "Authorization": token ? `Bearer ${token}` : undefined };
|
|
137
|
+
const resp = await httpsGet(`https://api.github.com/repos/${repo}/issues/${issueNum}`, headers);
|
|
138
|
+
if (resp.status === 200) {
|
|
139
|
+
return {
|
|
140
|
+
id: `#${issueNum}`,
|
|
141
|
+
title: resp.body.title || `Issue #${issueNum}`,
|
|
142
|
+
status: resp.body.state || "unknown",
|
|
143
|
+
url: resp.body.html_url,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
} catch {}
|
|
147
|
+
return { id: `#${issueNum}`, title: `Issue #${issueNum}`, status: "unknown", url: null };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ── Sub-commands ──────────────────────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
async function subcmdAdd(args, infernoDir, config) {
|
|
153
|
+
const jsonMode = args.includes("--json");
|
|
154
|
+
const capIdx = args.indexOf("--capability");
|
|
155
|
+
const jiraIdx = args.indexOf("--jira");
|
|
156
|
+
const linearIdx = args.indexOf("--linear");
|
|
157
|
+
const githubIdx = args.indexOf("--github");
|
|
158
|
+
|
|
159
|
+
const capability = capIdx !== -1 ? args[capIdx + 1] : null;
|
|
160
|
+
const jiraId = jiraIdx !== -1 ? args[jiraIdx + 1] : null;
|
|
161
|
+
const linearId = linearIdx !== -1 ? args[linearIdx + 1] : null;
|
|
162
|
+
const githubNum = githubIdx !== -1 ? args[githubIdx + 1] : null;
|
|
163
|
+
|
|
164
|
+
if (!capability) {
|
|
165
|
+
const msg = "Usage: infernoflow link --capability <id> --jira <TICKET> | --linear <ID> | --github <NUM>";
|
|
166
|
+
if (jsonMode) { console.log(JSON.stringify({ ok: false, error: msg })); } else { warn(msg); }
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
let ticket = null;
|
|
171
|
+
let platform = null;
|
|
172
|
+
|
|
173
|
+
if (jiraId) {
|
|
174
|
+
if (!jsonMode) process.stdout.write(` Fetching Jira ${jiraId}… `);
|
|
175
|
+
ticket = await fetchJiraTicket(jiraId, config);
|
|
176
|
+
platform = "jira";
|
|
177
|
+
} else if (linearId) {
|
|
178
|
+
if (!jsonMode) process.stdout.write(` Fetching Linear ${linearId}… `);
|
|
179
|
+
ticket = await fetchLinearTicket(linearId, config);
|
|
180
|
+
platform = "linear";
|
|
181
|
+
} else if (githubNum) {
|
|
182
|
+
if (!jsonMode) process.stdout.write(` Fetching GitHub #${githubNum}… `);
|
|
183
|
+
ticket = await fetchGithubIssue(githubNum, config);
|
|
184
|
+
platform = "github";
|
|
185
|
+
} else {
|
|
186
|
+
const msg = "Specify --jira, --linear, or --github";
|
|
187
|
+
if (jsonMode) { console.log(JSON.stringify({ ok: false, error: msg })); } else { warn(msg); }
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const links = readLinks(infernoDir);
|
|
192
|
+
const existing = links.findIndex(l => l.capability === capability);
|
|
193
|
+
|
|
194
|
+
const link = {
|
|
195
|
+
capability,
|
|
196
|
+
platform,
|
|
197
|
+
ticketId: ticket?.id || jiraId || linearId || `#${githubNum}`,
|
|
198
|
+
title: ticket?.title || "",
|
|
199
|
+
status: ticket?.status || "unknown",
|
|
200
|
+
url: ticket?.url || null,
|
|
201
|
+
linkedAt: new Date().toISOString(),
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
if (existing !== -1) {
|
|
205
|
+
links[existing] = link;
|
|
206
|
+
} else {
|
|
207
|
+
links.push(link);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
writeLinks(infernoDir, links);
|
|
211
|
+
|
|
212
|
+
if (!jsonMode) console.log(green("done"));
|
|
213
|
+
|
|
214
|
+
if (jsonMode) {
|
|
215
|
+
console.log(JSON.stringify({ ok: true, link }));
|
|
216
|
+
} else {
|
|
217
|
+
done(`Linked: ${bold(capability)} → ${cyan(link.ticketId)} (${link.status})`);
|
|
218
|
+
if (link.url) console.log(` ${gray(link.url)}`);
|
|
219
|
+
console.log();
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function subcmdList(args, infernoDir) {
|
|
224
|
+
const jsonMode = args.includes("--json");
|
|
225
|
+
const links = readLinks(infernoDir);
|
|
226
|
+
|
|
227
|
+
if (jsonMode) {
|
|
228
|
+
console.log(JSON.stringify({ ok: true, links }));
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (!links.length) {
|
|
233
|
+
info("No links yet. Use: infernoflow link --capability <id> --jira <TICKET>");
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
console.log();
|
|
238
|
+
console.log(` ${bold(`${links.length} capability link${links.length !== 1 ? "s" : ""}`)}`);
|
|
239
|
+
console.log();
|
|
240
|
+
|
|
241
|
+
const w = Math.max(...links.map(l => l.capability.length), 10) + 2;
|
|
242
|
+
for (const l of links) {
|
|
243
|
+
const statusColor = l.status?.toLowerCase() === "done" || l.status?.toLowerCase() === "closed"
|
|
244
|
+
? green : l.status?.toLowerCase() === "in progress" ? yellow : gray;
|
|
245
|
+
console.log(` ${bold(l.capability.padEnd(w))} ${cyan(l.ticketId.padEnd(14))} ${statusColor(l.status || "unknown")}`);
|
|
246
|
+
if (l.title && l.title !== l.ticketId) console.log(` ${" ".repeat(w + 2)}${gray(l.title)}`);
|
|
247
|
+
}
|
|
248
|
+
console.log();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function subcmdStatus(args, infernoDir) {
|
|
252
|
+
const jsonMode = args.includes("--json");
|
|
253
|
+
const links = readLinks(infernoDir);
|
|
254
|
+
|
|
255
|
+
// Load contract to find unlinked capabilities
|
|
256
|
+
let contract = null;
|
|
257
|
+
for (const f of ["contract.json", "capabilities.json"]) {
|
|
258
|
+
const p = path.join(infernoDir, f);
|
|
259
|
+
if (fs.existsSync(p)) { try { contract = JSON.parse(fs.readFileSync(p, "utf8")); break; } catch {} }
|
|
260
|
+
}
|
|
261
|
+
const allCaps = (contract?.capabilities || []).map(c => typeof c === "string" ? c : c.id);
|
|
262
|
+
const linkedIds = new Set(links.map(l => l.capability));
|
|
263
|
+
const unlinked = allCaps.filter(id => !linkedIds.has(id));
|
|
264
|
+
|
|
265
|
+
if (jsonMode) {
|
|
266
|
+
console.log(JSON.stringify({ ok: true, linked: links.length, unlinked: unlinked.length, links, unlinkedCapabilities: unlinked }));
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
console.log();
|
|
271
|
+
console.log(` ${bold("Capability link status")}`);
|
|
272
|
+
console.log();
|
|
273
|
+
|
|
274
|
+
if (links.length) {
|
|
275
|
+
console.log(` ${gray("Linked:")}`);
|
|
276
|
+
for (const l of links) {
|
|
277
|
+
const icon = l.status?.toLowerCase() === "done" ? green("✔") : l.status?.toLowerCase() === "in progress" ? yellow("⟳") : gray("○");
|
|
278
|
+
console.log(` ${icon} ${bold(l.capability)} ${cyan(l.ticketId)} ${gray(l.status || "")}`);
|
|
279
|
+
}
|
|
280
|
+
console.log();
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (unlinked.length) {
|
|
284
|
+
console.log(` ${gray("Unlinked capabilities:")}`);
|
|
285
|
+
unlinked.forEach(id => console.log(` ${gray("·")} ${id}`));
|
|
286
|
+
console.log();
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
console.log(` ${green(String(links.length))} linked · ${gray(String(unlinked.length))} unlinked`);
|
|
290
|
+
console.log();
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async function subcmdRemove(args, infernoDir) {
|
|
294
|
+
const jsonMode = args.includes("--json");
|
|
295
|
+
const capIdx = args.indexOf("--capability");
|
|
296
|
+
const capId = capIdx !== -1 ? args[capIdx + 1] : null;
|
|
297
|
+
|
|
298
|
+
if (!capId) {
|
|
299
|
+
if (jsonMode) { console.log(JSON.stringify({ ok: false, error: "Usage: infernoflow link remove --capability <id>" })); }
|
|
300
|
+
else { warn("Usage: infernoflow link remove --capability <id>"); }
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const links = readLinks(infernoDir);
|
|
305
|
+
const before = links.length;
|
|
306
|
+
const updated = links.filter(l => l.capability !== capId);
|
|
307
|
+
|
|
308
|
+
if (updated.length === before) {
|
|
309
|
+
if (jsonMode) { console.log(JSON.stringify({ ok: false, error: `No link found for: ${capId}` })); }
|
|
310
|
+
else { warn(`No link found for: ${capId}`); }
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
writeLinks(infernoDir, updated);
|
|
315
|
+
if (jsonMode) { console.log(JSON.stringify({ ok: true, removed: capId })); }
|
|
316
|
+
else { done(`Removed link for ${bold(capId)}`); console.log(); }
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// ── Entry ─────────────────────────────────────────────────────────────────────
|
|
320
|
+
|
|
321
|
+
export async function linkCommand(rawArgs) {
|
|
322
|
+
const args = rawArgs.slice(1);
|
|
323
|
+
const cwd = process.cwd();
|
|
324
|
+
const infernoDir = path.join(cwd, "inferno");
|
|
325
|
+
|
|
326
|
+
if (!fs.existsSync(infernoDir)) {
|
|
327
|
+
const msg = "inferno/ not found. Run: infernoflow init";
|
|
328
|
+
if (args.includes("--json")) { console.log(JSON.stringify({ ok: false, error: msg })); }
|
|
329
|
+
else { warn(msg); }
|
|
330
|
+
process.exit(1);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const config = readIntegrationConfig(infernoDir);
|
|
334
|
+
const subcmd = args[0];
|
|
335
|
+
|
|
336
|
+
if (subcmd === "list") return subcmdList(args.slice(1), infernoDir);
|
|
337
|
+
if (subcmd === "status") return subcmdStatus(args.slice(1), infernoDir);
|
|
338
|
+
if (subcmd === "remove") return subcmdRemove(args.slice(1), infernoDir);
|
|
339
|
+
|
|
340
|
+
// Default: add a link
|
|
341
|
+
return subcmdAdd(args, infernoDir, config);
|
|
342
|
+
}
|