infernoflow 0.13.0 → 0.14.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
CHANGED
|
@@ -32,6 +32,9 @@ const COMMAND_DESCRIPTIONS = {
|
|
|
32
32
|
synthesize: "Auto-detect workflow patterns and synthesize reusable skills + agents",
|
|
33
33
|
agent: "Manage and run auto-synthesized agents (list | run | show | delete)",
|
|
34
34
|
version: "Smart semver bump recommendation based on capability changes (--apply to write)",
|
|
35
|
+
"pr-comment": "Post capability drift analysis as a GitHub PR comment (works in CI automatically)",
|
|
36
|
+
dashboard: "Launch local web dashboard on localhost:7337 — live contract health, capabilities, agents",
|
|
37
|
+
"team-sync": "Sync capability contract across a team via a shared git branch (push | pull | status | init)",
|
|
35
38
|
};
|
|
36
39
|
|
|
37
40
|
const COMMAND_HANDLERS = {
|
|
@@ -57,6 +60,9 @@ const COMMAND_HANDLERS = {
|
|
|
57
60
|
synthesize: async (args) => (await import("../lib/commands/synthesize.mjs")).synthesizeCommand(args),
|
|
58
61
|
agent: async (args) => (await import("../lib/commands/agent.mjs")).agentCommand(args),
|
|
59
62
|
version: async (args) => (await import("../lib/commands/version.mjs")).versionCommand(args),
|
|
63
|
+
"pr-comment": async (args) => (await import("../lib/commands/prComment.mjs")).prCommentCommand(args),
|
|
64
|
+
dashboard: async (args) => (await import("../lib/commands/dashboard.mjs")).dashboardCommand(args),
|
|
65
|
+
"team-sync": async (args) => (await import("../lib/commands/teamSync.mjs")).teamSyncCommand(args),
|
|
60
66
|
};
|
|
61
67
|
|
|
62
68
|
function formatCommandsHelp() {
|
|
@@ -167,6 +173,14 @@ ${formatCommandsHelp()}
|
|
|
167
173
|
--apply Write recommended version bump to package.json
|
|
168
174
|
--json Machine-readable output
|
|
169
175
|
|
|
176
|
+
${bold("pr-comment options:")}
|
|
177
|
+
--pr <number> PR number to comment on (auto-detected in GitHub Actions)
|
|
178
|
+
--repo <owner/repo> GitHub repository (auto-detected in GitHub Actions)
|
|
179
|
+
--token <ghp_...> GitHub token (auto-detected from GITHUB_TOKEN env var)
|
|
180
|
+
--ref <ref> Base ref to diff against (auto-detected from GITHUB_BASE_REF)
|
|
181
|
+
--dry-run Print the comment without posting it
|
|
182
|
+
--json Machine-readable output
|
|
183
|
+
|
|
170
184
|
${bold("Machine output:")}
|
|
171
185
|
${gray("status --json")}
|
|
172
186
|
${gray("check --json")}
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* infernoflow dashboard
|
|
3
|
+
*
|
|
4
|
+
* Launches a local web server on http://localhost:7337 showing:
|
|
5
|
+
* - Contract health status
|
|
6
|
+
* - Capability list with add/remove/change history
|
|
7
|
+
* - Drift timeline (last N sessions)
|
|
8
|
+
* - Agent activity log
|
|
9
|
+
* - Auto-refresh via SSE (server-sent events)
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
* infernoflow dashboard # open on port 7337
|
|
13
|
+
* infernoflow dashboard --port 8080 # custom port
|
|
14
|
+
* infernoflow dashboard --no-open # don't auto-open browser
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import * as fs from "node:fs";
|
|
18
|
+
import * as path from "node:path";
|
|
19
|
+
import * as http from "node:http";
|
|
20
|
+
import * as os from "node:os";
|
|
21
|
+
import { execSync } from "node:child_process";
|
|
22
|
+
import { fileURLToPath } from "node:url";
|
|
23
|
+
import { header, ok, info, warn, bold, cyan, gray } from "../ui/output.mjs";
|
|
24
|
+
|
|
25
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
26
|
+
|
|
27
|
+
// ── data loaders ──────────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
function loadContract(infernoDir) {
|
|
30
|
+
const contractPath = path.join(infernoDir, "contract.json");
|
|
31
|
+
if (!fs.existsSync(contractPath)) return null;
|
|
32
|
+
try { return JSON.parse(fs.readFileSync(contractPath, "utf8")); } catch { return null; }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function loadCapabilities(infernoDir) {
|
|
36
|
+
for (const name of ["capabilities.json", "contract.json"]) {
|
|
37
|
+
const p = path.join(infernoDir, name);
|
|
38
|
+
if (!fs.existsSync(p)) continue;
|
|
39
|
+
try {
|
|
40
|
+
const obj = JSON.parse(fs.readFileSync(p, "utf8"));
|
|
41
|
+
const raw = obj.capabilities || [];
|
|
42
|
+
return raw.map(c => typeof c === "string" ? { id: c, title: c } : c);
|
|
43
|
+
} catch {}
|
|
44
|
+
}
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function loadProfile(infernoDir) {
|
|
49
|
+
const p = path.join(infernoDir, "developer-profile.json");
|
|
50
|
+
if (!fs.existsSync(p)) return null;
|
|
51
|
+
try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch { return null; }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function loadAgents(infernoDir) {
|
|
55
|
+
const agentsDir = path.join(infernoDir, "agents");
|
|
56
|
+
if (!fs.existsSync(agentsDir)) return [];
|
|
57
|
+
return fs.readdirSync(agentsDir)
|
|
58
|
+
.filter(f => f.endsWith(".json"))
|
|
59
|
+
.map(f => { try { return JSON.parse(fs.readFileSync(path.join(agentsDir, f), "utf8")); } catch { return null; } })
|
|
60
|
+
.filter(Boolean);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function loadHookLog(infernoDir) {
|
|
64
|
+
const logPath = path.join(infernoDir, "HOOK.log");
|
|
65
|
+
if (!fs.existsSync(logPath)) return null;
|
|
66
|
+
try { return JSON.parse(fs.readFileSync(logPath, "utf8")); } catch { return null; }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function runCheck(infernoDir) {
|
|
70
|
+
try {
|
|
71
|
+
const out = execSync("npx infernoflow check --json", {
|
|
72
|
+
cwd: path.dirname(infernoDir),
|
|
73
|
+
encoding: "utf8",
|
|
74
|
+
timeout: 15_000,
|
|
75
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
76
|
+
});
|
|
77
|
+
return JSON.parse(out);
|
|
78
|
+
} catch (err) {
|
|
79
|
+
try { return JSON.parse(err.stdout || "{}"); } catch { return { status: "error", error: "check failed" }; }
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function gatherData(infernoDir) {
|
|
84
|
+
const caps = loadCapabilities(infernoDir);
|
|
85
|
+
const contract = loadContract(infernoDir);
|
|
86
|
+
const profile = loadProfile(infernoDir);
|
|
87
|
+
const agents = loadAgents(infernoDir);
|
|
88
|
+
const hookLog = loadHookLog(infernoDir);
|
|
89
|
+
const check = runCheck(infernoDir);
|
|
90
|
+
const sessions = profile?.recentSessions?.slice(-10) || [];
|
|
91
|
+
const candidates = [
|
|
92
|
+
...(profile?.agentCandidates || []),
|
|
93
|
+
...(profile?.skillCandidates || []),
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
return { caps, contract, agents, hookLog, check, sessions, candidates, infernoDir };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ── HTML builder ──────────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
function buildHtml(data, projectName) {
|
|
102
|
+
const { caps, agents, check, sessions, candidates } = data;
|
|
103
|
+
|
|
104
|
+
const statusColor = check?.status === "ok" ? "#22c55e"
|
|
105
|
+
: check?.status === "warning" ? "#f59e0b"
|
|
106
|
+
: check?.status === "error" ? "#ef4444"
|
|
107
|
+
: "#6b7280";
|
|
108
|
+
|
|
109
|
+
const statusLabel = check?.status || "unknown";
|
|
110
|
+
const capCount = caps.length;
|
|
111
|
+
const agentCount = agents.length;
|
|
112
|
+
const issueCount = (check?.issues || []).length;
|
|
113
|
+
|
|
114
|
+
// Capability rows
|
|
115
|
+
const capRows = caps.map(c => {
|
|
116
|
+
const statusBadge = c.status ? `<span class="badge">${c.status}</span>` : "";
|
|
117
|
+
return `<tr>
|
|
118
|
+
<td><code>${esc(c.id)}</code></td>
|
|
119
|
+
<td>${esc(c.title || "")}${statusBadge}</td>
|
|
120
|
+
<td>${esc(c.since || "")}</td>
|
|
121
|
+
</tr>`;
|
|
122
|
+
}).join("\n");
|
|
123
|
+
|
|
124
|
+
// Agent rows
|
|
125
|
+
const agentRows = agents.map(a => {
|
|
126
|
+
const steps = (a.steps || []).map(s => typeof s === "string" ? s : s.command).join(" → ");
|
|
127
|
+
const conf = a.confidence ? `${Math.round(a.confidence * 100)}%` : "—";
|
|
128
|
+
return `<tr>
|
|
129
|
+
<td><strong>${esc(a.name)}</strong></td>
|
|
130
|
+
<td>${esc(a.description || steps)}</td>
|
|
131
|
+
<td><code>${esc(steps)}</code></td>
|
|
132
|
+
<td>${conf}</td>
|
|
133
|
+
</tr>`;
|
|
134
|
+
}).join("\n");
|
|
135
|
+
|
|
136
|
+
// Issues
|
|
137
|
+
const issueItems = (check?.issues || []).map(i =>
|
|
138
|
+
`<li class="issue">${esc(typeof i === "string" ? i : i.message || JSON.stringify(i))}</li>`
|
|
139
|
+
).join("\n");
|
|
140
|
+
|
|
141
|
+
// Session timeline
|
|
142
|
+
const sessionItems = sessions.slice().reverse().map(s => {
|
|
143
|
+
const cmds = (s.commands || []).join(", ");
|
|
144
|
+
const date = s.startedAt ? new Date(s.startedAt).toLocaleString() : "unknown";
|
|
145
|
+
return `<div class="session-item">
|
|
146
|
+
<span class="session-date">${esc(date)}</span>
|
|
147
|
+
<span class="session-cmds">${esc(cmds || "no commands recorded")}</span>
|
|
148
|
+
</div>`;
|
|
149
|
+
}).join("\n");
|
|
150
|
+
|
|
151
|
+
// Candidate suggestions
|
|
152
|
+
const candidateItems = candidates.map(c =>
|
|
153
|
+
`<li class="candidate">${esc(c.name || c.id || "unnamed")}: ${esc(c.description || "")}</li>`
|
|
154
|
+
).join("\n");
|
|
155
|
+
|
|
156
|
+
return `<!DOCTYPE html>
|
|
157
|
+
<html lang="en">
|
|
158
|
+
<head>
|
|
159
|
+
<meta charset="UTF-8">
|
|
160
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
161
|
+
<title>infernoflow — ${esc(projectName)}</title>
|
|
162
|
+
<style>
|
|
163
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
164
|
+
:root {
|
|
165
|
+
--bg: #0f1117; --surface: #1a1d27; --border: #2d3148;
|
|
166
|
+
--text: #e2e8f0; --muted: #64748b; --accent: #f97316;
|
|
167
|
+
--green: #22c55e; --yellow: #f59e0b; --red: #ef4444; --blue: #3b82f6;
|
|
168
|
+
}
|
|
169
|
+
body { background: var(--bg); color: var(--text); font-family: system-ui, sans-serif; font-size: 14px; line-height: 1.5; }
|
|
170
|
+
header { background: var(--surface); border-bottom: 1px solid var(--border); padding: 16px 24px; display: flex; align-items: center; gap: 12px; }
|
|
171
|
+
header h1 { font-size: 18px; font-weight: 700; }
|
|
172
|
+
header .flame { font-size: 22px; }
|
|
173
|
+
header .project { color: var(--muted); font-size: 13px; }
|
|
174
|
+
header .live { margin-left: auto; font-size: 11px; color: var(--green); display: flex; align-items: center; gap: 4px; }
|
|
175
|
+
header .live::before { content: ""; display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--green); animation: pulse 2s infinite; }
|
|
176
|
+
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }
|
|
177
|
+
main { max-width: 1100px; margin: 0 auto; padding: 24px; display: grid; gap: 20px; }
|
|
178
|
+
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 16px; }
|
|
179
|
+
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 18px; }
|
|
180
|
+
.card .label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted); margin-bottom: 8px; }
|
|
181
|
+
.card .value { font-size: 28px; font-weight: 700; }
|
|
182
|
+
.card .sub { font-size: 12px; color: var(--muted); margin-top: 4px; }
|
|
183
|
+
.status-ok { color: var(--green); }
|
|
184
|
+
.status-warning { color: var(--yellow); }
|
|
185
|
+
.status-error { color: var(--red); }
|
|
186
|
+
section { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; overflow: hidden; }
|
|
187
|
+
section h2 { font-size: 13px; font-weight: 600; padding: 14px 18px; border-bottom: 1px solid var(--border); color: var(--muted); text-transform: uppercase; letter-spacing: 0.06em; }
|
|
188
|
+
table { width: 100%; border-collapse: collapse; }
|
|
189
|
+
th, td { padding: 10px 18px; text-align: left; border-bottom: 1px solid var(--border); }
|
|
190
|
+
th { font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); background: rgba(255,255,255,0.02); }
|
|
191
|
+
tr:last-child td { border-bottom: none; }
|
|
192
|
+
tr:hover td { background: rgba(255,255,255,0.03); }
|
|
193
|
+
code { font-family: monospace; font-size: 12px; background: rgba(255,255,255,0.07); padding: 1px 5px; border-radius: 4px; }
|
|
194
|
+
.badge { font-size: 10px; background: rgba(249,115,22,0.15); color: var(--accent); padding: 1px 6px; border-radius: 9px; margin-left: 6px; }
|
|
195
|
+
.issues-list, .candidates-list { list-style: none; padding: 14px 18px; display: flex; flex-direction: column; gap: 8px; }
|
|
196
|
+
.issue { color: var(--red); font-size: 13px; }
|
|
197
|
+
.candidate { color: var(--yellow); font-size: 13px; }
|
|
198
|
+
.empty { padding: 24px 18px; color: var(--muted); text-align: center; font-size: 13px; }
|
|
199
|
+
.session-item { display: flex; gap: 16px; align-items: baseline; padding: 9px 18px; border-bottom: 1px solid var(--border); }
|
|
200
|
+
.session-item:last-child { border-bottom: none; }
|
|
201
|
+
.session-date { font-size: 11px; color: var(--muted); white-space: nowrap; min-width: 140px; }
|
|
202
|
+
.session-cmds { font-size: 12px; color: var(--text); }
|
|
203
|
+
footer { text-align: center; color: var(--muted); font-size: 11px; padding: 24px; }
|
|
204
|
+
</style>
|
|
205
|
+
</head>
|
|
206
|
+
<body>
|
|
207
|
+
<header>
|
|
208
|
+
<span class="flame">🔥</span>
|
|
209
|
+
<div>
|
|
210
|
+
<h1>infernoflow</h1>
|
|
211
|
+
<div class="project">${esc(projectName)}</div>
|
|
212
|
+
</div>
|
|
213
|
+
<div class="live">Live</div>
|
|
214
|
+
</header>
|
|
215
|
+
<main>
|
|
216
|
+
|
|
217
|
+
<!-- Stat cards -->
|
|
218
|
+
<div class="cards">
|
|
219
|
+
<div class="card">
|
|
220
|
+
<div class="label">Contract status</div>
|
|
221
|
+
<div class="value status-${statusLabel}" style="color:${statusColor}">${statusLabel.toUpperCase()}</div>
|
|
222
|
+
<div class="sub">${issueCount > 0 ? issueCount + " issue" + (issueCount !== 1 ? "s" : "") : "All checks passed"}</div>
|
|
223
|
+
</div>
|
|
224
|
+
<div class="card">
|
|
225
|
+
<div class="label">Capabilities</div>
|
|
226
|
+
<div class="value">${capCount}</div>
|
|
227
|
+
<div class="sub">tracked in contract</div>
|
|
228
|
+
</div>
|
|
229
|
+
<div class="card">
|
|
230
|
+
<div class="label">Agents</div>
|
|
231
|
+
<div class="value">${agentCount}</div>
|
|
232
|
+
<div class="sub">synthesized workflows</div>
|
|
233
|
+
</div>
|
|
234
|
+
<div class="card">
|
|
235
|
+
<div class="label">Sessions</div>
|
|
236
|
+
<div class="value">${sessions.length}</div>
|
|
237
|
+
<div class="sub">recent sessions logged</div>
|
|
238
|
+
</div>
|
|
239
|
+
</div>
|
|
240
|
+
|
|
241
|
+
${issueCount > 0 ? `
|
|
242
|
+
<!-- Issues -->
|
|
243
|
+
<section>
|
|
244
|
+
<h2>⚠ Issues</h2>
|
|
245
|
+
<ul class="issues-list">${issueItems}</ul>
|
|
246
|
+
</section>` : ""}
|
|
247
|
+
|
|
248
|
+
<!-- Capabilities -->
|
|
249
|
+
<section>
|
|
250
|
+
<h2>Capabilities (${capCount})</h2>
|
|
251
|
+
${capCount > 0 ? `
|
|
252
|
+
<table>
|
|
253
|
+
<thead><tr><th>ID</th><th>Title</th><th>Since</th></tr></thead>
|
|
254
|
+
<tbody>${capRows}</tbody>
|
|
255
|
+
</table>` : `<div class="empty">No capabilities found in inferno/capabilities.json</div>`}
|
|
256
|
+
</section>
|
|
257
|
+
|
|
258
|
+
<!-- Agents -->
|
|
259
|
+
<section>
|
|
260
|
+
<h2>Synthesized Agents (${agentCount})</h2>
|
|
261
|
+
${agentCount > 0 ? `
|
|
262
|
+
<table>
|
|
263
|
+
<thead><tr><th>Name</th><th>Description</th><th>Steps</th><th>Confidence</th></tr></thead>
|
|
264
|
+
<tbody>${agentRows}</tbody>
|
|
265
|
+
</table>` : `<div class="empty">No agents yet — run <code>infernoflow synthesize</code> to generate them</div>`}
|
|
266
|
+
</section>
|
|
267
|
+
|
|
268
|
+
${candidates.length > 0 ? `
|
|
269
|
+
<!-- Candidates -->
|
|
270
|
+
<section>
|
|
271
|
+
<h2>Workflow Candidates (${candidates.length})</h2>
|
|
272
|
+
<ul class="candidates-list">${candidateItems}</ul>
|
|
273
|
+
</section>` : ""}
|
|
274
|
+
|
|
275
|
+
<!-- Session timeline -->
|
|
276
|
+
<section>
|
|
277
|
+
<h2>Recent Sessions</h2>
|
|
278
|
+
${sessions.length > 0 ? `<div>${sessionItems}</div>`
|
|
279
|
+
: `<div class="empty">No session data yet — sessions are logged automatically as you use infernoflow</div>`}
|
|
280
|
+
</section>
|
|
281
|
+
|
|
282
|
+
</main>
|
|
283
|
+
<footer>infernoflow dashboard · auto-refreshes every 10s · <a href="/" style="color:var(--muted)">refresh now</a></footer>
|
|
284
|
+
<script>
|
|
285
|
+
// SSE live reload
|
|
286
|
+
const es = new EventSource('/events');
|
|
287
|
+
es.onmessage = () => window.location.reload();
|
|
288
|
+
es.onerror = () => {};
|
|
289
|
+
</script>
|
|
290
|
+
</body>
|
|
291
|
+
</html>`;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function esc(str) {
|
|
295
|
+
return String(str || "")
|
|
296
|
+
.replace(/&/g, "&")
|
|
297
|
+
.replace(/</g, "<")
|
|
298
|
+
.replace(/>/g, ">")
|
|
299
|
+
.replace(/"/g, """);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ── HTTP server ───────────────────────────────────────────────────────────────
|
|
303
|
+
|
|
304
|
+
function startServer(infernoDir, port) {
|
|
305
|
+
const cwd = path.dirname(infernoDir);
|
|
306
|
+
const projectName = path.basename(cwd);
|
|
307
|
+
const sseClients = new Set();
|
|
308
|
+
|
|
309
|
+
// Watch inferno/ for changes → notify SSE clients
|
|
310
|
+
let watchTimer = null;
|
|
311
|
+
try {
|
|
312
|
+
fs.watch(infernoDir, { recursive: true }, () => {
|
|
313
|
+
clearTimeout(watchTimer);
|
|
314
|
+
watchTimer = setTimeout(() => {
|
|
315
|
+
for (const res of sseClients) {
|
|
316
|
+
try { res.write("data: reload\n\n"); } catch {}
|
|
317
|
+
}
|
|
318
|
+
}, 500);
|
|
319
|
+
});
|
|
320
|
+
} catch {}
|
|
321
|
+
|
|
322
|
+
const server = http.createServer((req, res) => {
|
|
323
|
+
// SSE endpoint
|
|
324
|
+
if (req.url === "/events") {
|
|
325
|
+
res.writeHead(200, {
|
|
326
|
+
"Content-Type": "text/event-stream",
|
|
327
|
+
"Cache-Control": "no-cache",
|
|
328
|
+
"Connection": "keep-alive",
|
|
329
|
+
});
|
|
330
|
+
sseClients.add(res);
|
|
331
|
+
req.on("close", () => sseClients.delete(res));
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// JSON API
|
|
336
|
+
if (req.url === "/api/data") {
|
|
337
|
+
const data = gatherData(infernoDir);
|
|
338
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
339
|
+
res.end(JSON.stringify(data, null, 2));
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Dashboard HTML
|
|
344
|
+
try {
|
|
345
|
+
const data = gatherData(infernoDir);
|
|
346
|
+
const html = buildHtml(data, projectName);
|
|
347
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
348
|
+
res.end(html);
|
|
349
|
+
} catch (err) {
|
|
350
|
+
res.writeHead(500, { "Content-Type": "text/plain" });
|
|
351
|
+
res.end(`Error: ${err.message}`);
|
|
352
|
+
}
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
server.listen(port, "127.0.0.1", () => {});
|
|
356
|
+
return server;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function openBrowser(url) {
|
|
360
|
+
const platform = os.platform();
|
|
361
|
+
try {
|
|
362
|
+
if (platform === "darwin") execSync(`open "${url}"`, { stdio: "ignore" });
|
|
363
|
+
else if (platform === "win32") execSync(`start "" "${url}"`, { stdio: "ignore", shell: true });
|
|
364
|
+
else execSync(`xdg-open "${url}"`, { stdio: "ignore" });
|
|
365
|
+
} catch {}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// ── main ──────────────────────────────────────────────────────────────────────
|
|
369
|
+
|
|
370
|
+
export async function dashboardCommand(rawArgs) {
|
|
371
|
+
const args = rawArgs.slice(1);
|
|
372
|
+
const noOpen = args.includes("--no-open");
|
|
373
|
+
const portIdx = args.indexOf("--port");
|
|
374
|
+
const port = portIdx !== -1 ? parseInt(args[portIdx + 1], 10) : 7337;
|
|
375
|
+
|
|
376
|
+
const cwd = process.cwd();
|
|
377
|
+
const infernoDir = path.join(cwd, "inferno");
|
|
378
|
+
|
|
379
|
+
header("infernoflow dashboard");
|
|
380
|
+
|
|
381
|
+
if (!fs.existsSync(infernoDir)) {
|
|
382
|
+
warn("inferno/ not found — run: infernoflow init");
|
|
383
|
+
process.exit(1);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const url = `http://localhost:${port}`;
|
|
387
|
+
|
|
388
|
+
startServer(infernoDir, port);
|
|
389
|
+
|
|
390
|
+
ok(`Dashboard running → ${cyan(url)}`);
|
|
391
|
+
info("Auto-refreshes when inferno/ files change");
|
|
392
|
+
info("Press Ctrl+C to stop");
|
|
393
|
+
console.log();
|
|
394
|
+
|
|
395
|
+
if (!noOpen) openBrowser(url);
|
|
396
|
+
|
|
397
|
+
// Keep alive
|
|
398
|
+
await new Promise(() => {});
|
|
399
|
+
}
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* infernoflow pr-comment
|
|
3
|
+
*
|
|
4
|
+
* Posts a capability drift analysis as a GitHub PR comment.
|
|
5
|
+
* Designed to run in CI (GitHub Actions) on pull_request events.
|
|
6
|
+
*
|
|
7
|
+
* Auto-reads context from GitHub Actions environment variables:
|
|
8
|
+
* GITHUB_TOKEN — required for posting comments
|
|
9
|
+
* GITHUB_REPOSITORY — e.g. "owner/repo"
|
|
10
|
+
* GITHUB_EVENT_PATH — path to the event JSON (contains PR number)
|
|
11
|
+
* GITHUB_SHA — current commit SHA
|
|
12
|
+
* GITHUB_BASE_REF — base branch name (e.g. "main")
|
|
13
|
+
*
|
|
14
|
+
* Usage:
|
|
15
|
+
* infernoflow pr-comment # auto-detect from CI env
|
|
16
|
+
* infernoflow pr-comment --pr 42 # explicit PR number
|
|
17
|
+
* infernoflow pr-comment --repo owner/r # explicit repo
|
|
18
|
+
* infernoflow pr-comment --token ghp_... # explicit token
|
|
19
|
+
* infernoflow pr-comment --dry-run # print comment without posting
|
|
20
|
+
* infernoflow pr-comment --json # machine-readable result
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import * as fs from "node:fs";
|
|
24
|
+
import * as path from "node:path";
|
|
25
|
+
import * as https from "node:https";
|
|
26
|
+
import { execSync } from "node:child_process";
|
|
27
|
+
import { header, ok, warn, info, done, bold, cyan, gray, green, red, yellow } from "../ui/output.mjs";
|
|
28
|
+
|
|
29
|
+
// ── git helpers ───────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
function capture(cmd, cwd) {
|
|
32
|
+
try {
|
|
33
|
+
return execSync(cmd, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
34
|
+
} catch { return null; }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function lastTag(cwd) {
|
|
38
|
+
return capture("git describe --tags --abbrev=0", cwd) || null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function fileAtRef(ref, relPath, cwd) {
|
|
42
|
+
return capture(`git show "${ref}:${relPath}"`, cwd);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── capability helpers ────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
function parseCaps(jsonText) {
|
|
48
|
+
if (!jsonText) return null;
|
|
49
|
+
try {
|
|
50
|
+
const obj = JSON.parse(jsonText);
|
|
51
|
+
const raw = obj.capabilities || [];
|
|
52
|
+
return raw.map(c => {
|
|
53
|
+
if (typeof c === "string") return { id: c, title: c };
|
|
54
|
+
return { id: c.id || c, title: c.title || c.id || String(c), status: c.status };
|
|
55
|
+
});
|
|
56
|
+
} catch { return null; }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function loadCapsFromDisk(infernoDir) {
|
|
60
|
+
for (const name of ["capabilities.json", "contract.json"]) {
|
|
61
|
+
const p = path.join(infernoDir, name);
|
|
62
|
+
if (fs.existsSync(p)) return parseCaps(fs.readFileSync(p, "utf8"));
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function loadCapsAtRef(ref, cwd) {
|
|
68
|
+
for (const name of ["capabilities.json", "contract.json"]) {
|
|
69
|
+
const content = fileAtRef(ref, `inferno/${name}`, cwd);
|
|
70
|
+
if (content) return parseCaps(content);
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function diffCaps(before, after) {
|
|
76
|
+
const beforeMap = new Map(before.map(c => [c.id, c]));
|
|
77
|
+
const afterMap = new Map(after.map(c => [c.id, c]));
|
|
78
|
+
const added = after.filter(c => !beforeMap.has(c.id));
|
|
79
|
+
const removed = before.filter(c => !afterMap.has(c.id));
|
|
80
|
+
const changed = [];
|
|
81
|
+
for (const c of after) {
|
|
82
|
+
const old = beforeMap.get(c.id);
|
|
83
|
+
if (!old) continue;
|
|
84
|
+
const changes = [];
|
|
85
|
+
if (old.title !== c.title) changes.push({ field: "title", from: old.title, to: c.title });
|
|
86
|
+
if ((old.status || "") !== (c.status || "")) changes.push({ field: "status", from: old.status || "—", to: c.status || "—" });
|
|
87
|
+
if (changes.length) changed.push({ id: c.id, changes });
|
|
88
|
+
}
|
|
89
|
+
return { added, removed, changed };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function classifyBump(diff) {
|
|
93
|
+
if (diff.removed.length > 0) return "major";
|
|
94
|
+
if (diff.added.length > 0) return "minor";
|
|
95
|
+
if (diff.changed.length > 0) return "patch";
|
|
96
|
+
return "none";
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ── comment builder ───────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
function buildComment(diff, bump, ref, currentVersion, nextVersion) {
|
|
102
|
+
const lines = [];
|
|
103
|
+
|
|
104
|
+
// Header
|
|
105
|
+
const bumpEmoji = bump === "major" ? "🔴" : bump === "minor" ? "🟡" : bump === "patch" ? "🟢" : "✅";
|
|
106
|
+
const bumpLabel = bump === "none" ? "No capability changes" : `${bump.toUpperCase()} bump recommended`;
|
|
107
|
+
lines.push(`## 🔥 infernoflow — Capability Analysis`);
|
|
108
|
+
lines.push(``);
|
|
109
|
+
lines.push(`${bumpEmoji} **${bumpLabel}**${bump !== "none" ? ` · \`${currentVersion}\` → \`${nextVersion}\`` : ""}`);
|
|
110
|
+
lines.push(``);
|
|
111
|
+
|
|
112
|
+
// Summary table
|
|
113
|
+
const hasChanges = diff.added.length || diff.removed.length || diff.changed.length;
|
|
114
|
+
if (!hasChanges) {
|
|
115
|
+
lines.push(`> No capability changes detected since \`${ref}\`. Contract is in sync.`);
|
|
116
|
+
} else {
|
|
117
|
+
lines.push(`| Change | Count |`);
|
|
118
|
+
lines.push(`|--------|-------|`);
|
|
119
|
+
if (diff.added.length) lines.push(`| ➕ Added | ${diff.added.length} |`);
|
|
120
|
+
if (diff.removed.length) lines.push(`| ❌ Removed | ${diff.removed.length} |`);
|
|
121
|
+
if (diff.changed.length) lines.push(`| ✏️ Modified | ${diff.changed.length} |`);
|
|
122
|
+
lines.push(``);
|
|
123
|
+
|
|
124
|
+
// Detail sections
|
|
125
|
+
if (diff.added.length) {
|
|
126
|
+
lines.push(`<details><summary>➕ Added capabilities (${diff.added.length})</summary>`);
|
|
127
|
+
lines.push(``);
|
|
128
|
+
for (const c of diff.added) lines.push(`- \`${c.id}\` — ${c.title}`);
|
|
129
|
+
lines.push(``);
|
|
130
|
+
lines.push(`</details>`);
|
|
131
|
+
lines.push(``);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (diff.removed.length) {
|
|
135
|
+
lines.push(`<details><summary>❌ Removed capabilities (${diff.removed.length}) — breaking change</summary>`);
|
|
136
|
+
lines.push(``);
|
|
137
|
+
for (const c of diff.removed) lines.push(`- \`${c.id}\` — ${c.title}`);
|
|
138
|
+
lines.push(``);
|
|
139
|
+
lines.push(`</details>`);
|
|
140
|
+
lines.push(``);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (diff.changed.length) {
|
|
144
|
+
lines.push(`<details><summary>✏️ Modified capabilities (${diff.changed.length})</summary>`);
|
|
145
|
+
lines.push(``);
|
|
146
|
+
for (const item of diff.changed) {
|
|
147
|
+
lines.push(`- \`${item.id}\``);
|
|
148
|
+
for (const ch of item.changes) lines.push(` - ${ch.field}: \`${ch.from}\` → \`${ch.to}\``);
|
|
149
|
+
}
|
|
150
|
+
lines.push(``);
|
|
151
|
+
lines.push(`</details>`);
|
|
152
|
+
lines.push(``);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Bump recommendation
|
|
157
|
+
if (bump === "major") {
|
|
158
|
+
lines.push(`> ⚠️ **Breaking change detected.** Capabilities were removed. Consider a major version bump.`);
|
|
159
|
+
lines.push(`> Run \`infernoflow version --apply\` to update \`package.json\`.`);
|
|
160
|
+
} else if (bump === "minor") {
|
|
161
|
+
lines.push(`> ℹ️ New capabilities added. A minor version bump is recommended.`);
|
|
162
|
+
lines.push(`> Run \`infernoflow version --apply\` to update \`package.json\`.`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
lines.push(``);
|
|
166
|
+
lines.push(`---`);
|
|
167
|
+
lines.push(`<sub>Generated by [infernoflow](https://github.com/ronmiz/infernoflow) · compared against \`${ref}\`</sub>`);
|
|
168
|
+
|
|
169
|
+
return lines.join("\n");
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ── GitHub API ────────────────────────────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
function githubRequest(method, pathname, body, token) {
|
|
175
|
+
return new Promise((resolve, reject) => {
|
|
176
|
+
const data = body ? JSON.stringify(body) : null;
|
|
177
|
+
const req = https.request({
|
|
178
|
+
hostname: "api.github.com",
|
|
179
|
+
path: pathname,
|
|
180
|
+
method,
|
|
181
|
+
headers: {
|
|
182
|
+
"Authorization": `Bearer ${token}`,
|
|
183
|
+
"Accept": "application/vnd.github+json",
|
|
184
|
+
"Content-Type": "application/json",
|
|
185
|
+
"User-Agent": "infernoflow-cli",
|
|
186
|
+
...(data ? { "Content-Length": Buffer.byteLength(data) } : {}),
|
|
187
|
+
},
|
|
188
|
+
}, (res) => {
|
|
189
|
+
let raw = "";
|
|
190
|
+
res.on("data", chunk => raw += chunk);
|
|
191
|
+
res.on("end", () => {
|
|
192
|
+
try { resolve({ status: res.statusCode, body: JSON.parse(raw) }); }
|
|
193
|
+
catch { resolve({ status: res.statusCode, body: raw }); }
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
req.on("error", reject);
|
|
197
|
+
if (data) req.write(data);
|
|
198
|
+
req.end();
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function findExistingComment(repo, prNumber, token) {
|
|
203
|
+
// Look for a previous infernoflow comment to update instead of creating a new one
|
|
204
|
+
const res = await githubRequest("GET", `/repos/${repo}/issues/${prNumber}/comments?per_page=100`, null, token);
|
|
205
|
+
if (res.status !== 200 || !Array.isArray(res.body)) return null;
|
|
206
|
+
return res.body.find(c => c.body && c.body.includes("🔥 infernoflow — Capability Analysis")) || null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function postComment(repo, prNumber, body, token) {
|
|
210
|
+
// Update existing comment if found (avoids spam on multiple pushes)
|
|
211
|
+
const existing = await findExistingComment(repo, prNumber, token);
|
|
212
|
+
if (existing) {
|
|
213
|
+
return githubRequest("PATCH", `/repos/${repo}/issues/comments/${existing.id}`, { body }, token);
|
|
214
|
+
}
|
|
215
|
+
return githubRequest("POST", `/repos/${repo}/issues/${prNumber}/comments`, { body }, token);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ── env helpers ───────────────────────────────────────────────────────────────
|
|
219
|
+
|
|
220
|
+
function readGithubEventPr() {
|
|
221
|
+
const eventPath = process.env.GITHUB_EVENT_PATH;
|
|
222
|
+
if (!eventPath || !fs.existsSync(eventPath)) return null;
|
|
223
|
+
try {
|
|
224
|
+
const event = JSON.parse(fs.readFileSync(eventPath, "utf8"));
|
|
225
|
+
return event.pull_request?.number || event.number || null;
|
|
226
|
+
} catch { return null; }
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function applyBump(version, type) {
|
|
230
|
+
const parts = (version || "0.0.0").split(".").map(Number);
|
|
231
|
+
if (type === "major") { parts[0]++; parts[1] = 0; parts[2] = 0; }
|
|
232
|
+
else if (type === "minor") { parts[1]++; parts[2] = 0; }
|
|
233
|
+
else if (type === "patch") { parts[2]++; }
|
|
234
|
+
return parts.join(".");
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function readPackageVersion(cwd) {
|
|
238
|
+
const p = path.join(cwd, "package.json");
|
|
239
|
+
if (!fs.existsSync(p)) return "0.0.0";
|
|
240
|
+
try { return JSON.parse(fs.readFileSync(p, "utf8")).version || "0.0.0"; } catch { return "0.0.0"; }
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ── main ──────────────────────────────────────────────────────────────────────
|
|
244
|
+
|
|
245
|
+
export async function prCommentCommand(rawArgs) {
|
|
246
|
+
const args = rawArgs.slice(1);
|
|
247
|
+
const dryRun = args.includes("--dry-run");
|
|
248
|
+
const asJson = args.includes("--json");
|
|
249
|
+
|
|
250
|
+
const prIdx = args.indexOf("--pr");
|
|
251
|
+
const repoIdx = args.indexOf("--repo");
|
|
252
|
+
const tokenIdx = args.indexOf("--token");
|
|
253
|
+
const refIdx = args.indexOf("--ref");
|
|
254
|
+
|
|
255
|
+
const cwd = process.cwd();
|
|
256
|
+
const infernoDir = path.join(cwd, "inferno");
|
|
257
|
+
|
|
258
|
+
if (!asJson) header("infernoflow pr-comment");
|
|
259
|
+
|
|
260
|
+
// ── Resolve inputs ────────────────────────────────────────────────────────
|
|
261
|
+
const token = tokenIdx !== -1 ? args[tokenIdx + 1] : process.env.GITHUB_TOKEN;
|
|
262
|
+
const repo = repoIdx !== -1 ? args[repoIdx + 1] : process.env.GITHUB_REPOSITORY;
|
|
263
|
+
const prNumber = prIdx !== -1 ? parseInt(args[prIdx + 1], 10)
|
|
264
|
+
: readGithubEventPr();
|
|
265
|
+
|
|
266
|
+
let ref = refIdx !== -1 ? args[refIdx + 1] : null;
|
|
267
|
+
if (!ref) ref = process.env.GITHUB_BASE_REF ? `origin/${process.env.GITHUB_BASE_REF}` : null;
|
|
268
|
+
if (!ref) ref = lastTag(cwd);
|
|
269
|
+
if (!ref) {
|
|
270
|
+
const parentExists = capture("git rev-parse HEAD~1", cwd);
|
|
271
|
+
ref = parentExists ? "HEAD~1" : null;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ── Validate ──────────────────────────────────────────────────────────────
|
|
275
|
+
if (!fs.existsSync(infernoDir)) {
|
|
276
|
+
const msg = "inferno/ not found — run: infernoflow init";
|
|
277
|
+
if (asJson) { console.log(JSON.stringify({ ok: false, error: msg })); process.exit(1); }
|
|
278
|
+
warn(msg); process.exit(1);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (!dryRun && !token) {
|
|
282
|
+
const msg = "No GitHub token found. Set GITHUB_TOKEN env var or use --token";
|
|
283
|
+
if (asJson) { console.log(JSON.stringify({ ok: false, error: msg })); process.exit(1); }
|
|
284
|
+
warn(msg); process.exit(1);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (!dryRun && !repo) {
|
|
288
|
+
const msg = "No repository found. Set GITHUB_REPOSITORY env var or use --repo owner/repo";
|
|
289
|
+
if (asJson) { console.log(JSON.stringify({ ok: false, error: msg })); process.exit(1); }
|
|
290
|
+
warn(msg); process.exit(1);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (!dryRun && !prNumber) {
|
|
294
|
+
const msg = "No PR number found. Use --pr <number> or run in GitHub Actions on pull_request event";
|
|
295
|
+
if (asJson) { console.log(JSON.stringify({ ok: false, error: msg })); process.exit(1); }
|
|
296
|
+
warn(msg); process.exit(1);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ── Load capabilities and compute diff ───────────────────────────────────
|
|
300
|
+
const current = loadCapsFromDisk(infernoDir);
|
|
301
|
+
const previous = ref ? loadCapsAtRef(ref, cwd) : null;
|
|
302
|
+
|
|
303
|
+
if (!current) {
|
|
304
|
+
const msg = "No capabilities.json or contract.json found in inferno/";
|
|
305
|
+
if (asJson) { console.log(JSON.stringify({ ok: false, error: msg })); process.exit(1); }
|
|
306
|
+
warn(msg); process.exit(1);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const diff = diffCaps(previous || [], current);
|
|
310
|
+
const bump = classifyBump(diff);
|
|
311
|
+
const currentVersion = readPackageVersion(cwd);
|
|
312
|
+
const nextVersion = bump !== "none" ? applyBump(currentVersion, bump) : currentVersion;
|
|
313
|
+
|
|
314
|
+
// ── Build comment ─────────────────────────────────────────────────────────
|
|
315
|
+
const commentBody = buildComment(diff, bump, ref || "HEAD", currentVersion, nextVersion);
|
|
316
|
+
|
|
317
|
+
// ── Dry run ───────────────────────────────────────────────────────────────
|
|
318
|
+
if (dryRun) {
|
|
319
|
+
if (asJson) {
|
|
320
|
+
console.log(JSON.stringify({ ok: true, dryRun: true, bump, currentVersion, nextVersion, comment: commentBody }));
|
|
321
|
+
} else {
|
|
322
|
+
console.log();
|
|
323
|
+
info("DRY RUN — comment that would be posted:");
|
|
324
|
+
console.log();
|
|
325
|
+
console.log(commentBody);
|
|
326
|
+
console.log();
|
|
327
|
+
}
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// ── Post comment ──────────────────────────────────────────────────────────
|
|
332
|
+
if (!asJson) info(`Posting to ${bold(repo)} PR #${prNumber}...`);
|
|
333
|
+
|
|
334
|
+
try {
|
|
335
|
+
const result = await postComment(repo, prNumber, commentBody, token);
|
|
336
|
+
|
|
337
|
+
if (result.status === 200 || result.status === 201) {
|
|
338
|
+
const commentUrl = result.body?.html_url || "";
|
|
339
|
+
if (asJson) {
|
|
340
|
+
console.log(JSON.stringify({ ok: true, bump, currentVersion, nextVersion, prNumber, repo, commentUrl }));
|
|
341
|
+
} else {
|
|
342
|
+
ok(`Comment posted → ${cyan(commentUrl || `PR #${prNumber}`)}`);
|
|
343
|
+
if (bump !== "none") {
|
|
344
|
+
console.log();
|
|
345
|
+
info(`Recommended bump: ${bold(bump.toUpperCase())} ${currentVersion} → ${nextVersion}`);
|
|
346
|
+
}
|
|
347
|
+
console.log();
|
|
348
|
+
}
|
|
349
|
+
} else {
|
|
350
|
+
const msg = `GitHub API error ${result.status}: ${JSON.stringify(result.body)}`;
|
|
351
|
+
if (asJson) { console.log(JSON.stringify({ ok: false, error: msg })); }
|
|
352
|
+
else { warn(msg); }
|
|
353
|
+
process.exit(1);
|
|
354
|
+
}
|
|
355
|
+
} catch (err) {
|
|
356
|
+
const msg = `Failed to post comment: ${err.message}`;
|
|
357
|
+
if (asJson) { console.log(JSON.stringify({ ok: false, error: msg })); }
|
|
358
|
+
else { warn(msg); }
|
|
359
|
+
process.exit(1);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* infernoflow team-sync
|
|
3
|
+
*
|
|
4
|
+
* Shared capability contract sync across a team.
|
|
5
|
+
* Uses a dedicated git branch (`inferno-contracts`) as the source of truth.
|
|
6
|
+
*
|
|
7
|
+
* Sub-commands:
|
|
8
|
+
* infernoflow team-sync push — push local contract to shared branch
|
|
9
|
+
* infernoflow team-sync pull — pull shared contract, detect conflicts
|
|
10
|
+
* infernoflow team-sync status — show diff between local and shared
|
|
11
|
+
* infernoflow team-sync init — create the shared branch if it doesn't exist
|
|
12
|
+
*
|
|
13
|
+
* Flags:
|
|
14
|
+
* --branch <name> Shared branch name (default: inferno-contracts)
|
|
15
|
+
* --remote <name> Git remote (default: origin)
|
|
16
|
+
* --json Machine-readable output
|
|
17
|
+
* --force Overwrite conflicts without prompting
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import * as fs from "node:fs";
|
|
21
|
+
import * as path from "node:path";
|
|
22
|
+
import { execSync } from "node:child_process";
|
|
23
|
+
import { header, ok, warn, info, done, bold, cyan, gray, green, red, yellow } from "../ui/output.mjs";
|
|
24
|
+
|
|
25
|
+
// ── git helpers ───────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
function capture(cmd, cwd) {
|
|
28
|
+
try {
|
|
29
|
+
return execSync(cmd, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
30
|
+
} catch { return null; }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function run(cmd, cwd) {
|
|
34
|
+
execSync(cmd, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function currentBranch(cwd) {
|
|
38
|
+
return capture("git rev-parse --abbrev-ref HEAD", cwd) || "HEAD";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function currentUser(cwd) {
|
|
42
|
+
return capture("git config user.name", cwd) || capture("git config user.email", cwd) || "unknown";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function hasRemote(remote, cwd) {
|
|
46
|
+
return !!capture(`git remote get-url ${remote}`, cwd);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function branchExistsRemote(remote, branch, cwd) {
|
|
50
|
+
return !!capture(`git ls-remote --heads ${remote} refs/heads/${branch}`, cwd);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ── capability helpers ────────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
function parseCaps(jsonText) {
|
|
56
|
+
if (!jsonText) return [];
|
|
57
|
+
try {
|
|
58
|
+
const obj = JSON.parse(jsonText);
|
|
59
|
+
const raw = obj.capabilities || [];
|
|
60
|
+
return raw.map(c => typeof c === "string" ? { id: c, title: c } : c);
|
|
61
|
+
} catch { return []; }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function capsToMap(caps) {
|
|
65
|
+
return new Map(caps.map(c => [c.id, c]));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function detectConflicts(local, shared, base) {
|
|
69
|
+
// A conflict occurs when BOTH local and shared changed the same capability
|
|
70
|
+
// since the last sync (base).
|
|
71
|
+
const localMap = capsToMap(local);
|
|
72
|
+
const sharedMap = capsToMap(shared);
|
|
73
|
+
const baseMap = capsToMap(base);
|
|
74
|
+
|
|
75
|
+
const conflicts = [];
|
|
76
|
+
const localOnly = [];
|
|
77
|
+
const sharedOnly = [];
|
|
78
|
+
|
|
79
|
+
const allIds = new Set([...localMap.keys(), ...sharedMap.keys(), ...baseMap.keys()]);
|
|
80
|
+
|
|
81
|
+
for (const id of allIds) {
|
|
82
|
+
const localCap = localMap.get(id);
|
|
83
|
+
const sharedCap = sharedMap.get(id);
|
|
84
|
+
const baseCap = baseMap.get(id);
|
|
85
|
+
|
|
86
|
+
const localChanged = JSON.stringify(localCap) !== JSON.stringify(baseCap);
|
|
87
|
+
const sharedChanged = JSON.stringify(sharedCap) !== JSON.stringify(baseCap);
|
|
88
|
+
|
|
89
|
+
if (localChanged && sharedChanged && JSON.stringify(localCap) !== JSON.stringify(sharedCap)) {
|
|
90
|
+
conflicts.push({ id, local: localCap, shared: sharedCap, base: baseCap });
|
|
91
|
+
} else if (localCap && !sharedCap && !baseCap) {
|
|
92
|
+
localOnly.push(localCap); // added locally, not in shared yet
|
|
93
|
+
} else if (!localCap && sharedCap && !baseCap) {
|
|
94
|
+
sharedOnly.push(sharedCap); // added in shared, not locally yet
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return { conflicts, localOnly, sharedOnly };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ── shared branch operations ──────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
function readContractFromBranch(remote, branch, cwd) {
|
|
104
|
+
// Fetch the branch first
|
|
105
|
+
try { run(`git fetch ${remote} ${branch} --quiet`, cwd); } catch {}
|
|
106
|
+
|
|
107
|
+
const content = capture(`git show ${remote}/${branch}:inferno/contract.json`, cwd);
|
|
108
|
+
if (!content) return null;
|
|
109
|
+
try { return JSON.parse(content); } catch { return null; }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function readLastSyncBase(infernoDir) {
|
|
113
|
+
const basePath = path.join(infernoDir, ".team-sync-base.json");
|
|
114
|
+
if (!fs.existsSync(basePath)) return null;
|
|
115
|
+
try { return JSON.parse(fs.readFileSync(basePath, "utf8")); } catch { return null; }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function writeLastSyncBase(infernoDir, contract) {
|
|
119
|
+
const basePath = path.join(infernoDir, ".team-sync-base.json");
|
|
120
|
+
fs.writeFileSync(basePath, JSON.stringify(contract, null, 2), "utf8");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ── sub-commands ──────────────────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
function initSharedBranch(cwd, remote, branch, infernoDir, asJson) {
|
|
126
|
+
if (!hasRemote(remote, cwd)) {
|
|
127
|
+
const msg = `Remote "${remote}" not found. Add it first: git remote add ${remote} <url>`;
|
|
128
|
+
if (asJson) { console.log(JSON.stringify({ ok: false, error: msg })); process.exit(1); }
|
|
129
|
+
warn(msg); process.exit(1);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (branchExistsRemote(remote, branch, cwd)) {
|
|
133
|
+
if (asJson) { console.log(JSON.stringify({ ok: true, action: "already_exists", branch })); }
|
|
134
|
+
else { ok(`Shared branch ${bold(branch)} already exists on ${remote}`); }
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Create orphan branch with just the contract
|
|
139
|
+
const contractPath = path.join(infernoDir, "contract.json");
|
|
140
|
+
if (!fs.existsSync(contractPath)) {
|
|
141
|
+
const msg = "inferno/contract.json not found — run: infernoflow init";
|
|
142
|
+
if (asJson) { console.log(JSON.stringify({ ok: false, error: msg })); process.exit(1); }
|
|
143
|
+
warn(msg); process.exit(1);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Use a temp worktree approach: push contract.json to the branch
|
|
147
|
+
const tmpDir = path.join(infernoDir, ".team-sync-tmp");
|
|
148
|
+
try {
|
|
149
|
+
fs.mkdirSync(tmpDir, { recursive: true });
|
|
150
|
+
const contractContent = fs.readFileSync(contractPath, "utf8");
|
|
151
|
+
fs.writeFileSync(path.join(tmpDir, "contract.json"), contractContent);
|
|
152
|
+
|
|
153
|
+
// Create an empty tree commit on the shared branch
|
|
154
|
+
run(`git checkout --orphan ${branch}`, cwd);
|
|
155
|
+
run(`git rm -rf . --quiet 2>/dev/null || true`, cwd);
|
|
156
|
+
run(`git checkout ${currentBranch(cwd)} -- inferno/contract.json`, cwd);
|
|
157
|
+
run(`git add inferno/contract.json`, cwd);
|
|
158
|
+
run(`git commit -m "infernoflow: initialize shared contract branch"`, cwd);
|
|
159
|
+
run(`git push ${remote} ${branch}`, cwd);
|
|
160
|
+
run(`git checkout -`, cwd); // back to previous branch
|
|
161
|
+
} catch (err) {
|
|
162
|
+
// Restore original branch on failure
|
|
163
|
+
try { run(`git checkout -`, cwd); } catch {}
|
|
164
|
+
const msg = `Failed to create shared branch: ${err.message}`;
|
|
165
|
+
if (asJson) { console.log(JSON.stringify({ ok: false, error: msg })); process.exit(1); }
|
|
166
|
+
warn(msg); process.exit(1);
|
|
167
|
+
} finally {
|
|
168
|
+
try { fs.rmSync(tmpDir, { recursive: true }); } catch {}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (asJson) { console.log(JSON.stringify({ ok: true, action: "created", branch, remote })); }
|
|
172
|
+
else { done(`Shared branch ${bold(branch)} created on ${bold(remote)}`); }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function pushToShared(cwd, remote, branch, infernoDir, asJson, force) {
|
|
176
|
+
const contractPath = path.join(infernoDir, "contract.json");
|
|
177
|
+
if (!fs.existsSync(contractPath)) {
|
|
178
|
+
const msg = "inferno/contract.json not found";
|
|
179
|
+
if (asJson) { console.log(JSON.stringify({ ok: false, error: msg })); process.exit(1); }
|
|
180
|
+
warn(msg); process.exit(1);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const localContract = JSON.parse(fs.readFileSync(contractPath, "utf8"));
|
|
184
|
+
const user = currentUser(cwd);
|
|
185
|
+
|
|
186
|
+
// Stamp the push metadata
|
|
187
|
+
localContract._teamSync = {
|
|
188
|
+
pushedBy: user,
|
|
189
|
+
pushedAt: new Date().toISOString(),
|
|
190
|
+
fromBranch: currentBranch(cwd),
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
// Write updated contract
|
|
194
|
+
fs.writeFileSync(contractPath, JSON.stringify(localContract, null, 2), "utf8");
|
|
195
|
+
|
|
196
|
+
// Commit + push to shared branch
|
|
197
|
+
try {
|
|
198
|
+
run(`git fetch ${remote} ${branch} --quiet`, cwd);
|
|
199
|
+
// Use a temporary stash-push approach: push just the contract file
|
|
200
|
+
capture(`git stash --quiet`, cwd);
|
|
201
|
+
try {
|
|
202
|
+
run(`git checkout ${remote}/${branch} -- inferno/contract.json 2>/dev/null || git checkout ${remote}/${branch} inferno/contract.json`, cwd);
|
|
203
|
+
} catch {}
|
|
204
|
+
capture(`git stash pop --quiet`, cwd);
|
|
205
|
+
|
|
206
|
+
// Write the updated content
|
|
207
|
+
fs.writeFileSync(contractPath, JSON.stringify(localContract, null, 2), "utf8");
|
|
208
|
+
run(`git add inferno/contract.json`, cwd);
|
|
209
|
+
run(`git commit -m "infernoflow team-sync: push by ${user}"`, cwd);
|
|
210
|
+
run(`git push ${remote} HEAD:${branch}`, cwd);
|
|
211
|
+
|
|
212
|
+
// Save base snapshot
|
|
213
|
+
writeLastSyncBase(infernoDir, localContract);
|
|
214
|
+
|
|
215
|
+
if (asJson) { console.log(JSON.stringify({ ok: true, action: "pushed", remote, branch, user })); }
|
|
216
|
+
else { done(`Contract pushed to ${bold(remote + "/" + branch)} by ${bold(user)}`); }
|
|
217
|
+
} catch (err) {
|
|
218
|
+
const msg = `Push failed: ${err.message}`;
|
|
219
|
+
if (asJson) { console.log(JSON.stringify({ ok: false, error: msg })); process.exit(1); }
|
|
220
|
+
warn(msg);
|
|
221
|
+
info(`Try: git push ${remote} HEAD:${branch} --force (use --force flag)`);
|
|
222
|
+
process.exit(1);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function pullFromShared(cwd, remote, branch, infernoDir, asJson, force) {
|
|
227
|
+
const contractPath = path.join(infernoDir, "contract.json");
|
|
228
|
+
|
|
229
|
+
// Fetch remote contract
|
|
230
|
+
const sharedContract = readContractFromBranch(remote, branch, cwd);
|
|
231
|
+
if (!sharedContract) {
|
|
232
|
+
const msg = `Could not read contract from ${remote}/${branch}. Run: infernoflow team-sync init`;
|
|
233
|
+
if (asJson) { console.log(JSON.stringify({ ok: false, error: msg })); process.exit(1); }
|
|
234
|
+
warn(msg); process.exit(1);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const localContract = fs.existsSync(contractPath)
|
|
238
|
+
? JSON.parse(fs.readFileSync(contractPath, "utf8")) : { capabilities: [] };
|
|
239
|
+
|
|
240
|
+
const baseContract = readLastSyncBase(infernoDir) || { capabilities: [] };
|
|
241
|
+
|
|
242
|
+
const localCaps = parseCaps(JSON.stringify(localContract));
|
|
243
|
+
const sharedCaps = parseCaps(JSON.stringify(sharedContract));
|
|
244
|
+
const baseCaps = parseCaps(JSON.stringify(baseContract));
|
|
245
|
+
|
|
246
|
+
const { conflicts, localOnly, sharedOnly } = detectConflicts(localCaps, sharedCaps, baseCaps);
|
|
247
|
+
|
|
248
|
+
if (conflicts.length > 0 && !force) {
|
|
249
|
+
if (asJson) {
|
|
250
|
+
console.log(JSON.stringify({ ok: false, error: "conflicts_detected", conflicts, hint: "Use --force to overwrite with remote version" }));
|
|
251
|
+
process.exit(1);
|
|
252
|
+
}
|
|
253
|
+
warn(`${conflicts.length} capability conflict${conflicts.length !== 1 ? "s" : ""} detected:\n`);
|
|
254
|
+
for (const c of conflicts) {
|
|
255
|
+
console.log(` ${red("✗")} ${bold(c.id)}`);
|
|
256
|
+
console.log(` local: ${gray(c.local?.title || "(removed)")}`);
|
|
257
|
+
console.log(` shared: ${gray(c.shared?.title || "(removed)")}`);
|
|
258
|
+
}
|
|
259
|
+
console.log();
|
|
260
|
+
warn("Resolve conflicts manually or use --force to take the shared version");
|
|
261
|
+
process.exit(1);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Merge: take shared as base, apply localOnly additions
|
|
265
|
+
const merged = { ...sharedContract };
|
|
266
|
+
const mergedCaps = [...sharedCaps];
|
|
267
|
+
for (const cap of localOnly) mergedCaps.push(cap);
|
|
268
|
+
merged.capabilities = mergedCaps;
|
|
269
|
+
delete merged._teamSync;
|
|
270
|
+
|
|
271
|
+
fs.writeFileSync(contractPath, JSON.stringify(merged, null, 2), "utf8");
|
|
272
|
+
writeLastSyncBase(infernoDir, merged);
|
|
273
|
+
|
|
274
|
+
if (asJson) {
|
|
275
|
+
console.log(JSON.stringify({
|
|
276
|
+
ok: true, action: "pulled", remote, branch,
|
|
277
|
+
conflicts: conflicts.length,
|
|
278
|
+
localOnly: localOnly.length,
|
|
279
|
+
sharedOnly: sharedOnly.length,
|
|
280
|
+
}));
|
|
281
|
+
} else {
|
|
282
|
+
console.log();
|
|
283
|
+
ok("Contract updated from shared branch");
|
|
284
|
+
if (conflicts.length > 0) warn(`${conflicts.length} conflict(s) resolved with --force (shared version wins)`);
|
|
285
|
+
if (localOnly.length > 0) ok(`${localOnly.length} local capability(-ies) preserved`);
|
|
286
|
+
if (sharedOnly.length > 0) ok(`${sharedOnly.length} new capability(-ies) pulled from shared`);
|
|
287
|
+
if (conflicts.length === 0 && localOnly.length === 0 && sharedOnly.length === 0) {
|
|
288
|
+
info("Already in sync — no changes");
|
|
289
|
+
}
|
|
290
|
+
console.log();
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function showStatus(cwd, remote, branch, infernoDir, asJson) {
|
|
295
|
+
const contractPath = path.join(infernoDir, "contract.json");
|
|
296
|
+
|
|
297
|
+
try { run(`git fetch ${remote} ${branch} --quiet`, cwd); } catch {}
|
|
298
|
+
|
|
299
|
+
const sharedContract = readContractFromBranch(remote, branch, cwd);
|
|
300
|
+
if (!sharedContract) {
|
|
301
|
+
const msg = `Shared branch ${remote}/${branch} not found. Run: infernoflow team-sync init`;
|
|
302
|
+
if (asJson) { console.log(JSON.stringify({ ok: false, error: msg })); process.exit(1); }
|
|
303
|
+
warn(msg); process.exit(1);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const localContract = fs.existsSync(contractPath)
|
|
307
|
+
? JSON.parse(fs.readFileSync(contractPath, "utf8")) : { capabilities: [] };
|
|
308
|
+
|
|
309
|
+
const localCaps = parseCaps(JSON.stringify(localContract));
|
|
310
|
+
const sharedCaps = parseCaps(JSON.stringify(sharedContract));
|
|
311
|
+
|
|
312
|
+
const localMap = capsToMap(localCaps);
|
|
313
|
+
const sharedMap = capsToMap(sharedCaps);
|
|
314
|
+
|
|
315
|
+
const onlyLocal = localCaps.filter(c => !sharedMap.has(c.id));
|
|
316
|
+
const onlyShared = sharedCaps.filter(c => !localMap.has(c.id));
|
|
317
|
+
const inSync = onlyLocal.length === 0 && onlyShared.length === 0;
|
|
318
|
+
const pushedBy = sharedContract._teamSync?.pushedBy || "unknown";
|
|
319
|
+
const pushedAt = sharedContract._teamSync?.pushedAt || "unknown";
|
|
320
|
+
|
|
321
|
+
if (asJson) {
|
|
322
|
+
console.log(JSON.stringify({
|
|
323
|
+
ok: true, inSync,
|
|
324
|
+
local: localCaps.length, shared: sharedCaps.length,
|
|
325
|
+
onlyLocal: onlyLocal.map(c => c.id),
|
|
326
|
+
onlyShared: onlyShared.map(c => c.id),
|
|
327
|
+
lastPush: { by: pushedBy, at: pushedAt },
|
|
328
|
+
}));
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
console.log();
|
|
333
|
+
console.log(` Shared branch ${bold(cyan(remote + "/" + branch))}`);
|
|
334
|
+
console.log(` Last push ${bold(pushedBy)} ${gray(pushedAt.slice(0, 19).replace("T", " "))}`);
|
|
335
|
+
console.log();
|
|
336
|
+
|
|
337
|
+
if (inSync) {
|
|
338
|
+
ok("Local and shared contracts are in sync");
|
|
339
|
+
} else {
|
|
340
|
+
if (onlyLocal.length) {
|
|
341
|
+
console.log(` ${yellow("→")} ${bold(onlyLocal.length)} local capability(-ies) not yet pushed:`);
|
|
342
|
+
for (const c of onlyLocal) console.log(` ${yellow("+")} ${c.id} ${gray(c.title)}`);
|
|
343
|
+
}
|
|
344
|
+
if (onlyShared.length) {
|
|
345
|
+
console.log(` ${cyan("←")} ${bold(onlyShared.length)} shared capability(-ies) not yet pulled:`);
|
|
346
|
+
for (const c of onlyShared) console.log(` ${cyan("+")} ${c.id} ${gray(c.title)}`);
|
|
347
|
+
}
|
|
348
|
+
console.log();
|
|
349
|
+
if (onlyLocal.length) info(`Run ${cyan("infernoflow team-sync push")} to share your changes`);
|
|
350
|
+
if (onlyShared.length) info(`Run ${cyan("infernoflow team-sync pull")} to get team changes`);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
console.log();
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// ── main ──────────────────────────────────────────────────────────────────────
|
|
357
|
+
|
|
358
|
+
export async function teamSyncCommand(rawArgs) {
|
|
359
|
+
const args = rawArgs.slice(1);
|
|
360
|
+
const asJson = args.includes("--json");
|
|
361
|
+
const force = args.includes("--force");
|
|
362
|
+
|
|
363
|
+
const branchIdx = args.indexOf("--branch");
|
|
364
|
+
const remoteIdx = args.indexOf("--remote");
|
|
365
|
+
const branch = branchIdx !== -1 ? args[branchIdx + 1] : "inferno-contracts";
|
|
366
|
+
const remote = remoteIdx !== -1 ? args[remoteIdx + 1] : "origin";
|
|
367
|
+
|
|
368
|
+
const sub = args.find(a => !a.startsWith("-")) || "status";
|
|
369
|
+
|
|
370
|
+
const cwd = process.cwd();
|
|
371
|
+
const infernoDir = path.join(cwd, "inferno");
|
|
372
|
+
|
|
373
|
+
if (!asJson) header("infernoflow team-sync");
|
|
374
|
+
|
|
375
|
+
if (!fs.existsSync(infernoDir)) {
|
|
376
|
+
const msg = "inferno/ not found — run: infernoflow init";
|
|
377
|
+
if (asJson) { console.log(JSON.stringify({ ok: false, error: msg })); process.exit(1); }
|
|
378
|
+
warn(msg); process.exit(1);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
switch (sub) {
|
|
382
|
+
case "init": initSharedBranch(cwd, remote, branch, infernoDir, asJson); break;
|
|
383
|
+
case "push": pushToShared(cwd, remote, branch, infernoDir, asJson, force); break;
|
|
384
|
+
case "pull": pullFromShared(cwd, remote, branch, infernoDir, asJson, force); break;
|
|
385
|
+
case "status":
|
|
386
|
+
default: showStatus(cwd, remote, branch, infernoDir, asJson); break;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# infernoflow PR capability analysis
|
|
2
|
+
#
|
|
3
|
+
# Posts a comment on every PR showing:
|
|
4
|
+
# - which capabilities were added, removed, or changed
|
|
5
|
+
# - recommended semver bump (major / minor / patch)
|
|
6
|
+
# - direct link to the drift details
|
|
7
|
+
#
|
|
8
|
+
# Setup:
|
|
9
|
+
# 1. Copy this file to .github/workflows/infernoflow-pr.yml
|
|
10
|
+
# 2. Ensure GITHUB_TOKEN has write access to pull requests
|
|
11
|
+
# (default GitHub Actions token works if repo settings allow it)
|
|
12
|
+
#
|
|
13
|
+
# That's it — infernoflow does the rest automatically.
|
|
14
|
+
|
|
15
|
+
name: infernoflow PR Analysis
|
|
16
|
+
|
|
17
|
+
on:
|
|
18
|
+
pull_request:
|
|
19
|
+
types: [opened, synchronize, reopened]
|
|
20
|
+
|
|
21
|
+
permissions:
|
|
22
|
+
pull-requests: write # required to post comments
|
|
23
|
+
contents: read
|
|
24
|
+
|
|
25
|
+
jobs:
|
|
26
|
+
capability-analysis:
|
|
27
|
+
name: Capability drift check
|
|
28
|
+
runs-on: ubuntu-latest
|
|
29
|
+
|
|
30
|
+
steps:
|
|
31
|
+
- name: Checkout PR branch
|
|
32
|
+
uses: actions/checkout@v4
|
|
33
|
+
with:
|
|
34
|
+
fetch-depth: 0 # full history needed for git diff
|
|
35
|
+
|
|
36
|
+
- name: Setup Node.js
|
|
37
|
+
uses: actions/setup-node@v4
|
|
38
|
+
with:
|
|
39
|
+
node-version: '20'
|
|
40
|
+
|
|
41
|
+
- name: Install infernoflow
|
|
42
|
+
run: npm install -g infernoflow@latest
|
|
43
|
+
|
|
44
|
+
- name: Post capability analysis comment
|
|
45
|
+
env:
|
|
46
|
+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
47
|
+
run: infernoflow pr-comment
|
|
48
|
+
# infernoflow auto-reads GITHUB_TOKEN, GITHUB_REPOSITORY,
|
|
49
|
+
# GITHUB_EVENT_PATH, and GITHUB_BASE_REF from the environment.
|
|
50
|
+
# No extra config needed.
|