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.
- package/dist/bin/infernoflow.mjs +24 -0
- package/dist/lib/commands/ci.mjs +207 -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,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, "<")}</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
|
+
}
|