Files
sp/scripts/add-spoke.ts
Semih 5ef1597842 feat: pnpm add-spoke scaffolder for new spokes
Usage:
  pnpm add-spoke <key> [--name X] [--description Y]

Generates per-spoke prisma schema + db client + admin-sdk stub,
patches package.json (prisma generate chain) and prisma/seed.ts.
Prints SQL + Coolify env checklist for finishing the wiring.
2026-05-13 11:00:47 +00:00

211 lines
7.5 KiB
TypeScript

#!/usr/bin/env -S tsx
/**
* Scaffold a new spoke into the panel.
*
* pnpm add-spoke <key> [--name "Display Name"] [--description "..."]
*
* Generates:
* apps/web/prisma/<key>/schema.prisma — partial RO view (User template)
* apps/web/src/lib/db-<key>.ts — typed RO client
* apps/web/src/lib/admin-sdk/<key>.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 <key> [--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 '<gen>';`);
console.log(` GRANT CONNECT ON DATABASE <db> 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:<pw>@<host>:5432/<db>`);
console.log(` # later, when spoke ships /internal/admin/*:`);
console.log(` ${key.toUpperCase()}_ADMIN_API_BASE=https://<spoke-internal>`);
console.log(` INTERNAL_API_TOKEN_${key.toUpperCase()}=<token>`);
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 <Health/> component under src/app/projects/[key]/_" + key + ".tsx.");
console.log(" 6) Commit & push — Coolify deploys; the entrypoint reseeds projects.");
}
main();