@norskvideo/ctl-dev-kit 0.1.97 → 0.1.99

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.
@@ -1,26 +1,154 @@
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
1
+ // The turnkey skeleton — ONE module for every turnkey shape. The base emitted
2
+ // here is reuters-shaped (shared/ + backend/ + tests/: one composed workflow
3
+ // served as a product-template tar from an express backend); the components,
4
+ // views, dashboard and frontend FEATURES add their workspaces on top and patch
5
+ // the handful of shared files that have to know about them (see features/).
6
+ //
7
+ // There is deliberately no second copy of this skeleton per shape: it is ~1600
8
+ // lines, the shape layer is not drift-gated, and three copies would diverge
9
+ // within a release. A shape is a preset over features (create-product.ts).
10
+ //
11
+ // The skeleton ships the post-review state as defaults: seed through
5
12
  // parseManifestSeed, ManifestInput with no boilerplate, packProductTemplate
6
13
  // from the schema package, addComponent/stubbedLibrary with a THROWING
7
14
  // validate(), INVARIANTS + parity + byte-snapshot + studio-load wired into
8
15
  // test:unit, and an image smoke tier.
9
- import type { GeneratedFile, ShapeContext, ShapeModule } from "./create-product.ts";
16
+ import { type GeneratedFile, type ShapeContext, type ShapeModule, studioPackage } from "./create-product.ts";
17
+ import {
18
+ COMPONENTS_GITIGNORE,
19
+ componentsFiles,
20
+ EXAMPLE_ID,
21
+ EXAMPLE_IDENTIFIER,
22
+ libraryPackage,
23
+ } from "./features/components.ts";
24
+ import {
25
+ DASHBOARD_GITIGNORE,
26
+ DASHBOARD_PROXY_EXPOSE,
27
+ DASHBOARD_RUNTIME_SCREEN,
28
+ dashboardFiles,
29
+ } from "./features/dashboard.ts";
30
+ import { CONFIG_SCREEN_URL, FRONTEND_SERVER_BLOCK, frontendFiles } from "./features/frontend.ts";
31
+ import { VIEWS_PROXY_EXPOSE, VIEWS_RUNTIME_SCREEN, viewsFiles } from "./features/views.ts";
32
+
33
+ // The per-feature section of the generated CLAUDE.md: what each optional
34
+ // workspace is, and the trap that comes with it. Every line here is something
35
+ // a fleet review found someone had to rediscover.
36
+ function featureNotes(ctx: ShapeContext): string {
37
+ const sections: string[] = [];
38
+ if (ctx.features.has("components")) {
39
+ sections.push(`### \`components/\` — this product's own Studio nodes
40
+
41
+ Build order is load-bearing: \`codegen\` (types.source.yaml -> _gen/types.ts)
42
+ -> \`tsc\` -> \`copy-yamls\` (types.yaml beside the compiled runtime, plus the
43
+ BROWSER bundle info.client.js). \`bun run build:components\` does all three.
44
+
45
+ It is CommonJS on purpose (\`module: "commonjs"\`): under \`node16\` Node's
46
+ CJS-via-ESM interop double-wraps the exports object and \`infoMod.default\`
47
+ becomes the wrapper rather than the component factory.
48
+
49
+ Adding a component is dropping a sibling directory under
50
+ \`components/src/library/\` with an \`info.ts\` and a \`runtime.ts\` — the library
51
+ entry discovers it. But TWO things elsewhere have to follow, and both fail
52
+ quietly:
53
+
54
+ - **\`COMPONENT_STUBS\`** (\`shared/src/workflow/studio-library.ts\`) — the
55
+ builder's library is Studio's npm NodeInfos, which cannot know this product's
56
+ own nodes. Without a stub the composer's throwing \`validate()\` rejects the
57
+ graph. Declare the stub; never demote \`validate()\` to a warning.
58
+ - **\`server.library\` in the emitted compose** — the product's list OVERRIDES
59
+ the daemon's seeded \`default.yaml\` rather than merging, so it must name every
60
+ library including the built-ins. A component that is mounted but missing here
61
+ is never loaded, with no error at all.
62
+
63
+ \`components/src/library/example/\` is a placeholder wired all the way through so
64
+ a fresh repo proves the pipeline on its first launch. Replace it or delete it.
65
+ `);
66
+ }
67
+ if (ctx.features.has("views")) {
68
+ sections.push(`### The operator screen — component views
69
+
70
+ \`inline.tsx\` / \`summary.tsx\` / \`fullscreen.tsx\` beside the component, registered
71
+ under its \`runtime\` and rendered INSIDE Studio's workflow view: \`inline\` inside
72
+ the node on the canvas (without it the node shows only static config),
73
+ \`summary\` in the sidebar when the node is selected, \`fullscreen\` as the full
74
+ page. State arrives as
75
+ \`ViewProps.state\` (pushed from every \`updates.update()\`) and commands leave
76
+ through \`sendCommand\` — a view never fetches and never opens a socket.
77
+
78
+ They are bundled for the BROWSER by \`copy-yamls\`, and Studio's loader has no
79
+ import map: a bare specifier left in info.client.js is a fatal
80
+ module-resolution crash in the workflow view. React is rewritten to Studio's
81
+ window globals so no second copy is bundled;
82
+ \`components/src/library/info-client-externals.test.ts\` guards it.
83
+
84
+ The template manifest points \`runtimeScreenUrl\` at Studio's own \`/studio/\`
85
+ route, and \`proxy.expose\` carries \`/live/*\` — \`/live/api/*\` alone covers the
86
+ component's routes but not its PAGES or their state websockets.
87
+ `);
88
+ }
89
+ if (ctx.features.has("dashboard")) {
90
+ sections.push(`### The operator screen — dashboard SPA
91
+
92
+ Four paths name the same thing, and conflating them is how a dashboard builds,
93
+ packs and launches while serving a 404:
94
+
95
+ | Layer | Path |
96
+ | -------------------- | --------------------------------------- |
97
+ | repo (vite outDir) | \`dashboards/workflow/\` |
98
+ | inside the tar | \`dashboards/workflow/\` |
99
+ | inside the container | \`studio-save-files/workflow/dashboards/\` |
100
+ | URL | \`/dashboard/workflow/\` |
101
+
102
+ The directory name **is** the workflow name. The tar ships \`workflow.yml\` at
103
+ its root, so the workflow is \`workflow\`; ctl does the rename at mount.
104
+
105
+ \`base: "./"\` is required — the page is served behind a per-instance proxy
106
+ prefix, so absolute asset URLs escape it. And the API client discovers
107
+ \`apiBasePath\`/\`wsBasePath\` from the \`env\` document Studio serves beside the
108
+ page: a root-absolute \`/live/api/...\` resolves against the PROXY ROOT and never
109
+ reaches the instance.
110
+
111
+ \`proxy.expose\` carries \`/dashboard/*\`. Without it the dashboard works on the
112
+ direct Studio port and 404s through the proxy.
113
+ `);
114
+ }
115
+ if (ctx.features.has("frontend")) {
116
+ sections.push(`### \`frontend/\` — the configure screen
117
+
118
+ Served by THIS container at \`/configure\` and iframed by ctl at
119
+ \`manifest.ui.configScreenUrl\`. Not an operator screen: that is per-instance and
120
+ Studio's, this is registration-time and ours.
121
+
122
+ The daemon PROBES \`configScreenUrl\` at \`product add\` and refuses the product
123
+ with \`CONFIG_SCREEN_UNREACHABLE\` if it does not answer. The backend serves
124
+ \`frontend/dist\`, so it must be built first — \`bun run dev\` and the image build
125
+ both do. \`bun run dev:frontend\` is vite's own server, for iterating on the
126
+ screen with HMR.
127
+ `);
128
+ }
129
+ if (sections.length === 0) return "";
130
+ return `## The optional workspaces this repo has
131
+
132
+ ${sections.join("\n")}
133
+ `;
134
+ }
10
135
 
