@norskvideo/ctl-dev-kit 0.1.58 → 0.1.60

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.
@@ -125,10 +125,13 @@ function rootPackageJson(ctx: ShapeContext): string {
125
125
  "test:image": "bun test tests/image/",
126
126
  "check:drift": "bun run node_modules/@norskvideo/ctl-dev-kit/conventions/check-drift.ts",
127
127
  "docs:check": "bun run node_modules/@norskvideo/ctl-dev-kit/docs/path-existence.ts",
128
+ "docs:manual": `bun run node_modules/@norskvideo/ctl-dev-kit/doc-guide/prose-bundle.js --brand "${ctx.name}" --title "${ctx.name} — Documentation" --link "norsk.video=https://norsk.video/" --out docs/generated/bundle docs/handover.md docs/known-limitations.md`,
129
+ "docs:handover": "bash scripts/docs-handover.sh",
130
+ "docs:handover:check": "bash scripts/docs-handover.sh --check",
128
131
  "sync:drift": "bun run node_modules/@norskvideo/ctl-dev-kit/conventions/sync-drift.ts",
129
132
  typecheck: "bunx tsc --noEmit -p shared && bunx tsc --noEmit -p backend && bunx tsc --noEmit -p .",
130
133
  build: "bun run lint && bun run typecheck && bun run build:no-lint",
131
- "build:no-lint": "bun run build:shared && bun run build:backend",
134
+ "build:no-lint": "bun run build:shared && bun run build:backend && bun run docs:manual",
132
135
  "build:shared": "bun run --cwd shared build",
133
136
  "build:backend": "bun run --cwd backend build",
134
137
  "build:image": "bash deployment/build-image.sh",
@@ -336,9 +339,10 @@ const MANIFEST_TS = `import { type Manifest, type ManifestInput, ManifestSchema
336
339
  import { PRODUCT_NAME, PRODUCT_VERSION } from "./version.ts";
337
340
 
338
341
  // Producer-side ManifestInput: everything the schema defaults stays omitted
339
- // (ui/cli/components/runtime), so this declares only what the product has. A
342
+ // (cli/components/runtime), so this declares only what the product has. A
340
343
  // backend-only turnkey has no configure UI — configScreenUrl is optional and
341
- // omitted, so the runner skips its registration probe (no placeholder HTML).
344
+ // omitted, so the runner skips its registration probe (no placeholder HTML);
345
+ // its one ui entry is the documentation the image serves at /docs.
342
346
  // Parsing at build time keeps the served manifest the READER shape with the
343
347
  // defaults filled in.
344
348
  export function buildManifest(): Manifest {
@@ -355,6 +359,7 @@ export function buildManifest(): Manifest {
355
359
  targets: ["docker-compose"],
356
360
  // Keep in lockstep with STARTER_CONFIGS in backend/src/routes/product-template.ts.
357
361
  defaultProductTemplates: [{ name: "default", url: "/api/product-template/default" }],
362
+ ui: { sidebarEntries: [{ label: "Documentation", route: "/docs/" }] },
358
363
  };
359
364
  return ManifestSchema.parse(manifest);
360
365
  }
@@ -965,6 +970,7 @@ console.log(\`${ctx.name} listening on :\${port}\`);
965
970
  function backendServerTs(ctx: ShapeContext): string {
966
971
  return `import type { Server } from "node:http";
967
972
  import { buildStudioLibrary } from "${ctx.scope}/shared/workflow";
973
+ import { docsBundleDir, serveDocs } from "@norskvideo/ctl-sdk";
968
974
  import cors from "cors";
969
975
  import express from "express";
970
976
  import { manifestRouter } from "./routes/manifest.ts";
@@ -987,6 +993,9 @@ export async function startServer(port: number): Promise<Server> {
987
993
  app.use(manifestRouter);
988
994
  app.use("/api", openapiRouter);
989
995
  app.use("/api", makeProductTemplateRouter(library));
996
+ // The docs bundle (\`bun run docs:manual\`, baked into the image as docs/),
997
+ // served at /docs; a no-op until it has been built.
998
+ serveDocs(app, docsBundleDir(import.meta.dir));
990
999
 
991
1000
  return await new Promise<Server>((resolve, reject) => {
992
1001
  const server = app.listen(port, () => resolve(server));
@@ -1451,6 +1460,144 @@ function exampleInputJson(_ctx: ShapeContext): string {
1451
1460
  )}\n`;
1452
1461
  }
1453
1462
 
1463
+ // ---- The canonical doc set (fleet review 02 s9, backend-only turnkey) ------
1464
+ // README (developer), docs/README (the index), docs/handover (the customer's
1465
+ // deployment document: prose around a block norsk-ctl generates), docs/known-
1466
+ // limitations (operator). Prose only: a turnkey's bundle is built from these
1467
+ // files by docs:manual, which the image build runs, so the image serves them.
1468
+ // Bundle-internal file names are written without backticks on purpose: the
1469
+ // path-existence lint would look for them in the repo.
1470
+
1471
+ function readmeMd(ctx: ShapeContext): string {
1472
+ return `# ${ctx.name}
1473
+
1474
+ A Norsk product for norsk-ctl: a backend-only turnkey. The control plane is a
1475
+ small service the daemon runs (the manifest, the product-template build, the
1476
+ documentation); the media path is a Norsk Studio workflow the product composes.
1477
+
1478
+ ## Layout
1479
+
1480
+ - \`shared/\` — the product's config schema, workflow composer and manifest.
1481
+ - \`backend/\` — the control-plane service (manifest, product-template build, docs).
1482
+ - \`examples/\` — starter configs; \`examples/default/input.json\` is the default template.
1483
+ - \`tests/\` — unit, image and demo tiers; \`tests/demo.spec.ts\` is the customer journey.
1484
+ - \`docs/\` — reader-facing documents; \`docs/README.md\` is the index.
1485
+ - \`deployment/\` — the image build wrapper and the iterate loop.
1486
+
1487
+ ## Dev loop
1488
+
1489
+ Everything runs inside the dev shell (\`nix develop .#dev\`), which carries bun,
1490
+ norsk-ctl and the pinned Norsk SDK:
1491
+
1492
+ - \`bun install\`, then \`bun run check:drift && bun run docs:check && bun run test:unit && bun run typecheck\`.
1493
+ - \`bun run demo\` — the customer journey on a private daemon.
1494
+ - \`bun run build:image\` — the product image (\`deployment/build-image.sh\`).
1495
+
1496
+ ## Documentation
1497
+
1498
+ \`bun run docs:manual\` builds the docs bundle from \`docs/handover.md\` and
1499
+ \`docs/known-limitations.md\`; the image build runs it, so every image serves
1500
+ them at /docs (linked from the product's page in norsk-ctl). \`bun run
1501
+ docs:handover\` regenerates the handover's generated block from a daemon that
1502
+ has this product added; CI runs its \`--check\`.
1503
+
1504
+ \`CLAUDE.md\` has the conventions and the traps.
1505
+ `;
1506
+ }
1507
+
1508
+ function docsReadmeMd(ctx: ShapeContext): string {
1509
+ return `# ${ctx.name} documentation
1510
+
1511
+ Reader-facing documents live at this level and ship inside the product image,
1512
+ served at /docs at the version that is running.
1513
+
1514
+ | Document | Audience | What it is |
1515
+ | ---------------------------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------- |
1516
+ | \`docs/handover.md\` | the platform team deploying this product | Deploying from the exported bundle without norsk-ctl: every mount, environment and injected setting |
1517
+ | \`docs/known-limitations.md\` | operators | What the product does not do yet, with the consequence and the workaround |
1518
+
1519
+ \`bun run docs:manual\` builds the bundle from these files. The tables in the
1520
+ handover are generated by norsk-ctl (\`bun run docs:handover\`) — regenerate
1521
+ them, never edit them.
1522
+ `;
1523
+ }
1524
+
1525
+ function handoverMd(ctx: ShapeContext): string {
1526
+ return `# Deploying ${ctx.name} without norsk-ctl
1527
+
1528
+ What the platform team receives, and how to run it. The tables in the
1529
+ generated block below come from norsk-ctl's own rendering of this product's
1530
+ default template (\`bun run docs:handover\`), so they are the launch truth of
1531
+ the version this document ships with. Regenerate them; never edit them.
1532
+
1533
+ ## What you receive
1534
+
1535
+ A portable bundle exported by norsk-ctl: compose.yml (the flat compose, every
1536
+ path relative), HANDOVER.md (the same tables as below), the working-directory
1537
+ seed, the product-template content the services mount, and a licence
1538
+ placeholder to replace with a production licence before the first start.
1539
+
1540
+ <!-- BEGIN generated: norsk-ctl instance export-template default --format handover -->
1541
+ _Not generated yet. Run \`bun run docs:handover\` against a daemon that has this product added._
1542
+ <!-- END generated -->
1543
+
1544
+ ## Verifying a deployment
1545
+
1546
+ - The control plane answers on its port: GET /healthz returns ok.
1547
+ - The media engine is licensed: its log names the licence's product entry, not a placeholder.
1548
+ - The default template's outputs are reachable from where the audience is.
1549
+
1550
+ ## Not in this artefact
1551
+
1552
+ Anything norsk-ctl-specific: the daemon, product registration, the instance
1553
+ proxy and its authentication, templates-as-storage. The bundle is the launch
1554
+ truth of one product template and needs none of them.
1555
+ `;
1556
+ }
1557
+
1558
+ const KNOWN_LIMITATIONS_MD = `# Known limitations
1559
+
1560
+ What this product does not do yet, or does with a caveat an operator must know
1561
+ before relying on it. One entry per limitation: the consequence and the
1562
+ workaround, not the roadmap.
1563
+
1564
+ ## Ingest
1565
+
1566
+ - _None recorded yet._
1567
+
1568
+ ## Output
1569
+
1570
+ - _None recorded yet._
1571
+
1572
+ ## Operations
1573
+
1574
+ - _None recorded yet._
1575
+ `;
1576
+
1577
+ const DOCS_HANDOVER_SH = `#!/usr/bin/env bash
1578
+ # Fill (or, with --check, verify) the generated block of docs/handover.md from
1579
+ # norsk-ctl's own rendering of this product's stored template: every mount,
1580
+ # environment and launch-injected setting, as the runner would launch it.
1581
+ # Needs a daemon that has this product added (the demo or integration tier
1582
+ # leaves one); TEMPLATE names the stored template (default: the manifest's).
1583
+ set -euo pipefail
1584
+ cd "$(dirname "$0")/.."
1585
+ template="\${TEMPLATE:-default}"
1586
+ out="$(mktemp -d)/bundle"
1587
+ norsk-ctl instance export-template "$template" --to "$out" --format handover |
1588
+ bun run node_modules/@norskvideo/ctl-dev-kit/docs/splice-generated.ts docs/handover.md "$@"
1589
+ `;
1590
+
1591
+ const REGEN_MANUAL_SH = `#!/usr/bin/env bash
1592
+ # Regenerate this product's docs bundle. A prose-only turnkey has no doc-guide
1593
+ # tiers: the bundle is built from the hand-written documents under docs/ by
1594
+ # \`bun run docs:manual\`. The embedded docs workflow (build-docs.yml) calls
1595
+ # this; the image build runs docs:manual itself.
1596
+ set -euo pipefail
1597
+ cd "$(dirname "$0")/../.."
1598
+ bun run docs:manual
1599
+ `;
1600
+
1454
1601
  export const backendTurnkey: ShapeModule = {
1455
1602
  claudeHead,
1456
1603
  claudeTail,
@@ -1463,6 +1610,13 @@ export const backendTurnkey: ShapeModule = {
1463
1610
  { path: "examples/default/input.json", content: exampleInputJson(ctx) },
1464
1611
  { path: "deployment/iterate.sh", content: iterateSh(ctx), executable: true },
1465
1612
  { path: "scripts/demo", content: ctx.canon.demoShim, executable: true },
1613
+ { path: "README.md", content: readmeMd(ctx) },
1614
+ { path: "docs/README.md", content: docsReadmeMd(ctx) },
1615
+ { path: "docs/handover.md", content: handoverMd(ctx) },
1616
+ { path: "docs/known-limitations.md", content: KNOWN_LIMITATIONS_MD },
1617
+ { path: ".github/workflows/build-docs.yml", content: ctx.canon.buildDocs },
1618
+ { path: "scripts/doc-guide/regen-manual.sh", content: REGEN_MANUAL_SH, executable: true },
1619
+ { path: "scripts/docs-handover.sh", content: DOCS_HANDOVER_SH, executable: true },
1466
1620
  { path: "tests/demo.spec.ts", content: demoSpecTs(ctx) },
1467
1621
  { path: "tests/unit/demo-spec.test.ts", content: DEMO_SPEC_TEST_TS },
1468
1622
  { path: "shared/package.json", content: sharedPackageJson(ctx) },
@@ -24,6 +24,8 @@ export interface Canon {
24
24
  dprint: string;
25
25
  buildImageBootstrap: string;
26
26
  buildImage: string;
27
+ /** conventions/build-docs.yml — the embedded docs workflow a turnkey carries. */
28
+ buildDocs: string;
27
29
  gitignoreCore: string;
28
30
  invariantsTemplate: string;
29
31
  /** conventions/demo.sh, emitted as scripts/demo. */
@@ -38,6 +40,7 @@ export function loadCanon(): Canon {
38
40
  upgradeLatest: readFileSync(join(conventionsDir, "upgrade-latest.yml"), "utf8"),
39
41
  syncDevKit: readFileSync(join(conventionsDir, "sync-dev-kit.yml"), "utf8"),
40
42
  checks: readFileSync(join(conventionsDir, "checks.yml"), "utf8"),
43
+ buildDocs: readFileSync(join(conventionsDir, "build-docs.yml"), "utf8"),
41
44
  biome: readFileSync(join(conventionsDir, "biome.base.json"), "utf8"),
42
45
  tsconfigBase: readFileSync(join(conventionsDir, "tsconfig.base.json"), "utf8"),
43
46
  dprint: readFileSync(join(conventionsDir, "dprint.base.jsonc"), "utf8"),
@@ -73,6 +73,7 @@ export function main(argv: string[]): void {
73
73
  Next steps (also in the generated CLAUDE.md):
74
74
  1. cd ${dir} && git init && bun install
75
75
  2. UPDATE_SNAPSHOTS=1 bun run test:unit # bake the byte-snapshot fixtures, commit them
76
+ 3. bun run docs:handover # once a daemon has this product added: fills docs/handover.md from the launch truth
76
77
  3. bun run check:drift && bun run docs:check && bun run test:unit && bun run typecheck
77
78
  4. Replace the starter graph in shared/src/workflow/ with the real one,
78
79
  keeping INVARIANTS.md and its rules tests in step.
@@ -0,0 +1,15 @@
1
+ import { type ManualDocument, type ManualLink, type ManualSpec } from "./build-manual.js";
2
+ export interface ProseFile {
3
+ file: string;
4
+ markdown: string;
5
+ }
6
+ /** A markdown file as a manual document: its id from the file name, its title
7
+ * from the H1 (which then leaves the body — the page renders its own). */
8
+ export declare function proseDocument(file: string, markdown: string): ManualDocument;
9
+ export interface ProseSpecOptions {
10
+ brand: string;
11
+ title: string;
12
+ lead?: string;
13
+ links?: ManualLink[];
14
+ }
15
+ export declare function proseSpec(opts: ProseSpecOptions, files: ProseFile[]): ManualSpec;
@@ -0,0 +1,86 @@
1
+ // A docs bundle from hand-written markdown alone: no doc-guide tiers, no
2
+ // captures, no per-repo TypeScript driver. A turnkey (a handover document, a
3
+ // known-limitations page) names its files in one package.json script and gets
4
+ // the same bundle a product's illustrated manual becomes — served at /docs by
5
+ // the image, baked in by the image build.
6
+ //
7
+ // bun run node_modules/@norskvideo/ctl-dev-kit/doc-guide/prose-bundle.js \
8
+ // --brand "Acme" --title "Acme — Documentation" \
9
+ // --link "norsk.video=https://norsk.video/" \
10
+ // --out docs/generated/bundle docs/handover.md docs/known-limitations.md
11
+ import { readFileSync } from "node:fs";
12
+ import { basename, resolve } from "node:path";
13
+ import { provenanceFromEnv } from "./build-manual.js";
14
+ import { buildBundle } from "./bundle.js";
15
+ /** A markdown file as a manual document: its id from the file name, its title
16
+ * from the H1 (which then leaves the body — the page renders its own). */
17
+ export function proseDocument(file, markdown) {
18
+ const id = basename(file)
19
+ .replace(/\.[^.]+$/, "")
20
+ .toLowerCase()
21
+ .replace(/[^a-z0-9-]+/g, "-");
22
+ const h1 = markdown.match(/^# (.+)\r?\n/);
23
+ const title = h1 ? h1[1].trim() : id;
24
+ const body = h1 ? markdown.slice(h1[0].length).replace(/^\n+/, "") : markdown;
25
+ return { id, nav: title, title, markdown: body };
26
+ }
27
+ export function proseSpec(opts, files) {
28
+ return {
29
+ brand: opts.brand,
30
+ title: opts.title,
31
+ overview: {
32
+ headline: opts.title,
33
+ lead: opts.lead ?? "The documentation shipped with this product, at the version you are running.",
34
+ byExampleIntro: "",
35
+ },
36
+ functionality: [],
37
+ examples: [],
38
+ documents: files.map((f) => proseDocument(f.file, f.markdown)),
39
+ links: opts.links ?? [],
40
+ };
41
+ }
42
+ function parseArgs(argv) {
43
+ const opts = { brand: "", title: "", links: [] };
44
+ let out = "docs/generated/bundle";
45
+ const files = [];
46
+ for (let i = 0; i < argv.length; i++) {
47
+ const a = argv[i];
48
+ const value = () => {
49
+ const v = argv[++i];
50
+ if (v === undefined)
51
+ throw new Error(`${a} needs a value`);
52
+ return v;
53
+ };
54
+ if (a === "--brand")
55
+ opts.brand = value();
56
+ else if (a === "--title")
57
+ opts.title = value();
58
+ else if (a === "--lead")
59
+ opts.lead = value();
60
+ else if (a === "--out")
61
+ out = value();
62
+ else if (a === "--link") {
63
+ const v = value();
64
+ const eq = v.indexOf("=");
65
+ if (eq <= 0)
66
+ throw new Error(`--link wants label=href, got ${JSON.stringify(v)}`);
67
+ opts.links?.push({ label: v.slice(0, eq), href: v.slice(eq + 1) });
68
+ }
69
+ else if (a.startsWith("--"))
70
+ throw new Error(`unknown option ${a}`);
71
+ else
72
+ files.push(a);
73
+ }
74
+ if (!opts.brand || !opts.title)
75
+ throw new Error("--brand and --title are required");
76
+ if (files.length === 0)
77
+ throw new Error("name at least one markdown file");
78
+ return { opts, out, files };
79
+ }
80
+ if (import.meta.main) {
81
+ const { opts, out, files } = parseArgs(process.argv.slice(2));
82
+ const spec = proseSpec(opts, files.map((file) => ({ file, markdown: readFileSync(file, "utf8") })));
83
+ const outDir = resolve(out);
84
+ const result = buildBundle(spec, { docsRoot: process.cwd(), outDir, provenance: provenanceFromEnv() });
85
+ console.log(`wrote ${outDir} (${files.length} documents, ${result.manifest.pages.length} pages)`);
86
+ }
@@ -0,0 +1,75 @@
1
+ // Replace the generated block of a hand-written markdown document with what a
2
+ // tool printed, touching nothing outside the markers. The block is delimited by
3
+ //
4
+ // <!-- BEGIN generated: <what produced it> -->
5
+ // ...
6
+ // <!-- END generated -->
7
+ //
8
+ // on their own lines. The prose around it is the author's; the block is
9
+ // regenerated (a turnkey's handover tables come from `norsk-ctl instance
10
+ // export-template --format handover`), and `--check` is the CI snapshot: a
11
+ // document whose block would change is stale, and the diff is the review.
12
+ //
13
+ // <tool that prints the block> | bun run node_modules/@norskvideo/ctl-dev-kit/docs/splice-generated.ts docs/handover.md [--check]
14
+ import { readFileSync, writeFileSync } from "node:fs";
15
+
16
+ export const BEGIN_MARKER = "<!-- BEGIN generated";
17
+ export const END_MARKER = "<!-- END generated -->";
18
+
19
+ export type SpliceResult =
20
+ | { status: "ok"; value: { next: string; changed: boolean } }
21
+ | { status: "error"; error: string };
22
+
23
+ export function spliceGenerated(current: string, generated: string): SpliceResult {
24
+ const lines = current.split("\n");
25
+ const begins = lines.flatMap((l, i) => (l.startsWith(BEGIN_MARKER) && l.trimEnd().endsWith("-->") ? [i] : []));
26
+ const ends = lines.flatMap((l, i) => (l.trim() === END_MARKER ? [i] : []));
27
+ if (begins.length === 0 || ends.length === 0) {
28
+ return {
29
+ status: "error",
30
+ error: `no generated block: expected a line starting "${BEGIN_MARKER}" and a line "${END_MARKER}"`,
31
+ };
32
+ }
33
+ if (begins.length > 1 || ends.length > 1) {
34
+ return { status: "error", error: "more than one generated block; exactly one is spliced" };
35
+ }
36
+ const [begin] = begins;
37
+ const [end] = ends;
38
+ if (end < begin) return { status: "error", error: `"${END_MARKER}" precedes the BEGIN marker` };
39
+ const body = generated.replace(/^\n+/, "").replace(/\s+$/, "");
40
+ const next = [...lines.slice(0, begin + 1), body, ...lines.slice(end)].join("\n");
41
+ return { status: "ok", value: { next, changed: next !== current } };
42
+ }
43
+
44
+ if (import.meta.main) {
45
+ const args = process.argv.slice(2);
46
+ const check = args.includes("--check");
47
+ const file = args.find((a) => !a.startsWith("--"));
48
+ if (!file) {
49
+ console.error("usage: <generator> | splice-generated.ts <file> [--check]");
50
+ process.exit(2);
51
+ }
52
+ const generated = await Bun.stdin.text();
53
+ const result = spliceGenerated(readFileSync(file, "utf8"), generated);
54
+ if (result.status === "error") {
55
+ console.error(`splice-generated: ${file}: ${result.error}`);
56
+ process.exit(2);
57
+ }
58
+ const { next, changed } = result.value;
59
+ if (check) {
60
+ if (changed) {
61
+ console.error(
62
+ `splice-generated: ${file} is stale — its generated block differs from what the generator prints now`,
63
+ );
64
+ process.exit(1);
65
+ }
66
+ console.log(`splice-generated: ${file} is current`);
67
+ process.exit(0);
68
+ }
69
+ if (changed) {
70
+ writeFileSync(file, next);
71
+ console.log(`splice-generated: updated ${file}`);
72
+ } else {
73
+ console.log(`splice-generated: ${file} unchanged`);
74
+ }
75
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@norskvideo/ctl-dev-kit",
3
- "version": "0.1.58",
3
+ "version": "0.1.60",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./package.json": "./package.json",
@@ -8,9 +8,11 @@
8
8
  "./testing/byte-snapshot": "./testing/byte-snapshot.ts",
9
9
  "./create-product": "./create-product/create-product.ts",
10
10
  "./docs/path-existence": "./docs/path-existence.ts",
11
+ "./docs/splice-generated": "./docs/splice-generated.ts",
11
12
  "./doc-guide": "./doc-guide/doc-guide.js",
12
13
  "./doc-guide/build-manual": "./doc-guide/build-manual.js",
13
14
  "./doc-guide/bundle": "./doc-guide/bundle.js",
15
+ "./doc-guide/prose-bundle": "./doc-guide/prose-bundle.js",
14
16
  "./doc-guide/guides-config": "./doc-guide/guides-config.js",
15
17
  "./doc-guide/instance-proxy": "./doc-guide/instance-proxy.js",
16
18
  "./doc-guide/live-browser": "./doc-guide/live-browser.js"