infernoflow 0.16.0 → 0.18.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 +42 -0
- package/dist/lib/commands/ci.mjs +207 -0
- package/dist/lib/commands/notify.mjs +258 -0
- package/dist/lib/commands/report.mjs +320 -0
- package/dist/lib/commands/share.mjs +236 -0
- package/dist/lib/commands/watch.mjs +203 -0
- package/package.json +1 -1
package/dist/bin/infernoflow.mjs
CHANGED
|
@@ -37,6 +37,11 @@ const COMMAND_DESCRIPTIONS = {
|
|
|
37
37
|
"team-sync": "Sync capability contract across a team via a shared git branch (push | pull | status | init)",
|
|
38
38
|
onboard: "Interactive onboarding wizard for new developers — explains infernoflow in 5 minutes",
|
|
39
39
|
cloud: "Sync capability contracts via infernoflow cloud (init | push | pull | status | dashboard)",
|
|
40
|
+
share: "Generate a public read-only HTML snapshot of your capability contract",
|
|
41
|
+
watch: "Watch source files and run suggest automatically on save",
|
|
42
|
+
ci: "CI-native check: GitHub Actions annotations, GitLab code quality, exit codes",
|
|
43
|
+
notify: "Post capability drift summary to Slack or Discord",
|
|
44
|
+
report: "Generate a weekly/monthly HTML or Markdown report of capability activity",
|
|
40
45
|
};
|
|
41
46
|
|
|
42
47
|
const COMMAND_HANDLERS = {
|
|
@@ -67,6 +72,11 @@ const COMMAND_HANDLERS = {
|
|
|
67
72
|
"team-sync": async (args) => (await import("../lib/commands/teamSync.mjs")).teamSyncCommand(args),
|
|
68
73
|
onboard: async (args) => (await import("../lib/commands/onboard.mjs")).onboardCommand(args),
|
|
69
74
|
cloud: async (args) => (await import("../lib/commands/cloud.mjs")).cloudCommand(args),
|
|
75
|
+
share: async (args) => (await import("../lib/commands/share.mjs")).shareCommand(args),
|
|
76
|
+
watch: async (args) => (await import("../lib/commands/watch.mjs")).watchCommand(args),
|
|
77
|
+
ci: async (args) => (await import("../lib/commands/ci.mjs")).ciCommand(args),
|
|
78
|
+
notify: async (args) => (await import("../lib/commands/notify.mjs")).notifyCommand(args),
|
|
79
|
+
report: async (args) => (await import("../lib/commands/report.mjs")).reportCommand(args),
|
|
70
80
|
};
|
|
71
81
|
|
|
72
82
|
function formatCommandsHelp() {
|
|
@@ -201,6 +211,38 @@ ${formatCommandsHelp()}
|
|
|
201
211
|
--dry-run Print what would happen without sending
|
|
202
212
|
--json Machine-readable output
|
|
203
213
|
|
|
214
|
+
${bold("share options:")}
|
|
215
|
+
--upload Upload to dpaste.com and print a public URL
|
|
216
|
+
--open Open the snapshot in your browser immediately
|
|
217
|
+
--copy Copy HTML to clipboard
|
|
218
|
+
--out <path> Custom output path (default: inferno/share.html)
|
|
219
|
+
--json Machine-readable: { ok, file, url }
|
|
220
|
+
|
|
221
|
+
${bold("watch options:")}
|
|
222
|
+
[dirs...] Directories to watch (default: src/, lib/, app/)
|
|
223
|
+
--interval <secs> Debounce interval in seconds (default: 3)
|
|
224
|
+
--dry-run Print what would run without executing
|
|
225
|
+
--silent No output (for git hook use)
|
|
226
|
+
|
|
227
|
+
${bold("notify options:")}
|
|
228
|
+
--slack <url> Slack incoming webhook URL
|
|
229
|
+
--discord <url> Discord webhook URL
|
|
230
|
+
--on-change Only notify if capabilities actually changed
|
|
231
|
+
--dry-run Print message without sending
|
|
232
|
+
--json Machine-readable result
|
|
233
|
+
|
|
234
|
+
${bold("report options:")}
|
|
235
|
+
--format html|md Output format (default: html)
|
|
236
|
+
--since <period> 7d, 30d, 90d, or YYYY-MM-DD (default: 30d)
|
|
237
|
+
--out <path> Output file path (default: inferno/report.html)
|
|
238
|
+
--open Open HTML report in browser after generating
|
|
239
|
+
--json Machine-readable summary
|
|
240
|
+
|
|
241
|
+
${bold("ci options:")}
|
|
242
|
+
--platform <name> github | gitlab | bitbucket | generic (auto-detected)
|
|
243
|
+
--fail-on <level> error | warning (default: error)
|
|
244
|
+
--json Machine-readable result + exit code
|
|
245
|
+
|
|
204
246
|
${bold("Machine output:")}
|
|
205
247
|
${gray("status --json")}
|
|
206
248
|
${gray("check --json")}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* infernoflow ci
|
|
3
|
+
*
|
|
4
|
+
* Auto-detect the CI environment and output structured annotations that
|
|
5
|
+
* integrate natively with each platform's UI.
|
|
6
|
+
*
|
|
7
|
+
* Supported platforms:
|
|
8
|
+
* GitHub Actions → ::error:: / ::warning:: / step summary (GITHUB_STEP_SUMMARY)
|
|
9
|
+
* GitLab CI → gl-code-quality.json artifact
|
|
10
|
+
* Bitbucket → annotations API via curl
|
|
11
|
+
* Generic → exit code + JSON output (for any CI)
|
|
12
|
+
*
|
|
13
|
+
* Usage:
|
|
14
|
+
* infernoflow ci Auto-detect platform, run check + diff
|
|
15
|
+
* infernoflow ci --platform github Force a platform
|
|
16
|
+
* infernoflow ci --fail-on warning Fail on warning or higher (default: error)
|
|
17
|
+
* infernoflow ci --json Machine-readable result
|
|
18
|
+
*
|
|
19
|
+
* Exits 0 on success, 1 on error/warning (based on --fail-on).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import * as fs from "node:fs";
|
|
23
|
+
import * as path from "node:path";
|
|
24
|
+
import { spawnSync } from "node:child_process";
|
|
25
|
+
import { header, ok, warn, info, done, bold, cyan, gray, green, red, yellow } from "../ui/output.mjs";
|
|
26
|
+
|
|
27
|
+
// ── Platform detection ────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
function detectPlatform() {
|
|
30
|
+
if (process.env.GITHUB_ACTIONS === "true") return "github";
|
|
31
|
+
if (process.env.GITLAB_CI === "true") return "gitlab";
|
|
32
|
+
if (process.env.BITBUCKET_BUILD_NUMBER) return "bitbucket";
|
|
33
|
+
if (process.env.CIRCLECI === "true") return "circleci";
|
|
34
|
+
if (process.env.JENKINS_URL) return "jenkins";
|
|
35
|
+
if (process.env.CI === "true") return "generic";
|
|
36
|
+
return "local";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ── CLI runner ────────────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
function runJson(command, cwd) {
|
|
42
|
+
try {
|
|
43
|
+
const [bin, ...args] = command.split(" ");
|
|
44
|
+
const result = spawnSync(process.execPath, [
|
|
45
|
+
path.join(path.dirname(path.dirname(new URL(import.meta.url).pathname)), "..", "bin", "infernoflow.mjs"),
|
|
46
|
+
...command.split(" ").slice(1)
|
|
47
|
+
], { cwd, encoding: "utf8", timeout: 30_000 });
|
|
48
|
+
const out = result.stdout?.trim();
|
|
49
|
+
if (out) return JSON.parse(out);
|
|
50
|
+
} catch {}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function runCli(args, cwd) {
|
|
55
|
+
try {
|
|
56
|
+
const result = spawnSync(process.execPath, [
|
|
57
|
+
path.join(path.dirname(path.dirname(new URL(import.meta.url).pathname)), "..", "bin", "infernoflow.mjs"),
|
|
58
|
+
...args
|
|
59
|
+
], { cwd, encoding: "utf8", timeout: 30_000 });
|
|
60
|
+
return result.stdout?.trim() || "";
|
|
61
|
+
} catch { return ""; }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ── GitHub Actions output ─────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
function emitGithub(checkResult, diffResult, failOn) {
|
|
67
|
+
const status = checkResult?.status || "unknown";
|
|
68
|
+
const issues = checkResult?.issues || [];
|
|
69
|
+
const caps = checkResult?.capabilities || 0;
|
|
70
|
+
const added = diffResult?.added?.length || 0;
|
|
71
|
+
const removed = diffResult?.removed?.length || 0;
|
|
72
|
+
const changed = diffResult?.changed?.length || 0;
|
|
73
|
+
|
|
74
|
+
// GitHub workflow commands
|
|
75
|
+
if (status === "error") {
|
|
76
|
+
issues.filter(i => i.severity === "error").forEach(i => {
|
|
77
|
+
console.log(`::error::infernoflow: ${i.message}`);
|
|
78
|
+
});
|
|
79
|
+
} else if (status === "warning") {
|
|
80
|
+
issues.filter(i => i.severity === "warning").forEach(i => {
|
|
81
|
+
console.log(`::warning::infernoflow: ${i.message}`);
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (added > 0) console.log(`::notice::infernoflow: ${added} new capability${added !== 1 ? "ies" : "y"} added`);
|
|
86
|
+
if (removed > 0) console.log(`::warning::infernoflow: ${removed} capability${removed !== 1 ? "ies" : "y"} removed`);
|
|
87
|
+
|
|
88
|
+
// Step summary
|
|
89
|
+
const summaryPath = process.env.GITHUB_STEP_SUMMARY;
|
|
90
|
+
if (summaryPath) {
|
|
91
|
+
const statusIcon = status === "ok" ? "✅" : status === "warning" ? "⚠️" : "❌";
|
|
92
|
+
const lines = [
|
|
93
|
+
`## 🔥 infernoflow CI report`,
|
|
94
|
+
"",
|
|
95
|
+
`${statusIcon} **Status:** ${status.toUpperCase()} · **Capabilities:** ${caps}`,
|
|
96
|
+
"",
|
|
97
|
+
];
|
|
98
|
+
if (added || removed || changed) {
|
|
99
|
+
lines.push("### Capability changes");
|
|
100
|
+
if (added) lines.push(`- ✅ **${added}** added`);
|
|
101
|
+
if (removed) lines.push(`- ❌ **${removed}** removed`);
|
|
102
|
+
if (changed) lines.push(`- 📝 **${changed}** changed`);
|
|
103
|
+
lines.push("");
|
|
104
|
+
}
|
|
105
|
+
if (issues.length) {
|
|
106
|
+
lines.push("### Issues");
|
|
107
|
+
issues.forEach(i => lines.push(`- **${i.severity?.toUpperCase() || "INFO"}**: ${i.message}`));
|
|
108
|
+
lines.push("");
|
|
109
|
+
}
|
|
110
|
+
lines.push("---");
|
|
111
|
+
lines.push("*Generated by [infernoflow](https://github.com/ronmiz/infernoflow)*");
|
|
112
|
+
try { fs.appendFileSync(summaryPath, lines.join("\n") + "\n"); } catch {}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ── GitLab code quality report ────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
function emitGitlab(checkResult, cwd) {
|
|
119
|
+
const issues = checkResult?.issues || [];
|
|
120
|
+
const report = issues.map((issue, i) => ({
|
|
121
|
+
description: issue.message || "infernoflow issue",
|
|
122
|
+
fingerprint: Buffer.from(`infernoflow-${i}-${issue.message}`).toString("hex").slice(0, 40),
|
|
123
|
+
severity: issue.severity === "error" ? "critical" : "minor",
|
|
124
|
+
location: {
|
|
125
|
+
path: "inferno/contract.json",
|
|
126
|
+
lines: { begin: 1 },
|
|
127
|
+
},
|
|
128
|
+
}));
|
|
129
|
+
const reportPath = path.join(cwd, "gl-code-quality-report.json");
|
|
130
|
+
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));
|
|
131
|
+
console.log(`infernoflow: GitLab code quality report written → gl-code-quality-report.json`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ── Generic CI output ─────────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
function emitGeneric(checkResult, diffResult, platform) {
|
|
137
|
+
const status = checkResult?.status || "unknown";
|
|
138
|
+
const caps = checkResult?.capabilities || 0;
|
|
139
|
+
const added = diffResult?.added?.length || 0;
|
|
140
|
+
const removed = diffResult?.removed?.length || 0;
|
|
141
|
+
|
|
142
|
+
console.log(`[infernoflow] platform=${platform} status=${status} capabilities=${caps} added=${added} removed=${removed}`);
|
|
143
|
+
if (checkResult?.issues?.length) {
|
|
144
|
+
checkResult.issues.forEach(i => {
|
|
145
|
+
console.log(`[infernoflow] ${(i.severity || "info").toUpperCase()}: ${i.message}`);
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ── Main ──────────────────────────────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
export async function ciCommand(rawArgs) {
|
|
153
|
+
const args = rawArgs.slice(1);
|
|
154
|
+
const jsonMode = args.includes("--json");
|
|
155
|
+
const platformArg = args.includes("--platform") ? args[args.indexOf("--platform") + 1] : null;
|
|
156
|
+
const failOn = args.includes("--fail-on") ? args[args.indexOf("--fail-on") + 1] : "error";
|
|
157
|
+
const cwd = process.cwd();
|
|
158
|
+
const infernoDir = path.join(cwd, "inferno");
|
|
159
|
+
|
|
160
|
+
if (!fs.existsSync(infernoDir)) {
|
|
161
|
+
if (jsonMode) { console.log(JSON.stringify({ ok: false, error: "inferno/ not found" })); }
|
|
162
|
+
else { console.log("[infernoflow] inferno/ not found — skipping CI check"); }
|
|
163
|
+
process.exit(0); // Don't block CI if infernoflow isn't set up
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const platform = platformArg || detectPlatform();
|
|
167
|
+
|
|
168
|
+
if (!jsonMode) {
|
|
169
|
+
console.log(`[infernoflow] running CI check (platform: ${platform})`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Run check + diff
|
|
173
|
+
const checkResult = runJson("check --json", cwd);
|
|
174
|
+
const diffResult = runJson("diff --json", cwd);
|
|
175
|
+
const status = checkResult?.status || "unknown";
|
|
176
|
+
|
|
177
|
+
// Platform-specific output
|
|
178
|
+
switch (platform) {
|
|
179
|
+
case "github":
|
|
180
|
+
emitGithub(checkResult, diffResult, failOn);
|
|
181
|
+
break;
|
|
182
|
+
case "gitlab":
|
|
183
|
+
emitGitlab(checkResult, cwd);
|
|
184
|
+
emitGeneric(checkResult, diffResult, platform);
|
|
185
|
+
break;
|
|
186
|
+
default:
|
|
187
|
+
emitGeneric(checkResult, diffResult, platform);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (jsonMode) {
|
|
191
|
+
console.log(JSON.stringify({
|
|
192
|
+
ok: status === "ok" || status === "warning",
|
|
193
|
+
platform,
|
|
194
|
+
status,
|
|
195
|
+
capabilities: checkResult?.capabilities || 0,
|
|
196
|
+
issues: checkResult?.issues || [],
|
|
197
|
+
diff: { added: diffResult?.added || [], removed: diffResult?.removed || [], changed: diffResult?.changed || [] },
|
|
198
|
+
}));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Exit code
|
|
202
|
+
const shouldFail = failOn === "warning"
|
|
203
|
+
? (status === "error" || status === "warning")
|
|
204
|
+
: (status === "error");
|
|
205
|
+
|
|
206
|
+
process.exit(shouldFail ? 1 : 0);
|
|
207
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* infernoflow report
|
|
3
|
+
*
|
|
4
|
+
* Generate a weekly/monthly HTML or Markdown report of capability changes,
|
|
5
|
+
* version history, drift events, and agent usage. Can be committed to the
|
|
6
|
+
* repo on a schedule or emailed to the team.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* infernoflow report Generate HTML report (last 30 days)
|
|
10
|
+
* infernoflow report --format md Markdown instead of HTML
|
|
11
|
+
* infernoflow report --since 7d Last 7 days
|
|
12
|
+
* infernoflow report --since 2024-01-01 Since a specific date
|
|
13
|
+
* infernoflow report --out report.html Custom output path
|
|
14
|
+
* infernoflow report --open Open in browser after generating
|
|
15
|
+
* infernoflow report --json Machine-readable summary
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import * as fs from "node:fs";
|
|
19
|
+
import * as path from "node:path";
|
|
20
|
+
import { execSync, spawnSync } from "node:child_process";
|
|
21
|
+
import { done, warn, info, bold, cyan, gray } from "../ui/output.mjs";
|
|
22
|
+
|
|
23
|
+
// ── Git helpers ───────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
function git(cmd, cwd) {
|
|
26
|
+
try { return execSync(cmd, { cwd, encoding: "utf8", timeout: 15_000 }).trim(); }
|
|
27
|
+
catch { return ""; }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function parseSinceDuration(since) {
|
|
31
|
+
if (!since) return new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
|
32
|
+
if (/^\d+d$/.test(since)) return new Date(Date.now() - parseInt(since) * 24 * 60 * 60 * 1000);
|
|
33
|
+
if (/^\d+w$/.test(since)) return new Date(Date.now() - parseInt(since) * 7 * 24 * 60 * 60 * 1000);
|
|
34
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(since)) return new Date(since);
|
|
35
|
+
return new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function getCommitsSince(cwd, since) {
|
|
39
|
+
const iso = since.toISOString();
|
|
40
|
+
const out = git(`git log --after="${iso}" --format="%H|%s|%an|%ad" --date=short`, cwd);
|
|
41
|
+
if (!out) return [];
|
|
42
|
+
return out.split("\n").filter(Boolean).map(line => {
|
|
43
|
+
const [hash, subject, author, date] = line.split("|");
|
|
44
|
+
return { hash: hash?.slice(0, 8), subject, author, date };
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function getCapabilityHistory(infernoDir, cwd, since) {
|
|
49
|
+
const out = git(`git log --after="${since.toISOString()}" --format="%H|%ad" --date=short -- inferno/`, cwd);
|
|
50
|
+
if (!out) return [];
|
|
51
|
+
return out.split("\n").filter(Boolean).map(line => {
|
|
52
|
+
const [hash, date] = line.split("|");
|
|
53
|
+
return { hash: hash?.slice(0, 8), date };
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function getVersionTags(cwd, since) {
|
|
58
|
+
const out = git(`git tag --sort=-version:refname`, cwd);
|
|
59
|
+
if (!out) return [];
|
|
60
|
+
return out.split("\n").filter(t => t.startsWith("v")).slice(0, 10).map(tag => {
|
|
61
|
+
const date = git(`git log -1 --format=%ad --date=short ${tag}`, cwd);
|
|
62
|
+
return { tag, date };
|
|
63
|
+
}).filter(t => !since || new Date(t.date) >= since);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── Data gathering ────────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
function gatherData(cwd, infernoDir, since) {
|
|
69
|
+
const contract = (() => {
|
|
70
|
+
for (const f of ["contract.json", "capabilities.json"]) {
|
|
71
|
+
const p = path.join(infernoDir, f);
|
|
72
|
+
if (fs.existsSync(p)) { try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch {} }
|
|
73
|
+
}
|
|
74
|
+
return {};
|
|
75
|
+
})();
|
|
76
|
+
|
|
77
|
+
const caps = contract.capabilities || [];
|
|
78
|
+
const covered = caps.filter(c => typeof c === "object" && c.covered).length;
|
|
79
|
+
|
|
80
|
+
const commits = getCommitsSince(cwd, since);
|
|
81
|
+
const capCommits = getCapabilityHistory(infernoDir, cwd, since);
|
|
82
|
+
const versionTags = getVersionTags(cwd, since);
|
|
83
|
+
|
|
84
|
+
const agents = (() => {
|
|
85
|
+
const dir = path.join(infernoDir, "agents");
|
|
86
|
+
if (!fs.existsSync(dir)) return [];
|
|
87
|
+
return fs.readdirSync(dir).filter(f => f.endsWith(".json")).map(f => {
|
|
88
|
+
try { return JSON.parse(fs.readFileSync(path.join(dir, f), "utf8")); }
|
|
89
|
+
catch { return null; }
|
|
90
|
+
}).filter(Boolean);
|
|
91
|
+
})();
|
|
92
|
+
|
|
93
|
+
const changelog = (() => {
|
|
94
|
+
const p = path.join(cwd, "CHANGELOG.md");
|
|
95
|
+
if (!fs.existsSync(p)) return null;
|
|
96
|
+
try {
|
|
97
|
+
const lines = fs.readFileSync(p, "utf8").split("\n");
|
|
98
|
+
const start = lines.findIndex(l => l.startsWith("## "));
|
|
99
|
+
if (start === -1) return null;
|
|
100
|
+
const end = lines.findIndex((l, i) => i > start && l.startsWith("## "));
|
|
101
|
+
return lines.slice(start, end === -1 ? start + 20 : end).join("\n");
|
|
102
|
+
} catch { return null; }
|
|
103
|
+
})();
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
project: contract.policyId || path.basename(cwd),
|
|
107
|
+
version: contract.policyVersion || "?",
|
|
108
|
+
caps,
|
|
109
|
+
covered,
|
|
110
|
+
commits,
|
|
111
|
+
capCommits,
|
|
112
|
+
versionTags,
|
|
113
|
+
agents,
|
|
114
|
+
changelog,
|
|
115
|
+
since,
|
|
116
|
+
generatedAt: new Date().toLocaleString(),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ── HTML report ───────────────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
function buildHtmlReport(data) {
|
|
123
|
+
const { project, version, caps, covered, commits, capCommits, versionTags, agents, changelog, since, generatedAt } = data;
|
|
124
|
+
const sinceStr = since.toLocaleDateString();
|
|
125
|
+
const coverage = caps.length ? Math.round((covered / caps.length) * 100) : 0;
|
|
126
|
+
|
|
127
|
+
const capRows = caps.slice(0, 30).map(c => {
|
|
128
|
+
const id = typeof c === "string" ? c : c.id;
|
|
129
|
+
const cov = typeof c === "object" ? c.covered : undefined;
|
|
130
|
+
const badge = cov === true ? `<span style="color:#4ade80">✔</span>` : cov === false ? `<span style="color:#f87171">✗</span>` : `<span style="color:#475569">·</span>`;
|
|
131
|
+
return `<tr><td style="padding:4px 10px">${badge}</td><td style="padding:4px 10px;font-weight:600">${id}</td></tr>`;
|
|
132
|
+
}).join("");
|
|
133
|
+
|
|
134
|
+
const versionRows = versionTags.map(t =>
|
|
135
|
+
`<tr><td style="padding:4px 10px;font-weight:600">${t.tag}</td><td style="padding:4px 10px;color:#94a3b8">${t.date}</td></tr>`
|
|
136
|
+
).join("") || `<tr><td colspan="2" style="padding:4px 10px;color:#475569">No version tags in this period</td></tr>`;
|
|
137
|
+
|
|
138
|
+
const agentRows = agents.map(a => {
|
|
139
|
+
const steps = (a.steps || []).map(s => typeof s === "string" ? s : s.command).join(" → ");
|
|
140
|
+
const conf = a.confidence ? Math.round(a.confidence * 100) + "%" : "—";
|
|
141
|
+
return `<tr><td style="padding:4px 10px;font-weight:600">${a.name}</td><td style="padding:4px 10px;color:#94a3b8">${conf}</td><td style="padding:4px 10px;color:#64748b;font-size:0.8em">${steps}</td></tr>`;
|
|
142
|
+
}).join("") || `<tr><td colspan="3" style="padding:4px 10px;color:#475569">No agents synthesized yet</td></tr>`;
|
|
143
|
+
|
|
144
|
+
return `<!doctype html>
|
|
145
|
+
<html lang="en">
|
|
146
|
+
<head>
|
|
147
|
+
<meta charset="UTF-8">
|
|
148
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
149
|
+
<title>infernoflow report — ${project}</title>
|
|
150
|
+
<style>
|
|
151
|
+
*{box-sizing:border-box;margin:0;padding:0}
|
|
152
|
+
body{background:#0f0f1a;color:#e2e8f0;font-family:'Segoe UI',system-ui,sans-serif;padding:0 0 3rem}
|
|
153
|
+
header{background:#1a1a2e;border-bottom:2px solid #f97316;padding:1.5rem 2rem;display:flex;justify-content:space-between;align-items:flex-end}
|
|
154
|
+
header h1{font-size:1.4rem;font-weight:700;color:#f97316}
|
|
155
|
+
header .meta{color:#64748b;font-size:0.8rem}
|
|
156
|
+
main{max-width:960px;margin:2rem auto;padding:0 1.5rem}
|
|
157
|
+
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:1rem;margin-bottom:2rem}
|
|
158
|
+
.card{background:#1a1a2e;border:1px solid #2d2d4e;border-radius:8px;padding:1rem 1.25rem}
|
|
159
|
+
.card .n{font-size:2rem;font-weight:700;color:#f97316}
|
|
160
|
+
.card .l{font-size:0.72rem;color:#64748b;text-transform:uppercase;letter-spacing:0.06em;margin-top:2px}
|
|
161
|
+
.section{margin-bottom:2rem}
|
|
162
|
+
h2{font-size:0.78rem;text-transform:uppercase;letter-spacing:0.08em;color:#64748b;margin-bottom:0.6rem;padding-bottom:0.35rem;border-bottom:1px solid #2d2d4e}
|
|
163
|
+
table{width:100%;border-collapse:collapse;background:#1a1a2e;border:1px solid #2d2d4e;border-radius:6px;overflow:hidden;font-size:0.875rem}
|
|
164
|
+
tr:nth-child(even) td{background:#16162a}
|
|
165
|
+
.prog{background:#2d2d4e;border-radius:999px;height:8px;margin-top:6px}
|
|
166
|
+
.prog-bar{background:#f97316;border-radius:999px;height:100%;transition:width 0.3s}
|
|
167
|
+
pre{background:#1a1a2e;border:1px solid #2d2d4e;border-radius:6px;padding:1rem;font-size:0.78rem;white-space:pre-wrap;color:#94a3b8;overflow-x:auto}
|
|
168
|
+
footer{text-align:center;color:#334155;font-size:0.72rem;margin-top:3rem}
|
|
169
|
+
footer a{color:#f97316;text-decoration:none}
|
|
170
|
+
</style>
|
|
171
|
+
</head>
|
|
172
|
+
<body>
|
|
173
|
+
<header>
|
|
174
|
+
<div>
|
|
175
|
+
<h1>🔥 infernoflow report — ${project}</h1>
|
|
176
|
+
<div class="meta">v${version} · ${sinceStr} → ${generatedAt}</div>
|
|
177
|
+
</div>
|
|
178
|
+
<div class="meta" style="text-align:right">${commits.length} commits · ${capCommits.length} contract updates</div>
|
|
179
|
+
</header>
|
|
180
|
+
<main>
|
|
181
|
+
<div class="grid">
|
|
182
|
+
<div class="card"><div class="n">${caps.length}</div><div class="l">capabilities</div></div>
|
|
183
|
+
<div class="card"><div class="n">${coverage}%</div><div class="l">coverage</div><div class="prog"><div class="prog-bar" style="width:${coverage}%"></div></div></div>
|
|
184
|
+
<div class="card"><div class="n">${commits.length}</div><div class="l">commits</div></div>
|
|
185
|
+
<div class="card"><div class="n">${capCommits.length}</div><div class="l">contract updates</div></div>
|
|
186
|
+
<div class="card"><div class="n">${versionTags.length}</div><div class="l">releases</div></div>
|
|
187
|
+
<div class="card"><div class="n">${agents.length}</div><div class="l">agents</div></div>
|
|
188
|
+
</div>
|
|
189
|
+
|
|
190
|
+
<div class="section">
|
|
191
|
+
<h2>Capabilities (${caps.length})</h2>
|
|
192
|
+
<table><tbody>${capRows}${caps.length > 30 ? `<tr><td colspan="2" style="padding:6px 10px;color:#475569">… and ${caps.length - 30} more</td></tr>` : ""}</tbody></table>
|
|
193
|
+
</div>
|
|
194
|
+
|
|
195
|
+
<div class="section">
|
|
196
|
+
<h2>Version history</h2>
|
|
197
|
+
<table><tbody>${versionRows}</tbody></table>
|
|
198
|
+
</div>
|
|
199
|
+
|
|
200
|
+
<div class="section">
|
|
201
|
+
<h2>Agents (${agents.length})</h2>
|
|
202
|
+
<table><tbody>${agentRows}</tbody></table>
|
|
203
|
+
</div>
|
|
204
|
+
|
|
205
|
+
${changelog ? `<div class="section"><h2>Latest changelog</h2><pre>${changelog.replace(/</g, "<")}</pre></div>` : ""}
|
|
206
|
+
</main>
|
|
207
|
+
<footer>Generated by <a href="https://github.com/ronmiz/infernoflow">infernoflow</a> on ${generatedAt}</footer>
|
|
208
|
+
</body>
|
|
209
|
+
</html>`;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ── Markdown report ───────────────────────────────────────────────────────────
|
|
213
|
+
|
|
214
|
+
function buildMarkdownReport(data) {
|
|
215
|
+
const { project, version, caps, covered, commits, capCommits, versionTags, agents, changelog, since, generatedAt } = data;
|
|
216
|
+
const coverage = caps.length ? Math.round((covered / caps.length) * 100) : 0;
|
|
217
|
+
|
|
218
|
+
const lines = [
|
|
219
|
+
`# 🔥 infernoflow report — ${project}`,
|
|
220
|
+
``,
|
|
221
|
+
`**Version:** v${version} · **Period:** ${since.toLocaleDateString()} → ${generatedAt}`,
|
|
222
|
+
``,
|
|
223
|
+
`## Summary`,
|
|
224
|
+
``,
|
|
225
|
+
`| Metric | Value |`,
|
|
226
|
+
`|---|---|`,
|
|
227
|
+
`| Capabilities | ${caps.length} |`,
|
|
228
|
+
`| Coverage | ${coverage}% |`,
|
|
229
|
+
`| Commits | ${commits.length} |`,
|
|
230
|
+
`| Contract updates | ${capCommits.length} |`,
|
|
231
|
+
`| Releases | ${versionTags.length} |`,
|
|
232
|
+
`| Agents | ${agents.length} |`,
|
|
233
|
+
``,
|
|
234
|
+
`## Capabilities`,
|
|
235
|
+
``,
|
|
236
|
+
...caps.slice(0, 30).map(c => {
|
|
237
|
+
const id = typeof c === "string" ? c : c.id;
|
|
238
|
+
const cov = typeof c === "object" ? c.covered : undefined;
|
|
239
|
+
return `- ${cov === true ? "✅" : cov === false ? "❌" : "·"} **${id}**`;
|
|
240
|
+
}),
|
|
241
|
+
caps.length > 30 ? `- *… and ${caps.length - 30} more*` : "",
|
|
242
|
+
``,
|
|
243
|
+
`## Version history`,
|
|
244
|
+
``,
|
|
245
|
+
...(versionTags.length
|
|
246
|
+
? versionTags.map(t => `- **${t.tag}** — ${t.date}`)
|
|
247
|
+
: ["- *No releases in this period*"]),
|
|
248
|
+
``,
|
|
249
|
+
`## Agents`,
|
|
250
|
+
``,
|
|
251
|
+
...(agents.length
|
|
252
|
+
? agents.map(a => `- **${a.name}** (${a.confidence ? Math.round(a.confidence * 100) + "%" : "—"}) — ${a.description || ""}`)
|
|
253
|
+
: ["- *No agents synthesized yet*"]),
|
|
254
|
+
``,
|
|
255
|
+
changelog ? `## Latest changelog\n\n\`\`\`\n${changelog}\n\`\`\`` : "",
|
|
256
|
+
``,
|
|
257
|
+
`---`,
|
|
258
|
+
`*Generated by [infernoflow](https://github.com/ronmiz/infernoflow) on ${generatedAt}*`,
|
|
259
|
+
];
|
|
260
|
+
|
|
261
|
+
return lines.filter(l => l !== undefined).join("\n");
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ── Main ──────────────────────────────────────────────────────────────────────
|
|
265
|
+
|
|
266
|
+
export async function reportCommand(rawArgs) {
|
|
267
|
+
const args = rawArgs.slice(1);
|
|
268
|
+
const jsonMode = args.includes("--json");
|
|
269
|
+
const openBrowser = args.includes("--open");
|
|
270
|
+
const format = args.includes("--format") ? args[args.indexOf("--format") + 1] : "html";
|
|
271
|
+
const sinceArg = args.includes("--since") ? args[args.indexOf("--since") + 1] : null;
|
|
272
|
+
const outArg = args.includes("--out") ? args[args.indexOf("--out") + 1] : null;
|
|
273
|
+
const cwd = process.cwd();
|
|
274
|
+
const infernoDir = path.join(cwd, "inferno");
|
|
275
|
+
|
|
276
|
+
if (!fs.existsSync(infernoDir)) {
|
|
277
|
+
const msg = "inferno/ not found. Run: infernoflow init";
|
|
278
|
+
if (jsonMode) { console.log(JSON.stringify({ ok: false, error: msg })); } else { warn(msg); }
|
|
279
|
+
process.exit(1);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (!jsonMode) info(`Generating ${format} report…`);
|
|
283
|
+
|
|
284
|
+
const since = parseSinceDuration(sinceArg);
|
|
285
|
+
const data = gatherData(cwd, infernoDir, since);
|
|
286
|
+
|
|
287
|
+
const ext = format === "md" ? "md" : "html";
|
|
288
|
+
const outPath = outArg || path.join(infernoDir, `report.${ext}`);
|
|
289
|
+
const content = format === "md" ? buildMarkdownReport(data) : buildHtmlReport(data);
|
|
290
|
+
|
|
291
|
+
fs.writeFileSync(outPath, content, "utf8");
|
|
292
|
+
|
|
293
|
+
if (openBrowser && format === "html") {
|
|
294
|
+
try {
|
|
295
|
+
const cmd = process.platform === "win32" ? `start "" "${outPath}"`
|
|
296
|
+
: process.platform === "darwin" ? `open "${outPath}"`
|
|
297
|
+
: `xdg-open "${outPath}"`;
|
|
298
|
+
execSync(cmd, { stdio: "ignore" });
|
|
299
|
+
} catch {}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (jsonMode) {
|
|
303
|
+
console.log(JSON.stringify({
|
|
304
|
+
ok: true,
|
|
305
|
+
file: outPath,
|
|
306
|
+
format,
|
|
307
|
+
project: data.project,
|
|
308
|
+
version: data.version,
|
|
309
|
+
capabilities: data.caps.length,
|
|
310
|
+
commits: data.commits.length,
|
|
311
|
+
agents: data.agents.length,
|
|
312
|
+
}));
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
done(`Report generated`);
|
|
317
|
+
console.log(` ${cyan(outPath)}`);
|
|
318
|
+
console.log(` ${gray(data.caps.length + " capabilities · " + data.commits.length + " commits · " + data.agents.length + " agents")}`);
|
|
319
|
+
console.log();
|
|
320
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* infernoflow share
|
|
3
|
+
*
|
|
4
|
+
* Generate a public read-only snapshot of your capability contract — no cloud
|
|
5
|
+
* account needed. Creates a self-contained HTML file you can host anywhere,
|
|
6
|
+
* or uploads to a paste service and prints a short link.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* infernoflow share Generate share.html locally
|
|
10
|
+
* infernoflow share --open Open in browser immediately
|
|
11
|
+
* infernoflow share --upload Upload to dpaste.com and print URL
|
|
12
|
+
* infernoflow share --copy Copy HTML to clipboard
|
|
13
|
+
* infernoflow share --json Machine-readable: { ok, file, url }
|
|
14
|
+
* infernoflow share --out <path> Custom output path
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import * as fs from "node:fs";
|
|
18
|
+
import * as path from "node:path";
|
|
19
|
+
import * as https from "node:https";
|
|
20
|
+
import { execSync } from "node:child_process";
|
|
21
|
+
import { done, warn, info, bold, cyan, gray, green } from "../ui/output.mjs";
|
|
22
|
+
|
|
23
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
function readContract(infernoDir) {
|
|
26
|
+
for (const f of ["contract.json", "capabilities.json"]) {
|
|
27
|
+
const p = path.join(infernoDir, f);
|
|
28
|
+
if (fs.existsSync(p)) {
|
|
29
|
+
try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch {}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function readChangelog(cwd) {
|
|
36
|
+
const p = path.join(cwd, "CHANGELOG.md");
|
|
37
|
+
if (!fs.existsSync(p)) return null;
|
|
38
|
+
try {
|
|
39
|
+
const lines = fs.readFileSync(p, "utf8").split("\n");
|
|
40
|
+
const start = lines.findIndex(l => l.startsWith("## "));
|
|
41
|
+
if (start === -1) return null;
|
|
42
|
+
const end = lines.findIndex((l, i) => i > start && l.startsWith("## "));
|
|
43
|
+
return lines.slice(start, end === -1 ? start + 30 : end).join("\n");
|
|
44
|
+
} catch { return null; }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ── HTML generator ────────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
function buildHtml(contract, changelog, generatedAt) {
|
|
50
|
+
const caps = contract.capabilities || [];
|
|
51
|
+
const policyId = contract.policyId || "project";
|
|
52
|
+
const version = contract.policyVersion || "?";
|
|
53
|
+
|
|
54
|
+
const capRows = caps.map(c => {
|
|
55
|
+
const id = typeof c === "string" ? c : c.id;
|
|
56
|
+
const title = typeof c === "string" ? c : (c.title || c.id);
|
|
57
|
+
const cov = typeof c === "object" ? c.covered : undefined;
|
|
58
|
+
const badge = cov === true ? `<span class="cov yes">✔</span>`
|
|
59
|
+
: cov === false ? `<span class="cov no">✗</span>`
|
|
60
|
+
: `<span class="cov uk">·</span>`;
|
|
61
|
+
const desc = typeof c === "object" && c.description ? `<div class="cap-desc">${c.description}</div>` : "";
|
|
62
|
+
return `<div class="cap-row">${badge}<div class="cap-body"><div class="cap-id">${id}</div><div class="cap-title">${title !== id ? title : ""}</div>${desc}</div></div>`;
|
|
63
|
+
}).join("");
|
|
64
|
+
|
|
65
|
+
const changelogHtml = changelog
|
|
66
|
+
? `<section><h2>Latest changes</h2><pre class="changelog">${changelog.replace(/</g, "<")}</pre></section>`
|
|
67
|
+
: "";
|
|
68
|
+
|
|
69
|
+
return `<!doctype html>
|
|
70
|
+
<html lang="en">
|
|
71
|
+
<head>
|
|
72
|
+
<meta charset="UTF-8">
|
|
73
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
74
|
+
<title>${policyId} v${version} — infernoflow snapshot</title>
|
|
75
|
+
<style>
|
|
76
|
+
*{box-sizing:border-box;margin:0;padding:0}
|
|
77
|
+
body{background:#0f0f1a;color:#e2e8f0;font-family:'Segoe UI',system-ui,sans-serif;line-height:1.5;padding:0 0 3rem}
|
|
78
|
+
header{background:#1a1a2e;border-bottom:2px solid #f97316;padding:1.5rem 2rem}
|
|
79
|
+
header h1{font-size:1.5rem;font-weight:700;color:#f97316}
|
|
80
|
+
header .meta{color:#64748b;font-size:0.85rem;margin-top:0.25rem}
|
|
81
|
+
main{max-width:860px;margin:2rem auto;padding:0 1.5rem}
|
|
82
|
+
section{margin-bottom:2rem}
|
|
83
|
+
h2{font-size:0.8rem;text-transform:uppercase;letter-spacing:0.08em;color:#64748b;margin-bottom:0.75rem;padding-bottom:0.4rem;border-bottom:1px solid #2d2d4e}
|
|
84
|
+
.stats{display:flex;gap:1rem;flex-wrap:wrap;margin-bottom:2rem}
|
|
85
|
+
.stat{background:#1a1a2e;border:1px solid #2d2d4e;border-radius:8px;padding:1rem 1.5rem;min-width:130px}
|
|
86
|
+
.stat .n{font-size:2rem;font-weight:700;color:#f97316}
|
|
87
|
+
.stat .l{font-size:0.75rem;color:#64748b;margin-top:2px}
|
|
88
|
+
.cap-row{display:flex;align-items:flex-start;gap:0.75rem;padding:0.6rem 0;border-bottom:1px solid #1e1e30}
|
|
89
|
+
.cap-row:last-child{border-bottom:none}
|
|
90
|
+
.cov{font-size:0.9rem;min-width:18px;text-align:center;margin-top:2px}
|
|
91
|
+
.cov.yes{color:#4ade80}.cov.no{color:#f87171}.cov.uk{color:#475569}
|
|
92
|
+
.cap-id{font-weight:600;font-size:0.9rem}
|
|
93
|
+
.cap-title{color:#94a3b8;font-size:0.8rem}
|
|
94
|
+
.cap-desc{color:#64748b;font-size:0.78rem;margin-top:2px}
|
|
95
|
+
.changelog{background:#1a1a2e;border:1px solid #2d2d4e;border-radius:6px;padding:1rem;font-size:0.8rem;white-space:pre-wrap;color:#94a3b8;overflow-x:auto}
|
|
96
|
+
footer{text-align:center;color:#334155;font-size:0.75rem;margin-top:3rem}
|
|
97
|
+
footer a{color:#f97316;text-decoration:none}
|
|
98
|
+
</style>
|
|
99
|
+
</head>
|
|
100
|
+
<body>
|
|
101
|
+
<header>
|
|
102
|
+
<h1>🔥 ${policyId}</h1>
|
|
103
|
+
<div class="meta">v${version} · ${caps.length} capabilities · snapshot ${generatedAt}</div>
|
|
104
|
+
</header>
|
|
105
|
+
<main>
|
|
106
|
+
<div class="stats">
|
|
107
|
+
<div class="stat"><div class="n">${caps.length}</div><div class="l">capabilities</div></div>
|
|
108
|
+
<div class="stat"><div class="n">${caps.filter(c => typeof c === "object" && c.covered).length || "—"}</div><div class="l">covered</div></div>
|
|
109
|
+
<div class="stat"><div class="n">v${version}</div><div class="l">version</div></div>
|
|
110
|
+
</div>
|
|
111
|
+
<section>
|
|
112
|
+
<h2>Capabilities</h2>
|
|
113
|
+
<div>${capRows || '<p style="color:#475569">No capabilities yet.</p>'}</div>
|
|
114
|
+
</section>
|
|
115
|
+
${changelogHtml}
|
|
116
|
+
</main>
|
|
117
|
+
<footer>Generated by <a href="https://github.com/ronmiz/infernoflow">infernoflow</a> on ${generatedAt}</footer>
|
|
118
|
+
</body>
|
|
119
|
+
</html>`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ── Upload to dpaste ──────────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
function uploadToDpaste(content) {
|
|
125
|
+
return new Promise((resolve, reject) => {
|
|
126
|
+
const body = `content=${encodeURIComponent(content)}&syntax=html&expiry_days=365`;
|
|
127
|
+
const req = https.request({
|
|
128
|
+
hostname: "dpaste.com",
|
|
129
|
+
path: "/api/v2/",
|
|
130
|
+
method: "POST",
|
|
131
|
+
headers: {
|
|
132
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
133
|
+
"Content-Length": Buffer.byteLength(body),
|
|
134
|
+
"User-Agent": "infernoflow-cli",
|
|
135
|
+
},
|
|
136
|
+
}, (res) => {
|
|
137
|
+
let data = "";
|
|
138
|
+
res.on("data", d => (data += d));
|
|
139
|
+
res.on("end", () => {
|
|
140
|
+
const url = data.trim();
|
|
141
|
+
if (url.startsWith("http")) resolve(url + ".html");
|
|
142
|
+
else reject(new Error("Unexpected response: " + data));
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
req.on("error", reject);
|
|
146
|
+
req.write(body);
|
|
147
|
+
req.end();
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ── Main ──────────────────────────────────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
export async function shareCommand(rawArgs) {
|
|
154
|
+
const args = rawArgs.slice(1);
|
|
155
|
+
const jsonMode = args.includes("--json");
|
|
156
|
+
const openBrowser = args.includes("--open");
|
|
157
|
+
const upload = args.includes("--upload");
|
|
158
|
+
const copyToClip = args.includes("--copy");
|
|
159
|
+
const outIdx = args.indexOf("--out");
|
|
160
|
+
const cwd = process.cwd();
|
|
161
|
+
const infernoDir = path.join(cwd, "inferno");
|
|
162
|
+
|
|
163
|
+
if (!fs.existsSync(infernoDir)) {
|
|
164
|
+
const msg = "inferno/ not found. Run: infernoflow init";
|
|
165
|
+
if (jsonMode) { console.log(JSON.stringify({ ok: false, error: msg })); } else { warn(msg); }
|
|
166
|
+
process.exit(1);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const contract = readContract(infernoDir);
|
|
170
|
+
if (!contract) {
|
|
171
|
+
const msg = "No contract.json found. Run: infernoflow init";
|
|
172
|
+
if (jsonMode) { console.log(JSON.stringify({ ok: false, error: msg })); } else { warn(msg); }
|
|
173
|
+
process.exit(1);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const changelog = readChangelog(cwd);
|
|
177
|
+
const generatedAt = new Date().toLocaleString();
|
|
178
|
+
const htmlContent = buildHtml(contract, changelog, generatedAt);
|
|
179
|
+
|
|
180
|
+
const outPath = outIdx !== -1 ? args[outIdx + 1] : path.join(cwd, "inferno", "share.html");
|
|
181
|
+
fs.writeFileSync(outPath, htmlContent, "utf8");
|
|
182
|
+
|
|
183
|
+
let url = null;
|
|
184
|
+
|
|
185
|
+
if (upload) {
|
|
186
|
+
if (!jsonMode) info("Uploading to dpaste.com…");
|
|
187
|
+
try {
|
|
188
|
+
url = await uploadToDpaste(htmlContent);
|
|
189
|
+
} catch (err) {
|
|
190
|
+
if (!jsonMode) warn(`Upload failed: ${err.message}`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (copyToClip) {
|
|
195
|
+
try {
|
|
196
|
+
const cmd = process.platform === "win32" ? `echo ${JSON.stringify(htmlContent)} | clip`
|
|
197
|
+
: process.platform === "darwin" ? `pbcopy`
|
|
198
|
+
: `xclip -selection clipboard`;
|
|
199
|
+
if (process.platform === "darwin") {
|
|
200
|
+
const { execSync: ex } = await import("node:child_process");
|
|
201
|
+
const proc = ex;
|
|
202
|
+
require("child_process").execSync("pbcopy", { input: htmlContent });
|
|
203
|
+
} else {
|
|
204
|
+
execSync(cmd, { input: htmlContent });
|
|
205
|
+
}
|
|
206
|
+
if (!jsonMode) info("HTML copied to clipboard");
|
|
207
|
+
} catch {}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (openBrowser) {
|
|
211
|
+
try {
|
|
212
|
+
const target = url || outPath;
|
|
213
|
+
const cmd = process.platform === "win32" ? `start "" "${target}"`
|
|
214
|
+
: process.platform === "darwin" ? `open "${target}"`
|
|
215
|
+
: `xdg-open "${target}"`;
|
|
216
|
+
execSync(cmd, { stdio: "ignore" });
|
|
217
|
+
} catch {}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (jsonMode) {
|
|
221
|
+
console.log(JSON.stringify({ ok: true, file: outPath, url }));
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const caps = (contract.capabilities || []).length;
|
|
226
|
+
done(`Snapshot created — ${bold(String(caps))} capabilities`);
|
|
227
|
+
console.log();
|
|
228
|
+
console.log(` File: ${cyan(outPath)}`);
|
|
229
|
+
if (url) console.log(` URL: ${cyan(url)}`);
|
|
230
|
+
console.log();
|
|
231
|
+
if (!url) {
|
|
232
|
+
console.log(` ${gray("Share the file or run with --upload to get a public URL:")}`);
|
|
233
|
+
console.log(` ${cyan("infernoflow share --upload --open")}`);
|
|
234
|
+
}
|
|
235
|
+
console.log();
|
|
236
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* infernoflow watch
|
|
3
|
+
*
|
|
4
|
+
* File-system watcher that runs `infernoflow suggest` automatically whenever
|
|
5
|
+
* source files are saved. Zero manual steps — just code, save, and the
|
|
6
|
+
* contract stays in sync.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* infernoflow watch Watch src/ (or auto-detected root)
|
|
10
|
+
* infernoflow watch src lib Watch specific directories
|
|
11
|
+
* infernoflow watch --interval 5 Debounce interval in seconds (default: 3)
|
|
12
|
+
* infernoflow watch --dry-run Print what would run, don't actually run
|
|
13
|
+
* infernoflow watch --silent No output (git-hook friendly)
|
|
14
|
+
*
|
|
15
|
+
* What it does on each save:
|
|
16
|
+
* 1. Debounce (3 s default) — batches rapid multi-file saves
|
|
17
|
+
* 2. Diff changed files against capability-map.json
|
|
18
|
+
* 3. If relevant capabilities may be affected → run suggest
|
|
19
|
+
* 4. Run check silently — log issues to inferno/WATCH.log
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import * as fs from "node:fs";
|
|
23
|
+
import * as path from "node:path";
|
|
24
|
+
import { execSync, spawnSync } from "node:child_process";
|
|
25
|
+
import { ok, warn, info, bold, cyan, gray, green, yellow } from "../ui/output.mjs";
|
|
26
|
+
|
|
27
|
+
// ── Source file detection ─────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
const SOURCE_EXTS = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".py", ".go", ".java", ".cs", ".rb", ".swift"]);
|
|
30
|
+
const SKIP_DIRS = new Set(["node_modules", ".git", "dist", "build", "out", ".next", ".angular", "vendor", "coverage", "__pycache__"]);
|
|
31
|
+
|
|
32
|
+
function defaultWatchDirs(cwd) {
|
|
33
|
+
const candidates = ["src", "lib", "app", "pages", "components", "server", "api"];
|
|
34
|
+
const found = candidates.filter(d => fs.existsSync(path.join(cwd, d)));
|
|
35
|
+
return found.length ? found.map(d => path.join(cwd, d)) : [cwd];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isSourceFile(filePath) {
|
|
39
|
+
return SOURCE_EXTS.has(path.extname(filePath).toLowerCase());
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ── Capability relevance check ────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
function capabilityRelevance(changedFiles, infernoDir) {
|
|
45
|
+
const mapPath = path.join(infernoDir, "capability-map.json");
|
|
46
|
+
if (!fs.existsSync(mapPath)) return { relevant: true, reason: "no cap-map — suggesting broadly" };
|
|
47
|
+
|
|
48
|
+
let capMap;
|
|
49
|
+
try { capMap = JSON.parse(fs.readFileSync(mapPath, "utf8")); } catch { return { relevant: true, reason: "cap-map unreadable" }; }
|
|
50
|
+
|
|
51
|
+
const hits = [];
|
|
52
|
+
for (const file of changedFiles) {
|
|
53
|
+
const rel = path.relative(process.cwd(), file).replace(/\\/g, "/");
|
|
54
|
+
for (const [prefix, capIds] of Object.entries(capMap)) {
|
|
55
|
+
if (rel.startsWith(prefix.replace(/\\/g, "/"))) {
|
|
56
|
+
hits.push(...capIds);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (hits.length > 0) return { relevant: true, reason: `touches: ${[...new Set(hits)].slice(0,3).join(", ")}` };
|
|
62
|
+
return { relevant: false, reason: "no mapped capabilities affected" };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ── Run suggest + check ───────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
function runSuggest(changedFiles, cwd, infernoDir, dryRun, silent) {
|
|
68
|
+
const names = changedFiles.map(f => path.basename(f, path.extname(f))).slice(0, 3).join(", ");
|
|
69
|
+
const desc = `code changes in ${names}`;
|
|
70
|
+
|
|
71
|
+
if (!silent) {
|
|
72
|
+
process.stdout.write(` ${yellow("⟳")} suggesting from ${bold(String(changedFiles.length))} changed file${changedFiles.length !== 1 ? "s" : ""}… `);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (dryRun) {
|
|
76
|
+
if (!silent) console.log(gray("(dry run)"));
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
spawnSync(process.execPath, [
|
|
82
|
+
path.join(path.dirname(path.dirname(path.dirname(new URL(import.meta.url).pathname))), "bin", "infernoflow.mjs"),
|
|
83
|
+
"suggest", desc, "--json"
|
|
84
|
+
], { cwd, encoding: "utf8", timeout: 30_000, stdio: "ignore" });
|
|
85
|
+
|
|
86
|
+
if (!silent) console.log(green("done"));
|
|
87
|
+
} catch {
|
|
88
|
+
if (!silent) console.log(gray("skipped (no changes)"));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Silent check — write issues to WATCH.log
|
|
92
|
+
try {
|
|
93
|
+
const result = spawnSync(process.execPath, [
|
|
94
|
+
path.join(path.dirname(path.dirname(path.dirname(new URL(import.meta.url).pathname))), "bin", "infernoflow.mjs"),
|
|
95
|
+
"check", "--json"
|
|
96
|
+
], { cwd, encoding: "utf8", timeout: 15_000 });
|
|
97
|
+
|
|
98
|
+
const out = result.stdout?.trim();
|
|
99
|
+
if (out) {
|
|
100
|
+
const data = JSON.parse(out);
|
|
101
|
+
if (data.status === "error" || data.status === "warning") {
|
|
102
|
+
fs.writeFileSync(path.join(infernoDir, "WATCH.log"), out + "\n");
|
|
103
|
+
if (!silent) warn(`Contract issues detected — see inferno/WATCH.log`);
|
|
104
|
+
} else {
|
|
105
|
+
const logPath = path.join(infernoDir, "WATCH.log");
|
|
106
|
+
if (fs.existsSync(logPath)) fs.unlinkSync(logPath);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
} catch {}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ── Watcher ───────────────────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
export async function watchCommand(rawArgs) {
|
|
115
|
+
const args = rawArgs.slice(1);
|
|
116
|
+
const dryRun = args.includes("--dry-run");
|
|
117
|
+
const silent = args.includes("--silent");
|
|
118
|
+
const intervalIdx = args.indexOf("--interval");
|
|
119
|
+
const debounceMs = ((intervalIdx !== -1 ? parseFloat(args[intervalIdx + 1]) : 3) || 3) * 1000;
|
|
120
|
+
const cwd = process.cwd();
|
|
121
|
+
const infernoDir = path.join(cwd, "inferno");
|
|
122
|
+
|
|
123
|
+
if (!fs.existsSync(infernoDir)) {
|
|
124
|
+
warn("inferno/ not found. Run: infernoflow init");
|
|
125
|
+
process.exit(1);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Collect directories to watch
|
|
129
|
+
const dirArgs = args.filter(a => !a.startsWith("-") && a !== String(args[intervalIdx + 1]));
|
|
130
|
+
const watchDirs = dirArgs.length
|
|
131
|
+
? dirArgs.map(d => path.resolve(cwd, d))
|
|
132
|
+
: defaultWatchDirs(cwd);
|
|
133
|
+
|
|
134
|
+
const validDirs = watchDirs.filter(d => fs.existsSync(d));
|
|
135
|
+
if (!validDirs.length) {
|
|
136
|
+
warn("No valid directories to watch.");
|
|
137
|
+
process.exit(1);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (!silent) {
|
|
141
|
+
console.log();
|
|
142
|
+
console.log(` ${bold("🔥 infernoflow watch")} ${gray("(Ctrl+C to stop)")}`);
|
|
143
|
+
console.log();
|
|
144
|
+
validDirs.forEach(d => console.log(` ${cyan("watching")} ${gray(path.relative(cwd, d) || ".")}`));
|
|
145
|
+
console.log(` ${gray(`debounce: ${debounceMs / 1000}s`)}`);
|
|
146
|
+
console.log();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
let debounceTimer = null;
|
|
150
|
+
const pendingFiles = new Set();
|
|
151
|
+
|
|
152
|
+
const handleChange = (filePath) => {
|
|
153
|
+
if (!isSourceFile(filePath)) return;
|
|
154
|
+
pendingFiles.add(filePath);
|
|
155
|
+
|
|
156
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
157
|
+
debounceTimer = setTimeout(() => {
|
|
158
|
+
const changed = Array.from(pendingFiles);
|
|
159
|
+
pendingFiles.clear();
|
|
160
|
+
|
|
161
|
+
if (!silent) {
|
|
162
|
+
const names = changed.map(f => path.relative(cwd, f)).slice(0, 3).join(", ");
|
|
163
|
+
process.stdout.write(`\n ${gray(new Date().toLocaleTimeString())} ${bold(names)}${changed.length > 3 ? ` +${changed.length - 3} more` : ""} `);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const { relevant, reason } = capabilityRelevance(changed, infernoDir);
|
|
167
|
+
if (!relevant) {
|
|
168
|
+
if (!silent) console.log(gray(`skip (${reason})`));
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
runSuggest(changed, cwd, infernoDir, dryRun, silent);
|
|
173
|
+
}, debounceMs);
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
// Start watchers on each directory
|
|
177
|
+
const watchers = [];
|
|
178
|
+
for (const dir of validDirs) {
|
|
179
|
+
try {
|
|
180
|
+
const watcher = fs.watch(dir, { recursive: true }, (event, filename) => {
|
|
181
|
+
if (filename) handleChange(path.join(dir, filename));
|
|
182
|
+
});
|
|
183
|
+
watchers.push(watcher);
|
|
184
|
+
} catch (err) {
|
|
185
|
+
if (!silent) warn(`Cannot watch ${dir}: ${err.message}`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (!watchers.length) {
|
|
190
|
+
warn("No directories could be watched.");
|
|
191
|
+
process.exit(1);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Keep alive
|
|
195
|
+
process.on("SIGINT", () => {
|
|
196
|
+
watchers.forEach(w => w.close());
|
|
197
|
+
if (!silent) { console.log("\n\n Stopped."); console.log(); }
|
|
198
|
+
process.exit(0);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// Block forever
|
|
202
|
+
await new Promise(() => {});
|
|
203
|
+
}
|