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