@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.
- 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 +97 -0
- package/create-product/{backend-turnkey.ts → turnkey.ts} +501 -82
- package/package.json +1 -1
|
@@ -1,26 +1,151 @@
|
|
|
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
|
+
\`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
|
-
(
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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
|
|
51
|
-
2. \`
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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: [
|
|
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":
|
|
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
|
-
|
|
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":
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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: [
|
|
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: {
|
|
535
|
+
ui: { __CONFIG_SCREEN__sidebarEntries: [{ label: "Documentation", route: "/docs/" }] },
|
|
363
536
|
};
|
|
364
537
|
return ManifestSchema.parse(manifest);
|
|
365
538
|
}
|
|
366
539
|
`;
|
|
367
540
|
|
|
368
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
555
|
-
//
|
|
556
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
704
|
-
|
|
705
|
-
export * from "./
|
|
706
|
-
export * from "./
|
|
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: [
|
|
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
|
-
|
|
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
|
-
|
|
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, {
|
|
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
|
|
1475
|
-
|
|
1476
|
-
|
|
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
|
-
|
|
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:
|
|
1629
|
-
{ path: "shared/src/manifest.test.ts", content:
|
|
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:
|
|
1636
|
-
{ path: "shared/src/workflow/ids.ts", content:
|
|
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:
|
|
1639
|
-
{ path: "shared/src/workflow/compose-workflow.ts", content:
|
|
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 },
|