@norskvideo/ctl-dev-kit 0.1.51 → 0.1.53
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/conventions/CLAUDE.core.md +16 -1
- package/conventions/check-drift.ts +56 -2
- package/conventions/demo.sh +29 -0
- package/conventions/sync-drift.ts +17 -2
- package/create-product/backend-turnkey.ts +103 -31
- package/create-product/canon.ts +3 -0
- package/local-dev/README.md +15 -6
- package/package.json +1 -1
- package/testing/INVARIANTS.customer.template.md +65 -0
|
@@ -84,7 +84,22 @@
|
|
|
84
84
|
the doc lives and whether rules sit in one sweep suite
|
|
85
85
|
(`norsk-ctl-product-playout`, `-commentary`) or beside the code they guard
|
|
86
86
|
(`-probe`, `-turnkey-funke-pegasus`) is the repo's choice — the contract is
|
|
87
|
-
the IDs and the guard, not the layout.
|
|
87
|
+
the IDs and the guard, not the layout. A **customer-specific** workflow (a
|
|
88
|
+
reconciled `input.json` under `customers/<slug>/`) carries its **own**
|
|
89
|
+
`customers/<slug>/INVARIANTS.md`, scoped to that customer, seeded from
|
|
90
|
+
`testing/INVARIANTS.customer.template.md` and guarded by a parity test whose
|
|
91
|
+
`testDirs` is scoped to that customer's tests; assert against the composed
|
|
92
|
+
graph so resolved defaults (segment durations, resolutions, output count) are
|
|
93
|
+
guarded, not just literal config.
|
|
94
|
+
- **Invariants are a developer-managed contract — never edit them autonomously.**
|
|
95
|
+
An `INVARIANTS.md` (product or customer) records what a human, or a paying
|
|
96
|
+
customer, has decided must not regress. Claude / automated agents MUST NOT add,
|
|
97
|
+
edit, remove, or renumber invariant rows — or weaken the tests that guard them
|
|
98
|
+
— on their own initiative; those changes come only through deliberate
|
|
99
|
+
interaction with a developer. If a code change appears to conflict with an
|
|
100
|
+
invariant, STOP and surface it to a developer rather than "fixing" the
|
|
101
|
+
invariant to make a test pass. This is precisely what protects against
|
|
102
|
+
accidental regressions to the things that matter most.
|
|
88
103
|
- **Know who reads it — name the persona before writing docs or a
|
|
89
104
|
persona-facing surface.** Every Norsk ctl product writes for the same four
|
|
90
105
|
reader personas — Evaluator, Builder, Integrator, Operator (SRE) — defined in
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// shared conventions have diverged from this dev-kit's canonical source. Each
|
|
3
3
|
// gated file uses the loosest mechanism that still pins what is shared:
|
|
4
4
|
// - byte-verbatim: CLAUDE.md fenced core, flake.nix, biome.json,
|
|
5
|
-
// tsconfig.base.json, the .gitignore core block
|
|
5
|
+
// tsconfig.base.json, the .gitignore core block, scripts/demo (only a
|
|
6
|
+
// product with tests/demo.spec.ts is asked for it)
|
|
6
7
|
// - masked per-repo line: checks.yml + upgrade-latest.yml (`product:` key)
|
|
7
8
|
// - base + sanctioned extension block: dprint.json (repo-specific excludes)
|
|
8
9
|
// - structural: root tsconfig.json (shape-dependent includes stay free),
|
|
@@ -16,7 +17,7 @@
|
|
|
16
17
|
// dev-kit forces a re-sync. Canonical bytes ship alongside this script
|
|
17
18
|
// (conventions/* + build/*), resolved package-relative so they work both
|
|
18
19
|
// workspace-symlinked and installed from the published tarball.
|
|
19
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
20
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
20
21
|
import { join } from "node:path";
|
|
21
22
|
import { parseManifestSeed } from "@norskvideo/ctl-sdk/manifest-seed";
|
|
22
23
|
|
|
@@ -245,6 +246,56 @@ export interface CanonicalBytes {
|
|
|
245
246
|
gitignoreCore: string;
|
|
246
247
|
buildImage: string;
|
|
247
248
|
smoke: string;
|
|
249
|
+
/** conventions/demo.sh — the scripts/demo shim a product with tests/demo.spec.ts carries. */
|
|
250
|
+
demoShim: string;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// 05-demo s6: a product that has a demo (tests/demo.spec.ts) exposes it the
|
|
254
|
+
// shared way — scripts/demo, the verbatim executable shim that re-execs under
|
|
255
|
+
// the nix dev shell and delegates to `bun run demo`, and the package.json
|
|
256
|
+
// "demo" script that resolves to the harness's ctl-demo bin. A product without
|
|
257
|
+
// a demo is asked for neither; a shim left behind is still a copy and stays
|
|
258
|
+
// verbatim.
|
|
259
|
+
const DEMO_SPEC = "tests/demo.spec.ts";
|
|
260
|
+
const DEMO_SHIM = "scripts/demo";
|
|
261
|
+
const DEMO_SCRIPT = "ctl-demo";
|
|
262
|
+
|
|
263
|
+
export function demoProblems(repoRoot: string, canonicalShim: string): string[] {
|
|
264
|
+
const problems: string[] = [];
|
|
265
|
+
const hasSpec = existsSync(join(repoRoot, DEMO_SPEC));
|
|
266
|
+
const shimPath = join(repoRoot, DEMO_SHIM);
|
|
267
|
+
if (existsSync(shimPath)) {
|
|
268
|
+
const actual = readFileSync(shimPath, "utf8");
|
|
269
|
+
if (actual !== canonicalShim) {
|
|
270
|
+
problems.push(
|
|
271
|
+
`${DEMO_SHIM} has drifted from @norskvideo/ctl-dev-kit conventions/demo.sh (${firstDiffLine(actual, canonicalShim)}). ${RESYNC}`,
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
if ((statSync(shimPath).mode & 0o111) === 0) {
|
|
275
|
+
problems.push(`${DEMO_SHIM} is not executable (chmod +x) — it is the entry a developer runs.`);
|
|
276
|
+
}
|
|
277
|
+
} else if (hasSpec) {
|
|
278
|
+
problems.push(
|
|
279
|
+
`${DEMO_SHIM} not found but ${DEMO_SPEC} exists. A product with a demo carries the shim verbatim from @norskvideo/ctl-dev-kit conventions/demo.sh (sync-drift writes it). ${RESYNC}`,
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
if (hasSpec) {
|
|
283
|
+
const pkgPath = join(repoRoot, "package.json");
|
|
284
|
+
let scripts: Record<string, string> | undefined;
|
|
285
|
+
try {
|
|
286
|
+
scripts = existsSync(pkgPath)
|
|
287
|
+
? (JSON.parse(readFileSync(pkgPath, "utf8")) as { scripts?: Record<string, string> }).scripts
|
|
288
|
+
: undefined;
|
|
289
|
+
} catch (e) {
|
|
290
|
+
problems.push(`package.json is not parseable JSON (${e instanceof Error ? e.message : String(e)}).`);
|
|
291
|
+
}
|
|
292
|
+
if (scripts?.demo !== DEMO_SCRIPT) {
|
|
293
|
+
problems.push(
|
|
294
|
+
`package.json must carry "demo": "${DEMO_SCRIPT}" when ${DEMO_SPEC} exists — ${DEMO_SHIM} delegates to \`bun run demo\`.`,
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return problems;
|
|
248
299
|
}
|
|
249
300
|
|
|
250
301
|
// A file that must be a verbatim, byte-for-byte copy of its dev-kit canonical.
|
|
@@ -435,6 +486,8 @@ export function checkDrift(repoRoot: string, canonical: CanonicalBytes): DriftRe
|
|
|
435
486
|
}
|
|
436
487
|
}
|
|
437
488
|
|
|
489
|
+
for (const p of demoProblems(repoRoot, canonical.demoShim)) push(p);
|
|
490
|
+
|
|
438
491
|
return { ok: problems.length === 0, problems };
|
|
439
492
|
}
|
|
440
493
|
|
|
@@ -454,6 +507,7 @@ if (import.meta.main) {
|
|
|
454
507
|
gitignoreCore: readFileSync(join(import.meta.dir, "gitignore.core"), "utf8"),
|
|
455
508
|
buildImage: readFileSync(join(import.meta.dir, "build-image.yml"), "utf8"),
|
|
456
509
|
smoke: readFileSync(join(import.meta.dir, "smoke.yml"), "utf8"),
|
|
510
|
+
demoShim: readFileSync(join(import.meta.dir, "demo.sh"), "utf8"),
|
|
457
511
|
};
|
|
458
512
|
const report = checkDrift(repoRoot, canonical);
|
|
459
513
|
if (report.ok) {
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# scripts/demo — this product's demo: tests/demo.spec.ts run by the
|
|
3
|
+
# @norskvideo/ctl-test-harness demo driver (`ctl-demo`).
|
|
4
|
+
#
|
|
5
|
+
# scripts/demo up from source on a private daemon; Ctrl-C tears down
|
|
6
|
+
# scripts/demo up --mode image --daemon reuse the built image into YOUR live daemon
|
|
7
|
+
# scripts/demo check headless; what CI runs
|
|
8
|
+
# scripts/demo spec | down | ui the plan / teardown / a multiplexer layout
|
|
9
|
+
#
|
|
10
|
+
# The driver's private daemon is the released norsk-ctl the repo's flake pins,
|
|
11
|
+
# so this re-execs itself under `nix develop .#dev` when that binary is not on
|
|
12
|
+
# PATH, then delegates to the package's `demo` script (`bun run demo`).
|
|
13
|
+
#
|
|
14
|
+
# Copied verbatim from @norskvideo/ctl-dev-kit conventions/demo.sh and
|
|
15
|
+
# drift-gated: edit the dev-kit source and re-sync, never this copy.
|
|
16
|
+
set -euo pipefail
|
|
17
|
+
|
|
18
|
+
repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
19
|
+
cd "$repo"
|
|
20
|
+
|
|
21
|
+
if ! command -v norsk-ctl >/dev/null 2>&1; then
|
|
22
|
+
if [ -n "${IN_NIX_SHELL:-}" ]; then
|
|
23
|
+
echo "scripts/demo: norsk-ctl is not on PATH in this nix shell — the .#build shell carries no ctl; use \`nix develop .#dev\`" >&2
|
|
24
|
+
exit 1
|
|
25
|
+
fi
|
|
26
|
+
exec nix develop "$repo#dev" --command "$repo/scripts/demo" "$@"
|
|
27
|
+
fi
|
|
28
|
+
|
|
29
|
+
exec bun run demo -- "$@"
|
|
@@ -18,8 +18,8 @@
|
|
|
18
18
|
// cannot mechanically converge (e.g. a workflow with no product key to preserve)
|
|
19
19
|
// is left for check-drift to flag, and a convention that genuinely breaks the
|
|
20
20
|
// product surfaces as a red lint/typecheck/test — not as a silent bad copy.
|
|
21
|
-
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
22
|
-
import { join } from "node:path";
|
|
21
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
22
|
+
import { dirname, join } from "node:path";
|
|
23
23
|
import {
|
|
24
24
|
BEGIN,
|
|
25
25
|
type CanonicalBytes,
|
|
@@ -195,6 +195,19 @@ function syncDprint(repoRoot: string, canonical: string, r: SyncReport): void {
|
|
|
195
195
|
writeIfChanged(path, out, "dprint.json", r.written);
|
|
196
196
|
}
|
|
197
197
|
|
|
198
|
+
// scripts/demo (05-demo s6): verbatim + executable for a repo that has a demo
|
|
199
|
+
// (tests/demo.spec.ts) or already carries the shim; a repo without a demo is
|
|
200
|
+
// not given one. The package.json "demo" script is structural and stays the
|
|
201
|
+
// product's to add — the gate names it.
|
|
202
|
+
function syncDemoShim(repoRoot: string, canonical: string, r: SyncReport): void {
|
|
203
|
+
const rel = "scripts/demo";
|
|
204
|
+
const path = join(repoRoot, rel);
|
|
205
|
+
if (!existsSync(join(repoRoot, "tests/demo.spec.ts")) && !existsSync(path)) return;
|
|
206
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
207
|
+
writeIfChanged(path, canonical, rel, r.written);
|
|
208
|
+
chmodSync(path, 0o755);
|
|
209
|
+
}
|
|
210
|
+
|
|
198
211
|
export function syncDrift(repoRoot: string, canonical: CanonicalBytes): SyncReport {
|
|
199
212
|
const r: SyncReport = { written: [], skipped: [] };
|
|
200
213
|
|
|
@@ -218,6 +231,7 @@ export function syncDrift(repoRoot: string, canonical: CanonicalBytes): SyncRepo
|
|
|
218
231
|
syncClaude(repoRoot, canonical.core, r);
|
|
219
232
|
syncGitignore(repoRoot, canonical.gitignoreCore, r);
|
|
220
233
|
syncDprint(repoRoot, canonical.dprint, r);
|
|
234
|
+
syncDemoShim(repoRoot, canonical.demoShim, r);
|
|
221
235
|
|
|
222
236
|
return r;
|
|
223
237
|
}
|
|
@@ -239,6 +253,7 @@ if (import.meta.main) {
|
|
|
239
253
|
gitignoreCore: readFileSync(join(dir, "gitignore.core"), "utf8"),
|
|
240
254
|
buildImage: readFileSync(join(dir, "build-image.yml"), "utf8"),
|
|
241
255
|
smoke: readFileSync(join(dir, "smoke.yml"), "utf8"),
|
|
256
|
+
demoShim: readFileSync(join(dir, "demo.sh"), "utf8"),
|
|
242
257
|
};
|
|
243
258
|
const report = syncDrift(repoRoot, canonical);
|
|
244
259
|
if (report.written.length === 0) {
|
|
@@ -32,10 +32,18 @@ pinned in \`flake.nix\` and put on PATH by the nix shell:
|
|
|
32
32
|
\`norsk-ctl product add --dev-url http://localhost:${ctx.devPort}\` registers the
|
|
33
33
|
running backend as a dev product.
|
|
34
34
|
|
|
35
|
-
**
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
35
|
+
**Demo:** \`bun run demo -- up\` runs \`tests/demo.spec.ts\` (the
|
|
36
|
+
@norskvideo/ctl-test-harness demo driver) from source on a private daemon —
|
|
37
|
+
dev backend, registration, launch, the camera1 sample on the ingest, ready
|
|
38
|
+
gates, URLs; Ctrl-C tears down. \`check\` is the headless form CI runs,
|
|
39
|
+
\`spec\` prints the plan. \`scripts/demo\` is the same entry re-exec'd under
|
|
40
|
+
\`nix develop .#dev\` when the pinned ctl is not on PATH.
|
|
41
|
+
|
|
42
|
+
**Outer loop (real image):** \`bun run iterate\` — \`bun run build:image\` (the
|
|
43
|
+
shared dev-kit bundle driver via \`deployment/build-image.sh\`, tag \`${ctx.name}\`,
|
|
44
|
+
container port 4321), then \`bun run demo -- up --mode image --daemon reuse\`:
|
|
45
|
+
the driver deletes the instance, removes the template + product, re-adds the
|
|
46
|
+
fresh image and checks the pins before relaunching.
|
|
39
47
|
|
|
40
48
|
## First run after generation
|
|
41
49
|
|
|
@@ -61,7 +69,8 @@ The daemon launches instances from a **stored** product-template snapshot
|
|
|
61
69
|
fetched once at \`product add\`, and refuses to overwrite one by name.
|
|
62
70
|
Rebuilding the image does NOT reach launched instances or new launches until
|
|
63
71
|
you refresh the stored template (\`norsk-ctl template refresh <name>\`, or the
|
|
64
|
-
|
|
72
|
+
delete/remove/re-add dance \`bun run iterate\` drives through the demo
|
|
73
|
+
driver). Verify the
|
|
65
74
|
refreshed \`~/.norsk-ctl/product-templates/<name>/compose.yml\` carries the pins
|
|
66
75
|
\`manifest.seed.json\` declares.
|
|
67
76
|
|
|
@@ -126,10 +135,14 @@ function rootPackageJson(ctx: ShapeContext): string {
|
|
|
126
135
|
dev: "bun run --cwd backend dev",
|
|
127
136
|
start: "bun run --cwd backend start",
|
|
128
137
|
iterate: "bash deployment/iterate.sh",
|
|
138
|
+
demo: "ctl-demo",
|
|
129
139
|
},
|
|
130
140
|
devDependencies: {
|
|
131
141
|
"@biomejs/biome": "2.5.5",
|
|
132
142
|
"@norskvideo/ctl-dev-kit": "^0.1.10",
|
|
143
|
+
// The demo driver (`ctl-demo`, tests/demo.spec.ts) — hoisted here so the
|
|
144
|
+
// root `demo` script resolves the bin.
|
|
145
|
+
"@norskvideo/ctl-test-harness": "^0.1.23",
|
|
133
146
|
"@types/bun": "latest",
|
|
134
147
|
"@types/node": "^22.10.0",
|
|
135
148
|
typescript: "^5.7.2",
|
|
@@ -1174,7 +1187,7 @@ function testsPackageJson(ctx: ShapeContext): string {
|
|
|
1174
1187
|
// directly, and bun links a member's deps into its OWN node_modules —
|
|
1175
1188
|
// the backend's copy is not reachable from here.
|
|
1176
1189
|
"@norskvideo/ctl-product-template-schema": "^0.1.0",
|
|
1177
|
-
"@norskvideo/ctl-test-harness": "^0.1.
|
|
1190
|
+
"@norskvideo/ctl-test-harness": "^0.1.23",
|
|
1178
1191
|
"@types/bun": "latest",
|
|
1179
1192
|
"@types/node": "^22.10.0",
|
|
1180
1193
|
typescript: "^5.7.2",
|
|
@@ -1325,42 +1338,98 @@ describe("${ctx.name} image: container surface", () => {
|
|
|
1325
1338
|
|
|
1326
1339
|
function iterateSh(ctx: ShapeContext): string {
|
|
1327
1340
|
return `#!/usr/bin/env bash
|
|
1328
|
-
#
|
|
1329
|
-
#
|
|
1330
|
-
#
|
|
1331
|
-
#
|
|
1341
|
+
#
|
|
1342
|
+
# Rebuild ${ctx.name} and reload it into the LIVE norsk-ctl daemon:
|
|
1343
|
+
# build image -> \`bun run demo -- up --mode image --daemon reuse\`.
|
|
1344
|
+
#
|
|
1345
|
+
# The driver (tests/demo.spec.ts via @norskvideo/ctl-test-harness/demo) does
|
|
1346
|
+
# the stored-template dance CLAUDE.md describes: deletes the instance, removes
|
|
1347
|
+
# the template + product, adds the fresh image again, checks the studio pin
|
|
1348
|
+
# against manifest.seed.json, pumps the source, waits for the ready gates,
|
|
1349
|
+
# prints the URLs and holds. Ctrl-C (or \`bun run demo -- down\`) removes the
|
|
1350
|
+
# instance; your daemon and the registration stay.
|
|
1351
|
+
#
|
|
1352
|
+
# Needs the daemon already running (\`norsk-ctl serve\`), \`norsk-ctl\` on PATH
|
|
1353
|
+
# (\`nix develop .#dev\` provides it) and NORSK_LICENSE_FILE.
|
|
1354
|
+
#
|
|
1355
|
+
# Usage (from the repo root):
|
|
1356
|
+
# bun run iterate # full loop
|
|
1357
|
+
# SKIP_BUILD=1 bun run iterate # reuse the existing image
|
|
1358
|
+
# IMAGE_TAG=... bun run iterate # build + register under another tag
|
|
1332
1359
|
set -euo pipefail
|
|
1333
1360
|
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
image="\${IMAGE_TAG:-${ctx.name}:dev}"
|
|
1361
|
+
here="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
|
|
1362
|
+
repo="$(cd "$here/.." && pwd)"
|
|
1337
1363
|
|
|
1338
|
-
|
|
1339
|
-
|
|
1364
|
+
# The licence names the product by image ref, so the build lands on that ref
|
|
1365
|
+
# directly (build-image.sh honours IMAGE_TAG).
|
|
1366
|
+
export IMAGE_TAG="\${IMAGE_TAG:-${ctx.name}:dev}"
|
|
1340
1367
|
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
if norsk-ctl template refresh "\${template}" 2>/dev/null; then
|
|
1344
|
-
echo "==> stored template refreshed"
|
|
1368
|
+
if [ -n "\${SKIP_BUILD:-}" ]; then
|
|
1369
|
+
echo "=== build (skipped, SKIP_BUILD set) ==="
|
|
1345
1370
|
else
|
|
1346
|
-
echo "
|
|
1347
|
-
|
|
1348
|
-
norsk-ctl template remove "\${template}" || true
|
|
1349
|
-
norsk-ctl product remove "\${product}" || true
|
|
1350
|
-
norsk-ctl product add --image "\${image}" \${NORSK_LICENSE_FILE:+--license-file "\${NORSK_LICENSE_FILE}"}
|
|
1371
|
+
echo "=== build image $IMAGE_TAG ==="
|
|
1372
|
+
bun run --cwd "$repo" build:image
|
|
1351
1373
|
fi
|
|
1352
1374
|
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1375
|
+
cd "$repo"
|
|
1376
|
+
exec bun run demo -- up --mode image --daemon reuse
|
|
1377
|
+
`;
|
|
1378
|
+
}
|
|
1357
1379
|
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1380
|
+
function demoSpecTs(ctx: ShapeContext): string {
|
|
1381
|
+
return `// The ${ctx.name} demo (fleet review 05-demo): the manifest's default
|
|
1382
|
+
// template with the daemon's camera1 sample pumped into the SRT ingest. Run by
|
|
1383
|
+
// the @norskvideo/ctl-test-harness demo driver:
|
|
1384
|
+
//
|
|
1385
|
+
// bun run demo -- up from source on a private daemon; Ctrl-C tears down
|
|
1386
|
+
// bun run demo -- up --mode image --daemon reuse the built image into YOUR live daemon (bun run iterate)
|
|
1387
|
+
// bun run demo -- check headless, torn down on exit; what CI runs
|
|
1388
|
+
//
|
|
1389
|
+
// The driver owns the daemon, the ingest port (INGEST_PORT, read back at
|
|
1390
|
+
// launch), the pump and the teardown. Grow the ready gates as the product
|
|
1391
|
+
// takes shape: the one below only says Studio's runtime API answers.
|
|
1392
|
+
import { defineDemo } from "@norskvideo/ctl-test-harness/demo";
|
|
1393
|
+
|
|
1394
|
+
export default defineDemo({
|
|
1395
|
+
product: "${ctx.name}",
|
|
1396
|
+
dev: { command: ["bun", "run", "dev"], readyPath: "/healthz" },
|
|
1397
|
+
// The licence names the product by image ref; build-image.sh honours IMAGE_TAG.
|
|
1398
|
+
image: process.env.IMAGE_TAG ?? "${ctx.name}:dev",
|
|
1399
|
+
template: { name: "default" },
|
|
1400
|
+
launch: { hardware: "none" },
|
|
1401
|
+
sources: [{ name: "input", asset: { preset: "camera1" }, ingest: { param: "INGEST_PORT" }, streamId: "input" }],
|
|
1402
|
+
ready: [{ http: { studio: "/live/api/components" }, status: 200, timeoutMs: 120_000 }],
|
|
1403
|
+
open: [
|
|
1404
|
+
{ name: "Studio", url: { proxy: "/instance/{id}/studio/" } },
|
|
1405
|
+
{ name: "Studio (host port, no auth)", url: { studio: "/" } },
|
|
1406
|
+
],
|
|
1407
|
+
});
|
|
1361
1408
|
`;
|
|
1362
1409
|
}
|
|
1363
1410
|
|
|
1411
|
+
const DEMO_SPEC_TEST_TS = `// The demo spec (tests/demo.spec.ts) is validated by defineDemo at import;
|
|
1412
|
+
// this adds, without Docker, the product-side agreement: the template it
|
|
1413
|
+
// names is one the manifest publishes and its source dials the parameter the
|
|
1414
|
+
// template declares — the class of rot the demo driver exists to stop.
|
|
1415
|
+
import { describe, expect, test } from "bun:test";
|
|
1416
|
+
import { buildManifest } from "../../shared/src/manifest.ts";
|
|
1417
|
+
import demo from "../demo.spec.ts";
|
|
1418
|
+
|
|
1419
|
+
describe("tests/demo.spec.ts", () => {
|
|
1420
|
+
test("names a template the manifest publishes as a default", () => {
|
|
1421
|
+
const published = buildManifest().defaultProductTemplates.map((t) => t.name);
|
|
1422
|
+
if (!demo.template || !("name" in demo.template)) throw new Error("the demo launches a published template");
|
|
1423
|
+
expect(published).toContain(demo.template.name);
|
|
1424
|
+
});
|
|
1425
|
+
|
|
1426
|
+
test("its source dials INGEST_PORT, the parameter the srt-listener template declares", () => {
|
|
1427
|
+
const [input] = demo.sources ?? [];
|
|
1428
|
+
expect(input && "param" in input.ingest ? input.ingest.param : "").toBe("INGEST_PORT");
|
|
1429
|
+
});
|
|
1430
|
+
});
|
|
1431
|
+
`;
|
|
1432
|
+
|
|
1364
1433
|
const DOCKERIGNORE = `node_modules
|
|
1365
1434
|
**/node_modules
|
|
1366
1435
|
dist
|
|
@@ -1393,6 +1462,9 @@ export const backendTurnkey: ShapeModule = {
|
|
|
1393
1462
|
{ path: ".dockerignore", content: DOCKERIGNORE },
|
|
1394
1463
|
{ path: "examples/default/input.json", content: exampleInputJson(ctx) },
|
|
1395
1464
|
{ path: "deployment/iterate.sh", content: iterateSh(ctx), executable: true },
|
|
1465
|
+
{ path: "scripts/demo", content: ctx.canon.demoShim, executable: true },
|
|
1466
|
+
{ path: "tests/demo.spec.ts", content: demoSpecTs(ctx) },
|
|
1467
|
+
{ path: "tests/unit/demo-spec.test.ts", content: DEMO_SPEC_TEST_TS },
|
|
1396
1468
|
{ path: "shared/package.json", content: sharedPackageJson(ctx) },
|
|
1397
1469
|
{ path: "shared/tsconfig.json", content: WORKSPACE_TSCONFIG },
|
|
1398
1470
|
{ path: "shared/src/index.ts", content: SHARED_INDEX_TS },
|
package/create-product/canon.ts
CHANGED
|
@@ -26,6 +26,8 @@ export interface Canon {
|
|
|
26
26
|
buildImage: string;
|
|
27
27
|
gitignoreCore: string;
|
|
28
28
|
invariantsTemplate: string;
|
|
29
|
+
/** conventions/demo.sh, emitted as scripts/demo. */
|
|
30
|
+
demoShim: string;
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
export function loadCanon(): Canon {
|
|
@@ -43,6 +45,7 @@ export function loadCanon(): Canon {
|
|
|
43
45
|
buildImage: readFileSync(join(conventionsDir, "build-image.yml"), "utf8"),
|
|
44
46
|
gitignoreCore: readFileSync(join(conventionsDir, "gitignore.core"), "utf8"),
|
|
45
47
|
invariantsTemplate: readFileSync(join(import.meta.dir, "..", "testing", "INVARIANTS.template.md"), "utf8"),
|
|
48
|
+
demoShim: readFileSync(join(conventionsDir, "demo.sh"), "utf8"),
|
|
46
49
|
};
|
|
47
50
|
}
|
|
48
51
|
|
package/local-dev/README.md
CHANGED
|
@@ -30,7 +30,12 @@ bump `ctlVersion` + the four platform hashes in `flake.nix` (or run
|
|
|
30
30
|
|
|
31
31
|
**Inner loop (fast, no image build)** — run the backend from source and register
|
|
32
32
|
it as a `kind: dev` product, so edits to the backend / workflow / components land
|
|
33
|
-
without building a container
|
|
33
|
+
without building a container. `bun run demo -- up` does the whole thing on a
|
|
34
|
+
private daemon — `tests/demo.spec.ts` run by the test-harness demo driver: dev
|
|
35
|
+
backend, registration, launch, the sample sources, the ready gates, the URLs;
|
|
36
|
+
Ctrl-C tears down (`spec` prints the plan, `down` tears down from another
|
|
37
|
+
shell). `scripts/demo` is the same entry, re-exec'd under `nix develop .#dev`
|
|
38
|
+
when the pinned ctl is not on PATH. By hand, against your own daemon:
|
|
34
39
|
|
|
35
40
|
```sh
|
|
36
41
|
bun install
|
|
@@ -40,15 +45,19 @@ norsk-ctl instance launch-template <id> --template <t> --hardware none
|
|
|
40
45
|
```
|
|
41
46
|
|
|
42
47
|
**Outer loop (real container)** — rebuild the product image and reload it into
|
|
43
|
-
the live daemon
|
|
48
|
+
the live daemon. This is `bun run iterate` (`deployment/iterate.sh`):
|
|
49
|
+
`build:image`, then `bun run demo -- up --mode image --daemon reuse`, where the
|
|
50
|
+
driver deletes the instance, removes the template + product, re-adds the fresh
|
|
51
|
+
image, checks the pins against `manifest.seed.json` and relaunches:
|
|
44
52
|
|
|
45
53
|
```sh
|
|
46
|
-
NORSK_LICENSE_FILE=/path/to/license.json bun run iterate # needs a running daemon + GPU
|
|
47
|
-
HARDWARE=none NORSK_LICENSE_FILE=... bun run iterate # no GPU reservation
|
|
54
|
+
NORSK_LICENSE_FILE=/path/to/license.json bun run iterate # needs a running daemon (+ GPU where the spec says nvidia)
|
|
55
|
+
HARDWARE=none NORSK_LICENSE_FILE=... bun run iterate # no GPU reservation (funke)
|
|
48
56
|
```
|
|
49
57
|
|
|
50
|
-
`
|
|
51
|
-
(via `NORSK_CTL_BINARY`), exactly as
|
|
58
|
+
`bun run demo -- check` is the headless form CI runs after `test:integration`,
|
|
59
|
+
both against the **released** ctl (via `NORSK_CTL_BINARY`), exactly as a
|
|
60
|
+
customer would — see `.github/workflows/integration.yml`.
|
|
52
61
|
|
|
53
62
|
---
|
|
54
63
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# <customer> workflow invariants
|
|
2
|
+
|
|
3
|
+
<!-- Template from @norskvideo/ctl-dev-kit testing/INVARIANTS.customer.template.md.
|
|
4
|
+
Copy to customers/<slug>/INVARIANTS.md, replace the example rows with the rules
|
|
5
|
+
THIS customer cares about, and add the parity-guard test below. This is the
|
|
6
|
+
customer-scoped sibling of INVARIANTS.template.md (which is product-scoped): use
|
|
7
|
+
this one for a reconciled per-customer input.json under customers/, and the
|
|
8
|
+
product template for the shipped product's own composed workflow. -->
|
|
9
|
+
|
|
10
|
+
> **CUSTOMER CONTRACT — developer-managed.** These are the load-bearing rules
|
|
11
|
+
> this customer deems important about their workflow. They change **ONLY**
|
|
12
|
+
> through deliberate interaction with a developer. Claude / automated agents MUST
|
|
13
|
+
> NOT add, edit, remove, or renumber the rows below — or weaken their guarding
|
|
14
|
+
> tests — on their own initiative. If a code change appears to conflict with an
|
|
15
|
+
> invariant, **stop and surface it to a developer** rather than "fixing" the
|
|
16
|
+
> invariant. This is what protects the customer from accidental regressions to
|
|
17
|
+
> the things they care about.
|
|
18
|
+
|
|
19
|
+
Each rule has a stable ID (`INV-<AREA>-<NNN>`; never renumber, never reuse a
|
|
20
|
+
retired ID) and at least one test that cites the ID verbatim — the parity guard
|
|
21
|
+
fails CI when a row has no citing test or a test cites an unknown ID. Prefer
|
|
22
|
+
asserting against the **composed Studio graph** (compose the customer's
|
|
23
|
+
`input.json` and inspect the emitted workflow), not just the raw config — that
|
|
24
|
+
way resolved defaults (segment durations, as-rendered resolutions, output count)
|
|
25
|
+
are guarded too, not only literal fields. When a code comment carries one of
|
|
26
|
+
these rules, cite the ID there too instead of restating the rule.
|
|
27
|
+
|
|
28
|
+
| ID | Invariant | Guarded by |
|
|
29
|
+
| ---------------- | ------------------------------------------------------------------------------------------------------------- | -------------------------------- |
|
|
30
|
+
| INV-PLAYLIST-001 | This customer serves legacy playlists only — every composed CMAF playlist is non-low-latency, never LL-HLS | `<slug>/<slug>-invariants.test.ts` |
|
|
31
|
+
| INV-SEG-001 | CMAF segment duration is 4s on every rendition (a resolved default — only guardable via the composed graph) | `<slug>/<slug>-invariants.test.ts` |
|
|
32
|
+
| INV-OUT-001 | Exactly three delivery rungs in a single named CMAF group, one playlist per rung | `<slug>/<slug>-invariants.test.ts` |
|
|
33
|
+
| INV-RES-001 | The delivery resolutions are exactly the ladder the customer signed off (e.g. 1280x720 / 640x360 / 320x180) | `<slug>/<slug>-invariants.test.ts` |
|
|
34
|
+
| INV-MOQ-001 | The low-latency return / monitor rides MoQ, never a WHIP/RTMP fallback | `<slug>/<slug>-invariants.test.ts` |
|
|
35
|
+
|
|
36
|
+
The rows above are worked examples (drawn from the datum commentary deployment);
|
|
37
|
+
delete them when seeding a new customer.
|
|
38
|
+
|
|
39
|
+
## Parity guard
|
|
40
|
+
|
|
41
|
+
Copy into `customers/<slug>/`-adjacent test dir as `<slug>-invariants-parity.test.ts`
|
|
42
|
+
(keep it beside the invariant tests so `testDirs` stays scoped to this customer's
|
|
43
|
+
IDs — other customers and the product's own invariants use their own guards):
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
import { expect, test } from "bun:test";
|
|
47
|
+
import { join } from "node:path";
|
|
48
|
+
import { invariantParityFromDisk } from "@norskvideo/ctl-dev-kit/testing/invariants";
|
|
49
|
+
|
|
50
|
+
// Adjust the number of "../" hops to reach the repo root from this test's dir.
|
|
51
|
+
const repoRoot = join(import.meta.dir, "..", "..", "..", "..");
|
|
52
|
+
|
|
53
|
+
test("every <slug> invariant has a citing test and every cited ID is tabled", () => {
|
|
54
|
+
const report = invariantParityFromDisk({
|
|
55
|
+
invariantsPath: join(repoRoot, "customers", "<slug>", "INVARIANTS.md"),
|
|
56
|
+
testDirs: [import.meta.dir],
|
|
57
|
+
});
|
|
58
|
+
expect(report.problems).toEqual([]);
|
|
59
|
+
});
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
A runtime-only invariant the composed graph can't show (e.g. a timing
|
|
63
|
+
guarantee) still gets a table row — declare its ID in the guard's `untestableIds`
|
|
64
|
+
option; the guard rejects unknown entries and flags the marker as stale once a
|
|
65
|
+
test does cite the ID.
|