infernoflow 0.16.0 → 0.17.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.
@@ -37,6 +37,9 @@ 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",
40
43
  };
41
44
 
42
45
  const COMMAND_HANDLERS = {
@@ -67,6 +70,9 @@ const COMMAND_HANDLERS = {
67
70
  "team-sync": async (args) => (await import("../lib/commands/teamSync.mjs")).teamSyncCommand(args),
68
71
  onboard: async (args) => (await import("../lib/commands/onboard.mjs")).onboardCommand(args),
69
72
  cloud: async (args) => (await import("../lib/commands/cloud.mjs")).cloudCommand(args),
73
+ share: async (args) => (await import("../lib/commands/share.mjs")).shareCommand(args),
74
+ watch: async (args) => (await import("../lib/commands/watch.mjs")).watchCommand(args),
75
+ ci: async (args) => (await import("../lib/commands/ci.mjs")).ciCommand(args),
70
76
  };
71
77
 
72
78
  function formatCommandsHelp() {
@@ -201,6 +207,24 @@ ${formatCommandsHelp()}
201
207
  --dry-run Print what would happen without sending
202
208
  --json Machine-readable output
203
209
 
210
+ ${bold("share options:")}
211
+ --upload Upload to dpaste.com and print a public URL
212
+ --open Open the snapshot in your browser immediately
213
+ --copy Copy HTML to clipboard
214
+ --out <path> Custom output path (default: inferno/share.html)
215
+ --json Machine-readable: { ok, file, url }
216
+
217
+ ${bold("watch options:")}
218
+ [dirs...] Directories to watch (default: src/, lib/, app/)
219
+ --interval <secs> Debounce interval in seconds (default: 3)
220
+ --dry-run Print what would run without executing
221
+ --silent No output (for git hook use)
222
+
223
+ ${bold("ci options:")}
224
+ --platform <name> github | gitlab | bitbucket | generic (auto-detected)
225
+ --fail-on <level> error | warning (default: error)
226
+ --json Machine-readable result + exit code
227
+
204
228
  ${bold("Machine output:")}
205
229
  ${gray("status --json")}
206
230
  ${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,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, "&lt;")}</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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "infernoflow",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "The forge for liquid code - keep capabilities, contracts, and docs in sync.",
5
5
  "type": "module",
6
6
  "bin": {