infernoflow 0.14.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/infernoflow.mjs +20 -0
- package/dist/lib/commands/changelog.mjs +255 -6
- package/dist/lib/commands/cloud.mjs +521 -0
- package/dist/lib/commands/onboard.mjs +296 -0
- package/package.json +1 -1
package/dist/bin/infernoflow.mjs
CHANGED
|
@@ -35,6 +35,8 @@ const COMMAND_DESCRIPTIONS = {
|
|
|
35
35
|
"pr-comment": "Post capability drift analysis as a GitHub PR comment (works in CI automatically)",
|
|
36
36
|
dashboard: "Launch local web dashboard on localhost:7337 — live contract health, capabilities, agents",
|
|
37
37
|
"team-sync": "Sync capability contract across a team via a shared git branch (push | pull | status | init)",
|
|
38
|
+
onboard: "Interactive onboarding wizard for new developers — explains infernoflow in 5 minutes",
|
|
39
|
+
cloud: "Sync capability contracts via infernoflow cloud (init | push | pull | status | dashboard)",
|
|
38
40
|
};
|
|
39
41
|
|
|
40
42
|
const COMMAND_HANDLERS = {
|
|
@@ -63,6 +65,8 @@ const COMMAND_HANDLERS = {
|
|
|
63
65
|
"pr-comment": async (args) => (await import("../lib/commands/prComment.mjs")).prCommentCommand(args),
|
|
64
66
|
dashboard: async (args) => (await import("../lib/commands/dashboard.mjs")).dashboardCommand(args),
|
|
65
67
|
"team-sync": async (args) => (await import("../lib/commands/teamSync.mjs")).teamSyncCommand(args),
|
|
68
|
+
onboard: async (args) => (await import("../lib/commands/onboard.mjs")).onboardCommand(args),
|
|
69
|
+
cloud: async (args) => (await import("../lib/commands/cloud.mjs")).cloudCommand(args),
|
|
66
70
|
};
|
|
67
71
|
|
|
68
72
|
function formatCommandsHelp() {
|
|
@@ -92,7 +96,9 @@ ${formatCommandsHelp()}
|
|
|
92
96
|
update Draft ## Unreleased from commits (default sub-command)
|
|
93
97
|
show Print the current ## Unreleased block
|
|
94
98
|
list List commits since last tag
|
|
99
|
+
ai Generate human-readable changelog with AI (Anthropic or Ollama)
|
|
95
100
|
--ref <tag|commit> Use a specific ref instead of last tag
|
|
101
|
+
--version <x.y.z> Version label for the AI-generated entry
|
|
96
102
|
--dry-run Print what would be written without modifying file
|
|
97
103
|
--append Append to existing ## Unreleased instead of replacing
|
|
98
104
|
--json Machine-readable output
|
|
@@ -181,6 +187,20 @@ ${formatCommandsHelp()}
|
|
|
181
187
|
--dry-run Print the comment without posting it
|
|
182
188
|
--json Machine-readable output
|
|
183
189
|
|
|
190
|
+
${bold("cloud sub-commands:")}
|
|
191
|
+
init Generate a project token and configure cloud sync
|
|
192
|
+
push Upload local capability contract to cloud
|
|
193
|
+
pull Download latest contract from cloud (conflict detection)
|
|
194
|
+
status Compare local vs cloud (hashes, capability counts)
|
|
195
|
+
dashboard Print hosted dashboard URL and open in browser
|
|
196
|
+
|
|
197
|
+
${bold("cloud options:")}
|
|
198
|
+
--token <tok> Override token (or set INFERNOFLOW_TOKEN env var)
|
|
199
|
+
--endpoint <url> Override default endpoint (https://cloud.infernoflow.dev)
|
|
200
|
+
--force, -f Overwrite on init; overwrite local on conflicted pull
|
|
201
|
+
--dry-run Print what would happen without sending
|
|
202
|
+
--json Machine-readable output
|
|
203
|
+
|
|
184
204
|
${bold("Machine output:")}
|
|
185
205
|
${gray("status --json")}
|
|
186
206
|
${gray("check --json")}
|
|
@@ -303,6 +303,248 @@ async function subcmdUpdate(cwd, changelogPath, opts) {
|
|
|
303
303
|
console.log(` Run ${cyan("infernoflow publish")} when ready to cut the release\n`);
|
|
304
304
|
}
|
|
305
305
|
|
|
306
|
+
// ── AI changelog writer ───────────────────────────────────────────────────────
|
|
307
|
+
|
|
308
|
+
function parseCaps(jsonText) {
|
|
309
|
+
if (!jsonText) return [];
|
|
310
|
+
try {
|
|
311
|
+
const obj = JSON.parse(jsonText);
|
|
312
|
+
const raw = obj.capabilities || [];
|
|
313
|
+
return raw.map(c => typeof c === "string" ? { id: c, title: c } : c);
|
|
314
|
+
} catch { return []; }
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function loadCapsFromDisk(infernoDir) {
|
|
318
|
+
for (const name of ["capabilities.json", "contract.json"]) {
|
|
319
|
+
const p = path.join(infernoDir, name);
|
|
320
|
+
if (fs.existsSync(p)) return parseCaps(fs.readFileSync(p, "utf8"));
|
|
321
|
+
}
|
|
322
|
+
return [];
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function loadCapsAtRef(ref, cwd) {
|
|
326
|
+
const { execSync: _exec } = await import ? null : null; // handled below
|
|
327
|
+
try {
|
|
328
|
+
const { execSync: ex } = await import("node:child_process");
|
|
329
|
+
for (const name of ["capabilities.json", "contract.json"]) {
|
|
330
|
+
try {
|
|
331
|
+
const content = ex(`git show "${ref}:inferno/${name}"`, {
|
|
332
|
+
cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"]
|
|
333
|
+
}).trim();
|
|
334
|
+
if (content) return parseCaps(content);
|
|
335
|
+
} catch {}
|
|
336
|
+
}
|
|
337
|
+
} catch {}
|
|
338
|
+
return [];
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function diffCapsSimple(before, after) {
|
|
342
|
+
const beforeMap = new Map(before.map(c => [c.id, c]));
|
|
343
|
+
const afterMap = new Map(after.map(c => [c.id, c]));
|
|
344
|
+
return {
|
|
345
|
+
added: after.filter(c => !beforeMap.has(c.id)),
|
|
346
|
+
removed: before.filter(c => !afterMap.has(c.id)),
|
|
347
|
+
changed: after.filter(c => {
|
|
348
|
+
const old = beforeMap.get(c.id);
|
|
349
|
+
return old && old.title !== c.title;
|
|
350
|
+
}),
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function buildAiPrompt(commits, diff, ref, version) {
|
|
355
|
+
const lines = [];
|
|
356
|
+
lines.push(`You are writing a changelog entry for a software release.`);
|
|
357
|
+
lines.push(`Write it in a friendly, clear, developer-facing tone.`);
|
|
358
|
+
lines.push(`Use Markdown. Start with ## ${version || "Unreleased"}`);
|
|
359
|
+
lines.push(`Include sections: ### Added, ### Fixed, ### Changed, ### Removed (only if non-empty).`);
|
|
360
|
+
lines.push(`Be concise — one line per item. Do not include commit hashes.`);
|
|
361
|
+
lines.push(`Do not invent features that are not listed below.`);
|
|
362
|
+
lines.push(``);
|
|
363
|
+
lines.push(`## Capability changes since ${ref || "last release"}:`);
|
|
364
|
+
if (diff.added.length) lines.push(`Added: ${diff.added.map(c => c.title || c.id).join(", ")}`);
|
|
365
|
+
if (diff.removed.length) lines.push(`Removed: ${diff.removed.map(c => c.title || c.id).join(", ")}`);
|
|
366
|
+
if (diff.changed.length) lines.push(`Changed: ${diff.changed.map(c => c.title || c.id).join(", ")}`);
|
|
367
|
+
if (!diff.added.length && !diff.removed.length && !diff.changed.length) lines.push(`(no capability changes)`);
|
|
368
|
+
lines.push(``);
|
|
369
|
+
lines.push(`## Git commits since ${ref || "last release"}:`);
|
|
370
|
+
for (const c of commits.slice(0, 40)) lines.push(`- ${c.subject}`);
|
|
371
|
+
lines.push(``);
|
|
372
|
+
lines.push(`Write the changelog entry now:`);
|
|
373
|
+
return lines.join("\n");
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function buildStructuredFallback(commits, diff, version) {
|
|
377
|
+
// High-quality template fallback when no AI provider is available
|
|
378
|
+
const sections = groupCommits(commits);
|
|
379
|
+
|
|
380
|
+
// Merge capability changes into sections
|
|
381
|
+
for (const c of diff.added) sections.Added.unshift(`${c.title || c.id} capability`);
|
|
382
|
+
for (const c of diff.removed) sections.Removed.unshift(`${c.title || c.id} capability`);
|
|
383
|
+
for (const c of diff.changed) sections.Changed.unshift(`Updated ${c.title || c.id} capability`);
|
|
384
|
+
|
|
385
|
+
// Deduplicate
|
|
386
|
+
for (const key of Object.keys(sections)) sections[key] = [...new Set(sections[key])];
|
|
387
|
+
|
|
388
|
+
const tag = version || "Unreleased";
|
|
389
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
390
|
+
const lines = [`## ${tag} — ${date}`, ""];
|
|
391
|
+
|
|
392
|
+
const ORDER = ["Breaking", "Added", "Fixed", "Changed", "Removed"];
|
|
393
|
+
for (const heading of ORDER) {
|
|
394
|
+
const items = sections[heading];
|
|
395
|
+
if (!items?.length) continue;
|
|
396
|
+
lines.push(`### ${heading}`);
|
|
397
|
+
for (const item of items) lines.push(`- ${item}`);
|
|
398
|
+
lines.push("");
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
return lines.join("\n");
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
async function callLocalAi(prompt) {
|
|
405
|
+
// Try Ollama (localhost:11434) — most common local AI setup
|
|
406
|
+
try {
|
|
407
|
+
const { default: http } = await import("node:http");
|
|
408
|
+
return new Promise((resolve, reject) => {
|
|
409
|
+
const body = JSON.stringify({ model: "llama3", prompt, stream: false });
|
|
410
|
+
const req = http.request({
|
|
411
|
+
hostname: "localhost", port: 11434,
|
|
412
|
+
path: "/api/generate", method: "POST",
|
|
413
|
+
headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) },
|
|
414
|
+
}, (res) => {
|
|
415
|
+
let raw = "";
|
|
416
|
+
res.on("data", d => raw += d);
|
|
417
|
+
res.on("end", () => {
|
|
418
|
+
try { resolve(JSON.parse(raw).response || null); } catch { resolve(null); }
|
|
419
|
+
});
|
|
420
|
+
});
|
|
421
|
+
req.setTimeout(30_000, () => { req.destroy(); reject(new Error("timeout")); });
|
|
422
|
+
req.on("error", reject);
|
|
423
|
+
req.write(body);
|
|
424
|
+
req.end();
|
|
425
|
+
});
|
|
426
|
+
} catch { return null; }
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
async function callAnthropicAi(prompt, apiKey) {
|
|
430
|
+
const { default: https } = await import("node:https");
|
|
431
|
+
return new Promise((resolve, reject) => {
|
|
432
|
+
const body = JSON.stringify({
|
|
433
|
+
model: "claude-haiku-4-5-20251001",
|
|
434
|
+
max_tokens: 1024,
|
|
435
|
+
messages: [{ role: "user", content: prompt }],
|
|
436
|
+
});
|
|
437
|
+
const req = https.request({
|
|
438
|
+
hostname: "api.anthropic.com",
|
|
439
|
+
path: "/v1/messages", method: "POST",
|
|
440
|
+
headers: {
|
|
441
|
+
"x-api-key": apiKey,
|
|
442
|
+
"anthropic-version": "2023-06-01",
|
|
443
|
+
"Content-Type": "application/json",
|
|
444
|
+
"Content-Length": Buffer.byteLength(body),
|
|
445
|
+
},
|
|
446
|
+
}, (res) => {
|
|
447
|
+
let raw = "";
|
|
448
|
+
res.on("data", d => raw += d);
|
|
449
|
+
res.on("end", () => {
|
|
450
|
+
try {
|
|
451
|
+
const data = JSON.parse(raw);
|
|
452
|
+
resolve(data.content?.[0]?.text || null);
|
|
453
|
+
} catch { resolve(null); }
|
|
454
|
+
});
|
|
455
|
+
});
|
|
456
|
+
req.setTimeout(30_000, () => { req.destroy(); reject(new Error("timeout")); });
|
|
457
|
+
req.on("error", reject);
|
|
458
|
+
req.write(body);
|
|
459
|
+
req.end();
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
async function subcmdAi(cwd, changelogPath, opts) {
|
|
464
|
+
const { ref, dryRun, asJson, version } = opts;
|
|
465
|
+
const infernoDir = path.join(cwd, "inferno");
|
|
466
|
+
|
|
467
|
+
const tag = ref || lastTag(cwd);
|
|
468
|
+
const commits = commitsSince(tag, cwd);
|
|
469
|
+
|
|
470
|
+
// Load capability diff
|
|
471
|
+
const currentCaps = fs.existsSync(infernoDir) ? loadCapsFromDisk(infernoDir) : [];
|
|
472
|
+
const prevCaps = tag ? (() => {
|
|
473
|
+
try {
|
|
474
|
+
const ex = require("child_process").execSync;
|
|
475
|
+
for (const name of ["capabilities.json", "contract.json"]) {
|
|
476
|
+
try {
|
|
477
|
+
const c = ex(`git show "${tag}:inferno/${name}"`, { cwd, encoding: "utf8", stdio: ["ignore","pipe","pipe"] }).trim();
|
|
478
|
+
if (c) return parseCaps(c);
|
|
479
|
+
} catch {}
|
|
480
|
+
}
|
|
481
|
+
return [];
|
|
482
|
+
} catch { return []; }
|
|
483
|
+
})() : [];
|
|
484
|
+
|
|
485
|
+
const diff = diffCapsSimple(prevCaps, currentCaps);
|
|
486
|
+
|
|
487
|
+
if (!asJson) {
|
|
488
|
+
info(`Generating AI changelog since ${bold(tag || "beginning")}...`);
|
|
489
|
+
info(`${commits.length} commits · ${diff.added.length} added · ${diff.removed.length} removed · ${diff.changed.length} changed`);
|
|
490
|
+
console.log();
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const prompt = buildAiPrompt(commits, diff, tag, version);
|
|
494
|
+
let aiText = null;
|
|
495
|
+
let provider = "template";
|
|
496
|
+
|
|
497
|
+
// Try AI providers in order: Anthropic API → Ollama → structured template
|
|
498
|
+
const anthropicKey = process.env.ANTHROPIC_API_KEY;
|
|
499
|
+
if (anthropicKey) {
|
|
500
|
+
try {
|
|
501
|
+
aiText = await callAnthropicAi(prompt, anthropicKey);
|
|
502
|
+
if (aiText) provider = "anthropic";
|
|
503
|
+
} catch {}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
if (!aiText) {
|
|
507
|
+
try {
|
|
508
|
+
aiText = await callLocalAi(prompt);
|
|
509
|
+
if (aiText) provider = "ollama";
|
|
510
|
+
} catch {}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// Fallback: structured template
|
|
514
|
+
if (!aiText) {
|
|
515
|
+
aiText = buildStructuredFallback(commits, diff, version);
|
|
516
|
+
provider = "template";
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
if (asJson) {
|
|
520
|
+
console.log(JSON.stringify({ ok: true, provider, ref: tag, commits: commits.length, markdown: aiText }));
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
console.log(gray(" ─── Generated changelog ──────────────────────────────"));
|
|
525
|
+
aiText.split("\n").forEach(l => console.log(" " + l));
|
|
526
|
+
console.log(gray(" ──────────────────────────────────────────────────────"));
|
|
527
|
+
console.log();
|
|
528
|
+
info(`Generated via: ${bold(provider)}`);
|
|
529
|
+
|
|
530
|
+
if (dryRun) {
|
|
531
|
+
warn("Dry run — CHANGELOG.md not modified");
|
|
532
|
+
console.log();
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// Write to CHANGELOG.md
|
|
537
|
+
let text = fs.existsSync(changelogPath) ? fs.readFileSync(changelogPath, "utf8") : "# Changelog\n\n";
|
|
538
|
+
const updated = injectUnreleased(text, aiText);
|
|
539
|
+
fs.writeFileSync(changelogPath, updated);
|
|
540
|
+
|
|
541
|
+
ok(`CHANGELOG.md updated with AI-generated entry`);
|
|
542
|
+
if (provider === "template") {
|
|
543
|
+
info(`Tip: set ANTHROPIC_API_KEY for richer AI-written changelogs`);
|
|
544
|
+
}
|
|
545
|
+
console.log();
|
|
546
|
+
}
|
|
547
|
+
|
|
306
548
|
// ── main ─────────────────────────────────────────────────────────────────────
|
|
307
549
|
|
|
308
550
|
export async function changelogCommand(rawArgs) {
|
|
@@ -311,12 +553,14 @@ export async function changelogCommand(rawArgs) {
|
|
|
311
553
|
// Sub-command: first non-flag arg
|
|
312
554
|
const sub = args.find(a => !a.startsWith("-")) || "update";
|
|
313
555
|
|
|
314
|
-
const dryRun
|
|
315
|
-
const append
|
|
316
|
-
const asJson
|
|
556
|
+
const dryRun = args.includes("--dry-run");
|
|
557
|
+
const append = args.includes("--append");
|
|
558
|
+
const asJson = args.includes("--json");
|
|
317
559
|
|
|
318
|
-
const refIdx
|
|
319
|
-
const
|
|
560
|
+
const refIdx = args.indexOf("--ref");
|
|
561
|
+
const versionIdx = args.indexOf("--version");
|
|
562
|
+
const ref = refIdx !== -1 ? args[refIdx + 1] : null;
|
|
563
|
+
const version = versionIdx !== -1 ? args[versionIdx + 1] : null;
|
|
320
564
|
|
|
321
565
|
const cwd = process.cwd();
|
|
322
566
|
const changelogPath = path.join(cwd, "CHANGELOG.md");
|
|
@@ -338,6 +582,11 @@ export async function changelogCommand(rawArgs) {
|
|
|
338
582
|
return;
|
|
339
583
|
}
|
|
340
584
|
|
|
341
|
-
|
|
585
|
+
if (sub === "ai") {
|
|
586
|
+
await subcmdAi(cwd, changelogPath, { ref, dryRun, asJson, version });
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
fail(`Unknown sub-command: ${sub}`, "Use: update | show | list | ai");
|
|
342
591
|
process.exit(1);
|
|
343
592
|
}
|
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* infernoflow cloud
|
|
3
|
+
*
|
|
4
|
+
* Sync capability contracts with the infernoflow cloud service.
|
|
5
|
+
* A hosted alternative to `team-sync` (which uses a shared git branch).
|
|
6
|
+
*
|
|
7
|
+
* Sub-commands:
|
|
8
|
+
* cloud init Generate a project token and write inferno/.cloud.json
|
|
9
|
+
* cloud push Upload local contract to cloud
|
|
10
|
+
* cloud pull Download latest contract from cloud
|
|
11
|
+
* cloud status Show local vs cloud diff
|
|
12
|
+
* cloud dashboard Print hosted dashboard URL
|
|
13
|
+
*
|
|
14
|
+
* Flags:
|
|
15
|
+
* --token <tok> Override token from env INFERNOFLOW_TOKEN
|
|
16
|
+
* --endpoint <url> Override default endpoint
|
|
17
|
+
* --dry-run Print what would happen without sending
|
|
18
|
+
* --json Machine-readable output
|
|
19
|
+
*
|
|
20
|
+
* Usage:
|
|
21
|
+
* infernoflow cloud init
|
|
22
|
+
* infernoflow cloud push
|
|
23
|
+
* infernoflow cloud pull
|
|
24
|
+
* infernoflow cloud status --json
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import * as fs from "node:fs";
|
|
28
|
+
import * as path from "node:path";
|
|
29
|
+
import * as https from "node:https";
|
|
30
|
+
import * as http from "node:http";
|
|
31
|
+
import * as crypto from "node:crypto";
|
|
32
|
+
import { header, ok, warn, info, done, bold, cyan, gray, green, red, yellow } from "../ui/output.mjs";
|
|
33
|
+
|
|
34
|
+
// ── Config ────────────────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
const DEFAULT_ENDPOINT = "https://cloud.infernoflow.dev";
|
|
37
|
+
const CLOUD_CONFIG_FILE = ".cloud.json";
|
|
38
|
+
|
|
39
|
+
function readCloudConfig(infernoDir) {
|
|
40
|
+
const p = path.join(infernoDir, CLOUD_CONFIG_FILE);
|
|
41
|
+
if (!fs.existsSync(p)) return null;
|
|
42
|
+
try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch { return null; }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function writeCloudConfig(infernoDir, config) {
|
|
46
|
+
const p = path.join(infernoDir, CLOUD_CONFIG_FILE);
|
|
47
|
+
fs.writeFileSync(p, JSON.stringify(config, null, 2) + "\n");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function getToken(config, args) {
|
|
51
|
+
const idx = args.indexOf("--token");
|
|
52
|
+
if (idx !== -1) return args[idx + 1];
|
|
53
|
+
return process.env.INFERNOFLOW_TOKEN || config?.token || null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function getEndpoint(config, args) {
|
|
57
|
+
const idx = args.indexOf("--endpoint");
|
|
58
|
+
if (idx !== -1) return args[idx + 1];
|
|
59
|
+
return process.env.INFERNOFLOW_ENDPOINT || config?.endpoint || DEFAULT_ENDPOINT;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ── HTTP helpers ──────────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
function httpsRequest(method, url, body, token) {
|
|
65
|
+
return new Promise((resolve, reject) => {
|
|
66
|
+
const parsed = new URL(url);
|
|
67
|
+
const isHttps = parsed.protocol === "https:";
|
|
68
|
+
const lib = isHttps ? https : http;
|
|
69
|
+
const payload = body ? JSON.stringify(body) : null;
|
|
70
|
+
|
|
71
|
+
const options = {
|
|
72
|
+
hostname: parsed.hostname,
|
|
73
|
+
port: parsed.port || (isHttps ? 443 : 80),
|
|
74
|
+
path: parsed.pathname + (parsed.search || ""),
|
|
75
|
+
method,
|
|
76
|
+
headers: {
|
|
77
|
+
"Content-Type": "application/json",
|
|
78
|
+
"Accept": "application/json",
|
|
79
|
+
"User-Agent": "infernoflow-cli",
|
|
80
|
+
...(token ? { "Authorization": `Bearer ${token}` } : {}),
|
|
81
|
+
...(payload ? { "Content-Length": Buffer.byteLength(payload) } : {}),
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const req = lib.request(options, (res) => {
|
|
86
|
+
let data = "";
|
|
87
|
+
res.on("data", (chunk) => (data += chunk));
|
|
88
|
+
res.on("end", () => {
|
|
89
|
+
try {
|
|
90
|
+
resolve({ status: res.statusCode, body: JSON.parse(data) });
|
|
91
|
+
} catch {
|
|
92
|
+
resolve({ status: res.statusCode, body: data });
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
req.on("error", reject);
|
|
98
|
+
if (payload) req.write(payload);
|
|
99
|
+
req.end();
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ── Contract helpers ──────────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
function readContract(infernoDir) {
|
|
106
|
+
const candidates = ["contract.json", "capabilities.json"];
|
|
107
|
+
for (const f of candidates) {
|
|
108
|
+
const p = path.join(infernoDir, f);
|
|
109
|
+
if (fs.existsSync(p)) {
|
|
110
|
+
try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch {}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function contractHash(contract) {
|
|
117
|
+
return crypto.createHash("sha256").update(JSON.stringify(contract)).digest("hex").slice(0, 12);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ── Sub-commands ──────────────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
async function subcmdInit(args, cwd, infernoDir) {
|
|
123
|
+
const jsonMode = args.includes("--json");
|
|
124
|
+
const endpoint = getEndpoint(null, args);
|
|
125
|
+
const dryRun = args.includes("--dry-run");
|
|
126
|
+
|
|
127
|
+
// Check for existing config
|
|
128
|
+
const existing = readCloudConfig(infernoDir);
|
|
129
|
+
if (existing && !args.includes("--force") && !args.includes("-f")) {
|
|
130
|
+
if (jsonMode) {
|
|
131
|
+
console.log(JSON.stringify({ ok: false, error: "Already initialised. Use --force to overwrite.", config: existing }));
|
|
132
|
+
} else {
|
|
133
|
+
warn("Cloud already configured for this project.");
|
|
134
|
+
console.log(` Token: ${gray(existing.token)}`);
|
|
135
|
+
console.log(` Endpoint: ${gray(existing.endpoint)}`);
|
|
136
|
+
console.log(` Project: ${gray(existing.projectId)}`);
|
|
137
|
+
console.log();
|
|
138
|
+
info("Use --force to generate a new token.");
|
|
139
|
+
}
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Generate a project ID and token
|
|
144
|
+
const projectId = crypto.randomBytes(8).toString("hex");
|
|
145
|
+
const token = crypto.randomBytes(24).toString("base64url");
|
|
146
|
+
|
|
147
|
+
const config = {
|
|
148
|
+
projectId,
|
|
149
|
+
token,
|
|
150
|
+
endpoint,
|
|
151
|
+
createdAt: new Date().toISOString(),
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
if (dryRun) {
|
|
155
|
+
if (jsonMode) {
|
|
156
|
+
console.log(JSON.stringify({ ok: true, dryRun: true, config }));
|
|
157
|
+
} else {
|
|
158
|
+
info("Dry run — would write inferno/.cloud.json:");
|
|
159
|
+
console.log(" " + JSON.stringify(config, null, 2).split("\n").join("\n "));
|
|
160
|
+
}
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (!jsonMode) header("Initialising infernoflow cloud");
|
|
165
|
+
|
|
166
|
+
// Register project with cloud endpoint (best-effort)
|
|
167
|
+
try {
|
|
168
|
+
const resp = await httpsRequest("POST", `${endpoint}/api/projects`, { projectId }, null);
|
|
169
|
+
if (resp.status === 200 || resp.status === 201) {
|
|
170
|
+
if (!jsonMode) ok("Project registered on cloud");
|
|
171
|
+
}
|
|
172
|
+
} catch {
|
|
173
|
+
if (!jsonMode) info("Cloud endpoint unreachable — saved config locally (will connect on first push)");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
writeCloudConfig(infernoDir, config);
|
|
177
|
+
|
|
178
|
+
if (jsonMode) {
|
|
179
|
+
console.log(JSON.stringify({ ok: true, projectId, endpoint }));
|
|
180
|
+
} else {
|
|
181
|
+
done("Cloud configured!");
|
|
182
|
+
console.log();
|
|
183
|
+
console.log(` Project ID: ${cyan(projectId)}`);
|
|
184
|
+
console.log(` Endpoint: ${gray(endpoint)}`);
|
|
185
|
+
console.log(` Token: ${gray(token.slice(0, 8) + "…")} (stored in inferno/.cloud.json)`);
|
|
186
|
+
console.log();
|
|
187
|
+
console.log(` ${gray("Share the dashboard:")} ${cyan(`${endpoint}/p/${projectId}`)}`);
|
|
188
|
+
console.log();
|
|
189
|
+
console.log(` ${yellow("⚠")} Add inferno/.cloud.json to .gitignore to protect your token!`);
|
|
190
|
+
console.log(` ${gray("echo 'inferno/.cloud.json' >> .gitignore")}`);
|
|
191
|
+
console.log();
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function subcmdPush(args, cwd, infernoDir) {
|
|
196
|
+
const jsonMode = args.includes("--json");
|
|
197
|
+
const dryRun = args.includes("--dry-run");
|
|
198
|
+
const config = readCloudConfig(infernoDir);
|
|
199
|
+
const token = getToken(config, args);
|
|
200
|
+
const endpoint = getEndpoint(config, args);
|
|
201
|
+
|
|
202
|
+
if (!token) {
|
|
203
|
+
const msg = "No token found. Run: infernoflow cloud init";
|
|
204
|
+
if (jsonMode) { console.log(JSON.stringify({ ok: false, error: msg })); } else { warn(msg); }
|
|
205
|
+
process.exit(1);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const contract = readContract(infernoDir);
|
|
209
|
+
if (!contract) {
|
|
210
|
+
const msg = "No contract.json found. Run: infernoflow init";
|
|
211
|
+
if (jsonMode) { console.log(JSON.stringify({ ok: false, error: msg })); } else { warn(msg); }
|
|
212
|
+
process.exit(1);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const projectId = config?.projectId || "unknown";
|
|
216
|
+
const hash = contractHash(contract);
|
|
217
|
+
const caps = (contract.capabilities || []).length;
|
|
218
|
+
|
|
219
|
+
if (dryRun) {
|
|
220
|
+
if (jsonMode) {
|
|
221
|
+
console.log(JSON.stringify({ ok: true, dryRun: true, projectId, hash, capabilities: caps }));
|
|
222
|
+
} else {
|
|
223
|
+
info(`Dry run — would push ${bold(String(caps))} capabilities (hash: ${hash}) to ${endpoint}`);
|
|
224
|
+
}
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (!jsonMode) header("Pushing contract to cloud");
|
|
229
|
+
|
|
230
|
+
try {
|
|
231
|
+
const resp = await httpsRequest(
|
|
232
|
+
"PUT",
|
|
233
|
+
`${endpoint}/api/projects/${projectId}/contract`,
|
|
234
|
+
{ contract, hash, pushedAt: new Date().toISOString() },
|
|
235
|
+
token
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
if (resp.status === 200 || resp.status === 201 || resp.status === 204) {
|
|
239
|
+
if (jsonMode) {
|
|
240
|
+
console.log(JSON.stringify({ ok: true, projectId, hash, capabilities: caps }));
|
|
241
|
+
} else {
|
|
242
|
+
done(`Pushed ${bold(String(caps))} capabilities`);
|
|
243
|
+
console.log(` ${gray("Dashboard:")} ${cyan(`${endpoint}/p/${projectId}`)}`);
|
|
244
|
+
console.log();
|
|
245
|
+
}
|
|
246
|
+
} else {
|
|
247
|
+
const errMsg = `Cloud returned ${resp.status}`;
|
|
248
|
+
if (jsonMode) { console.log(JSON.stringify({ ok: false, error: errMsg, status: resp.status })); }
|
|
249
|
+
else { warn(errMsg); }
|
|
250
|
+
process.exit(1);
|
|
251
|
+
}
|
|
252
|
+
} catch (err) {
|
|
253
|
+
// Cloud unreachable — save a pending push marker
|
|
254
|
+
const pendingPath = path.join(infernoDir, ".cloud-pending.json");
|
|
255
|
+
fs.writeFileSync(pendingPath, JSON.stringify({ hash, pendingAt: new Date().toISOString() }));
|
|
256
|
+
|
|
257
|
+
if (jsonMode) {
|
|
258
|
+
console.log(JSON.stringify({ ok: false, error: err.message, pending: true }));
|
|
259
|
+
} else {
|
|
260
|
+
warn("Cloud unreachable — push queued locally.");
|
|
261
|
+
info("Changes will sync automatically on next successful connection.");
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function subcmdPull(args, cwd, infernoDir) {
|
|
267
|
+
const jsonMode = args.includes("--json");
|
|
268
|
+
const dryRun = args.includes("--dry-run");
|
|
269
|
+
const config = readCloudConfig(infernoDir);
|
|
270
|
+
const token = getToken(config, args);
|
|
271
|
+
const endpoint = getEndpoint(config, args);
|
|
272
|
+
|
|
273
|
+
if (!token) {
|
|
274
|
+
const msg = "No token found. Run: infernoflow cloud init";
|
|
275
|
+
if (jsonMode) { console.log(JSON.stringify({ ok: false, error: msg })); } else { warn(msg); }
|
|
276
|
+
process.exit(1);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const projectId = config?.projectId || "unknown";
|
|
280
|
+
|
|
281
|
+
if (!jsonMode) header("Pulling contract from cloud");
|
|
282
|
+
|
|
283
|
+
try {
|
|
284
|
+
const resp = await httpsRequest(
|
|
285
|
+
"GET",
|
|
286
|
+
`${endpoint}/api/projects/${projectId}/contract`,
|
|
287
|
+
null,
|
|
288
|
+
token
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
if (resp.status !== 200) {
|
|
292
|
+
const errMsg = `Cloud returned ${resp.status}`;
|
|
293
|
+
if (jsonMode) { console.log(JSON.stringify({ ok: false, error: errMsg })); }
|
|
294
|
+
else { warn(errMsg); }
|
|
295
|
+
process.exit(1);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const remote = resp.body?.contract;
|
|
299
|
+
const localRaw = readContract(infernoDir);
|
|
300
|
+
|
|
301
|
+
if (!remote) {
|
|
302
|
+
const msg = "No contract found on cloud. Push first.";
|
|
303
|
+
if (jsonMode) { console.log(JSON.stringify({ ok: false, error: msg })); }
|
|
304
|
+
else { warn(msg); }
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Detect conflicts (same capability changed on both sides)
|
|
309
|
+
const localCaps = (localRaw?.capabilities || []).map(c => typeof c === "string" ? c : c.id);
|
|
310
|
+
const remoteCaps = (remote.capabilities || []).map(c => typeof c === "string" ? c : c.id);
|
|
311
|
+
const localSet = new Set(localCaps);
|
|
312
|
+
const remoteSet = new Set(remoteCaps);
|
|
313
|
+
const onlyLocal = localCaps.filter(id => !remoteSet.has(id));
|
|
314
|
+
const onlyRemote = remoteCaps.filter(id => !localSet.has(id));
|
|
315
|
+
|
|
316
|
+
if (onlyLocal.length > 0 && onlyRemote.length > 0) {
|
|
317
|
+
if (!jsonMode) {
|
|
318
|
+
warn("Diverged contracts detected:");
|
|
319
|
+
onlyLocal.forEach(id => console.log(` ${red("-")} local-only: ${id}`));
|
|
320
|
+
onlyRemote.forEach(id => console.log(` ${green("+")} remote-only: ${id}`));
|
|
321
|
+
console.log();
|
|
322
|
+
warn("Merge manually or use --force to overwrite local with remote.");
|
|
323
|
+
} else {
|
|
324
|
+
console.log(JSON.stringify({
|
|
325
|
+
ok: false,
|
|
326
|
+
conflict: true,
|
|
327
|
+
onlyLocal,
|
|
328
|
+
onlyRemote,
|
|
329
|
+
}));
|
|
330
|
+
}
|
|
331
|
+
if (!args.includes("--force") && !args.includes("-f")) return;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (dryRun) {
|
|
335
|
+
if (jsonMode) {
|
|
336
|
+
console.log(JSON.stringify({ ok: true, dryRun: true, capabilities: remoteCaps.length, hash: contractHash(remote) }));
|
|
337
|
+
} else {
|
|
338
|
+
info(`Dry run — would write ${bold(String(remoteCaps.length))} capabilities from cloud`);
|
|
339
|
+
}
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Write pulled contract
|
|
344
|
+
const contractPath = path.join(infernoDir, "contract.json");
|
|
345
|
+
fs.writeFileSync(contractPath, JSON.stringify(remote, null, 2) + "\n");
|
|
346
|
+
|
|
347
|
+
if (jsonMode) {
|
|
348
|
+
console.log(JSON.stringify({ ok: true, capabilities: remoteCaps.length, hash: contractHash(remote) }));
|
|
349
|
+
} else {
|
|
350
|
+
done(`Pulled ${bold(String(remoteCaps.length))} capabilities from cloud`);
|
|
351
|
+
if (onlyLocal.length) warn(`${onlyLocal.length} local-only capabilities were overwritten.`);
|
|
352
|
+
console.log();
|
|
353
|
+
}
|
|
354
|
+
} catch (err) {
|
|
355
|
+
if (jsonMode) { console.log(JSON.stringify({ ok: false, error: err.message })); }
|
|
356
|
+
else { warn(`Cloud unreachable: ${err.message}`); }
|
|
357
|
+
process.exit(1);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
async function subcmdStatus(args, cwd, infernoDir) {
|
|
362
|
+
const jsonMode = args.includes("--json");
|
|
363
|
+
const config = readCloudConfig(infernoDir);
|
|
364
|
+
const token = getToken(config, args);
|
|
365
|
+
const endpoint = getEndpoint(config, args);
|
|
366
|
+
|
|
367
|
+
if (!config) {
|
|
368
|
+
if (jsonMode) { console.log(JSON.stringify({ ok: false, error: "Not initialised. Run: infernoflow cloud init" })); }
|
|
369
|
+
else { warn("Cloud not configured. Run: infernoflow cloud init"); }
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const projectId = config.projectId;
|
|
374
|
+
const localContract = readContract(infernoDir);
|
|
375
|
+
const localHash = localContract ? contractHash(localContract) : null;
|
|
376
|
+
const localCaps = (localContract?.capabilities || []).length;
|
|
377
|
+
|
|
378
|
+
if (!jsonMode) header("Cloud status");
|
|
379
|
+
|
|
380
|
+
let remoteHash = null;
|
|
381
|
+
let remoteCaps = 0;
|
|
382
|
+
let reachable = false;
|
|
383
|
+
|
|
384
|
+
try {
|
|
385
|
+
const resp = await httpsRequest(
|
|
386
|
+
"GET",
|
|
387
|
+
`${endpoint}/api/projects/${projectId}/contract`,
|
|
388
|
+
null,
|
|
389
|
+
token
|
|
390
|
+
);
|
|
391
|
+
if (resp.status === 200 && resp.body?.contract) {
|
|
392
|
+
reachable = true;
|
|
393
|
+
remoteHash = contractHash(resp.body.contract);
|
|
394
|
+
remoteCaps = (resp.body.contract?.capabilities || []).length;
|
|
395
|
+
}
|
|
396
|
+
} catch {}
|
|
397
|
+
|
|
398
|
+
const inSync = localHash === remoteHash;
|
|
399
|
+
const pending = fs.existsSync(path.join(infernoDir, ".cloud-pending.json"));
|
|
400
|
+
|
|
401
|
+
if (jsonMode) {
|
|
402
|
+
console.log(JSON.stringify({
|
|
403
|
+
ok: true,
|
|
404
|
+
projectId,
|
|
405
|
+
endpoint,
|
|
406
|
+
reachable,
|
|
407
|
+
inSync,
|
|
408
|
+
pending,
|
|
409
|
+
local: { hash: localHash, capabilities: localCaps },
|
|
410
|
+
remote: reachable ? { hash: remoteHash, capabilities: remoteCaps } : null,
|
|
411
|
+
}));
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
console.log(` Project: ${cyan(projectId)}`);
|
|
416
|
+
console.log(` Endpoint: ${gray(endpoint)}`);
|
|
417
|
+
console.log(` Dashboard: ${cyan(`${endpoint}/p/${projectId}`)}`);
|
|
418
|
+
console.log();
|
|
419
|
+
console.log(` Local: ${bold(String(localCaps))} capabilities ${gray("(hash: " + (localHash || "none") + ")")}`);
|
|
420
|
+
|
|
421
|
+
if (!reachable) {
|
|
422
|
+
console.log(` Cloud: ${yellow("unreachable")}`);
|
|
423
|
+
} else {
|
|
424
|
+
console.log(` Cloud: ${bold(String(remoteCaps))} capabilities ${gray("(hash: " + (remoteHash || "none") + ")")}`);
|
|
425
|
+
console.log();
|
|
426
|
+
if (inSync) {
|
|
427
|
+
console.log(` ${green("✔")} In sync with cloud`);
|
|
428
|
+
} else {
|
|
429
|
+
console.log(` ${yellow("⚠")} Out of sync — run ${cyan("infernoflow cloud push")} or ${cyan("infernoflow cloud pull")}`);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
if (pending) {
|
|
434
|
+
console.log(` ${yellow("⚠")} Pending push queued (cloud was unreachable last time)`);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
console.log();
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
async function subcmdDashboard(args, cwd, infernoDir) {
|
|
441
|
+
const config = readCloudConfig(infernoDir);
|
|
442
|
+
const endpoint = getEndpoint(config, args);
|
|
443
|
+
const projectId = config?.projectId;
|
|
444
|
+
const jsonMode = args.includes("--json");
|
|
445
|
+
|
|
446
|
+
if (!projectId) {
|
|
447
|
+
if (jsonMode) { console.log(JSON.stringify({ ok: false, error: "Run: infernoflow cloud init first" })); }
|
|
448
|
+
else { warn("Not configured. Run: infernoflow cloud init first."); }
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const url = `${endpoint}/p/${projectId}`;
|
|
453
|
+
|
|
454
|
+
if (jsonMode) {
|
|
455
|
+
console.log(JSON.stringify({ ok: true, url }));
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
console.log();
|
|
460
|
+
console.log(` ${bold("🔥 infernoflow cloud dashboard")}`);
|
|
461
|
+
console.log();
|
|
462
|
+
console.log(` ${cyan(url)}`);
|
|
463
|
+
console.log();
|
|
464
|
+
console.log(` ${gray("Share this URL with your whole team.")}`);
|
|
465
|
+
console.log();
|
|
466
|
+
|
|
467
|
+
// Try to open in browser
|
|
468
|
+
try {
|
|
469
|
+
const { execSync } = await import("node:child_process");
|
|
470
|
+
const cmd = process.platform === "win32" ? `start "" "${url}"` :
|
|
471
|
+
process.platform === "darwin" ? `open "${url}"` : `xdg-open "${url}"`;
|
|
472
|
+
execSync(cmd, { stdio: "ignore" });
|
|
473
|
+
} catch {}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// ── Entry point ───────────────────────────────────────────────────────────────
|
|
477
|
+
|
|
478
|
+
export async function cloudCommand(rawArgs) {
|
|
479
|
+
const args = rawArgs.slice(1);
|
|
480
|
+
const subcmd = args[0];
|
|
481
|
+
const cwd = process.cwd();
|
|
482
|
+
const infernoDir = path.join(cwd, "inferno");
|
|
483
|
+
|
|
484
|
+
if (!fs.existsSync(infernoDir)) {
|
|
485
|
+
const msg = "inferno/ directory not found. Run: infernoflow init";
|
|
486
|
+
if (args.includes("--json")) { console.log(JSON.stringify({ ok: false, error: msg })); }
|
|
487
|
+
else { warn(msg); }
|
|
488
|
+
process.exit(1);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const subArgs = args.slice(1);
|
|
492
|
+
|
|
493
|
+
switch (subcmd) {
|
|
494
|
+
case "init":
|
|
495
|
+
return subcmdInit(subArgs, cwd, infernoDir);
|
|
496
|
+
case "push":
|
|
497
|
+
return subcmdPush(subArgs, cwd, infernoDir);
|
|
498
|
+
case "pull":
|
|
499
|
+
return subcmdPull(subArgs, cwd, infernoDir);
|
|
500
|
+
case "status":
|
|
501
|
+
return subcmdStatus(subArgs, cwd, infernoDir);
|
|
502
|
+
case "dashboard":
|
|
503
|
+
return subcmdDashboard(subArgs, cwd, infernoDir);
|
|
504
|
+
default: {
|
|
505
|
+
const jsonMode = args.includes("--json");
|
|
506
|
+
const msg = `Unknown cloud sub-command: ${subcmd || "(none)"}. Use: init | push | pull | status | dashboard`;
|
|
507
|
+
if (jsonMode) { console.log(JSON.stringify({ ok: false, error: msg })); }
|
|
508
|
+
else {
|
|
509
|
+
console.log();
|
|
510
|
+
console.log(` ${bold("infernoflow cloud")} — hosted contract sync`);
|
|
511
|
+
console.log();
|
|
512
|
+
console.log(` ${cyan("infernoflow cloud init")} Set up cloud sync for this project`);
|
|
513
|
+
console.log(` ${cyan("infernoflow cloud push")} Upload local contract to cloud`);
|
|
514
|
+
console.log(` ${cyan("infernoflow cloud pull")} Download latest contract from cloud`);
|
|
515
|
+
console.log(` ${cyan("infernoflow cloud status")} Compare local vs cloud`);
|
|
516
|
+
console.log(` ${cyan("infernoflow cloud dashboard")} Open hosted dashboard in browser`);
|
|
517
|
+
console.log();
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* infernoflow onboard
|
|
3
|
+
*
|
|
4
|
+
* Interactive step-by-step onboarding wizard for new developers.
|
|
5
|
+
* Walks through: what infernoflow is, detecting the stack, running init,
|
|
6
|
+
* showing the first contract, explaining each file, and a test suggest.
|
|
7
|
+
*
|
|
8
|
+
* Designed for teams adding a new member — run once, understand everything.
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* infernoflow onboard
|
|
12
|
+
* infernoflow onboard --yes # non-interactive (auto-accept all steps)
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import * as fs from "node:fs";
|
|
16
|
+
import * as path from "node:path";
|
|
17
|
+
import * as readline from "node:readline";
|
|
18
|
+
import { execSync } from "node:child_process";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
20
|
+
import { header, ok, warn, info, done, bold, cyan, gray, green, yellow, red } from "../ui/output.mjs";
|
|
21
|
+
|
|
22
|
+
// ── readline helpers ──────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
function createRl() {
|
|
25
|
+
return readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function ask(rl, question) {
|
|
29
|
+
return new Promise(resolve => rl.question(question, resolve));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function confirm(rl, question, defaultYes = true) {
|
|
33
|
+
const hint = defaultYes ? "[Y/n]" : "[y/N]";
|
|
34
|
+
const answer = await ask(rl, ` ${question} ${gray(hint)} `);
|
|
35
|
+
if (!answer.trim()) return defaultYes;
|
|
36
|
+
return answer.trim().toLowerCase().startsWith("y");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ── step renderer ─────────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
function step(n, total, title) {
|
|
42
|
+
console.log();
|
|
43
|
+
console.log(` ${bold(cyan(`Step ${n}/${total}`))} ${bold(title)}`);
|
|
44
|
+
console.log(` ${gray("─".repeat(50))}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function explain(lines) {
|
|
48
|
+
for (const line of lines) {
|
|
49
|
+
console.log(` ${gray(line)}`);
|
|
50
|
+
}
|
|
51
|
+
console.log();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ── project detection ─────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
function detectStack(cwd) {
|
|
57
|
+
const has = (f) => fs.existsSync(path.join(cwd, f));
|
|
58
|
+
const pkg = has("package.json") ? JSON.parse(fs.readFileSync(path.join(cwd, "package.json"), "utf8")) : {};
|
|
59
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
60
|
+
|
|
61
|
+
const framework = deps.react ? "React"
|
|
62
|
+
: deps.next ? "Next.js"
|
|
63
|
+
: deps.vue ? "Vue"
|
|
64
|
+
: deps.angular ? "Angular"
|
|
65
|
+
: deps.express ? "Express"
|
|
66
|
+
: deps.fastify ? "Fastify"
|
|
67
|
+
: has("requirements.txt") ? "Python"
|
|
68
|
+
: has("go.mod") ? "Go"
|
|
69
|
+
: has("Cargo.toml") ? "Rust"
|
|
70
|
+
: "unknown";
|
|
71
|
+
|
|
72
|
+
const language = has("tsconfig.json") ? "TypeScript"
|
|
73
|
+
: has("package.json") ? "JavaScript"
|
|
74
|
+
: has("requirements.txt") ? "Python"
|
|
75
|
+
: has("go.mod") ? "Go"
|
|
76
|
+
: "unknown";
|
|
77
|
+
|
|
78
|
+
return { framework, language, name: pkg.name || path.basename(cwd) };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function capture(cmd, cwd) {
|
|
82
|
+
try {
|
|
83
|
+
return execSync(cmd, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
84
|
+
} catch { return null; }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── main wizard ───────────────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
export async function onboardCommand(rawArgs) {
|
|
90
|
+
const args = rawArgs.slice(1);
|
|
91
|
+
const autoYes = args.includes("--yes") || args.includes("-y");
|
|
92
|
+
const cwd = process.cwd();
|
|
93
|
+
const infernoDir = path.join(cwd, "inferno");
|
|
94
|
+
const TOTAL = 7;
|
|
95
|
+
|
|
96
|
+
const rl = autoYes ? null : createRl();
|
|
97
|
+
|
|
98
|
+
const ask_confirm = autoYes
|
|
99
|
+
? async () => true
|
|
100
|
+
: (q, def) => confirm(rl, q, def);
|
|
101
|
+
|
|
102
|
+
console.clear();
|
|
103
|
+
console.log();
|
|
104
|
+
console.log(` ${bold("🔥 Welcome to infernoflow")}`);
|
|
105
|
+
console.log(` ${gray("This wizard walks you through everything in about 5 minutes.")}`);
|
|
106
|
+
console.log(` ${gray("You'll understand what infernoflow does and how it fits your workflow.")}`);
|
|
107
|
+
console.log();
|
|
108
|
+
|
|
109
|
+
if (!autoYes) {
|
|
110
|
+
const ready = await ask_confirm("Ready to start?", true);
|
|
111
|
+
if (!ready) { console.log("\n See you next time!\n"); rl.close(); return; }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ── Step 1: What is infernoflow? ───────────────────────────────────────────
|
|
115
|
+
step(1, TOTAL, "What is infernoflow?");
|
|
116
|
+
explain([
|
|
117
|
+
"infernoflow keeps a living record of your project's capabilities",
|
|
118
|
+
"— the features your code actually provides — and makes sure that",
|
|
119
|
+
"record never drifts out of sync as you build.",
|
|
120
|
+
"",
|
|
121
|
+
"It works invisibly in the background:",
|
|
122
|
+
" • Claude auto-tracks capability changes as you code",
|
|
123
|
+
" • Git hooks update the changelog on every commit",
|
|
124
|
+
" • PRs get automatic capability drift analysis",
|
|
125
|
+
" • Version bumps are recommended automatically (major/minor/patch)",
|
|
126
|
+
"",
|
|
127
|
+
"You write code. infernoflow handles the bookkeeping.",
|
|
128
|
+
]);
|
|
129
|
+
|
|
130
|
+
await ask_confirm("Got it — continue?", true);
|
|
131
|
+
|
|
132
|
+
// ── Step 2: Detect your project ────────────────────────────────────────────
|
|
133
|
+
step(2, TOTAL, "Detecting your project");
|
|
134
|
+
const stack = detectStack(cwd);
|
|
135
|
+
const isGitRepo = !!capture("git rev-parse --git-dir", cwd);
|
|
136
|
+
const alreadySetup = fs.existsSync(infernoDir);
|
|
137
|
+
|
|
138
|
+
console.log(` Project: ${bold(stack.name)}`);
|
|
139
|
+
console.log(` Framework: ${bold(stack.framework)}`);
|
|
140
|
+
console.log(` Language: ${bold(stack.language)}`);
|
|
141
|
+
console.log(` Git repo: ${isGitRepo ? green("yes") : red("no — git init first")}`);
|
|
142
|
+
console.log(` infernoflow: ${alreadySetup ? green("already set up") : yellow("not set up yet")}`);
|
|
143
|
+
console.log();
|
|
144
|
+
|
|
145
|
+
if (!isGitRepo) {
|
|
146
|
+
warn("This directory is not a git repository.");
|
|
147
|
+
warn("Run: git init && git add . && git commit -m 'init'");
|
|
148
|
+
warn("Then run: infernoflow onboard");
|
|
149
|
+
if (rl) rl.close();
|
|
150
|
+
process.exit(1);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
await ask_confirm("Continue?", true);
|
|
154
|
+
|
|
155
|
+
// ── Step 3: Run infernoflow setup ──────────────────────────────────────────
|
|
156
|
+
step(3, TOTAL, "Setting up infernoflow");
|
|
157
|
+
if (alreadySetup) {
|
|
158
|
+
ok("infernoflow is already set up in this project");
|
|
159
|
+
explain(["The inferno/ folder exists — skipping init."]);
|
|
160
|
+
} else {
|
|
161
|
+
explain([
|
|
162
|
+
"infernoflow setup will:",
|
|
163
|
+
" 1. Scan your codebase and infer capabilities automatically",
|
|
164
|
+
" 2. Create the inferno/ folder with contract.json",
|
|
165
|
+
" 3. Install the MCP server (for Claude Code integration)",
|
|
166
|
+
" 4. Write CLAUDE.md (makes Claude auto-track capabilities)",
|
|
167
|
+
" 5. Install git hooks (auto changelog + drift check)",
|
|
168
|
+
]);
|
|
169
|
+
|
|
170
|
+
const go = await ask_confirm("Run infernoflow setup now?", true);
|
|
171
|
+
if (go) {
|
|
172
|
+
console.log();
|
|
173
|
+
try {
|
|
174
|
+
execSync("npx infernoflow setup --yes", {
|
|
175
|
+
cwd,
|
|
176
|
+
stdio: "inherit",
|
|
177
|
+
timeout: 120_000,
|
|
178
|
+
});
|
|
179
|
+
} catch (err) {
|
|
180
|
+
warn("Setup encountered an issue — you can re-run it manually:");
|
|
181
|
+
warn(" infernoflow setup");
|
|
182
|
+
}
|
|
183
|
+
} else {
|
|
184
|
+
info("Skipped — run: infernoflow setup");
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
await ask_confirm("Continue?", true);
|
|
189
|
+
|
|
190
|
+
// ── Step 4: Your capability contract ──────────────────────────────────────
|
|
191
|
+
step(4, TOTAL, "Your capability contract");
|
|
192
|
+
explain([
|
|
193
|
+
"The inferno/contract.json is the heart of infernoflow.",
|
|
194
|
+
"It lists every capability your project has — the things users can DO.",
|
|
195
|
+
"",
|
|
196
|
+
"Think of it as a living API contract, but for features, not endpoints.",
|
|
197
|
+
]);
|
|
198
|
+
|
|
199
|
+
const contractPath = path.join(infernoDir, "contract.json");
|
|
200
|
+
if (fs.existsSync(contractPath)) {
|
|
201
|
+
try {
|
|
202
|
+
const contract = JSON.parse(fs.readFileSync(contractPath, "utf8"));
|
|
203
|
+
const caps = contract.capabilities || [];
|
|
204
|
+
console.log(` ${bold(String(caps.length))} capabilities tracked:\n`);
|
|
205
|
+
for (const c of caps.slice(0, 8)) {
|
|
206
|
+
const cap = typeof c === "string" ? { id: c, title: c } : c;
|
|
207
|
+
console.log(` ${green("✔")} ${bold(cap.id)} ${gray(cap.title || "")}`);
|
|
208
|
+
}
|
|
209
|
+
if (caps.length > 8) console.log(` ${gray(`… and ${caps.length - 8} more`)}`);
|
|
210
|
+
console.log();
|
|
211
|
+
} catch {
|
|
212
|
+
warn("Could not read contract.json");
|
|
213
|
+
}
|
|
214
|
+
} else {
|
|
215
|
+
warn("No contract.json yet — run: infernoflow setup");
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
await ask_confirm("Continue?", true);
|
|
219
|
+
|
|
220
|
+
// ── Step 5: Your daily workflow ────────────────────────────────────────────
|
|
221
|
+
step(5, TOTAL, "Your daily workflow");
|
|
222
|
+
explain([
|
|
223
|
+
"Here's exactly how infernoflow fits into your git workflow:",
|
|
224
|
+
"",
|
|
225
|
+
" 1. git checkout -b feature/my-feature",
|
|
226
|
+
" (branch from main as usual)",
|
|
227
|
+
"",
|
|
228
|
+
" 2. Write code in Claude / Cursor / VS Code",
|
|
229
|
+
" infernoflow tracks capability changes automatically via CLAUDE.md",
|
|
230
|
+
"",
|
|
231
|
+
" 3. git commit -m 'add my feature'",
|
|
232
|
+
" post-commit hook silently updates the changelog",
|
|
233
|
+
"",
|
|
234
|
+
" 4. git push && open PR",
|
|
235
|
+
" GitHub Actions posts a capability drift analysis comment on the PR",
|
|
236
|
+
" pre-push hook warns if drift is HIGH",
|
|
237
|
+
"",
|
|
238
|
+
" 5. infernoflow version",
|
|
239
|
+
" see what semver bump is recommended (major/minor/patch)",
|
|
240
|
+
"",
|
|
241
|
+
"You never run infernoflow manually in day-to-day work.",
|
|
242
|
+
"It runs itself.",
|
|
243
|
+
]);
|
|
244
|
+
|
|
245
|
+
await ask_confirm("Got it — continue?", true);
|
|
246
|
+
|
|
247
|
+
// ── Step 6: Key commands to know ──────────────────────────────────────────
|
|
248
|
+
step(6, TOTAL, "Key commands");
|
|
249
|
+
const commands = [
|
|
250
|
+
["infernoflow status", "Quick health check of the contract"],
|
|
251
|
+
["infernoflow diff", "What changed since the last release"],
|
|
252
|
+
["infernoflow version", "What semver bump to use"],
|
|
253
|
+
["infernoflow version --apply", "Write the bump to package.json"],
|
|
254
|
+
["infernoflow changelog ai", "Generate a human-readable changelog"],
|
|
255
|
+
["infernoflow dashboard", "Open the live web dashboard"],
|
|
256
|
+
["infernoflow team-sync status","See if your team is in sync"],
|
|
257
|
+
["infernoflow suggest 'what I built'", "Manually update the contract"],
|
|
258
|
+
];
|
|
259
|
+
|
|
260
|
+
const maxLen = Math.max(...commands.map(([c]) => c.length));
|
|
261
|
+
for (const [cmd, desc] of commands) {
|
|
262
|
+
console.log(` ${cyan(cmd.padEnd(maxLen + 2))}${gray(desc)}`);
|
|
263
|
+
}
|
|
264
|
+
console.log();
|
|
265
|
+
|
|
266
|
+
await ask_confirm("Continue?", true);
|
|
267
|
+
|
|
268
|
+
// ── Step 7: Live test ─────────────────────────────────────────────────────
|
|
269
|
+
step(7, TOTAL, "Quick live test");
|
|
270
|
+
explain(["Let's run infernoflow status to confirm everything is working."]);
|
|
271
|
+
|
|
272
|
+
const runTest = await ask_confirm("Run infernoflow status now?", true);
|
|
273
|
+
if (runTest) {
|
|
274
|
+
console.log();
|
|
275
|
+
try {
|
|
276
|
+
execSync("npx infernoflow status", { cwd, stdio: "inherit", timeout: 30_000 });
|
|
277
|
+
} catch {
|
|
278
|
+
warn("Status check failed — try: infernoflow setup");
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ── Done ──────────────────────────────────────────────────────────────────
|
|
283
|
+
console.log();
|
|
284
|
+
console.log(` ${bold("🎉 You're all set!")}`);
|
|
285
|
+
console.log();
|
|
286
|
+
console.log(` ${green("✔")} infernoflow is installed and running`);
|
|
287
|
+
console.log(` ${green("✔")} Claude will auto-track capabilities as you code`);
|
|
288
|
+
console.log(` ${green("✔")} Git hooks handle changelog and drift automatically`);
|
|
289
|
+
console.log(` ${green("✔")} PRs will get automatic capability analysis`);
|
|
290
|
+
console.log();
|
|
291
|
+
console.log(` ${gray("Share this with teammates:")} ${cyan("infernoflow onboard")}`);
|
|
292
|
+
console.log(` ${gray("Questions?")} ${cyan("infernoflow --help")}`);
|
|
293
|
+
console.log();
|
|
294
|
+
|
|
295
|
+
if (rl) rl.close();
|
|
296
|
+
}
|