rahman-resources 0.4.2 → 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 +519 -14
- package/lib/manifest.json +643 -56
- package/lib/slice-schema.json +161 -0
- package/lib/starter/_gitignore +3 -1
- package/lib/starter/_package.json +1 -0
- package/lib/starter/app/layout.tsx +4 -1
- package/lib/starter/components/convex-provider.tsx +31 -7
- package/lib/starter/tsconfig.json +5 -2
- package/lib/workflows/features.md +50 -0
- package/lib/workflows/recipes.md +42 -0
- package/lib/workflows/skills.md +48 -0
- package/lib/workflows/templates.md +51 -0
- package/package.json +7 -3
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
|
|
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
|
|
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(
|
|
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 === "
|
|
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) {
|