11
136
  function claudeHead(ctx: ShapeContext): string {
137
+ const features = [...ctx.features].sort();
138
+ const built = features.length === 0 ? "no optional workspaces" : `features: ${features.join(", ")}`;
12
139
  return `# Working in this repo (${ctx.name})
13
140
 
14
141
  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.
142
+ (${built}). It builds a product container image and launches through the
143
+ **released** norsk-ctl daemon — it needs no norsk-ctl source checkout. This
144
+ file is conventions + gotchas; the fenced block below is the drift-gated
145
+ shared core.
19
146
  `;
20
147
  }
21
148
 
22
149
  function claudeTail(ctx: ShapeContext): string {
23
- return `## Getting norsk-ctl + the dev loop
150
+ const workspaceNotes = featureNotes(ctx);
151
+ return `${workspaceNotes}## Getting norsk-ctl + the dev loop
24
152
 
25
153
  \`norsk-ctl\` (the daemon/CLI) is **not** an npm dep — it's the released binary,
26
154
  pinned in \`flake.nix\` and put on PATH by the nix shell:
@@ -47,12 +175,18 @@ fresh image and checks the pins before relaunching.
47
175
 
48
176
  ## First run after generation
49
177
 
50
- 1. \`bun install\`
51
- 2. \`UPDATE_SNAPSHOTS=1 bun run test:unit\` — bakes the byte-snapshot fixtures
52
- under \`tests/unit/__snapshots__/\` (the generator cannot compose without the
53
- Studio packages installed). Commit them; from then on any emission change is
54
- a reviewable fixture diff.
55
- 3. \`bun run check:drift && bun run docs:check && bun run test:unit && bun run typecheck\`
178
+ 1. \`nix develop\` — the dev shell: pinned bun, biome and norsk-ctl.
179
+ 2. \`git init && bun install\`
180
+ 3. \`UPDATE_SNAPSHOTS=1 bun run test:unit\` — bakes the byte-snapshot fixtures
181
+ under tests/unit/__snapshots__/ (the generator cannot compose without the
182
+ Studio packages installed). That directory does not exist until this command
183
+ creates it, which is why it is not written as a repo path here. Commit the
184
+ fixtures; from then on any emission change is a reviewable fixture diff.
185
+ 4. \`bun run check:drift && bun run docs:check && bun run test:unit && bun run typecheck && bun run lint\`
186
+ 5. Replace the starter graph in \`shared/src/workflow/\` with the real one,
187
+ keeping \`INVARIANTS.md\` and its rules tests in step.
188
+ 6. \`bun run docs:handover\` — once a daemon has this product added; fills the
189
+ handover's generated block from the launch truth.
56
190
 
57
191
  ## Version pins
58
192
 
@@ -108,20 +242,31 @@ bytes; \`UPDATE_SNAPSHOTS=1\` refreshes deliberately.
108
242
 
109
243
  function rootPackageJson(ctx: ShapeContext): string {
110
244
  const { studioLib, mediaLib } = ctx.pins;
245
+ const hasComponents = ctx.features.has("components");
246
+ const hasDashboard = ctx.features.has("dashboard");
247
+ const hasFrontend = ctx.features.has("frontend");
111
248
  return `${JSON.stringify(
112
249
  {
113
250
  name: ctx.name,
114
251
  version: "0.0.1",
115
252
  private: true,
116
253
  type: "module",
117
- workspaces: ["shared", "backend", "tests"],
254
+ workspaces: [
255
+ "shared",
256
+ ...(hasComponents ? ["components"] : []),
257
+ ...(hasDashboard ? ["dashboard"] : []),
258
+ ...(hasFrontend ? ["frontend"] : []),
259
+ "backend",
260
+ "tests",
261
+ ],
118
262
  scripts: {
263
+ ...(hasComponents ? { codegen: "bun run --cwd components codegen" } : {}),
119
264
  clean: "bun run --cwd shared clean",
120
265
  format: "biome format --write .",
121
266
  lint: "biome check .",
122
267
  "lint:fix": "biome check --write .",
123
268
  test: "bun run test:unit",
124
- "test:unit": "bun test shared/src/ backend/src/ tests/unit/",
269
+ "test:unit": `bun test shared/src/ backend/src/ ${hasComponents ? "components/src/ " : ""}tests/unit/`,
125
270
  "test:image": "bun test tests/image/",
126
271
  "check:drift": "bun run node_modules/@norskvideo/ctl-dev-kit/conventions/check-drift.ts",
127
272
  "docs:check": "bun run node_modules/@norskvideo/ctl-dev-kit/docs/path-existence.ts",
@@ -129,13 +274,37 @@ function rootPackageJson(ctx: ShapeContext): string {
129
274
  "docs:handover": "bash scripts/docs-handover.sh",
130
275
  "docs:handover:check": "bash scripts/docs-handover.sh --check",
131
276
  "sync:drift": "bun run node_modules/@norskvideo/ctl-dev-kit/conventions/sync-drift.ts",
132
- typecheck: "bunx tsc --noEmit -p shared && bunx tsc --noEmit -p backend && bunx tsc --noEmit -p .",
277
+ // codegen first: components/_gen/types.ts is gitignored build output,
278
+ // so on a clean checkout tsc has nothing to resolve against without it.
279
+ typecheck: [
280
+ ...(hasComponents ? ["bun run codegen"] : []),
281
+ "bunx tsc --noEmit -p shared",
282
+ ...(hasComponents ? ["bunx tsc --noEmit -p components"] : []),
283
+ ...(hasDashboard ? ["bunx tsc --noEmit -p dashboard"] : []),
284
+ ...(hasFrontend ? ["bunx tsc --noEmit -p frontend"] : []),
285
+ "bunx tsc --noEmit -p backend",
286
+ "bunx tsc --noEmit -p .",
287
+ ].join(" && "),
133
288
  build: "bun run lint && bun run typecheck && bun run build:no-lint",
134
- "build:no-lint": "bun run build:shared && bun run build:backend && bun run docs:manual",
289
+ "build:no-lint": [
290
+ "bun run build:shared",
291
+ ...(hasComponents ? ["bun run build:components"] : []),
292
+ ...(hasDashboard ? ["bun run build:dashboard"] : []),
293
+ ...(hasFrontend ? ["bun run build:frontend"] : []),
294
+ "bun run build:backend",
295
+ "bun run docs:manual",
296
+ ].join(" && "),
135
297
  "build:shared": "bun run --cwd shared build",
298
+ ...(hasComponents ? { "build:components": "bun run --cwd components build" } : {}),
299
+ ...(hasDashboard ? { "build:dashboard": "bun run --cwd dashboard build" } : {}),
300
+ ...(hasFrontend ? { "build:frontend": "bun run --cwd frontend build" } : {}),
136
301
  "build:backend": "bun run --cwd backend build",
137
302
  "build:image": "bash deployment/build-image.sh",
138
- dev: "bun run --cwd backend dev",
303
+ // The backend SERVES frontend/dist, so a dev loop that never builds it
304
+ // hands the daemon's configure-screen probe a 404. `dev:frontend` is
305
+ // vite's own server, for iterating on the screen with HMR.
306
+ dev: hasFrontend ? "bun run build:frontend && bun run --cwd backend dev" : "bun run --cwd backend dev",
307
+ ...(hasFrontend ? { "dev:frontend": "bun run --cwd frontend dev" } : {}),
139
308
  start: "bun run --cwd backend start",
140
309
  iterate: "bash deployment/iterate.sh",
141
310
  demo: "ctl-demo",
@@ -159,6 +328,7 @@ function rootPackageJson(ctx: ShapeContext): string {
159
328
  "@norskvideo/norsk-api": mediaLib,
160
329
  "@norskvideo/norsk-sdk": mediaLib,
161
330
  "@norskvideo/norsk-studio": studioLib,
331
+ ...optionalStudioPins(ctx),
162
332
  "@norskvideo/norsk-studio-builder": studioLib,
163
333
  "@norskvideo/norsk-studio-built-ins": studioLib,
164
334
  },
@@ -168,6 +338,10 @@ function rootPackageJson(ctx: ShapeContext): string {
168
338
  )}\n`;
169
339
  }
170
340
 
341
+ function optionalStudioPins(ctx: ShapeContext): Record<string, string> {
342
+ return Object.fromEntries(ctx.studioLibs.map((lib) => [studioPackage(lib), ctx.pins.studioLib]));
343
+ }
344
+
171
345
  function invariantsMd(ctx: ShapeContext): string {
172
346
  return `# Workflow invariants
173
347
 
@@ -208,8 +382,11 @@ function sharedPackageJson(ctx: ShapeContext): string {
208
382
  "./workflow": "./src/workflow/index.ts",
209
383
  },
210
384
  scripts: {
211
- build:
212
- "bun build src/index.ts --outdir dist --target=bun --external @norskvideo/norsk-studio --external @norskvideo/norsk-studio-builder --external @norskvideo/norsk-studio-built-ins",
385
+ build: [
386
+ "bun build src/index.ts --outdir dist --target=bun --external @norskvideo/norsk-studio",
387
+ ...ctx.studioLibs.map((lib) => `--external ${studioPackage(lib)}`),
388
+ "--external @norskvideo/norsk-studio-builder --external @norskvideo/norsk-studio-built-ins",
389
+ ].join(" "),
213
390
  clean: "rm -rf dist",
214
391
  test: "bun test src/",
215
392
  typecheck: "bunx tsc --noEmit",
@@ -219,6 +396,7 @@ function sharedPackageJson(ctx: ShapeContext): string {
219
396
  "@norskvideo/ctl-sdk": "^0.1.1",
220
397
  "@norskvideo/norsk-sdk": mediaLib,
221
398
  "@norskvideo/norsk-studio": studioLib,
399
+ ...optionalStudioPins(ctx),
222
400
  "@norskvideo/norsk-studio-builder": studioLib,
223
401
  "@norskvideo/norsk-studio-built-ins": studioLib,
224
402
  yaml: "^2.8.0",
@@ -335,16 +513,14 @@ it("repo and tag recombine to the full image ref", () => {
335
513
  `;
336
514
  }
337
515
 
338
- const MANIFEST_TS = `import { type Manifest, type ManifestInput, ManifestSchema } from "@norskvideo/ctl-sdk/browser";
516
+ const MANIFEST_TS_TEMPLATE = `import { type Manifest, type ManifestInput, ManifestSchema } from "@norskvideo/ctl-sdk/browser";
339
517
  import { PRODUCT_NAME, PRODUCT_VERSION } from "./version.ts";
340
518
 
341
519
  // Producer-side ManifestInput: everything the schema defaults stays omitted
342
- // (cli/components/runtime), so this declares only what the product has. A
343
- // backend-only turnkey has no configure UI — configScreenUrl is optional and
344
- // omitted, so the runner skips its registration probe (no placeholder HTML);
345
- // its one ui entry is the documentation the image serves at /docs.
520
+ // (cli/components/runtime), so this declares only what the product has.
346
521
  // Parsing at build time keeps the served manifest the READER shape with the
347
522
  // defaults filled in.
523
+ __CONFIG_SCREEN_NOTE__
348
524
  export function buildManifest(): Manifest {
349
525
  const manifest: ManifestInput = {
350
526
  manifestSchemaVersion: 1,
@@ -353,19 +529,38 @@ export function buildManifest(): Manifest {
353
529
  minRunnerVersion: "0.1.0",
354
530
  api: {
355
531
  basePath: "/api",
356
- proxyPaths: ["/api/*"],
532
+ proxyPaths: [__PROXY_PATHS__],
357
533
  openapiFragmentPath: "/api/openapi.yaml",
358
534
  },
359
535
  targets: ["docker-compose"],
360
536
  // Keep in lockstep with STARTER_CONFIGS in backend/src/routes/product-template.ts.
361
537
  defaultProductTemplates: [{ name: "default", url: "/api/product-template/default" }],
362
- ui: { sidebarEntries: [{ label: "Documentation", route: "/docs/" }] },
538
+ ui: { __CONFIG_SCREEN__sidebarEntries: [{ label: "Documentation", route: "/docs/" }] },
363
539
  };
364
540
  return ManifestSchema.parse(manifest);
365
541
  }
366
542
  `;
367
543
 
368
- const MANIFEST_TEST_TS = `import { describe, expect, it } from "bun:test";
544
+ function manifestTs(ctx: ShapeContext): string {
545
+ const hasFrontend = ctx.features.has("frontend");
546
+ return MANIFEST_TS_TEMPLATE.replace(
547
+ "__CONFIG_SCREEN_NOTE__",
548
+ hasFrontend
549
+ ? `// The configure screen is served by THIS container out of frontend/dist. The
550
+ // daemon probes it at \`product add\` and refuses the product with
551
+ // CONFIG_SCREEN_UNREACHABLE if it does not answer, so it must be built first.`
552
+ : `// This product has no configure UI — configScreenUrl is optional and omitted,
553
+ // so the daemon skips its registration probe entirely (no placeholder HTML
554
+ // needed); the one ui entry is the documentation the image serves at /docs.`,
555
+ )
556
+ .replace(
557
+ "__PROXY_PATHS__",
558
+ ['"/api/*"', ...(hasFrontend ? [`"${CONFIG_SCREEN_URL}"`, `"${CONFIG_SCREEN_URL}/*"`] : [])].join(", "),
559
+ )
560
+ .replace("__CONFIG_SCREEN__", hasFrontend ? `configScreenUrl: "${CONFIG_SCREEN_URL}", ` : "");
561
+ }
562
+
563
+ const MANIFEST_TEST_TS_TEMPLATE = `import { describe, expect, it } from "bun:test";
369
564
  import { ManifestSchema } from "@norskvideo/ctl-sdk/browser";
370
565
  import { buildManifest } from "./manifest.ts";
371
566
 
@@ -374,11 +569,25 @@ describe("manifest", () => {
374
569
  expect(ManifestSchema.safeParse(buildManifest()).success).toBe(true);
375
570
  });
376
571
 
377
- it("omits the configure UI (backend-only: the runner skips its probe)", () => {
572
+ __CONFIG_SCREEN_TEST__});
573
+ `;
574
+
575
+ function manifestTestTs(ctx: ShapeContext): string {
576
+ return MANIFEST_TEST_TS_TEMPLATE.replace(
577
+ "__CONFIG_SCREEN_TEST__",
578
+ ctx.features.has("frontend")
579
+ ? ` it("declares the configure screen and proxies it", () => {
580
+ const manifest = buildManifest();
581
+ expect(manifest.ui.configScreenUrl).toBe("${CONFIG_SCREEN_URL}");
582
+ expect(manifest.api.proxyPaths).toContain("${CONFIG_SCREEN_URL}");
583
+ });
584
+ `
585
+ : ` it("omits the configure UI (the daemon then skips its probe)", () => {
378
586
  expect(buildManifest().ui.configScreenUrl).toBeUndefined();
379
587
  });
380
- });
381
- `;
588
+ `,
589
+ );
590
+ }
382
591
 
383
592
  const SCHEMAS_INDEX_TS = `export * from "./config.ts";
384
593
  `;
@@ -428,13 +637,20 @@ describe("ProductConfigSchema", () => {
428
637
  });
429
638
  `;
430
639
 
431
- const IDS_TS = `// Stable component ids — part of the workflow contract: they appear in the
640
+ const IDS_TS_TEMPLATE = `// Stable component ids — part of the workflow contract: they appear in the
432
641
  // emitted YAML and are cited by INVARIANTS.md rules, so never rename without
433
642
  // coordinating every consumer.
434
643
  export const INGEST_ID = "ingest";
435
644
  export const EGEST_ID = "egest";
436
645
  export const PREVIEW_ID = "preview";
437
- `;
646
+ __EXAMPLE_ID__`;
647
+
648
+ function idsTs(ctx: ShapeContext): string {
649
+ return IDS_TS_TEMPLATE.replace(
650
+ "__EXAMPLE_ID__",
651
+ ctx.features.has("components") ? `export const EXAMPLE_ID = "${EXAMPLE_ID}";\n` : "",
652
+ );
653
+ }
438
654
 
439
655
  const COMPONENTS_TS = `// Typed factories for the Studio components the starter graph wires up.
440
656
  // Config types come from each built-in's OpenAPI-generated _gen/types, so
@@ -527,7 +743,7 @@ export function preview(args: PreviewArgs): WorkflowComponent<PreviewConfig> {
527
743
  }
528
744
  `;
529
745
 
530
- const STUDIO_LIBRARY_TS = `// Build the composer's ComponentLibrary: Studio's REAL NodeInfos first, this
746
+ const STUDIO_LIBRARY_TS_TEMPLATE = `// Build the composer's ComponentLibrary: Studio's REAL NodeInfos first, this
531
747
  // product's declared stubs second, and nothing else — so builder.validate()
532
748
  // stays a THROWING gate (a typo'd identifier fails the compose instead of
533
749
  // silently resolving to a permissive synthetic).
@@ -540,7 +756,7 @@ const STUDIO_LIBRARY_TS = `// Build the composer's ComponentLibrary: Studio's RE
540
756
  import { callableDefault } from "@norskvideo/ctl-sdk";
541
757
  import { type ComponentStubSpec, stubbedLibrary } from "@norskvideo/ctl-sdk/workflow";
542
758
  import { RegistrationConsts } from "@norskvideo/norsk-studio/lib/extension/client-types";
543
- import type { ComponentLibrary, NodeInfoForBuilder } from "@norskvideo/norsk-studio-builder";
759
+ __OPTIONAL_IMPORTS__import type { ComponentLibrary, NodeInfoForBuilder } from "@norskvideo/norsk-studio-builder";
544
760
  import getNodeInfoImport from "@norskvideo/norsk-studio-built-ins/lib/info";
545
761
 
546
762
  // TS-compiled-to-CJS with __esModule + exports.default; how that surfaces to
@@ -550,22 +766,53 @@ const getNodeInfo = callableDefault<(consts: unknown, identifier: string) => unk
550
766
  getNodeInfoImport,
551
767
  "@norskvideo/norsk-studio-built-ins/lib/info",
552
768
  );
553
-
554
- // Components not in the npm library (this product's own, or alpha/custom nodes
555
- // that live only in the studio image) are declared here as metadata-only stubs
556
- // — precise media contracts, no browser code, no _gen/types needed. See
557
- // ComponentStubSpec in @norskvideo/ctl-sdk/workflow.
558
- export const COMPONENT_STUBS: ComponentStubSpec[] = [];
769
+ __OPTIONAL_LOOKUPS__
770
+ // Components no npm library provides (this product's own) are declared here as
771
+ // metadata-only stubs — precise media contracts, no browser code, no _gen/types
772
+ // needed. Studio's alpha/beta components come from --studio-libs instead, so
773
+ // their configs stay typed. See ComponentStubSpec in @norskvideo/ctl-sdk/workflow.
774
+ export const COMPONENT_STUBS: ComponentStubSpec[] = [__STUBS__];
775
+
776
+ // Each library's aggregator returns undefined for an identifier it does not own.
777
+ const LOOKUPS = [__LOOKUPS__];
778
+
779
+ function findNodeInfo(identifier: string): NodeInfoForBuilder | undefined {
780
+ for (const lookup of LOOKUPS) {
781
+ const info = lookup(RegistrationConsts, identifier);
782
+ if (info !== undefined) return info as NodeInfoForBuilder;
783
+ }
784
+ return undefined;
785
+ }
559
786
 
560
787
  export async function buildStudioLibrary(): Promise<ComponentLibrary> {
561
- return stubbedLibrary<NodeInfoForBuilder>(
562
- (identifier) => getNodeInfo(RegistrationConsts, identifier) as NodeInfoForBuilder | undefined,
563
- COMPONENT_STUBS,
564
- );
788
+ return stubbedLibrary<NodeInfoForBuilder>(findNodeInfo, COMPONENT_STUBS);
565
789
  }
566
790
  `;
567
791
 
568
- const COMPOSE_WORKFLOW_TS = `// The production composer: source-first, left-to-right, every connect narrowed
792
+ function studioLibraryTs(ctx: ShapeContext): string {
793
+ // The placeholder component is this product's own, so the builder's library
794
+ // (Studio's npm NodeInfos) cannot know it. Without the stub the composer's
795
+ // THROWING validate() rejects the starter graph — the trap that tempts people
796
+ // into demoting validate() to a warning.
797
+ const stubs = ctx.features.has("components")
798
+ ? `\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`
799
+ : "";
800
+ const libs = ctx.studioLibs;
801
+ const lookupName = (lib: string) => `get${lib[0]?.toUpperCase()}${lib.slice(1)}NodeInfo`;
802
+ const imports = libs.map((lib) => `import ${lib}InfoImport from "${studioPackage(lib)}/lib/info";\n`).join("");
803
+ const lookups = libs
804
+ .map(
805
+ (lib) =>
806
+ `const ${lookupName(lib)} = callableDefault<(consts: unknown, identifier: string) => unknown>(\n ${lib}InfoImport,\n "${studioPackage(lib)}/lib/info",\n);\n`,
807
+ )
808
+ .join("");
809
+ return STUDIO_LIBRARY_TS_TEMPLATE.replace("__STUBS__", stubs)
810
+ .replace("__OPTIONAL_IMPORTS__", imports)
811
+ .replace("__OPTIONAL_LOOKUPS__", lookups)
812
+ .replace("__LOOKUPS__", ["getNodeInfo", ...libs.map(lookupName)].join(", "));
813
+ }
814
+
815
+ const COMPOSE_WORKFLOW_TS_TEMPLATE = `// The production composer: source-first, left-to-right, every connect narrowed
569
816
  // through a pick* helper so filters resolve against real availableStreams().
570
817
  // addComponent keeps the factories' typing at the addNode boundary — no
571
818
  // \`config as never\`.
@@ -573,7 +820,7 @@ import { addComponent, toWorkflowDoc, type WorkflowDoc } from "@norskvideo/ctl-s
573
820
  import { autoLayout, type ComponentLibrary, pickAll, WorkflowBuilder } from "@norskvideo/norsk-studio-builder";
574
821
  import type { ProductConfig } from "../schemas/config.ts";
575
822
  import { egestSrtCaller, ingestSrtCaller, ingestSrtListener, preview } from "./components.ts";
576
- import { EGEST_ID, INGEST_ID, PREVIEW_ID } from "./ids.ts";
823
+ __EXAMPLE_IMPORTS__import { EGEST_ID, INGEST_ID, PREVIEW_ID } from "./ids.ts";
577
824
 
578
825
  export function composeWorkflow(config: ProductConfig, library: ComponentLibrary): WorkflowDoc {
579
826
  const builder = new WorkflowBuilder(library);
@@ -610,7 +857,7 @@ export function composeWorkflow(config: ProductConfig, library: ComponentLibrary
610
857
 
611
858
  pickAll(builder.connect(ingest, egest), ["video", "audio"]);
612
859
  pickAll(builder.connect(ingest, monitor), ["video"]);
613
-
860
+ __EXAMPLE_NODE__
614
861
  // Layer 2 is a THROWING gate (INV-COMPOSE-001). Never demote this to a
615
862
  // warning — declare out-of-library components in COMPONENT_STUBS instead.
616
863
  const issues = builder.validate();
@@ -625,6 +872,27 @@ export function composeWorkflow(config: ProductConfig, library: ComponentLibrary
625
872
  }
626
873
  `;
627
874
 
875
+ function composeWorkflowTs(ctx: ShapeContext): string {
876
+ if (!ctx.features.has("components")) {
877
+ return COMPOSE_WORKFLOW_TS_TEMPLATE.replace("__EXAMPLE_IMPORTS__", "").replace("__EXAMPLE_NODE__", "");
878
+ }
879
+ return COMPOSE_WORKFLOW_TS_TEMPLATE.replace(
880
+ "__EXAMPLE_IMPORTS__",
881
+ 'import { exampleSidecar } from "./example-config.ts";\n',
882
+ )
883
+ .replace(
884
+ "__EXAMPLE_NODE__",
885
+ `
886
+ // The placeholder component, wired in so a freshly generated product proves
887
+ // the whole custom-node pipeline on its first launch: registered, in the
888
+ // toolbox, publishing state. It carries no media (subscription: {}), so it
889
+ // connects to nothing and perturbs no edge. Delete it with the component.
890
+ addComponent(builder, exampleSidecar({ id: EXAMPLE_ID, displayName: "Example" }));
891
+ `,
892
+ )
893
+ .replace("import { EGEST_ID, INGEST_ID, PREVIEW_ID }", "import { EGEST_ID, EXAMPLE_ID, INGEST_ID, PREVIEW_ID }");
894
+ }
895
+
628
896
  const COMPOSE_WORKFLOW_TEST_TS = `// Layer 3: the INVARIANTS.md rules table in executable form. Each rule cites
629
897
  // its INV-* ID verbatim; tests/unit/invariants-parity.test.ts fails the suite
630
898
  // if table and tests drift apart. The matrix exercises every composer branch.
@@ -700,11 +968,16 @@ test("INV-COMPOSE-001: an identifier that is neither real nor stubbed fails the
700
968
  });
701
969
  `;
702
970
 
703
- const WORKFLOW_INDEX_TS = `export * from "./components.ts";
704
- export * from "./compose-workflow.ts";
705
- export * from "./ids.ts";
706
- export * from "./studio-library.ts";
707
- `;
971
+ function workflowIndexTs(ctx: ShapeContext): string {
972
+ return [
973
+ 'export * from "./components.ts";',
974
+ 'export * from "./compose-workflow.ts";',
975
+ ...(ctx.features.has("components") ? ['export * from "./example-config.ts";'] : []),
976
+ 'export * from "./ids.ts";',
977
+ 'export * from "./studio-library.ts";',
978
+ "",
979
+ ].join("\n");
980
+ }
708
981
 
709
982
  const SHARED_INDEX_TS = `export * from "./manifest.ts";
710
983
  export * from "./product-template.ts";
@@ -713,7 +986,77 @@ export * from "./version.ts";
713
986
  export * from "./workflow/index.ts";
714
987
  `;
715
988
 
989
+ // Biome collapses an array onto one line whenever it fits the 120-column width
990
+ // and breaks it one element per line otherwise; objects keep the expansion they
991
+ // are written with. An emitted array whose length depends on the product name
992
+ // has to be laid out by the same rule, or the generated repo fails lint.
993
+ function biomeStringArray(prefix: string, suffix: string, items: string[]): string {
994
+ const quoted = items.map((item) => `"${item}"`);
995
+ const oneLine = `${prefix}[${quoted.join(", ")}]${suffix}`;
996
+ if (oneLine.length <= 120) return oneLine;
997
+ const indent = `${prefix.match(/^ */)?.[0] ?? ""} `;
998
+ return `${prefix}[\n${quoted.map((q) => `${indent}${q},`).join("\n")}\n${indent.slice(2)}]${suffix}`;
999
+ }
1000
+
716
1001
  function productTemplateTs(ctx: ShapeContext): string {
1002
+ const hasComponents = ctx.features.has("components");
1003
+ // The shared module stays I/O-free: callers (the backend route, tests) read
1004
+ // from disk and hand the bytes in.
1005
+ const optsFields = [
1006
+ ...(hasComponents
1007
+ ? [
1008
+ ` // Pre-read component files (components/lib, compiled).
1009
+ components?: Array<{ path: string; content: string | Uint8Array<ArrayBuffer> }>;`,
1010
+ ]
1011
+ : []),
1012
+ ...(ctx.features.has("dashboard")
1013
+ ? [
1014
+ ` // Pre-read dashboard files, keyed by workflow name (dashboards/, built).
1015
+ dashboards?: Array<{ path: string; content: string | Uint8Array<ArrayBuffer> }>;`,
1016
+ ]
1017
+ : []),
1018
+ ]
1019
+ .map((field) => `${field}\n`)
1020
+ .join("");
1021
+ const materialComponents = hasComponents ? "opts.components ?? []" : "[]";
1022
+ const materialDashboards = ctx.features.has("dashboard") ? "opts.dashboards ?? []" : "[]";
1023
+ const studioLibraries = biomeStringArray(" library: ", ",", [
1024
+ "@norskvideo/norsk-studio-built-ins",
1025
+ ...ctx.studioLibs.map(studioPackage),
1026
+ ...(hasComponents ? [libraryPackage(ctx)] : []),
1027
+ ]);
1028
+ // Where the operator looks. Each feature answers it differently, and the
1029
+ // proxy entry it needs comes with it — a screen exposed on the wrong path
1030
+ // works on the direct port and 404s through the proxy.
1031
+ const screen = ctx.features.has("views")
1032
+ ? VIEWS_RUNTIME_SCREEN
1033
+ : ctx.features.has("dashboard")
1034
+ ? DASHBOARD_RUNTIME_SCREEN
1035
+ : undefined;
1036
+ const screenWhy = ctx.features.has("views")
1037
+ ? ` // The whole per-instance surface is Studio's: ctl anchors
1038
+ // studio_url_prefix on its own /studio/ route, so the component's
1039
+ // fullscreen page resolves under it.`
1040
+ : ` // Studio's per-workflow mount serves the dashboards/ directory the
1041
+ // template packs. The path segment IS the workflow name — the tar ships
1042
+ // workflow.yml at its root, so it is "workflow".`;
1043
+ const runtimeScreen = screen
1044
+ ? ` ui: {
1045
+ ${screenWhy}
1046
+ runtimeScreenUrl: "${screen.url}",
1047
+ runtimeScreenLabel: "${screen.label}",
1048
+ },\n`
1049
+ : "";
1050
+ const proxyExpose = [
1051
+ '"/"',
1052
+ '"/static/*"',
1053
+ '"/api/*"',
1054
+ ...(ctx.features.has("views") ? [`"${VIEWS_PROXY_EXPOSE}"`] : []),
1055
+ ...(ctx.features.has("dashboard") ? [`"${DASHBOARD_PROXY_EXPOSE}"`] : []),
1056
+ '"/live/api/*"',
1057
+ '"/ws"',
1058
+ '"/ws/*"',
1059
+ ].join(", ");
717
1060
  return `// Product-template materials: the manifest/compose/parameters/workflow set the
718
1061
  // runner stores at \`product add\`. Packing to tar is single-sourced in
719
1062
  // @norskvideo/ctl-product-template-schema/pack — this repo carries no copy of
@@ -739,16 +1082,14 @@ export type BuildOpts = {
739
1082
  // Create-time custom defaults from the build form's disclosure, merged
740
1083
  // per-knob over STANDARD_ADVANCED_OVERRIDES into manifest.advanced.
741
1084
  advanced?: ProductTemplateManifest["advanced"];
742
- };
1085
+ ${optsFields}};
743
1086
 
744
1087
  // The build form rides its Custom defaults as a reserved $advancedDefaults
745
1088
  // sibling of the config keys; the config schema is strict, so split it off
746
1089
  // before the parse. Absent key = plain config passthrough.
747
1090
  export function splitAdvancedDefaults(
748
1091
  body: unknown,
749
- ):
750
- | { ok: true; config: unknown; advanced?: ProductTemplateManifest["advanced"] }
751
- | { ok: false; issues: unknown[] } {
1092
+ ): { ok: true; config: unknown; advanced?: ProductTemplateManifest["advanced"] } | { ok: false; issues: unknown[] } {
752
1093
  if (typeof body !== "object" || body === null || !("$advancedDefaults" in body)) return { ok: true, config: body };
753
1094
  const { $advancedDefaults: raw, ...config } = body as Record<string, unknown>;
754
1095
  const parsed = ProductTemplateAdvancedSchema.safeParse(raw);
@@ -771,10 +1112,10 @@ export function buildProductTemplateMaterials(config: ProductConfig, opts: Build
771
1112
  productVersion: PRODUCT_VERSION,
772
1113
  target: "docker-compose",
773
1114
  generatedAt: generatedAt.toISOString(),
774
- proxy: {
1115
+ ${runtimeScreen} proxy: {
775
1116
  // Permissive starter set: exposes Studio's own UI/API through the oauth2
776
1117
  // proxy. Narrow as the product grows a surface of its own.
777
- expose: ["/", "/static/*", "/api/*", "/live/api/*", "/ws", "/ws/*"],
1118
+ expose: [${proxyExpose}],
778
1119
  },
779
1120
  advanced: { ...STANDARD_ADVANCED_OVERRIDES, ...opts.advanced },
780
1121
  };
@@ -784,8 +1125,8 @@ export function buildProductTemplateMaterials(config: ProductConfig, opts: Build
784
1125
  composeYaml: renderCompose(config),
785
1126
  parameters: { parameters: renderParameters(config) },
786
1127
  workflow: yamlStringify(composeWorkflow(config, opts.library), { aliasDuplicateObjects: false }),
787
- components: [],
788
- dashboards: [],
1128
+ components: ${materialComponents},
1129
+ dashboards: ${materialDashboards},
789
1130
  assets: [],
790
1131
  workdirSeed: [],
791
1132
  };
@@ -801,7 +1142,14 @@ export function renderCompose(config: ProductConfig): string {
801
1142
  // without it Studio falls back to its baked default and the instance would
802
1143
  // demand a Studio entitlement rather than its own.
803
1144
  NORSK_PRODUCT_NAME: PRODUCT_NAME,
804
- NODE_CONFIG: JSON.stringify({ server: { library: ["@norskvideo/norsk-studio-built-ins"] } }),
1145
+ // The product's list OVERRIDES the daemon's seeded default.yaml rather than
1146
+ // merging with it, so it has to name EVERY library it wants loaded — a
1147
+ // component mounted but absent from here is never loaded, with no error.
1148
+ NODE_CONFIG: JSON.stringify({
1149
+ server: {
1150
+ ${studioLibraries}
1151
+ },
1152
+ }),
805
1153
  };
806
1154
 
807
1155
  // biome-ignore lint/suspicious/noTemplateCurlyInString: docker-compose variable reference, not a TS template literal
@@ -968,15 +1316,23 @@ console.log(\`${ctx.name} listening on :\${port}\`);
968
1316
  }
969
1317
 
970
1318
  function backendServerTs(ctx: ShapeContext): string {
1319
+ const hasFrontend = ctx.features.has("frontend");
1320
+ const frontendImports = hasFrontend ? 'import path from "node:path";\n' : "";
1321
+ const frontendDist = hasFrontend
1322
+ ? '\nconst FRONTEND_DIST = path.resolve(import.meta.dir, "../../frontend/dist");\n'
1323
+ : "";
1324
+ // Carries its own LEADING newline and no trailing one, so the absent case
1325
+ // leaves exactly one blank line rather than two.
1326
+ const frontendBlock = hasFrontend ? `\n${FRONTEND_SERVER_BLOCK}\n` : "";
971
1327
  return `import type { Server } from "node:http";
972
- import { buildStudioLibrary } from "${ctx.scope}/shared/workflow";
1328
+ ${frontendImports}import { buildStudioLibrary } from "${ctx.scope}/shared/workflow";
973
1329
  import { docsBundleDir, serveDocs } from "@norskvideo/ctl-sdk";
974
1330
  import cors from "cors";
975
1331
  import express from "express";
976
1332
  import { manifestRouter } from "./routes/manifest.ts";
977
1333
  import { openapiRouter } from "./routes/openapi.ts";
978
1334
  import { makeProductTemplateRouter } from "./routes/product-template.ts";
979
-
1335
+ ${frontendDist}
980
1336
  export async function startServer(port: number): Promise<Server> {
981
1337
  // Built once at startup: Studio's real NodeInfos + this product's declared
982
1338
  // stubs. Passed into the product-template route so every build reuses it.
@@ -996,7 +1352,7 @@ export async function startServer(port: number): Promise<Server> {
996
1352
  // The docs bundle (\`bun run docs:manual\`, baked into the image as docs/),
997
1353
  // served at /docs; a no-op until it has been built.
998
1354
  serveDocs(app, docsBundleDir(import.meta.dir));
999
-
1355
+ ${frontendBlock}
1000
1356
  return await new Promise<Server>((resolve, reject) => {
1001
1357
  const server = app.listen(port, () => resolve(server));
1002
1358
  server.on("error", (err) => reject(err));
@@ -1112,13 +1468,35 @@ export const openapiRouter = createOpenapiRouter(buildOpenApiYaml);
1112
1468
  }
1113
1469
 
1114
1470
  function routesProductTemplateTs(ctx: ShapeContext): string {
1115
- return `import { type BuildOpts, buildProductTemplateMaterials, splitAdvancedDefaults } from "${ctx.scope}/shared/product-template";
1471
+ const hasComponents = ctx.features.has("components");
1472
+ const hasDashboard = ctx.features.has("dashboard");
1473
+ // Each entry carries its own newline: the interpolation sits directly against
1474
+ // the blank line before the next block, so an empty list must add nothing.
1475
+ const loaderImports = [
1476
+ ...(hasComponents ? ['import { loadCompiledComponents } from "../lib/components.ts";'] : []),
1477
+ ...(hasDashboard ? ['import { loadDashboardFiles } from "../lib/dashboards.ts";'] : []),
1478
+ ]
1479
+ .map((line) => `${line}\n`)
1480
+ .join("");
1481
+ const buildOpts = [
1482
+ "library",
1483
+ "advanced",
1484
+ ...(hasComponents ? ["components: loadCompiledComponents()"] : []),
1485
+ ...(hasDashboard ? ["dashboards: loadDashboardFiles()"] : []),
1486
+ ]
1487
+ .map((entry) => ` ${entry},`)
1488
+ .join("\n");
1489
+ return `import {
1490
+ type BuildOpts,
1491
+ buildProductTemplateMaterials,
1492
+ splitAdvancedDefaults,
1493
+ } from "${ctx.scope}/shared/product-template";
1116
1494
  import { ProductConfigSchema } from "${ctx.scope}/shared/schemas";
1117
1495
  import { packProductTemplate } from "@norskvideo/ctl-product-template-schema/pack";
1118
1496
  import type { ComponentLibrary } from "@norskvideo/norsk-studio-builder";
1119
1497
  import { Router } from "express";
1120
1498
  import defaultExample from "../../../examples/default/input.json" with { type: "json" };
1121
-
1499
+ ${loaderImports}
1122
1500
  // Starter configs, mirrored in manifest.ts's defaultProductTemplates[]. JSON
1123
1501
  // imported at module load so the bundler inlines it — the deployed container
1124
1502
  // doesn't ship the examples/ source tree. Keep the two in lockstep.
@@ -1141,7 +1519,9 @@ export function makeProductTemplateRouter(library: ComponentLibrary): Router {
1141
1519
  return;
1142
1520
  }
1143
1521
  try {
1144
- const materials = buildProductTemplateMaterials(parsed.data, { library, advanced });
1522
+ const materials = buildProductTemplateMaterials(parsed.data, {
1523
+ ${buildOpts}
1524
+ });
1145
1525
  const tar = packProductTemplate(materials);
1146
1526
  res
1147
1527
  .status(200)
@@ -1469,21 +1849,49 @@ function exampleInputJson(_ctx: ShapeContext): string {
1469
1849
  // path-existence lint would look for them in the repo.
1470
1850
 
1471
1851
  function readmeMd(ctx: ShapeContext): string {
1852
+ const optional = [
1853
+ ...(ctx.features.has("components")
1854
+ ? ["- `components/` — this product's own Studio nodes, packed into the product-template tar."]
1855
+ : []),
1856
+ ...(ctx.features.has("dashboard")
1857
+ ? ["- `dashboard/` — the per-instance operator SPA; builds into `dashboards/`, which Studio serves."]
1858
+ : []),
1859
+ ...(ctx.features.has("frontend")
1860
+ ? ["- `frontend/` — the configure screen the backend serves at /configure and norsk-ctl iframes."]
1861
+ : []),
1862
+ ]
1863
+ .map((line) => `${line}\n`)
1864
+ .join("");
1865
+ const screen = ctx.features.has("views")
1866
+ ? " The operator screen is the component's own fullscreen view, inside Studio."
1867
+ : ctx.features.has("dashboard")
1868
+ ? " The operator screen is a dashboard Studio serves per instance."
1869
+ : "";
1472
1870
  return `# ${ctx.name}
1473
1871
 
1474
- A Norsk product for norsk-ctl: a backend-only turnkey. The control plane is a
1475
- small service the daemon runs (the manifest, the product-template build, the
1476
- documentation); the media path is a Norsk Studio workflow the product composes.
1872
+ A Norsk product for norsk-ctl. The control plane is a small service the daemon
1873
+ runs (the manifest, the product-template build, the documentation); the media
1874
+ path is a Norsk Studio workflow the product composes.${screen}
1477
1875
 
1478
1876
  ## Layout
1479
1877
 
1480
1878
  - \`shared/\` — the product's config schema, workflow composer and manifest.
1481
- - \`backend/\` — the control-plane service (manifest, product-template build, docs).
1879
+ ${optional}- \`backend/\` — the control-plane service (manifest, product-template build, docs).
1482
1880
  - \`examples/\` — starter configs; \`examples/default/input.json\` is the default template.
1483
1881
  - \`tests/\` — unit, image and demo tiers; \`tests/demo.spec.ts\` is the customer journey.
1484
1882
  - \`docs/\` — reader-facing documents; \`docs/README.md\` is the index.
1485
1883
  - \`deployment/\` — the image build wrapper and the iterate loop.
1486
1884
 
1885
+ ## First run
1886
+
1887
+ 1. \`nix develop\` — the dev shell: pinned bun, biome and norsk-ctl.
1888
+ 2. \`git init && bun install\`
1889
+ 3. \`UPDATE_SNAPSHOTS=1 bun run test:unit\` — bake the byte-snapshot fixtures, then commit them.
1890
+ 4. \`bun run check:drift && bun run docs:check && bun run test:unit && bun run typecheck && bun run lint\`
1891
+ 5. Replace the starter graph in \`shared/src/workflow/\` with the real one,
1892
+ keeping \`INVARIANTS.md\` and its rules tests in step.
1893
+ 6. \`bun run docs:handover\` — once a daemon has this product added.
1894
+
1487
1895
  ## Dev loop
1488
1896
 
1489
1897
  Everything runs inside the dev shell (\`nix develop .#dev\`), which carries bun,
@@ -1598,12 +2006,26 @@ cd "$(dirname "$0")/../.."
1598
2006
  bun run docs:manual
1599
2007
  `;
1600
2008
 
1601
- export const backendTurnkey: ShapeModule = {
2009
+ // The drift-gated core block covers node_modules/, dist/, generated, logs/ and
2010
+ // friends; everything a FEATURE's workspace builds has to be named here.
2011
+ function gitignoreTail(ctx: ShapeContext): string {
2012
+ const blocks = ["# Repo-specific entries go below (the block above is drift-gated verbatim).\n"];
2013
+ if (ctx.features.has("components")) blocks.push(COMPONENTS_GITIGNORE);
2014
+ if (ctx.features.has("dashboard")) blocks.push(DASHBOARD_GITIGNORE);
2015
+ return blocks.join("\n");
2016
+ }
2017
+
2018
+ export const turnkey: ShapeModule = {
1602
2019
  claudeHead,
1603
2020
  claudeTail,
2021
+ gitignoreTail,
1604
2022
  rootTsconfigInclude: ["tests/**/*"],
1605
2023
  files(ctx: ShapeContext): GeneratedFile[] {
1606
2024
  return [
2025
+ ...(ctx.features.has("components") ? componentsFiles(ctx) : []),
2026
+ ...(ctx.features.has("views") ? viewsFiles(ctx) : []),
2027
+ ...(ctx.features.has("dashboard") ? dashboardFiles(ctx) : []),
2028
+ ...(ctx.features.has("frontend") ? frontendFiles(ctx) : []),
1607
2029
  { path: "package.json", content: rootPackageJson(ctx) },
1608
2030
  { path: "INVARIANTS.md", content: invariantsMd(ctx) },
1609
2031
  { path: ".dockerignore", content: DOCKERIGNORE },
@@ -1625,18 +2047,18 @@ export const backendTurnkey: ShapeModule = {
1625
2047
  { path: "shared/src/index.ts", content: SHARED_INDEX_TS },
1626
2048
  { path: "shared/src/version.ts", content: versionTs(ctx) },
1627
2049
  { path: "shared/src/version.test.ts", content: versionTestTs(ctx) },
1628
- { path: "shared/src/manifest.ts", content: MANIFEST_TS },
1629
- { path: "shared/src/manifest.test.ts", content: MANIFEST_TEST_TS },
2050
+ { path: "shared/src/manifest.ts", content: manifestTs(ctx) },
2051
+ { path: "shared/src/manifest.test.ts", content: manifestTestTs(ctx) },
1630
2052
  { path: "shared/src/schemas/index.ts", content: SCHEMAS_INDEX_TS },
1631
2053
  { path: "shared/src/schemas/config.ts", content: CONFIG_TS },
1632
2054
  { path: "shared/src/schemas/config.test.ts", content: CONFIG_TEST_TS },
1633
2055
  { path: "shared/src/product-template.ts", content: productTemplateTs(ctx) },
1634
2056
  { path: "shared/src/product-template.test.ts", content: PRODUCT_TEMPLATE_TEST_TS },
1635
- { path: "shared/src/workflow/index.ts", content: WORKFLOW_INDEX_TS },
1636
- { path: "shared/src/workflow/ids.ts", content: IDS_TS },
2057
+ { path: "shared/src/workflow/index.ts", content: workflowIndexTs(ctx) },
2058
+ { path: "shared/src/workflow/ids.ts", content: idsTs(ctx) },
1637
2059
  { path: "shared/src/workflow/components.ts", content: COMPONENTS_TS },
1638
- { path: "shared/src/workflow/studio-library.ts", content: STUDIO_LIBRARY_TS },
1639
- { path: "shared/src/workflow/compose-workflow.ts", content: COMPOSE_WORKFLOW_TS },
2060
+ { path: "shared/src/workflow/studio-library.ts", content: studioLibraryTs(ctx) },
2061
+ { path: "shared/src/workflow/compose-workflow.ts", content: composeWorkflowTs(ctx) },
1640
2062
  { path: "shared/src/workflow/compose-workflow.test.ts", content: COMPOSE_WORKFLOW_TEST_TS },
1641
2063
  { path: "backend/package.json", content: backendPackageJson(ctx) },
1642
2064
  { path: "backend/tsconfig.json", content: WORKSPACE_TSCONFIG },