// The turnkey skeleton — ONE module for every turnkey shape. The base emitted // here is reuters-shaped (shared/ + backend/ + tests/: one composed workflow // served as a product-template tar from an express backend); the components, // views, dashboard and frontend FEATURES add their workspaces on top and patch // the handful of shared files that have to know about them (see features/). // // There is deliberately no second copy of this skeleton per shape: it is ~1600 // lines, the shape layer is not drift-gated, and three copies would diverge // within a release. A shape is a preset over features (create-product.ts). // // The skeleton ships the post-review state as defaults: seed through // parseManifestSeed, ManifestInput with no boilerplate, packProductTemplate // from the schema package, addComponent/stubbedLibrary with a THROWING // validate(), INVARIANTS + parity + byte-snapshot + studio-load wired into // test:unit, and an image smoke tier. import { pinnedBunVersion } from "../conventions/check-drift.ts"; import { type GeneratedFile, PRODUCT_ZOD, type ShapeContext, type ShapeModule, studioPackage, } from "./create-product.ts"; import { DOCS_SITE_GITIGNORE, DOCS_SITE_SCRIPTS, docsSiteFiles } from "./docs-site.ts"; import { COMPONENTS_GITIGNORE, componentsFiles, EXAMPLE_ID, EXAMPLE_IDENTIFIER, libraryPackage, } from "./features/components.ts"; import { DASHBOARD_GITIGNORE, DASHBOARD_PROXY_EXPOSE, DASHBOARD_RUNTIME_SCREEN, dashboardFiles, } from "./features/dashboard.ts"; import { CONFIG_SCREEN_URL, FRONTEND_SERVER_BLOCK, frontendFiles } from "./features/frontend.ts"; import { VIEWS_PROXY_EXPOSE, VIEWS_RUNTIME_SCREEN, viewsFiles } from "./features/views.ts"; import { packageReleaseSh, SERVER_KIT_GITIGNORE, shipImageSh } from "./server-kit.ts"; // The per-feature section of the generated CLAUDE.md: what each optional // workspace is, and the trap that comes with it. Every line here is something // a fleet review found someone had to rediscover. function featureNotes(ctx: ShapeContext): string { const sections: string[] = []; if (ctx.features.has("components")) { sections.push(`### \`components/\` — this product's own Studio nodes Build order is load-bearing: \`codegen\` (types.source.yaml -> _gen/types.ts) -> \`tsc\` -> \`copy-yamls\` (types.yaml beside the compiled runtime, plus the BROWSER bundle info.client.js). \`bun run build:components\` does all three. It is CommonJS on purpose (\`module: "commonjs"\`): under \`node16\` Node's CJS-via-ESM interop double-wraps the exports object and \`infoMod.default\` becomes the wrapper rather than the component factory. Adding a component is dropping a sibling directory under \`components/src/library/\` with an \`info.ts\` and a \`runtime.ts\` — the library entry discovers it. But TWO things elsewhere have to follow, and both fail quietly: - **\`COMPONENT_STUBS\`** (\`shared/src/workflow/studio-library.ts\`) — the builder's library is Studio's npm NodeInfos, which cannot know this product's own nodes. Without a stub the composer's throwing \`validate()\` rejects the graph. Declare the stub; never demote \`validate()\` to a warning. - **\`server.library\` in the emitted compose** — the product's list OVERRIDES the daemon's seeded \`default.yaml\` rather than merging, so it must name every library including the built-ins. A component that is mounted but missing here is never loaded, with no error at all. \`components/src/library/example/\` is a placeholder wired all the way through so a fresh repo proves the pipeline on its first launch. Replace it or delete it. `); } if (ctx.features.has("views")) { sections.push(`### The operator screen — component views \`inline.tsx\` / \`summary.tsx\` / \`fullscreen.tsx\` beside the component, registered under its \`runtime\` and rendered INSIDE Studio's workflow view: \`inline\` inside the node on the canvas (without it the node shows only static config), \`summary\` in the sidebar when the node is selected, \`fullscreen\` as the full page. State arrives as \`ViewProps.state\` (pushed from every \`updates.update()\`) and commands leave through \`sendCommand\` — a view never fetches and never opens a socket. They are bundled for the BROWSER by \`copy-yamls\`, and Studio's loader has no import map: a bare specifier left in info.client.js is a fatal module-resolution crash in the workflow view. React is rewritten to Studio's window globals so no second copy is bundled; \`components/src/library/info-client-externals.test.ts\` guards it. The template manifest points \`runtimeScreenUrl\` at Studio's own \`/studio/\` route, and \`proxy.expose\` carries \`/live/*\` — \`/live/api/*\` alone covers the component's routes but not its PAGES or their state websockets. `); } if (ctx.features.has("dashboard")) { sections.push(`### The operator screen — dashboard SPA Four paths name the same thing, and conflating them is how a dashboard builds, packs and launches while serving a 404: | Layer | Path | | -------------------- | --------------------------------------- | | repo (vite outDir) | \`dashboards/workflow/\` | | inside the tar | \`dashboards/workflow/\` | | inside the container | \`studio-save-files/workflow/dashboards/\` | | URL | \`/dashboard/workflow/\` | The directory name **is** the workflow name. The tar ships \`workflow.yml\` at its root, so the workflow is \`workflow\`; ctl does the rename at mount. \`base: "./"\` is required — the page is served behind a per-instance proxy prefix, so absolute asset URLs escape it. And the API client discovers \`apiBasePath\`/\`wsBasePath\` from the \`env\` document Studio serves beside the page: a root-absolute \`/live/api/...\` resolves against the PROXY ROOT and never reaches the instance. \`proxy.expose\` carries \`/dashboard/*\`. Without it the dashboard works on the direct Studio port and 404s through the proxy. `); } if (ctx.features.has("frontend")) { sections.push(`### \`frontend/\` — the configure screen Served by THIS container at \`/configure\` and iframed by ctl at \`manifest.ui.configScreenUrl\`. Not an operator screen: that is per-instance and Studio's, this is registration-time and ours. The daemon PROBES \`configScreenUrl\` at \`product add\` and refuses the product with \`CONFIG_SCREEN_UNREACHABLE\` if it does not answer. The backend serves \`frontend/dist\`, so it must be built first — \`bun run dev\` and the image build both do. \`bun run dev:frontend\` is vite's own server, for iterating on the screen with HMR. `); } if (sections.length === 0) return ""; return `## The optional workspaces this repo has ${sections.join("\n")} `; } function claudeHead(ctx: ShapeContext): string { const features = [...ctx.features].sort(); const built = features.length === 0 ? "no optional workspaces" : `features: ${features.join(", ")}`; return `# Working in this repo (${ctx.name}) This is a **standalone product repo** generated by \`ctl-dev-kit create-product\` (${built}). It builds a product container image and launches through the **released** norsk-ctl daemon — it needs no norsk-ctl source checkout. This file is conventions + gotchas; the fenced block below is the drift-gated shared core. `; } function claudeTail(ctx: ShapeContext): string { const workspaceNotes = featureNotes(ctx); const screens = (["dashboard", "frontend"] as const).filter((f) => ctx.features.has(f)); const screenTests = screens.length === 0 ? "" : `, then the vitest suites of ${screens.map((s) => `\`${s}/\``).join(" and ")} (jsdom)`; return `${workspaceNotes}## Getting norsk-ctl + the dev loop \`norsk-ctl\` (the daemon/CLI) is **not** an npm dep — it's the released binary, pinned in \`flake.nix\` and put on PATH by the nix shell: - \`nix develop\` -> build tools + the pinned \`norsk-ctl\` (\`.#dev\` is an alias). - \`nix develop .#build\` -> lean shell, no ctl — what CI uses. **Inner loop:** \`bun run dev\` (backend on :${ctx.devPort}), then \`norsk-ctl product add --dev-url http://localhost:${ctx.devPort}\` registers the running backend as a dev product. **Licence:** commit a non-production dev licence for this product at licensing/${ctx.name}-dev.json and \`bun run iterate\` uses it whenever \`NORSK_LICENSE_FILE\` is unset; export it yourself for \`bun run demo\`. **Demo:** \`bun run demo -- up\` runs \`tests/demo.spec.ts\` (the @norskvideo/ctl-test-harness demo driver) from source on a private daemon — dev backend, registration, launch, the camera1 sample on the ingest, ready gates, URLs; Ctrl-C tears down. \`check\` is the headless form CI runs, \`spec\` prints the plan. \`DEMO_SOURCES=none\` skips the sample, for feeds pushed in by hand. \`scripts/demo\` is the same entry re-exec'd under \`nix develop .#dev\` when the pinned ctl is not on PATH. **Outer loop (real image):** \`bun run iterate\` — \`bun run build:image\` (the shared dev-kit bundle driver via \`deployment/build-image.sh\`, tag \`${ctx.name}\`, container port 4321), then \`bun run demo -- up --mode image --daemon reuse\`: the driver deletes the instance, removes the template + product, re-adds the fresh image and checks the pins before relaunching. **A customer's server:** \`bun run package-release\` writes \`release/\`: the image as a tar (tagged by commit, so a server tells releases apart), the drift-gated \`deployment/install.sh\` and \`deployment/uninstall.sh\`, and \`release.env\`. Copied to the server, \`install.sh --license \` takes a bare box to a running instance and upgrades one in place (norsk-ctl too, media kept); every template parameter is taken from a same-named environment variable. \`uninstall.sh\` restores the box and lists anything left. A dev box with no registry access: \`bun run ship-image -- \`. ## First run after generation 1. \`nix develop\` — the dev shell: pinned bun, biome and norsk-ctl. 2. \`git init && bun install\` 3. \`UPDATE_SNAPSHOTS=1 bun run test:unit\` — bakes the byte-snapshot fixtures under tests/unit/__snapshots__/ (the generator cannot compose without the Studio packages installed). That directory does not exist until this command creates it, which is why it is not written as a repo path here. Commit the fixtures; from then on any emission change is a reviewable fixture diff. 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 the handover's generated block from the launch truth. ## The docs site (\`website/\`) A **standalone package**, deliberately outside the root \`workspaces\` array: Astro's dependency tree has no business in the product's install. Two consequences that bite in that order — - \`bun install\` at the root does NOT install it. \`bun install --cwd website\` does, once; then \`bun run docs:dev\` / \`bun run docs:site\`. - \`website/src/starlight.ts\`, \`website/src/custom.css\`, \`website/src/content.config.ts\`, \`website/src/components/\`, \`website/src/fonts/\` and \`website/public/favicon.png\` are **copies of the dev-kit's shared theme**, not this repo's files. \`bun run check:drift\` fails on a hand-edited one; \`bun run sync:drift\` rewrites them. A styling or layout change belongs in the dev-kit, where it reaches every product's manual. This repo owns four things: the origin (\`website/src/lib/site.ts\`), the two names and the sidebar (\`website/astro.config.mjs\`), and the content (\`website/src/content/docs/\`). Keep the five top-level sections — Start here, Guides, How-to, Concepts, Reference — they are what make several Norsk manuals navigable by one reader. The placeholder pages say what belongs in each. It is NOT the bundle the image serves at /docs (\`bun run docs:manual\`); both channels exist on purpose, and the dev-kit's \`node_modules/@norskvideo/ctl-dev-kit/conventions/docs-site.md\` says which to reach for. ## Version pins Media + Studio container tags derive from **\`manifest.seed.json\`** (the machine-writable source of truth — RFC 0001 Workstream C) via \`shared/src/version.ts\`, which validates the seed through the SDK's \`parseManifestSeed\` at module load. \`version.test.ts\` fails if the seed and the \`norsk-sdk\`/\`norsk-studio*\` npm pins in \`shared/package.json\` drift apart — the shared \`bump.yml\` bumps them all in lockstep nightly. ## The stored-template trap The daemon launches instances from a **stored** product-template snapshot fetched once at \`product add\`, and refuses to overwrite one by name. Rebuilding the image does NOT reach launched instances or new launches until you refresh the stored template (\`norsk-ctl template refresh \`, or the delete/remove/re-add dance \`bun run iterate\` drives through the demo driver). Verify the refreshed \`~/.norsk-ctl/product-templates//compose.yml\` carries the pins \`manifest.seed.json\` declares. ## Build / test — the five layers - **Layer 1 — typed factories** (\`shared/src/workflow/components.ts\`): configs \`import type\`d from each built-in's OpenAPI-generated types (\`@norskvideo/norsk-studio-built-ins/lib//_gen/types\`); drift from Studio's schema is a \`bun run typecheck\` failure. - **Layer 2 — \`builder.validate()\`**: the composer throws on any issue. Never demote it to a warning; declare out-of-library components in \`COMPONENT_STUBS\` (SDK \`stubbedLibrary\`) instead. - **Layer 3 — INVARIANTS.md + rules tests**: stable \`INV-*\` IDs, one row per load-bearing invariant, parity-guarded (\`tests/unit/invariants-parity.test.ts\`) so table and tests cannot drift apart. - **Layer 4 — studio-load round-trip** (\`tests/unit/loads-in-studio.test.ts\`): emitted YAML through Studio's real \`document.load()\`. - **Layer 5 — integration**: the smoke tier (\`tests/smoke.spec.ts\`, run by the convention \`smoke.yml\` on PRs, main and the nightly bump). It launches the built image, sends the camera1 sample to the SRT ingest and checks the graph is up and the ingest connected. Grow its observations as the product takes shape; add an \`integration.yml\` when a regression needs more than one journey (see \`node_modules/@norskvideo/ctl-dev-kit/conventions/integration-testing.md\`). Locally: \`bun run build:image && bun run test:smoke\` with \`NORSK_LICENSE_FILE\` set. Byte-snapshots (\`tests/unit/workflow-snapshot.test.ts\`) pin the emitted wire bytes; \`UPDATE_SNAPSHOTS=1\` refreshes deliberately. - \`bun run test\` / \`test:unit\` — shared/backend units + tests/unit scaffolds${screenTests}. - \`bun run test:image\` — image smoke test (needs Docker + \`bun run build:image\`): runs the built container and asserts the surface \`product add\` probes. - \`bun run check:drift\` — the shared-conventions gate; CI runs it plus lint/typecheck/test:unit via the drift-gated \`checks.yml\`. - \`bun run docs:check\` — every backticked repo path in the README, this file and the markdown under docs/ must exist (\`\` exempts a line that names something outside the tree). `; } function rootPackageJson(ctx: ShapeContext): string { const { studioLib, mediaLib } = ctx.pins; const hasComponents = ctx.features.has("components"); const hasDashboard = ctx.features.has("dashboard"); const hasFrontend = ctx.features.has("frontend"); return `${JSON.stringify( { name: ctx.name, version: "0.0.1", private: true, type: "module", workspaces: [ "shared", ...(hasComponents ? ["components"] : []), ...(hasDashboard ? ["dashboard"] : []), ...(hasFrontend ? ["frontend"] : []), "backend", "tests", ], scripts: { ...(hasComponents ? { codegen: "bun run --cwd components codegen" } : {}), clean: "bun run --cwd shared clean", format: "biome format --write .", lint: "biome check .", "lint:fix": "biome check --write .", test: "bun run test:unit", "test:unit": [ `bun test shared/src/ backend/src/ ${hasComponents ? "components/src/ " : ""}tests/unit/`, ...(hasDashboard ? ["bun run --cwd dashboard test"] : []), ...(hasFrontend ? ["bun run --cwd frontend test"] : []), ].join(" && "), "test:image": "bun test tests/image/", "test:smoke": "NORSK_CTL_ENV=test bun test tests/smoke.spec.ts", "check:drift": "bun run node_modules/@norskvideo/ctl-dev-kit/conventions/check-drift.ts", "docs:check": "bun run node_modules/@norskvideo/ctl-dev-kit/docs/path-existence.ts && bun run node_modules/@norskvideo/ctl-dev-kit/docs/light-pages.ts && bun run node_modules/@norskvideo/ctl-dev-kit/docs/journeys.ts", "docs:manual": `bun run node_modules/@norskvideo/ctl-dev-kit/doc-guide/prose-bundle.js --brand "${ctx.name}" --title "${ctx.name} — Documentation" --link "norsk.video=https://norsk.video/" --out docs/generated/bundle docs/handover.md docs/administering.md docs/operating.md docs/known-limitations.md`, "docs:handover": "bash scripts/docs-handover.sh", "docs:handover:check": "bash scripts/docs-handover.sh --check", // The docs SITE (website/) — a standalone package, so its install is its // own: `bun install --cwd website` before the first build. ...DOCS_SITE_SCRIPTS, "sync:drift": "bun run node_modules/@norskvideo/ctl-dev-kit/conventions/sync-drift.ts", // codegen first: components/_gen/types.ts is gitignored build output, // so on a clean checkout tsc has nothing to resolve against without it. typecheck: [ ...(hasComponents ? ["bun run codegen"] : []), "bunx tsc --noEmit -p shared", ...(hasComponents ? ["bunx tsc --noEmit -p components"] : []), ...(hasDashboard ? ["bunx tsc --noEmit -p dashboard"] : []), ...(hasFrontend ? ["bunx tsc --noEmit -p frontend"] : []), "bunx tsc --noEmit -p backend", "bunx tsc --noEmit -p .", ].join(" && "), build: "bun run lint && bun run typecheck && bun run build:no-lint", "build:no-lint": [ "bun run build:shared", ...(hasComponents ? ["bun run build:components"] : []), ...(hasDashboard ? ["bun run build:dashboard"] : []), ...(hasFrontend ? ["bun run build:frontend"] : []), "bun run build:backend", "bun run docs:manual", ].join(" && "), "build:shared": "bun run --cwd shared build", ...(hasComponents ? { "build:components": "bun run --cwd components build" } : {}), ...(hasDashboard ? { "build:dashboard": "bun run --cwd dashboard build" } : {}), ...(hasFrontend ? { "build:frontend": "bun run --cwd frontend build" } : {}), "build:backend": "bun run --cwd backend build", "build:image": "bash deployment/build-image.sh", // The backend SERVES frontend/dist and PACKS components/lib and // dashboards/, so a dev loop that skips a build hands the daemon a 404 // configure screen, a stale component or a dashboard that 404s. // `dev:frontend` is vite's own server, for iterating on the screen with HMR. dev: [ ...(hasComponents ? ["bun run build:components"] : []), ...(hasDashboard ? ["bun run build:dashboard"] : []), ...(hasFrontend ? ["bun run build:frontend"] : []), "bun run --cwd backend dev", ].join(" && "), ...(hasFrontend ? { "dev:frontend": "bun run --cwd frontend dev" } : {}), start: "bun run --cwd backend start", iterate: "bash deployment/iterate.sh", "package-release": "bash deployment/package-release.sh", "ship-image": "bash deployment/ship-image.sh", demo: "ctl-demo", }, devDependencies: { "@biomejs/biome": "2.5.5", "@norskvideo/ctl-dev-kit": "^0.2.0", // The demo driver (`ctl-demo`, tests/demo.spec.ts) — hoisted here so the // root `demo` script resolves the bin. "@norskvideo/ctl-test-harness": "^0.1.23", "@types/bun": "latest", "@types/node": "^22.10.0", typescript: "^5.7.2", }, // The shell's Bun (build/ctl-shell.nix): the drift gate holds the two together. packageManager: `bun@${pinnedBunVersion(ctx.canon.ctlShell)}`, overrides: { ws: "^8.20.1", "shell-quote": "^1.9.0", "fast-xml-parser": "^5.10.1", "fast-uri": "^3.1.4", "@norskvideo/norsk-api": mediaLib, "@norskvideo/norsk-sdk": mediaLib, "@norskvideo/norsk-studio": studioLib, ...optionalStudioPins(ctx), "@norskvideo/norsk-studio-builder": studioLib, "@norskvideo/norsk-studio-built-ins": studioLib, zod: PRODUCT_ZOD, }, }, null, 2, )}\n`; } function optionalStudioPins(ctx: ShapeContext): Record { return Object.fromEntries(ctx.studioLibs.map((lib) => [studioPackage(lib), ctx.pins.studioLib])); } function invariantsMd(ctx: ShapeContext): string { return `# Workflow invariants The load-bearing rules this product's composed workflow must never break. Each has a stable ID (\`INV--\`; never renumber, never reuse a retired ID) and at least one test that cites the ID verbatim — the parity guard (\`tests/unit/invariants-parity.test.ts\`) fails CI when a row has no citing test or a test cites an unknown ID. When a code comment carries one of these rules, cite the ID there too instead of restating the rule. | ID | Invariant | Guarded by | | --------------- | ------------------------------------------------------------------------------------------ | -------------------------- | | INV-GRAPH-001 | The ingest matches the configured transport (srt-listener/srt-caller) and port | \`compose-workflow.test.ts\` | | INV-GRAPH-002 | The egest dials the configured destination and subscribes to the ingest's video + audio | \`compose-workflow.test.ts\` | | INV-GRAPH-003 | The preview taps ingest video only — it never sits in the egest path | \`compose-workflow.test.ts\` | | INV-COMPOSE-001 | validate() is a throwing gate: an identifier that is neither real nor stubbed fails compose | \`compose-workflow.test.ts\` | Seeded by \`ctl-dev-kit create-product\` for the ${ctx.name} starter graph — replace/extend the rows as the real workflow takes shape. `; } function sharedPackageJson(ctx: ShapeContext): string { const { studioLib, mediaLib } = ctx.pins; return `${JSON.stringify( { name: `${ctx.scope}/shared`, version: "0.0.1", private: true, type: "module", exports: { ".": "./src/index.ts", "./manifest": "./src/manifest.ts", "./product-template": "./src/product-template.ts", "./schemas": "./src/schemas/index.ts", "./workflow": "./src/workflow/index.ts", }, scripts: { build: [ "bun build src/index.ts --outdir dist --target=bun --external @norskvideo/norsk-studio", ...ctx.studioLibs.map((lib) => `--external ${studioPackage(lib)}`), "--external @norskvideo/norsk-studio-builder --external @norskvideo/norsk-studio-built-ins", ].join(" "), clean: "rm -rf dist", test: "bun test src/", typecheck: "bunx tsc --noEmit", }, dependencies: { "@norskvideo/ctl-product-template-schema": "^0.1.0", "@norskvideo/ctl-sdk": "^0.1.1", "@norskvideo/norsk-sdk": mediaLib, "@norskvideo/norsk-studio": studioLib, ...optionalStudioPins(ctx), "@norskvideo/norsk-studio-builder": studioLib, "@norskvideo/norsk-studio-built-ins": studioLib, yaml: "^2.8.0", zod: PRODUCT_ZOD, }, devDependencies: { "@norskvideo/ctl-foundation": "^0.1.0", "@types/bun": "latest", "@types/node": "^22.10.0", typescript: "^5.7.2", }, }, null, 2, )}\n`; } const WORKSPACE_TSCONFIG = `{ "extends": "../tsconfig.base.json", "include": ["src/**/*"] } `; function versionTs(ctx: ShapeContext): string { return `import { parseManifestSeed } from "@norskvideo/ctl-sdk/manifest-seed"; import rawSeed from "../../manifest.seed.json" with { type: "json" }; export const PRODUCT_NAME = "${ctx.name}"; export const PRODUCT_VERSION = "0.0.1"; // Validate at module load so an invalid seed fails loudly at startup (and in // every test run) rather than rendering a compose from garbage. The SDK's // SeedSchema is strict; never import the JSON raw. const seedResult = parseManifestSeed(rawSeed); if (seedResult.status !== "ok") { throw new Error(\`manifest.seed.json is invalid: \${seedResult.error}\`); } const latest = seedResult.value.latest; if (latest === undefined) { throw new Error("manifest.seed.json has no 'latest' channel"); } const bareTag = (ref: string) => ref.slice(ref.lastIndexOf(":") + 1); const repoOf = (ref: string) => ref.slice(0, ref.lastIndexOf(":")); // Media-engine + Studio control-plane images, derived from manifest.seed.json // (the machine-writable source of truth — RFC 0001 Workstream C). Kept in step // with the norsk-sdk / norsk-studio-* npm pins in shared/package.json; // version.test.ts fails if the tags and the npm pins drift apart. // // Overridable via env as a FULL ref (repo:tag) on the PRODUCT BACKEND process, // which renders the product-template compose. Guard \`process\` for the // browser: this module is re-exported from the shared index. const env = typeof process !== "undefined" ? process.env : ({} as Record); export const MEDIA_IMAGE = env.MEDIA_IMAGE ?? latest.media; export const MEDIA_IMAGE_REPO = repoOf(MEDIA_IMAGE); export const MEDIA_IMAGE_TAG = bareTag(MEDIA_IMAGE); export const STUDIO_IMAGE = env.STUDIO_IMAGE ?? latest.studio; export const STUDIO_IMAGE_REPO = repoOf(STUDIO_IMAGE); export const STUDIO_IMAGE_TAG = bareTag(STUDIO_IMAGE); `; } function versionTestTs(ctx: ShapeContext): string { return `import { describe, expect, it } from "bun:test"; import pkg from "../package.json" with { type: "json" }; import { MEDIA_IMAGE, MEDIA_IMAGE_REPO, MEDIA_IMAGE_TAG, PRODUCT_NAME, STUDIO_IMAGE, STUDIO_IMAGE_REPO, STUDIO_IMAGE_TAG, } from "./version.ts"; const studioNpmPin = pkg.dependencies["@norskvideo/norsk-studio"]; // Media engine + its SDK are built from the same commit, so the image tag and // the norsk-sdk npm pin share one version string — guard the coupling. const mediaNpmPin = pkg.dependencies["@norskvideo/norsk-sdk"]; it("product identifies as ${ctx.name}", () => { expect(PRODUCT_NAME).toBe("${ctx.name}"); }); describe("npm pins stay coupled to the seed", () => { it("media image matches the @norskvideo/norsk-sdk npm pin (same-commit family)", () => { expect(MEDIA_IMAGE_TAG).toBe(mediaNpmPin); }); // The studio npm/image SHA split is INTENTIONAL when it happens: Norsk builds // the studio npm packages and the studio Docker image from different commits, // so the image rides the newest published Docker Hub tag on the same // major.minor.patch line as the npm nightly (exactly how the shared // bump.yml advances the pair). it("studio image stays on the npm pin's major.minor.patch line", () => { expect(STUDIO_IMAGE_TAG.split("-")[0]).toBe(studioNpmPin.split("-")[0]); }); it("keeps the norsk-studio-* npm pins in step with each other", () => { expect(pkg.dependencies["@norskvideo/norsk-studio-builder"]).toBe(studioNpmPin); expect(pkg.dependencies["@norskvideo/norsk-studio-built-ins"]).toBe(studioNpmPin); }); }); // The repo/tag split stays consistent with the full ref — the RFC 0001 §5 // invariant that keeps every product speaking the same MEDIA_*/STUDIO_* shape. it("repo and tag recombine to the full image ref", () => { expect(\`\${MEDIA_IMAGE_REPO}:\${MEDIA_IMAGE_TAG}\`).toBe(MEDIA_IMAGE); expect(\`\${STUDIO_IMAGE_REPO}:\${STUDIO_IMAGE_TAG}\`).toBe(STUDIO_IMAGE); }); `; } const MANIFEST_TS_TEMPLATE = `import { type Manifest, type ManifestInput, ManifestSchema } from "@norskvideo/ctl-sdk/browser"; import { PRODUCT_NAME, PRODUCT_VERSION } from "./version.ts"; // Producer-side ManifestInput: everything the schema defaults stays omitted // (cli/components/runtime), so this declares only what the product has. // Parsing at build time keeps the served manifest the READER shape with the // defaults filled in. __CONFIG_SCREEN_NOTE__ export function buildManifest(): Manifest { const manifest: ManifestInput = { manifestSchemaVersion: 1, name: PRODUCT_NAME, version: PRODUCT_VERSION, minRunnerVersion: "0.1.0", api: { basePath: "/api", proxyPaths: [__PROXY_PATHS__], openapiFragmentPath: "/api/openapi.yaml", }, targets: ["docker-compose"], // Keep in lockstep with STARTER_CONFIGS in backend/src/routes/product-template.ts. defaultProductTemplates: [{ name: "default", url: "/api/product-template/default" }], ui: { __CONFIG_SCREEN__sidebarEntries: [{ label: "Documentation", route: "/docs/" }] }, }; return ManifestSchema.parse(manifest); } `; function manifestTs(ctx: ShapeContext): string { const hasFrontend = ctx.features.has("frontend"); return MANIFEST_TS_TEMPLATE.replace( "__CONFIG_SCREEN_NOTE__", hasFrontend ? `// The configure screen is served by THIS container out of frontend/dist. The // daemon probes it at \`product add\` and refuses the product with // CONFIG_SCREEN_UNREACHABLE if it does not answer, so it must be built first.` : `// This product has no configure UI — configScreenUrl is optional and omitted, // so the daemon skips its registration probe entirely (no placeholder HTML // needed); the one ui entry is the documentation the image serves at /docs.`, ) .replace( "__PROXY_PATHS__", ['"/api/*"', ...(hasFrontend ? [`"${CONFIG_SCREEN_URL}"`, `"${CONFIG_SCREEN_URL}/*"`] : [])].join(", "), ) .replace("__CONFIG_SCREEN__", hasFrontend ? `configScreenUrl: "${CONFIG_SCREEN_URL}", ` : ""); } const MANIFEST_TEST_TS_TEMPLATE = `import { describe, expect, it } from "bun:test"; import { ManifestSchema } from "@norskvideo/ctl-sdk/browser"; import { buildManifest } from "./manifest.ts"; describe("manifest", () => { it("round-trips through ManifestSchema", () => { expect(ManifestSchema.safeParse(buildManifest()).success).toBe(true); }); __CONFIG_SCREEN_TEST__}); `; function manifestTestTs(ctx: ShapeContext): string { return MANIFEST_TEST_TS_TEMPLATE.replace( "__CONFIG_SCREEN_TEST__", ctx.features.has("frontend") ? ` it("declares the configure screen and proxies it", () => { const manifest = buildManifest(); expect(manifest.ui.configScreenUrl).toBe("${CONFIG_SCREEN_URL}"); expect(manifest.api.proxyPaths).toContain("${CONFIG_SCREEN_URL}"); }); ` : ` it("omits the configure UI (the daemon then skips its probe)", () => { expect(buildManifest().ui.configScreenUrl).toBeUndefined(); }); `, ); } const SCHEMAS_INDEX_TS = `export * from "./config.ts"; `; const CONFIG_TS = `import { z } from "zod"; // The product's launch-time config: one SRT ingest passed through to one SRT // egest with a monitoring preview — the starter graph. Grow it field-by-field // as the product takes shape, ADDITIVELY (RFC 0001): never repurpose or remove // a required field once released. export const ProductConfigSchema = z .object({ input: z .object({ transport: z.enum(["srt-listener", "srt-caller"]).default("srt-listener"), // srt-caller only: the remote host this ingest dials out to. host: z.string().optional(), port: z.number().int().min(1).max(65535).default(5001), streamId: z.string().default("input"), }) // prefault, not default: the fallback is input-shaped ({} with the inner // defaults filling in), which zod v4's .default() no longer accepts. .prefault({}), output: z.object({ host: z.string(), port: z.number().int().min(1).max(65535), }), }) .strict(); export type ProductConfig = z.infer; `; const CONFIG_TEST_TS = `import { describe, expect, it } from "bun:test"; import example from "../../../examples/default/input.json" with { type: "json" }; import { ProductConfigSchema } from "./config.ts"; describe("ProductConfigSchema", () => { it("accepts the shipped starter example", () => { expect(ProductConfigSchema.safeParse(example).success).toBe(true); }); it("rejects an unknown field (strict — catch typos at the edge)", () => { const parsed = ProductConfigSchema.safeParse({ output: { host: "h", port: 1 }, extra: true }); expect(parsed.success).toBe(false); }); }); `; const IDS_TS_TEMPLATE = `// Stable component ids — part of the workflow contract: they appear in the // emitted YAML and are cited by INVARIANTS.md rules, so never rename without // coordinating every consumer. export const INGEST_ID = "ingest"; export const EGEST_ID = "egest"; export const PREVIEW_ID = "preview"; __EXAMPLE_ID__`; function idsTs(ctx: ShapeContext): string { return IDS_TS_TEMPLATE.replace( "__EXAMPLE_ID__", ctx.features.has("components") ? `export const EXAMPLE_ID = "${EXAMPLE_ID}";\n` : "", ); } const COMPONENTS_TS = `// Typed factories for the Studio components the starter graph wires up. // Config types come from each built-in's OpenAPI-generated _gen/types, so // drift between what we emit and Studio's real schema surfaces as a TS error // at compile time (Layer 1). Subscriptions are NOT set here — the builder // derives them via connect()/pick*(); the factories only shape config. import type { WorkflowComponent } from "@norskvideo/ctl-sdk/workflow"; import type { components as SrtCallerInputSchemas } from "@norskvideo/norsk-studio-built-ins/lib/input.srt-caller/_gen/types"; import type { components as SrtListenerInputSchemas } from "@norskvideo/norsk-studio-built-ins/lib/input.srt-listener/_gen/types"; import type { components as PreviewOutputSchemas } from "@norskvideo/norsk-studio-built-ins/lib/output.preview/_gen/types"; import type { components as SrtCallerOutputSchemas } from "@norskvideo/norsk-studio-built-ins/lib/output.srt-caller/_gen/types"; type SrtListenerInputConfig = SrtListenerInputSchemas["schemas"]["Config"]; type SrtCallerInputConfig = SrtCallerInputSchemas["schemas"]["Config"]; type SrtCallerOutputConfig = SrtCallerOutputSchemas["schemas"]["Config"]; type PreviewConfig = PreviewOutputSchemas["schemas"]["Config"]; export type IngestSrtListenerArgs = { id: string; port: number; streamId: string; displayName: string }; export function ingestSrtListener(args: IngestSrtListenerArgs): WorkflowComponent { return { type: "input.srt-listener", config: { port: args.port, host: "0.0.0.0", socketOptions: {}, sourceNames: "permissive", streamIds: [args.streamId], burstProtection: true, initialState: "enabled", decodeOutputs: { decode: false }, id: args.id, displayName: args.displayName, }, subscriptions: [], }; } export type IngestSrtCallerArgs = { id: string; host: string; port: number; streamId: string; displayName: string }; export function ingestSrtCaller(args: IngestSrtCallerArgs): WorkflowComponent { return { type: "input.srt-caller", config: { port: args.port, host: args.host, enabled: true, socketOptions: {}, streamId: args.streamId, id: args.id, displayName: args.displayName, }, subscriptions: [], }; } export type EgestSrtCallerArgs = { id: string; host: string; port: number; displayName: string }; export function egestSrtCaller(args: EgestSrtCallerArgs): WorkflowComponent { return { type: "output.srt-caller", config: { displayName: args.displayName, destinations: [{ host: args.host, port: args.port }], jitterBuffer: {}, avDelayMs: 50, subtitles: { kind: "cta708" }, socketOptions: {}, tsEncoder: { kind: "standard" }, id: args.id, }, subscriptions: [], }; } export type PreviewArgs = { id: string; displayName: string }; export function preview(args: PreviewArgs): WorkflowComponent { return { type: "output.preview", config: { displayName: args.displayName, previewMode: "image", showPreview: true, jitterBuffer: {}, id: args.id, }, subscriptions: [], }; } `; const STUDIO_LIBRARY_TS_TEMPLATE = `// Build the composer's ComponentLibrary: Studio's REAL NodeInfos first, this // product's declared stubs second, and nothing else — so builder.validate() // stays a THROWING gate (a typo'd identifier fails the compose instead of // silently resolving to a permissive synthetic). // // NodeInfos are resolved STATICALLY via the built-ins' getNodeInfo aggregator // rather than a RuntimeSystem + registerAll: registerAll does an fs.readdir of // the package's lib/ AT RUNTIME, and \`bun build --target=bun\` bakes in the // host path, which does not exist inside the product image. The static path // pulls each component's design-time info module only and survives bundling. import { callableDefault } from "@norskvideo/ctl-sdk"; import { type ComponentStubSpec, stubbedLibrary } from "@norskvideo/ctl-sdk/workflow"; import { RegistrationConsts } from "@norskvideo/norsk-studio/lib/extension/client-types"; __OPTIONAL_IMPORTS__import type { ComponentLibrary, NodeInfoForBuilder } from "@norskvideo/norsk-studio-builder"; import getNodeInfoImport from "@norskvideo/norsk-studio-built-ins/lib/info"; // TS-compiled-to-CJS with __esModule + exports.default; how that surfaces to // an ESM importer depends on who bundled it. callableDefault tolerates every // interop shape. const getNodeInfo = callableDefault<(consts: unknown, identifier: string) => unknown>( getNodeInfoImport, "@norskvideo/norsk-studio-built-ins/lib/info", ); __OPTIONAL_LOOKUPS__ // Components no npm library provides (this product's own) are declared here as // metadata-only stubs — precise media contracts, no browser code, no _gen/types // needed. Studio's alpha/beta components come from --studio-libs instead, so // their configs stay typed. See ComponentStubSpec in @norskvideo/ctl-sdk/workflow. export const COMPONENT_STUBS: ComponentStubSpec[] = [__STUBS__]; // Each library's aggregator returns undefined for an identifier it does not own. const LOOKUPS = [__LOOKUPS__]; function findNodeInfo(identifier: string): NodeInfoForBuilder | undefined { for (const lookup of LOOKUPS) { const info = lookup(RegistrationConsts, identifier); if (info !== undefined) return info as NodeInfoForBuilder; } return undefined; } export async function buildStudioLibrary(): Promise { return stubbedLibrary(findNodeInfo, COMPONENT_STUBS); } `; function studioLibraryTs(ctx: ShapeContext): string { // The placeholder component is this product's own, so the builder's library // (Studio's npm NodeInfos) cannot know it. Without the stub the composer's // THROWING validate() rejects the starter graph — the trap that tempts people // into demoting validate() to a warning. const stubs = ctx.features.has("components") ? `\n // The placeholder component: a pure side-car, so no accepts/produces —\n // mirrors \`subscription: {}\` in components/src/library/example/info.ts.\n { identifier: "${EXAMPLE_IDENTIFIER}" },\n` : ""; const libs = ctx.studioLibs; const lookupName = (lib: string) => `get${lib[0]?.toUpperCase()}${lib.slice(1)}NodeInfo`; const imports = libs.map((lib) => `import ${lib}InfoImport from "${studioPackage(lib)}/lib/info";\n`).join(""); const lookups = libs .map( (lib) => `const ${lookupName(lib)} = callableDefault<(consts: unknown, identifier: string) => unknown>(\n ${lib}InfoImport,\n "${studioPackage(lib)}/lib/info",\n);\n`, ) .join(""); return STUDIO_LIBRARY_TS_TEMPLATE.replace("__STUBS__", stubs) .replace("__OPTIONAL_IMPORTS__", imports) .replace("__OPTIONAL_LOOKUPS__", lookups) .replace("__LOOKUPS__", ["getNodeInfo", ...libs.map(lookupName)].join(", ")); } const COMPOSE_WORKFLOW_TS_TEMPLATE = `// The production composer: source-first, left-to-right, every connect narrowed // through a pick* helper so filters resolve against real availableStreams(). // addComponent keeps the factories' typing at the addNode boundary — no // \`config as never\`. import { addComponent, toWorkflowDoc, type WorkflowDoc } from "@norskvideo/ctl-sdk/workflow"; import { autoLayout, type ComponentLibrary, pickAll, WorkflowBuilder } from "@norskvideo/norsk-studio-builder"; import type { ProductConfig } from "../schemas/config.ts"; import { egestSrtCaller, ingestSrtCaller, ingestSrtListener, preview } from "./components.ts"; __EXAMPLE_IMPORTS__import { EGEST_ID, INGEST_ID, PREVIEW_ID } from "./ids.ts"; export function composeWorkflow(config: ProductConfig, library: ComponentLibrary): WorkflowDoc { const builder = new WorkflowBuilder(library); // addComponent per branch: each factory keeps its own config typing at the // addNode boundary (a union would erase it). const ingest = config.input.transport === "srt-caller" ? addComponent( builder, ingestSrtCaller({ id: INGEST_ID, host: config.input.host ?? "127.0.0.1", port: config.input.port, streamId: config.input.streamId, displayName: "Ingest", }), ) : addComponent( builder, ingestSrtListener({ id: INGEST_ID, port: config.input.port, streamId: config.input.streamId, displayName: "Ingest", }), ); const egest = addComponent( builder, egestSrtCaller({ id: EGEST_ID, host: config.output.host, port: config.output.port, displayName: "Egest" }), ); const monitor = addComponent(builder, preview({ id: PREVIEW_ID, displayName: "Preview" })); pickAll(builder.connect(ingest, egest), ["video", "audio"]); pickAll(builder.connect(ingest, monitor), ["video"]); __EXAMPLE_NODE__ // Layer 2 is a THROWING gate (INV-COMPOSE-001). Never demote this to a // warning — declare out-of-library components in COMPONENT_STUBS instead. const issues = builder.validate(); if (issues.length > 0) { const formatted = issues.map((i) => \` - \${i.nodeId ?? ""}: \${i.message}\`).join("\\n"); throw new Error(\`WorkflowBuilder.validate() returned issues:\\n\${formatted}\`); } const doc = toWorkflowDoc(builder); doc.__layout = autoLayout(doc); return doc; } `; function composeWorkflowTs(ctx: ShapeContext): string { if (!ctx.features.has("components")) { return COMPOSE_WORKFLOW_TS_TEMPLATE.replace("__EXAMPLE_IMPORTS__", "").replace("__EXAMPLE_NODE__", ""); } return COMPOSE_WORKFLOW_TS_TEMPLATE.replace( "__EXAMPLE_IMPORTS__", 'import { exampleSidecar } from "./example-config.ts";\n', ) .replace( "__EXAMPLE_NODE__", ` // The placeholder component, wired in so a freshly generated product proves // the whole custom-node pipeline on its first launch: registered, in the // toolbox, publishing state. It carries no media (subscription: {}), so it // connects to nothing and perturbs no edge. Delete it with the component. addComponent(builder, exampleSidecar({ id: EXAMPLE_ID, displayName: "Example" })); `, ) .replace("import { EGEST_ID, INGEST_ID, PREVIEW_ID }", "import { EGEST_ID, EXAMPLE_ID, INGEST_ID, PREVIEW_ID }"); } const COMPOSE_WORKFLOW_TEST_TS = `// Layer 3: the INVARIANTS.md rules table in executable form. Each rule cites // its INV-* ID verbatim; tests/unit/invariants-parity.test.ts fails the suite // if table and tests drift apart. The matrix exercises every composer branch. import { beforeAll, describe, expect, test } from "bun:test"; import type { WorkflowComponent, WorkflowDoc } from "@norskvideo/ctl-sdk/workflow"; import type { ComponentLibrary } from "@norskvideo/norsk-studio-builder"; import { type ProductConfig, ProductConfigSchema } from "../schemas/config.ts"; import { composeWorkflow } from "./compose-workflow.ts"; import { EGEST_ID, INGEST_ID, PREVIEW_ID } from "./ids.ts"; import { buildStudioLibrary } from "./studio-library.ts"; const MATRIX: [string, ProductConfig][] = [ ["listener", ProductConfigSchema.parse({ input: {}, output: { host: "203.0.113.10", port: 8001 } })], [ "caller", ProductConfigSchema.parse({ input: { transport: "srt-caller", host: "203.0.113.9", port: 7001 }, output: { host: "203.0.113.10", port: 8001 }, }), ], ]; let library: ComponentLibrary; const docs = new Map(); beforeAll(async () => { library = await buildStudioLibrary(); for (const [name, config] of MATRIX) docs.set(name, composeWorkflow(config, library)); }); function findById(doc: WorkflowDoc, id: string): WorkflowComponent { const found = doc.components.find((c) => (c.config as { id?: string }).id === id); if (!found) throw new Error(\`no component with id \${id}\`); return found; } function subscribedMedia(component: WorkflowComponent, source: string): string[] { return component.subscriptions .filter((s) => s.source === source) .flatMap((s) => s.streams.filter.map((f) => f.media)); } describe.each(MATRIX)("composed graph (%s)", (name) => { test("INV-GRAPH-001: ingest matches the configured transport and port", () => { const config = MATRIX.find(([n]) => n === name)?.[1] as ProductConfig; const ingest = findById(docs.get(name) as WorkflowDoc, INGEST_ID); const expectedType = config.input.transport === "srt-caller" ? "input.srt-caller" : "input.srt-listener"; expect(ingest.type).toBe(expectedType); expect((ingest.config as { port?: number }).port).toBe(config.input.port); }); test("INV-GRAPH-002: egest dials the configured destination with ingest AV", () => { const config = MATRIX.find(([n]) => n === name)?.[1] as ProductConfig; const egest = findById(docs.get(name) as WorkflowDoc, EGEST_ID); expect((egest.config as { destinations?: { host: string; port: number }[] }).destinations).toEqual([ { host: config.output.host, port: config.output.port }, ]); const media = subscribedMedia(egest, INGEST_ID); expect(media).toContain("video"); expect(media).toContain("audio"); }); test("INV-GRAPH-003: preview taps ingest video only", () => { const monitor = findById(docs.get(name) as WorkflowDoc, PREVIEW_ID); expect(subscribedMedia(monitor, INGEST_ID)).toEqual(["video"]); }); }); test("INV-COMPOSE-001: an identifier that is neither real nor stubbed fails the compose", () => { const emptyLibrary: ComponentLibrary = { find: () => undefined }; const [, config] = MATRIX[0] as [string, ProductConfig]; expect(() => composeWorkflow(config, emptyLibrary)).toThrow(); }); `; function workflowIndexTs(ctx: ShapeContext): string { return [ 'export * from "./components.ts";', 'export * from "./compose-workflow.ts";', ...(ctx.features.has("components") ? ['export * from "./example-config.ts";'] : []), 'export * from "./ids.ts";', 'export * from "./studio-library.ts";', "", ].join("\n"); } const SHARED_INDEX_TS = `export * from "./manifest.ts"; export * from "./product-template.ts"; export * from "./schemas/index.ts"; export * from "./version.ts"; export * from "./workflow/index.ts"; `; // Biome collapses an array onto one line whenever it fits the 120-column width // and breaks it one element per line otherwise; objects keep the expansion they // are written with. An emitted array whose length depends on the product name // has to be laid out by the same rule, or the generated repo fails lint. function biomeStringArray(prefix: string, suffix: string, items: string[]): string { const quoted = items.map((item) => `"${item}"`); const oneLine = `${prefix}[${quoted.join(", ")}]${suffix}`; if (oneLine.length <= 120) return oneLine; const indent = `${prefix.match(/^ */)?.[0] ?? ""} `; return `${prefix}[\n${quoted.map((q) => `${indent}${q},`).join("\n")}\n${indent.slice(2)}]${suffix}`; } function productTemplateTs(ctx: ShapeContext): string { const hasComponents = ctx.features.has("components"); // The shared module stays I/O-free: callers (the backend route, tests) read // from disk and hand the bytes in. const optsFields = [ ...(hasComponents ? [ ` // Pre-read component files (components/lib, compiled). components?: Array<{ path: string; content: string | Uint8Array }>;`, ] : []), ...(ctx.features.has("dashboard") ? [ ` // Pre-read dashboard files, keyed by workflow name (dashboards/, built). dashboards?: Array<{ path: string; content: string | Uint8Array }>;`, ] : []), ] .map((field) => `${field}\n`) .join(""); const materialComponents = hasComponents ? "opts.components ?? []" : "[]"; const materialDashboards = ctx.features.has("dashboard") ? "opts.dashboards ?? []" : "[]"; const studioLibraries = biomeStringArray(" library: ", ",", [ "@norskvideo/norsk-studio-built-ins", ...ctx.studioLibs.map(studioPackage), ...(hasComponents ? [libraryPackage(ctx)] : []), ]); // Where the operator looks. Each feature answers it differently, and the // proxy entry it needs comes with it — a screen exposed on the wrong path // works on the direct port and 404s through the proxy. const screen = ctx.features.has("views") ? VIEWS_RUNTIME_SCREEN : ctx.features.has("dashboard") ? DASHBOARD_RUNTIME_SCREEN : undefined; const screenWhy = ctx.features.has("views") ? ` // The whole per-instance surface is Studio's: ctl anchors // studio_url_prefix on its own /studio/ route, so the component's // fullscreen page resolves under it.` : ` // Studio's per-workflow mount serves the dashboards/ directory the // template packs. The path segment IS the workflow name — the tar ships // workflow.yml at its root, so it is "workflow".`; const runtimeScreen = screen ? ` ui: { ${screenWhy} runtimeScreenUrl: "${screen.url}", runtimeScreenLabel: "${screen.label}", },\n` : ""; const proxyExpose = [ '"/"', '"/static/*"', '"/api/*"', ...(ctx.features.has("views") ? [`"${VIEWS_PROXY_EXPOSE}"`] : []), ...(ctx.features.has("dashboard") ? [`"${DASHBOARD_PROXY_EXPOSE}"`] : []), '"/live/api/*"', '"/ws"', '"/ws/*"', ].join(", "); return `// Product-template materials: the manifest/compose/parameters/workflow set the // runner stores at \`product add\`. Packing to tar is single-sourced in // @norskvideo/ctl-product-template-schema/pack — this repo carries no copy of // the wire format. import { ProductTemplateAdvancedSchema, type ProductTemplateManifest, type ProductTemplateMaterials, type ProductTemplateParameter, STANDARD_ADVANCED_OVERRIDES, STANDARD_LOGS, } from "@norskvideo/ctl-product-template-schema"; import type { ComponentLibrary } from "@norskvideo/norsk-studio-builder"; import { stringify as yamlStringify } from "yaml"; import type { ProductConfig } from "./schemas/config.ts"; import { MEDIA_IMAGE, PRODUCT_NAME, PRODUCT_VERSION, STUDIO_IMAGE } from "./version.ts"; import { composeWorkflow } from "./workflow/compose-workflow.ts"; export type BuildOpts = { // The builder's component library, built once at process startup via // buildStudioLibrary(). Required — the production composer is the builder. library?: ComponentLibrary; generatedAt?: Date; // Create-time custom defaults from the build form's disclosure, merged // per-knob over STANDARD_ADVANCED_OVERRIDES into manifest.advanced. advanced?: ProductTemplateManifest["advanced"]; ${optsFields}}; // The build form rides its Custom defaults as a reserved $advancedDefaults // sibling of the config keys; the config schema is strict, so split it off // before the parse. Absent key = plain config passthrough. export function splitAdvancedDefaults( body: unknown, ): { ok: true; config: unknown; advanced?: ProductTemplateManifest["advanced"] } | { ok: false; issues: unknown[] } { if (typeof body !== "object" || body === null || !("$advancedDefaults" in body)) return { ok: true, config: body }; const { $advancedDefaults: raw, ...config } = body as Record; const parsed = ProductTemplateAdvancedSchema.safeParse(raw); if (!parsed.success) return { ok: false, issues: parsed.error.issues }; return { ok: true, config, advanced: parsed.data }; } const STUDIO_DEFAULT_HOST_PORT = 18000; export function buildProductTemplateMaterials(config: ProductConfig, opts: BuildOpts = {}): ProductTemplateMaterials { if (!opts.library) { throw new Error( "buildProductTemplateMaterials: ComponentLibrary required (call buildStudioLibrary() once at startup)", ); } const generatedAt = opts.generatedAt ?? new Date(); const manifest: ProductTemplateManifest = { productTemplateSchemaVersion: 1, productName: PRODUCT_NAME, productVersion: PRODUCT_VERSION, target: "docker-compose", generatedAt: generatedAt.toISOString(), ${runtimeScreen} proxy: { // Permissive starter set: exposes Studio's own UI/API through the oauth2 // proxy. Narrow as the product grows a surface of its own. expose: [${proxyExpose}], }, advanced: { ...STANDARD_ADVANCED_OVERRIDES, ...opts.advanced }, // How each compose service logs, for the node's log agent. A service // added to renderCompose needs its own entry here — the drift gate // requires a test (product-template.test.ts) that says so. logs: STANDARD_LOGS, }; return { manifest, composeYaml: renderCompose(config), parameters: { parameters: renderParameters(config) }, workflow: yamlStringify(composeWorkflow(config, opts.library), { aliasDuplicateObjects: false }), components: ${materialComponents}, dashboards: ${materialDashboards}, assets: [], workdirSeed: [], }; } // docker-compose for the studio + media pair. The reserved \`studio\` + // \`media\` service names let the runner's override generator target them // (workflow.yml mount, license env/file mount, etc). export function renderCompose(config: ProductConfig): string { const studioEnv: Record = { // Studio declares this product to the engine at the V2 licence handshake; // without it Studio falls back to its baked default and the instance would // demand a Studio entitlement rather than its own. NORSK_PRODUCT_NAME: PRODUCT_NAME, // The product's list OVERRIDES the daemon's seeded default.yaml rather than // merging with it, so it has to name EVERY library it wants loaded — a // component mounted but absent from here is never loaded, with no error. NODE_CONFIG: JSON.stringify({ server: { ${studioLibraries} }, }), }; // biome-ignore lint/suspicious/noTemplateCurlyInString: docker-compose variable reference, not a TS template literal const studioPorts: string[] = ["\${STUDIO_HOST_PORT}:8000"]; const mediaPorts: string[] = []; // SRT-listener ingests bind inside the media engine and must be published as // UDP (without /udp docker publishes TCP and the host can't reach Norsk's // listener). srt-caller ingests dial out — no published port. // INGEST_PORT is the host side only: the listener inside the engine binds the // configured port whatever INGEST_PORT says. if (config.input.transport === "srt-listener") { mediaPorts.push(\`\\\${INGEST_PORT}:\${config.input.port}/udp\`); } const compose = { // biome-ignore lint/suspicious/noTemplateCurlyInString: docker-compose variable reference, not a TS template literal name: "\${INSTANCE_NAME}", services: { studio: { image: STUDIO_IMAGE, environment: studioEnv, ports: studioPorts, restart: "unless-stopped", }, media: { image: MEDIA_IMAGE, ...(mediaPorts.length > 0 ? { ports: mediaPorts } : {}), restart: "unless-stopped", }, }, }; return yamlStringify(compose, { aliasDuplicateObjects: false, lineWidth: 0 }); } function renderParameters(config: ProductConfig): ProductTemplateParameter[] { const params: ProductTemplateParameter[] = [ { name: "INSTANCE_NAME", purpose: "Compose project label for this instance", type: "string", default: "${ctx.dashboardKey}", }, { name: "STUDIO_HOST_PORT", purpose: "Port on the host where Studio's web UI is exposed", type: "number", default: String(STUDIO_DEFAULT_HOST_PORT), }, ]; if (config.input.transport === "srt-listener") { params.push({ name: "INGEST_PORT", purpose: "Host UDP port to bind for the SRT ingest", type: "number", default: String(config.input.port), }); } return params; } `; } const PRODUCT_TEMPLATE_TEST_TS = `// Tar round-trip: pack real materials, unpack, validate what came out against // the schema package — proves the wire format end to end without a daemon. import { beforeAll, describe, expect, it } from "bun:test"; import { listTarNames, unpackTar } from "@norskvideo/ctl-foundation"; import { ParametersFileSchema, ProductTemplateManifestSchema, ProductTemplateMaterialsSchema, productTemplateLogsProblems, } from "@norskvideo/ctl-product-template-schema"; import { packProductTemplate } from "@norskvideo/ctl-product-template-schema/pack"; import type { ComponentLibrary } from "@norskvideo/norsk-studio-builder"; import { parse as yamlParse } from "yaml"; import example from "../../examples/default/input.json" with { type: "json" }; import { buildProductTemplateMaterials } from "./product-template.ts"; import { ProductConfigSchema } from "./schemas/config.ts"; import { buildStudioLibrary } from "./workflow/studio-library.ts"; let library: ComponentLibrary; beforeAll(async () => { library = await buildStudioLibrary(); }); describe("product-template materials", () => { it("validate against ProductTemplateMaterialsSchema", () => { const materials = buildProductTemplateMaterials(ProductConfigSchema.parse(example), { library }); expect(ProductTemplateMaterialsSchema.safeParse(materials).success).toBe(true); }); it("declare how every compose service logs", () => { const materials = buildProductTemplateMaterials(ProductConfigSchema.parse(example), { library }); expect(productTemplateLogsProblems(materials)).toEqual([]); }); it("pack to a tar whose entries round-trip through the schema package", () => { const materials = buildProductTemplateMaterials(ProductConfigSchema.parse(example), { library }); const tar = packProductTemplate(materials); const names = listTarNames(tar); expect(names.status).toBe("ok"); if (names.status !== "ok") return; expect(names.value).toEqual( expect.arrayContaining(["manifest.json", "compose.yml", "workflow.yml", "parameters.yaml"]), ); const entries = unpackTar(tar); expect(entries.status).toBe("ok"); if (entries.status !== "ok") return; const decode = (name: string) => { const entry = entries.value.find((e) => e.name === name); if (!entry) throw new Error(\`missing tar entry \${name}\`); return new TextDecoder().decode(entry.content); }; expect(ProductTemplateManifestSchema.safeParse(JSON.parse(decode("manifest.json"))).success).toBe(true); expect(ParametersFileSchema.safeParse(yamlParse(decode("parameters.yaml"))).success).toBe(true); expect(decode("compose.yml")).toContain("studio:"); }); }); `; function backendPackageJson(ctx: ShapeContext): string { return `${JSON.stringify( { name: `${ctx.scope}/backend`, version: "0.0.1", private: true, type: "module", exports: { ".": "./src/index.ts" }, scripts: { dev: "bun run --watch src/index.ts", build: "bun build src/index.ts --outdir dist --target=bun", clean: "rm -rf dist", start: "bun run src/index.ts", test: "bun test src/", typecheck: "bunx tsc --noEmit", }, dependencies: { [`${ctx.scope}/shared`]: "workspace:*", "@norskvideo/ctl-product-template-schema": "^0.1.0", "@norskvideo/ctl-sdk": "^0.1.1", "@norskvideo/norsk-studio-builder": ctx.pins.studioLib, cors: "^2.8.5", express: "^5.0.1", zod: PRODUCT_ZOD, }, devDependencies: { "@types/bun": "latest", "@types/cors": "^2.8.17", "@types/express": "^5.0.0", "@types/node": "^22.10.0", typescript: "^5.7.2", }, }, null, 2, )}\n`; } function backendIndexTs(ctx: ShapeContext): string { return `import { startServer } from "./server.ts"; // ${ctx.devPort} is the LOCAL-DEV default only; inside the container the // bundle driver sets PORT=4321 (the port the runner hardcodes). const port = Number(process.env.PORT ?? ${ctx.devPort}); await startServer(port); console.log(\`${ctx.name} listening on :\${port}\`); `; } function backendServerTs(ctx: ShapeContext): string { const hasFrontend = ctx.features.has("frontend"); const frontendImports = hasFrontend ? 'import path from "node:path";\n' : ""; const frontendDist = hasFrontend ? '\nconst FRONTEND_DIST = path.resolve(import.meta.dir, "../../frontend/dist");\n' : ""; // Carries its own LEADING newline and no trailing one, so the absent case // leaves exactly one blank line rather than two. const frontendBlock = hasFrontend ? `\n${FRONTEND_SERVER_BLOCK}\n` : ""; return `import type { Server } from "node:http"; ${frontendImports}import { buildStudioLibrary } from "${ctx.scope}/shared/workflow"; import { docsBundleDir, serveDocs } from "@norskvideo/ctl-sdk"; import cors from "cors"; import express from "express"; import { manifestRouter } from "./routes/manifest.ts"; import { openapiRouter } from "./routes/openapi.ts"; import { makeProductTemplateRouter } from "./routes/product-template.ts"; ${frontendDist} export async function startServer(port: number): Promise { // Built once at startup: Studio's real NodeInfos + this product's declared // stubs. Passed into the product-template route so every build reuses it. const library = await buildStudioLibrary(); const app = express(); app.use(cors()); app.use(express.json({ limit: "1mb" })); app.get("/healthz", (_req, res) => { res.status(200).send("ok"); }); app.use(manifestRouter); app.use("/api", openapiRouter); app.use("/api", makeProductTemplateRouter(library)); // The docs bundle (\`bun run docs:manual\`, baked into the image as docs/), // served at /docs; a no-op until it has been built. serveDocs(app, docsBundleDir(import.meta.dir)); ${frontendBlock} return await new Promise((resolve, reject) => { const server = app.listen(port, () => resolve(server)); server.on("error", (err) => reject(err)); }); } `; } function backendServerTestTs(ctx: ShapeContext): string { return `// The daemon hard-rejects a registration whose manifest-declared paths don't // answer (waitForReady probes api.healthCheckPath; defaultProductTemplates are // GET at product-add time). Assert here that every declared path is actually // served — a mismatch otherwise only surfaces as "product did not become // ready" at \`product add\` time, never in a local run. import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import type { Server } from "node:http"; import { buildManifest } from "${ctx.scope}/shared/manifest"; import { startServer } from "./server.ts"; let server: Server; let baseUrl: string; beforeAll(async () => { server = await startServer(0); const address = server.address(); if (address === null || typeof address === "string") throw new Error("expected a TCP address"); baseUrl = \`http://127.0.0.1:\${address.port}\`; }); afterAll(() => { server.close(); }); describe("manifest-declared paths are actually served", () => { it("serves api.healthCheckPath", async () => { const res = await fetch(\`\${baseUrl}\${buildManifest().api.healthCheckPath}\`); expect(res.status).toBe(200); }); it("serves the manifest itself at /manifest.json", async () => { const res = await fetch(\`\${baseUrl}/manifest.json\`); expect(res.status).toBe(200); const body = (await res.json()) as { name?: string }; expect(body.name).toBe(buildManifest().name); }); it("serves api.openapiFragmentPath", async () => { const res = await fetch(\`\${baseUrl}\${buildManifest().api.openapiFragmentPath}\`); expect(res.status).toBe(200); expect(await res.text()).toContain("openapi:"); }); it("serves every defaultProductTemplates url as a tar", async () => { for (const template of buildManifest().defaultProductTemplates) { const res = await fetch(\`\${baseUrl}\${template.url}\`); expect(res.status).toBe(200); expect(res.headers.get("content-type")).toContain("x-tar"); } }); }); `; } function routesManifestTs(ctx: ShapeContext): string { return `import { buildManifest } from "${ctx.scope}/shared/manifest"; import { createManifestRouter } from "@norskvideo/ctl-sdk"; export const manifestRouter = createManifestRouter(buildManifest); `; } function routesOpenapiTs(ctx: ShapeContext): string { return `import { PRODUCT_VERSION } from "${ctx.scope}/shared"; import { createOpenapiRouter } from "@norskvideo/ctl-sdk"; // Minimal OpenAPI fragment — the manifest points the runner at // /api/openapi.yaml. Expand (or generate from zod) as the API grows. const buildOpenApiYaml = (): string => \`openapi: 3.0.3 info: title: ${ctx.name} version: "\${PRODUCT_VERSION}" paths: /api/product-template: post: summary: Build a product-template tar from a ProductConfig responses: "200": description: product-template tar content: application/x-tar: {} "400": description: invalid config /api/product-template/{slug}: get: summary: Build a product-template tar from a named starter config parameters: - name: slug in: path required: true schema: type: string responses: "200": description: product-template tar content: application/x-tar: {} "404": description: unknown slug \`; export const openapiRouter = createOpenapiRouter(buildOpenApiYaml); `; } function routesProductTemplateTs(ctx: ShapeContext): string { const hasComponents = ctx.features.has("components"); const hasDashboard = ctx.features.has("dashboard"); // Each entry carries its own newline: the interpolation sits directly against // the blank line before the next block, so an empty list must add nothing. const loaderImports = [ ...(hasComponents ? ['import { loadCompiledComponents } from "../lib/components.ts";'] : []), ...(hasDashboard ? ['import { loadDashboardFiles } from "../lib/dashboards.ts";'] : []), ] .map((line) => `${line}\n`) .join(""); const buildOpts = [ "library", "advanced", ...(hasComponents ? ["components: loadCompiledComponents()"] : []), ...(hasDashboard ? ["dashboards: loadDashboardFiles()"] : []), ] .map((entry) => ` ${entry},`) .join("\n"); return `import { type BuildOpts, buildProductTemplateMaterials, splitAdvancedDefaults, } from "${ctx.scope}/shared/product-template"; import { ProductConfigSchema } from "${ctx.scope}/shared/schemas"; import { packProductTemplate } from "@norskvideo/ctl-product-template-schema/pack"; import type { ComponentLibrary } from "@norskvideo/norsk-studio-builder"; import { Router } from "express"; import defaultExample from "../../../examples/default/input.json" with { type: "json" }; ${loaderImports} // Starter configs, mirrored in manifest.ts's defaultProductTemplates[]. JSON // imported at module load so the bundler inlines it — the deployed container // doesn't ship the examples/ source tree. Keep the two in lockstep. const STARTER_CONFIGS: Record = { default: defaultExample, }; export function makeProductTemplateRouter(library: ComponentLibrary): Router { const router = Router(); const sendTemplate = ( res: Parameters[1]>[1], raw: unknown, filename: string, advanced?: BuildOpts["advanced"], ) => { const parsed = ProductConfigSchema.safeParse(raw); if (!parsed.success) { res.status(400).json({ error: "invalid config", issues: parsed.error.issues }); return; } try { const materials = buildProductTemplateMaterials(parsed.data, { ${buildOpts} }); const tar = packProductTemplate(materials); res .status(200) .type("application/x-tar") .setHeader("Content-Disposition", \`attachment; filename="\${filename}"\`) .send(Buffer.from(tar)); } catch (err) { res.status(500).json({ error: "product-template generation failed", message: err instanceof Error ? err.message : String(err), }); } }; // POST /api/product-template — build a tar from an arbitrary ProductConfig, // optionally carrying create-time custom defaults as $advancedDefaults. router.post("/product-template", (req, res) => { const split = splitAdvancedDefaults(req.body); if (!split.ok) { res.status(400).json({ error: "invalid $advancedDefaults", issues: split.issues }); return; } sendTemplate(res, split.config, "${ctx.name}.tar", split.advanced); }); // GET /api/product-template/ — the runner GETs these at product-add // time per manifest.defaultProductTemplates. router.get("/product-template/:slug", (req, res) => { const raw = STARTER_CONFIGS[req.params.slug]; if (!raw) { res.status(404).json({ error: "unknown product-template slug", slug: req.params.slug }); return; } sendTemplate(res, raw, \`\${req.params.slug}.tar\`); }); return router; } `; } function testsPackageJson(ctx: ShapeContext): string { return `${JSON.stringify( { name: `${ctx.scope}/tests`, version: "0.0.1", private: true, type: "module", devDependencies: { "@norskvideo/ctl-dev-kit": "^0.2.0", // Same range the backend declares: the tests tree imports the schema // directly, and bun links a member's deps into its OWN node_modules — // the backend's copy is not reachable from here. "@norskvideo/ctl-product-template-schema": "^0.1.0", "@norskvideo/ctl-test-harness": "^0.1.23", "@types/bun": "latest", "@types/node": "^22.10.0", typescript: "^5.7.2", }, }, null, 2, )}\n`; } const INVARIANTS_PARITY_TEST_TS = `import { expect, test } from "bun:test"; import { join } from "node:path"; import { invariantParityFromDisk } from "@norskvideo/ctl-dev-kit/testing/invariants"; const root = join(import.meta.dir, "..", ".."); test("every invariant has a citing test and every cited ID is tabled", () => { const report = invariantParityFromDisk({ invariantsPath: join(root, "INVARIANTS.md"), testDirs: [join(root, "shared"), join(root, "backend"), join(root, "tests")], }); expect(report.problems).toEqual([]); }); `; const WORKFLOW_SNAPSHOT_TEST_TS = `// Byte-snapshots of the emitted wire formats for the starter config: any // emission change must come with a reviewable fixture diff. First run: // UPDATE_SNAPSHOTS=1 bun run test:unit bakes the fixtures — commit them. import { beforeAll, test } from "bun:test"; import { join } from "node:path"; import { expectByteSnapshot } from "@norskvideo/ctl-dev-kit/testing/byte-snapshot"; import type { ProductTemplateMaterials } from "@norskvideo/ctl-product-template-schema"; import example from "../../examples/default/input.json" with { type: "json" }; import { buildProductTemplateMaterials } from "../../shared/src/product-template.ts"; import { ProductConfigSchema } from "../../shared/src/schemas/config.ts"; import { buildStudioLibrary } from "../../shared/src/workflow/studio-library.ts"; const fixture = (name: string) => join(import.meta.dir, "__snapshots__", name); let materials: ProductTemplateMaterials; beforeAll(async () => { materials = buildProductTemplateMaterials(ProductConfigSchema.parse(example), { library: await buildStudioLibrary(), // Snapshots must be byte-stable; only the manifest carries generatedAt and // it is not snapshotted. generatedAt: new Date(0), }); }); test("workflow.yml emission is byte-stable", () => { expectByteSnapshot(materials.workflow ?? "", fixture("default.workflow.yml")); }); test("compose.yml emission is byte-stable", () => { expectByteSnapshot(materials.composeYaml, fixture("default.compose.yml")); }); `; const LOADS_IN_STUDIO_TEST_TS = `// Layer 4: the emitted YAML through Studio's REAL document.load(), so every // built-in's zod schema validates what the composer actually emits. Stubs // cover COMPONENT_STUBS identifiers (no schema to validate against). import { expect, test } from "bun:test"; import { assertLoadsInStudio } from "@norskvideo/ctl-test-harness/studio-load"; import example from "../../examples/default/input.json" with { type: "json" }; import { buildProductTemplateMaterials } from "../../shared/src/product-template.ts"; import { ProductConfigSchema } from "../../shared/src/schemas/config.ts"; import { buildStudioLibrary, COMPONENT_STUBS } from "../../shared/src/workflow/studio-library.ts"; test("starter workflow round-trips through Studio document.load()", async () => { const materials = buildProductTemplateMaterials(ProductConfigSchema.parse(example), { library: await buildStudioLibrary(), }); const compiled = await assertLoadsInStudio(materials.workflow ?? "", { stubs: COMPONENT_STUBS.map((s) => s.identifier), }); expect(compiled.errors).toEqual([]); }); `; function imageSmokeTestTs(ctx: ShapeContext): string { return `// Image-tier smoke test for the packaged product container. Docker-only (no // daemon, no licence): docker run the built image on a loopback port, // mirroring the runner's -p 127.0.0.1::4321, and curl the surface // \`product add\` probes. This is the tier that catches bundling constraints // (e.g. a runtime fs.readdir surviving \`bun build --target=bun\`) that unit // tests and a host \`bun run start\` cannot see. // // Run \`bun run build:image\` first; then \`bun run test:image\`. import { describe, expect, test } from "bun:test"; import { spawnSync } from "node:child_process"; const IMAGE_TAG = process.env.IMAGE_TAG ?? "${ctx.name}:dev"; function imageExists(tag: string): boolean { return spawnSync("docker", ["image", "inspect", tag], { stdio: "ignore" }).status === 0; } async function waitForHealthz(url: string, timeoutMs: number): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { try { const r = await fetch(url, { signal: AbortSignal.timeout(1000) }); if (r.ok) return; } catch {} await Bun.sleep(500); } throw new Error(\`container \${url} never became healthy within \${timeoutMs}ms\`); } describe("${ctx.name} image: container surface", () => { test("built image serves /healthz, the manifest, and the starter template tar", async () => { expect(imageExists(IMAGE_TAG), \`image \${IMAGE_TAG} not found — run: bun run build:image\`).toBe(true); const hostPort = 24329; const run = spawnSync("docker", ["run", "-d", "--rm", "-p", \`127.0.0.1:\${hostPort}:4321\`, IMAGE_TAG], { encoding: "utf-8", }); expect(run.status, \`docker run failed: \${run.stderr}\`).toBe(0); const containerId = run.stdout.trim(); try { const base = \`http://127.0.0.1:\${hostPort}\`; await waitForHealthz(\`\${base}/healthz\`, 30_000); const manRes = await fetch(\`\${base}/manifest.json\`); expect(manRes.status).toBe(200); const manifest = (await manRes.json()) as { name?: string }; expect(manifest.name).toBe("${ctx.name}"); const tarRes = await fetch(\`\${base}/api/product-template/default\`); expect(tarRes.status).toBe(200); expect(tarRes.headers.get("content-type")).toContain("x-tar"); const tar = Buffer.from(await tarRes.arrayBuffer()); // ustar magic at offset 257 of the first header block. expect(tar.subarray(257, 262).toString("ascii")).toBe("ustar"); const tarText = tar.toString("latin1"); for (const entry of ["manifest.json", "compose.yml", "workflow.yml", "parameters.yaml"]) { expect(tarText).toContain(entry); } } finally { spawnSync("docker", ["rm", "-f", containerId], { stdio: "ignore" }); } }, 120_000); }); `; } function iterateSh(ctx: ShapeContext): string { return `#!/usr/bin/env bash # # Rebuild ${ctx.name} and reload it into the LIVE norsk-ctl daemon: # build image -> \`bun run demo -- up --mode image --daemon reuse\`. # # The driver (tests/demo.spec.ts via @norskvideo/ctl-test-harness/demo) does # the stored-template dance CLAUDE.md describes: deletes the instance, removes # the template + product, adds the fresh image again, checks the studio pin # against manifest.seed.json, pumps the source, waits for the ready gates, # prints the URLs and holds. Ctrl-C (or \`bun run demo -- down\`) removes the # instance; your daemon and the registration stay. # # Needs the daemon already running (\`norsk-ctl serve\`), \`norsk-ctl\` on PATH # (\`nix develop .#dev\` provides it) and a licence: NORSK_LICENSE_FILE, else # the committed non-production dev licence at licensing/${ctx.name}-dev.json. # # Usage (from the repo root): # bun run iterate # full loop # SKIP_BUILD=1 bun run iterate # reuse the existing image # IMAGE_TAG=... bun run iterate # build + register under another tag set -euo pipefail here="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)" repo="$(cd "$here/.." && pwd)" # The licence names the product by image ref, so the build lands on that ref # directly (build-image.sh honours IMAGE_TAG). export IMAGE_TAG="\${IMAGE_TAG:-${ctx.name}:dev}" if [ -z "\${NORSK_LICENSE_FILE:-}" ] && [ -f "$repo/licensing/${ctx.name}-dev.json" ]; then export NORSK_LICENSE_FILE="$repo/licensing/${ctx.name}-dev.json" fi if [ -n "\${SKIP_BUILD:-}" ]; then echo "=== build (skipped, SKIP_BUILD set) ===" else echo "=== build image $IMAGE_TAG ===" bun run --cwd "$repo" build:image fi cd "$repo" exec bun run demo -- up --mode image --daemon reuse `; } function smokeSpecTs(ctx: ShapeContext): string { return `// The smoke tier: one journey through the built image, run by // @norskvideo/ctl-test-harness/smoke on PRs, main and the nightly bump. // smoke.yml builds the image and sets SMOKE_IMAGE_TAG; locally, // \`bun run build:image && bun run test:smoke\` with NORSK_LICENSE_FILE set. // The tag must be one the licence names (licenseTemplate.json). // // The camera1 sample goes to the SRT ingest. What it proves: the graph is up // and the ingest takes the feed. Grow the observations as the product takes // shape: they are what an unattended nightly bump is trusted on. import { test } from "bun:test"; import { hashSlot } from "@norskvideo/ctl-test-harness/harness-config"; import { runProductSmoke } from "@norskvideo/ctl-test-harness/smoke"; // The listener inside the media container (the configured default), which the // pump dials on the docker network. const LISTENER_PORT = 5001; // The host side of that publish, moved off 5001: other products' smokes (and a // running demo) hold it on a shared runner. const INGEST_HOST_PORT = 27001 + hashSlot("${ctx.name}", 40) * 4; test( "${ctx.name}: the graph comes up and takes the SRT feed", () => runProductSmoke("${ctx.name}", { product: { name: "${ctx.name}", register: { image: process.env.SMOKE_IMAGE_TAG ?? "${ctx.name}:dev" }, }, template: { name: "default" }, params: [\`INGEST_PORT=\${INGEST_HOST_PORT}\`], hardware: "none", sources: [{ port: LISTENER_PORT, name: "input", streamId: "input", preset: "camera1" }], observe: [ { kind: "components", ids: ["ingest", "egest"] }, { kind: "srt-connected", componentId: "ingest" }, ], }), 600_000, ); `; } function demoSpecTs(ctx: ShapeContext): string { return `// The ${ctx.name} demo (fleet review 05-demo): the manifest's default // template with the daemon's camera1 sample pumped into the SRT ingest. Run by // the @norskvideo/ctl-test-harness demo driver: // // bun run demo -- up from source on a private daemon; Ctrl-C tears down // bun run demo -- up --mode image --daemon reuse the built image into YOUR live daemon (bun run iterate) // bun run demo -- check headless, torn down on exit; what CI runs // // The driver owns the daemon, the ingest port (INGEST_PORT, read back at // launch), the pump and the teardown. Grow the ready gates as the product // takes shape: the one below only says Studio's runtime API answers. import { defineDemo } from "@norskvideo/ctl-test-harness/demo"; export default defineDemo({ product: "${ctx.name}", dev: { command: ["bun", "run", "dev"], readyPath: "/healthz" }, // The licence names the product by image ref; build-image.sh honours IMAGE_TAG. image: process.env.IMAGE_TAG ?? "${ctx.name}:dev", template: { name: "default" }, launch: { hardware: "none" }, // DEMO_SOURCES=none leaves the ingest to feeds someone pushes in by hand // (stream id input), so they can be stopped and started live. sources: process.env.DEMO_SOURCES === "none" ? [] : [{ name: "input", asset: { preset: "camera1" }, ingest: { param: "INGEST_PORT" }, streamId: "input" }], ready: [{ http: { studio: "/live/api/components" }, status: 200, timeoutMs: 120_000 }], open: [ { name: "Studio", url: { proxy: "/instance/{id}/studio/" } }, { name: "Studio (host port, no auth)", url: { studio: "/" } }, ], }); `; } const DEMO_SPEC_TEST_TS = `// The demo spec (tests/demo.spec.ts) is validated by defineDemo at import; // this adds, without Docker, the product-side agreement: the template it // names is one the manifest publishes and its source dials the parameter the // template declares — the class of rot the demo driver exists to stop. import { describe, expect, test } from "bun:test"; import { buildManifest } from "../../shared/src/manifest.ts"; import demo from "../demo.spec.ts"; describe("tests/demo.spec.ts", () => { test("names a template the manifest publishes as a default", () => { const published = buildManifest().defaultProductTemplates.map((t) => t.name); if (!demo.template || !("name" in demo.template)) throw new Error("the demo launches a published template"); expect(published).toContain(demo.template.name); }); test("its source dials INGEST_PORT, the parameter the srt-listener template declares", () => { const [input] = demo.sources ?? []; expect(input && "param" in input.ingest ? input.ingest.param : "").toBe("INGEST_PORT"); }); }); `; const DOCKERIGNORE = `node_modules **/node_modules dist backend/dist shared/dist .git .github *.md release `; function exampleInputJson(_ctx: ShapeContext): string { return `${JSON.stringify( { input: { transport: "srt-listener", port: 5001, streamId: "input" }, output: { host: "127.0.0.1", port: 6000 }, }, null, 2, )}\n`; } // ---- The canonical doc set (fleet review 02 s9, backend-only turnkey) ------ // README (developer), docs/README (the index), docs/handover (the customer's // deployment document: prose around a block norsk-ctl generates), docs/operating // and docs/known-limitations (operator). Prose only: a turnkey's bundle is built from these // files by docs:manual, which the image build runs, so the image serves them. // Bundle-internal file names are written without backticks on purpose: the // path-existence lint would look for them in the repo. function readmeMd(ctx: ShapeContext): string { const optional = [ ...(ctx.features.has("components") ? ["- `components/` — this product's own Studio nodes, packed into the product-template tar."] : []), ...(ctx.features.has("dashboard") ? ["- `dashboard/` — the per-instance operator SPA; builds into `dashboards/`, which Studio serves."] : []), ...(ctx.features.has("frontend") ? ["- `frontend/` — the configure screen the backend serves at /configure and norsk-ctl iframes."] : []), ] .map((line) => `${line}\n`) .join(""); const screen = ctx.features.has("views") ? " The operator screen is the component's own fullscreen view, inside Studio." : ctx.features.has("dashboard") ? " The operator screen is a dashboard Studio serves per instance." : ""; return `# ${ctx.name} A Norsk product for norsk-ctl. The control plane is a small service the daemon runs (the manifest, the product-template build, the documentation); the media path is a Norsk Studio workflow the product composes.${screen} ## Layout - \`shared/\` — the product's config schema, workflow composer and manifest. ${optional}- \`backend/\` — the control-plane service (manifest, product-template build, docs). - \`examples/\` — starter configs; \`examples/default/input.json\` is the default template. - \`tests/\` — unit, image and demo tiers; \`tests/demo.spec.ts\` is the customer journey. - \`docs/\` — reader-facing documents; \`docs/README.md\` is the index. - \`website/\` — the public docs site (Astro + Starlight on the shared Norsk theme). - \`deployment/\` — the image build wrapper and the iterate loop. ## First run 1. \`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, then 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. ## Dev loop Everything runs inside the dev shell (\`nix develop .#dev\`), which carries bun, norsk-ctl and the pinned Norsk SDK: - \`bun install\`, then \`bun run check:drift && bun run docs:check && bun run test:unit && bun run typecheck\`. - \`bun run demo\` — the customer journey on a private daemon. - \`bun run build:image\` — the product image (\`deployment/build-image.sh\`). ## Documentation Two channels, deliberately: - **The bundle the image serves.** \`bun run docs:manual\` builds it from \`docs/handover.md\`, \`docs/administering.md\`, \`docs/operating.md\` and \`docs/known-limitations.md\`; the image build runs it, so every image serves them at /docs at the version that is running (linked from the product's page in norsk-ctl). \`bun run docs:handover\` regenerates the handover's generated block from a daemon that has this product added; CI runs its \`--check\`. - **The docs site**, \`website/\` — the public manual on the shared Norsk theme, five sections deep, faster-moving and readable without the product in hand. \`bun install --cwd website\` once, then \`bun run docs:dev\` / \`bun run docs:site\`. \`website/README.md\` says what is the site's and what is the theme's; the dev-kit's \`node_modules/@norskvideo/ctl-dev-kit/conventions/docs-site.md\` is the contract. \`CLAUDE.md\` has the conventions and the traps. `; } function docsReadmeMd(ctx: ShapeContext): string { return `# ${ctx.name} documentation Reader-facing documents live at this level and ship inside the product image, served at /docs at the version that is running. | Document | Audience | What it is | | ---------------------------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------- | | \`docs/handover.md\` | whoever gets this product running | How norsk-ctl arrives, the one command that adds this product, and the bundle route for platform teams | | \`docs/administering.md\` | the server's administrator | What runs on the server and where, how to reach it, update it, back it up and remove it | | \`docs/operating.md\` | operators | What a running deployment looks like from the operator's chair: the controls, the checks, the signs | | \`docs/known-limitations.md\` | operators | What the product does not do yet, with the consequence and the workaround | The **docs site** is a separate channel and lives in \`website/\`: the public manual, on the shared Norsk theme, not built from these files. See \`website/README.md\`. \`bun run docs:manual\` builds the bundle from these files. The tables in the handover are generated by norsk-ctl (\`bun run docs:handover\`) — regenerate them, never edit them. \`docs/personas.md\` is for writers, not readers, and is not in the bundle: it maps this product's audiences onto the four shared personas and carries the journey table \`bun run docs:check\` verifies. `; } // The per-product persona mapping and the journey table the journeys lint // reads (conventions/personas.md). The routes name the pages the skeleton // ships; a product rewrites them as its pages arrive, and docs:check keeps // every named page real. function personasMd(ctx: ShapeContext): string { return `# Who ${ctx.name}'s documentation is for The four personas are shared across every Norsk product and described in \`node_modules/@norskvideo/ctl-dev-kit/conventions/personas.md\`. This page maps ${ctx.name}'s own audiences onto them and says where each reader goes. ## The mapping _Name this product's audiences (the people who set it up, run it, and are invited into it) and say which of the four personas each one is. Runtime actors are not personas: an operator at the console is the Builder wearing the Administrator's hat._ ## The journey table | Persona | Starts at | Then, in order | Hands off to | | ------------- | ------------------- | --------------------------------------- | ----------------------------------------------------------------------- | | Evaluator | \`/docs/\` | \`/docs/guides/\` | [norsk-ctl's manual](https://ctl.norsk.video/), to run it for real | | Builder | \`/docs/guides/\` | \`/docs/how-to/\` | \`/docs/reference/\`, for the settings they just used | | Integrator | \`/docs/reference/\` | \`/docs/concepts/\` | [norsk-ctl's CLI and API reference](https://ctl.norsk.video/) | | Administrator | \`handover.md\` | \`administering.md\`, \`operating.md\`, \`known-limitations.md\` | [norsk-ctl's manual](https://ctl.norsk.video/), for everything about the machine | Site paths resolve under website/src/content/docs; a bare name resolves under docs/. Each route is at most five pages and ends in a hand-off, which is the one place "see norsk-ctl for details" lives. `; } function handoverMd(ctx: ShapeContext): string { return `# Deploying ${ctx.name} How ${ctx.name} reaches you and how to get it running. Nearly every deployment goes through norsk-ctl, and that is the first two sections. The rest of this page is for a platform team running the exported bundle in its own orchestration instead. ## How norsk-ctl arrives \`\`\`sh curl -fsSL https://norsk.video/install/ctl | bash \`\`\` When it finishes, norsk-ctl is running on the machine and its web page is where products are added, launched and watched. Everything about the machine itself is in [norsk-ctl's manual](https://ctl.norsk.video/): ports, TLS, cloud hosts, a box behind another proxy. ## Add this product \`\`\`sh norsk-ctl product add --image --license-file \`\`\` One command. ${ctx.name} then appears on norsk-ctl's page, ready to launch; operating.md says what to do with it once it is running. ## Deploying without norsk-ctl Some platform teams run the product from a bundle norsk-ctl exports, inside their own orchestration. The tables in the generated block below come from norsk-ctl's own rendering of this product's default template (\`bun run docs:handover\`), so they are the launch truth of the version this document ships with. Regenerate them; never edit them. ### What you receive A portable bundle exported by norsk-ctl: compose.yml (the flat compose, every path relative), HANDOVER.md (the same tables as below), the working-directory seed, the product-template content the services mount, and a licence placeholder to replace with a production licence before the first start. _Not generated yet. Run \`bun run docs:handover\` against a daemon that has this product added._ ### Verifying a deployment - The control plane answers on its port: GET /healthz returns ok. - The media engine is licensed: its log names the licence's product entry, not a placeholder. - The default template's outputs are reachable from where the audience is. ### Not in this artefact Anything norsk-ctl-specific: the daemon, product registration, the instance proxy and its authentication, templates-as-storage. The bundle is the launch truth of one product template and needs none of them. `; } // The operator's page, in the shape of the plan's exemplar (reuters' // operating.md). Each section opens with a marker so a half-filled page is // grep-able, and the fill-me lines say what the exemplar put there. function operatingMd(ctx: ShapeContext): string { return `# Operating ${ctx.name} What an operator does with a running ${ctx.name}, and where. For getting one running, see handover.md; this page assumes it is up. ## What it is _One paragraph: what the deployment takes in, what it puts out, and the one thing an operator adjusts while it runs._ ## Where the controls are _Which screen the operator works in, and what the product's own page at /docs and its health check at /healthz are for._ ## Checking it is doing its job 1. _The health check answers._ 2. _Each input is arriving. A listening input with nothing connected is idle, not failed; the sender is the thing to check._ 3. _Each output is connected to its destination._ ## When something looks wrong - _The picture is absent: which input or output to look at first._ - _The picture is wrong rather than absent: what is a runtime control and what needs a rebuilt template._ `; } // The server admin's page: the product running under norsk-ctl on the // customer's own machine (gameplay-football247's dev-ops guide, generalised). // norsk-ctl's server install is the same everywhere, so its half is written // out; the product's half carries a marker per section. function administeringMd(ctx: ShapeContext): string { return `# Administering ${ctx.name} For whoever looks after the server ${ctx.name} runs on: what is installed where, how to reach it, how to update it, and how to remove it. Getting it running the first time is in handover.md; running the product day to day is in operating.md. ## What runs on the server ${ctx.name} runs under **norsk-ctl**, Norsk's service manager. | Process / container | What it is | |---|---| | \`norsk-ctl\` (systemd service) | The manager: launches and supervises ${ctx.name}, serves its API | | \`norsk-proxy\`, \`norsk-proxy-oauth2\` | The HTTPS entry point and its login | | \`norsk-product-${ctx.name}\` | The product's control plane | | \`--1\` | One per service of a launched instance | _Name this product's instance containers and say what each one does (the starter's are Studio, the workflow and its dashboard, and the media engine)._ ## Where things are | Path | Contents | |---|---| | \`/etc/norsk-ctl/\` | norsk-ctl's configuration (\`config.yaml\`), licences, TLS certificate | | \`/var/lib/norsk-ctl/\` | norsk-ctl's state: database, product registry, templates, proxy | | \`/var/log/norsk-ctl/\` | norsk-ctl's logs | | \`/opt/norsk-ctl/bin/\` | norsk-ctl binaries, one per version; \`/usr/local/bin/norsk-ctl\` points at the current one | | \`/etc/systemd/system/norsk-ctl.service\` | The service unit | | \`/etc/sysctl.d/90-norsk-tuning.conf\` | Network buffer tuning for media | _Add the instance's working directory (uploaded media, settings) and anything else this product keeps on disk._ norsk-ctl runs as the user \`norsk\`. Run its CLI as that user, with its configuration: \`\`\`sh sudo -u norsk env NORSK_CTL_CONFIG_DIR=/etc/norsk-ctl NORSK_CTL_STATE_DIR=/var/lib/norsk-ctl \\ NORSK_CTL_LOG_DIR=/var/log/norsk-ctl norsk-ctl instance list \`\`\` ## Network | Port | Open to | What | |---|---|---| | 22/tcp | administrators | SSH | | _443/tcp_ | _operators_ | _The web page (or loopback only: see below)_ | | _the product's inputs and outputs_ | _the senders and receivers_ | _one row per media port_ | ## Reaching the web page The web page is norsk-ctl's proxy. On a server that must not expose it, bind it to loopback (norsk-ctl's \`bindAddress\` setting) and reach it through an SSH tunnel: \`\`\`sh ssh -L 8443:127.0.0.1:443 @ # 443: the proxy's port, if unchanged # then browse https://localhost:8443 \`\`\` A self-signed certificate is accepted once in the browser. ## Installing and updating _The release this product ships and the one command that installs or updates it, with the settings it takes. Without a release kit: norsk-ctl's installer, then \`norsk-ctl product add\` (handover.md); a new image is \`norsk-ctl product reload\`; norsk-ctl itself is \`norsk-ctl upgrade --version \`, as root._ ## Checking it is healthy \`\`\`sh systemctl status norsk-ctl # the manager journalctl -u norsk-ctl -f # its log docker ps # every container above, Up \`\`\` _What the product's own page shows when it is healthy, and which container's log to read first when it is not._ ## Removing it _How to remove the product, and norsk-ctl with it, and what is left behind. Say plainly what is deleted (uploaded media) and how to keep it._ ## Backups norsk-ctl's state worth keeping is \`/etc/norsk-ctl\` and \`/var/lib/norsk-ctl\`, plus each instance's working directory. _Anything else of this product's that is worth keeping._ ## Licence ${ctx.name} runs under a Norsk licence that entitles it, staged in \`/etc/norsk-ctl/licenses/\`. The product takes its licence when it is added (\`norsk-ctl product add --license-file\`), so a new licence goes in with the next \`product add\`. `; } const KNOWN_LIMITATIONS_MD = `# Known limitations What this product does not do yet, or does with a caveat an operator must know before relying on it. One entry per limitation: the consequence and the workaround, not the roadmap. ## Ingest - _None recorded yet._ ## Output - _None recorded yet._ ## Operations - _None recorded yet._ `; const DOCS_HANDOVER_SH = `#!/usr/bin/env bash # Fill (or, with --check, verify) the generated block of docs/handover.md from # norsk-ctl's own rendering of this product's stored template: every mount, # environment and launch-injected setting, as the runner would launch it. # Needs a daemon that has this product added (the demo or integration tier # leaves one); TEMPLATE names the stored template (default: the manifest's). set -euo pipefail cd "$(dirname "$0")/.." template="\${TEMPLATE:-default}" out="$(mktemp -d)/bundle" norsk-ctl instance export-template "$template" --to "$out" --format handover | bun run node_modules/@norskvideo/ctl-dev-kit/docs/splice-generated.ts docs/handover.md "$@" `; const REGEN_MANUAL_SH = `#!/usr/bin/env bash # Regenerate this product's docs bundle. A prose-only turnkey has no doc-guide # tiers: the bundle is built from the hand-written documents under docs/ by # \`bun run docs:manual\`. The embedded docs workflow (build-docs.yml) calls # this; the image build runs docs:manual itself. set -euo pipefail cd "$(dirname "$0")/../.." bun run docs:manual `; // The drift-gated core block covers node_modules/, dist/, generated, logs/ and // friends; everything a FEATURE's workspace builds has to be named here. function gitignoreTail(ctx: ShapeContext): string { const blocks = ["# Repo-specific entries go below (the block above is drift-gated verbatim).\n"]; if (ctx.features.has("components")) blocks.push(COMPONENTS_GITIGNORE); if (ctx.features.has("dashboard")) blocks.push(DASHBOARD_GITIGNORE); blocks.push(DOCS_SITE_GITIGNORE); blocks.push(SERVER_KIT_GITIGNORE); return blocks.join("\n"); } export const turnkey: ShapeModule = { claudeHead, claudeTail, gitignoreTail, rootTsconfigInclude: ["tests/**/*"], files(ctx: ShapeContext): GeneratedFile[] { return [ ...(ctx.features.has("components") ? componentsFiles(ctx) : []), ...(ctx.features.has("views") ? viewsFiles(ctx) : []), ...(ctx.features.has("dashboard") ? dashboardFiles(ctx) : []), ...(ctx.features.has("frontend") ? frontendFiles(ctx) : []), { path: "package.json", content: rootPackageJson(ctx) }, { path: "INVARIANTS.md", content: invariantsMd(ctx) }, { path: ".dockerignore", content: DOCKERIGNORE }, { path: "examples/default/input.json", content: exampleInputJson(ctx) }, { path: "deployment/iterate.sh", content: iterateSh(ctx), executable: true }, { path: "deployment/package-release.sh", content: packageReleaseSh(ctx), executable: true }, { path: "deployment/ship-image.sh", content: shipImageSh(ctx), executable: true }, { path: "deployment/install.sh", content: ctx.canon.serverInstall, executable: true }, { path: "deployment/uninstall.sh", content: ctx.canon.serverUninstall, executable: true }, { path: "scripts/demo", content: ctx.canon.demoShim, executable: true }, { path: "README.md", content: readmeMd(ctx) }, ...docsSiteFiles(ctx), { path: "docs/README.md", content: docsReadmeMd(ctx) }, { path: "docs/handover.md", content: handoverMd(ctx) }, { path: "docs/operating.md", content: operatingMd(ctx) }, { path: "docs/administering.md", content: administeringMd(ctx) }, { path: "docs/known-limitations.md", content: KNOWN_LIMITATIONS_MD }, { path: "docs/personas.md", content: personasMd(ctx) }, { path: ".github/workflows/build-docs.yml", content: ctx.canon.buildDocs }, { path: "scripts/doc-guide/regen-manual.sh", content: REGEN_MANUAL_SH, executable: true }, { path: "scripts/with-display.sh", content: ctx.canon.withDisplay, executable: true }, { path: "scripts/docs-handover.sh", content: DOCS_HANDOVER_SH, executable: true }, { path: "tests/demo.spec.ts", content: demoSpecTs(ctx) }, { path: "tests/smoke.spec.ts", content: smokeSpecTs(ctx) }, { path: "tests/unit/demo-spec.test.ts", content: DEMO_SPEC_TEST_TS }, { path: "shared/package.json", content: sharedPackageJson(ctx) }, { path: "shared/tsconfig.json", content: WORKSPACE_TSCONFIG }, { path: "shared/src/index.ts", content: SHARED_INDEX_TS }, { path: "shared/src/version.ts", content: versionTs(ctx) }, { path: "shared/src/version.test.ts", content: versionTestTs(ctx) }, { path: "shared/src/manifest.ts", content: manifestTs(ctx) }, { path: "shared/src/manifest.test.ts", content: manifestTestTs(ctx) }, { path: "shared/src/schemas/index.ts", content: SCHEMAS_INDEX_TS }, { path: "shared/src/schemas/config.ts", content: CONFIG_TS }, { path: "shared/src/schemas/config.test.ts", content: CONFIG_TEST_TS }, { path: "shared/src/product-template.ts", content: productTemplateTs(ctx) }, { path: "shared/src/product-template.test.ts", content: PRODUCT_TEMPLATE_TEST_TS }, { path: "shared/src/workflow/index.ts", content: workflowIndexTs(ctx) }, { path: "shared/src/workflow/ids.ts", content: idsTs(ctx) }, { path: "shared/src/workflow/components.ts", content: COMPONENTS_TS }, { path: "shared/src/workflow/studio-library.ts", content: studioLibraryTs(ctx) }, { path: "shared/src/workflow/compose-workflow.ts", content: composeWorkflowTs(ctx) }, { path: "shared/src/workflow/compose-workflow.test.ts", content: COMPOSE_WORKFLOW_TEST_TS }, { path: "backend/package.json", content: backendPackageJson(ctx) }, { path: "backend/tsconfig.json", content: WORKSPACE_TSCONFIG }, { path: "backend/src/index.ts", content: backendIndexTs(ctx) }, { path: "backend/src/server.ts", content: backendServerTs(ctx) }, { path: "backend/src/server.test.ts", content: backendServerTestTs(ctx) }, { path: "backend/src/routes/manifest.ts", content: routesManifestTs(ctx) }, { path: "backend/src/routes/openapi.ts", content: routesOpenapiTs(ctx) }, { path: "backend/src/routes/product-template.ts", content: routesProductTemplateTs(ctx) }, { path: "tests/package.json", content: testsPackageJson(ctx) }, { path: "tests/unit/invariants-parity.test.ts", content: INVARIANTS_PARITY_TEST_TS }, { path: "tests/unit/workflow-snapshot.test.ts", content: WORKFLOW_SNAPSHOT_TEST_TS }, { path: "tests/unit/loads-in-studio.test.ts", content: LOADS_IN_STUDIO_TEST_TS }, { path: "tests/image/image-smoke.test.ts", content: imageSmokeTestTs(ctx) }, ]; }, };