// `ctl-dev-kit create-product` — renders a new product repo from the dev-kit's // canonical bytes (see canon.ts). Two layers per shape: // // - the CONVENTION layer (this file): every file the drift gate checks, // identical across shapes, with the per-repo fill-ins placed exactly where // the gate masks or leaves structure free; // - the SHAPE layer (turnkey.ts + features/): the source skeleton — package // manifests, manifest/composer/materials modules, the five test tiers — // with the optional workspaces (components, operator screen, configure // screen) added by FEATURE, not by a second copy of the skeleton. // // The convention layer is shared verbatim across every shape. import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { syncTheme } from "../docs-theme/sync.ts"; import { type Canon, loadAsset, loadCanon, PRODUCT_SENTINEL } from "./canon.ts"; import { SITE_DIR as DOCS_SITE_DIR } from "./docs-site.ts"; import { turnkey } from "./turnkey.ts"; // A SHAPE is a preset over FEATURES, not a separate skeleton — the four // product repos in the fleet are not a ladder (reuters: none; oracle: // components + views; funke/probe: components + dashboard + frontend), so // naming every combination would multiply shapes without adding anything. export type ProductShape = "backend-turnkey" | "turnkey"; // - components the components/ workspace: custom Studio nodes packed into the // product-template tar (all three UI surfaces need one). // - views the operator screen AS component inline/summary/fullscreen React // rendered inside Studio's own workflow view (oracle). // - dashboard the operator screen as a standalone SPA built into // dashboards// and served by Studio (funke, probe). // - frontend the product-level configure screen ctl iframes at // ui.configScreenUrl (funke, probe). export type ProductFeature = "components" | "views" | "dashboard" | "frontend"; const ALL_FEATURES: readonly ProductFeature[] = ["components", "views", "dashboard", "frontend"]; const SHAPE_FEATURES: Record = { "backend-turnkey": [], turnkey: ["components", "views", "frontend"], }; export interface CreateProductOptions { dir: string; name: string; shape: ProductShape; /** Local-dev backend port (the container always listens on 4321). */ devPort?: number; /** Full media image ref (repo:tag). The npm norsk-sdk pin is the same * version string — media npm + image are built from one commit. */ mediaImage?: string; /** Full studio image ref (repo:tag). */ studioImage?: string; /** norsk-studio* npm pin; defaults to `studioImage`'s bare tag when one is * given, else to the default lib pin. */ studioLib?: string; /** Features to add on top of the shape's preset (`--with`). */ withFeatures?: ProductFeature[]; /** Features to drop from the shape's preset (`--without`). */ withoutFeatures?: ProductFeature[]; /** Studio component libraries beyond built-ins the workflow uses (`--studio-libs`). */ studioLibs?: StudioLibrary[]; } export const STUDIO_LIBRARIES = ["alpha", "beta"] as const; export type StudioLibrary = (typeof STUDIO_LIBRARIES)[number]; /** `@norskvideo/norsk-studio-` — the npm package and the Studio server.library entry. */ export const studioPackage = (lib: StudioLibrary): string => `@norskvideo/norsk-studio-${lib}`; export interface GeneratedFile { path: string; content: string; executable?: boolean; } export interface ShapeContext { name: string; /** Workspace scope, `@`. */ scope: string; /** `product:` value for the shared workflows — `name` minus the `norsk-` prefix. */ dashboardKey: string; devPort: number; pins: { mediaImage: string; studioImage: string; studioLib: string; mediaLib: string }; canon: Canon; features: ReadonlySet; studioLibs: StudioLibrary[]; } export interface ShapeModule { files(ctx: ShapeContext): GeneratedFile[]; claudeHead(ctx: ShapeContext): string; claudeTail(ctx: ShapeContext): string; rootTsconfigInclude: string[]; /** Everything below the drift-gated core block in .gitignore — the build * output each enabled feature's workspace produces. */ gitignoreTail(ctx: ShapeContext): string; } // One skeleton, both shapes: the difference between them is entirely in ctx.features. const SHAPES: Record = { "backend-turnkey": turnkey, turnkey, }; /** A pin from the dev-kit's own package.json — the coherent set set-sdk-source.ts * moves as one line, and the only copy that travels with the published package. */ export function devKitPin(pkg: { devDependencies?: Record }, name: string): string { const pin = pkg.devDependencies?.[name]; if (pin === undefined) { throw new Error(`the dev-kit's package.json pins no ${name}; a generated product would carry no version`); } return pin; } const devKitPackageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { devDependencies?: Record; }; // Derived, never a second copy of the tag: a hardcoded default silently missed // every nightly sdk-bump, which moves the package.json line and nothing else. // Media's npm packages and image are built from one commit, so they share a tag. const DEFAULT_MEDIA_IMAGE = `norskvideo/norsk:${devKitPin(devKitPackageJson, "@norskvideo/norsk-sdk")}`; // Studio's npm packages and image ship from different commits, so its image tag // cannot be derived; it only has to sit on the lib pin's version line. const DEFAULT_STUDIO_IMAGE = "norskvideo/norsk-studio:1.30.0-2026-09-24-f57973c6"; const DEFAULT_STUDIO_LIB = devKitPin(devKitPackageJson, "@norskvideo/norsk-studio"); /** The one zod 4 a generated product resolves: pinned in its root `overrides` * and declared by every workspace that uses zod. Derived from the dev-kit's own * pin, the same exact version the published ctl-* packages depend on, so the * product's tree dedupes onto it instead of splitting beside a newer zod and * failing conventions/zod-singleton.ts on day one. It is held at 4.4.3 because * @modelcontextprotocol/sdk does not work with 4.5 despite its declared range. */ export const PRODUCT_ZOD = devKitPin(devKitPackageJson, "zod"); const bareTag = (ref: string) => ref.slice(ref.lastIndexOf(":") + 1); export function resolveFeatures(opts: CreateProductOptions): Set { const known = (names: ProductFeature[] | undefined, flag: string): ProductFeature[] => { for (const name of names ?? []) { if (!ALL_FEATURES.includes(name)) { throw new Error(`unknown feature ${JSON.stringify(name)} in ${flag} — features: ${ALL_FEATURES.join(", ")}.`); } } return names ?? []; }; const features = new Set(SHAPE_FEATURES[opts.shape] ?? []); for (const name of known(opts.withoutFeatures, "--without")) features.delete(name); for (const name of known(opts.withFeatures, "--with")) features.add(name); // The template manifest's runtimeScreenUrl names ONE screen (/studio/ for // views, /dashboard// for the SPA), so a seed carrying both would // ship one the operator can never reach. if (features.has("views") && features.has("dashboard")) { throw new Error( "features 'views' and 'dashboard' are two answers to the same question (where the operator screen lives) — pick one.", ); } // Both operator screens read a component's state: views render inside the // component itself, the SPA talks to it over /live/api//*. const screen = features.has("views") ? "views" : features.has("dashboard") ? "dashboard" : undefined; if (screen && !features.has("components")) { throw new Error( `feature ${JSON.stringify(screen)} needs 'components' — the operator screen reads a component's state.`, ); } return features; } export function shapeContext(opts: CreateProductOptions): ShapeContext { if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(opts.name)) { throw new Error( `invalid product name ${JSON.stringify(opts.name)} — lowercase letters, digits, hyphens; 1-63 chars (it names the container image, npm scope, and manifest).`, ); } const unknownLibs = (opts.studioLibs ?? []).filter((lib) => !STUDIO_LIBRARIES.includes(lib)); if (unknownLibs.length > 0) { throw new Error(`unknown studio libs ${unknownLibs.join(", ")} — known: ${STUDIO_LIBRARIES.join(", ")}.`); } const mediaImage = opts.mediaImage ?? DEFAULT_MEDIA_IMAGE; const studioImage = opts.studioImage ?? DEFAULT_STUDIO_IMAGE; return { name: opts.name, scope: `@${opts.name}`, dashboardKey: opts.name.replace(/^norsk-/, ""), devPort: opts.devPort ?? 4323, pins: { mediaImage, studioImage, studioLib: opts.studioLib ?? (opts.studioImage ? bareTag(studioImage) : DEFAULT_STUDIO_LIB), mediaLib: bareTag(mediaImage), }, canon: loadCanon(), features: resolveFeatures(opts), studioLibs: STUDIO_LIBRARIES.filter((lib) => opts.studioLibs?.includes(lib)), }; } function conventionFiles(ctx: ShapeContext, shape: ShapeModule): GeneratedFile[] { const { canon } = ctx; const fillProduct = (workflow: string) => workflow.replaceAll(PRODUCT_SENTINEL, ctx.dashboardKey); return [ { path: "CLAUDE.md", content: `${shape.claudeHead(ctx)}\n${canon.core}\n${shape.claudeTail(ctx)}` }, { path: "flake.nix", content: canon.flake }, { path: "nix/ctl-shell.nix", content: canon.ctlShell }, { path: "flake.lock", content: canon.flakeLock }, { path: "biome.json", content: canon.biome }, { path: "tsconfig.base.json", content: canon.tsconfigBase }, { path: "dprint.json", content: canon.dprint }, { // Hand-rolled rather than JSON.stringify'd: biome keeps a short array on // one line, and a generated repo has to be lint-clean on its first run. path: "tsconfig.json", content: `{ "extends": "./tsconfig.base.json", "include": [${shape.rootTsconfigInclude.map((entry) => JSON.stringify(entry)).join(", ")}] } `, }, { path: ".gitignore", content: `${canon.gitignoreCore}\n${shape.gitignoreTail(ctx)}` }, { path: ".github/workflows/checks.yml", content: fillProduct(canon.checks) }, { path: ".github/workflows/bump.yml", content: fillProduct(canon.bump) }, { path: ".github/workflows/bot-prs.yml", content: canon.botPrs }, { path: ".github/workflows/build-image.yml", content: fillProduct(canon.buildImage) }, { path: ".github/workflows/smoke.yml", content: fillProduct(canon.smoke) }, { path: ".github/actions/ci-status-dispatch/action.yml", content: loadAsset("ci-status-dispatch.yml") }, { path: "manifest.seed.json", content: seedJson(ctx) }, { path: "licenseTemplate.json", content: licenseTemplateJson(ctx) }, { path: "deployment/build-image.sh", content: buildImageWrapper(ctx), executable: true }, ]; } function seedJson(ctx: ShapeContext): string { return `${JSON.stringify( { latest: { media: ctx.pins.mediaImage, studio: ctx.pins.studioImage, source: "nightly-baseline", }, }, null, 2, )}\n`; } // The licence this product needs, as CI's licence-template step checks it: this // product at the image the repo builds. Until PUBLISH_IMAGE is set that is the // build script's IMAGE_TAG default, `:dev`, whose repo part is the name. function licenseTemplateJson(ctx: ShapeContext): string { return `${JSON.stringify( { products: [{ product: ctx.name, imageRef: ctx.name, versionConstraint: "*", mode: "designer" }] }, null, 2, )}\n`; } function buildImageWrapper(ctx: ShapeContext): string { // The gate requires the find_dev_kit function verbatim and a `source` of a // dev-kit build/ script; the surrounding wrapper (tag, port) is per-product. const canonBootstrap = ctx.canon.buildImageBootstrap; const fnStart = canonBootstrap.indexOf("find_dev_kit() {"); if (fnStart === -1) throw new Error("canonical build-image.bootstrap.sh is missing find_dev_kit()"); const fn = canonBootstrap.slice(fnStart); return `#!/usr/bin/env bash # Thin wrapper — all build logic lives in the shared bundle-image driver, # single-sourced in @norskvideo/ctl-dev-kit (build/build-bundle-image.sh). This # repo carries no copy of the build machinery; only the bootstrap below is a # forced (drift-gated) copy. set -euo pipefail product_dir="$(cd "$(dirname "\${BASH_SOURCE[0]}")/.." && pwd)" ${fn}dev_kit="$(find_dev_kit "\${product_dir}")" export PRODUCT_DIR="\${product_dir}" export IMAGE_TAG="\${IMAGE_TAG:-${ctx.name}:dev}" # MUST be 4321: the runner's docker-runner hardcodes the container-internal # port and publishes 127.0.0.1::4321 for every product. The dev port # in backend/src/index.ts only applies outside the container (PORT overrides it # here). export PRODUCT_PORT=4321 # shellcheck source=/dev/null source "\${dev_kit}/build/build-bundle-image.sh" `; } export function planProduct(opts: CreateProductOptions): GeneratedFile[] { const shape = SHAPES[opts.shape]; if (!shape) throw new Error(`unknown shape ${JSON.stringify(opts.shape)}`); const ctx = shapeContext(opts); return [...conventionFiles(ctx, shape), ...shape.files(ctx)]; } export function createProduct(opts: CreateProductOptions): { files: string[] } { const plan = planProduct(opts); for (const file of plan) { const path = join(opts.dir, file.path); mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, file.content, { mode: file.executable ? 0o755 : 0o644 }); } // The docs site's theme files are written by the THEME'S OWN writer, not // rendered here: they include two woff2 faces (a plan entry is a string), and // more to the point one copier and one gate is the whole arrangement — the // same `syncTheme` the drift gate's writer half calls, against the same // canonical bytes `check-drift` compares. A second rendering of those bytes // here is exactly the drift this package exists to stop. const themeFiles = syncTheme(join(opts.dir, DOCS_SITE_DIR)).map((rel) => `${DOCS_SITE_DIR}/${rel}`); return { files: [...plan.map((f) => f.path), ...themeFiles] }; }