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.
@@ -0,0 +1,427 @@
1
+ /**
2
+ * infernoflow monorepo
3
+ *
4
+ * Monorepo-aware capability tracking. Detects packages in a monorepo
5
+ * (nx, turborepo, pnpm workspaces, yarn workspaces, Lerna) and manages
6
+ * per-package contracts.
7
+ *
8
+ * Sub-commands:
9
+ * monorepo init Detect packages, scaffold inferno/ per package
10
+ * monorepo list List all detected packages + contract status
11
+ * monorepo status Show health across all packages at once
12
+ * monorepo diff [--package] Diff capabilities for one or all packages
13
+ * monorepo sync Sync all package contracts to root summary
14
+ *
15
+ * Usage:
16
+ * infernoflow monorepo init
17
+ * infernoflow monorepo list
18
+ * infernoflow monorepo status
19
+ * infernoflow monorepo diff --package auth
20
+ * infernoflow monorepo sync
21
+ * infernoflow monorepo status --json
22
+ */
23
+
24
+ import * as fs from "node:fs";
25
+ import * as path from "node:path";
26
+ import { spawnSync } from "node:child_process";
27
+ import { header, ok, warn, info, done, bold, cyan, gray, green, yellow, red } from "../ui/output.mjs";
28
+
29
+ // ── Package detection ─────────────────────────────────────────────────────────
30
+
31
+ function detectWorkspaceType(cwd) {
32
+ const has = (f) => fs.existsSync(path.join(cwd, f));
33
+
34
+ if (has("nx.json")) return "nx";
35
+ if (has("turbo.json")) return "turborepo";
36
+ if (has("lerna.json")) return "lerna";
37
+ if (has("pnpm-workspace.yaml")) return "pnpm";
38
+
39
+ const pkg = (() => {
40
+ try { return JSON.parse(fs.readFileSync(path.join(cwd, "package.json"), "utf8")); }
41
+ catch { return {}; }
42
+ })();
43
+
44
+ if (pkg.workspaces) return Array.isArray(pkg.workspaces) ? "yarn" : "npm-workspaces";
45
+ return null;
46
+ }
47
+
48
+ function readWorkspaceGlobs(cwd, wsType) {
49
+ try {
50
+ if (wsType === "pnpm") {
51
+ const raw = fs.readFileSync(path.join(cwd, "pnpm-workspace.yaml"), "utf8");
52
+ const matches = [...raw.matchAll(/^\s*-\s*['"]?([^'"]+)['"]?/gm)];
53
+ return matches.map(m => m[1].trim());
54
+ }
55
+ if (wsType === "lerna") {
56
+ const cfg = JSON.parse(fs.readFileSync(path.join(cwd, "lerna.json"), "utf8"));
57
+ return cfg.packages || ["packages/*"];
58
+ }
59
+ const pkg = JSON.parse(fs.readFileSync(path.join(cwd, "package.json"), "utf8"));
60
+ const ws = pkg.workspaces;
61
+ if (Array.isArray(ws)) return ws;
62
+ if (ws?.packages) return ws.packages;
63
+ } catch {}
64
+ return ["packages/*", "apps/*", "libs/*"];
65
+ }
66
+
67
+ function globToPackages(cwd, globs) {
68
+ const packages = [];
69
+ for (const pattern of globs) {
70
+ // Simple glob: handle "packages/*" and "apps/*" patterns
71
+ const parts = pattern.split("/");
72
+ const parent = parts.slice(0, -1).join("/");
73
+ const leaf = parts[parts.length - 1];
74
+ const dir = path.join(cwd, parent);
75
+
76
+ if (!fs.existsSync(dir)) continue;
77
+ if (leaf === "*") {
78
+ try {
79
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
80
+ if (entry.isDirectory()) {
81
+ const pkgPath = path.join(dir, entry.name, "package.json");
82
+ if (fs.existsSync(pkgPath)) {
83
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
84
+ packages.push({
85
+ name: pkg.name || entry.name,
86
+ dir: path.join(dir, entry.name),
87
+ version: pkg.version || "0.0.0",
88
+ });
89
+ }
90
+ }
91
+ }
92
+ } catch {}
93
+ } else {
94
+ const fullDir = path.join(cwd, pattern);
95
+ const pkgPath = path.join(fullDir, "package.json");
96
+ if (fs.existsSync(pkgPath)) {
97
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
98
+ packages.push({ name: pkg.name || leaf, dir: fullDir, version: pkg.version || "0.0.0" });
99
+ }
100
+ }
101
+ }
102
+ return packages;
103
+ }
104
+
105
+ function detectPackages(cwd) {
106
+ const wsType = detectWorkspaceType(cwd);
107
+ if (!wsType) return { type: null, packages: [] };
108
+ const globs = readWorkspaceGlobs(cwd, wsType);
109
+ const packages = globToPackages(cwd, globs);
110
+ return { type: wsType, packages };
111
+ }
112
+
113
+ // ── Contract helpers ──────────────────────────────────────────────────────────
114
+
115
+ function readPackageContract(pkgDir) {
116
+ for (const f of ["contract.json", "capabilities.json"]) {
117
+ const p = path.join(pkgDir, "inferno", f);
118
+ if (fs.existsSync(p)) { try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch {} }
119
+ }
120
+ return null;
121
+ }
122
+
123
+ function hasInferno(pkgDir) {
124
+ return fs.existsSync(path.join(pkgDir, "inferno"));
125
+ }
126
+
127
+ function contractStatus(pkgDir) {
128
+ if (!hasInferno(pkgDir)) return "not-init";
129
+ const contract = readPackageContract(pkgDir);
130
+ if (!contract) return "no-contract";
131
+ return "ok";
132
+ }
133
+
134
+ // ── CLI runner ────────────────────────────────────────────────────────────────
135
+
136
+ function runInferno(args, cwd) {
137
+ const binPath = path.join(path.dirname(path.dirname(new URL(import.meta.url).pathname)), "..", "bin", "infernoflow.mjs");
138
+ const result = spawnSync(process.execPath, [binPath, ...args], {
139
+ cwd,
140
+ encoding: "utf8",
141
+ timeout: 60_000,
142
+ env: { ...process.env, NO_COLOR: "1" },
143
+ });
144
+ return { stdout: result.stdout || "", stderr: result.stderr || "", status: result.status };
145
+ }
146
+
147
+ // ── Sub-commands ──────────────────────────────────────────────────────────────
148
+
149
+ async function subcmdInit(args, cwd) {
150
+ const jsonMode = args.includes("--json");
151
+ const force = args.includes("--force") || args.includes("-f");
152
+ const autoYes = args.includes("--yes") || args.includes("-y");
153
+
154
+ const { type, packages } = detectPackages(cwd);
155
+
156
+ if (!type) {
157
+ const msg = "No monorepo configuration detected. Supported: nx, turborepo, pnpm workspaces, yarn workspaces, lerna.";
158
+ if (jsonMode) { console.log(JSON.stringify({ ok: false, error: msg })); } else { warn(msg); }
159
+ process.exit(1);
160
+ }
161
+
162
+ if (!jsonMode) {
163
+ header(`Monorepo init (${type})`);
164
+ console.log(` Detected ${bold(String(packages.length))} packages:\n`);
165
+ packages.forEach(p => {
166
+ const status = contractStatus(p.dir);
167
+ const icon = status === "ok" ? green("✔") : yellow("·");
168
+ console.log(` ${icon} ${bold(p.name)} ${gray(path.relative(cwd, p.dir))}`);
169
+ });
170
+ console.log();
171
+ }
172
+
173
+ if (packages.length === 0) {
174
+ if (jsonMode) { console.log(JSON.stringify({ ok: false, error: "No packages found" })); }
175
+ else { warn("No packages found matching workspace globs."); }
176
+ process.exit(1);
177
+ }
178
+
179
+ const results = [];
180
+ for (const pkg of packages) {
181
+ const status = contractStatus(pkg.dir);
182
+ if (status === "ok" && !force) {
183
+ if (!jsonMode) info(`${pkg.name}: already initialised (use --force to reinit)`);
184
+ results.push({ name: pkg.name, status: "skipped" });
185
+ continue;
186
+ }
187
+
188
+ if (!jsonMode) process.stdout.write(` Initialising ${cyan(pkg.name)}… `);
189
+
190
+ const initArgs = ["init", "--adopt", "--yes"];
191
+ if (force) initArgs.push("--force");
192
+ const r = runInferno(initArgs, pkg.dir);
193
+
194
+ if (r.status === 0) {
195
+ if (!jsonMode) console.log(green("done"));
196
+ results.push({ name: pkg.name, status: "ok" });
197
+ } else {
198
+ if (!jsonMode) console.log(red("failed"));
199
+ results.push({ name: pkg.name, status: "error", error: r.stderr.trim().slice(0, 120) });
200
+ }
201
+ }
202
+
203
+ // Write root summary
204
+ const summary = {
205
+ monorepoType: type,
206
+ packages: results,
207
+ updatedAt: new Date().toISOString(),
208
+ };
209
+ fs.writeFileSync(path.join(cwd, "inferno-monorepo.json"), JSON.stringify(summary, null, 2) + "\n");
210
+
211
+ if (jsonMode) {
212
+ console.log(JSON.stringify({ ok: true, type, packages: results }));
213
+ } else {
214
+ console.log();
215
+ const succeeded = results.filter(r => r.status === "ok").length;
216
+ done(`Initialised ${bold(String(succeeded))} of ${results.length} packages`);
217
+ console.log(` ${gray("Root summary:")} ${cyan("inferno-monorepo.json")}`);
218
+ console.log();
219
+ }
220
+ }
221
+
222
+ async function subcmdList(args, cwd) {
223
+ const jsonMode = args.includes("--json");
224
+ const { type, packages } = detectPackages(cwd);
225
+
226
+ if (!type && packages.length === 0) {
227
+ if (jsonMode) { console.log(JSON.stringify({ ok: false, error: "No monorepo detected" })); }
228
+ else { warn("No monorepo detected. Run: infernoflow monorepo init"); }
229
+ return;
230
+ }
231
+
232
+ const rows = packages.map(p => ({
233
+ name: p.name,
234
+ dir: path.relative(cwd, p.dir),
235
+ version: p.version,
236
+ status: contractStatus(p.dir),
237
+ caps: (() => {
238
+ const c = readPackageContract(p.dir);
239
+ return c ? (c.capabilities || []).length : 0;
240
+ })(),
241
+ }));
242
+
243
+ if (jsonMode) {
244
+ console.log(JSON.stringify({ ok: true, type, packages: rows }));
245
+ return;
246
+ }
247
+
248
+ console.log();
249
+ console.log(` ${bold("Monorepo packages")} ${gray("(" + type + ")")}`);
250
+ console.log();
251
+ const w = Math.max(...rows.map(r => r.name.length), 8) + 2;
252
+ rows.forEach(r => {
253
+ const icon = r.status === "ok" ? green("✔") : r.status === "not-init" ? yellow("○") : red("✗");
254
+ const caps = r.status === "ok" ? gray(`${r.caps} caps`) : gray(r.status);
255
+ console.log(` ${icon} ${r.name.padEnd(w)}${r.version.padEnd(12)}${caps}`);
256
+ });
257
+ console.log();
258
+ }
259
+
260
+ async function subcmdStatus(args, cwd) {
261
+ const jsonMode = args.includes("--json");
262
+ const { type, packages } = detectPackages(cwd);
263
+
264
+ if (!packages.length) {
265
+ if (jsonMode) { console.log(JSON.stringify({ ok: false, error: "No packages found" })); }
266
+ else { warn("No packages found."); }
267
+ return;
268
+ }
269
+
270
+ if (!jsonMode) header(`Monorepo status (${packages.length} packages)`);
271
+
272
+ const results = [];
273
+ for (const pkg of packages) {
274
+ if (!hasInferno(pkg.dir)) {
275
+ results.push({ name: pkg.name, status: "not-init", caps: 0 });
276
+ if (!jsonMode) console.log(` ${yellow("○")} ${bold(pkg.name)} ${gray("not initialised")}`);
277
+ continue;
278
+ }
279
+ const r = runInferno(["status", "--json"], pkg.dir);
280
+ try {
281
+ const data = JSON.parse(r.stdout.trim());
282
+ const caps = (data.capabilityDetails || []).length;
283
+ const ok_ = data.ok !== false;
284
+ results.push({ name: pkg.name, status: ok_ ? "ok" : "error", caps, version: data.policyVersion });
285
+ if (!jsonMode) {
286
+ const icon = ok_ ? green("✔") : red("✗");
287
+ console.log(` ${icon} ${bold(pkg.name.padEnd(28))}${gray("v" + (data.policyVersion || "?"))} ${caps} caps`);
288
+ }
289
+ } catch {
290
+ results.push({ name: pkg.name, status: "error", caps: 0, error: "status failed" });
291
+ if (!jsonMode) console.log(` ${red("✗")} ${bold(pkg.name)} ${gray("status check failed")}`);
292
+ }
293
+ }
294
+
295
+ if (jsonMode) {
296
+ const allOk = results.every(r => r.status === "ok");
297
+ console.log(JSON.stringify({ ok: allOk, type, packages: results }));
298
+ } else {
299
+ console.log();
300
+ const ok_ = results.filter(r => r.status === "ok").length;
301
+ console.log(` ${ok_ === results.length ? green("✔") : yellow("⚠")} ${ok_}/${results.length} packages healthy`);
302
+ console.log();
303
+ }
304
+ }
305
+
306
+ async function subcmdDiff(args, cwd) {
307
+ const jsonMode = args.includes("--json");
308
+ const pkgFilter = args.includes("--package") ? args[args.indexOf("--package") + 1] : null;
309
+ const { packages } = detectPackages(cwd);
310
+
311
+ const targets = pkgFilter
312
+ ? packages.filter(p => p.name === pkgFilter || p.name.endsWith("/" + pkgFilter))
313
+ : packages.filter(p => hasInferno(p.dir));
314
+
315
+ if (!targets.length) {
316
+ if (jsonMode) { console.log(JSON.stringify({ ok: false, error: pkgFilter ? `Package not found: ${pkgFilter}` : "No initialised packages found" })); }
317
+ else { warn(pkgFilter ? `Package not found: ${pkgFilter}` : "No initialised packages found."); }
318
+ return;
319
+ }
320
+
321
+ if (!jsonMode && !pkgFilter) header(`Monorepo diff (${targets.length} packages)`);
322
+
323
+ const allResults = [];
324
+ for (const pkg of targets) {
325
+ const r = runInferno(["diff", "--json"], pkg.dir);
326
+ try {
327
+ const data = JSON.parse(r.stdout.trim());
328
+ const added = (data.added || []).length;
329
+ const removed = (data.removed || []).length;
330
+ const changed = (data.changed || []).length;
331
+ allResults.push({ name: pkg.name, added, removed, changed, data });
332
+ if (!jsonMode) {
333
+ if (added || removed || changed) {
334
+ console.log(` ${bold(pkg.name)}`);
335
+ if (added) console.log(` ${green("+")} ${added} added`);
336
+ if (removed) console.log(` ${red("-")} ${removed} removed`);
337
+ if (changed) console.log(` ${yellow("~")} ${changed} changed`);
338
+ } else {
339
+ console.log(` ${green("✔")} ${bold(pkg.name)} ${gray("no changes")}`);
340
+ }
341
+ }
342
+ } catch {
343
+ allResults.push({ name: pkg.name, error: "diff failed" });
344
+ if (!jsonMode) console.log(` ${red("✗")} ${bold(pkg.name)} ${gray("diff failed")}`);
345
+ }
346
+ }
347
+
348
+ if (jsonMode) {
349
+ console.log(JSON.stringify({ ok: true, packages: allResults }));
350
+ } else {
351
+ console.log();
352
+ }
353
+ }
354
+
355
+ async function subcmdSync(args, cwd) {
356
+ const jsonMode = args.includes("--json");
357
+ const { type, packages } = detectPackages(cwd);
358
+
359
+ if (!jsonMode) header("Syncing monorepo contracts");
360
+
361
+ // Build a root aggregate contract
362
+ const aggregate = {
363
+ monorepoType: type,
364
+ updatedAt: new Date().toISOString(),
365
+ packages: [],
366
+ };
367
+
368
+ for (const pkg of packages) {
369
+ const contract = readPackageContract(pkg.dir);
370
+ if (!contract) continue;
371
+ aggregate.packages.push({
372
+ name: pkg.name,
373
+ version: contract.policyVersion || pkg.version,
374
+ capabilities: (contract.capabilities || []).map(c => typeof c === "string" ? c : c.id),
375
+ capsCount: (contract.capabilities || []).length,
376
+ });
377
+ if (!jsonMode) console.log(` ${green("✔")} ${bold(pkg.name)} ${gray((contract.capabilities || []).length + " caps")}`);
378
+ }
379
+
380
+ const outPath = path.join(cwd, "inferno-monorepo.json");
381
+ fs.writeFileSync(outPath, JSON.stringify(aggregate, null, 2) + "\n");
382
+
383
+ const totalCaps = aggregate.packages.reduce((sum, p) => sum + p.capsCount, 0);
384
+
385
+ if (jsonMode) {
386
+ console.log(JSON.stringify({ ok: true, packages: aggregate.packages.length, totalCaps }));
387
+ } else {
388
+ console.log();
389
+ done(`Synced ${bold(String(aggregate.packages.length))} packages (${totalCaps} total capabilities)`);
390
+ console.log(` ${cyan(outPath)}`);
391
+ console.log();
392
+ }
393
+ }
394
+
395
+ // ── Entry ─────────────────────────────────────────────────────────────────────
396
+
397
+ export async function monorepoCommand(rawArgs) {
398
+ const args = rawArgs.slice(1);
399
+ const subcmd = args[0];
400
+ const cwd = process.cwd();
401
+ const rest = args.slice(1);
402
+
403
+ switch (subcmd) {
404
+ case "init": return subcmdInit(rest, cwd);
405
+ case "list": return subcmdList(rest, cwd);
406
+ case "status": return subcmdStatus(rest, cwd);
407
+ case "diff": return subcmdDiff(rest, cwd);
408
+ case "sync": return subcmdSync(rest, cwd);
409
+ default: {
410
+ const jsonMode = args.includes("--json");
411
+ const msg = `Unknown monorepo sub-command: ${subcmd || "(none)"}`;
412
+ if (jsonMode) { console.log(JSON.stringify({ ok: false, error: msg })); return; }
413
+ console.log();
414
+ console.log(` ${bold("infernoflow monorepo")} — per-package capability tracking`);
415
+ console.log();
416
+ console.log(` ${cyan("infernoflow monorepo init")} Scaffold inferno/ in each package`);
417
+ console.log(` ${cyan("infernoflow monorepo list")} List packages and contract status`);
418
+ console.log(` ${cyan("infernoflow monorepo status")} Health check across all packages`);
419
+ console.log(` ${cyan("infernoflow monorepo diff")} Capability diff for all packages`);
420
+ console.log(` ${cyan("infernoflow monorepo diff --package auth")} Diff a specific package`);
421
+ console.log(` ${cyan("infernoflow monorepo sync")} Aggregate all contracts to inferno-monorepo.json`);
422
+ console.log();
423
+ console.log(` ${gray("Supported: nx, turborepo, pnpm workspaces, yarn workspaces, lerna")}`);
424
+ console.log();
425
+ }
426
+ }
427
+ }
@@ -0,0 +1,258 @@
1
+ /**
2
+ * infernoflow notify
3
+ *
4
+ * Post capability drift summaries to Slack or Discord.
5
+ * Runs automatically after significant capability changes (via git hook or CI).
6
+ * Can also be triggered manually.
7
+ *
8
+ * Usage:
9
+ * infernoflow notify Auto-detect channel from config
10
+ * infernoflow notify --slack <url> Post to Slack webhook URL
11
+ * infernoflow notify --discord <url> Post to Discord webhook URL
12
+ * infernoflow notify --dry-run Print message without sending
13
+ * infernoflow notify --json Machine-readable: { ok, platform, message }
14
+ * infernoflow notify --on-change Only notify if capabilities actually changed
15
+ *
16
+ * Config (inferno/notify.json):
17
+ * { "slack": "https://hooks.slack.com/...", "discord": "https://discord.com/api/webhooks/..." }
18
+ *
19
+ * Or set env vars:
20
+ * INFERNOFLOW_SLACK_WEBHOOK
21
+ * INFERNOFLOW_DISCORD_WEBHOOK
22
+ */
23
+
24
+ import * as fs from "node:fs";
25
+ import * as path from "node:path";
26
+ import * as https from "node:https";
27
+ import * as http from "node:http";
28
+ import { spawnSync } from "node:child_process";
29
+ import { done, warn, info, bold, cyan, gray, green, red, yellow } from "../ui/output.mjs";
30
+
31
+ // ── Config ────────────────────────────────────────────────────────────────────
32
+
33
+ function loadNotifyConfig(infernoDir, args) {
34
+ const configPath = path.join(infernoDir, "notify.json");
35
+ const fileConfig = fs.existsSync(configPath)
36
+ ? (() => { try { return JSON.parse(fs.readFileSync(configPath, "utf8")); } catch { return {}; } })()
37
+ : {};
38
+
39
+ const slackIdx = args.indexOf("--slack");
40
+ const discordIdx = args.indexOf("--discord");
41
+
42
+ return {
43
+ slack: slackIdx !== -1 ? args[slackIdx + 1] : process.env.INFERNOFLOW_SLACK_WEBHOOK || fileConfig.slack,
44
+ discord: discordIdx !== -1 ? args[discordIdx + 1] : process.env.INFERNOFLOW_DISCORD_WEBHOOK || fileConfig.discord,
45
+ };
46
+ }
47
+
48
+ // ── Data loading ──────────────────────────────────────────────────────────────
49
+
50
+ function runJson(cmd, cwd) {
51
+ try {
52
+ const result = spawnSync(process.execPath, [
53
+ path.join(path.dirname(path.dirname(new URL(import.meta.url).pathname)), "..", "bin", "infernoflow.mjs"),
54
+ ...cmd.split(" ").slice(1),
55
+ ], { cwd, encoding: "utf8", timeout: 20_000 });
56
+ const out = result.stdout?.trim();
57
+ if (out) return JSON.parse(out);
58
+ } catch {}
59
+ return null;
60
+ }
61
+
62
+ function buildSummary(checkResult, diffResult, contract) {
63
+ const status = checkResult?.status || "unknown";
64
+ const caps = (contract?.capabilities || []).length;
65
+ const version = contract?.policyVersion || "?";
66
+ const project = contract?.policyId || "project";
67
+ const added = diffResult?.added || [];
68
+ const removed = diffResult?.removed || [];
69
+ const changed = diffResult?.changed || [];
70
+
71
+ return { status, caps, version, project, added, removed, changed };
72
+ }
73
+
74
+ // ── Slack message builder ─────────────────────────────────────────────────────
75
+
76
+ function buildSlackMessage(summary) {
77
+ const { status, caps, version, project, added, removed, changed } = summary;
78
+ const statusEmoji = status === "ok" ? "✅" : status === "warning" ? "⚠️" : "❌";
79
+ const hasChanges = added.length || removed.length || changed.length;
80
+
81
+ const blocks = [
82
+ {
83
+ type: "header",
84
+ text: { type: "plain_text", text: `🔥 infernoflow — ${project} v${version}`, emoji: true },
85
+ },
86
+ {
87
+ type: "section",
88
+ fields: [
89
+ { type: "mrkdwn", text: `*Status*\n${statusEmoji} ${status.toUpperCase()}` },
90
+ { type: "mrkdwn", text: `*Capabilities*\n${caps} tracked` },
91
+ ],
92
+ },
93
+ ];
94
+
95
+ if (hasChanges) {
96
+ const lines = [];
97
+ if (added.length) lines.push(`✅ *${added.length}* added: ${added.slice(0, 3).join(", ")}${added.length > 3 ? ` +${added.length - 3} more` : ""}`);
98
+ if (removed.length) lines.push(`❌ *${removed.length}* removed: ${removed.slice(0, 3).join(", ")}${removed.length > 3 ? ` +${removed.length - 3} more` : ""}`);
99
+ if (changed.length) lines.push(`📝 *${changed.length}* changed`);
100
+ blocks.push({ type: "section", text: { type: "mrkdwn", text: lines.join("\n") } });
101
+ }
102
+
103
+ blocks.push({
104
+ type: "context",
105
+ elements: [{ type: "mrkdwn", text: `<https://github.com/ronmiz/infernoflow|infernoflow> · ${new Date().toLocaleString()}` }],
106
+ });
107
+
108
+ return { blocks };
109
+ }
110
+
111
+ // ── Discord message builder ───────────────────────────────────────────────────
112
+
113
+ function buildDiscordMessage(summary) {
114
+ const { status, caps, version, project, added, removed, changed } = summary;
115
+ const color = status === "ok" ? 0x4ade80 : status === "warning" ? 0xf97316 : 0xf87171;
116
+ const hasChanges = added.length || removed.length || changed.length;
117
+
118
+ const fields = [
119
+ { name: "Status", value: status.toUpperCase(), inline: true },
120
+ { name: "Capabilities", value: String(caps), inline: true },
121
+ { name: "Version", value: `v${version}`, inline: true },
122
+ ];
123
+
124
+ if (added.length) fields.push({ name: "✅ Added", value: added.slice(0,5).join(", ") + (added.length > 5 ? ` +${added.length-5}` : ""), inline: false });
125
+ if (removed.length) fields.push({ name: "❌ Removed", value: removed.slice(0,5).join(", ") + (removed.length > 5 ? ` +${removed.length-5}` : ""), inline: false });
126
+
127
+ return {
128
+ embeds: [{
129
+ title: `🔥 infernoflow — ${project}`,
130
+ description: hasChanges ? "Capability changes detected" : "Contract healthy",
131
+ color,
132
+ fields,
133
+ footer: { text: "infernoflow · " + new Date().toLocaleString() },
134
+ url: "https://github.com/ronmiz/infernoflow",
135
+ }],
136
+ };
137
+ }
138
+
139
+ // ── HTTP post ─────────────────────────────────────────────────────────────────
140
+
141
+ function postWebhook(url, payload) {
142
+ return new Promise((resolve, reject) => {
143
+ const body = JSON.stringify(payload);
144
+ const parsed = new URL(url);
145
+ const isHttps = parsed.protocol === "https:";
146
+ const lib = isHttps ? https : http;
147
+
148
+ const req = lib.request({
149
+ hostname: parsed.hostname,
150
+ port: parsed.port || (isHttps ? 443 : 80),
151
+ path: parsed.pathname + (parsed.search || ""),
152
+ method: "POST",
153
+ headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body), "User-Agent": "infernoflow-cli" },
154
+ }, (res) => {
155
+ let data = "";
156
+ res.on("data", d => (data += d));
157
+ res.on("end", () => resolve({ status: res.statusCode, body: data }));
158
+ });
159
+
160
+ req.on("error", reject);
161
+ req.write(body);
162
+ req.end();
163
+ });
164
+ }
165
+
166
+ // ── Main ──────────────────────────────────────────────────────────────────────
167
+
168
+ export async function notifyCommand(rawArgs) {
169
+ const args = rawArgs.slice(1);
170
+ const jsonMode = args.includes("--json");
171
+ const dryRun = args.includes("--dry-run");
172
+ const onlyChange = args.includes("--on-change");
173
+ const cwd = process.cwd();
174
+ const infernoDir = path.join(cwd, "inferno");
175
+
176
+ if (!fs.existsSync(infernoDir)) {
177
+ const msg = "inferno/ not found. Run: infernoflow init";
178
+ if (jsonMode) { console.log(JSON.stringify({ ok: false, error: msg })); } else { warn(msg); }
179
+ process.exit(1);
180
+ }
181
+
182
+ const config = loadNotifyConfig(infernoDir, args);
183
+
184
+ if (!config.slack && !config.discord) {
185
+ const msg = "No webhook configured. Use --slack <url>, --discord <url>, or set INFERNOFLOW_SLACK_WEBHOOK / INFERNOFLOW_DISCORD_WEBHOOK.";
186
+ if (jsonMode) { console.log(JSON.stringify({ ok: false, error: msg })); } else { warn(msg); }
187
+ if (!jsonMode) {
188
+ console.log();
189
+ console.log(` ${gray("To configure permanently, create inferno/notify.json:")}`);
190
+ console.log(` ${cyan('{ "slack": "https://hooks.slack.com/...", "discord": "https://discord.com/api/webhooks/..." }')}`);
191
+ console.log();
192
+ }
193
+ process.exit(1);
194
+ }
195
+
196
+ // Load data
197
+ const contract = (() => {
198
+ for (const f of ["contract.json", "capabilities.json"]) {
199
+ const p = path.join(infernoDir, f);
200
+ if (fs.existsSync(p)) { try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch {} }
201
+ }
202
+ return {};
203
+ })();
204
+ const checkResult = runJson("check --json", cwd);
205
+ const diffResult = runJson("diff --json", cwd);
206
+ const summary = buildSummary(checkResult, diffResult, contract);
207
+
208
+ // --on-change: skip if nothing changed
209
+ if (onlyChange && !summary.added.length && !summary.removed.length && !summary.changed.length) {
210
+ if (jsonMode) { console.log(JSON.stringify({ ok: true, skipped: true, reason: "no capability changes" })); }
211
+ else { info("No capability changes — skipping notification."); }
212
+ return;
213
+ }
214
+
215
+ const results = [];
216
+
217
+ // Slack
218
+ if (config.slack) {
219
+ const payload = buildSlackMessage(summary);
220
+ if (dryRun) {
221
+ if (!jsonMode) { info("Slack payload (dry run):"); console.log(JSON.stringify(payload, null, 2)); }
222
+ results.push({ platform: "slack", ok: true, dryRun: true });
223
+ } else {
224
+ try {
225
+ const resp = await postWebhook(config.slack, payload);
226
+ const ok = resp.status >= 200 && resp.status < 300;
227
+ if (!jsonMode) { ok ? done("Slack notification sent") : warn(`Slack returned ${resp.status}`); }
228
+ results.push({ platform: "slack", ok, status: resp.status });
229
+ } catch (err) {
230
+ if (!jsonMode) warn(`Slack failed: ${err.message}`);
231
+ results.push({ platform: "slack", ok: false, error: err.message });
232
+ }
233
+ }
234
+ }
235
+
236
+ // Discord
237
+ if (config.discord) {
238
+ const payload = buildDiscordMessage(summary);
239
+ if (dryRun) {
240
+ if (!jsonMode) { info("Discord payload (dry run):"); console.log(JSON.stringify(payload, null, 2)); }
241
+ results.push({ platform: "discord", ok: true, dryRun: true });
242
+ } else {
243
+ try {
244
+ const resp = await postWebhook(config.discord, payload);
245
+ const ok = resp.status >= 200 && resp.status < 300;
246
+ if (!jsonMode) { ok ? done("Discord notification sent") : warn(`Discord returned ${resp.status}`); }
247
+ results.push({ platform: "discord", ok, status: resp.status });
248
+ } catch (err) {
249
+ if (!jsonMode) warn(`Discord failed: ${err.message}`);
250
+ results.push({ platform: "discord", ok: false, error: err.message });
251
+ }
252
+ }
253
+ }
254
+
255
+ if (jsonMode) {
256
+ console.log(JSON.stringify({ ok: results.every(r => r.ok), results, summary }));
257
+ }
258
+ }