rahman-resources 0.4.3 → 0.8.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 +673 -23
- package/lib/manifest.json +432 -25
- package/lib/rr-schema.json +14 -0
- package/lib/rr.mjs +19 -0
- 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 +4 -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
|
|
|
@@ -24,6 +25,7 @@ import {
|
|
|
24
25
|
rrExists,
|
|
25
26
|
validateRr,
|
|
26
27
|
addFeature as rrAddFeature,
|
|
28
|
+
addSlice as rrAddSlice,
|
|
27
29
|
addSkill as rrAddSkill,
|
|
28
30
|
} from "../lib/rr.mjs";
|
|
29
31
|
import { runPostInit } from "../lib/post-init.mjs";
|
|
@@ -37,7 +39,7 @@ const REPO = manifest.repo ?? "rahmanef63/resource-site";
|
|
|
37
39
|
const BRANCH = manifest.branch ?? "main";
|
|
38
40
|
const SKILLS_REPO = "anthropics/skills";
|
|
39
41
|
|
|
40
|
-
const KINDS = /** @type {const} */ (["layout", "recipe", "feature"]);
|
|
42
|
+
const KINDS = /** @type {const} */ (["slice", "layout", "recipe", "feature"]);
|
|
41
43
|
|
|
42
44
|
const [, , cmd, ...rest] = process.argv;
|
|
43
45
|
|
|
@@ -58,6 +60,12 @@ async function main() {
|
|
|
58
60
|
return runAdd(rest);
|
|
59
61
|
case "add-skill":
|
|
60
62
|
return runAddSkill(rest);
|
|
63
|
+
case "scaffold-slice":
|
|
64
|
+
return runScaffoldSlice(rest);
|
|
65
|
+
case "lift":
|
|
66
|
+
return runLift(rest);
|
|
67
|
+
case "publish-slice":
|
|
68
|
+
return runPublishSlice(rest);
|
|
61
69
|
case "list":
|
|
62
70
|
case "ls":
|
|
63
71
|
return runList(rest);
|
|
@@ -94,10 +102,13 @@ ${kleur.bold("rahman-resources")} — scaffold + install templates, recipes, fea
|
|
|
94
102
|
|
|
95
103
|
${kleur.bold("Usage:")}
|
|
96
104
|
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]
|
|
105
|
+
[--no-install] [--with-shadcn-reinit] [--with-shadcn-all]
|
|
106
|
+
npx rahman-resources add <slug> [target-dir] [--at root|preview] [--with-shadcn-all]
|
|
99
107
|
npx rahman-resources add-skill <slug> [target-dir]
|
|
100
|
-
npx rahman-resources
|
|
108
|
+
npx rahman-resources scaffold-slice <slug> [--category <cat>] [--target <dir>]
|
|
109
|
+
npx rahman-resources lift <source>:<path> [--target <dir>] [--dry-run]
|
|
110
|
+
npx rahman-resources publish-slice <local-slice-dir> [--open-pr]
|
|
111
|
+
npx rahman-resources list [layouts|recipes|features|skills|slices]
|
|
101
112
|
npx rahman-resources info <slug>
|
|
102
113
|
npx rahman-resources doctor
|
|
103
114
|
npx rahman-resources mcp
|
|
@@ -105,12 +116,21 @@ ${kleur.bold("Usage:")}
|
|
|
105
116
|
${kleur.bold("Init flags:")}
|
|
106
117
|
--no-install skip 'npm install' step (faster scaffolds; you run it manually)
|
|
107
118
|
--with-shadcn-reinit delete starter components.json + run 'npx shadcn init -y -d' (canonical shadcn flow)
|
|
119
|
+
--with-shadcn-all run 'npx shadcn add --all' instead of the per-template list
|
|
120
|
+
(heavy; ~50 components — use only if you'll customize beyond the template)
|
|
121
|
+
|
|
122
|
+
${kleur.bold("Add flags:")}
|
|
123
|
+
--at root install template AT app/(public)/ + app/admin/ (recommended; rewrites
|
|
124
|
+
/preview/<slug> path constants in nav-config/robots/sitemap)
|
|
125
|
+
--at preview install template AT app/preview/<slug>/ (default — sandbox style)
|
|
126
|
+
--with-shadcn-all same as init flag
|
|
108
127
|
|
|
109
128
|
${kleur.bold("Examples:")}
|
|
110
129
|
npx rahman-resources init my-app
|
|
111
130
|
npx rahman-resources init my-app --template personal-brand-os --skills frontend-design,mcp-builder
|
|
112
131
|
npx rahman-resources init my-app --no-install
|
|
113
|
-
npx rahman-resources add personal-brand-os .
|
|
132
|
+
npx rahman-resources add personal-brand-os . --at root
|
|
133
|
+
npx rahman-resources add personal-brand-os . --with-shadcn-all
|
|
114
134
|
npx rahman-resources add-skill webapp-testing
|
|
115
135
|
npx rahman-resources list skills
|
|
116
136
|
`);
|
|
@@ -144,7 +164,7 @@ function csv(s) {
|
|
|
144
164
|
|
|
145
165
|
function findEntry(slug) {
|
|
146
166
|
for (const kind of KINDS) {
|
|
147
|
-
const list = manifest[kind + "s"];
|
|
167
|
+
const list = manifest[kind + "s"] ?? [];
|
|
148
168
|
const e = list.find((x) => x.slug === slug);
|
|
149
169
|
if (e) return { kind, entry: e };
|
|
150
170
|
}
|
|
@@ -160,7 +180,7 @@ function findSkill(slug) {
|
|
|
160
180
|
function runList([filter]) {
|
|
161
181
|
const groups = filter
|
|
162
182
|
? [filter]
|
|
163
|
-
: ["layouts", "recipes", "features", "skills"];
|
|
183
|
+
: ["slices", "layouts", "recipes", "features", "skills"];
|
|
164
184
|
for (const g of groups) {
|
|
165
185
|
if (g === "skills") {
|
|
166
186
|
console.log(`\n${kleur.bold("SKILLS")} ${kleur.dim(`(${skillsInventory.skills.length})`)}\n`);
|
|
@@ -219,6 +239,22 @@ ${t.description}
|
|
|
219
239
|
} else if (kind === "recipe") {
|
|
220
240
|
console.log(`${kleur.bold("Files:")}`);
|
|
221
241
|
console.log(t.files.map((f) => ` · ${f}`).join("\n"));
|
|
242
|
+
} else if (kind === "slice") {
|
|
243
|
+
console.log(`${kleur.bold("Pulls:")}`);
|
|
244
|
+
console.log(` · ${t.slicePath}`);
|
|
245
|
+
for (const cp of t.convexPaths ?? []) console.log(` · ${cp}`);
|
|
246
|
+
if (t.npm?.length) console.log(`\n${kleur.bold("npm:")}\n ${t.npm.join(" ")}`);
|
|
247
|
+
if (t.shadcn?.length) console.log(`\n${kleur.bold("shadcn:")}\n ${t.shadcn.join(" ")}`);
|
|
248
|
+
if (t.env?.length) {
|
|
249
|
+
console.log(`\n${kleur.bold("env:")}`);
|
|
250
|
+
for (const e of t.env) console.log(` · ${e.name} ${kleur.dim(`(${e.scope})`)}${e.required === false ? kleur.dim(" optional") : ""}`);
|
|
251
|
+
}
|
|
252
|
+
if (t.peers?.length) {
|
|
253
|
+
console.log(`\n${kleur.bold("peers:")}`);
|
|
254
|
+
for (const p of t.peers) console.log(` · ${p.slug} ${p.range}`);
|
|
255
|
+
}
|
|
256
|
+
if (t.providers?.length) console.log(`\n${kleur.bold("providers:")} ${t.providers.join(", ")}`);
|
|
257
|
+
console.log(`\n${kleur.bold("Install:")} ${kleur.cyan(`npx rahman-resources add ${t.slug}`)}`);
|
|
222
258
|
}
|
|
223
259
|
if (t.docsUrl) console.log(`\n${kleur.dim(`Docs: ${t.docsUrl}`)}`);
|
|
224
260
|
console.log(`${kleur.dim(`Source: ${t.source ?? "—"}`)}\n`);
|
|
@@ -339,6 +375,20 @@ async function runInit(rest) {
|
|
|
339
375
|
await pull(p, dest);
|
|
340
376
|
console.log(kleur.green("ok"));
|
|
341
377
|
}
|
|
378
|
+
if (!skipInstall) {
|
|
379
|
+
await maybeRunShadcnAdd(t, target, !!flags["with-shadcn-all"]);
|
|
380
|
+
} else {
|
|
381
|
+
console.log(kleur.dim(`\n (skipping shadcn add — --no-install)`));
|
|
382
|
+
}
|
|
383
|
+
// Strip the placeholder app/page.tsx — the template owns the root route.
|
|
384
|
+
const placeholder = path.join(target, "app", "page.tsx");
|
|
385
|
+
if (existsSync(placeholder)) {
|
|
386
|
+
try { rmSync(placeholder); console.log(` ${kleur.dim("removed placeholder")} app/page.tsx`); } catch {}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (!skipInstall) {
|
|
391
|
+
await runOfflineConvexCodegen(target);
|
|
342
392
|
}
|
|
343
393
|
|
|
344
394
|
if (skills.length) {
|
|
@@ -366,7 +416,9 @@ function runShell(cmd, args, cwd) {
|
|
|
366
416
|
|
|
367
417
|
// ─── add (template / feature / recipe) ────────────────────────────────────
|
|
368
418
|
|
|
369
|
-
async function runAdd(
|
|
419
|
+
async function runAdd(rest) {
|
|
420
|
+
const { positional, flags } = parseFlags(rest);
|
|
421
|
+
const [slug, targetArg = "."] = positional;
|
|
370
422
|
if (!slug) {
|
|
371
423
|
console.error(kleur.red("Missing slug."));
|
|
372
424
|
printHelp();
|
|
@@ -377,16 +429,22 @@ async function runAdd([slug, targetArg = "."]) {
|
|
|
377
429
|
const { kind, entry } = found;
|
|
378
430
|
const target = path.resolve(process.cwd(), targetArg);
|
|
379
431
|
|
|
380
|
-
if (kind === "
|
|
432
|
+
if (kind === "slice") return runLift([`rahman:${entry.slug}`, ...(targetArg !== "." ? ["--target", targetArg] : [])]);
|
|
433
|
+
if (kind === "layout") return addLayout(entry, target, targetArg, flags);
|
|
381
434
|
if (kind === "feature") return addFeature(entry, target, targetArg);
|
|
382
435
|
if (kind === "recipe") return addRecipe(entry);
|
|
383
436
|
}
|
|
384
437
|
|
|
385
|
-
async function addLayout(t, target, targetArg) {
|
|
438
|
+
async function addLayout(t, target, targetArg, flags = {}) {
|
|
386
439
|
console.log(kleur.bold(`\n→ Installing ${kleur.cyan(t.title)} into ${kleur.dim(target)}\n`));
|
|
387
440
|
if (!t.pullPaths || t.pullPaths.length === 0) {
|
|
388
441
|
throw new Error(`Layout "${t.slug}" has no valid pullPaths in manifest.`);
|
|
389
442
|
}
|
|
443
|
+
const at = typeof flags.at === "string" ? flags.at : "preview";
|
|
444
|
+
if (!["root", "preview"].includes(at)) {
|
|
445
|
+
throw new Error(`--at must be "root" or "preview" (got "${at}").`);
|
|
446
|
+
}
|
|
447
|
+
|
|
390
448
|
for (const p of t.pullPaths) {
|
|
391
449
|
const dest = path.join(target, p);
|
|
392
450
|
process.stdout.write(` pulling ${kleur.dim(p)} ... `);
|
|
@@ -405,6 +463,12 @@ async function addLayout(t, target, targetArg) {
|
|
|
405
463
|
}
|
|
406
464
|
}
|
|
407
465
|
|
|
466
|
+
await maybeRunShadcnAdd(t, target, !!flags["with-shadcn-all"]);
|
|
467
|
+
|
|
468
|
+
if (at === "root") {
|
|
469
|
+
promoteToRoot(t, target);
|
|
470
|
+
}
|
|
471
|
+
|
|
408
472
|
if (rrExists(target)) {
|
|
409
473
|
const rr = readRr(target);
|
|
410
474
|
rr.template = { slug: t.slug, version: "main" };
|
|
@@ -415,6 +479,160 @@ async function addLayout(t, target, targetArg) {
|
|
|
415
479
|
if (t.agentRecipe) console.log(`\n${kleur.bold("Next:")}\n${indent(t.agentRecipe, 2)}\n`);
|
|
416
480
|
}
|
|
417
481
|
|
|
482
|
+
// ─── offline convex codegen ───────────────────────────────────────────────
|
|
483
|
+
//
|
|
484
|
+
// Self-hosted deploys (Dokploy etc) can't run codegen inside Docker because
|
|
485
|
+
// they have no Convex auth context. Postmortem 1.2: the workaround is to
|
|
486
|
+
// generate types locally with a dummy admin key + typecheck disabled, then
|
|
487
|
+
// commit `convex/_generated/` so the Docker build can typecheck against it.
|
|
488
|
+
async function runOfflineConvexCodegen(target) {
|
|
489
|
+
const convexDir = path.join(target, "convex");
|
|
490
|
+
if (!existsSync(convexDir)) return;
|
|
491
|
+
const generated = path.join(convexDir, "_generated");
|
|
492
|
+
if (existsSync(generated)) {
|
|
493
|
+
console.log(kleur.dim(`\n (convex/_generated already present — skipping codegen)`));
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
console.log(kleur.bold(`\n→ Generating convex/_generated (offline)\n`));
|
|
497
|
+
try {
|
|
498
|
+
await new Promise((resolve, reject) => {
|
|
499
|
+
const ps = spawn(
|
|
500
|
+
"npx",
|
|
501
|
+
["convex", "codegen", "--typecheck=disable"],
|
|
502
|
+
{
|
|
503
|
+
cwd: target,
|
|
504
|
+
stdio: "inherit",
|
|
505
|
+
shell: true,
|
|
506
|
+
env: {
|
|
507
|
+
...process.env,
|
|
508
|
+
CONVEX_SELF_HOSTED_URL: "http://localhost:3210",
|
|
509
|
+
CONVEX_SELF_HOSTED_ADMIN_KEY: "x|x",
|
|
510
|
+
},
|
|
511
|
+
},
|
|
512
|
+
);
|
|
513
|
+
ps.on("error", reject);
|
|
514
|
+
ps.on("exit", (code) => (code === 0 ? resolve() : reject(new Error(`convex codegen exited ${code}`))));
|
|
515
|
+
});
|
|
516
|
+
} catch (err) {
|
|
517
|
+
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`));
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// ─── shadcn auto-add ──────────────────────────────────────────────────────
|
|
522
|
+
|
|
523
|
+
async function maybeRunShadcnAdd(t, target, all) {
|
|
524
|
+
if (!hasPackageJson(target)) {
|
|
525
|
+
console.log(kleur.dim(`\n (skipping shadcn add — no package.json in target)`));
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
const componentsJson = path.join(target, "components.json");
|
|
529
|
+
if (!existsSync(componentsJson)) {
|
|
530
|
+
console.log(kleur.yellow(`\n ⚠ components.json missing — run 'npx shadcn init' first, then re-add.`));
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
const list = all ? ["--all"] : (t.shadcnComponents ?? []);
|
|
534
|
+
if (list.length === 0) {
|
|
535
|
+
console.log(kleur.dim(`\n (skipping shadcn add — no shadcnComponents declared for ${t.slug})`));
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
console.log(kleur.bold(`\n→ Installing shadcn components ${all ? "(--all)" : `(${list.length})`}\n`));
|
|
539
|
+
console.log(kleur.dim(` ${list.join(" ")}\n`));
|
|
540
|
+
try {
|
|
541
|
+
await runShell("npx", ["shadcn@latest", "add", ...list, "--yes", "--overwrite"], target);
|
|
542
|
+
} catch (err) {
|
|
543
|
+
console.log(kleur.yellow(` ⚠ shadcn add failed (${err.message}). Run manually: npx shadcn@latest add ${list.join(" ")}`));
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// ─── promote-to-root: move template files out of app/preview/<slug>/ into
|
|
548
|
+
// app/(public)/ + app/admin/, then rewrite hardcoded /preview/<slug>
|
|
549
|
+
// path constants in nav-config / robots / sitemap / site-config. ────────
|
|
550
|
+
|
|
551
|
+
function promoteToRoot(t, target) {
|
|
552
|
+
const previewDir = path.join(target, "app", "preview", t.slug);
|
|
553
|
+
if (!existsSync(previewDir)) {
|
|
554
|
+
console.log(kleur.dim(`\n (--at root: ${previewDir} not found — skipping promote)`));
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
console.log(kleur.bold(`\n→ Promoting to app/(public)/ + app/admin/ (--at root)\n`));
|
|
558
|
+
|
|
559
|
+
const publicSrc = path.join(previewDir, "public");
|
|
560
|
+
const adminSrc = path.join(previewDir, "admin");
|
|
561
|
+
const publicDest = path.join(target, "app", "(public)");
|
|
562
|
+
const adminDest = path.join(target, "app", "admin");
|
|
563
|
+
|
|
564
|
+
if (existsSync(publicSrc)) {
|
|
565
|
+
mvTree(publicSrc, publicDest);
|
|
566
|
+
console.log(` ${kleur.green("+")} app/(public)/`);
|
|
567
|
+
}
|
|
568
|
+
if (existsSync(adminSrc)) {
|
|
569
|
+
mvTree(adminSrc, adminDest);
|
|
570
|
+
console.log(` ${kleur.green("+")} app/admin/`);
|
|
571
|
+
}
|
|
572
|
+
// Move robots/sitemap/og from app/preview/<slug>/ to app/
|
|
573
|
+
for (const stub of ["robots.ts", "sitemap.ts", "opengraph-image.tsx"]) {
|
|
574
|
+
const src = path.join(previewDir, stub);
|
|
575
|
+
if (existsSync(src)) {
|
|
576
|
+
const dest = path.join(target, "app", stub);
|
|
577
|
+
writeFileSync(dest, readFileSync(src, "utf8"));
|
|
578
|
+
console.log(` ${kleur.green("+")} app/${stub}`);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
rewritePreviewPaths(target, t.slug);
|
|
583
|
+
|
|
584
|
+
// Best-effort cleanup of now-empty preview dir
|
|
585
|
+
try { rmSync(previewDir, { recursive: true, force: true }); } catch {}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function mvTree(src, dest) {
|
|
589
|
+
mkdirSync(dest, { recursive: true });
|
|
590
|
+
for (const entry of readdirSync(src)) {
|
|
591
|
+
const sFull = path.join(src, entry);
|
|
592
|
+
const dFull = path.join(dest, entry);
|
|
593
|
+
const stat = statSync(sFull);
|
|
594
|
+
if (stat.isDirectory()) {
|
|
595
|
+
mvTree(sFull, dFull);
|
|
596
|
+
} else {
|
|
597
|
+
writeFileSync(dFull, readFileSync(sFull, "utf8"));
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// Rewrite hardcoded /preview/<slug>/{public,admin} → "" / "/admin" in known files
|
|
603
|
+
// (nav-config, robots, sitemap, site-config, plus any *Page.tsx that hardcodes them).
|
|
604
|
+
function rewritePreviewPaths(target, slug) {
|
|
605
|
+
const previewBase = `/preview/${slug}`;
|
|
606
|
+
const candidates = [
|
|
607
|
+
path.join(target, "app", "robots.ts"),
|
|
608
|
+
path.join(target, "app", "sitemap.ts"),
|
|
609
|
+
];
|
|
610
|
+
// also components/templates/<base>/shared/{site-config,nav-config}.ts
|
|
611
|
+
const tplShared = path.join(target, "components", "templates");
|
|
612
|
+
if (existsSync(tplShared)) {
|
|
613
|
+
for (const baseDir of readdirSync(tplShared)) {
|
|
614
|
+
const sharedDir = path.join(tplShared, baseDir, "shared");
|
|
615
|
+
if (!existsSync(sharedDir)) continue;
|
|
616
|
+
for (const f of ["site-config.ts", "nav-config.ts"]) {
|
|
617
|
+
const p = path.join(sharedDir, f);
|
|
618
|
+
if (existsSync(p)) candidates.push(p);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
for (const f of candidates) {
|
|
623
|
+
if (!existsSync(f)) continue;
|
|
624
|
+
const before = readFileSync(f, "utf8");
|
|
625
|
+
const after = before
|
|
626
|
+
.replaceAll(`${previewBase}/public`, "")
|
|
627
|
+
.replaceAll(`${previewBase}/admin`, "/admin")
|
|
628
|
+
.replaceAll(previewBase, "");
|
|
629
|
+
if (after !== before) {
|
|
630
|
+
writeFileSync(f, after);
|
|
631
|
+
console.log(` ${kleur.dim("rewrote")} ${path.relative(target, f)}`);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
418
636
|
async function addFeature(t, target, targetArg) {
|
|
419
637
|
console.log(kleur.bold(`\n→ Adding feature ${kleur.cyan(t.title)} to ${kleur.dim(target)}\n`));
|
|
420
638
|
if (!t.npmPackages || t.npmPackages.length === 0) {
|
|
@@ -497,7 +715,7 @@ async function installSkill(slug, target) {
|
|
|
497
715
|
|
|
498
716
|
// ─── doctor ───────────────────────────────────────────────────────────────
|
|
499
717
|
|
|
500
|
-
function runDoctor() {
|
|
718
|
+
function runDoctor(rest = []) {
|
|
501
719
|
const target = process.cwd();
|
|
502
720
|
if (!rrExists(target)) {
|
|
503
721
|
console.log(kleur.yellow("⚠ No rr.json found in cwd. Run 'rahman-resources init <app>' first."));
|
|
@@ -505,16 +723,102 @@ function runDoctor() {
|
|
|
505
723
|
}
|
|
506
724
|
const rr = readRr(target);
|
|
507
725
|
const issues = validateRr(rr);
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
console.log(
|
|
512
|
-
console.log(`
|
|
726
|
+
const sliceCheck = rest.includes("--slices");
|
|
727
|
+
|
|
728
|
+
if (issues.length > 0) {
|
|
729
|
+
console.log(kleur.red(`✖ rr.json has ${issues.length} issue(s):`));
|
|
730
|
+
for (const i of issues) console.log(` · ${i}`);
|
|
731
|
+
process.exit(1);
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
console.log(kleur.green("✓ rr.json is valid."));
|
|
735
|
+
console.log(` template: ${kleur.cyan(rr.template?.slug ?? "(none)")}`);
|
|
736
|
+
console.log(` features: ${kleur.cyan(rr.features?.length ?? 0)}`);
|
|
737
|
+
console.log(` slices: ${kleur.cyan(rr.slices?.length ?? 0)}`);
|
|
738
|
+
console.log(` skills: ${kleur.cyan(rr.skills?.length ?? 0)}`);
|
|
739
|
+
|
|
740
|
+
if (sliceCheck) doctorSlices(target, rr);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
function doctorSlices(target, rr) {
|
|
744
|
+
console.log(kleur.bold(`\n→ Slice composition check\n`));
|
|
745
|
+
const installed = rr.slices ?? [];
|
|
746
|
+
if (installed.length === 0) {
|
|
747
|
+
console.log(kleur.dim(" (no slices installed — nothing to check)"));
|
|
513
748
|
return;
|
|
514
749
|
}
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
750
|
+
|
|
751
|
+
const errors = [];
|
|
752
|
+
const warnings = [];
|
|
753
|
+
const sliceMap = new Map((manifest.slices ?? []).map((s) => [s.slug, s]));
|
|
754
|
+
const present = new Set(installed.map((s) => s.slug));
|
|
755
|
+
|
|
756
|
+
for (const inst of installed) {
|
|
757
|
+
const def = sliceMap.get(inst.slug);
|
|
758
|
+
if (!def) {
|
|
759
|
+
warnings.push(`${inst.slug}: not in kitab manifest (custom slice — skipping peer check)`);
|
|
760
|
+
continue;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// 1. Slice path exists locally
|
|
764
|
+
const slicePath = path.join(target, def.slicePath);
|
|
765
|
+
if (!existsSync(slicePath)) {
|
|
766
|
+
errors.push(`${inst.slug}: missing on disk at ${def.slicePath}`);
|
|
767
|
+
}
|
|
768
|
+
const sliceJsonPath = path.join(slicePath, "slice.json");
|
|
769
|
+
if (existsSync(slicePath) && !existsSync(sliceJsonPath)) {
|
|
770
|
+
errors.push(`${inst.slug}: slice.json missing at ${def.slicePath}/slice.json`);
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// 2. Convex paths exist locally (when declared)
|
|
774
|
+
for (const cp of def.convexPaths ?? []) {
|
|
775
|
+
const cpAbs = path.join(target, cp);
|
|
776
|
+
if (!existsSync(cpAbs)) {
|
|
777
|
+
warnings.push(`${inst.slug}: convex path missing at ${cp} (lift again with 'npx rr add ${inst.slug}')`);
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// 3. Peers transitively present
|
|
782
|
+
for (const peer of def.peers ?? []) {
|
|
783
|
+
if (!present.has(peer.slug)) {
|
|
784
|
+
errors.push(`${inst.slug} requires peer ${peer.slug} ${peer.range} — not installed`);
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
// 4. Convex table-name collision across installed slices
|
|
790
|
+
const tableOwners = new Map();
|
|
791
|
+
for (const inst of installed) {
|
|
792
|
+
const def = sliceMap.get(inst.slug);
|
|
793
|
+
if (!def) continue;
|
|
794
|
+
for (const cp of def.convexPaths ?? []) {
|
|
795
|
+
const schemaFile = path.join(target, cp, "schema.ts");
|
|
796
|
+
if (!existsSync(schemaFile)) continue;
|
|
797
|
+
const body = readFileSync(schemaFile, "utf8");
|
|
798
|
+
const matches = [...body.matchAll(/^\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*:\s*defineTable\(/gm)];
|
|
799
|
+
for (const m of matches) {
|
|
800
|
+
const tName = m[1];
|
|
801
|
+
if (tableOwners.has(tName)) {
|
|
802
|
+
errors.push(`convex table "${tName}" declared by both ${tableOwners.get(tName)} and ${inst.slug}`);
|
|
803
|
+
} else {
|
|
804
|
+
tableOwners.set(tName, inst.slug);
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
if (warnings.length > 0) {
|
|
811
|
+
console.log(kleur.yellow(` ⚠ ${warnings.length} warning(s):`));
|
|
812
|
+
for (const w of warnings) console.log(` · ${w}`);
|
|
813
|
+
}
|
|
814
|
+
if (errors.length > 0) {
|
|
815
|
+
console.log(kleur.red(`\n ✖ ${errors.length} error(s):`));
|
|
816
|
+
for (const e of errors) console.log(` · ${e}`);
|
|
817
|
+
process.exit(1);
|
|
818
|
+
}
|
|
819
|
+
if (errors.length === 0 && warnings.length === 0) {
|
|
820
|
+
console.log(kleur.green(` ✓ ${installed.length} slice(s) — composition healthy`));
|
|
821
|
+
}
|
|
518
822
|
}
|
|
519
823
|
|
|
520
824
|
function runMcpHint() {
|
|
@@ -536,6 +840,352 @@ Then in Claude Code: ${kleur.cyan("/mcp")} to see available rr_* tools.
|
|
|
536
840
|
`);
|
|
537
841
|
}
|
|
538
842
|
|
|
843
|
+
// ─── scaffold-slice ───────────────────────────────────────────────────────
|
|
844
|
+
//
|
|
845
|
+
// Creates a new slice from the kitab's `frontend/slices/_templates/example-feature/`
|
|
846
|
+
// reference. Pulls both the frontend half + the convex backend half, then
|
|
847
|
+
// rewrites the slug + camelCase identifiers everywhere they appear.
|
|
848
|
+
|
|
849
|
+
const VALID_CATEGORIES = ["ai", "auth", "data", "payment", "email", "realtime", "storage", "search", "content", "ui", "infra"];
|
|
850
|
+
|
|
851
|
+
async function runScaffoldSlice(rest) {
|
|
852
|
+
const { positional, flags } = parseFlags(rest);
|
|
853
|
+
const [slug] = positional;
|
|
854
|
+
if (!slug) {
|
|
855
|
+
throw new Error("Usage: rahman-resources scaffold-slice <slug> [--category <cat>] [--target <dir>]");
|
|
856
|
+
}
|
|
857
|
+
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(slug)) {
|
|
858
|
+
throw new Error(`Slug must be kebab-case (got "${slug}").`);
|
|
859
|
+
}
|
|
860
|
+
const category = typeof flags.category === "string" ? flags.category : "data";
|
|
861
|
+
if (!VALID_CATEGORIES.includes(category)) {
|
|
862
|
+
throw new Error(`--category must be one of: ${VALID_CATEGORIES.join(", ")}`);
|
|
863
|
+
}
|
|
864
|
+
const target = path.resolve(process.cwd(), typeof flags.target === "string" ? flags.target : ".");
|
|
865
|
+
|
|
866
|
+
const frontendDest = path.join(target, "frontend", "slices", slug);
|
|
867
|
+
const convexDest = path.join(target, "convex", "features", slug);
|
|
868
|
+
if (existsSync(frontendDest)) {
|
|
869
|
+
throw new Error(`Already exists: ${frontendDest}`);
|
|
870
|
+
}
|
|
871
|
+
if (existsSync(convexDest)) {
|
|
872
|
+
throw new Error(`Already exists: ${convexDest}`);
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
console.log(kleur.bold(`\n→ Scaffolding slice ${kleur.cyan(slug)} (${category})\n`));
|
|
876
|
+
|
|
877
|
+
process.stdout.write(` pulling frontend half ... `);
|
|
878
|
+
await pull("frontend/slices/_templates/example-feature", frontendDest);
|
|
879
|
+
console.log(kleur.green("ok"));
|
|
880
|
+
|
|
881
|
+
process.stdout.write(` pulling convex half ... `);
|
|
882
|
+
await pull("convex/features/example-feature", convexDest);
|
|
883
|
+
console.log(kleur.green("ok"));
|
|
884
|
+
|
|
885
|
+
process.stdout.write(` rewriting identifiers ... `);
|
|
886
|
+
rewriteSlugInTree(frontendDest, "example-feature", slug, category);
|
|
887
|
+
rewriteSlugInTree(convexDest, "example-feature", slug, category);
|
|
888
|
+
console.log(kleur.green("ok"));
|
|
889
|
+
|
|
890
|
+
console.log(`\n${kleur.green("✓")} Slice ${kleur.bold(slug)} scaffolded.`);
|
|
891
|
+
console.log(`\n${kleur.bold("Next:")}`);
|
|
892
|
+
console.log(` 1. Edit ${kleur.cyan(`frontend/slices/${slug}/slice.json`)} — set description, deps, peers.`);
|
|
893
|
+
console.log(` 2. Replace stub UI in ${kleur.cyan(`frontend/slices/${slug}/{page,components}`)}.`);
|
|
894
|
+
console.log(` 3. Define your backend in ${kleur.cyan(`convex/features/${slug}/{schema,queries,mutations}.ts`)}.`);
|
|
895
|
+
console.log(` 4. Spread tables in ${kleur.cyan(`convex/schema.ts`)}: ${kleur.dim(`...${camelCase(slug)}Tables`)}`);
|
|
896
|
+
console.log(` 5. Run ${kleur.cyan("npm run gen:slices && node packages/cli/scripts/validate-slice.mjs")}.\n`);
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
function camelCase(slug) {
|
|
900
|
+
return slug.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
function pascalCase(slug) {
|
|
904
|
+
const cc = camelCase(slug);
|
|
905
|
+
return cc.charAt(0).toUpperCase() + cc.slice(1);
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
function rewriteSlugInTree(dir, fromSlug, toSlug, newCategory) {
|
|
909
|
+
const fromCamel = camelCase(fromSlug);
|
|
910
|
+
const fromPascal = pascalCase(fromSlug);
|
|
911
|
+
const toCamel = camelCase(toSlug);
|
|
912
|
+
const toPascal = pascalCase(toSlug);
|
|
913
|
+
|
|
914
|
+
for (const entry of readdirSync(dir)) {
|
|
915
|
+
const full = path.join(dir, entry);
|
|
916
|
+
const stat = statSync(full);
|
|
917
|
+
if (stat.isDirectory()) {
|
|
918
|
+
rewriteSlugInTree(full, fromSlug, toSlug, newCategory);
|
|
919
|
+
continue;
|
|
920
|
+
}
|
|
921
|
+
let body = readFileSync(full, "utf8");
|
|
922
|
+
const before = body;
|
|
923
|
+
body = body
|
|
924
|
+
.replaceAll(fromSlug, toSlug)
|
|
925
|
+
.replaceAll(fromPascal, toPascal)
|
|
926
|
+
.replaceAll(fromCamel, toCamel);
|
|
927
|
+
// slice.json: also patch category if user passed one
|
|
928
|
+
if (entry === "slice.json" && newCategory) {
|
|
929
|
+
body = body.replace(/"category"\s*:\s*"[^"]*"/, `"category": "${newCategory}"`);
|
|
930
|
+
}
|
|
931
|
+
if (body !== before) writeFileSync(full, body);
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// ─── lift ─────────────────────────────────────────────────────────────────
|
|
936
|
+
//
|
|
937
|
+
// `npx rr lift <source>:<path>` pulls a slice tree (and its convex pair, when
|
|
938
|
+
// known) from one of three source kinds:
|
|
939
|
+
//
|
|
940
|
+
// rahman:<slug> — kitab manifest entry (uses `slices.ts`)
|
|
941
|
+
// superspace:<sub-path> — local ~/projects/superspace/<sub-path>
|
|
942
|
+
// github:<owner>/<repo>/<sub-path> — arbitrary tiged pull
|
|
943
|
+
//
|
|
944
|
+
// Flags:
|
|
945
|
+
// --target <dir> — destination project root (default cwd)
|
|
946
|
+
// --dry-run — show what would be pulled, write nothing
|
|
947
|
+
|
|
948
|
+
async function runLift(rest) {
|
|
949
|
+
const { positional, flags } = parseFlags(rest);
|
|
950
|
+
const [src] = positional;
|
|
951
|
+
if (!src) {
|
|
952
|
+
throw new Error("Usage: rahman-resources lift <source>:<path> [--target <dir>] [--dry-run]");
|
|
953
|
+
}
|
|
954
|
+
const target = path.resolve(process.cwd(), typeof flags.target === "string" ? flags.target : ".");
|
|
955
|
+
const dryRun = !!flags["dry-run"];
|
|
956
|
+
|
|
957
|
+
const parsed = parseLiftSource(src);
|
|
958
|
+
console.log(kleur.bold(`\n→ Lift ${kleur.cyan(src)} ${dryRun ? kleur.yellow("(dry-run)") : ""}\n`));
|
|
959
|
+
|
|
960
|
+
const plan = await resolveLiftPlan(parsed, target);
|
|
961
|
+
|
|
962
|
+
for (const step of plan.steps) {
|
|
963
|
+
console.log(` ${kleur.dim(step.from)} → ${kleur.cyan(step.toRel)}`);
|
|
964
|
+
}
|
|
965
|
+
if (plan.peers.length > 0) {
|
|
966
|
+
console.log(`\n Peers required:`);
|
|
967
|
+
for (const p of plan.peers) console.log(` · ${kleur.cyan(p.slug)} ${kleur.dim(p.range)}`);
|
|
968
|
+
}
|
|
969
|
+
if (plan.npm.length > 0) console.log(`\n npm: ${plan.npm.join(" ")}`);
|
|
970
|
+
if (plan.shadcn.length > 0) console.log(` shadcn: ${plan.shadcn.join(" ")}`);
|
|
971
|
+
if (plan.env.length > 0) {
|
|
972
|
+
console.log(`\n env vars to set:`);
|
|
973
|
+
for (const e of plan.env) console.log(` ${e.scope === "next-public" ? "NEXT_PUBLIC_" : ""}${e.name}=… ${kleur.dim(`(${e.scope})`)}`);
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
if (dryRun) {
|
|
977
|
+
console.log(`\n${kleur.yellow("dry-run — no changes written.")}\n`);
|
|
978
|
+
return;
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
for (const step of plan.steps) {
|
|
982
|
+
process.stdout.write(`\n pulling ${kleur.dim(step.from)} ... `);
|
|
983
|
+
if (parsed.kind === "superspace-local") {
|
|
984
|
+
copyLocalTree(step.localFromAbs, step.toAbs);
|
|
985
|
+
} else {
|
|
986
|
+
await pull(step.from, step.toAbs);
|
|
987
|
+
}
|
|
988
|
+
console.log(kleur.green("ok"));
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
if (plan.npm.length > 0 && hasPackageJson(target)) {
|
|
992
|
+
const pm = detectPM(target);
|
|
993
|
+
console.log(kleur.bold(`\n→ Installing ${plan.npm.length} npm dep(s) via ${kleur.cyan(pm)}\n`));
|
|
994
|
+
await runPM(pm, plan.npm, target);
|
|
995
|
+
}
|
|
996
|
+
if (plan.shadcn.length > 0 && hasPackageJson(target)) {
|
|
997
|
+
await maybeRunShadcnAdd({ slug: parsed.slug ?? "lifted", shadcnComponents: plan.shadcn }, target, false);
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
// Register the slice in the consumer's rr.json (if present).
|
|
1001
|
+
if (parsed.kind === "rahman" && rrExists(target)) {
|
|
1002
|
+
const slice = (manifest.slices ?? []).find((s) => s.slug === parsed.slug);
|
|
1003
|
+
if (slice) {
|
|
1004
|
+
const rr = readRr(target);
|
|
1005
|
+
rrAddSlice(rr, parsed.slug, { version: slice.version, category: slice.category });
|
|
1006
|
+
writeRr(rr, target);
|
|
1007
|
+
console.log(kleur.dim(` rr.json: slices += ${parsed.slug}@${slice.version}`));
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
console.log(`\n${kleur.green("✓")} Lift complete.`);
|
|
1012
|
+
if (plan.env.length > 0) {
|
|
1013
|
+
console.log(`${kleur.bold("Don't forget:")} set the env vars listed above before running.\n`);
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
function parseLiftSource(src) {
|
|
1018
|
+
const m = src.match(/^(rahman|superspace|github):(.+)$/);
|
|
1019
|
+
if (!m) {
|
|
1020
|
+
throw new Error(`lift: source must look like "rahman:<slug>" or "superspace:<path>" or "github:<owner>/<repo>/<path>"`);
|
|
1021
|
+
}
|
|
1022
|
+
const [, scheme, rest] = m;
|
|
1023
|
+
if (scheme === "rahman") {
|
|
1024
|
+
return { kind: "rahman", slug: rest };
|
|
1025
|
+
}
|
|
1026
|
+
if (scheme === "superspace") {
|
|
1027
|
+
return { kind: "superspace-local", subPath: rest };
|
|
1028
|
+
}
|
|
1029
|
+
// github:<owner>/<repo>/<sub-path>
|
|
1030
|
+
const gh = rest.match(/^([^/]+)\/([^/]+)\/(.+)$/);
|
|
1031
|
+
if (!gh) throw new Error(`lift: github source must be "<owner>/<repo>/<sub-path>"`);
|
|
1032
|
+
return { kind: "github", owner: gh[1], repo: gh[2], subPath: gh[3] };
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
async function resolveLiftPlan(parsed, target) {
|
|
1036
|
+
const steps = [];
|
|
1037
|
+
const peers = [];
|
|
1038
|
+
const npm = [];
|
|
1039
|
+
const shadcn = [];
|
|
1040
|
+
const env = [];
|
|
1041
|
+
|
|
1042
|
+
if (parsed.kind === "rahman") {
|
|
1043
|
+
const slice = (manifest.slices ?? []).find((s) => s.slug === parsed.slug);
|
|
1044
|
+
if (!slice) {
|
|
1045
|
+
throw new Error(`Slice not found in manifest: ${parsed.slug}. Run 'list slices'.`);
|
|
1046
|
+
}
|
|
1047
|
+
steps.push({
|
|
1048
|
+
from: slice.slicePath,
|
|
1049
|
+
toRel: slice.slicePath,
|
|
1050
|
+
toAbs: path.join(target, slice.slicePath),
|
|
1051
|
+
});
|
|
1052
|
+
for (const cp of slice.convexPaths ?? []) {
|
|
1053
|
+
steps.push({ from: cp, toRel: cp, toAbs: path.join(target, cp) });
|
|
1054
|
+
}
|
|
1055
|
+
npm.push(...(slice.npm ?? []));
|
|
1056
|
+
shadcn.push(...(slice.shadcn ?? []));
|
|
1057
|
+
env.push(...(slice.env ?? []));
|
|
1058
|
+
peers.push(...(slice.peers ?? []));
|
|
1059
|
+
} else if (parsed.kind === "superspace-local") {
|
|
1060
|
+
const SUPERSPACE = process.env.RAHMAN_SUPERSPACE_PATH ?? path.join(process.env.HOME ?? "", "projects/superspace");
|
|
1061
|
+
const localFromAbs = path.join(SUPERSPACE, parsed.subPath);
|
|
1062
|
+
if (!existsSync(localFromAbs)) {
|
|
1063
|
+
throw new Error(`superspace local source not found: ${localFromAbs}\n set RAHMAN_SUPERSPACE_PATH to override.`);
|
|
1064
|
+
}
|
|
1065
|
+
steps.push({
|
|
1066
|
+
from: `~/projects/superspace/${parsed.subPath}`,
|
|
1067
|
+
toRel: parsed.subPath,
|
|
1068
|
+
toAbs: path.join(target, parsed.subPath),
|
|
1069
|
+
localFromAbs,
|
|
1070
|
+
});
|
|
1071
|
+
} else if (parsed.kind === "github") {
|
|
1072
|
+
steps.push({
|
|
1073
|
+
from: `${parsed.owner}/${parsed.repo}/${parsed.subPath}`,
|
|
1074
|
+
toRel: parsed.subPath,
|
|
1075
|
+
toAbs: path.join(target, parsed.subPath),
|
|
1076
|
+
});
|
|
1077
|
+
}
|
|
1078
|
+
return { steps, peers, npm, shadcn, env };
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
function copyLocalTree(srcDir, destDir) {
|
|
1082
|
+
const stat = statSync(srcDir);
|
|
1083
|
+
if (!stat.isDirectory()) {
|
|
1084
|
+
mkdirSync(path.dirname(destDir), { recursive: true });
|
|
1085
|
+
writeFileSync(destDir, readFileSync(srcDir));
|
|
1086
|
+
return;
|
|
1087
|
+
}
|
|
1088
|
+
mkdirSync(destDir, { recursive: true });
|
|
1089
|
+
for (const name of readdirSync(srcDir)) {
|
|
1090
|
+
if (name === "node_modules" || name === ".next" || name === "dist") continue;
|
|
1091
|
+
copyLocalTree(path.join(srcDir, name), path.join(destDir, name));
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
// ─── publish-slice ────────────────────────────────────────────────────────
|
|
1096
|
+
//
|
|
1097
|
+
// Validate a local slice and (optionally) open a PR upstream to add it to
|
|
1098
|
+
// the kitab. Default dry-run; pass --open-pr to actually create the PR.
|
|
1099
|
+
|
|
1100
|
+
async function runPublishSlice(rest) {
|
|
1101
|
+
const { positional, flags } = parseFlags(rest);
|
|
1102
|
+
const [sliceDir] = positional;
|
|
1103
|
+
if (!sliceDir) {
|
|
1104
|
+
throw new Error("Usage: rahman-resources publish-slice <local-slice-dir> [--open-pr]");
|
|
1105
|
+
}
|
|
1106
|
+
const abs = path.resolve(process.cwd(), sliceDir);
|
|
1107
|
+
if (!existsSync(abs) || !existsSync(path.join(abs, "slice.json"))) {
|
|
1108
|
+
throw new Error(`Not a slice dir (no slice.json): ${abs}`);
|
|
1109
|
+
}
|
|
1110
|
+
console.log(kleur.bold(`\n→ Validating ${kleur.cyan(abs)}\n`));
|
|
1111
|
+
|
|
1112
|
+
// Run the validator script as a subprocess so any user-side override holds.
|
|
1113
|
+
await new Promise((resolve, reject) => {
|
|
1114
|
+
const ps = spawn(
|
|
1115
|
+
"node",
|
|
1116
|
+
[path.join(__dirname, "../scripts/validate-slice.mjs"), path.join(abs, "slice.json")],
|
|
1117
|
+
{ stdio: "inherit", shell: true },
|
|
1118
|
+
);
|
|
1119
|
+
ps.on("error", reject);
|
|
1120
|
+
ps.on("exit", (code) => (code === 0 ? resolve() : reject(new Error(`validate-slice exited ${code}`))));
|
|
1121
|
+
});
|
|
1122
|
+
|
|
1123
|
+
// Read the slice's metadata so we can prefill the PR title + body.
|
|
1124
|
+
const sliceMeta = JSON.parse(readFileSync(path.join(abs, "slice.json"), "utf8"));
|
|
1125
|
+
|
|
1126
|
+
if (!flags["open-pr"]) {
|
|
1127
|
+
console.log(`\n${kleur.yellow("dry-run — pass --open-pr to scaffold the PR command.")}`);
|
|
1128
|
+
return;
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
const ghAvailable = await checkGhInstalled();
|
|
1132
|
+
|
|
1133
|
+
if (!ghAvailable) {
|
|
1134
|
+
console.log(`\n${kleur.yellow("⚠ `gh` CLI not found.")}`);
|
|
1135
|
+
console.log(`Install it: ${kleur.cyan("https://cli.github.com")} then re-run with --open-pr.`);
|
|
1136
|
+
console.log(`Manual fallback steps:`);
|
|
1137
|
+
console.log(`
|
|
1138
|
+
1. Fork ${kleur.cyan("https://github.com/rahmanef63/resource-site")} on GitHub.
|
|
1139
|
+
2. Clone your fork: ${kleur.cyan("git clone https://github.com/<you>/resource-site && cd resource-site")}
|
|
1140
|
+
3. Copy slice: ${kleur.cyan(`cp -r ${abs} frontend/slices/${sliceMeta.slug}`)}
|
|
1141
|
+
4. Copy convex half: ${kleur.cyan(`cp -r ${abs.replace("frontend/slices", "convex/features")} convex/features/${sliceMeta.slug}`)} (if exists)
|
|
1142
|
+
5. Add entry to ${kleur.cyan("lib/content/slices.ts")}
|
|
1143
|
+
6. ${kleur.cyan("npm run slices:check")}
|
|
1144
|
+
7. ${kleur.cyan(`git checkout -b feat/slice-${sliceMeta.slug} && git add -A && git commit -m "feat(slices): add ${sliceMeta.slug}" && git push -u origin feat/slice-${sliceMeta.slug}`)}
|
|
1145
|
+
8. Open PR via GitHub UI.
|
|
1146
|
+
`);
|
|
1147
|
+
return;
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
// gh installed — emit ready-to-run gh commands.
|
|
1151
|
+
const branchName = `feat/slice-${sliceMeta.slug}`;
|
|
1152
|
+
const title = `feat(slices): add ${sliceMeta.slug}`;
|
|
1153
|
+
const body = [
|
|
1154
|
+
`Adds the **${sliceMeta.title}** slice (${sliceMeta.category}, v${sliceMeta.version}).`,
|
|
1155
|
+
"",
|
|
1156
|
+
sliceMeta.description,
|
|
1157
|
+
"",
|
|
1158
|
+
"Validated locally with `npm run slices:check`.",
|
|
1159
|
+
"",
|
|
1160
|
+
"🤖 Generated with [`rahman-resources publish-slice`](https://github.com/rahmanef63/resource-site)",
|
|
1161
|
+
].join("\n");
|
|
1162
|
+
|
|
1163
|
+
console.log(`\n${kleur.bold("✓")} ${kleur.green("gh CLI detected.")} Run these from inside YOUR fork of the kitab repo:`);
|
|
1164
|
+
console.log(`
|
|
1165
|
+
# Inside your forked clone of rahmanef63/resource-site, copy the slice in:
|
|
1166
|
+
${kleur.cyan(`cp -r ${abs} frontend/slices/${sliceMeta.slug}`)}
|
|
1167
|
+
${kleur.cyan(`# Add ${sliceMeta.slug} entry to lib/content/slices.ts`)}
|
|
1168
|
+
${kleur.cyan(`npm run slices:check`)}
|
|
1169
|
+
|
|
1170
|
+
# Then open the PR (gh handles fork + push):
|
|
1171
|
+
${kleur.cyan(`git checkout -b ${branchName}`)}
|
|
1172
|
+
${kleur.cyan(`git add -A && git commit -m "${title}"`)}
|
|
1173
|
+
${kleur.cyan(`gh pr create --repo rahmanef63/resource-site \\
|
|
1174
|
+
--title "${title}" \\
|
|
1175
|
+
--body ${JSON.stringify(body)}`)}
|
|
1176
|
+
|
|
1177
|
+
${kleur.dim("(If you haven't forked yet, run `gh repo fork rahmanef63/resource-site --clone` first.)")}
|
|
1178
|
+
`);
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
async function checkGhInstalled() {
|
|
1182
|
+
return new Promise((resolve) => {
|
|
1183
|
+
const ps = spawn("gh", ["--version"], { stdio: "ignore", shell: true });
|
|
1184
|
+
ps.on("error", () => resolve(false));
|
|
1185
|
+
ps.on("exit", (code) => resolve(code === 0));
|
|
1186
|
+
});
|
|
1187
|
+
}
|
|
1188
|
+
|
|
539
1189
|
// ─── helpers ──────────────────────────────────────────────────────────────
|
|
540
1190
|
|
|
541
1191
|
async function pull(repoPath, dest) {
|