#!/usr/bin/env bun // `bunx @norskvideo/ctl-dev-kit create-product ...` — a bin rather than a repo // script because the generator's whole use case is an EMPTY directory: there // is no node_modules to run a script from yet. The dev-kit ships TS verbatim, // so the bin runs under bun (the fleet's only runtime). import { existsSync, readdirSync } from "node:fs"; import { parseArgs } from "node:util"; import { createDashboard } from "../dashboard/create-dashboard.ts"; import { writeDashboardManifest } from "../dashboard/manifest.ts"; import { createProduct, type ProductFeature, type ProductShape, type StudioLibrary } from "./create-product.ts"; const SHAPES: ProductShape[] = ["backend-turnkey", "turnkey"]; const USAGE = `Usage: ctl-dev-kit create-product --name [options] ctl-dev-kit create-dashboard --name a fleet dashboard norsk-mgr hosts (a live runtime list) ctl-dev-kit dashboard-manifest write /dashboard.json from a fleet dashboard's calls Options: --shape Repo shape — a preset over the features below (default: backend-turnkey) backend-turnkey no features (reuters-shaped) turnkey components,views,frontend (oracle-shaped) --with Add features on top of the shape's preset --without Drop features from the shape's preset components custom Studio nodes packed into the template tar views operator screen inside Studio's workflow view (needs components) dashboard operator screen as a standalone SPA (needs components) frontend the configure screen ctl iframes --name Product name, e.g. norsk-acme (lowercase, digits, hyphens) --port Local-dev backend port (default: 4323; container is always 4321) --media-image Full media image ref (repo:tag); norsk-sdk npm pin = its tag --studio-image Full studio image ref (repo:tag) --studio-lib norsk-studio* npm pin (default: the studio image's tag) --studio-libs Studio component libraries beyond built-ins the workflow uses: alpha, beta (e.g. alpha for processor.reasoning) — pinned, typed, and loaded by Studio Examples: ctl-dev-kit create-product --name norsk-acme ./acme # backend only ctl-dev-kit create-product --name norsk-acme --shape turnkey ./acme # + component views ctl-dev-kit create-product --name norsk-acme --shape turnkey \\ --with dashboard --without views ./acme # + a dashboard SPA instead `; function fail(message: string): never { console.error(message); console.error(USAGE); process.exit(1); } export function main(argv: string[]): void { const [command, ...rest] = argv; if (command === "dashboard-manifest") { dashboardManifest(rest); return; } if (command === "create-dashboard") { newDashboard(rest); return; } if (command !== "create-product") { fail( `unknown command ${JSON.stringify(command ?? "")} — the commands are create-product, create-dashboard and dashboard-manifest.`, ); } const { values, positionals } = parseArgs({ args: rest, allowPositionals: true, options: { shape: { type: "string", default: "backend-turnkey" }, with: { type: "string" }, without: { type: "string" }, name: { type: "string" }, port: { type: "string" }, "media-image": { type: "string" }, "studio-image": { type: "string" }, "studio-lib": { type: "string" }, "studio-libs": { type: "string" }, }, }); const dir = positionals[0]; if (!dir) fail("missing ."); if (!values.name) fail("missing --name."); if (!SHAPES.includes(values.shape as ProductShape)) { fail(`unknown shape ${JSON.stringify(values.shape)} — shapes: ${SHAPES.join(", ")}.`); } if (existsSync(dir) && readdirSync(dir).length > 0) { fail(`${dir} exists and is not empty — refusing to overwrite.`); } const devPort = values.port === undefined ? undefined : Number(values.port); if (devPort !== undefined && (!Number.isInteger(devPort) || devPort < 1 || devPort > 65535)) { fail(`--port must be an integer in 1..65535, got ${JSON.stringify(values.port)}.`); } // Comma-separated so one flag carries a set; the resolver validates the names. const csvList = (raw: string | undefined): T[] | undefined => raw === undefined ? undefined : (raw .split(",") .map((f) => f.trim()) .filter(Boolean) as T[]); // The resolver throws on a name, feature or combination it cannot honour; // the operator asked for something impossible, not hit a bug, so report the // reason and the usage rather than a stack trace. let files: string[]; try { ({ files } = createProduct({ dir, name: values.name, shape: values.shape as ProductShape, withFeatures: csvList(values.with), withoutFeatures: csvList(values.without), devPort, mediaImage: values["media-image"], studioImage: values["studio-image"], studioLib: values["studio-lib"], studioLibs: csvList(values["studio-libs"]), })); } catch (err) { fail(err instanceof Error ? err.message : String(err)); } console.log(`Generated ${files.length} files into ${dir} (shape: ${values.shape}). Next steps (also in the generated CLAUDE.md and README): 1. cd ${dir} && nix develop # the dev shell: pinned bun, biome and norsk-ctl 2. git init && bun install 3. UPDATE_SNAPSHOTS=1 bun run test:unit # bake the byte-snapshot fixtures, commit them 4. bun run check:drift && bun run docs:check && bun run test:unit && bun run typecheck && bun run lint 5. Replace the starter graph in shared/src/workflow/ with the real one, keeping INVARIANTS.md and its rules tests in step. 6. bun run docs:handover # once a daemon has this product added: fills docs/handover.md from the launch truth `); } if (import.meta.main) { main(process.argv.slice(2)); } function dashboardManifest(args: string[]): void { const [src, dist] = args; if (!src || !dist) fail("dashboard-manifest needs and ."); const written = writeDashboardManifest(src, dist); if (written.status === "error") { console.error( `dashboard.json not written: the build cannot read what this dashboard uses.\n ${written.error.join("\n ")}`, ); process.exit(1); } const kinds = written.value.kinds; console.log(`${dist}/dashboard.json: uses ${kinds.length > 0 ? kinds.join(", ") : "no kinds"}`); } function newDashboard(args: string[]): void { const { values, positionals } = parseArgs({ args, allowPositionals: true, options: { name: { type: "string" } } }); const dir = positionals[0]; if (!dir) fail("missing ."); if (!values.name) fail("missing --name."); if (existsSync(dir) && readdirSync(dir).length > 0) fail(`${dir} exists and is not empty — refusing to overwrite.`); let files: string[]; try { ({ files } = createDashboard({ dir, name: values.name })); } catch (err) { fail(err instanceof Error ? err.message : String(err)); } console.log(`Generated ${files.length} files into ${dir}. Next steps (also in its README): 1. cd ${dir} && bun install 2. bun run build # dist/dashboard.json and dashboard.tar 3. norsk-mgr dashboard register ${values.name} && norsk-mgr dashboard upload ${values.name} dashboard.tar `); }