@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.
- package/build/flake.lock +18 -1
- package/build/flake.nix +5 -5
- package/create-product/cli.ts +61 -21
- package/create-product/create-product.ts +103 -18
- package/create-product/features/components.ts +632 -0
- package/create-product/features/dashboard.ts +270 -0
- package/create-product/features/frontend.ts +176 -0
- package/create-product/features/views.ts +125 -0
- package/create-product/{backend-turnkey.ts → turnkey.ts} +504 -82
- package/package.json +1 -1
|
@@ -1,26 +1,154 @@
|
|
|
1
|
-
// The
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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
|
|
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
|
-
(
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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
|
|
51
|
-
2. \`
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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: [
|
|
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":
|
|
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
|
-
|
|
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":
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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: [
|
|
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: {
|
|
538
|
+
ui: { __CONFIG_SCREEN__sidebarEntries: [{ label: "Documentation", route: "/docs/" }] },
|
|
363
539
|
};
|
|
364
540
|
return ManifestSchema.parse(manifest);
|
|
365
541
|
}
|
|
366
542
|
`;
|
|
367
543
|
|
|
368
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
555
|
-
//
|
|
556
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
704
|
-
|
|
705
|
-
export * from "./
|
|
706
|
-
export * from "./
|
|
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: [
|
|
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
|
-
|
|
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
|
-
|
|
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, {
|
|
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
|
|
1475
|
-
|
|
1476
|
-
|
|
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
|
-
|
|
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:
|
|
1629
|
-
{ path: "shared/src/manifest.test.ts", content:
|
|
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:
|
|
1636
|
-
{ path: "shared/src/workflow/ids.ts", content:
|
|
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:
|
|
1639
|
-
{ path: "shared/src/workflow/compose-workflow.ts", content:
|
|
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 },
|