infernoflow 0.13.0 → 0.16.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 +34 -0
- package/dist/lib/commands/changelog.mjs +255 -6
- package/dist/lib/commands/cloud.mjs +521 -0
- package/dist/lib/commands/dashboard.mjs +399 -0
- package/dist/lib/commands/onboard.mjs +296 -0
- package/dist/lib/commands/prComment.mjs +361 -0
- package/dist/lib/commands/teamSync.mjs +388 -0
- package/dist/templates/ci/github-pr-comment.yml +50 -0
- package/package.json +1 -1
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* infernoflow onboard
|
|
3
|
+
*
|
|
4
|
+
* Interactive step-by-step onboarding wizard for new developers.
|
|
5
|
+
* Walks through: what infernoflow is, detecting the stack, running init,
|
|
6
|
+
* showing the first contract, explaining each file, and a test suggest.
|
|
7
|
+
*
|
|
8
|
+
* Designed for teams adding a new member — run once, understand everything.
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* infernoflow onboard
|
|
12
|
+
* infernoflow onboard --yes # non-interactive (auto-accept all steps)
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import * as fs from "node:fs";
|
|
16
|
+
import * as path from "node:path";
|
|
17
|
+
import * as readline from "node:readline";
|
|
18
|
+
import { execSync } from "node:child_process";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
20
|
+
import { header, ok, warn, info, done, bold, cyan, gray, green, yellow, red } from "../ui/output.mjs";
|
|
21
|
+
|
|
22
|
+
// ── readline helpers ──────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
function createRl() {
|
|
25
|
+
return readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function ask(rl, question) {
|
|
29
|
+
return new Promise(resolve => rl.question(question, resolve));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function confirm(rl, question, defaultYes = true) {
|
|
33
|
+
const hint = defaultYes ? "[Y/n]" : "[y/N]";
|
|
34
|
+
const answer = await ask(rl, ` ${question} ${gray(hint)} `);
|
|
35
|
+
if (!answer.trim()) return defaultYes;
|
|
36
|
+
return answer.trim().toLowerCase().startsWith("y");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ── step renderer ─────────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
function step(n, total, title) {
|
|
42
|
+
console.log();
|
|
43
|
+
console.log(` ${bold(cyan(`Step ${n}/${total}`))} ${bold(title)}`);
|
|
44
|
+
console.log(` ${gray("─".repeat(50))}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function explain(lines) {
|
|
48
|
+
for (const line of lines) {
|
|
49
|
+
console.log(` ${gray(line)}`);
|
|
50
|
+
}
|
|
51
|
+
console.log();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ── project detection ─────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
function detectStack(cwd) {
|
|
57
|
+
const has = (f) => fs.existsSync(path.join(cwd, f));
|
|
58
|
+
const pkg = has("package.json") ? JSON.parse(fs.readFileSync(path.join(cwd, "package.json"), "utf8")) : {};
|
|
59
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
60
|
+
|
|
61
|
+
const framework = deps.react ? "React"
|
|
62
|
+
: deps.next ? "Next.js"
|
|
63
|
+
: deps.vue ? "Vue"
|
|
64
|
+
: deps.angular ? "Angular"
|
|
65
|
+
: deps.express ? "Express"
|
|
66
|
+
: deps.fastify ? "Fastify"
|
|
67
|
+
: has("requirements.txt") ? "Python"
|
|
68
|
+
: has("go.mod") ? "Go"
|
|
69
|
+
: has("Cargo.toml") ? "Rust"
|
|
70
|
+
: "unknown";
|
|
71
|
+
|
|
72
|
+
const language = has("tsconfig.json") ? "TypeScript"
|
|
73
|
+
: has("package.json") ? "JavaScript"
|
|
74
|
+
: has("requirements.txt") ? "Python"
|
|
75
|
+
: has("go.mod") ? "Go"
|
|
76
|
+
: "unknown";
|
|
77
|
+
|
|
78
|
+
return { framework, language, name: pkg.name || path.basename(cwd) };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function capture(cmd, cwd) {
|
|
82
|
+
try {
|
|
83
|
+
return execSync(cmd, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
84
|
+
} catch { return null; }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── main wizard ───────────────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
export async function onboardCommand(rawArgs) {
|
|
90
|
+
const args = rawArgs.slice(1);
|
|
91
|
+
const autoYes = args.includes("--yes") || args.includes("-y");
|
|
92
|
+
const cwd = process.cwd();
|
|
93
|
+
const infernoDir = path.join(cwd, "inferno");
|
|
94
|
+
const TOTAL = 7;
|
|
95
|
+
|
|
96
|
+
const rl = autoYes ? null : createRl();
|
|
97
|
+
|
|
98
|
+
const ask_confirm = autoYes
|
|
99
|
+
? async () => true
|
|
100
|
+
: (q, def) => confirm(rl, q, def);
|
|
101
|
+
|
|
102
|
+
console.clear();
|
|
103
|
+
console.log();
|
|
104
|
+
console.log(` ${bold("🔥 Welcome to infernoflow")}`);
|
|
105
|
+
console.log(` ${gray("This wizard walks you through everything in about 5 minutes.")}`);
|
|
106
|
+
console.log(` ${gray("You'll understand what infernoflow does and how it fits your workflow.")}`);
|
|
107
|
+
console.log();
|
|
108
|
+
|
|
109
|
+
if (!autoYes) {
|
|
110
|
+
const ready = await ask_confirm("Ready to start?", true);
|
|
111
|
+
if (!ready) { console.log("\n See you next time!\n"); rl.close(); return; }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ── Step 1: What is infernoflow? ───────────────────────────────────────────
|
|
115
|
+
step(1, TOTAL, "What is infernoflow?");
|
|
116
|
+
explain([
|
|
117
|
+
"infernoflow keeps a living record of your project's capabilities",
|
|
118
|
+
"— the features your code actually provides — and makes sure that",
|
|
119
|
+
"record never drifts out of sync as you build.",
|
|
120
|
+
"",
|
|
121
|
+
"It works invisibly in the background:",
|
|
122
|
+
" • Claude auto-tracks capability changes as you code",
|
|
123
|
+
" • Git hooks update the changelog on every commit",
|
|
124
|
+
" • PRs get automatic capability drift analysis",
|
|
125
|
+
" • Version bumps are recommended automatically (major/minor/patch)",
|
|
126
|
+
"",
|
|
127
|
+
"You write code. infernoflow handles the bookkeeping.",
|
|
128
|
+
]);
|
|
129
|
+
|
|
130
|
+
await ask_confirm("Got it — continue?", true);
|
|
131
|
+
|
|
132
|
+
// ── Step 2: Detect your project ────────────────────────────────────────────
|
|
133
|
+
step(2, TOTAL, "Detecting your project");
|
|
134
|
+
const stack = detectStack(cwd);
|
|
135
|
+
const isGitRepo = !!capture("git rev-parse --git-dir", cwd);
|
|
136
|
+
const alreadySetup = fs.existsSync(infernoDir);
|
|
137
|
+
|
|
138
|
+
console.log(` Project: ${bold(stack.name)}`);
|
|
139
|
+
console.log(` Framework: ${bold(stack.framework)}`);
|
|
140
|
+
console.log(` Language: ${bold(stack.language)}`);
|
|
141
|
+
console.log(` Git repo: ${isGitRepo ? green("yes") : red("no — git init first")}`);
|
|
142
|
+
console.log(` infernoflow: ${alreadySetup ? green("already set up") : yellow("not set up yet")}`);
|
|
143
|
+
console.log();
|
|
144
|
+
|
|
145
|
+
if (!isGitRepo) {
|
|
146
|
+
warn("This directory is not a git repository.");
|
|
147
|
+
warn("Run: git init && git add . && git commit -m 'init'");
|
|
148
|
+
warn("Then run: infernoflow onboard");
|
|
149
|
+
if (rl) rl.close();
|
|
150
|
+
process.exit(1);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
await ask_confirm("Continue?", true);
|
|
154
|
+
|
|
155
|
+
// ── Step 3: Run infernoflow setup ──────────────────────────────────────────
|
|
156
|
+
step(3, TOTAL, "Setting up infernoflow");
|
|
157
|
+
if (alreadySetup) {
|
|
158
|
+
ok("infernoflow is already set up in this project");
|
|
159
|
+
explain(["The inferno/ folder exists — skipping init."]);
|
|
160
|
+
} else {
|
|
161
|
+
explain([
|
|
162
|
+
"infernoflow setup will:",
|
|
163
|
+
" 1. Scan your codebase and infer capabilities automatically",
|
|
164
|
+
" 2. Create the inferno/ folder with contract.json",
|
|
165
|
+
" 3. Install the MCP server (for Claude Code integration)",
|
|
166
|
+
" 4. Write CLAUDE.md (makes Claude auto-track capabilities)",
|
|
167
|
+
" 5. Install git hooks (auto changelog + drift check)",
|
|
168
|
+
]);
|
|
169
|
+
|
|
170
|
+
const go = await ask_confirm("Run infernoflow setup now?", true);
|
|
171
|
+
if (go) {
|
|
172
|
+
console.log();
|
|
173
|
+
try {
|
|
174
|
+
execSync("npx infernoflow setup --yes", {
|
|
175
|
+
cwd,
|
|
176
|
+
stdio: "inherit",
|
|
177
|
+
timeout: 120_000,
|
|
178
|
+
});
|
|
179
|
+
} catch (err) {
|
|
180
|
+
warn("Setup encountered an issue — you can re-run it manually:");
|
|
181
|
+
warn(" infernoflow setup");
|
|
182
|
+
}
|
|
183
|
+
} else {
|
|
184
|
+
info("Skipped — run: infernoflow setup");
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
await ask_confirm("Continue?", true);
|
|
189
|
+
|
|
190
|
+
// ── Step 4: Your capability contract ──────────────────────────────────────
|
|
191
|
+
step(4, TOTAL, "Your capability contract");
|
|
192
|
+
explain([
|
|
193
|
+
"The inferno/contract.json is the heart of infernoflow.",
|
|
194
|
+
"It lists every capability your project has — the things users can DO.",
|
|
195
|
+
"",
|
|
196
|
+
"Think of it as a living API contract, but for features, not endpoints.",
|
|
197
|
+
]);
|
|
198
|
+
|
|
199
|
+
const contractPath = path.join(infernoDir, "contract.json");
|
|
200
|
+
if (fs.existsSync(contractPath)) {
|
|
201
|
+
try {
|
|
202
|
+
const contract = JSON.parse(fs.readFileSync(contractPath, "utf8"));
|
|
203
|
+
const caps = contract.capabilities || [];
|
|
204
|
+
console.log(` ${bold(String(caps.length))} capabilities tracked:\n`);
|
|
205
|
+
for (const c of caps.slice(0, 8)) {
|
|
206
|
+
const cap = typeof c === "string" ? { id: c, title: c } : c;
|
|
207
|
+
console.log(` ${green("✔")} ${bold(cap.id)} ${gray(cap.title || "")}`);
|
|
208
|
+
}
|
|
209
|
+
if (caps.length > 8) console.log(` ${gray(`… and ${caps.length - 8} more`)}`);
|
|
210
|
+
console.log();
|
|
211
|
+
} catch {
|
|
212
|
+
warn("Could not read contract.json");
|
|
213
|
+
}
|
|
214
|
+
} else {
|
|
215
|
+
warn("No contract.json yet — run: infernoflow setup");
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
await ask_confirm("Continue?", true);
|
|
219
|
+
|
|
220
|
+
// ── Step 5: Your daily workflow ────────────────────────────────────────────
|
|
221
|
+
step(5, TOTAL, "Your daily workflow");
|
|
222
|
+
explain([
|
|
223
|
+
"Here's exactly how infernoflow fits into your git workflow:",
|
|
224
|
+
"",
|
|
225
|
+
" 1. git checkout -b feature/my-feature",
|
|
226
|
+
" (branch from main as usual)",
|
|
227
|
+
"",
|
|
228
|
+
" 2. Write code in Claude / Cursor / VS Code",
|
|
229
|
+
" infernoflow tracks capability changes automatically via CLAUDE.md",
|
|
230
|
+
"",
|
|
231
|
+
" 3. git commit -m 'add my feature'",
|
|
232
|
+
" post-commit hook silently updates the changelog",
|
|
233
|
+
"",
|
|
234
|
+
" 4. git push && open PR",
|
|
235
|
+
" GitHub Actions posts a capability drift analysis comment on the PR",
|
|
236
|
+
" pre-push hook warns if drift is HIGH",
|
|
237
|
+
"",
|
|
238
|
+
" 5. infernoflow version",
|
|
239
|
+
" see what semver bump is recommended (major/minor/patch)",
|
|
240
|
+
"",
|
|
241
|
+
"You never run infernoflow manually in day-to-day work.",
|
|
242
|
+
"It runs itself.",
|
|
243
|
+
]);
|
|
244
|
+
|
|
245
|
+
await ask_confirm("Got it — continue?", true);
|
|
246
|
+
|
|
247
|
+
// ── Step 6: Key commands to know ──────────────────────────────────────────
|
|
248
|
+
step(6, TOTAL, "Key commands");
|
|
249
|
+
const commands = [
|
|
250
|
+
["infernoflow status", "Quick health check of the contract"],
|
|
251
|
+
["infernoflow diff", "What changed since the last release"],
|
|
252
|
+
["infernoflow version", "What semver bump to use"],
|
|
253
|
+
["infernoflow version --apply", "Write the bump to package.json"],
|
|
254
|
+
["infernoflow changelog ai", "Generate a human-readable changelog"],
|
|
255
|
+
["infernoflow dashboard", "Open the live web dashboard"],
|
|
256
|
+
["infernoflow team-sync status","See if your team is in sync"],
|
|
257
|
+
["infernoflow suggest 'what I built'", "Manually update the contract"],
|
|
258
|
+
];
|
|
259
|
+
|
|
260
|
+
const maxLen = Math.max(...commands.map(([c]) => c.length));
|
|
261
|
+
for (const [cmd, desc] of commands) {
|
|
262
|
+
console.log(` ${cyan(cmd.padEnd(maxLen + 2))}${gray(desc)}`);
|
|
263
|
+
}
|
|
264
|
+
console.log();
|
|
265
|
+
|
|
266
|
+
await ask_confirm("Continue?", true);
|
|
267
|
+
|
|
268
|
+
// ── Step 7: Live test ─────────────────────────────────────────────────────
|
|
269
|
+
step(7, TOTAL, "Quick live test");
|
|
270
|
+
explain(["Let's run infernoflow status to confirm everything is working."]);
|
|
271
|
+
|
|
272
|
+
const runTest = await ask_confirm("Run infernoflow status now?", true);
|
|
273
|
+
if (runTest) {
|
|
274
|
+
console.log();
|
|
275
|
+
try {
|
|
276
|
+
execSync("npx infernoflow status", { cwd, stdio: "inherit", timeout: 30_000 });
|
|
277
|
+
} catch {
|
|
278
|
+
warn("Status check failed — try: infernoflow setup");
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ── Done ──────────────────────────────────────────────────────────────────
|
|
283
|
+
console.log();
|
|
284
|
+
console.log(` ${bold("🎉 You're all set!")}`);
|
|
285
|
+
console.log();
|
|
286
|
+
console.log(` ${green("✔")} infernoflow is installed and running`);
|
|
287
|
+
console.log(` ${green("✔")} Claude will auto-track capabilities as you code`);
|
|
288
|
+
console.log(` ${green("✔")} Git hooks handle changelog and drift automatically`);
|
|
289
|
+
console.log(` ${green("✔")} PRs will get automatic capability analysis`);
|
|
290
|
+
console.log();
|
|
291
|
+
console.log(` ${gray("Share this with teammates:")} ${cyan("infernoflow onboard")}`);
|
|
292
|
+
console.log(` ${gray("Questions?")} ${cyan("infernoflow --help")}`);
|
|
293
|
+
console.log();
|
|
294
|
+
|
|
295
|
+
if (rl) rl.close();
|
|
296
|
+
}
|
|
@@ -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
|
+
}
|