rahman-resources 0.4.3 → 0.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
@@ -1,17 +1,18 @@
1
1
  #!/usr/bin/env node
2
2
  // rahman-resources — installer for the Rahman kitab.
3
3
  // Usage:
4
- // npx rahman-resources init <app-name> [--template <slug>] [--features a,b] [--skills x,y]
5
- // npx rahman-resources add <slug> [target-dir]
4
+ // npx rahman-resources init <app-name> [--template <slug>] [--features a,b] [--skills x,y] [--with-shadcn-all]
5
+ // npx rahman-resources add <slug> [target-dir] [--at root|preview] [--with-shadcn-all]
6
6
  // npx rahman-resources add-skill <slug> [target-dir]
7
- // npx rahman-resources list [layouts|recipes|features|skills]
7
+ // npx rahman-resources scaffold-slice <slug> [--category <cat>] [--target <dir>]
8
+ // npx rahman-resources list [layouts|recipes|features|skills|slices]
8
9
  // npx rahman-resources info <slug>
9
10
  // npx rahman-resources doctor
10
11
  // npx rahman-resources mcp # not implemented in CLI; install rahman-resources-mcp
11
12
 
12
13
  import { createRequire } from "node:module";
13
14
  import { spawn } from "node:child_process";
14
- import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, statSync } from "node:fs";
15
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, rmSync, statSync } from "node:fs";
15
16
  import path from "node:path";
16
17
  import { fileURLToPath } from "node:url";
17
18
 
@@ -37,7 +38,7 @@ const REPO = manifest.repo ?? "rahmanef63/resource-site";
37
38
  const BRANCH = manifest.branch ?? "main";
38
39
  const SKILLS_REPO = "anthropics/skills";
39
40
 
40
- const KINDS = /** @type {const} */ (["layout", "recipe", "feature"]);
41
+ const KINDS = /** @type {const} */ (["slice", "layout", "recipe", "feature"]);
41
42
 
42
43
  const [, , cmd, ...rest] = process.argv;
43
44
 
