@norskvideo/ctl-dev-kit 0.1.97 → 0.1.98

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