#!/usr/bin/env -S tsx /** * Scaffold a new spoke into the panel. * * pnpm add-spoke [--name "Display Name"] [--description "..."] * * Generates: * apps/web/prisma//schema.prisma — partial RO view (User template) * apps/web/src/lib/db-.ts — typed RO client * apps/web/src/lib/admin-sdk/.ts — admin SDK skeleton * * Updates: * apps/web/package.json — prisma:generate / build chain * apps/web/prisma/seed.ts — adds Project row (status=planned) * * Prints next-step checklist (DB role SQL, Coolify env, etc.). */ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; const root = join(__dirname, ".."); const web = join(root, "apps", "web"); function fail(msg: string): never { console.error(`add-spoke: ${msg}`); process.exit(1); } function parseArgs() { const argv = process.argv.slice(2); if (!argv[0] || argv[0].startsWith("-")) fail("usage: pnpm add-spoke [--name X] [--description Y]"); const key = argv[0]; if (!/^[a-z][a-z0-9_]{1,30}$/.test(key)) fail("key must match [a-z][a-z0-9_]{1,30}"); const out: { key: string; name: string; description: string } = { key, name: key[0].toUpperCase() + key.slice(1), description: `${key} spoke`, }; for (let i = 1; i < argv.length; i++) { if (argv[i] === "--name") out.name = argv[++i] ?? out.name; else if (argv[i] === "--description") out.description = argv[++i] ?? out.description; } return out; } function pascal(s: string) { return s.replace(/(^|[_-])(.)/g, (_m, _x, c) => c.toUpperCase()); } function camel(s: string) { const p = pascal(s); return p[0].toLowerCase() + p.slice(1); } function writeOnce(path: string, contents: string) { if (existsSync(path)) { console.log(` skip ${path} (exists)`); return false; } mkdirSync(join(path, ".."), { recursive: true }); writeFileSync(path, contents); console.log(` write ${path}`); return true; } function patchFile(path: string, edit: (src: string) => string) { const orig = readFileSync(path, "utf8"); const next = edit(orig); if (orig === next) { console.log(` skip ${path} (already patched)`); return false; } writeFileSync(path, next); console.log(` patch ${path}`); return true; } function schemaTemplate(key: string) { return `// Partial view of ${key} public schema. // Owned by the spoke; panel reads with super_panel_reader (SELECT only). // Add models here only as panel features need them — keep the surface narrow. generator client { provider = "prisma-client-js" output = "../../node_modules/.prisma/client-${key}" } datasource db { provider = "postgresql" url = env("DATABASE_URL_${key.toUpperCase()}_RO") } // Replace this template with real introspected models. // model User { // id String @id @db.Uuid // email String @unique // createdAt DateTime @map("created_at") @db.Timestamptz(6) // // @@map("users") // } `; } function dbClientTemplate(key: string) { return `import { PrismaClient } from ".prisma/client-${key}"; const g = globalThis as unknown as { ${camel(key)}Db?: PrismaClient }; export const ${camel(key)}Db = g.${camel(key)}Db ?? new PrismaClient({ log: process.env.NODE_ENV === "development" ? ["error", "warn"] : ["error"], }); if (process.env.NODE_ENV !== "production") g.${camel(key)}Db = ${camel(key)}Db; `; } function adminSdkTemplate(key: string, name: string) { const Pascal = pascal(key); return `import { AdminClient } from "./base"; export type ${Pascal}Admin = { // example: setSomething(input: {...}): Promise<{ id: string }>; }; export function create${Pascal}Admin(): ${Pascal}Admin { const base = process.env.${key.toUpperCase()}_ADMIN_API_BASE; const token = process.env.INTERNAL_API_TOKEN_${key.toUpperCase()}; if (!base || !token) return notWired(); const _client = new AdminClient({ projectKey: "${key}", baseUrl: base, token }); return { // setSomething: (input) => _client.call("POST", "/internal/admin/something", input), }; } function notWired(): ${Pascal}Admin { return {} as ${Pascal}Admin; } export function ${camel(key)}AdminWired(): boolean { return Boolean( process.env.${key.toUpperCase()}_ADMIN_API_BASE && process.env.INTERNAL_API_TOKEN_${key.toUpperCase()}, ); } export const ${Pascal.toUpperCase()}_ADMIN_ENDPOINTS: string[] = [ // "POST /internal/admin/...", ]; `; } function patchPackageJson(src: string, key: string): string { const buildAdd = ` && prisma generate --schema=./prisma/${key}/schema.prisma`; if (src.includes(`./prisma/${key}/schema.prisma`)) return src; return src .replace(/"prisma:generate": "([^"]+)"/, (_m, cur) => `"prisma:generate": "${cur}${buildAdd}"`) .replace(/"build": "([^"]+)"/, (_m, cur) => { // Inject after the panel `prisma generate` (the first occurrence) so the new client is generated before next build. if (cur.includes(`./prisma/${key}/schema.prisma`)) return `"build": "${cur}"`; return `"build": "${cur.replace(" && next build", `${buildAdd} && next build`)}"`; }); } function patchSeed(src: string, key: string, name: string, description: string): string { if (src.includes(`key: "${key}"`)) return src; const insertion = ` { key: "${key}", name: "${name.replace(/"/g, '\\"')}", description: "${description.replace(/"/g, '\\"')}", status: "planned", active: false },\n`; return src.replace(/(const projects = \[\n)([\s\S]*?)(\n\];)/, (_m, head, body, tail) => `${head}${body}\n${insertion.trimEnd()}${tail}`); } function main() { const { key, name, description } = parseArgs(); console.log(`add-spoke: scaffolding "${key}" (${name})`); writeOnce(join(web, "prisma", key, "schema.prisma"), schemaTemplate(key)); writeOnce(join(web, "src", "lib", `db-${key}.ts`), dbClientTemplate(key)); writeOnce(join(web, "src", "lib", "admin-sdk", `${key}.ts`), adminSdkTemplate(key, name)); const pkgPath = join(web, "package.json"); patchFile(pkgPath, (s) => patchPackageJson(s, key)); const seedPath = join(web, "prisma", "seed.ts"); patchFile(seedPath, (s) => patchSeed(s, key, name, description)); console.log(""); console.log("next steps (manual):"); console.log(""); console.log(" 1) On the spoke's Postgres, create the RO role:"); console.log(""); console.log(` CREATE ROLE super_panel_reader LOGIN PASSWORD '';`); console.log(` GRANT CONNECT ON DATABASE TO super_panel_reader;`); console.log(` GRANT USAGE ON SCHEMA public TO super_panel_reader;`); console.log(` GRANT SELECT ON ALL TABLES IN SCHEMA public TO super_panel_reader;`); console.log(` ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO super_panel_reader;`); console.log(""); console.log(" 2) In Coolify (panel-web env), add:"); console.log(""); console.log(` DATABASE_URL_${key.toUpperCase()}_RO=postgres://super_panel_reader:@:5432/`); console.log(` # later, when spoke ships /internal/admin/*:`); console.log(` ${key.toUpperCase()}_ADMIN_API_BASE=https://`); console.log(` INTERNAL_API_TOKEN_${key.toUpperCase()}=`); console.log(""); console.log(" 3) Edit prisma/" + key + "/schema.prisma — add only the models you'll query."); console.log(" 4) Edit src/lib/admin-sdk/" + key + ".ts — declare endpoints as you ship them."); console.log(" 5) Optionally: add a component under src/app/projects/[key]/_" + key + ".tsx."); console.log(" 6) Commit & push — Coolify deploys; the entrypoint reseeds projects."); } main();