infernoflow 0.18.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,427 @@
1
+ /**
2
+ * infernoflow monorepo
3
+ *
4
+ * Monorepo-aware capability tracking. Detects packages in a monorepo
5
+ * (nx, turborepo, pnpm workspaces, yarn workspaces, Lerna) and manages
6
+ * per-package contracts.
7
+ *
8
+ * Sub-commands:
9
+ * monorepo init Detect packages, scaffold inferno/ per package
10
+ * monorepo list List all detected packages + contract status
11
+ * monorepo status Show health across all packages at once
12
+ * monorepo diff [--package] Diff capabilities for one or all packages
13
+ * monorepo sync Sync all package contracts to root summary
14
+ *
15
+ * Usage:
16
+ * infernoflow monorepo init
17
+ * infernoflow monorepo list
18
+ * infernoflow monorepo status
19
+ * infernoflow monorepo diff --package auth
20
+ * infernoflow monorepo sync
21
+ * infernoflow monorepo status --json
22
+ */
23
+
24
+ import * as fs from "node:fs";
25
+ import * as path from "node:path";
26
+ import { spawnSync } from "node:child_process";
27
+ import { header, ok, warn, info, done, bold, cyan, gray, green, yellow, red } from "../ui/output.mjs";
28
+
29
+ // ── Package detection ─────────────────────────────────────────────────────────
30
+
31
+ function detectWorkspaceType(cwd) {
32
+ const has = (f) => fs.existsSync(path.join(cwd, f));
33
+
34
+ if (has("nx.json")) return "nx";
35
+ if (has("turbo.json")) return "turborepo";
36
+ if (has("lerna.json")) return "lerna";
37
+ if (has("pnpm-workspace.yaml")) return "pnpm";
38
+
39
+ const pkg = (() => {
40
+ try { return JSON.parse(fs.readFileSync(path.join(cwd, "package.json"), "utf8")); }
41
+ catch { return {}; }
42
+ })();
43
+
44
+ if (pkg.workspaces) return Array.isArray(pkg.workspaces) ? "yarn" : "npm-workspaces";
45
+ return null;
46
+ }
47
+
48
+ function readWorkspaceGlobs(cwd, wsType) {
49
+ try {
50
+ if (wsType === "pnpm") {
51
+ const raw = fs.readFileSync(path.join(cwd, "pnpm-workspace.yaml"), "utf8");
52
+ const matches = [...raw.matchAll(/^\s*-\s*['"]?([^'"]+)['"]?/gm)];
53
+ return matches.map(m => m[1].trim());
54
+ }
55
+ if (wsType === "lerna") {
56
+ const cfg = JSON.parse(fs.readFileSync(path.join(cwd, "lerna.json"), "utf8"));
57
+ return cfg.packages || ["packages/*"];
58
+ }
59
+ const pkg = JSON.parse(fs.readFileSync(path.join(cwd, "package.json"), "utf8"));
60
+ const ws = pkg.workspaces;
61
+ if (Array.isArray(ws)) return ws;
62
+ if (ws?.packages) return ws.packages;
63
+ } catch {}
64
+ return ["packages/*", "apps/*", "libs/*"];
65
+ }
66
+
67
+ function globToPackages(cwd, globs) {
68
+ const packages = [];
69
+ for (const pattern of globs) {
70
+ // Simple glob: handle "packages/*" and "apps/*" patterns
71
+ const parts = pattern.split("/");
72
+ const parent = parts.slice(0, -1).join("/");
73
+ const leaf = parts[parts.length - 1];
74
+ const dir = path.join(cwd, parent);
75
+
76
+ if (!fs.existsSync(dir)) continue;
77
+ if (leaf === "*") {
78
+ try {
79
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
80
+ if (entry.isDirectory()) {
81
+ const pkgPath = path.join(dir, entry.name, "package.json");
82
+ if (fs.existsSync(pkgPath)) {
83
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
84
+ packages.push({
85
+ name: pkg.name || entry.name,
86
+ dir: path.join(dir, entry.name),
87
+ version: pkg.version || "0.0.0",
88
+ });
89
+ }
90
+ }
91
+ }
92
+ } catch {}
93
+ } else {
94
+ const fullDir = path.join(cwd, pattern);
95
+ const pkgPath = path.join(fullDir, "package.json");
96
+ if (fs.existsSync(pkgPath)) {
97
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
98
+ packages.push({ name: pkg.name || leaf, dir: fullDir, version: pkg.version || "0.0.0" });
99
+ }
100
+ }
101
+ }
102
+ return packages;
103
+ }
104
+
105
+ function detectPackages(cwd) {
106
+ const wsType = detectWorkspaceType(cwd);
107
+ if (!wsType) return { type: null, packages: [] };
108
+ const globs = readWorkspaceGlobs(cwd, wsType);
109
+ const packages = globToPackages(cwd, globs);
110
+ return { type: wsType, packages };
111
+ }
112
+
113
+ // ── Contract helpers ──────────────────────────────────────────────────────────
114
+
115
+ function readPackageContract(pkgDir) {
116
+ for (const f of ["contract.json", "capabilities.json"]) {
117
+ const p = path.join(pkgDir, "inferno", f);
118
+ if (fs.existsSync(p)) { try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch {} }
119
+ }
120
+ return null;
121
+ }
122
+
123
+ function hasInferno(pkgDir) {
124
+ return fs.existsSync(path.join(pkgDir, "inferno"));
125
+ }
126
+
127
+ function contractStatus(pkgDir) {
128
+ if (!hasInferno(pkgDir)) return "not-init";
129
+ const contract = readPackageContract(pkgDir);
130
+ if (!contract) return "no-contract";
131
+ return "ok";
132
+ }
133
+
134
+ // ── CLI runner ────────────────────────────────────────────────────────────────
135
+
136
+ function runInferno(args, cwd) {
137
+ const binPath = path.join(path.dirname(path.dirname(new URL(import.meta.url).pathname)), "..", "bin", "infernoflow.mjs");
138
+ const result = spawnSync(process.execPath, [binPath, ...args], {
139
+ cwd,
140
+ encoding: "utf8",
141
+ timeout: 60_000,
142
+ env: { ...process.env, NO_COLOR: "1" },
143
+ });
144
+ return { stdout: result.stdout || "", stderr: result.stderr || "", status: result.status };
145
+ }
146
+
147
+ // ── Sub-commands ──────────────────────────────────────────────────────────────
148
+
149
+ async function subcmdInit(args, cwd) {
150
+ const jsonMode = args.includes("--json");
151
+ const force = args.includes("--force") || args.includes("-f");
152
+ const autoYes = args.includes("--yes") || args.includes("-y");
153
+
154
+ const { type, packages } = detectPackages(cwd);
155
+
156
+ if (!type) {
157
+ const msg = "No monorepo configuration detected. Supported: nx, turborepo, pnpm workspaces, yarn workspaces, lerna.";
158
+ if (jsonMode) { console.log(JSON.stringify({ ok: false, error: msg })); } else { warn(msg); }
159
+ process.exit(1);
160
+ }
161
+
162
+ if (!jsonMode) {
163
+ header(`Monorepo init (${type})`);
164
+ console.log(` Detected ${bold(String(packages.length))} packages:\n`);
165
+ packages.forEach(p => {
166
+ const status = contractStatus(p.dir);
167
+ const icon = status === "ok" ? green("✔") : yellow("·");
168
+ console.log(` ${icon} ${bold(p.name)} ${gray(path.relative(cwd, p.dir))}`);
169
+ });
170
+ console.log();
171
+ }
172
+
173
+ if (packages.length === 0) {
174
+ if (jsonMode) { console.log(JSON.stringify({ ok: false, error: "No packages found" })); }
175
+ else { warn("No packages found matching workspace globs."); }
176
+ process.exit(1);
177
+ }
178
+
179
+ const results = [];
180
+ for (const pkg of packages) {
181
+ const status = contractStatus(pkg.dir);
182
+ if (status === "ok" && !force) {
183
+ if (!jsonMode) info(`${pkg.name}: already initialised (use --force to reinit)`);
184
+ results.push({ name: pkg.name, status: "skipped" });
185
+ continue;
186
+ }
187
+
188
+ if (!jsonMode) process.stdout.write(` Initialising ${cyan(pkg.name)}… `);
189
+
190
+ const initArgs = ["init", "--adopt", "--yes"];
191
+ if (force) initArgs.push("--force");
192
+ const r = runInferno(initArgs, pkg.dir);
193
+
194
+ if (r.status === 0) {
195
+ if (!jsonMode) console.log(green("done"));
196
+ results.push({ name: pkg.name, status: "ok" });
197
+ } else {
198
+ if (!jsonMode) console.log(red("failed"));
199
+ results.push({ name: pkg.name, status: "error", error: r.stderr.trim().slice(0, 120) });
200
+ }
201
+ }
202
+
203
+ // Write root summary
204
+ const summary = {
205
+ monorepoType: type,
206
+ packages: results,
207
+ updatedAt: new Date().toISOString(),
208
+ };
209
+ fs.writeFileSync(path.join(cwd, "inferno-monorepo.json"), JSON.stringify(summary, null, 2) + "\n");
210
+
211
+ if (jsonMode) {
212
+ console.log(JSON.stringify({ ok: true, type, packages: results }));
213
+ } else {
214
+ console.log();
215
+ const succeeded = results.filter(r => r.status === "ok").length;
216
+ done(`Initialised ${bold(String(succeeded))} of ${results.length} packages`);
217
+ console.log(` ${gray("Root summary:")} ${cyan("inferno-monorepo.json")}`);
218
+ console.log();
219
+ }
220
+ }
221
+
222
+ async function subcmdList(args, cwd) {
223
+ const jsonMode = args.includes("--json");
224
+ const { type, packages } = detectPackages(cwd);
225
+
226
+ if (!type && packages.length === 0) {
227
+ if (jsonMode) { console.log(JSON.stringify({ ok: false, error: "No monorepo detected" })); }
228
+ else { warn("No monorepo detected. Run: infernoflow monorepo init"); }
229
+ return;
230
+ }
231
+
232
+ const rows = packages.map(p => ({
233
+ name: p.name,
234
+ dir: path.relative(cwd, p.dir),
235
+ version: p.version,
236
+ status: contractStatus(p.dir),
237
+ caps: (() => {
238
+ const c = readPackageContract(p.dir);
239
+ return c ? (c.capabilities || []).length : 0;
240
+ })(),
241
+ }));
242
+
243
+ if (jsonMode) {
244
+ console.log(JSON.stringify({ ok: true, type, packages: rows }));
245
+ return;
246
+ }
247
+
248
+ console.log();
249
+ console.log(` ${bold("Monorepo packages")} ${gray("(" + type + ")")}`);
250
+ console.log();
251
+ const w = Math.max(...rows.map(r => r.name.length), 8) + 2;
252
+ rows.forEach(r => {
253
+ const icon = r.status === "ok" ? green("✔") : r.status === "not-init" ? yellow("○") : red("✗");
254
+ const caps = r.status === "ok" ? gray(`${r.caps} caps`) : gray(r.status);
255
+ console.log(` ${icon} ${r.name.padEnd(w)}${r.version.padEnd(12)}${caps}`);
256
+ });
257
+ console.log();
258
+ }
259
+
260
+ async function subcmdStatus(args, cwd) {
261
+ const jsonMode = args.includes("--json");
262
+ const { type, packages } = detectPackages(cwd);
263
+
264
+ if (!packages.length) {
265
+ if (jsonMode) { console.log(JSON.stringify({ ok: false, error: "No packages found" })); }
266
+ else { warn("No packages found."); }
267
+ return;
268
+ }
269
+
270
+ if (!jsonMode) header(`Monorepo status (${packages.length} packages)`);
271
+
272
+ const results = [];
273
+ for (const pkg of packages) {
274
+ if (!hasInferno(pkg.dir)) {
275
+ results.push({ name: pkg.name, status: "not-init", caps: 0 });
276
+ if (!jsonMode) console.log(` ${yellow("○")} ${bold(pkg.name)} ${gray("not initialised")}`);
277
+ continue;
278
+ }
279
+ const r = runInferno(["status", "--json"], pkg.dir);
280
+ try {
281
+ const data = JSON.parse(r.stdout.trim());
282
+ const caps = (data.capabilityDetails || []).length;
283
+ const ok_ = data.ok !== false;
284
+ results.push({ name: pkg.name, status: ok_ ? "ok" : "error", caps, version: data.policyVersion });
285
+ if (!jsonMode) {
286
+ const icon = ok_ ? green("✔") : red("✗");
287
+ console.log(` ${icon} ${bold(pkg.name.padEnd(28))}${gray("v" + (data.policyVersion || "?"))} ${caps} caps`);
288
+ }
289
+ } catch {
290
+ results.push({ name: pkg.name, status: "error", caps: 0, error: "status failed" });
291
+ if (!jsonMode) console.log(` ${red("✗")} ${bold(pkg.name)} ${gray("status check failed")}`);
292
+ }
293
+ }
294
+
295
+ if (jsonMode) {
296
+ const allOk = results.every(r => r.status === "ok");
297
+ console.log(JSON.stringify({ ok: allOk, type, packages: results }));
298
+ } else {
299
+ console.log();
300
+ const ok_ = results.filter(r => r.status === "ok").length;
301
+ console.log(` ${ok_ === results.length ? green("✔") : yellow("⚠")} ${ok_}/${results.length} packages healthy`);
302
+ console.log();
303
+ }
304
+ }
305
+
306
+ async function subcmdDiff(args, cwd) {
307
+ const jsonMode = args.includes("--json");
308
+ const pkgFilter = args.includes("--package") ? args[args.indexOf("--package") + 1] : null;
309
+ const { packages } = detectPackages(cwd);
310
+
311
+ const targets = pkgFilter
312
+ ? packages.filter(p => p.name === pkgFilter || p.name.endsWith("/" + pkgFilter))
313
+ : packages.filter(p => hasInferno(p.dir));
314
+
315
+ if (!targets.length) {
316
+ if (jsonMode) { console.log(JSON.stringify({ ok: false, error: pkgFilter ? `Package not found: ${pkgFilter}` : "No initialised packages found" })); }
317
+ else { warn(pkgFilter ? `Package not found: ${pkgFilter}` : "No initialised packages found."); }
318
+ return;
319
+ }
320
+
321
+ if (!jsonMode && !pkgFilter) header(`Monorepo diff (${targets.length} packages)`);
322
+
323
+ const allResults = [];
324
+ for (const pkg of targets) {
325
+ const r = runInferno(["diff", "--json"], pkg.dir);
326
+ try {
327
+ const data = JSON.parse(r.stdout.trim());
328
+ const added = (data.added || []).length;
329
+ const removed = (data.removed || []).length;
330
+ const changed = (data.changed || []).length;
331
+ allResults.push({ name: pkg.name, added, removed, changed, data });
332
+ if (!jsonMode) {
333
+ if (added || removed || changed) {
334
+ console.log(` ${bold(pkg.name)}`);
335
+ if (added) console.log(` ${green("+")} ${added} added`);
336
+ if (removed) console.log(` ${red("-")} ${removed} removed`);
337
+ if (changed) console.log(` ${yellow("~")} ${changed} changed`);
338
+ } else {
339
+ console.log(` ${green("✔")} ${bold(pkg.name)} ${gray("no changes")}`);
340
+ }
341
+ }
342
+ } catch {
343
+ allResults.push({ name: pkg.name, error: "diff failed" });
344
+ if (!jsonMode) console.log(` ${red("✗")} ${bold(pkg.name)} ${gray("diff failed")}`);
345
+ }
346
+ }
347
+
348
+ if (jsonMode) {
349
+ console.log(JSON.stringify({ ok: true, packages: allResults }));
350
+ } else {
351
+ console.log();
352
+ }
353
+ }
354
+
355
+ async function subcmdSync(args, cwd) {
356
+ const jsonMode = args.includes("--json");
357
+ const { type, packages } = detectPackages(cwd);
358
+
359
+ if (!jsonMode) header("Syncing monorepo contracts");
360
+
361
+ // Build a root aggregate contract
362
+ const aggregate = {
363
+ monorepoType: type,
364
+ updatedAt: new Date().toISOString(),
365
+ packages: [],
366
+ };
367
+
368
+ for (const pkg of packages) {
369
+ const contract = readPackageContract(pkg.dir);
370
+ if (!contract) continue;
371
+ aggregate.packages.push({
372
+ name: pkg.name,
373
+ version: contract.policyVersion || pkg.version,
374
+ capabilities: (contract.capabilities || []).map(c => typeof c === "string" ? c : c.id),
375
+ capsCount: (contract.capabilities || []).length,
376
+ });
377
+ if (!jsonMode) console.log(` ${green("✔")} ${bold(pkg.name)} ${gray((contract.capabilities || []).length + " caps")}`);
378
+ }
379
+
380
+ const outPath = path.join(cwd, "inferno-monorepo.json");
381
+ fs.writeFileSync(outPath, JSON.stringify(aggregate, null, 2) + "\n");
382
+
383
+ const totalCaps = aggregate.packages.reduce((sum, p) => sum + p.capsCount, 0);
384
+
385
+ if (jsonMode) {
386
+ console.log(JSON.stringify({ ok: true, packages: aggregate.packages.length, totalCaps }));
387
+ } else {
388
+ console.log();
389
+ done(`Synced ${bold(String(aggregate.packages.length))} packages (${totalCaps} total capabilities)`);
390
+ console.log(` ${cyan(outPath)}`);
391
+ console.log();
392
+ }
393
+ }
394
+
395
+ // ── Entry ─────────────────────────────────────────────────────────────────────
396
+
397
+ export async function monorepoCommand(rawArgs) {
398
+ const args = rawArgs.slice(1);
399
+ const subcmd = args[0];
400
+ const cwd = process.cwd();
401
+ const rest = args.slice(1);
402
+
403
+ switch (subcmd) {
404
+ case "init": return subcmdInit(rest, cwd);
405
+ case "list": return subcmdList(rest, cwd);
406
+ case "status": return subcmdStatus(rest, cwd);
407
+ case "diff": return subcmdDiff(rest, cwd);
408
+ case "sync": return subcmdSync(rest, cwd);
409
+ default: {
410
+ const jsonMode = args.includes("--json");
411
+ const msg = `Unknown monorepo sub-command: ${subcmd || "(none)"}`;
412
+ if (jsonMode) { console.log(JSON.stringify({ ok: false, error: msg })); return; }
413
+ console.log();
414
+ console.log(` ${bold("infernoflow monorepo")} — per-package capability tracking`);
415
+ console.log();
416
+ console.log(` ${cyan("infernoflow monorepo init")} Scaffold inferno/ in each package`);
417
+ console.log(` ${cyan("infernoflow monorepo list")} List packages and contract status`);
418
+ console.log(` ${cyan("infernoflow monorepo status")} Health check across all packages`);
419
+ console.log(` ${cyan("infernoflow monorepo diff")} Capability diff for all packages`);
420
+ console.log(` ${cyan("infernoflow monorepo diff --package auth")} Diff a specific package`);
421
+ console.log(` ${cyan("infernoflow monorepo sync")} Aggregate all contracts to inferno-monorepo.json`);
422
+ console.log();
423
+ console.log(` ${gray("Supported: nx, turborepo, pnpm workspaces, yarn workspaces, lerna")}`);
424
+ console.log();
425
+ }
426
+ }
427
+ }