rahman-resources 0.9.2 → 0.12.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/graph.mjs ADDED
@@ -0,0 +1,247 @@
1
+ // `rr graph <slug?>` — render slice-DNA lineage to terminal.
2
+ //
3
+ // Surfaces:
4
+ // rr graph → ASCII summary of every slice + consumer adoption matrix
5
+ // rr graph --all → alias for the above
6
+ // rr graph <slug> → full DNA for a single slice + ASCII lineage tree
7
+ // rr graph <slug?> --json → emit JSON instead of ASCII
8
+ //
9
+ // Dispatched from bin/cli.js. Imports from ../lib/dna.mjs.
10
+
11
+ import kleur from "kleur";
12
+
13
+ import {
14
+ buildLineageGraph,
15
+ listAllDNA,
16
+ readDNA,
17
+ } from "../lib/dna.mjs";
18
+
19
+ /**
20
+ * Entry point invoked by cli.js with the post-`graph` argv tail.
21
+ * @param {string[]} rest
22
+ */
23
+ export async function runGraph(rest) {
24
+ const { positional, flags } = parseFlags(rest);
25
+ const slug = positional[0];
26
+ const asJson = !!flags.json;
27
+ const all = !!flags.all || !slug;
28
+
29
+ if (asJson) {
30
+ if (all) {
31
+ const graph = buildLineageGraph();
32
+ const slices = listAllDNA();
33
+ process.stdout.write(
34
+ JSON.stringify({ slices, graph }, null, 2) + "\n",
35
+ );
36
+ return;
37
+ }
38
+ const dna = readDNA(slug);
39
+ if (!dna) {
40
+ process.stderr.write(`No DNA found for "${slug}".\n`);
41
+ process.exit(1);
42
+ }
43
+ process.stdout.write(JSON.stringify(dna, null, 2) + "\n");
44
+ return;
45
+ }
46
+
47
+ if (all) {
48
+ printSummary();
49
+ return;
50
+ }
51
+ printSliceTree(slug);
52
+ }
53
+
54
+ function parseFlags(rest) {
55
+ const positional = [];
56
+ const flags = {};
57
+ for (let i = 0; i < rest.length; i++) {
58
+ const a = rest[i];
59
+ if (a.startsWith("--")) {
60
+ const key = a.slice(2);
61
+ const next = rest[i + 1];
62
+ if (next && !next.startsWith("--")) {
63
+ flags[key] = next;
64
+ i++;
65
+ } else {
66
+ flags[key] = true;
67
+ }
68
+ } else {
69
+ positional.push(a);
70
+ }
71
+ }
72
+ return { positional, flags };
73
+ }
74
+
75
+ // ─── ASCII renderers ──────────────────────────────────────────────────────
76
+
77
+ function printSummary() {
78
+ const slices = listAllDNA();
79
+ if (slices.length === 0) {
80
+ console.log(
81
+ kleur.yellow("No DNA files in .kitab/lineage/. ") +
82
+ kleur.dim("Seed some via `rr graph` integrations or manual write."),
83
+ );
84
+ return;
85
+ }
86
+ console.log(kleur.bold(`\nDNA graph — ${slices.length} slice(s)\n`));
87
+
88
+ // Header table: slug | lineage count | consumers | newest source
89
+ const rows = [];
90
+ for (const dna of slices) {
91
+ const newest = dna.lineage[dna.lineage.length - 1];
92
+ rows.push({
93
+ slug: dna.id,
94
+ lineage: dna.lineage.length,
95
+ consumers: Object.keys(dna.consumers).length,
96
+ source: newest ? newest.from : "—",
97
+ });
98
+ }
99
+ const w1 = Math.max(4, ...rows.map((r) => r.slug.length));
100
+ const w4 = Math.max(6, ...rows.map((r) => String(r.source).length));
101
+ console.log(
102
+ " " +
103
+ kleur.bold("slug".padEnd(w1)) +
104
+ " " +
105
+ kleur.bold("hops".padEnd(4)) +
106
+ " " +
107
+ kleur.bold("adopts".padEnd(6)) +
108
+ " " +
109
+ kleur.bold("latest source".padEnd(w4)),
110
+ );
111
+ console.log(
112
+ " " +
113
+ kleur.dim("-".repeat(w1)) +
114
+ " " +
115
+ kleur.dim("----") +
116
+ " " +
117
+ kleur.dim("------") +
118
+ " " +
119
+ kleur.dim("-".repeat(w4)),
120
+ );
121
+ for (const r of rows) {
122
+ console.log(
123
+ " " +
124
+ kleur.cyan(r.slug.padEnd(w1)) +
125
+ " " +
126
+ String(r.lineage).padEnd(4) +
127
+ " " +
128
+ String(r.consumers).padEnd(6) +
129
+ " " +
130
+ kleur.dim(r.source),
131
+ );
132
+ }
133
+
134
+ // Consumer adoption matrix.
135
+ /** @type {Set<string>} */
136
+ const consumerSet = new Set();
137
+ for (const dna of slices) {
138
+ for (const c of Object.keys(dna.consumers)) consumerSet.add(c);
139
+ }
140
+ const consumers = [...consumerSet].sort();
141
+ if (consumers.length > 0) {
142
+ console.log(kleur.bold(`\nAdoption matrix\n`));
143
+ const cw = Math.max(4, ...rows.map((r) => r.slug.length));
144
+ console.log(
145
+ " " +
146
+ " ".repeat(cw) +
147
+ " " +
148
+ consumers
149
+ .map((c) => kleur.bold(c.slice(0, 10).padEnd(10)))
150
+ .join(" "),
151
+ );
152
+ for (const dna of slices) {
153
+ const cells = consumers
154
+ .map((c) => {
155
+ const ad = dna.consumers[c];
156
+ if (!ad) return kleur.dim("·".padEnd(10));
157
+ const drift = ad.drift_score;
158
+ const tag = `v${ad.version} ${drift}%`.slice(0, 10).padEnd(10);
159
+ if (drift >= 40) return kleur.red(tag);
160
+ if (drift >= 15) return kleur.yellow(tag);
161
+ return kleur.green(tag);
162
+ })
163
+ .join(" ");
164
+ console.log(" " + kleur.cyan(dna.id.padEnd(cw)) + " " + cells);
165
+ }
166
+ console.log(
167
+ kleur.dim(
168
+ `\n legend: green <15% drift, yellow 15-39%, red >=40%, · = not adopted`,
169
+ ),
170
+ );
171
+ }
172
+
173
+ console.log(
174
+ kleur.dim(
175
+ `\nRun \`rr graph <slug>\` for the full lineage tree, \`--json\` for machine output.\n`,
176
+ ),
177
+ );
178
+ }
179
+
180
+ function printSliceTree(slug) {
181
+ const dna = readDNA(slug);
182
+ if (!dna) {
183
+ console.error(
184
+ kleur.red(`✖ No DNA found for "${slug}".`) +
185
+ kleur.dim(` (expected .kitab/lineage/${slug}.dna.json)`),
186
+ );
187
+ process.exit(1);
188
+ }
189
+ console.log(
190
+ `\n${kleur.bold(dna.id)} ${kleur.dim(
191
+ `created ${dna.created_at}`,
192
+ )}\n`,
193
+ );
194
+
195
+ // Lineage chain — chronological top-down tree.
196
+ console.log(kleur.bold("Lineage"));
197
+ if (dna.lineage.length === 0) {
198
+ console.log(" " + kleur.dim("(no lineage entries)"));
199
+ } else {
200
+ const sorted = [...dna.lineage].sort((a, b) =>
201
+ String(a.at).localeCompare(String(b.at)),
202
+ );
203
+ sorted.forEach((entry, i) => {
204
+ const isLast = i === sorted.length - 1;
205
+ const bullet = isLast ? "└─" : "├─";
206
+ const cont = isLast ? " " : "│ ";
207
+ console.log(
208
+ ` ${kleur.dim(bullet)} ${kleur.cyan(entry.from)}` +
209
+ (entry.to ? ` ${kleur.dim("→")} ${kleur.green(entry.to)}` : "") +
210
+ ` ${kleur.dim(entry.at)}`,
211
+ );
212
+ if (entry.transforms?.length) {
213
+ console.log(
214
+ ` ${kleur.dim(cont)} ${kleur.dim("transforms:")} ${entry.transforms.join(", ")}`,
215
+ );
216
+ }
217
+ if (entry.actor) {
218
+ console.log(` ${kleur.dim(cont)} ${kleur.dim("actor:")} ${entry.actor}`);
219
+ }
220
+ });
221
+ }
222
+
223
+ // Consumer adoption rows.
224
+ console.log(`\n${kleur.bold("Consumers")}`);
225
+ const consumers = Object.entries(dna.consumers);
226
+ if (consumers.length === 0) {
227
+ console.log(" " + kleur.dim("(no consumers recorded)"));
228
+ } else {
229
+ const w = Math.max(4, ...consumers.map(([c]) => c.length));
230
+ for (const [name, ad] of consumers) {
231
+ const drift = ad.drift_score;
232
+ const driftStr =
233
+ drift >= 40
234
+ ? kleur.red(`${drift}%`)
235
+ : drift >= 15
236
+ ? kleur.yellow(`${drift}%`)
237
+ : kleur.green(`${drift}%`);
238
+ const sync = ad.last_synced_at ? ` synced ${ad.last_synced_at}` : "";
239
+ console.log(
240
+ ` ${kleur.cyan(name.padEnd(w))} v${ad.version} drift ${driftStr} ${kleur.dim(
241
+ `adopted ${ad.adopted_at}${sync}`,
242
+ )}`,
243
+ );
244
+ }
245
+ }
246
+ console.log("");
247
+ }
@@ -0,0 +1,423 @@
1
+ // `rr migrate <slug> --from <v1> --to <v2>` — Phase E CLI dispatcher.
2
+ //
3
+ // Loads two versions of a slice contract (HEAD + a historic ref), runs
4
+ // the diff + planner, and either prints an ASCII summary or writes the
5
+ // proposed `convex/migrations/*.ts` artifacts into the cwd.
6
+ //
7
+ // Flags:
8
+ // --from <semver> Required. Source version to diff against.
9
+ // --to <semver> Optional. Defaults to the contract's current `version`.
10
+ // --json Emit the MigrationPlan as JSON instead of ASCII.
11
+ // --write-files Write convexMigration artifacts into ./convex/migrations/.
12
+ // --force-overwrite Overwrite existing files in convex/migrations/.
13
+ // --rr-path <path> Override the consumer rr.json location (used to
14
+ // anchor the convex/migrations write target).
15
+ // --repo-root <path> Override the kitab repo root discovery.
16
+ //
17
+ // Side-effects (only with --write-files):
18
+ // - Writes ./convex/migrations/<step-id>.ts
19
+ // - Appends a DNA lineage entry with transforms ["migration-applied", ...].
20
+
21
+ import { existsSync, mkdirSync, unlinkSync, writeFileSync } from "node:fs";
22
+ import { spawnSync } from "node:child_process";
23
+ import path from "node:path";
24
+ import { fileURLToPath } from "node:url";
25
+
26
+ import kleur from "kleur";
27
+
28
+ import { diffContracts, planMigration } from "../lib/migration-plan.mjs";
29
+ import { appendLineage } from "../lib/dna.mjs";
30
+
31
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
32
+
33
+ /**
34
+ * Entry point invoked by cli.js with the post-`migrate` argv tail.
35
+ * @param {string[]} rest
36
+ */
37
+ export async function runMigrate(rest) {
38
+ const { positional, flags } = parseFlags(rest);
39
+ const slug = positional[0];
40
+ if (!slug) {
41
+ process.stderr.write(
42
+ kleur.red(
43
+ "Usage: rahman-resources migrate <slug> --from <v1> [--to <v2>] [--json] [--write-files] [--force-overwrite]\n",
44
+ ),
45
+ );
46
+ process.exit(1);
47
+ }
48
+ const fromVersion = typeof flags.from === "string" ? flags.from : null;
49
+ if (!fromVersion) {
50
+ process.stderr.write(
51
+ kleur.red("migrate: --from <version> is required.\n"),
52
+ );
53
+ process.exit(1);
54
+ }
55
+ const explicitTo = typeof flags.to === "string" ? flags.to : null;
56
+ const asJson = !!flags.json;
57
+ const writeFiles = !!flags["write-files"];
58
+ const forceOverwrite = !!flags["force-overwrite"];
59
+
60
+ const repoRoot =
61
+ typeof flags["repo-root"] === "string"
62
+ ? path.resolve(process.cwd(), flags["repo-root"])
63
+ : findRepoRoot(__dirname);
64
+
65
+ const cwd = process.cwd();
66
+
67
+ // 1) Load contracts.
68
+ const toContract = loadCurrentContract(repoRoot, slug);
69
+ if (!toContract) {
70
+ process.stderr.write(
71
+ kleur.red(
72
+ `migrate: cannot load current contract for "${slug}". ` +
73
+ `Expected frontend/slices/${slug}/slice.contract.ts or template-base/frontend/slices/${slug}/slice.contract.ts.\n`,
74
+ ),
75
+ );
76
+ process.exit(1);
77
+ }
78
+ if (explicitTo && toContract.version !== explicitTo) {
79
+ // The current on-disk contract isn't the requested target — bail rather
80
+ // than silently diff against the wrong file.
81
+ process.stderr.write(
82
+ kleur.red(
83
+ `migrate: on-disk contract is v${toContract.version}, --to v${explicitTo} requested. ` +
84
+ `Bump the contract first or omit --to.\n`,
85
+ ),
86
+ );
87
+ process.exit(1);
88
+ }
89
+
90
+ const fromContract = loadHistoricContract(repoRoot, slug, fromVersion);
91
+ if (!fromContract) {
92
+ process.stderr.write(
93
+ kleur.red(
94
+ `migrate: cannot load contract for "${slug}" at version ${fromVersion}. ` +
95
+ `Tried tags v${fromVersion}, ${fromVersion}, ${slug}-v${fromVersion}, and a best-effort log scan.\n`,
96
+ ),
97
+ );
98
+ process.exit(1);
99
+ }
100
+
101
+ // 2) Diff + plan.
102
+ const diff = diffContracts(fromContract, toContract);
103
+ const plan = planMigration(diff);
104
+
105
+ // 3) Emit.
106
+ if (asJson) {
107
+ process.stdout.write(JSON.stringify(plan, null, 2) + "\n");
108
+ } else {
109
+ printPlan(plan);
110
+ }
111
+
112
+ // 4) Optional write.
113
+ if (writeFiles) {
114
+ const writeDir = path.join(cwd, "convex", "migrations");
115
+ mkdirSync(writeDir, { recursive: true });
116
+ const writtenIds = [];
117
+ for (const step of plan.steps) {
118
+ const body = step.artifacts?.convexMigration;
119
+ if (!body) continue;
120
+ const file = path.join(writeDir, `${step.id}.ts`);
121
+ if (existsSync(file) && !forceOverwrite) {
122
+ process.stderr.write(
123
+ kleur.red(
124
+ `migrate: ${path.relative(cwd, file)} already exists. Pass --force-overwrite to replace.\n`,
125
+ ),
126
+ );
127
+ process.exit(1);
128
+ }
129
+ writeFileSync(file, body + "\n");
130
+ writtenIds.push(step.id);
131
+ if (!asJson) {
132
+ process.stdout.write(
133
+ ` ${kleur.green("+")} ${path.relative(cwd, file)}\n`,
134
+ );
135
+ }
136
+ }
137
+ // 5) Append DNA lineage.
138
+ if (writtenIds.length > 0) {
139
+ try {
140
+ appendLineage(slug, {
141
+ from: `contract:${slug}@${plan.fromVersion}`,
142
+ to: `contract:${slug}@${plan.toVersion}`,
143
+ at: new Date().toISOString(),
144
+ transforms: ["migration-applied", ...writtenIds],
145
+ });
146
+ if (!asJson) {
147
+ process.stdout.write(
148
+ kleur.dim(
149
+ ` lineage: appended "migration-applied" entry to .kitab/lineage/${slug}.dna.json\n`,
150
+ ),
151
+ );
152
+ }
153
+ } catch (err) {
154
+ process.stderr.write(
155
+ kleur.yellow(
156
+ ` ⚠ lineage append failed (${err.message ?? err}). Files were still written.\n`,
157
+ ),
158
+ );
159
+ }
160
+ }
161
+ }
162
+ }
163
+
164
+ // ---------------------------------------------------------------------------
165
+ // Contract loading
166
+ // ---------------------------------------------------------------------------
167
+
168
+ const SLICE_ROOTS = [
169
+ ["frontend", "slices"],
170
+ ["template-base", "frontend", "slices"],
171
+ ];
172
+
173
+ function resolveContractPath(repoRoot, slug) {
174
+ for (const segs of SLICE_ROOTS) {
175
+ const p = path.join(repoRoot, ...segs, slug, "slice.contract.ts");
176
+ if (existsSync(p)) return p;
177
+ }
178
+ return null;
179
+ }
180
+
181
+ function resolveContractRelPath(repoRoot, slug) {
182
+ for (const segs of SLICE_ROOTS) {
183
+ const rel = [...segs, slug, "slice.contract.ts"].join("/");
184
+ if (existsSync(path.join(repoRoot, rel))) return rel;
185
+ }
186
+ return null;
187
+ }
188
+
189
+ function loadCurrentContract(repoRoot, slug) {
190
+ const p = resolveContractPath(repoRoot, slug);
191
+ if (!p) return null;
192
+ return evalContract(repoRoot, p, null);
193
+ }
194
+
195
+ /**
196
+ * Resolve the contract at a historic version. Strategy:
197
+ * 1. Try git tags `v<ver>`, `<ver>`, `<slug>-v<ver>` against the contract file.
198
+ * 2. If none have the right version, scan recent commits touching the file
199
+ * and pick the most recent one whose contract.version === fromVersion.
200
+ */
201
+ function loadHistoricContract(repoRoot, slug, fromVersion) {
202
+ const rel = resolveContractRelPath(repoRoot, slug);
203
+ if (!rel) return null;
204
+
205
+ const candidates = [`v${fromVersion}`, fromVersion, `${slug}-v${fromVersion}`];
206
+ for (const ref of candidates) {
207
+ const text = gitShowFile(repoRoot, ref, rel);
208
+ if (text) {
209
+ const c = evalContract(repoRoot, null, text);
210
+ if (c && c.version === fromVersion) return c;
211
+ }
212
+ }
213
+
214
+ // Fallback: scan commit history for the file.
215
+ const log = spawnSync("git", ["log", "--format=%H", "--", rel], {
216
+ cwd: repoRoot,
217
+ encoding: "utf8",
218
+ });
219
+ if (log.status === 0 && log.stdout) {
220
+ const hashes = log.stdout.split("\n").map((l) => l.trim()).filter(Boolean);
221
+ for (const sha of hashes) {
222
+ const text = gitShowFile(repoRoot, sha, rel);
223
+ if (!text) continue;
224
+ const c = evalContract(repoRoot, null, text);
225
+ if (c && c.version === fromVersion) return c;
226
+ }
227
+ }
228
+ return null;
229
+ }
230
+
231
+ function gitShowFile(repoRoot, ref, relPath) {
232
+ const res = spawnSync("git", ["show", `${ref}:${relPath}`], {
233
+ cwd: repoRoot,
234
+ encoding: "utf8",
235
+ });
236
+ if (res.status === 0 && typeof res.stdout === "string") return res.stdout;
237
+ return null;
238
+ }
239
+
240
+ /**
241
+ * Eval a contract file by spawning `npx tsx` and reading the JSON output.
242
+ * Pass `filePath` (absolute) for the on-disk version OR `inlineText` for a
243
+ * historic version pulled via git show.
244
+ */
245
+ function evalContract(repoRoot, filePath, inlineText) {
246
+ if (filePath) {
247
+ const rel = "./" + path.relative(repoRoot, filePath);
248
+ const code = [
249
+ `import(${JSON.stringify(rel)})`,
250
+ ` .then(m => { const c = m.contract || (m.default && m.default.contract); if (!c) { process.exit(2); } process.stdout.write(JSON.stringify(c)); })`,
251
+ ` .catch((e) => { process.stderr.write(String(e && e.stack || e)); process.exit(3); });`,
252
+ ].join("\n");
253
+ const res = spawnSync("npx", ["--no-install", "tsx", "-e", code], {
254
+ cwd: repoRoot,
255
+ encoding: "utf8",
256
+ });
257
+ if (res.status === 0 && res.stdout) {
258
+ try {
259
+ return JSON.parse(res.stdout);
260
+ } catch {
261
+ return null;
262
+ }
263
+ }
264
+ return null;
265
+ }
266
+
267
+ // Inline mode: write to a temp file under the kitab root (so its tsconfig +
268
+ // path aliases resolve) and eval. We use a `.migration-tmp/` folder so the
269
+ // temp file is colocated with packages/ but easy to ignore.
270
+ if (typeof inlineText !== "string") return null;
271
+ const tmpDir = path.join(repoRoot, ".migration-tmp");
272
+ mkdirSync(tmpDir, { recursive: true });
273
+ const tmpFile = path.join(
274
+ tmpDir,
275
+ `contract-${Date.now()}-${Math.random().toString(36).slice(2)}.ts`,
276
+ );
277
+ try {
278
+ // Rewrite the import path back to the absolute kitab `contract` module so
279
+ // `../../../packages/cli/lib/contract` (from a historic file pulled via
280
+ // git show) keeps resolving after we move it to a different directory.
281
+ const adjusted = adjustContractImport(inlineText, repoRoot, tmpDir);
282
+ writeFileSync(tmpFile, adjusted);
283
+ return evalContract(repoRoot, tmpFile, null);
284
+ } finally {
285
+ try {
286
+ if (existsSync(tmpFile)) unlinkSync(tmpFile);
287
+ } catch {
288
+ // ignore
289
+ }
290
+ }
291
+ }
292
+
293
+ /**
294
+ * Replace any `from "..contract"` import in the contract body with an
295
+ * absolute path so the temp-file copy still resolves the same module.
296
+ */
297
+ function adjustContractImport(text, repoRoot, tmpDir) {
298
+ const contractMod = path.join(repoRoot, "packages", "cli", "lib", "contract");
299
+ const relFromTmp = path
300
+ .relative(tmpDir, contractMod)
301
+ .split(path.sep)
302
+ .join("/");
303
+ // Match imports ending in /contract or /contract.ts (with or without .ts).
304
+ return text.replace(
305
+ /from\s+["'][^"']*\/contract(?:\.ts)?["']/g,
306
+ `from "${relFromTmp}"`,
307
+ );
308
+ }
309
+
310
+ // ---------------------------------------------------------------------------
311
+ // Presentation
312
+ // ---------------------------------------------------------------------------
313
+
314
+ function printPlan(plan, _ctx) {
315
+ const head = plan.summary.totalSteps === 0
316
+ ? kleur.green("✓ no migration needed")
317
+ : kleur.bold(`→ ${plan.slug}: v${plan.fromVersion} → v${plan.toVersion}`);
318
+ process.stdout.write(`\n${head}\n\n`);
319
+
320
+ if (plan.summary.totalSteps === 0) {
321
+ process.stdout.write(kleur.dim(" (contracts are equivalent — nothing to migrate)\n\n"));
322
+ return;
323
+ }
324
+
325
+ // Header row.
326
+ const cols = ["id", "kind", "risk", "rev", "description"];
327
+ const widths = [28, 26, 6, 4, 50];
328
+ const sep =
329
+ "+" + widths.map((w) => "-".repeat(w + 2)).join("+") + "+\n";
330
+ const row = (vals) =>
331
+ "|" +
332
+ vals
333
+ .map((v, i) => " " + String(v).padEnd(widths[i]).slice(0, widths[i]) + " ")
334
+ .join("|") +
335
+ "|\n";
336
+
337
+ process.stdout.write(sep);
338
+ process.stdout.write(row(cols));
339
+ process.stdout.write(sep);
340
+ for (const step of plan.steps) {
341
+ process.stdout.write(
342
+ row([
343
+ step.id,
344
+ step.kind,
345
+ step.risk,
346
+ step.reversible ? "yes" : "NO",
347
+ step.description.replace(/\s+/g, " ").slice(0, widths[4]),
348
+ ]),
349
+ );
350
+ }
351
+ process.stdout.write(sep);
352
+
353
+ // Per-step detail.
354
+ for (const step of plan.steps) {
355
+ process.stdout.write(`\n${kleur.bold(step.id)} ${kleur.dim("(" + step.kind + ")")}\n`);
356
+ process.stdout.write(` ${step.description}\n`);
357
+ if (step.artifacts.note) {
358
+ process.stdout.write(` ${kleur.dim("note:")} ${step.artifacts.note}\n`);
359
+ }
360
+ if (step.artifacts.envExample) {
361
+ process.stdout.write(` ${kleur.dim(".env.example:")} ${step.artifacts.envExample}\n`);
362
+ }
363
+ }
364
+
365
+ process.stdout.write(
366
+ `\n${kleur.bold("summary")}: ${plan.summary.totalSteps} step(s) — ` +
367
+ `${kleur.red(plan.summary.highRisk + " high-risk")}, ` +
368
+ `${kleur.yellow(plan.summary.irreversible + " irreversible")}\n`,
369
+ );
370
+
371
+ if (plan.warnings.length > 0) {
372
+ process.stdout.write(`\n${kleur.bold("warnings:")}\n`);
373
+ for (const w of plan.warnings) {
374
+ process.stdout.write(` ${kleur.yellow("⚠")} ${w}\n`);
375
+ }
376
+ }
377
+ process.stdout.write(
378
+ `\n${kleur.dim(
379
+ "pass --json for machine-readable output, --write-files to materialize convex/migrations/.",
380
+ )}\n\n`,
381
+ );
382
+ }
383
+
384
+ // ---------------------------------------------------------------------------
385
+ // Helpers
386
+ // ---------------------------------------------------------------------------
387
+
388
+ function parseFlags(rest) {
389
+ const positional = [];
390
+ const flags = {};
391
+ for (let i = 0; i < rest.length; i++) {
392
+ const a = rest[i];
393
+ if (a.startsWith("--")) {
394
+ const key = a.slice(2);
395
+ const next = rest[i + 1];
396
+ if (next && !next.startsWith("--")) {
397
+ flags[key] = next;
398
+ i++;
399
+ } else {
400
+ flags[key] = true;
401
+ }
402
+ } else {
403
+ positional.push(a);
404
+ }
405
+ }
406
+ return { positional, flags };
407
+ }
408
+
409
+ function findRepoRoot(start) {
410
+ let dir = start;
411
+ for (let i = 0; i < 8; i++) {
412
+ if (
413
+ existsSync(path.join(dir, "packages")) &&
414
+ existsSync(path.join(dir, "package.json"))
415
+ ) {
416
+ return dir;
417
+ }
418
+ const parent = path.dirname(dir);
419
+ if (parent === dir) break;
420
+ dir = parent;
421
+ }
422
+ return process.cwd();
423
+ }