@norskvideo/ctl-dev-kit 0.1.13 → 0.1.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1389 @@
1
+ // The backend-only turnkey shape (reuters-shaped): shared/ + backend/ + tests/,
2
+ // no frontend, dashboards, or components workspace. Smallest product surface —
3
+ // one composed workflow served as a product-template tar from an express
4
+ // backend. The skeleton ships the post-review state as defaults: seed through
5
+ // parseManifestSeed, ManifestInput with no boilerplate, packProductTemplate
6
+ // from the schema package, addComponent/stubbedLibrary with a THROWING
7
+ // validate(), INVARIANTS + parity + byte-snapshot + studio-load wired into
8
+ // test:unit, and an image smoke tier.
9
+ import type { GeneratedFile, ShapeContext, ShapeModule } from "./create-product.ts";
10
+
11
+ function claudeHead(ctx: ShapeContext): string {
12
+ return `# Working in this repo (${ctx.name})
13
+
14
+ This is a **standalone product repo** generated by \`ctl-dev-kit create-product\`
15
+ (shape: backend-turnkey). It builds a backend-only product container image and
16
+ launches through the **released** norsk-ctl daemon — it needs no norsk-ctl
17
+ source checkout. This file is conventions + gotchas; the fenced block below is
18
+ the drift-gated shared core.
19
+ `;
20
+ }
21
+
22
+ function claudeTail(ctx: ShapeContext): string {
23
+ return `## Getting norsk-ctl + the dev loop
24
+
25
+ \`norsk-ctl\` (the daemon/CLI) is **not** an npm dep — it's the released binary,
26
+ pinned in \`flake.nix\` and put on PATH by the nix shell:
27
+
28
+ - \`nix develop\` -> build tools + the pinned \`norsk-ctl\` (\`.#dev\` is an alias).
29
+ - \`nix develop .#build\` -> lean shell, no ctl — what CI uses.
30
+
31
+ **Inner loop:** \`bun run dev\` (backend on :${ctx.devPort}), then
32
+ \`norsk-ctl product add --dev-url http://localhost:${ctx.devPort}\` registers the
33
+ running backend as a dev product.
34
+
35
+ **Outer loop (real image):** \`bun run build:image\` builds the container via the
36
+ shared dev-kit bundle driver (\`deployment/build-image.sh\`, tag \`${ctx.name}\`,
37
+ container port 4321), then re-add it into the daemon —
38
+ \`deployment/iterate.sh\` scripts the remove/re-add dance.
39
+
40
+ ## First run after generation
41
+
42
+ 1. \`bun install\`
43
+ 2. \`UPDATE_SNAPSHOTS=1 bun run test:unit\` — bakes the byte-snapshot fixtures
44
+ under \`tests/unit/__snapshots__/\` (the generator cannot compose without the
45
+ Studio packages installed). Commit them; from then on any emission change is
46
+ a reviewable fixture diff.
47
+ 3. \`bun run check:drift && bun run test:unit && bun run typecheck\`
48
+
49
+ ## Version pins
50
+
51
+ Media + Studio container tags derive from **\`manifest.seed.json\`** (the
52
+ machine-writable source of truth — RFC 0001 Workstream C) via
53
+ \`shared/src/version.ts\`, which validates the seed through the SDK's
54
+ \`parseManifestSeed\` at module load. \`version.test.ts\` fails if the seed and
55
+ the \`norsk-sdk\`/\`norsk-studio*\` npm pins in \`shared/package.json\` drift
56
+ apart — the shared \`upgrade-latest.yml\` bumps them all in lockstep nightly.
57
+
58
+ ## The stored-template trap
59
+
60
+ The daemon launches instances from a **stored** product-template snapshot
61
+ fetched once at \`product add\`, and refuses to overwrite one by name.
62
+ Rebuilding the image does NOT reach launched instances or new launches until
63
+ you refresh the stored template (\`norsk-ctl template refresh <name>\`, or the
64
+ full delete/remove/re-add dance in \`deployment/iterate.sh\`). Verify the
65
+ refreshed \`~/.norsk-ctl/product-templates/<name>/compose.yml\` carries the pins
66
+ \`manifest.seed.json\` declares.
67
+
68
+ ## Build / test — the five layers
69
+
70
+ - **Layer 1 — typed factories** (\`shared/src/workflow/components.ts\`): configs
71
+ \`import type\`d from each built-in's \`_gen/types\`; drift from Studio's
72
+ schema is a \`bun run typecheck\` failure.
73
+ - **Layer 2 — \`builder.validate()\`**: the composer throws on any issue. Never
74
+ demote it to a warning; declare out-of-library components in
75
+ \`COMPONENT_STUBS\` (SDK \`stubbedLibrary\`) instead.
76
+ - **Layer 3 — INVARIANTS.md + rules tests**: stable \`INV-*\` IDs, one row per
77
+ load-bearing invariant, parity-guarded (\`tests/unit/invariants-parity.test.ts\`)
78
+ so table and tests cannot drift apart.
79
+ - **Layer 4 — studio-load round-trip** (\`tests/unit/loads-in-studio.test.ts\`):
80
+ emitted YAML through Studio's real \`document.load()\`.
81
+ - **Layer 5 — integration**: not scaffolded. Decide + record here whether this
82
+ product carries one (unattended nightly bumps need it; see the dev-kit's
83
+ \`conventions/integration-testing.md\`).
84
+
85
+ Byte-snapshots (\`tests/unit/workflow-snapshot.test.ts\`) pin the emitted wire
86
+ bytes; \`UPDATE_SNAPSHOTS=1\` refreshes deliberately.
87
+
88
+ - \`bun run test\` / \`test:unit\` — shared/backend units + tests/unit scaffolds.
89
+ - \`bun run test:image\` — image smoke test (needs Docker + \`bun run build:image\`):
90
+ runs the built container and asserts the surface \`product add\` probes.
91
+ - \`bun run check:drift\` — the shared-conventions gate; CI runs it plus
92
+ lint/typecheck/test:unit via the drift-gated \`checks.yml\`.
93
+ `;
94
+ }
95
+
96
+ function rootPackageJson(ctx: ShapeContext): string {
97
+ const { studioLib, mediaLib } = ctx.pins;
98
+ return `${JSON.stringify(
99
+ {
100
+ name: ctx.name,
101
+ version: "0.0.1",
102
+ private: true,
103
+ type: "module",
104
+ workspaces: ["shared", "backend", "tests"],
105
+ scripts: {
106
+ clean: "bun run --cwd shared clean",
107
+ format: "biome format --write .",
108
+ lint: "biome check .",
109
+ "lint:fix": "biome check --write .",
110
+ test: "bun run test:unit",
111
+ "test:unit": "bun test shared/src/ backend/src/ tests/unit/",
112
+ "test:image": "bun test tests/image/",
113
+ "check:drift": "bun run node_modules/@norskvideo/ctl-dev-kit/conventions/check-drift.ts",
114
+ typecheck: "bunx tsc --noEmit -p shared && bunx tsc --noEmit -p backend && bunx tsc --noEmit -p .",
115
+ build: "bun run lint && bun run typecheck && bun run build:no-lint",
116
+ "build:no-lint": "bun run build:shared && bun run build:backend",
117
+ "build:shared": "bun run --cwd shared build",
118
+ "build:backend": "bun run --cwd backend build",
119
+ "build:image": "bash deployment/build-image.sh",
120
+ dev: "bun run --cwd backend dev",
121
+ start: "bun run --cwd backend start",
122
+ iterate: "bash deployment/iterate.sh",
123
+ },
124
+ devDependencies: {
125
+ "@biomejs/biome": "2.5.5",
126
+ "@norskvideo/ctl-dev-kit": "^0.1.10",
127
+ "@types/bun": "latest",
128
+ "@types/node": "^22.10.0",
129
+ typescript: "^5.7.2",
130
+ },
131
+ packageManager: "bun@1.3.13",
132
+ overrides: {
133
+ ws: "^8.20.1",
134
+ "shell-quote": "^1.9.0",
135
+ "fast-xml-parser": "^5.10.1",
136
+ "fast-uri": "^3.1.4",
137
+ "@norskvideo/norsk-api": mediaLib,
138
+ "@norskvideo/norsk-sdk": mediaLib,
139
+ "@norskvideo/norsk-studio": studioLib,
140
+ "@norskvideo/norsk-studio-builder": studioLib,
141
+ "@norskvideo/norsk-studio-built-ins": studioLib,
142
+ },
143
+ },
144
+ null,
145
+ 2,
146
+ )}\n`;
147
+ }
148
+
149
+ function invariantsMd(ctx: ShapeContext): string {
150
+ return `# Workflow invariants
151
+
152
+ <!-- Format from @norskvideo/ctl-dev-kit testing/INVARIANTS.template.md. -->
153
+
154
+ The load-bearing rules this product's composed workflow must never break. Each
155
+ has a stable ID (\`INV-<AREA>-<NNN>\`; never renumber, never reuse a retired ID)
156
+ and at least one test that cites the ID verbatim — the parity guard
157
+ (\`tests/unit/invariants-parity.test.ts\`) fails CI when a row has no citing
158
+ test or a test cites an unknown ID. When a code comment carries one of these
159
+ rules, cite the ID there too instead of restating the rule.
160
+
161
+ | ID | Invariant | Guarded by |
162
+ | --------------- | ------------------------------------------------------------------------------------------ | -------------------------- |
163
+ | INV-GRAPH-001 | The ingest matches the configured transport (srt-listener/srt-caller) and port | \`compose-workflow.test.ts\` |
164
+ | INV-GRAPH-002 | The egest dials the configured destination and subscribes to the ingest's video + audio | \`compose-workflow.test.ts\` |
165
+ | INV-GRAPH-003 | The preview taps ingest video only — it never sits in the egest path | \`compose-workflow.test.ts\` |
166
+ | INV-COMPOSE-001 | validate() is a throwing gate: an identifier that is neither real nor stubbed fails compose | \`compose-workflow.test.ts\` |
167
+
168
+ Seeded by \`ctl-dev-kit create-product\` for the ${ctx.name} starter graph —
169
+ replace/extend the rows as the real workflow takes shape.
170
+ `;
171
+ }
172
+
173
+ function sharedPackageJson(ctx: ShapeContext): string {
174
+ const { studioLib, mediaLib } = ctx.pins;
175
+ return `${JSON.stringify(
176
+ {
177
+ name: `${ctx.scope}/shared`,
178
+ version: "0.0.1",
179
+ private: true,
180
+ type: "module",
181
+ exports: {
182
+ ".": "./src/index.ts",
183
+ "./manifest": "./src/manifest.ts",
184
+ "./product-template": "./src/product-template.ts",
185
+ "./schemas": "./src/schemas/index.ts",
186
+ "./workflow": "./src/workflow/index.ts",
187
+ },
188
+ scripts: {
189
+ build:
190
+ "bun build src/index.ts --outdir dist --target=bun --external @norskvideo/norsk-studio --external @norskvideo/norsk-studio-builder --external @norskvideo/norsk-studio-built-ins",
191
+ clean: "rm -rf dist",
192
+ test: "bun test src/",
193
+ typecheck: "bunx tsc --noEmit",
194
+ },
195
+ dependencies: {
196
+ "@norskvideo/ctl-product-template-schema": "^0.1.0",
197
+ "@norskvideo/ctl-sdk": "^0.1.1",
198
+ "@norskvideo/norsk-sdk": mediaLib,
199
+ "@norskvideo/norsk-studio": studioLib,
200
+ "@norskvideo/norsk-studio-builder": studioLib,
201
+ "@norskvideo/norsk-studio-built-ins": studioLib,
202
+ yaml: "^2.8.0",
203
+ zod: "^4.3.6",
204
+ },
205
+ devDependencies: {
206
+ "@norskvideo/ctl-foundation": "^0.1.0",
207
+ "@types/bun": "latest",
208
+ "@types/node": "^22.10.0",
209
+ typescript: "^5.7.2",
210
+ },
211
+ },
212
+ null,
213
+ 2,
214
+ )}\n`;
215
+ }
216
+
217
+ const WORKSPACE_TSCONFIG = `{
218
+ "extends": "../tsconfig.base.json",
219
+ "include": ["src/**/*"]
220
+ }
221
+ `;
222
+
223
+ function versionTs(ctx: ShapeContext): string {
224
+ return `import { parseManifestSeed } from "@norskvideo/ctl-sdk/manifest-seed";
225
+ import rawSeed from "../../manifest.seed.json" with { type: "json" };
226
+
227
+ export const PRODUCT_NAME = "${ctx.name}";
228
+ export const PRODUCT_VERSION = "0.0.1";
229
+
230
+ // Validate at module load so an invalid seed fails loudly at startup (and in
231
+ // every test run) rather than rendering a compose from garbage. The SDK's
232
+ // SeedSchema is strict; never import the JSON raw.
233
+ const seedResult = parseManifestSeed(rawSeed);
234
+ if (seedResult.status !== "ok") {
235
+ throw new Error(\`manifest.seed.json is invalid: \${seedResult.error}\`);
236
+ }
237
+ const latest = seedResult.value.latest;
238
+ if (latest === undefined) {
239
+ throw new Error("manifest.seed.json has no 'latest' channel");
240
+ }
241
+
242
+ const bareTag = (ref: string) => ref.slice(ref.lastIndexOf(":") + 1);
243
+ const repoOf = (ref: string) => ref.slice(0, ref.lastIndexOf(":"));
244
+
245
+ // Media-engine + Studio control-plane images, derived from manifest.seed.json
246
+ // (the machine-writable source of truth — RFC 0001 Workstream C). Kept in step
247
+ // with the norsk-sdk / norsk-studio-* npm pins in shared/package.json;
248
+ // version.test.ts fails if the tags and the npm pins drift apart.
249
+ //
250
+ // Overridable via env as a FULL ref (repo:tag) on the PRODUCT BACKEND process,
251
+ // which renders the product-template compose. Guard \`process\` for the
252
+ // browser: this module is re-exported from the shared index.
253
+ const env = typeof process !== "undefined" ? process.env : ({} as Record<string, string | undefined>);
254
+
255
+ export const MEDIA_IMAGE = env.MEDIA_IMAGE ?? latest.media;
256
+ export const MEDIA_IMAGE_REPO = repoOf(MEDIA_IMAGE);
257
+ export const MEDIA_IMAGE_TAG = bareTag(MEDIA_IMAGE);
258
+
259
+ export const STUDIO_IMAGE = env.STUDIO_IMAGE ?? latest.studio;
260
+ export const STUDIO_IMAGE_REPO = repoOf(STUDIO_IMAGE);
261
+ export const STUDIO_IMAGE_TAG = bareTag(STUDIO_IMAGE);
262
+ `;
263
+ }
264
+
265
+ function versionTestTs(ctx: ShapeContext): string {
266
+ return `import { describe, expect, it } from "bun:test";
267
+ import pkg from "../package.json" with { type: "json" };
268
+ import {
269
+ MEDIA_IMAGE,
270
+ MEDIA_IMAGE_REPO,
271
+ MEDIA_IMAGE_TAG,
272
+ PRODUCT_NAME,
273
+ STUDIO_IMAGE,
274
+ STUDIO_IMAGE_REPO,
275
+ STUDIO_IMAGE_TAG,
276
+ } from "./version.ts";
277
+
278
+ const studioNpmPin = pkg.dependencies["@norskvideo/norsk-studio"];
279
+ // Media engine + its SDK are built from the same commit, so the image tag and
280
+ // the norsk-sdk npm pin share one version string — guard the coupling.
281
+ const mediaNpmPin = pkg.dependencies["@norskvideo/norsk-sdk"];
282
+
283
+ it("product identifies as ${ctx.name}", () => {
284
+ expect(PRODUCT_NAME).toBe("${ctx.name}");
285
+ });
286
+
287
+ describe("npm pins stay coupled to the seed", () => {
288
+ it("media image matches the @norskvideo/norsk-sdk npm pin (same-commit family)", () => {
289
+ expect(MEDIA_IMAGE_TAG).toBe(mediaNpmPin);
290
+ });
291
+
292
+ // The studio npm/image SHA split is INTENTIONAL when it happens: Norsk builds
293
+ // the studio npm packages and the studio Docker image from different commits,
294
+ // so the image rides the newest published Docker Hub tag on the same
295
+ // major.minor.patch line as the npm nightly (exactly how the shared
296
+ // upgrade-latest.yml advances the pair).
297
+ it("studio image stays on the npm pin's major.minor.patch line", () => {
298
+ expect(STUDIO_IMAGE_TAG.split("-")[0]).toBe(studioNpmPin.split("-")[0]);
299
+ });
300
+
301
+ it("keeps the norsk-studio-* npm pins in step with each other", () => {
302
+ expect(pkg.dependencies["@norskvideo/norsk-studio-builder"]).toBe(studioNpmPin);
303
+ expect(pkg.dependencies["@norskvideo/norsk-studio-built-ins"]).toBe(studioNpmPin);
304
+ });
305
+ });
306
+
307
+ // The repo/tag split stays consistent with the full ref — the RFC 0001 §5
308
+ // invariant that keeps every product speaking the same MEDIA_*/STUDIO_* shape.
309
+ it("repo and tag recombine to the full image ref", () => {
310
+ expect(\`\${MEDIA_IMAGE_REPO}:\${MEDIA_IMAGE_TAG}\`).toBe(MEDIA_IMAGE);
311
+ expect(\`\${STUDIO_IMAGE_REPO}:\${STUDIO_IMAGE_TAG}\`).toBe(STUDIO_IMAGE);
312
+ });
313
+ `;
314
+ }
315
+
316
+ const MANIFEST_TS = `import { type Manifest, type ManifestInput, ManifestSchema } from "@norskvideo/ctl-sdk/browser";
317
+ import { PRODUCT_NAME, PRODUCT_VERSION } from "./version.ts";
318
+
319
+ // Producer-side ManifestInput: everything the schema defaults stays omitted
320
+ // (ui/cli/components/runtime), so this declares only what the product has. A
321
+ // backend-only turnkey has no configure UI — configScreenUrl is optional and
322
+ // omitted, so the runner skips its registration probe (no placeholder HTML).
323
+ // Parsing at build time keeps the served manifest the READER shape with the
324
+ // defaults filled in.
325
+ export function buildManifest(): Manifest {
326
+ const manifest: ManifestInput = {
327
+ manifestSchemaVersion: 1,
328
+ name: PRODUCT_NAME,
329
+ version: PRODUCT_VERSION,
330
+ minRunnerVersion: "0.1.0",
331
+ api: {
332
+ basePath: "/api",
333
+ proxyPaths: ["/api/*"],
334
+ openapiFragmentPath: "/api/openapi.yaml",
335
+ },
336
+ targets: ["docker-compose"],
337
+ // Keep in lockstep with STARTER_CONFIGS in backend/src/routes/product-template.ts.
338
+ defaultProductTemplates: [{ name: "default", url: "/api/product-template/default" }],
339
+ };
340
+ return ManifestSchema.parse(manifest);
341
+ }
342
+ `;
343
+
344
+ const MANIFEST_TEST_TS = `import { describe, expect, it } from "bun:test";
345
+ import { ManifestSchema } from "@norskvideo/ctl-sdk/browser";
346
+ import { buildManifest } from "./manifest.ts";
347
+
348
+ describe("manifest", () => {
349
+ it("round-trips through ManifestSchema", () => {
350
+ expect(ManifestSchema.safeParse(buildManifest()).success).toBe(true);
351
+ });
352
+
353
+ it("omits the configure UI (backend-only: the runner skips its probe)", () => {
354
+ expect(buildManifest().ui.configScreenUrl).toBeUndefined();
355
+ });
356
+ });
357
+ `;
358
+
359
+ const SCHEMAS_INDEX_TS = `export * from "./config.ts";
360
+ `;
361
+
362
+ const CONFIG_TS = `import { z } from "zod";
363
+
364
+ // The product's launch-time config: one SRT ingest passed through to one SRT
365
+ // egest with a monitoring preview — the starter graph. Grow it field-by-field
366
+ // as the product takes shape, ADDITIVELY (RFC 0001): never repurpose or remove
367
+ // a required field once released.
368
+ export const ProductConfigSchema = z
369
+ .object({
370
+ input: z
371
+ .object({
372
+ transport: z.enum(["srt-listener", "srt-caller"]).default("srt-listener"),
373
+ // srt-caller only: the remote host this ingest dials out to.
374
+ host: z.string().optional(),
375
+ port: z.number().int().min(1).max(65535).default(5001),
376
+ streamId: z.string().default("input"),
377
+ })
378
+ // prefault, not default: the fallback is input-shaped ({} with the inner
379
+ // defaults filling in), which zod v4's .default() no longer accepts.
380
+ .prefault({}),
381
+ output: z.object({
382
+ host: z.string(),
383
+ port: z.number().int().min(1).max(65535),
384
+ }),
385
+ })
386
+ .strict();
387
+
388
+ export type ProductConfig = z.infer<typeof ProductConfigSchema>;
389
+ `;
390
+
391
+ const CONFIG_TEST_TS = `import { describe, expect, it } from "bun:test";
392
+ import example from "../../../examples/default/input.json" with { type: "json" };
393
+ import { ProductConfigSchema } from "./config.ts";
394
+
395
+ describe("ProductConfigSchema", () => {
396
+ it("accepts the shipped starter example", () => {
397
+ expect(ProductConfigSchema.safeParse(example).success).toBe(true);
398
+ });
399
+
400
+ it("rejects an unknown field (strict — catch typos at the edge)", () => {
401
+ const parsed = ProductConfigSchema.safeParse({ output: { host: "h", port: 1 }, extra: true });
402
+ expect(parsed.success).toBe(false);
403
+ });
404
+ });
405
+ `;
406
+
407
+ const IDS_TS = `// Stable component ids — part of the workflow contract: they appear in the
408
+ // emitted YAML and are cited by INVARIANTS.md rules, so never rename without
409
+ // coordinating every consumer.
410
+ export const INGEST_ID = "ingest";
411
+ export const EGEST_ID = "egest";
412
+ export const PREVIEW_ID = "preview";
413
+ `;
414
+
415
+ const COMPONENTS_TS = `// Typed factories for the Studio components the starter graph wires up.
416
+ // Config types come from each built-in's OpenAPI-generated _gen/types, so
417
+ // drift between what we emit and Studio's real schema surfaces as a TS error
418
+ // at compile time (Layer 1). Subscriptions are NOT set here — the builder
419
+ // derives them via connect()/pick*(); the factories only shape config.
420
+ import type { WorkflowComponent } from "@norskvideo/ctl-sdk/workflow";
421
+ import type { components as SrtCallerInputSchemas } from "@norskvideo/norsk-studio-built-ins/lib/input.srt-caller/_gen/types";
422
+ import type { components as SrtListenerInputSchemas } from "@norskvideo/norsk-studio-built-ins/lib/input.srt-listener/_gen/types";
423
+ import type { components as PreviewOutputSchemas } from "@norskvideo/norsk-studio-built-ins/lib/output.preview/_gen/types";
424
+ import type { components as SrtCallerOutputSchemas } from "@norskvideo/norsk-studio-built-ins/lib/output.srt-caller/_gen/types";
425
+
426
+ type SrtListenerInputConfig = SrtListenerInputSchemas["schemas"]["Config"];
427
+ type SrtCallerInputConfig = SrtCallerInputSchemas["schemas"]["Config"];
428
+ type SrtCallerOutputConfig = SrtCallerOutputSchemas["schemas"]["Config"];
429
+ type PreviewConfig = PreviewOutputSchemas["schemas"]["Config"];
430
+
431
+ export type IngestSrtListenerArgs = { id: string; port: number; streamId: string; displayName: string };
432
+
433
+ export function ingestSrtListener(args: IngestSrtListenerArgs): WorkflowComponent<SrtListenerInputConfig> {
434
+ return {
435
+ type: "input.srt-listener",
436
+ config: {
437
+ port: args.port,
438
+ host: "0.0.0.0",
439
+ socketOptions: {},
440
+ sourceNames: "permissive",
441
+ streamIds: [args.streamId],
442
+ burstProtection: true,
443
+ initialState: "enabled",
444
+ decodeOutputs: { decode: false },
445
+ id: args.id,
446
+ displayName: args.displayName,
447
+ },
448
+ subscriptions: [],
449
+ };
450
+ }
451
+
452
+ export type IngestSrtCallerArgs = { id: string; host: string; port: number; streamId: string; displayName: string };
453
+
454
+ export function ingestSrtCaller(args: IngestSrtCallerArgs): WorkflowComponent<SrtCallerInputConfig> {
455
+ return {
456
+ type: "input.srt-caller",
457
+ config: {
458
+ port: args.port,
459
+ host: args.host,
460
+ enabled: true,
461
+ socketOptions: {},
462
+ streamId: args.streamId,
463
+ id: args.id,
464
+ displayName: args.displayName,
465
+ },
466
+ subscriptions: [],
467
+ };
468
+ }
469
+
470
+ export type EgestSrtCallerArgs = { id: string; host: string; port: number; displayName: string };
471
+
472
+ export function egestSrtCaller(args: EgestSrtCallerArgs): WorkflowComponent<SrtCallerOutputConfig> {
473
+ return {
474
+ type: "output.srt-caller",
475
+ config: {
476
+ displayName: args.displayName,
477
+ destinations: [{ host: args.host, port: args.port }],
478
+ jitterBuffer: {},
479
+ avDelayMs: 50,
480
+ subtitles: { kind: "cta708" },
481
+ socketOptions: {},
482
+ tsEncoder: { kind: "standard" },
483
+ id: args.id,
484
+ },
485
+ subscriptions: [],
486
+ };
487
+ }
488
+
489
+ export type PreviewArgs = { id: string; displayName: string };
490
+
491
+ export function preview(args: PreviewArgs): WorkflowComponent<PreviewConfig> {
492
+ return {
493
+ type: "output.preview",
494
+ config: {
495
+ displayName: args.displayName,
496
+ previewMode: "image",
497
+ showPreview: true,
498
+ jitterBuffer: {},
499
+ id: args.id,
500
+ },
501
+ subscriptions: [],
502
+ };
503
+ }
504
+ `;
505
+
506
+ const STUDIO_LIBRARY_TS = `// Build the composer's ComponentLibrary: Studio's REAL NodeInfos first, this
507
+ // product's declared stubs second, and nothing else — so builder.validate()
508
+ // stays a THROWING gate (a typo'd identifier fails the compose instead of
509
+ // silently resolving to a permissive synthetic).
510
+ //
511
+ // NodeInfos are resolved STATICALLY via the built-ins' getNodeInfo aggregator
512
+ // rather than a RuntimeSystem + registerAll: registerAll does an fs.readdir of
513
+ // the package's lib/ AT RUNTIME, and \`bun build --target=bun\` bakes in the
514
+ // host path, which does not exist inside the product image. The static path
515
+ // pulls each component's design-time info module only and survives bundling.
516
+ import { callableDefault } from "@norskvideo/ctl-sdk";
517
+ import { type ComponentStubSpec, stubbedLibrary } from "@norskvideo/ctl-sdk/workflow";
518
+ import { RegistrationConsts } from "@norskvideo/norsk-studio/lib/extension/client-types";
519
+ import type { ComponentLibrary, NodeInfoForBuilder } from "@norskvideo/norsk-studio-builder";
520
+ import getNodeInfoImport from "@norskvideo/norsk-studio-built-ins/lib/info";
521
+
522
+ // TS-compiled-to-CJS with __esModule + exports.default; how that surfaces to
523
+ // an ESM importer depends on who bundled it. callableDefault tolerates every
524
+ // interop shape.
525
+ const getNodeInfo = callableDefault<(consts: unknown, identifier: string) => unknown>(
526
+ getNodeInfoImport,
527
+ "@norskvideo/norsk-studio-built-ins/lib/info",
528
+ );
529
+
530
+ // Components not in the npm library (this product's own, or alpha/custom nodes
531
+ // that live only in the studio image) are declared here as metadata-only stubs
532
+ // — precise media contracts, no browser code, no _gen/types needed. See
533
+ // ComponentStubSpec in @norskvideo/ctl-sdk/workflow.
534
+ export const COMPONENT_STUBS: ComponentStubSpec[] = [];
535
+
536
+ export async function buildStudioLibrary(): Promise<ComponentLibrary> {
537
+ return stubbedLibrary<NodeInfoForBuilder>(
538
+ (identifier) => getNodeInfo(RegistrationConsts, identifier) as NodeInfoForBuilder | undefined,
539
+ COMPONENT_STUBS,
540
+ );
541
+ }
542
+ `;
543
+
544
+ const COMPOSE_WORKFLOW_TS = `// The production composer: source-first, left-to-right, every connect narrowed
545
+ // through a pick* helper so filters resolve against real availableStreams().
546
+ // addComponent keeps the factories' typing at the addNode boundary — no
547
+ // \`config as never\`.
548
+ import { addComponent, toWorkflowDoc, type WorkflowDoc } from "@norskvideo/ctl-sdk/workflow";
549
+ import { autoLayout, type ComponentLibrary, pickAll, WorkflowBuilder } from "@norskvideo/norsk-studio-builder";
550
+ import type { ProductConfig } from "../schemas/config.ts";
551
+ import { egestSrtCaller, ingestSrtCaller, ingestSrtListener, preview } from "./components.ts";
552
+ import { EGEST_ID, INGEST_ID, PREVIEW_ID } from "./ids.ts";
553
+
554
+ export function composeWorkflow(config: ProductConfig, library: ComponentLibrary): WorkflowDoc {
555
+ const builder = new WorkflowBuilder(library);
556
+
557
+ // addComponent per branch: each factory keeps its own config typing at the
558
+ // addNode boundary (a union would erase it).
559
+ const ingest =
560
+ config.input.transport === "srt-caller"
561
+ ? addComponent(
562
+ builder,
563
+ ingestSrtCaller({
564
+ id: INGEST_ID,
565
+ host: config.input.host ?? "127.0.0.1",
566
+ port: config.input.port,
567
+ streamId: config.input.streamId,
568
+ displayName: "Ingest",
569
+ }),
570
+ )
571
+ : addComponent(
572
+ builder,
573
+ ingestSrtListener({
574
+ id: INGEST_ID,
575
+ port: config.input.port,
576
+ streamId: config.input.streamId,
577
+ displayName: "Ingest",
578
+ }),
579
+ );
580
+
581
+ const egest = addComponent(
582
+ builder,
583
+ egestSrtCaller({ id: EGEST_ID, host: config.output.host, port: config.output.port, displayName: "Egest" }),
584
+ );
585
+ const monitor = addComponent(builder, preview({ id: PREVIEW_ID, displayName: "Preview" }));
586
+
587
+ pickAll(builder.connect(ingest, egest), ["video", "audio"]);
588
+ pickAll(builder.connect(ingest, monitor), ["video"]);
589
+
590
+ // Layer 2 is a THROWING gate (INV-COMPOSE-001). Never demote this to a
591
+ // warning — declare out-of-library components in COMPONENT_STUBS instead.
592
+ const issues = builder.validate();
593
+ if (issues.length > 0) {
594
+ const formatted = issues.map((i) => \` - \${i.nodeId ?? "<doc>"}: \${i.message}\`).join("\\n");
595
+ throw new Error(\`WorkflowBuilder.validate() returned issues:\\n\${formatted}\`);
596
+ }
597
+
598
+ const doc = toWorkflowDoc(builder);
599
+ doc.__layout = autoLayout(doc);
600
+ return doc;
601
+ }
602
+ `;
603
+
604
+ const COMPOSE_WORKFLOW_TEST_TS = `// Layer 3: the INVARIANTS.md rules table in executable form. Each rule cites
605
+ // its INV-* ID verbatim; tests/unit/invariants-parity.test.ts fails the suite
606
+ // if table and tests drift apart. The matrix exercises every composer branch.
607
+ import { beforeAll, describe, expect, test } from "bun:test";
608
+ import type { WorkflowComponent, WorkflowDoc } from "@norskvideo/ctl-sdk/workflow";
609
+ import type { ComponentLibrary } from "@norskvideo/norsk-studio-builder";
610
+ import { type ProductConfig, ProductConfigSchema } from "../schemas/config.ts";
611
+ import { composeWorkflow } from "./compose-workflow.ts";
612
+ import { EGEST_ID, INGEST_ID, PREVIEW_ID } from "./ids.ts";
613
+ import { buildStudioLibrary } from "./studio-library.ts";
614
+
615
+ const MATRIX: [string, ProductConfig][] = [
616
+ ["listener", ProductConfigSchema.parse({ input: {}, output: { host: "203.0.113.10", port: 8001 } })],
617
+ [
618
+ "caller",
619
+ ProductConfigSchema.parse({
620
+ input: { transport: "srt-caller", host: "203.0.113.9", port: 7001 },
621
+ output: { host: "203.0.113.10", port: 8001 },
622
+ }),
623
+ ],
624
+ ];
625
+
626
+ let library: ComponentLibrary;
627
+ const docs = new Map<string, WorkflowDoc>();
628
+
629
+ beforeAll(async () => {
630
+ library = await buildStudioLibrary();
631
+ for (const [name, config] of MATRIX) docs.set(name, composeWorkflow(config, library));
632
+ });
633
+
634
+ function findById(doc: WorkflowDoc, id: string): WorkflowComponent {
635
+ const found = doc.components.find((c) => (c.config as { id?: string }).id === id);
636
+ if (!found) throw new Error(\`no component with id \${id}\`);
637
+ return found;
638
+ }
639
+
640
+ function subscribedMedia(component: WorkflowComponent, source: string): string[] {
641
+ return component.subscriptions
642
+ .filter((s) => s.source === source)
643
+ .flatMap((s) => s.streams.filter.map((f) => f.media));
644
+ }
645
+
646
+ describe.each(MATRIX)("composed graph (%s)", (name) => {
647
+ test("INV-GRAPH-001: ingest matches the configured transport and port", () => {
648
+ const config = MATRIX.find(([n]) => n === name)?.[1] as ProductConfig;
649
+ const ingest = findById(docs.get(name) as WorkflowDoc, INGEST_ID);
650
+ const expectedType = config.input.transport === "srt-caller" ? "input.srt-caller" : "input.srt-listener";
651
+ expect(ingest.type).toBe(expectedType);
652
+ expect((ingest.config as { port?: number }).port).toBe(config.input.port);
653
+ });
654
+
655
+ test("INV-GRAPH-002: egest dials the configured destination with ingest AV", () => {
656
+ const config = MATRIX.find(([n]) => n === name)?.[1] as ProductConfig;
657
+ const egest = findById(docs.get(name) as WorkflowDoc, EGEST_ID);
658
+ expect((egest.config as { destinations?: { host: string; port: number }[] }).destinations).toEqual([
659
+ { host: config.output.host, port: config.output.port },
660
+ ]);
661
+ const media = subscribedMedia(egest, INGEST_ID);
662
+ expect(media).toContain("video");
663
+ expect(media).toContain("audio");
664
+ });
665
+
666
+ test("INV-GRAPH-003: preview taps ingest video only", () => {
667
+ const monitor = findById(docs.get(name) as WorkflowDoc, PREVIEW_ID);
668
+ expect(subscribedMedia(monitor, INGEST_ID)).toEqual(["video"]);
669
+ });
670
+ });
671
+
672
+ test("INV-COMPOSE-001: an identifier that is neither real nor stubbed fails the compose", () => {
673
+ const emptyLibrary: ComponentLibrary = { find: () => undefined };
674
+ const [, config] = MATRIX[0] as [string, ProductConfig];
675
+ expect(() => composeWorkflow(config, emptyLibrary)).toThrow();
676
+ });
677
+ `;
678
+
679
+ const WORKFLOW_INDEX_TS = `export * from "./components.ts";
680
+ export * from "./compose-workflow.ts";
681
+ export * from "./ids.ts";
682
+ export * from "./studio-library.ts";
683
+ `;
684
+
685
+ const SHARED_INDEX_TS = `export * from "./manifest.ts";
686
+ export * from "./product-template.ts";
687
+ export * from "./schemas/index.ts";
688
+ export * from "./version.ts";
689
+ export * from "./workflow/index.ts";
690
+ `;
691
+
692
+ function productTemplateTs(ctx: ShapeContext): string {
693
+ return `// Product-template materials: the manifest/compose/parameters/workflow set the
694
+ // runner stores at \`product add\`. Packing to tar is single-sourced in
695
+ // @norskvideo/ctl-product-template-schema/pack — this repo carries no copy of
696
+ // the wire format.
697
+ import {
698
+ type ProductTemplateManifest,
699
+ type ProductTemplateMaterials,
700
+ type ProductTemplateParameter,
701
+ STANDARD_ADVANCED_OVERRIDES,
702
+ } from "@norskvideo/ctl-product-template-schema";
703
+ import type { ComponentLibrary } from "@norskvideo/norsk-studio-builder";
704
+ import { stringify as yamlStringify } from "yaml";
705
+ import type { ProductConfig } from "./schemas/config.ts";
706
+ import { MEDIA_IMAGE, PRODUCT_NAME, PRODUCT_VERSION, STUDIO_IMAGE } from "./version.ts";
707
+ import { composeWorkflow } from "./workflow/compose-workflow.ts";
708
+
709
+ export type BuildOpts = {
710
+ // The builder's component library, built once at process startup via
711
+ // buildStudioLibrary(). Required — the production composer is the builder.
712
+ library?: ComponentLibrary;
713
+ generatedAt?: Date;
714
+ };
715
+
716
+ const STUDIO_DEFAULT_HOST_PORT = 18000;
717
+
718
+ export function buildProductTemplateMaterials(config: ProductConfig, opts: BuildOpts = {}): ProductTemplateMaterials {
719
+ if (!opts.library) {
720
+ throw new Error(
721
+ "buildProductTemplateMaterials: ComponentLibrary required (call buildStudioLibrary() once at startup)",
722
+ );
723
+ }
724
+ const generatedAt = opts.generatedAt ?? new Date();
725
+ const manifest: ProductTemplateManifest = {
726
+ productTemplateSchemaVersion: 1,
727
+ productName: PRODUCT_NAME,
728
+ productVersion: PRODUCT_VERSION,
729
+ target: "docker-compose",
730
+ generatedAt: generatedAt.toISOString(),
731
+ proxy: {
732
+ // Permissive starter set: exposes Studio's own UI/API through the oauth2
733
+ // proxy. Narrow as the product grows a surface of its own.
734
+ expose: ["/", "/static/*", "/api/*", "/live/api/*", "/ws", "/ws/*"],
735
+ },
736
+ advanced: { ...STANDARD_ADVANCED_OVERRIDES },
737
+ };
738
+
739
+ return {
740
+ manifest,
741
+ composeYaml: renderCompose(config),
742
+ parameters: { parameters: renderParameters(config) },
743
+ workflow: yamlStringify(composeWorkflow(config, opts.library), { aliasDuplicateObjects: false }),
744
+ components: [],
745
+ dashboards: [],
746
+ assets: [],
747
+ workdirSeed: [],
748
+ };
749
+ }
750
+
751
+ // docker-compose for the studio + media pair. The reserved \`studio\` +
752
+ // \`media\` service names let the runner's override generator target them
753
+ // (workflow.yml mount, license env/file mount, etc).
754
+ export function renderCompose(config: ProductConfig): string {
755
+ const studioEnv: Record<string, string> = {
756
+ NORSK_HOST: "media",
757
+ // Studio declares this product to the engine at the V2 licence handshake;
758
+ // without it Studio falls back to its baked default and the instance would
759
+ // demand a Studio entitlement rather than its own.
760
+ NORSK_PRODUCT_NAME: PRODUCT_NAME,
761
+ NODE_CONFIG: JSON.stringify({ server: { library: ["@norskvideo/norsk-studio-built-ins"] } }),
762
+ };
763
+
764
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: docker-compose variable reference, not a TS template literal
765
+ const studioPorts: string[] = ["\${STUDIO_HOST_PORT}:8000"];
766
+ const mediaPorts: string[] = [];
767
+ // SRT-listener ingests bind inside the media engine and must be published as
768
+ // UDP (without /udp docker publishes TCP and the host can't reach Norsk's
769
+ // listener). srt-caller ingests dial out — no published port.
770
+ if (config.input.transport === "srt-listener") {
771
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: docker-compose variable reference, not a TS template literal
772
+ mediaPorts.push("\${INGEST_PORT}:\${INGEST_PORT}/udp");
773
+ }
774
+
775
+ const compose = {
776
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: docker-compose variable reference, not a TS template literal
777
+ name: "\${INSTANCE_NAME}",
778
+ services: {
779
+ studio: {
780
+ image: STUDIO_IMAGE,
781
+ environment: studioEnv,
782
+ ports: studioPorts,
783
+ restart: "unless-stopped",
784
+ },
785
+ media: {
786
+ image: MEDIA_IMAGE,
787
+ ...(mediaPorts.length > 0 ? { ports: mediaPorts } : {}),
788
+ restart: "unless-stopped",
789
+ },
790
+ },
791
+ };
792
+ return yamlStringify(compose, { aliasDuplicateObjects: false, lineWidth: 0 });
793
+ }
794
+
795
+ function renderParameters(config: ProductConfig): ProductTemplateParameter[] {
796
+ const params: ProductTemplateParameter[] = [
797
+ {
798
+ name: "INSTANCE_NAME",
799
+ purpose: "Compose project label for this instance",
800
+ type: "string",
801
+ default: "${ctx.dashboardKey}",
802
+ },
803
+ {
804
+ name: "STUDIO_HOST_PORT",
805
+ purpose: "Port on the host where Studio's web UI is exposed",
806
+ type: "number",
807
+ default: String(STUDIO_DEFAULT_HOST_PORT),
808
+ },
809
+ ];
810
+ if (config.input.transport === "srt-listener") {
811
+ params.push({
812
+ name: "INGEST_PORT",
813
+ purpose: "Host UDP port to bind for the SRT ingest",
814
+ type: "number",
815
+ default: String(config.input.port),
816
+ });
817
+ }
818
+ return params;
819
+ }
820
+ `;
821
+ }
822
+
823
+ const PRODUCT_TEMPLATE_TEST_TS = `// Tar round-trip: pack real materials, unpack, validate what came out against
824
+ // the schema package — proves the wire format end to end without a daemon.
825
+ import { beforeAll, describe, expect, it } from "bun:test";
826
+ import { listTarNames, unpackTar } from "@norskvideo/ctl-foundation";
827
+ import {
828
+ ParametersFileSchema,
829
+ ProductTemplateManifestSchema,
830
+ ProductTemplateMaterialsSchema,
831
+ } from "@norskvideo/ctl-product-template-schema";
832
+ import { packProductTemplate } from "@norskvideo/ctl-product-template-schema/pack";
833
+ import type { ComponentLibrary } from "@norskvideo/norsk-studio-builder";
834
+ import { parse as yamlParse } from "yaml";
835
+ import example from "../../examples/default/input.json" with { type: "json" };
836
+ import { buildProductTemplateMaterials } from "./product-template.ts";
837
+ import { ProductConfigSchema } from "./schemas/config.ts";
838
+ import { buildStudioLibrary } from "./workflow/studio-library.ts";
839
+
840
+ let library: ComponentLibrary;
841
+
842
+ beforeAll(async () => {
843
+ library = await buildStudioLibrary();
844
+ });
845
+
846
+ describe("product-template materials", () => {
847
+ it("validate against ProductTemplateMaterialsSchema", () => {
848
+ const materials = buildProductTemplateMaterials(ProductConfigSchema.parse(example), { library });
849
+ expect(ProductTemplateMaterialsSchema.safeParse(materials).success).toBe(true);
850
+ });
851
+
852
+ it("pack to a tar whose entries round-trip through the schema package", () => {
853
+ const materials = buildProductTemplateMaterials(ProductConfigSchema.parse(example), { library });
854
+ const tar = packProductTemplate(materials);
855
+
856
+ const names = listTarNames(tar);
857
+ expect(names.status).toBe("ok");
858
+ if (names.status !== "ok") return;
859
+ expect(names.value).toEqual(
860
+ expect.arrayContaining(["manifest.json", "compose.yml", "workflow.yml", "parameters.yaml"]),
861
+ );
862
+
863
+ const entries = unpackTar(tar);
864
+ expect(entries.status).toBe("ok");
865
+ if (entries.status !== "ok") return;
866
+ const decode = (name: string) => {
867
+ const entry = entries.value.find((e) => e.name === name);
868
+ if (!entry) throw new Error(\`missing tar entry \${name}\`);
869
+ return new TextDecoder().decode(entry.content);
870
+ };
871
+ expect(ProductTemplateManifestSchema.safeParse(JSON.parse(decode("manifest.json"))).success).toBe(true);
872
+ expect(ParametersFileSchema.safeParse(yamlParse(decode("parameters.yaml"))).success).toBe(true);
873
+ expect(decode("compose.yml")).toContain("studio:");
874
+ });
875
+ });
876
+ `;
877
+
878
+ function backendPackageJson(ctx: ShapeContext): string {
879
+ return `${JSON.stringify(
880
+ {
881
+ name: `${ctx.scope}/backend`,
882
+ version: "0.0.1",
883
+ private: true,
884
+ type: "module",
885
+ exports: { ".": "./src/index.ts" },
886
+ scripts: {
887
+ dev: "bun run --watch src/index.ts",
888
+ build: "bun build src/index.ts --outdir dist --target=bun",
889
+ clean: "rm -rf dist",
890
+ start: "bun run src/index.ts",
891
+ test: "bun test src/",
892
+ typecheck: "bunx tsc --noEmit",
893
+ },
894
+ dependencies: {
895
+ [`${ctx.scope}/shared`]: "workspace:*",
896
+ "@norskvideo/ctl-product-template-schema": "^0.1.0",
897
+ "@norskvideo/ctl-sdk": "^0.1.1",
898
+ "@norskvideo/norsk-studio-builder": ctx.pins.studioLib,
899
+ cors: "^2.8.5",
900
+ express: "^5.0.1",
901
+ zod: "^4.3.6",
902
+ },
903
+ devDependencies: {
904
+ "@types/bun": "latest",
905
+ "@types/cors": "^2.8.17",
906
+ "@types/express": "^5.0.0",
907
+ "@types/node": "^22.10.0",
908
+ typescript: "^5.7.2",
909
+ },
910
+ },
911
+ null,
912
+ 2,
913
+ )}\n`;
914
+ }
915
+
916
+ function backendIndexTs(ctx: ShapeContext): string {
917
+ return `import { startServer } from "./server.ts";
918
+
919
+ // ${ctx.devPort} is the LOCAL-DEV default only; inside the container the
920
+ // bundle driver sets PORT=4321 (the port the runner hardcodes).
921
+ const port = Number(process.env.PORT ?? ${ctx.devPort});
922
+ await startServer(port);
923
+ console.log(\`${ctx.name} listening on :\${port}\`);
924
+ `;
925
+ }
926
+
927
+ function backendServerTs(ctx: ShapeContext): string {
928
+ return `import type { Server } from "node:http";
929
+ import { buildStudioLibrary } from "${ctx.scope}/shared/workflow";
930
+ import cors from "cors";
931
+ import express from "express";
932
+ import { manifestRouter } from "./routes/manifest.ts";
933
+ import { openapiRouter } from "./routes/openapi.ts";
934
+ import { makeProductTemplateRouter } from "./routes/product-template.ts";
935
+
936
+ export async function startServer(port: number): Promise<Server> {
937
+ // Built once at startup: Studio's real NodeInfos + this product's declared
938
+ // stubs. Passed into the product-template route so every build reuses it.
939
+ const library = await buildStudioLibrary();
940
+
941
+ const app = express();
942
+ app.use(cors());
943
+ app.use(express.json({ limit: "1mb" }));
944
+
945
+ app.get("/healthz", (_req, res) => {
946
+ res.status(200).send("ok");
947
+ });
948
+
949
+ app.use(manifestRouter);
950
+ app.use("/api", openapiRouter);
951
+ app.use("/api", makeProductTemplateRouter(library));
952
+
953
+ return await new Promise<Server>((resolve, reject) => {
954
+ const server = app.listen(port, () => resolve(server));
955
+ server.on("error", (err) => reject(err));
956
+ });
957
+ }
958
+ `;
959
+ }
960
+
961
+ function backendServerTestTs(ctx: ShapeContext): string {
962
+ return `// The daemon hard-rejects a registration whose manifest-declared paths don't
963
+ // answer (waitForReady probes api.healthCheckPath; defaultProductTemplates are
964
+ // GET at product-add time). Assert here that every declared path is actually
965
+ // served — a mismatch otherwise only surfaces as "product did not become
966
+ // ready" at \`product add\` time, never in a local run.
967
+ import { afterAll, beforeAll, describe, expect, it } from "bun:test";
968
+ import type { Server } from "node:http";
969
+ import { buildManifest } from "${ctx.scope}/shared/manifest";
970
+ import { startServer } from "./server.ts";
971
+
972
+ let server: Server;
973
+ let baseUrl: string;
974
+
975
+ beforeAll(async () => {
976
+ server = await startServer(0);
977
+ const address = server.address();
978
+ if (address === null || typeof address === "string") throw new Error("expected a TCP address");
979
+ baseUrl = \`http://127.0.0.1:\${address.port}\`;
980
+ });
981
+
982
+ afterAll(() => {
983
+ server.close();
984
+ });
985
+
986
+ describe("manifest-declared paths are actually served", () => {
987
+ it("serves api.healthCheckPath", async () => {
988
+ const res = await fetch(\`\${baseUrl}\${buildManifest().api.healthCheckPath}\`);
989
+ expect(res.status).toBe(200);
990
+ });
991
+
992
+ it("serves the manifest itself at /manifest.json", async () => {
993
+ const res = await fetch(\`\${baseUrl}/manifest.json\`);
994
+ expect(res.status).toBe(200);
995
+ const body = (await res.json()) as { name?: string };
996
+ expect(body.name).toBe(buildManifest().name);
997
+ });
998
+
999
+ it("serves api.openapiFragmentPath", async () => {
1000
+ const res = await fetch(\`\${baseUrl}\${buildManifest().api.openapiFragmentPath}\`);
1001
+ expect(res.status).toBe(200);
1002
+ expect(await res.text()).toContain("openapi:");
1003
+ });
1004
+
1005
+ it("serves every defaultProductTemplates url as a tar", async () => {
1006
+ for (const template of buildManifest().defaultProductTemplates) {
1007
+ const res = await fetch(\`\${baseUrl}\${template.url}\`);
1008
+ expect(res.status).toBe(200);
1009
+ expect(res.headers.get("content-type")).toContain("x-tar");
1010
+ }
1011
+ });
1012
+ });
1013
+ `;
1014
+ }
1015
+
1016
+ function routesManifestTs(ctx: ShapeContext): string {
1017
+ return `import { buildManifest } from "${ctx.scope}/shared/manifest";
1018
+ import { createManifestRouter } from "@norskvideo/ctl-sdk";
1019
+
1020
+ export const manifestRouter = createManifestRouter(buildManifest);
1021
+ `;
1022
+ }
1023
+
1024
+ function routesOpenapiTs(ctx: ShapeContext): string {
1025
+ return `import { PRODUCT_VERSION } from "${ctx.scope}/shared";
1026
+ import { createOpenapiRouter } from "@norskvideo/ctl-sdk";
1027
+
1028
+ // Minimal OpenAPI fragment — the manifest points the runner at
1029
+ // /api/openapi.yaml. Expand (or generate from zod) as the API grows.
1030
+ const buildOpenApiYaml = (): string => \`openapi: 3.0.3
1031
+ info:
1032
+ title: ${ctx.name}
1033
+ version: "\${PRODUCT_VERSION}"
1034
+ paths:
1035
+ /api/product-template:
1036
+ post:
1037
+ summary: Build a product-template tar from a ProductConfig
1038
+ responses:
1039
+ "200":
1040
+ description: product-template tar
1041
+ content:
1042
+ application/x-tar: {}
1043
+ "400":
1044
+ description: invalid config
1045
+ /api/product-template/{slug}:
1046
+ get:
1047
+ summary: Build a product-template tar from a named starter config
1048
+ parameters:
1049
+ - name: slug
1050
+ in: path
1051
+ required: true
1052
+ schema:
1053
+ type: string
1054
+ responses:
1055
+ "200":
1056
+ description: product-template tar
1057
+ content:
1058
+ application/x-tar: {}
1059
+ "404":
1060
+ description: unknown slug
1061
+ \`;
1062
+
1063
+ export const openapiRouter = createOpenapiRouter(buildOpenApiYaml);
1064
+ `;
1065
+ }
1066
+
1067
+ function routesProductTemplateTs(ctx: ShapeContext): string {
1068
+ return `import { buildProductTemplateMaterials } from "${ctx.scope}/shared/product-template";
1069
+ import { ProductConfigSchema } from "${ctx.scope}/shared/schemas";
1070
+ import { packProductTemplate } from "@norskvideo/ctl-product-template-schema/pack";
1071
+ import type { ComponentLibrary } from "@norskvideo/norsk-studio-builder";
1072
+ import { Router } from "express";
1073
+ import defaultExample from "../../../examples/default/input.json" with { type: "json" };
1074
+
1075
+ // Starter configs, mirrored in manifest.ts's defaultProductTemplates[]. JSON
1076
+ // imported at module load so the bundler inlines it — the deployed container
1077
+ // doesn't ship the examples/ source tree. Keep the two in lockstep.
1078
+ const STARTER_CONFIGS: Record<string, unknown> = {
1079
+ default: defaultExample,
1080
+ };
1081
+
1082
+ export function makeProductTemplateRouter(library: ComponentLibrary): Router {
1083
+ const router = Router();
1084
+
1085
+ const sendTemplate = (res: Parameters<Parameters<Router["get"]>[1]>[1], raw: unknown, filename: string) => {
1086
+ const parsed = ProductConfigSchema.safeParse(raw);
1087
+ if (!parsed.success) {
1088
+ res.status(400).json({ error: "invalid config", issues: parsed.error.issues });
1089
+ return;
1090
+ }
1091
+ try {
1092
+ const materials = buildProductTemplateMaterials(parsed.data, { library });
1093
+ const tar = packProductTemplate(materials);
1094
+ res
1095
+ .status(200)
1096
+ .type("application/x-tar")
1097
+ .setHeader("Content-Disposition", \`attachment; filename="\${filename}"\`)
1098
+ .send(Buffer.from(tar));
1099
+ } catch (err) {
1100
+ res.status(500).json({
1101
+ error: "product-template generation failed",
1102
+ message: err instanceof Error ? err.message : String(err),
1103
+ });
1104
+ }
1105
+ };
1106
+
1107
+ // POST /api/product-template — build a tar from an arbitrary ProductConfig.
1108
+ router.post("/product-template", (req, res) => {
1109
+ sendTemplate(res, req.body, "${ctx.name}.tar");
1110
+ });
1111
+
1112
+ // GET /api/product-template/<slug> — the runner GETs these at product-add
1113
+ // time per manifest.defaultProductTemplates.
1114
+ router.get("/product-template/:slug", (req, res) => {
1115
+ const raw = STARTER_CONFIGS[req.params.slug];
1116
+ if (!raw) {
1117
+ res.status(404).json({ error: "unknown product-template slug", slug: req.params.slug });
1118
+ return;
1119
+ }
1120
+ sendTemplate(res, raw, \`\${req.params.slug}.tar\`);
1121
+ });
1122
+
1123
+ return router;
1124
+ }
1125
+ `;
1126
+ }
1127
+
1128
+ function testsPackageJson(ctx: ShapeContext): string {
1129
+ return `${JSON.stringify(
1130
+ {
1131
+ name: `${ctx.scope}/tests`,
1132
+ version: "0.0.1",
1133
+ private: true,
1134
+ type: "module",
1135
+ devDependencies: {
1136
+ "@norskvideo/ctl-dev-kit": "^0.1.10",
1137
+ "@norskvideo/ctl-test-harness": "^0.1.3",
1138
+ "@types/bun": "latest",
1139
+ "@types/node": "^22.10.0",
1140
+ typescript: "^5.7.2",
1141
+ },
1142
+ },
1143
+ null,
1144
+ 2,
1145
+ )}\n`;
1146
+ }
1147
+
1148
+ const INVARIANTS_PARITY_TEST_TS = `import { expect, test } from "bun:test";
1149
+ import { join } from "node:path";
1150
+ import { invariantParityFromDisk } from "@norskvideo/ctl-dev-kit/testing/invariants";
1151
+
1152
+ const root = join(import.meta.dir, "..", "..");
1153
+
1154
+ test("every invariant has a citing test and every cited ID is tabled", () => {
1155
+ const report = invariantParityFromDisk({
1156
+ invariantsPath: join(root, "INVARIANTS.md"),
1157
+ testDirs: [join(root, "shared"), join(root, "backend"), join(root, "tests")],
1158
+ });
1159
+ expect(report.problems).toEqual([]);
1160
+ });
1161
+ `;
1162
+
1163
+ const WORKFLOW_SNAPSHOT_TEST_TS = `// Byte-snapshots of the emitted wire formats for the starter config: any
1164
+ // emission change must come with a reviewable fixture diff. First run:
1165
+ // UPDATE_SNAPSHOTS=1 bun run test:unit bakes the fixtures — commit them.
1166
+ import { beforeAll, test } from "bun:test";
1167
+ import { join } from "node:path";
1168
+ import { expectByteSnapshot } from "@norskvideo/ctl-dev-kit/testing/byte-snapshot";
1169
+ import type { ProductTemplateMaterials } from "@norskvideo/ctl-product-template-schema";
1170
+ import example from "../../examples/default/input.json" with { type: "json" };
1171
+ import { buildProductTemplateMaterials } from "../../shared/src/product-template.ts";
1172
+ import { ProductConfigSchema } from "../../shared/src/schemas/config.ts";
1173
+ import { buildStudioLibrary } from "../../shared/src/workflow/studio-library.ts";
1174
+
1175
+ const fixture = (name: string) => join(import.meta.dir, "__snapshots__", name);
1176
+
1177
+ let materials: ProductTemplateMaterials;
1178
+
1179
+ beforeAll(async () => {
1180
+ materials = buildProductTemplateMaterials(ProductConfigSchema.parse(example), {
1181
+ library: await buildStudioLibrary(),
1182
+ // Snapshots must be byte-stable; only the manifest carries generatedAt and
1183
+ // it is not snapshotted.
1184
+ generatedAt: new Date(0),
1185
+ });
1186
+ });
1187
+
1188
+ test("workflow.yml emission is byte-stable", () => {
1189
+ expectByteSnapshot(materials.workflow ?? "", fixture("default.workflow.yml"));
1190
+ });
1191
+
1192
+ test("compose.yml emission is byte-stable", () => {
1193
+ expectByteSnapshot(materials.composeYaml, fixture("default.compose.yml"));
1194
+ });
1195
+ `;
1196
+
1197
+ const LOADS_IN_STUDIO_TEST_TS = `// Layer 4: the emitted YAML through Studio's REAL document.load(), so every
1198
+ // built-in's zod schema validates what the composer actually emits. Stubs
1199
+ // cover COMPONENT_STUBS identifiers (no schema to validate against).
1200
+ import { expect, test } from "bun:test";
1201
+ import { assertLoadsInStudio } from "@norskvideo/ctl-test-harness/studio-load";
1202
+ import example from "../../examples/default/input.json" with { type: "json" };
1203
+ import { buildProductTemplateMaterials } from "../../shared/src/product-template.ts";
1204
+ import { ProductConfigSchema } from "../../shared/src/schemas/config.ts";
1205
+ import { buildStudioLibrary, COMPONENT_STUBS } from "../../shared/src/workflow/studio-library.ts";
1206
+
1207
+ test("starter workflow round-trips through Studio document.load()", async () => {
1208
+ const materials = buildProductTemplateMaterials(ProductConfigSchema.parse(example), {
1209
+ library: await buildStudioLibrary(),
1210
+ });
1211
+ const compiled = await assertLoadsInStudio(materials.workflow ?? "", {
1212
+ stubs: COMPONENT_STUBS.map((s) => s.identifier),
1213
+ });
1214
+ expect(compiled.errors).toEqual([]);
1215
+ });
1216
+ `;
1217
+
1218
+ function imageSmokeTestTs(ctx: ShapeContext): string {
1219
+ return `// Image-tier smoke test for the packaged product container. Docker-only (no
1220
+ // daemon, no licence): docker run the built image on a loopback port,
1221
+ // mirroring the runner's -p 127.0.0.1:<hostPort>:4321, and curl the surface
1222
+ // \`product add\` probes. This is the tier that catches bundling constraints
1223
+ // (e.g. a runtime fs.readdir surviving \`bun build --target=bun\`) that unit
1224
+ // tests and a host \`bun run start\` cannot see.
1225
+ //
1226
+ // Run \`bun run build:image\` first; then \`bun run test:image\`.
1227
+ import { describe, expect, test } from "bun:test";
1228
+ import { spawnSync } from "node:child_process";
1229
+
1230
+ const IMAGE_TAG = process.env.IMAGE_TAG ?? "${ctx.name}:dev";
1231
+
1232
+ function imageExists(tag: string): boolean {
1233
+ return spawnSync("docker", ["image", "inspect", tag], { stdio: "ignore" }).status === 0;
1234
+ }
1235
+
1236
+ async function waitForHealthz(url: string, timeoutMs: number): Promise<void> {
1237
+ const deadline = Date.now() + timeoutMs;
1238
+ while (Date.now() < deadline) {
1239
+ try {
1240
+ const r = await fetch(url, { signal: AbortSignal.timeout(1000) });
1241
+ if (r.ok) return;
1242
+ } catch {}
1243
+ await Bun.sleep(500);
1244
+ }
1245
+ throw new Error(\`container \${url} never became healthy within \${timeoutMs}ms\`);
1246
+ }
1247
+
1248
+ describe("${ctx.name} image: container surface", () => {
1249
+ test("built image serves /healthz, the manifest, and the starter template tar", async () => {
1250
+ expect(imageExists(IMAGE_TAG), \`image \${IMAGE_TAG} not found — run: bun run build:image\`).toBe(true);
1251
+
1252
+ const hostPort = 24329;
1253
+ const run = spawnSync("docker", ["run", "-d", "--rm", "-p", \`127.0.0.1:\${hostPort}:4321\`, IMAGE_TAG], {
1254
+ encoding: "utf-8",
1255
+ });
1256
+ expect(run.status, \`docker run failed: \${run.stderr}\`).toBe(0);
1257
+ const containerId = run.stdout.trim();
1258
+
1259
+ try {
1260
+ const base = \`http://127.0.0.1:\${hostPort}\`;
1261
+ await waitForHealthz(\`\${base}/healthz\`, 30_000);
1262
+
1263
+ const manRes = await fetch(\`\${base}/manifest.json\`);
1264
+ expect(manRes.status).toBe(200);
1265
+ const manifest = (await manRes.json()) as { name?: string };
1266
+ expect(manifest.name).toBe("${ctx.name}");
1267
+
1268
+ const tarRes = await fetch(\`\${base}/api/product-template/default\`);
1269
+ expect(tarRes.status).toBe(200);
1270
+ expect(tarRes.headers.get("content-type")).toContain("x-tar");
1271
+ const tar = Buffer.from(await tarRes.arrayBuffer());
1272
+ // ustar magic at offset 257 of the first header block.
1273
+ expect(tar.subarray(257, 262).toString("ascii")).toBe("ustar");
1274
+ const tarText = tar.toString("latin1");
1275
+ for (const entry of ["manifest.json", "compose.yml", "workflow.yml", "parameters.yaml"]) {
1276
+ expect(tarText).toContain(entry);
1277
+ }
1278
+ } finally {
1279
+ spawnSync("docker", ["rm", "-f", containerId], { stdio: "ignore" });
1280
+ }
1281
+ }, 120_000);
1282
+ });
1283
+ `;
1284
+ }
1285
+
1286
+ function iterateSh(ctx: ShapeContext): string {
1287
+ return `#!/usr/bin/env bash
1288
+ # Iterate loop: rebuild the image and push it through the daemon — the
1289
+ # stored-template trap (see CLAUDE.md) means NONE of this happens implicitly.
1290
+ # Modeled on funke-pegasus's deployment/iterate.sh (the fleet reference); grow
1291
+ # the TODOs into real probes as the product takes shape.
1292
+ set -euo pipefail
1293
+
1294
+ product="${ctx.name}"
1295
+ template="default"
1296
+ image="\${IMAGE_TAG:-${ctx.name}:dev}"
1297
+
1298
+ echo "==> build image \${image}"
1299
+ bun run build:image
1300
+
1301
+ # Refresh the stored template in place if the daemon supports it; otherwise
1302
+ # fall back to the full remove/re-add dance.
1303
+ if norsk-ctl template refresh "\${template}" 2>/dev/null; then
1304
+ echo "==> stored template refreshed"
1305
+ else
1306
+ echo "==> template refresh unavailable — full remove/re-add"
1307
+ # TODO: delete instances first: norsk-ctl instance delete <id> --purge
1308
+ norsk-ctl template remove "\${template}" || true
1309
+ norsk-ctl product remove "\${product}" || true
1310
+ norsk-ctl product add --image "\${image}" \${NORSK_LICENSE_FILE:+--license-file "\${NORSK_LICENSE_FILE}"}
1311
+ fi
1312
+
1313
+ # Verify the refresh landed — never assume (a stale stored template debugs
1314
+ # like a code bug).
1315
+ echo "==> stored template pins:"
1316
+ grep "image:" "\${HOME}/.norsk-ctl/product-templates/\${template}/compose.yml"
1317
+
1318
+ # TODO: launch an instance, pump a source, and assert output — funke's
1319
+ # iterate.sh pumps SRT and asserts playable HLS; do the equivalent here.
1320
+ echo "==> done (launch + probe steps are TODO)"
1321
+ `;
1322
+ }
1323
+
1324
+ const DOCKERIGNORE = `node_modules
1325
+ **/node_modules
1326
+ dist
1327
+ backend/dist
1328
+ shared/dist
1329
+ .git
1330
+ .github
1331
+ *.md
1332
+ `;
1333
+
1334
+ function exampleInputJson(_ctx: ShapeContext): string {
1335
+ return `${JSON.stringify(
1336
+ {
1337
+ input: { transport: "srt-listener", port: 5001, streamId: "input" },
1338
+ output: { host: "127.0.0.1", port: 6000 },
1339
+ },
1340
+ null,
1341
+ 2,
1342
+ )}\n`;
1343
+ }
1344
+
1345
+ export const backendTurnkey: ShapeModule = {
1346
+ claudeHead,
1347
+ claudeTail,
1348
+ rootTsconfigInclude: ["tests/**/*"],
1349
+ files(ctx: ShapeContext): GeneratedFile[] {
1350
+ return [
1351
+ { path: "package.json", content: rootPackageJson(ctx) },
1352
+ { path: "INVARIANTS.md", content: invariantsMd(ctx) },
1353
+ { path: ".dockerignore", content: DOCKERIGNORE },
1354
+ { path: "examples/default/input.json", content: exampleInputJson(ctx) },
1355
+ { path: "deployment/iterate.sh", content: iterateSh(ctx), executable: true },
1356
+ { path: "shared/package.json", content: sharedPackageJson(ctx) },
1357
+ { path: "shared/tsconfig.json", content: WORKSPACE_TSCONFIG },
1358
+ { path: "shared/src/index.ts", content: SHARED_INDEX_TS },
1359
+ { path: "shared/src/version.ts", content: versionTs(ctx) },
1360
+ { path: "shared/src/version.test.ts", content: versionTestTs(ctx) },
1361
+ { path: "shared/src/manifest.ts", content: MANIFEST_TS },
1362
+ { path: "shared/src/manifest.test.ts", content: MANIFEST_TEST_TS },
1363
+ { path: "shared/src/schemas/index.ts", content: SCHEMAS_INDEX_TS },
1364
+ { path: "shared/src/schemas/config.ts", content: CONFIG_TS },
1365
+ { path: "shared/src/schemas/config.test.ts", content: CONFIG_TEST_TS },
1366
+ { path: "shared/src/product-template.ts", content: productTemplateTs(ctx) },
1367
+ { path: "shared/src/product-template.test.ts", content: PRODUCT_TEMPLATE_TEST_TS },
1368
+ { path: "shared/src/workflow/index.ts", content: WORKFLOW_INDEX_TS },
1369
+ { path: "shared/src/workflow/ids.ts", content: IDS_TS },
1370
+ { path: "shared/src/workflow/components.ts", content: COMPONENTS_TS },
1371
+ { path: "shared/src/workflow/studio-library.ts", content: STUDIO_LIBRARY_TS },
1372
+ { path: "shared/src/workflow/compose-workflow.ts", content: COMPOSE_WORKFLOW_TS },
1373
+ { path: "shared/src/workflow/compose-workflow.test.ts", content: COMPOSE_WORKFLOW_TEST_TS },
1374
+ { path: "backend/package.json", content: backendPackageJson(ctx) },
1375
+ { path: "backend/tsconfig.json", content: WORKSPACE_TSCONFIG },
1376
+ { path: "backend/src/index.ts", content: backendIndexTs(ctx) },
1377
+ { path: "backend/src/server.ts", content: backendServerTs(ctx) },
1378
+ { path: "backend/src/server.test.ts", content: backendServerTestTs(ctx) },
1379
+ { path: "backend/src/routes/manifest.ts", content: routesManifestTs(ctx) },
1380
+ { path: "backend/src/routes/openapi.ts", content: routesOpenapiTs(ctx) },
1381
+ { path: "backend/src/routes/product-template.ts", content: routesProductTemplateTs(ctx) },
1382
+ { path: "tests/package.json", content: testsPackageJson(ctx) },
1383
+ { path: "tests/unit/invariants-parity.test.ts", content: INVARIANTS_PARITY_TEST_TS },
1384
+ { path: "tests/unit/workflow-snapshot.test.ts", content: WORKFLOW_SNAPSHOT_TEST_TS },
1385
+ { path: "tests/unit/loads-in-studio.test.ts", content: LOADS_IN_STUDIO_TEST_TS },
1386
+ { path: "tests/image/image-smoke.test.ts", content: imageSmokeTestTs(ctx) },
1387
+ ];
1388
+ },
1389
+ };