@@ -58,6 +59,12 @@ async function main() {
58
59
  return runAdd(rest);
59
60
  case "add-skill":
60
61
  return runAddSkill(rest);
62
+ case "scaffold-slice":
63
+ return runScaffoldSlice(rest);
64
+ case "lift":
65
+ return runLift(rest);
66
+ case "publish-slice":
67
+ return runPublishSlice(rest);
61
68
  case "list":
62
69
  case "ls":
63
70
  return runList(rest);
@@ -94,10 +101,13 @@ ${kleur.bold("rahman-resources")} — scaffold + install templates, recipes, fea
94
101
 
95
102
  ${kleur.bold("Usage:")}
96
103
  npx rahman-resources init <app-name> [--template <slug>] [--features a,b] [--skills x,y]
97
- [--no-install] [--with-shadcn-reinit]
98
- npx rahman-resources add <slug> [target-dir]
104
+ [--no-install] [--with-shadcn-reinit] [--with-shadcn-all]
105
+ npx rahman-resources add <slug> [target-dir] [--at root|preview] [--with-shadcn-all]
99
106
  npx rahman-resources add-skill <slug> [target-dir]
100
- npx rahman-resources list [layouts|recipes|features|skills]
107
+ npx rahman-resources scaffold-slice <slug> [--category <cat>] [--target <dir>]
108
+ npx rahman-resources lift <source>:<path> [--target <dir>] [--dry-run]
109
+ npx rahman-resources publish-slice <local-slice-dir> [--open-pr]
110
+ npx rahman-resources list [layouts|recipes|features|skills|slices]
101
111
  npx rahman-resources info <slug>
102
112
  npx rahman-resources doctor
103
113
  npx rahman-resources mcp
@@ -105,12 +115,21 @@ ${kleur.bold("Usage:")}
105
115
  ${kleur.bold("Init flags:")}
106
116
  --no-install skip 'npm install' step (faster scaffolds; you run it manually)
107
117
  --with-shadcn-reinit delete starter components.json + run 'npx shadcn init -y -d' (canonical shadcn flow)
118
+ --with-shadcn-all run 'npx shadcn add --all' instead of the per-template list
119
+ (heavy; ~50 components — use only if you'll customize beyond the template)
120
+
121
+ ${kleur.bold("Add flags:")}
122
+ --at root install template AT app/(public)/ + app/admin/ (recommended; rewrites
123
+ /preview/<slug> path constants in nav-config/robots/sitemap)
124
+ --at preview install template AT app/preview/<slug>/ (default — sandbox style)
125
+ --with-shadcn-all same as init flag
108
126
 
109
127
  ${kleur.bold("Examples:")}
110
128
  npx rahman-resources init my-app
111
129
  npx rahman-resources init my-app --template personal-brand-os --skills frontend-design,mcp-builder
112
130
  npx rahman-resources init my-app --no-install
113
- npx rahman-resources add personal-brand-os .
131
+ npx rahman-resources add personal-brand-os . --at root
132
+ npx rahman-resources add personal-brand-os . --with-shadcn-all
114
133
  npx rahman-resources add-skill webapp-testing
115
134
  npx rahman-resources list skills
116
135
  `);
@@ -144,7 +163,7 @@ function csv(s) {
144
163
 
145
164
  function findEntry(slug) {
146
165
  for (const kind of KINDS) {
147
- const list = manifest[kind + "s"];
166
+ const list = manifest[kind + "s"] ?? [];
148
167
  const e = list.find((x) => x.slug === slug);
149
168
  if (e) return { kind, entry: e };
150
169
  }
@@ -160,7 +179,7 @@ function findSkill(slug) {
160
179
  function runList([filter]) {
161
180
  const groups = filter
162
181
  ? [filter]
163
- : ["layouts", "recipes", "features", "skills"];
182
+ : ["slices", "layouts", "recipes", "features", "skills"];
164
183
  for (const g of groups) {
165
184
  if (g === "skills") {
166
185
  console.log(`\n${kleur.bold("SKILLS")} ${kleur.dim(`(${skillsInventory.skills.length})`)}\n`);
@@ -219,6 +238,22 @@ ${t.description}
219
238
  } else if (kind === "recipe") {
220
239
  console.log(`${kleur.bold("Files:")}`);
221
240
  console.log(t.files.map((f) => ` · ${f}`).join("\n"));
241
+ } else if (kind === "slice") {
242
+ console.log(`${kleur.bold("Pulls:")}`);
243
+ console.log(` · ${t.slicePath}`);
244
+ for (const cp of t.convexPaths ?? []) console.log(` · ${cp}`);
245
+ if (t.npm?.length) console.log(`\n${kleur.bold("npm:")}\n ${t.npm.join(" ")}`);
246
+ if (t.shadcn?.length) console.log(`\n${kleur.bold("shadcn:")}\n ${t.shadcn.join(" ")}`);
247
+ if (t.env?.length) {
248
+ console.log(`\n${kleur.bold("env:")}`);
249
+ for (const e of t.env) console.log(` · ${e.name} ${kleur.dim(`(${e.scope})`)}${e.required === false ? kleur.dim(" optional") : ""}`);
250
+ }
251
+ if (t.peers?.length) {
252
+ console.log(`\n${kleur.bold("peers:")}`);
253
+ for (const p of t.peers) console.log(` · ${p.slug} ${p.range}`);
254
+ }
255
+ if (t.providers?.length) console.log(`\n${kleur.bold("providers:")} ${t.providers.join(", ")}`);
256
+ console.log(`\n${kleur.bold("Install:")} ${kleur.cyan(`npx rahman-resources add ${t.slug}`)}`);
222
257
  }
223
258
  if (t.docsUrl) console.log(`\n${kleur.dim(`Docs: ${t.docsUrl}`)}`);
224
259
  console.log(`${kleur.dim(`Source: ${t.source ?? "—"}`)}\n`);
@@ -339,6 +374,20 @@ async function runInit(rest) {
339
374
  await pull(p, dest);
340
375
  console.log(kleur.green("ok"));
341
376
  }
377
+ if (!skipInstall) {
378
+ await maybeRunShadcnAdd(t, target, !!flags["with-shadcn-all"]);
379
+ } else {
380
+ console.log(kleur.dim(`\n (skipping shadcn add — --no-install)`));
381
+ }
382
+ // Strip the placeholder app/page.tsx — the template owns the root route.
383
+ const placeholder = path.join(target, "app", "page.tsx");
384
+ if (existsSync(placeholder)) {
385
+ try { rmSync(placeholder); console.log(` ${kleur.dim("removed placeholder")} app/page.tsx`); } catch {}
386
+ }
387
+ }
388
+
389
+ if (!skipInstall) {
390
+ await runOfflineConvexCodegen(target);
342
391
  }
343
392
 
344
393
  if (skills.length) {
@@ -366,7 +415,9 @@ function runShell(cmd, args, cwd) {
366
415
 
367
416
  // ─── add (template / feature / recipe) ────────────────────────────────────
368
417
 
369
- async function runAdd([slug, targetArg = "."]) {
418
+ async function runAdd(rest) {
419
+ const { positional, flags } = parseFlags(rest);
420
+ const [slug, targetArg = "."] = positional;
370
421
  if (!slug) {
371
422
  console.error(kleur.red("Missing slug."));
372
423
  printHelp();
@@ -377,16 +428,22 @@ async function runAdd([slug, targetArg = "."]) {
377
428
  const { kind, entry } = found;
378
429
  const target = path.resolve(process.cwd(), targetArg);
379
430
 
380
- if (kind === "layout") return addLayout(entry, target, targetArg);
431
+ if (kind === "slice") return runLift([`rahman:${entry.slug}`, ...(targetArg !== "." ? ["--target", targetArg] : [])]);
432
+ if (kind === "layout") return addLayout(entry, target, targetArg, flags);
381
433
  if (kind === "feature") return addFeature(entry, target, targetArg);
382
434
  if (kind === "recipe") return addRecipe(entry);
383
435
  }
384
436
 
385
- async function addLayout(t, target, targetArg) {
437
+ async function addLayout(t, target, targetArg, flags = {}) {
386
438
  console.log(kleur.bold(`\n→ Installing ${kleur.cyan(t.title)} into ${kleur.dim(target)}\n`));
387
439
  if (!t.pullPaths || t.pullPaths.length === 0) {
388
440
  throw new Error(`Layout "${t.slug}" has no valid pullPaths in manifest.`);
389
441
  }
442
+ const at = typeof flags.at === "string" ? flags.at : "preview";
443
+ if (!["root", "preview"].includes(at)) {
444
+ throw new Error(`--at must be "root" or "preview" (got "${at}").`);
445
+ }
446
+
390
447
  for (const p of t.pullPaths) {
391
448
  const dest = path.join(target, p);
392
449
  process.stdout.write(` pulling ${kleur.dim(p)} ... `);
@@ -405,6 +462,12 @@ async function addLayout(t, target, targetArg) {
405
462
  }
406
463
  }
407
464
 
465
+ await maybeRunShadcnAdd(t, target, !!flags["with-shadcn-all"]);
466
+
467
+ if (at === "root") {
468
+ promoteToRoot(t, target);
469
+ }
470
+
408
471
  if (rrExists(target)) {
409
472
  const rr = readRr(target);
410
473
  rr.template = { slug: t.slug, version: "main" };
@@ -415,6 +478,160 @@ async function addLayout(t, target, targetArg) {
415
478
  if (t.agentRecipe) console.log(`\n${kleur.bold("Next:")}\n${indent(t.agentRecipe, 2)}\n`);
416
479
  }
417
480
 
481
+ // ─── offline convex codegen ───────────────────────────────────────────────
482
+ //
483
+ // Self-hosted deploys (Dokploy etc) can't run codegen inside Docker because
484
+ // they have no Convex auth context. Postmortem 1.2: the workaround is to
485
+ // generate types locally with a dummy admin key + typecheck disabled, then
486
+ // commit `convex/_generated/` so the Docker build can typecheck against it.
487
+ async function runOfflineConvexCodegen(target) {
488
+ const convexDir = path.join(target, "convex");
489
+ if (!existsSync(convexDir)) return;
490
+ const generated = path.join(convexDir, "_generated");
491
+ if (existsSync(generated)) {
492
+ console.log(kleur.dim(`\n (convex/_generated already present — skipping codegen)`));
493
+ return;
494
+ }
495
+ console.log(kleur.bold(`\n→ Generating convex/_generated (offline)\n`));
496
+ try {
497
+ await new Promise((resolve, reject) => {
498
+ const ps = spawn(
499
+ "npx",
500
+ ["convex", "codegen", "--typecheck=disable"],
501
+ {
502
+ cwd: target,
503
+ stdio: "inherit",
504
+ shell: true,
505
+ env: {
506
+ ...process.env,
507
+ CONVEX_SELF_HOSTED_URL: "http://localhost:3210",
508
+ CONVEX_SELF_HOSTED_ADMIN_KEY: "x|x",
509
+ },
510
+ },
511
+ );
512
+ ps.on("error", reject);
513
+ ps.on("exit", (code) => (code === 0 ? resolve() : reject(new Error(`convex codegen exited ${code}`))));
514
+ });
515
+ } catch (err) {
516
+ console.log(kleur.yellow(` ⚠ codegen failed (${err.message}). Run later: CONVEX_SELF_HOSTED_URL=http://localhost:3210 CONVEX_SELF_HOSTED_ADMIN_KEY="x|x" npx convex codegen --typecheck=disable`));
517
+ }
518
+ }
519
+
520
+ // ─── shadcn auto-add ──────────────────────────────────────────────────────
521
+
522
+ async function maybeRunShadcnAdd(t, target, all) {
523
+ if (!hasPackageJson(target)) {
524
+ console.log(kleur.dim(`\n (skipping shadcn add — no package.json in target)`));
525
+ return;
526
+ }
527
+ const componentsJson = path.join(target, "components.json");
528
+ if (!existsSync(componentsJson)) {
529
+ console.log(kleur.yellow(`\n ⚠ components.json missing — run 'npx shadcn init' first, then re-add.`));
530
+ return;
531
+ }
532
+ const list = all ? ["--all"] : (t.shadcnComponents ?? []);
533
+ if (list.length === 0) {
534
+ console.log(kleur.dim(`\n (skipping shadcn add — no shadcnComponents declared for ${t.slug})`));
535
+ return;
536
+ }
537
+ console.log(kleur.bold(`\n→ Installing shadcn components ${all ? "(--all)" : `(${list.length})`}\n`));
538
+ console.log(kleur.dim(` ${list.join(" ")}\n`));
539
+ try {
540
+ await runShell("npx", ["shadcn@latest", "add", ...list, "--yes", "--overwrite"], target);
541
+ } catch (err) {
542
+ console.log(kleur.yellow(` ⚠ shadcn add failed (${err.message}). Run manually: npx shadcn@latest add ${list.join(" ")}`));
543
+ }
544
+ }
545
+
546
+ // ─── promote-to-root: move template files out of app/preview/<slug>/ into
547
+ // app/(public)/ + app/admin/, then rewrite hardcoded /preview/<slug>
548
+ // path constants in nav-config / robots / sitemap / site-config. ────────
549
+
550
+ function promoteToRoot(t, target) {
551
+ const previewDir = path.join(target, "app", "preview", t.slug);
552
+ if (!existsSync(previewDir)) {
553
+ console.log(kleur.dim(`\n (--at root: ${previewDir} not found — skipping promote)`));
554
+ return;
555
+ }
556
+ console.log(kleur.bold(`\n→ Promoting to app/(public)/ + app/admin/ (--at root)\n`));
557
+
558
+ const publicSrc = path.join(previewDir, "public");
559
+ const adminSrc = path.join(previewDir, "admin");
560
+ const publicDest = path.join(target, "app", "(public)");
561
+ const adminDest = path.join(target, "app", "admin");
562
+
563
+ if (existsSync(publicSrc)) {
564
+ mvTree(publicSrc, publicDest);
565
+ console.log(` ${kleur.green("+")} app/(public)/`);
566
+ }
567
+ if (existsSync(adminSrc)) {
568
+ mvTree(adminSrc, adminDest);
569
+ console.log(` ${kleur.green("+")} app/admin/`);
570
+ }
571
+ // Move robots/sitemap/og from app/preview/<slug>/ to app/
572
+ for (const stub of ["robots.ts", "sitemap.ts", "opengraph-image.tsx"]) {
573
+ const src = path.join(previewDir, stub);
574
+ if (existsSync(src)) {
575
+ const dest = path.join(target, "app", stub);
576
+ writeFileSync(dest, readFileSync(src, "utf8"));
577
+ console.log(` ${kleur.green("+")} app/${stub}`);
578
+ }
579
+ }
580
+
581
+ rewritePreviewPaths(target, t.slug);
582
+
583
+ // Best-effort cleanup of now-empty preview dir
584
+ try { rmSync(previewDir, { recursive: true, force: true }); } catch {}
585
+ }
586
+
587
+ function mvTree(src, dest) {
588
+ mkdirSync(dest, { recursive: true });
589
+ for (const entry of readdirSync(src)) {
590
+ const sFull = path.join(src, entry);
591
+ const dFull = path.join(dest, entry);
592
+ const stat = statSync(sFull);
593
+ if (stat.isDirectory()) {
594
+ mvTree(sFull, dFull);
595
+ } else {
596
+ writeFileSync(dFull, readFileSync(sFull, "utf8"));
597
+ }
598
+ }
599
+ }
600
+
601
+ // Rewrite hardcoded /preview/<slug>/{public,admin} → "" / "/admin" in known files
602
+ // (nav-config, robots, sitemap, site-config, plus any *Page.tsx that hardcodes them).
603
+ function rewritePreviewPaths(target, slug) {
604
+ const previewBase = `/preview/${slug}`;
605
+ const candidates = [
606
+ path.join(target, "app", "robots.ts"),
607
+ path.join(target, "app", "sitemap.ts"),
608
+ ];
609
+ // also components/templates/<base>/shared/{site-config,nav-config}.ts
610
+ const tplShared = path.join(target, "components", "templates");
611
+ if (existsSync(tplShared)) {
612
+ for (const baseDir of readdirSync(tplShared)) {
613
+ const sharedDir = path.join(tplShared, baseDir, "shared");
614
+ if (!existsSync(sharedDir)) continue;
615
+ for (const f of ["site-config.ts", "nav-config.ts"]) {
616
+ const p = path.join(sharedDir, f);
617
+ if (existsSync(p)) candidates.push(p);
618
+ }
619
+ }
620
+ }
621
+ for (const f of candidates) {
622
+ if (!existsSync(f)) continue;
623
+ const before = readFileSync(f, "utf8");
624
+ const after = before
625
+ .replaceAll(`${previewBase}/public`, "")
626
+ .replaceAll(`${previewBase}/admin`, "/admin")
627
+ .replaceAll(previewBase, "");
628
+ if (after !== before) {
629
+ writeFileSync(f, after);
630
+ console.log(` ${kleur.dim("rewrote")} ${path.relative(target, f)}`);
631
+ }
632
+ }
633
+ }
634
+
418
635
  async function addFeature(t, target, targetArg) {
419
636
  console.log(kleur.bold(`\n→ Adding feature ${kleur.cyan(t.title)} to ${kleur.dim(target)}\n`));
420
637
  if (!t.npmPackages || t.npmPackages.length === 0) {
@@ -536,6 +753,294 @@ Then in Claude Code: ${kleur.cyan("/mcp")} to see available rr_* tools.
536
753
  `);
537
754
  }
538
755
 
756
+ // ─── scaffold-slice ───────────────────────────────────────────────────────
757
+ //
758
+ // Creates a new slice from the kitab's `frontend/slices/_templates/example-feature/`
759
+ // reference. Pulls both the frontend half + the convex backend half, then
760
+ // rewrites the slug + camelCase identifiers everywhere they appear.
761
+
762
+ const VALID_CATEGORIES = ["ai", "auth", "data", "payment", "email", "realtime", "storage", "search", "content", "ui", "infra"];
763
+
764
+ async function runScaffoldSlice(rest) {
765
+ const { positional, flags } = parseFlags(rest);
766
+ const [slug] = positional;
767
+ if (!slug) {
768
+ throw new Error("Usage: rahman-resources scaffold-slice <slug> [--category <cat>] [--target <dir>]");
769
+ }
770
+ if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(slug)) {
771
+ throw new Error(`Slug must be kebab-case (got "${slug}").`);
772
+ }
773
+ const category = typeof flags.category === "string" ? flags.category : "data";
774
+ if (!VALID_CATEGORIES.includes(category)) {
775
+ throw new Error(`--category must be one of: ${VALID_CATEGORIES.join(", ")}`);
776
+ }
777
+ const target = path.resolve(process.cwd(), typeof flags.target === "string" ? flags.target : ".");
778
+
779
+ const frontendDest = path.join(target, "frontend", "slices", slug);
780
+ const convexDest = path.join(target, "convex", "features", slug);
781
+ if (existsSync(frontendDest)) {
782
+ throw new Error(`Already exists: ${frontendDest}`);
783
+ }
784
+ if (existsSync(convexDest)) {
785
+ throw new Error(`Already exists: ${convexDest}`);
786
+ }
787
+
788
+ console.log(kleur.bold(`\n→ Scaffolding slice ${kleur.cyan(slug)} (${category})\n`));
789
+
790
+ process.stdout.write(` pulling frontend half ... `);
791
+ await pull("frontend/slices/_templates/example-feature", frontendDest);
792
+ console.log(kleur.green("ok"));
793
+
794
+ process.stdout.write(` pulling convex half ... `);
795
+ await pull("convex/features/example-feature", convexDest);
796
+ console.log(kleur.green("ok"));
797
+
798
+ process.stdout.write(` rewriting identifiers ... `);
799
+ rewriteSlugInTree(frontendDest, "example-feature", slug, category);
800
+ rewriteSlugInTree(convexDest, "example-feature", slug, category);
801
+ console.log(kleur.green("ok"));
802
+
803
+ console.log(`\n${kleur.green("✓")} Slice ${kleur.bold(slug)} scaffolded.`);
804
+ console.log(`\n${kleur.bold("Next:")}`);
805
+ console.log(` 1. Edit ${kleur.cyan(`frontend/slices/${slug}/slice.json`)} — set description, deps, peers.`);
806
+ console.log(` 2. Replace stub UI in ${kleur.cyan(`frontend/slices/${slug}/{page,components}`)}.`);
807
+ console.log(` 3. Define your backend in ${kleur.cyan(`convex/features/${slug}/{schema,queries,mutations}.ts`)}.`);
808
+ console.log(` 4. Spread tables in ${kleur.cyan(`convex/schema.ts`)}: ${kleur.dim(`...${camelCase(slug)}Tables`)}`);
809
+ console.log(` 5. Run ${kleur.cyan("npm run gen:slices && node packages/cli/scripts/validate-slice.mjs")}.\n`);
810
+ }
811
+
812
+ function camelCase(slug) {
813
+ return slug.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
814
+ }
815
+
816
+ function pascalCase(slug) {
817
+ const cc = camelCase(slug);
818
+ return cc.charAt(0).toUpperCase() + cc.slice(1);
819
+ }
820
+
821
+ function rewriteSlugInTree(dir, fromSlug, toSlug, newCategory) {
822
+ const fromCamel = camelCase(fromSlug);
823
+ const fromPascal = pascalCase(fromSlug);
824
+ const toCamel = camelCase(toSlug);
825
+ const toPascal = pascalCase(toSlug);
826
+
827
+ for (const entry of readdirSync(dir)) {
828
+ const full = path.join(dir, entry);
829
+ const stat = statSync(full);
830
+ if (stat.isDirectory()) {
831
+ rewriteSlugInTree(full, fromSlug, toSlug, newCategory);
832
+ continue;
833
+ }
834
+ let body = readFileSync(full, "utf8");
835
+ const before = body;
836
+ body = body
837
+ .replaceAll(fromSlug, toSlug)
838
+ .replaceAll(fromPascal, toPascal)
839
+ .replaceAll(fromCamel, toCamel);
840
+ // slice.json: also patch category if user passed one
841
+ if (entry === "slice.json" && newCategory) {
842
+ body = body.replace(/"category"\s*:\s*"[^"]*"/, `"category": "${newCategory}"`);
843
+ }
844
+ if (body !== before) writeFileSync(full, body);
845
+ }
846
+ }
847
+
848
+ // ─── lift ─────────────────────────────────────────────────────────────────
849
+ //
850
+ // `npx rr lift <source>:<path>` pulls a slice tree (and its convex pair, when
851
+ // known) from one of three source kinds:
852
+ //
853
+ // rahman:<slug> — kitab manifest entry (uses `slices.ts`)
854
+ // superspace:<sub-path> — local ~/projects/superspace/<sub-path>
855
+ // github:<owner>/<repo>/<sub-path> — arbitrary tiged pull
856
+ //
857
+ // Flags:
858
+ // --target <dir> — destination project root (default cwd)
859
+ // --dry-run — show what would be pulled, write nothing
860
+
861
+ async function runLift(rest) {
862
+ const { positional, flags } = parseFlags(rest);
863
+ const [src] = positional;
864
+ if (!src) {
865
+ throw new Error("Usage: rahman-resources lift <source>:<path> [--target <dir>] [--dry-run]");
866
+ }
867
+ const target = path.resolve(process.cwd(), typeof flags.target === "string" ? flags.target : ".");
868
+ const dryRun = !!flags["dry-run"];
869
+
870
+ const parsed = parseLiftSource(src);
871
+ console.log(kleur.bold(`\n→ Lift ${kleur.cyan(src)} ${dryRun ? kleur.yellow("(dry-run)") : ""}\n`));
872
+
873
+ const plan = await resolveLiftPlan(parsed, target);
874
+
875
+ for (const step of plan.steps) {
876
+ console.log(` ${kleur.dim(step.from)} → ${kleur.cyan(step.toRel)}`);
877
+ }
878
+ if (plan.peers.length > 0) {
879
+ console.log(`\n Peers required:`);
880
+ for (const p of plan.peers) console.log(` · ${kleur.cyan(p.slug)} ${kleur.dim(p.range)}`);
881
+ }
882
+ if (plan.npm.length > 0) console.log(`\n npm: ${plan.npm.join(" ")}`);
883
+ if (plan.shadcn.length > 0) console.log(` shadcn: ${plan.shadcn.join(" ")}`);
884
+ if (plan.env.length > 0) {
885
+ console.log(`\n env vars to set:`);
886
+ for (const e of plan.env) console.log(` ${e.scope === "next-public" ? "NEXT_PUBLIC_" : ""}${e.name}=… ${kleur.dim(`(${e.scope})`)}`);
887
+ }
888
+
889
+ if (dryRun) {
890
+ console.log(`\n${kleur.yellow("dry-run — no changes written.")}\n`);
891
+ return;
892
+ }
893
+
894
+ for (const step of plan.steps) {
895
+ process.stdout.write(`\n pulling ${kleur.dim(step.from)} ... `);
896
+ if (parsed.kind === "superspace-local") {
897
+ copyLocalTree(step.localFromAbs, step.toAbs);
898
+ } else {
899
+ await pull(step.from, step.toAbs);
900
+ }
901
+ console.log(kleur.green("ok"));
902
+ }
903
+
904
+ if (plan.npm.length > 0 && hasPackageJson(target)) {
905
+ const pm = detectPM(target);
906
+ console.log(kleur.bold(`\n→ Installing ${plan.npm.length} npm dep(s) via ${kleur.cyan(pm)}\n`));
907
+ await runPM(pm, plan.npm, target);
908
+ }
909
+ if (plan.shadcn.length > 0 && hasPackageJson(target)) {
910
+ await maybeRunShadcnAdd({ slug: parsed.slug ?? "lifted", shadcnComponents: plan.shadcn }, target, false);
911
+ }
912
+
913
+ console.log(`\n${kleur.green("✓")} Lift complete.`);
914
+ if (plan.env.length > 0) {
915
+ console.log(`${kleur.bold("Don't forget:")} set the env vars listed above before running.\n`);
916
+ }
917
+ }
918
+
919
+ function parseLiftSource(src) {
920
+ const m = src.match(/^(rahman|superspace|github):(.+)$/);
921
+ if (!m) {
922
+ throw new Error(`lift: source must look like "rahman:<slug>" or "superspace:<path>" or "github:<owner>/<repo>/<path>"`);
923
+ }
924
+ const [, scheme, rest] = m;
925
+ if (scheme === "rahman") {
926
+ return { kind: "rahman", slug: rest };
927
+ }
928
+ if (scheme === "superspace") {
929
+ return { kind: "superspace-local", subPath: rest };
930
+ }
931
+ // github:<owner>/<repo>/<sub-path>
932
+ const gh = rest.match(/^([^/]+)\/([^/]+)\/(.+)$/);
933
+ if (!gh) throw new Error(`lift: github source must be "<owner>/<repo>/<sub-path>"`);
934
+ return { kind: "github", owner: gh[1], repo: gh[2], subPath: gh[3] };
935
+ }
936
+
937
+ async function resolveLiftPlan(parsed, target) {
938
+ const steps = [];
939
+ const peers = [];
940
+ const npm = [];
941
+ const shadcn = [];
942
+ const env = [];
943
+
944
+ if (parsed.kind === "rahman") {
945
+ const slice = (manifest.slices ?? []).find((s) => s.slug === parsed.slug);
946
+ if (!slice) {
947
+ throw new Error(`Slice not found in manifest: ${parsed.slug}. Run 'list slices'.`);
948
+ }
949
+ steps.push({
950
+ from: slice.slicePath,
951
+ toRel: slice.slicePath,
952
+ toAbs: path.join(target, slice.slicePath),
953
+ });
954
+ for (const cp of slice.convexPaths ?? []) {
955
+ steps.push({ from: cp, toRel: cp, toAbs: path.join(target, cp) });
956
+ }
957
+ npm.push(...(slice.npm ?? []));
958
+ shadcn.push(...(slice.shadcn ?? []));
959
+ env.push(...(slice.env ?? []));
960
+ peers.push(...(slice.peers ?? []));
961
+ } else if (parsed.kind === "superspace-local") {
962
+ const SUPERSPACE = process.env.RAHMAN_SUPERSPACE_PATH ?? path.join(process.env.HOME ?? "", "projects/superspace");
963
+ const localFromAbs = path.join(SUPERSPACE, parsed.subPath);
964
+ if (!existsSync(localFromAbs)) {
965
+ throw new Error(`superspace local source not found: ${localFromAbs}\n set RAHMAN_SUPERSPACE_PATH to override.`);
966
+ }
967
+ steps.push({
968
+ from: `~/projects/superspace/${parsed.subPath}`,
969
+ toRel: parsed.subPath,
970
+ toAbs: path.join(target, parsed.subPath),
971
+ localFromAbs,
972
+ });
973
+ } else if (parsed.kind === "github") {
974
+ steps.push({
975
+ from: `${parsed.owner}/${parsed.repo}/${parsed.subPath}`,
976
+ toRel: parsed.subPath,
977
+ toAbs: path.join(target, parsed.subPath),
978
+ });
979
+ }
980
+ return { steps, peers, npm, shadcn, env };
981
+ }
982
+
983
+ function copyLocalTree(srcDir, destDir) {
984
+ const stat = statSync(srcDir);
985
+ if (!stat.isDirectory()) {
986
+ mkdirSync(path.dirname(destDir), { recursive: true });
987
+ writeFileSync(destDir, readFileSync(srcDir));
988
+ return;
989
+ }
990
+ mkdirSync(destDir, { recursive: true });
991
+ for (const name of readdirSync(srcDir)) {
992
+ if (name === "node_modules" || name === ".next" || name === "dist") continue;
993
+ copyLocalTree(path.join(srcDir, name), path.join(destDir, name));
994
+ }
995
+ }
996
+
997
+ // ─── publish-slice ────────────────────────────────────────────────────────
998
+ //
999
+ // Validate a local slice and (optionally) open a PR upstream to add it to
1000
+ // the kitab. Default dry-run; pass --open-pr to actually create the PR.
1001
+
1002
+ async function runPublishSlice(rest) {
1003
+ const { positional, flags } = parseFlags(rest);
1004
+ const [sliceDir] = positional;
1005
+ if (!sliceDir) {
1006
+ throw new Error("Usage: rahman-resources publish-slice <local-slice-dir> [--open-pr]");
1007
+ }
1008
+ const abs = path.resolve(process.cwd(), sliceDir);
1009
+ if (!existsSync(abs) || !existsSync(path.join(abs, "slice.json"))) {
1010
+ throw new Error(`Not a slice dir (no slice.json): ${abs}`);
1011
+ }
1012
+ console.log(kleur.bold(`\n→ Validating ${kleur.cyan(abs)}\n`));
1013
+
1014
+ // Run the validator script as a subprocess so any user-side override holds.
1015
+ await new Promise((resolve, reject) => {
1016
+ const ps = spawn(
1017
+ "node",
1018
+ [path.join(__dirname, "../scripts/validate-slice.mjs"), path.join(abs, "slice.json")],
1019
+ { stdio: "inherit", shell: true },
1020
+ );
1021
+ ps.on("error", reject);
1022
+ ps.on("exit", (code) => (code === 0 ? resolve() : reject(new Error(`validate-slice exited ${code}`))));
1023
+ });
1024
+
1025
+ if (!flags["open-pr"]) {
1026
+ console.log(`\n${kleur.yellow("dry-run — pass --open-pr to actually open a PR upstream.")}`);
1027
+ return;
1028
+ }
1029
+
1030
+ // Open a PR. We don't push branches automatically — instruct the user.
1031
+ console.log(`\n${kleur.bold("Opening PR upstream:")}`);
1032
+ console.log(`
1033
+ 1. Fork ${kleur.cyan("https://github.com/rahmanef63/resource-site")} (if not already).
1034
+ 2. Clone your fork, copy this slice into ${kleur.cyan(`frontend/slices/<slug>/`)} and the
1035
+ convex half into ${kleur.cyan(`convex/features/<slug>/`)}.
1036
+ 3. Add an entry to ${kleur.cyan("lib/content/slices.ts")}.
1037
+ 4. Run ${kleur.cyan("npm run slices:check")} locally.
1038
+ 5. ${kleur.cyan(`gh pr create --title "feat(slices): add <slug>" --body "..."`)}.
1039
+
1040
+ (Auto-PR via gh CLI not yet implemented — coming in a future release.)
1041
+ `);
1042
+ }
1043
+
539
1044
  // ─── helpers ──────────────────────────────────────────────────────────────
540
1045
 
541
1046
  async function pull(repoPath, dest) {