rahman-resources 1.5.1 → 1.7.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/bin/cli.js CHANGED
@@ -34,6 +34,7 @@ import { runGraph } from "./graph.mjs";
34
34
  import { runCompose, preflight as composePreflight } from "./compose.mjs";
35
35
  import { runUpdate as runUpdate3Way } from "./update.mjs";
36
36
  import { runMigrate } from "./migrate.mjs";
37
+ import { augmentConsumerEnv } from "../lib/env-augment.mjs";
37
38
 
38
39
  const require = createRequire(import.meta.url);
39
40
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -141,9 +142,10 @@ ${kleur.bold("Init flags:")}
141
142
  (heavy; ~50 components — use only if you'll customize beyond the template)
142
143
 
143
144
  ${kleur.bold("Add flags:")}
144
- --at root install template AT app/(public)/ + app/admin/ (recommended; rewrites
145
+ --at root install template AT app/(public)/ + app/admin/ (default rewrites
145
146
  /preview/<slug> path constants in nav-config/robots/sitemap)
146
- --at preview install template AT app/preview/<slug>/ (default sandbox style)
147
+ --at preview install template AT app/preview/<slug>/ (sandbox; keeps hardcoded
148
+ /preview/<slug> URLs — only useful for parallel demo of multiple templates)
147
149
  --with-shadcn-all same as init flag
148
150
  --strict enforce strict compose pre-flight (uncontracted/env-missing → blocker)
149
151
  --force skip compose pre-flight entirely
@@ -516,7 +518,22 @@ async function runAdd(rest) {
516
518
  console.log(kleur.yellow(` ⚠ --force: skipping compose pre-flight.`));
517
519
  }
518
520
 
519
- if (kind === "slice") return runLift([`rahman:${entry.slug}`, ...(targetArg !== "." ? ["--target", targetArg] : [])]);
521
+ if (kind === "slice") {
522
+ console.log(
523
+ kleur.bold(`\n→ Adding slice ${kleur.cyan(entry.slug)} `) +
524
+ kleur.dim(`[SLICE — drop-in feature]`) +
525
+ kleur.bold(` to ${kleur.dim(target)}\n`),
526
+ );
527
+ await runLift([`rahman:${entry.slug}`, ...(targetArg !== "." ? ["--target", targetArg] : [])]);
528
+ // Augment consumer .env.example with this slice's env requirements.
529
+ // Idempotent — re-running `add` does not duplicate entries.
530
+ try {
531
+ augmentConsumerEnv(entry, target);
532
+ } catch (err) {
533
+ console.error(kleur.yellow(` ⚠ env augment skipped: ${err.message ?? err}`));
534
+ }
535
+ return;
536
+ }
520
537
  if (kind === "layout") return addLayout(entry, target, targetArg, flags);
521
538
  if (kind === "feature") return addFeature(entry, target, targetArg);
522
539
  if (kind === "recipe") return addRecipe(entry);
@@ -558,14 +575,26 @@ async function runUpdate(rest) {
558
575
  }
559
576
 
560
577
  async function addLayout(t, target, targetArg, flags = {}) {
561
- console.log(kleur.bold(`\n→ Installing ${kleur.cyan(t.title)} into ${kleur.dim(target)}\n`));
578
+ console.log(
579
+ kleur.bold(`\n→ Installing ${kleur.cyan(t.title)} `) +
580
+ kleur.dim(`[TEMPLATE — full-app scaffold]`) +
581
+ kleur.bold(` into ${kleur.dim(target)}\n`),
582
+ );
562
583
  if (!t.pullPaths || t.pullPaths.length === 0) {
563
584
  throw new Error(`Layout "${t.slug}" has no valid pullPaths in manifest.`);
564
585
  }
565
- const at = typeof flags.at === "string" ? flags.at : "preview";
586
+ const at = typeof flags.at === "string" ? flags.at : "root";
566
587
  if (!["root", "preview"].includes(at)) {
567
588
  throw new Error(`--at must be "root" or "preview" (got "${at}").`);
568
589
  }
590
+ if (at === "preview") {
591
+ console.log(
592
+ kleur.yellow(
593
+ ` ⚠ --at preview: routes stay under /preview/${t.slug}/* (sandbox).\n` +
594
+ ` For a production-ready install, omit --at or pass --at root.\n`,
595
+ ),
596
+ );
597
+ }
569
598
 
570
599
  for (const p of t.pullPaths) {
571
600
  const dest = path.join(target, p);
@@ -756,7 +785,11 @@ function rewritePreviewPaths(target, slug) {
756
785
  }
757
786
 
758
787
  async function addFeature(t, target, targetArg) {
759
- console.log(kleur.bold(`\n→ Adding feature ${kleur.cyan(t.title)} to ${kleur.dim(target)}\n`));
788
+ console.log(
789
+ kleur.bold(`\n→ Adding feature ${kleur.cyan(t.title)} `) +
790
+ kleur.dim(`[SLICE — drop-in feature]`) +
791
+ kleur.bold(` to ${kleur.dim(target)}\n`),
792
+ );
760
793
  if (!t.npmPackages || t.npmPackages.length === 0) {
761
794
  console.log(kleur.dim(` No npm packages to install (${t.install}).`));
762
795
  } else {
@@ -0,0 +1,83 @@
1
+ // compose-print.mjs — human ASCII renderer for `rr compose`.
2
+ //
3
+ // Extracted from compose.mjs.
4
+
5
+ import path from "node:path";
6
+ import kleur from "kleur";
7
+
8
+ import { hasBlocker } from "./compose-state.mjs";
9
+
10
+ export function printHuman(result, { rrPath, repoRoot }) {
11
+ const blocker = hasBlocker(result);
12
+ const head = blocker
13
+ ? kleur.red("✖ compose blocked")
14
+ : kleur.green("✓ compose ok");
15
+ console.log(
16
+ `\n${head} ${kleur.dim(`rr.json: ${path.relative(repoRoot, rrPath) || rrPath}`)}\n`,
17
+ );
18
+
19
+ console.log(kleur.bold("Proof"));
20
+ if (result.proof.length === 0) {
21
+ console.log(" " + kleur.dim("(no decisions — empty desired set)"));
22
+ } else {
23
+ for (const line of result.proof) {
24
+ const colored = line.startsWith("+ ")
25
+ ? kleur.green(line)
26
+ : line.startsWith("- ")
27
+ ? kleur.red(line)
28
+ : line;
29
+ console.log(" " + colored);
30
+ }
31
+ }
32
+
33
+ if (result.accepted.length > 0) {
34
+ console.log(`\n${kleur.bold("Accepted")} ${kleur.dim(`(${result.accepted.length})`)}`);
35
+ for (const s of result.accepted) console.log(" " + kleur.cyan(s));
36
+ }
37
+
38
+ if (result.rejected.length > 0) {
39
+ console.log(`\n${kleur.bold("Rejected")} ${kleur.dim(`(${result.rejected.length})`)}`);
40
+ for (const r of result.rejected) {
41
+ console.log(" " + kleur.red(r.slug));
42
+ for (const reason of r.reasons) {
43
+ console.log(
44
+ " " +
45
+ kleur.dim(`[${reason.type}]`) +
46
+ " " +
47
+ reason.detail,
48
+ );
49
+ }
50
+ }
51
+ }
52
+
53
+ const warnings = result.conflicts.filter((c) => c.severity === "warning");
54
+ if (warnings.length > 0) {
55
+ console.log(`\n${kleur.bold("Warnings")} ${kleur.dim(`(${warnings.length})`)}`);
56
+ for (const w of warnings) {
57
+ console.log(" " + kleur.yellow(`[${w.type}]`) + " " + w.detail);
58
+ }
59
+ }
60
+
61
+ if (result.envMissing.length > 0) {
62
+ console.log(`\n${kleur.bold("Env needed")}`);
63
+ for (const e of result.envMissing) console.log(" " + kleur.yellow(e));
64
+ }
65
+
66
+ if (result.rbacToCreate.length > 0) {
67
+ console.log(`\n${kleur.bold("RBAC to create")}`);
68
+ for (const p of result.rbacToCreate) console.log(" " + kleur.yellow(p));
69
+ }
70
+
71
+ if (result.tablesAdded.length > 0) {
72
+ console.log(`\n${kleur.bold("Tables added")}`);
73
+ for (const t of result.tablesAdded) {
74
+ console.log(
75
+ " " + kleur.cyan(t.slug) + ": " + kleur.dim(t.tables.join(", ")),
76
+ );
77
+ }
78
+ }
79
+
80
+ console.log(
81
+ `\n${kleur.dim("appendix: pass --json for machine-readable output")}\n`,
82
+ );
83
+ }
@@ -0,0 +1,105 @@
1
+ // compose-state.mjs — rr.json reader + strict-mode helper + repo-root finder.
2
+ //
3
+ // Extracted from compose.mjs.
4
+
5
+ import { existsSync, readFileSync } from "node:fs";
6
+ import path from "node:path";
7
+
8
+ /**
9
+ * Walk up from `start` looking for the directory that contains a `packages/`
10
+ * folder + `package.json`. Falls back to cwd.
11
+ */
12
+ export function findRepoRoot(start) {
13
+ let dir = start;
14
+ for (let i = 0; i < 8; i++) {
15
+ if (
16
+ existsSync(path.join(dir, "packages")) &&
17
+ existsSync(path.join(dir, "package.json"))
18
+ ) {
19
+ return dir;
20
+ }
21
+ const parent = path.dirname(dir);
22
+ if (parent === dir) break;
23
+ dir = parent;
24
+ }
25
+ return process.cwd();
26
+ }
27
+
28
+ /**
29
+ * Parse rr.json (if present) into a {@link RrJsonState}. Tolerates a
30
+ * missing file by returning an empty state.
31
+ *
32
+ * Maps `rr.json#/auth.provider` to the solver's `auth` enum:
33
+ * "convex-auth" → "convex"
34
+ * "clerk" / "next-auth" / "none" → passthrough
35
+ */
36
+ export function readStateFromRr(rrPath) {
37
+ if (!existsSync(rrPath)) return {};
38
+ let raw;
39
+ try {
40
+ raw = JSON.parse(readFileSync(rrPath, "utf8"));
41
+ } catch {
42
+ return {};
43
+ }
44
+ const provider = raw?.auth?.provider;
45
+ const authMap = {
46
+ "convex-auth": "convex",
47
+ clerk: "clerk",
48
+ "next-auth": "next-auth",
49
+ none: "none",
50
+ };
51
+ const auth = provider ? authMap[provider] ?? undefined : undefined;
52
+
53
+ const slicesInstalled = [
54
+ ...(raw?.slices ?? []).map((s) => s.slug).filter(Boolean),
55
+ ...(raw?.features ?? []).map((f) => f.slug).filter(Boolean),
56
+ ];
57
+
58
+ return {
59
+ auth,
60
+ slicesInstalled,
61
+ envExisting: Array.isArray(raw?.envExisting) ? raw.envExisting : [],
62
+ rbacRolesExisting: Array.isArray(raw?.rbacRolesExisting)
63
+ ? raw.rbacRolesExisting
64
+ : [],
65
+ convexTablesExisting: Array.isArray(raw?.convexTablesExisting)
66
+ ? raw.convexTablesExisting
67
+ : [],
68
+ };
69
+ }
70
+
71
+ export function hasBlocker(result) {
72
+ return result.conflicts.some((c) => c.severity === "blocker");
73
+ }
74
+
75
+ /**
76
+ * Elevate every `warning`-level conflict to `blocker`, then re-derive the
77
+ * accepted / rejected sets so the slice that triggered any warning gets
78
+ * rejected. Used for `--strict` / CI gating.
79
+ *
80
+ * @param {import("../lib/compose-solver").ComposeResult} result
81
+ * @returns {import("../lib/compose-solver").ComposeResult}
82
+ */
83
+ export function applyStrictMode(result) {
84
+ const conflicts = result.conflicts.map((c) =>
85
+ c.severity === "warning" ? { ...c, severity: "blocker" } : c,
86
+ );
87
+ const blockersBySlug = new Map();
88
+ for (const c of conflicts) {
89
+ if (c.severity !== "blocker") continue;
90
+ const cur = blockersBySlug.get(c.slug) ?? [];
91
+ cur.push(c);
92
+ blockersBySlug.set(c.slug, cur);
93
+ }
94
+ const newRejected = [...result.rejected];
95
+ const newAccepted = [];
96
+ for (const slug of result.accepted) {
97
+ const bl = blockersBySlug.get(slug);
98
+ if (bl && bl.length > 0) {
99
+ newRejected.push({ slug, reasons: bl, note: "strict-mode" });
100
+ } else {
101
+ newAccepted.push(slug);
102
+ }
103
+ }
104
+ return { ...result, conflicts, accepted: newAccepted, rejected: newRejected };
105
+ }
package/bin/compose.mjs CHANGED
@@ -7,14 +7,24 @@
7
7
  //
8
8
  // Dispatched from bin/cli.js. Imports the pure solver from
9
9
  // ../lib/compose-solver.mjs and only handles I/O + presentation.
10
+ //
11
+ // Module split (kept ≤200 LOC each):
12
+ // - compose-state.mjs — rr.json reader + strict-mode + repo-root finder
13
+ // - compose-print.mjs — ASCII renderer for the human output mode
10
14
 
11
- import { existsSync, readFileSync } from "node:fs";
12
15
  import path from "node:path";
13
16
  import { fileURLToPath } from "node:url";
14
17
 
15
18
  import kleur from "kleur";
16
19
 
17
20
  import { loadAllContracts, compose } from "../lib/compose-solver.mjs";
21
+ import {
22
+ findRepoRoot,
23
+ readStateFromRr,
24
+ hasBlocker,
25
+ applyStrictMode,
26
+ } from "./compose-state.mjs";
27
+ import { printHuman } from "./compose-print.mjs";
18
28
 
19
29
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
20
30
 
@@ -68,6 +78,25 @@ export async function runCompose(rest) {
68
78
  if (hasBlocker(result)) process.exit(1);
69
79
  }
70
80
 
81
+ /**
82
+ * Programmatic helper used by `rr add` for the pre-flight gate.
83
+ *
84
+ * @param {string} slug
85
+ * @param {string} repoRoot
86
+ * @param {string} targetDir Directory holding rr.json (cwd by default).
87
+ * @param {{ strict?: boolean }} [opts]
88
+ * @returns {Promise<{ result: import("../lib/compose-solver").ComposeResult; rrPath: string }>}
89
+ */
90
+ export async function preflight(slug, repoRoot, targetDir = process.cwd(), opts = {}) {
91
+ const rrPath = path.join(targetDir, "rr.json");
92
+ const state = readStateFromRr(rrPath);
93
+ if (opts.strict) state.allowUnknownSlices = false;
94
+ const contracts = await loadAllContracts(repoRoot);
95
+ let result = compose({ state, desired: [slug], resolveDeps: true }, contracts);
96
+ if (opts.strict) result = applyStrictMode(result);
97
+ return { result, rrPath };
98
+ }
99
+
71
100
  // ---------------------------------------------------------------------------
72
101
  // Helpers
73
102
  // ---------------------------------------------------------------------------
@@ -92,196 +121,3 @@ function parseFlags(rest) {
92
121
  }
93
122
  return { positional, flags };
94
123
  }
95
-
96
- /**
97
- * Walk up from `start` looking for the directory that contains a `packages/`
98
- * folder + `package.json`. Falls back to cwd.
99
- */
100
- function findRepoRoot(start) {
101
- let dir = start;
102
- for (let i = 0; i < 8; i++) {
103
- if (
104
- existsSync(path.join(dir, "packages")) &&
105
- existsSync(path.join(dir, "package.json"))
106
- ) {
107
- return dir;
108
- }
109
- const parent = path.dirname(dir);
110
- if (parent === dir) break;
111
- dir = parent;
112
- }
113
- return process.cwd();
114
- }
115
-
116
- /**
117
- * Parse rr.json (if present) into a {@link RrJsonState}. Tolerates a
118
- * missing file by returning an empty state.
119
- *
120
- * Maps `rr.json#/auth.provider` to the solver's `auth` enum:
121
- * "convex-auth" → "convex"
122
- * "clerk" / "next-auth" / "none" → passthrough
123
- */
124
- function readStateFromRr(rrPath) {
125
- if (!existsSync(rrPath)) return {};
126
- let raw;
127
- try {
128
- raw = JSON.parse(readFileSync(rrPath, "utf8"));
129
- } catch {
130
- return {};
131
- }
132
- const provider = raw?.auth?.provider;
133
- const authMap = {
134
- "convex-auth": "convex",
135
- clerk: "clerk",
136
- "next-auth": "next-auth",
137
- none: "none",
138
- };
139
- const auth = provider ? authMap[provider] ?? undefined : undefined;
140
-
141
- const slicesInstalled = [
142
- ...(raw?.slices ?? []).map((s) => s.slug).filter(Boolean),
143
- ...(raw?.features ?? []).map((f) => f.slug).filter(Boolean),
144
- ];
145
-
146
- return {
147
- auth,
148
- slicesInstalled,
149
- envExisting: Array.isArray(raw?.envExisting) ? raw.envExisting : [],
150
- rbacRolesExisting: Array.isArray(raw?.rbacRolesExisting)
151
- ? raw.rbacRolesExisting
152
- : [],
153
- convexTablesExisting: Array.isArray(raw?.convexTablesExisting)
154
- ? raw.convexTablesExisting
155
- : [],
156
- };
157
- }
158
-
159
- function hasBlocker(result) {
160
- return result.conflicts.some((c) => c.severity === "blocker");
161
- }
162
-
163
- function printHuman(result, { rrPath, repoRoot }) {
164
- const blocker = hasBlocker(result);
165
- const head = blocker
166
- ? kleur.red("✖ compose blocked")
167
- : kleur.green("✓ compose ok");
168
- console.log(
169
- `\n${head} ${kleur.dim(`rr.json: ${path.relative(repoRoot, rrPath) || rrPath}`)}\n`,
170
- );
171
-
172
- console.log(kleur.bold("Proof"));
173
- if (result.proof.length === 0) {
174
- console.log(" " + kleur.dim("(no decisions — empty desired set)"));
175
- } else {
176
- for (const line of result.proof) {
177
- const colored = line.startsWith("+ ")
178
- ? kleur.green(line)
179
- : line.startsWith("- ")
180
- ? kleur.red(line)
181
- : line;
182
- console.log(" " + colored);
183
- }
184
- }
185
-
186
- if (result.accepted.length > 0) {
187
- console.log(`\n${kleur.bold("Accepted")} ${kleur.dim(`(${result.accepted.length})`)}`);
188
- for (const s of result.accepted) console.log(" " + kleur.cyan(s));
189
- }
190
-
191
- if (result.rejected.length > 0) {
192
- console.log(`\n${kleur.bold("Rejected")} ${kleur.dim(`(${result.rejected.length})`)}`);
193
- for (const r of result.rejected) {
194
- console.log(" " + kleur.red(r.slug));
195
- for (const reason of r.reasons) {
196
- console.log(
197
- " " +
198
- kleur.dim(`[${reason.type}]`) +
199
- " " +
200
- reason.detail,
201
- );
202
- }
203
- }
204
- }
205
-
206
- const warnings = result.conflicts.filter((c) => c.severity === "warning");
207
- if (warnings.length > 0) {
208
- console.log(`\n${kleur.bold("Warnings")} ${kleur.dim(`(${warnings.length})`)}`);
209
- for (const w of warnings) {
210
- console.log(" " + kleur.yellow(`[${w.type}]`) + " " + w.detail);
211
- }
212
- }
213
-
214
- if (result.envMissing.length > 0) {
215
- console.log(`\n${kleur.bold("Env needed")}`);
216
- for (const e of result.envMissing) console.log(" " + kleur.yellow(e));
217
- }
218
-
219
- if (result.rbacToCreate.length > 0) {
220
- console.log(`\n${kleur.bold("RBAC to create")}`);
221
- for (const p of result.rbacToCreate) console.log(" " + kleur.yellow(p));
222
- }
223
-
224
- if (result.tablesAdded.length > 0) {
225
- console.log(`\n${kleur.bold("Tables added")}`);
226
- for (const t of result.tablesAdded) {
227
- console.log(
228
- " " + kleur.cyan(t.slug) + ": " + kleur.dim(t.tables.join(", ")),
229
- );
230
- }
231
- }
232
-
233
- console.log(
234
- `\n${kleur.dim("appendix: pass --json for machine-readable output")}\n`,
235
- );
236
- }
237
-
238
- /**
239
- * Programmatic helper used by `rr add` for the pre-flight gate.
240
- *
241
- * @param {string} slug
242
- * @param {string} repoRoot
243
- * @param {string} targetDir Directory holding rr.json (cwd by default).
244
- * @param {{ strict?: boolean }} [opts]
245
- * @returns {Promise<{ result: import("../lib/compose-solver").ComposeResult; rrPath: string }>}
246
- */
247
- export async function preflight(slug, repoRoot, targetDir = process.cwd(), opts = {}) {
248
- const rrPath = path.join(targetDir, "rr.json");
249
- const state = readStateFromRr(rrPath);
250
- if (opts.strict) state.allowUnknownSlices = false;
251
- const contracts = await loadAllContracts(repoRoot);
252
- let result = compose({ state, desired: [slug], resolveDeps: true }, contracts);
253
- if (opts.strict) result = applyStrictMode(result);
254
- return { result, rrPath };
255
- }
256
-
257
- /**
258
- * Elevate every `warning`-level conflict to `blocker`, then re-derive the
259
- * accepted / rejected sets so the slice that triggered any warning gets
260
- * rejected. Used for `--strict` / CI gating.
261
- *
262
- * @param {import("../lib/compose-solver").ComposeResult} result
263
- * @returns {import("../lib/compose-solver").ComposeResult}
264
- */
265
- function applyStrictMode(result) {
266
- const conflicts = result.conflicts.map((c) =>
267
- c.severity === "warning" ? { ...c, severity: "blocker" } : c,
268
- );
269
- const blockersBySlug = new Map();
270
- for (const c of conflicts) {
271
- if (c.severity !== "blocker") continue;
272
- const cur = blockersBySlug.get(c.slug) ?? [];
273
- cur.push(c);
274
- blockersBySlug.set(c.slug, cur);
275
- }
276
- const newRejected = [...result.rejected];
277
- const newAccepted = [];
278
- for (const slug of result.accepted) {
279
- const bl = blockersBySlug.get(slug);
280
- if (bl && bl.length > 0) {
281
- newRejected.push({ slug, reasons: bl, note: "strict-mode" });
282
- } else {
283
- newAccepted.push(slug);
284
- }
285
- }
286
- return { ...result, conflicts, accepted: newAccepted, rejected: newRejected };
287
- }
@@ -0,0 +1,179 @@
1
+ // graph-render.mjs — ASCII renderers for `rr graph`.
2
+ //
3
+ // Extracted from graph.mjs.
4
+
5
+ import kleur from "kleur";
6
+
7
+ import { listAllDNA, readDNA } from "../lib/dna.mjs";
8
+
9
+ export function printSummary() {
10
+ const slices = listAllDNA();
11
+ if (slices.length === 0) {
12
+ console.log(
13
+ kleur.yellow("No DNA files in .kitab/lineage/. ") +
14
+ kleur.dim("Seed some via `rr graph` integrations or manual write."),
15
+ );
16
+ return;
17
+ }
18
+ console.log(kleur.bold(`\nDNA graph — ${slices.length} slice(s)\n`));
19
+
20
+ // Header table: slug | lineage count | consumers | newest source
21
+ const rows = [];
22
+ for (const dna of slices) {
23
+ const newest = dna.lineage[dna.lineage.length - 1];
24
+ rows.push({
25
+ slug: dna.id,
26
+ lineage: dna.lineage.length,
27
+ consumers: Object.keys(dna.consumers).length,
28
+ source: newest ? newest.from : "—",
29
+ });
30
+ }
31
+ const w1 = Math.max(4, ...rows.map((r) => r.slug.length));
32
+ const w4 = Math.max(6, ...rows.map((r) => String(r.source).length));
33
+ console.log(
34
+ " " +
35
+ kleur.bold("slug".padEnd(w1)) +
36
+ " " +
37
+ kleur.bold("hops".padEnd(4)) +
38
+ " " +
39
+ kleur.bold("adopts".padEnd(6)) +
40
+ " " +
41
+ kleur.bold("latest source".padEnd(w4)),
42
+ );
43
+ console.log(
44
+ " " +
45
+ kleur.dim("-".repeat(w1)) +
46
+ " " +
47
+ kleur.dim("----") +
48
+ " " +
49
+ kleur.dim("------") +
50
+ " " +
51
+ kleur.dim("-".repeat(w4)),
52
+ );
53
+ for (const r of rows) {
54
+ console.log(
55
+ " " +
56
+ kleur.cyan(r.slug.padEnd(w1)) +
57
+ " " +
58
+ String(r.lineage).padEnd(4) +
59
+ " " +
60
+ String(r.consumers).padEnd(6) +
61
+ " " +
62
+ kleur.dim(r.source),
63
+ );
64
+ }
65
+
66
+ // Consumer adoption matrix.
67
+ /** @type {Set<string>} */
68
+ const consumerSet = new Set();
69
+ for (const dna of slices) {
70
+ for (const c of Object.keys(dna.consumers)) consumerSet.add(c);
71
+ }
72
+ const consumers = [...consumerSet].sort();
73
+ if (consumers.length > 0) {
74
+ console.log(kleur.bold(`\nAdoption matrix\n`));
75
+ const cw = Math.max(4, ...rows.map((r) => r.slug.length));
76
+ console.log(
77
+ " " +
78
+ " ".repeat(cw) +
79
+ " " +
80
+ consumers
81
+ .map((c) => kleur.bold(c.slice(0, 10).padEnd(10)))
82
+ .join(" "),
83
+ );
84
+ for (const dna of slices) {
85
+ const cells = consumers
86
+ .map((c) => {
87
+ const ad = dna.consumers[c];
88
+ if (!ad) return kleur.dim("·".padEnd(10));
89
+ const drift = ad.drift_score;
90
+ const tag = `v${ad.version} ${drift}%`.slice(0, 10).padEnd(10);
91
+ if (drift >= 40) return kleur.red(tag);
92
+ if (drift >= 15) return kleur.yellow(tag);
93
+ return kleur.green(tag);
94
+ })
95
+ .join(" ");
96
+ console.log(" " + kleur.cyan(dna.id.padEnd(cw)) + " " + cells);
97
+ }
98
+ console.log(
99
+ kleur.dim(
100
+ `\n legend: green <15% drift, yellow 15-39%, red >=40%, · = not adopted`,
101
+ ),
102
+ );
103
+ }
104
+
105
+ console.log(
106
+ kleur.dim(
107
+ `\nRun \`rr graph <slug>\` for the full lineage tree, \`--json\` for machine output.\n`,
108
+ ),
109
+ );
110
+ }
111
+
112
+ export function printSliceTree(slug) {
113
+ const dna = readDNA(slug);
114
+ if (!dna) {
115
+ console.error(
116
+ kleur.red(`✖ No DNA found for "${slug}".`) +
117
+ kleur.dim(` (expected .kitab/lineage/${slug}.dna.json)`),
118
+ );
119
+ process.exit(1);
120
+ }
121
+ console.log(
122
+ `\n${kleur.bold(dna.id)} ${kleur.dim(
123
+ `created ${dna.created_at}`,
124
+ )}\n`,
125
+ );
126
+
127
+ // Lineage chain — chronological top-down tree.
128
+ console.log(kleur.bold("Lineage"));
129
+ if (dna.lineage.length === 0) {
130
+ console.log(" " + kleur.dim("(no lineage entries)"));
131
+ } else {
132
+ const sorted = [...dna.lineage].sort((a, b) =>
133
+ String(a.at).localeCompare(String(b.at)),
134
+ );
135
+ sorted.forEach((entry, i) => {
136
+ const isLast = i === sorted.length - 1;
137
+ const bullet = isLast ? "└─" : "├─";
138
+ const cont = isLast ? " " : "│ ";
139
+ console.log(
140
+ ` ${kleur.dim(bullet)} ${kleur.cyan(entry.from)}` +
141
+ (entry.to ? ` ${kleur.dim("→")} ${kleur.green(entry.to)}` : "") +
142
+ ` ${kleur.dim(entry.at)}`,
143
+ );
144
+ if (entry.transforms?.length) {
145
+ console.log(
146
+ ` ${kleur.dim(cont)} ${kleur.dim("transforms:")} ${entry.transforms.join(", ")}`,
147
+ );
148
+ }
149
+ if (entry.actor) {
150
+ console.log(` ${kleur.dim(cont)} ${kleur.dim("actor:")} ${entry.actor}`);
151
+ }
152
+ });
153
+ }
154
+
155
+ // Consumer adoption rows.
156
+ console.log(`\n${kleur.bold("Consumers")}`);
157
+ const consumers = Object.entries(dna.consumers);
158
+ if (consumers.length === 0) {
159
+ console.log(" " + kleur.dim("(no consumers recorded)"));
160
+ } else {
161
+ const w = Math.max(4, ...consumers.map(([c]) => c.length));
162
+ for (const [name, ad] of consumers) {
163
+ const drift = ad.drift_score;
164
+ const driftStr =
165
+ drift >= 40
166
+ ? kleur.red(`${drift}%`)
167
+ : drift >= 15
168
+ ? kleur.yellow(`${drift}%`)
169
+ : kleur.green(`${drift}%`);
170
+ const sync = ad.last_synced_at ? ` synced ${ad.last_synced_at}` : "";
171
+ console.log(
172
+ ` ${kleur.cyan(name.padEnd(w))} v${ad.version} drift ${driftStr} ${kleur.dim(
173
+ `adopted ${ad.adopted_at}${sync}`,
174
+ )}`,
175
+ );
176
+ }
177
+ }
178
+ console.log("");
179
+ }