@norskvideo/ctl-dev-kit 0.1.96 → 0.1.98

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/build/flake.lock CHANGED
@@ -16,9 +16,26 @@
16
16
  "type": "github"
17
17
  }
18
18
  },
19
+ "nixpkgs-biome": {
20
+ "locked": {
21
+ "lastModified": 1785602060,
22
+ "narHash": "sha256-z7D96eESRM4CPV/XtwpwFn8IDdfLAmxz6lVWrGYXvR4=",
23
+ "owner": "NixOS",
24
+ "repo": "nixpkgs",
25
+ "rev": "a5cbcfe954791221bfffe2307f7d1a1bf61a871e",
26
+ "type": "github"
27
+ },
28
+ "original": {
29
+ "owner": "NixOS",
30
+ "repo": "nixpkgs",
31
+ "rev": "a5cbcfe954791221bfffe2307f7d1a1bf61a871e",
32
+ "type": "github"
33
+ }
34
+ },
19
35
  "root": {
20
36
  "inputs": {
21
- "nixpkgs": "nixpkgs"
37
+ "nixpkgs": "nixpkgs",
38
+ "nixpkgs-biome": "nixpkgs-biome"
22
39
  }
23
40
  }
24
41
  },
package/build/flake.nix CHANGED
@@ -36,13 +36,13 @@
36
36
 
37
37
  # --- norsk-ctl channel pin -------------------------------------------
38
38
  # The released daemon, one build per platform. Bump these together.
39
- ctlVersion = "0.1.0-2026-07-29-a968793";
39
+ ctlVersion = "0.1.0-2026-09-15-81bbde8";
40
40
  ctlBase = "https://s3.eu-west-1.amazonaws.com/norsk.video/norsk-ctl";
41
41
  ctlAsset = {
42
- "x86_64-linux" = { plat = "linux-x64"; hash = "sha256-F2F1iX4V10mB4n72qIMknHoCohOeiLCLB20BQJtuGU4="; };
43
- "aarch64-linux" = { plat = "linux-arm64"; hash = "sha256-Qnc8xsAm2r1XWAWIBYWPcUKn/Q+Xxs9kdwLq1fubb6M="; };
44
- "aarch64-darwin" = { plat = "darwin-arm64"; hash = "sha256-u7kAbgpmAVgpPXBgsEDiVGzLxwfZZDoZgqw2j9NwQeg="; };
45
- "x86_64-darwin" = { plat = "darwin-x64"; hash = "sha256-cTgyuZD/W/6dVXTd1/17KIbks3FmBVOOntqoMoJwZ7s="; };
42
+ "x86_64-linux" = { plat = "linux-x64"; hash = "sha256-6Zm1iLs29o89tav+TEDfLLdq2E+blku7DSxZVAqEwF0="; };
43
+ "aarch64-linux" = { plat = "linux-arm64"; hash = "sha256-ziH0YFLc8j7+xuYWArCU1JzWgq3NsFvfq67FqjlxdI8="; };
44
+ "aarch64-darwin" = { plat = "darwin-arm64"; hash = "sha256-A6uDgurkpYTwIdd7KwLv9oFNOLbGcoMgGLRW9SLuscw="; };
45
+ "x86_64-darwin" = { plat = "darwin-x64"; hash = "sha256-GzlT0Rg9t8sAG6e7Deu+DzSbfSxcr+WpVmyOgit2Q4g="; };
46
46
  };
47
47
 
48
48
  mkCtl = system:
@@ -5,18 +5,36 @@
5
5
  // so the bin runs under bun (the fleet's only runtime).
6
6
  import { existsSync, readdirSync } from "node:fs";
7
7
  import { parseArgs } from "node:util";
8
- import { createProduct, type ProductShape } from "./create-product.ts";
8
+ import { createProduct, type ProductFeature, type ProductShape, type StudioLibrary } from "./create-product.ts";
9
+
10
+ const SHAPES: ProductShape[] = ["backend-turnkey", "turnkey"];
9
11
 
10
12
  const USAGE = `Usage:
11
13
  ctl-dev-kit create-product --name <name> [options] <dir>
12
14
 
13
15
  Options:
14
- --shape <shape> Repo shape (default: backend-turnkey; the only shape yet)
16
+ --shape <shape> Repo shape — a preset over the features below (default: backend-turnkey)
17
+ backend-turnkey no features (reuters-shaped)
18
+ turnkey components,views,frontend (oracle-shaped)
19
+ --with <f,...> Add features on top of the shape's preset
20
+ --without <f,...> Drop features from the shape's preset
21
+ components custom Studio nodes packed into the template tar
22
+ views operator screen inside Studio's workflow view (needs components)
23
+ dashboard operator screen as a standalone SPA (needs components)
24
+ frontend the configure screen ctl iframes
15
25
  --name <name> Product name, e.g. norsk-acme (lowercase, digits, hyphens)
16
26
  --port <port> Local-dev backend port (default: 4323; container is always 4321)
17
27
  --media-image <ref> Full media image ref (repo:tag); norsk-sdk npm pin = its tag
18
28
  --studio-image <ref> Full studio image ref (repo:tag)
19
29
  --studio-lib <ver> norsk-studio* npm pin (default: the studio image's tag)
30
+ --studio-libs <l,...> Studio component libraries beyond built-ins the workflow uses: alpha, beta
31
+ (e.g. alpha for processor.reasoning) — pinned, typed, and loaded by Studio
32
+
33
+ Examples:
34
+ ctl-dev-kit create-product --name norsk-acme ./acme # backend only
35
+ ctl-dev-kit create-product --name norsk-acme --shape turnkey ./acme # + component views
36
+ ctl-dev-kit create-product --name norsk-acme --shape turnkey \\
37
+ --with dashboard --without views ./acme # + a dashboard SPA instead
20
38
  `;
21
39
 
22
40
  function fail(message: string): never {
@@ -35,20 +53,21 @@ export function main(argv: string[]): void {
35
53
  allowPositionals: true,
36
54
  options: {
37
55
  shape: { type: "string", default: "backend-turnkey" },
56
+ with: { type: "string" },
57
+ without: { type: "string" },
38
58
  name: { type: "string" },
39
59
  port: { type: "string" },
40
60
  "media-image": { type: "string" },
41
61
  "studio-image": { type: "string" },
42
62
  "studio-lib": { type: "string" },
63
+ "studio-libs": { type: "string" },
43
64
  },
44
65
  });
45
66
  const dir = positionals[0];
46
67
  if (!dir) fail("missing <dir>.");
47
68
  if (!values.name) fail("missing --name.");
48
- if (values.shape !== "backend-turnkey") {
49
- fail(
50
- `unknown shape ${JSON.stringify(values.shape)} — shapes: backend-turnkey (turnkey and product are follow-ups).`,
51
- );
69
+ if (!SHAPES.includes(values.shape as ProductShape)) {
70
+ fail(`unknown shape ${JSON.stringify(values.shape)} — shapes: ${SHAPES.join(", ")}.`);
52
71
  }
53
72
  if (existsSync(dir) && readdirSync(dir).length > 0) {
54
73
  fail(`${dir} exists and is not empty — refusing to overwrite.`);
@@ -58,25 +77,46 @@ export function main(argv: string[]): void {
58
77
  fail(`--port must be an integer in 1..65535, got ${JSON.stringify(values.port)}.`);
59
78
  }
60
79
 
61
- const { files } = createProduct({
62
- dir,
63
- name: values.name,
64
- shape: values.shape as ProductShape,
65
- devPort,
66
- mediaImage: values["media-image"],
67
- studioImage: values["studio-image"],
68
- studioLib: values["studio-lib"],
69
- });
80
+ // Comma-separated so one flag carries a set; the resolver validates the names.
81
+ const csvList = <T extends string>(raw: string | undefined): T[] | undefined =>
82
+ raw === undefined
83
+ ? undefined
84
+ : (raw
85
+ .split(",")
86
+ .map((f) => f.trim())
87
+ .filter(Boolean) as T[]);
88
+
89
+ // The resolver throws on a name, feature or combination it cannot honour;
90
+ // the operator asked for something impossible, not hit a bug, so report the
91
+ // reason and the usage rather than a stack trace.
92
+ let files: string[];
93
+ try {
94
+ ({ files } = createProduct({
95
+ dir,
96
+ name: values.name,
97
+ shape: values.shape as ProductShape,
98
+ withFeatures: csvList<ProductFeature>(values.with),
99
+ withoutFeatures: csvList<ProductFeature>(values.without),
100
+ devPort,
101
+ mediaImage: values["media-image"],
102
+ studioImage: values["studio-image"],
103
+ studioLib: values["studio-lib"],
104
+ studioLibs: csvList<StudioLibrary>(values["studio-libs"]),
105
+ }));
106
+ } catch (err) {
107
+ fail(err instanceof Error ? err.message : String(err));
108
+ }
70
109
 
71
110
  console.log(`Generated ${files.length} files into ${dir} (shape: ${values.shape}).
72
111
 
73
- Next steps (also in the generated CLAUDE.md):
74
- 1. cd ${dir} && git init && bun install
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
77
- 3. bun run check:drift && bun run docs:check && bun run test:unit && bun run typecheck
78
- 4. Replace the starter graph in shared/src/workflow/ with the real one,
112
+ Next steps (also in the generated CLAUDE.md and README):
113
+ 1. cd ${dir} && nix develop # the dev shell: pinned bun, biome and norsk-ctl
114
+ 2. git init && bun install
115
+ 3. UPDATE_SNAPSHOTS=1 bun run test:unit # bake the byte-snapshot fixtures, commit them
116
+ 4. bun run check:drift && bun run docs:check && bun run test:unit && bun run typecheck && bun run lint
117
+ 5. Replace the starter graph in shared/src/workflow/ with the real one,
79
118
  keeping INVARIANTS.md and its rules tests in step.
119
+ 6. bun run docs:handover # once a daemon has this product added: fills docs/handover.md from the launch truth
80
120
  `);
81
121
  }
82
122
 
@@ -4,17 +4,39 @@
4
4
  // - the CONVENTION layer (this file): every file the drift gate checks,
5
5
  // identical across shapes, with the per-repo fill-ins placed exactly where
6
6
  // the gate masks or leaves structure free;
7
- // - the SHAPE layer (backend-turnkey.ts, ...): the source skeleton — package
8
- // manifests, manifest/composer/materials modules, the five test tiers.
7
+ // - the SHAPE layer (turnkey.ts + features/): the source skeleton — package
8
+ // manifests, manifest/composer/materials modules, the five test tiers —
9
+ // with the optional workspaces (components, operator screen, configure
10
+ // screen) added by FEATURE, not by a second copy of the skeleton.
9
11
  //
10
- // New shapes (full turnkey, full product) slot in as additional ShapeModule
11
- // implementations; the convention layer is shared verbatim.
12
+ // The convention layer is shared verbatim across every shape.
12
13
  import { mkdirSync, writeFileSync } from "node:fs";
13
14
  import { dirname, join } from "node:path";
14
- import { backendTurnkey } from "./backend-turnkey.ts";
15
15
  import { type Canon, loadAsset, loadCanon, PRODUCT_SENTINEL } from "./canon.ts";
16
+ import { turnkey } from "./turnkey.ts";
16
17
 
17
- export type ProductShape = "backend-turnkey";
18
+ // A SHAPE is a preset over FEATURES, not a separate skeleton — the four
19
+ // product repos in the fleet are not a ladder (reuters: none; oracle:
20
+ // components + views; funke/probe: components + dashboard + frontend), so
21
+ // naming every combination would multiply shapes without adding anything.
22
+ export type ProductShape = "backend-turnkey" | "turnkey";
23
+
24
+ // - components the components/ workspace: custom Studio nodes packed into the
25
+ // product-template tar (all three UI surfaces need one).
26
+ // - views the operator screen AS component summary/fullscreen React
27
+ // rendered inside Studio's own workflow view (oracle).
28
+ // - dashboard the operator screen as a standalone SPA built into
29
+ // dashboards/<workflow>/ and served by Studio (funke, probe).
30
+ // - frontend the product-level configure screen ctl iframes at
31
+ // ui.configScreenUrl (funke, probe).
32
+ export type ProductFeature = "components" | "views" | "dashboard" | "frontend";
33
+
34
+ const ALL_FEATURES: readonly ProductFeature[] = ["components", "views", "dashboard", "frontend"];
35
+
36
+ const SHAPE_FEATURES: Record<ProductShape, readonly ProductFeature[]> = {
37
+ "backend-turnkey": [],
38
+ turnkey: ["components", "views", "frontend"],
39
+ };
18
40
 
19
41
  export interface CreateProductOptions {
20
42
  dir: string;
@@ -27,11 +49,23 @@ export interface CreateProductOptions {
27
49
  mediaImage?: string;
28
50
  /** Full studio image ref (repo:tag). */
29
51
  studioImage?: string;
30
- /** norsk-studio* npm pin; defaults to the studio image's bare tag (they
31
- * coincide when the published image matches the npm nightly). */
52
+ /** norsk-studio* npm pin; defaults to `studioImage`'s bare tag when one is
53
+ * given, else to the default lib pin. */
32
54
  studioLib?: string;
55
+ /** Features to add on top of the shape's preset (`--with`). */
56
+ withFeatures?: ProductFeature[];
57
+ /** Features to drop from the shape's preset (`--without`). */
58
+ withoutFeatures?: ProductFeature[];
59
+ /** Studio component libraries beyond built-ins the workflow uses (`--studio-libs`). */
60
+ studioLibs?: StudioLibrary[];
33
61
  }
34
62
 
63
+ export const STUDIO_LIBRARIES = ["alpha", "beta"] as const;
64
+ export type StudioLibrary = (typeof STUDIO_LIBRARIES)[number];
65
+
66
+ /** `@norskvideo/norsk-studio-<lib>` — the npm package and the Studio server.library entry. */
67
+ export const studioPackage = (lib: StudioLibrary): string => `@norskvideo/norsk-studio-${lib}`;
68
+
35
69
  export interface GeneratedFile {
36
70
  path: string;
37
71
  content: string;
@@ -47,6 +81,8 @@ export interface ShapeContext {
47
81
  devPort: number;
48
82
  pins: { mediaImage: string; studioImage: string; studioLib: string; mediaLib: string };
49
83
  canon: Canon;
84
+ features: ReadonlySet<ProductFeature>;
85
+ studioLibs: StudioLibrary[];
50
86
  }
51
87
 
52
88
  export interface ShapeModule {
@@ -54,25 +90,69 @@ export interface ShapeModule {
54
90
  claudeHead(ctx: ShapeContext): string;
55
91
  claudeTail(ctx: ShapeContext): string;
56
92
  rootTsconfigInclude: string[];
93
+ /** Everything below the drift-gated core block in .gitignore — the build
94
+ * output each enabled feature's workspace produces. */
95
+ gitignoreTail(ctx: ShapeContext): string;
57
96
  }
58
97
 
98
+ // One skeleton, both shapes: the difference between them is entirely in ctx.features.
59
99
  const SHAPES: Record<ProductShape, ShapeModule> = {
60
- "backend-turnkey": backendTurnkey,
100
+ "backend-turnkey": turnkey,
101
+ turnkey,
61
102
  };
62
103
 
63
- // Same known-good coherent set the monorepo's own test-harness pins. A fresh
104
+ // Same known-good coherent set the dev-kit's own package.json pins. A fresh
64
105
  // repo's first upgrade-latest.yml run advances every one of these in lockstep.
65
- const DEFAULT_MEDIA_IMAGE = "norskvideo/norsk:1.0.402-2026-07-20-71aab79d";
66
- const DEFAULT_STUDIO_IMAGE = "norskvideo/norsk-studio:1.27.0-2026-07-21-fdea1794";
106
+ const DEFAULT_MEDIA_IMAGE = "norskvideo/norsk:1.2.1-2026-09-15-a41445b4";
107
+ const DEFAULT_STUDIO_IMAGE = "norskvideo/norsk-studio:1.29.0-2026-09-10-dcf40e7c";
108
+ // Studio's npm packages and image ship from different commits, so the default
109
+ // lib pin can't be derived from the default image's tag.
110
+ const DEFAULT_STUDIO_LIB = "1.29.0-2026-09-10-b21d9013";
67
111
 
68
112
  const bareTag = (ref: string) => ref.slice(ref.lastIndexOf(":") + 1);
69
113
 
114
+ export function resolveFeatures(opts: CreateProductOptions): Set<ProductFeature> {
115
+ const known = (names: ProductFeature[] | undefined, flag: string): ProductFeature[] => {
116
+ for (const name of names ?? []) {
117
+ if (!ALL_FEATURES.includes(name)) {
118
+ throw new Error(`unknown feature ${JSON.stringify(name)} in ${flag} — features: ${ALL_FEATURES.join(", ")}.`);
119
+ }
120
+ }
121
+ return names ?? [];
122
+ };
123
+ const features = new Set<ProductFeature>(SHAPE_FEATURES[opts.shape] ?? []);
124
+ for (const name of known(opts.withoutFeatures, "--without")) features.delete(name);
125
+ for (const name of known(opts.withFeatures, "--with")) features.add(name);
126
+
127
+ // The template manifest's runtimeScreenUrl names ONE screen (/studio/ for
128
+ // views, /dashboard/<workflow>/ for the SPA), so a seed carrying both would
129
+ // ship one the operator can never reach.
130
+ if (features.has("views") && features.has("dashboard")) {
131
+ throw new Error(
132
+ "features 'views' and 'dashboard' are two answers to the same question (where the operator screen lives) — pick one.",
133
+ );
134
+ }
135
+ // Both operator screens read a component's state: views render inside the
136
+ // component itself, the SPA talks to it over /live/api/<id>/*.
137
+ const screen = features.has("views") ? "views" : features.has("dashboard") ? "dashboard" : undefined;
138
+ if (screen && !features.has("components")) {
139
+ throw new Error(
140
+ `feature ${JSON.stringify(screen)} needs 'components' — the operator screen reads a component's state.`,
141
+ );
142
+ }
143
+ return features;
144
+ }
145
+
70
146
  export function shapeContext(opts: CreateProductOptions): ShapeContext {
71
147
  if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(opts.name)) {
72
148
  throw new Error(
73
149
  `invalid product name ${JSON.stringify(opts.name)} — lowercase letters, digits, hyphens; 1-63 chars (it names the container image, npm scope, and manifest).`,
74
150
  );
75
151
  }
152
+ const unknownLibs = (opts.studioLibs ?? []).filter((lib) => !STUDIO_LIBRARIES.includes(lib));
153
+ if (unknownLibs.length > 0) {
154
+ throw new Error(`unknown studio libs ${unknownLibs.join(", ")} — known: ${STUDIO_LIBRARIES.join(", ")}.`);
155
+ }
76
156
  const mediaImage = opts.mediaImage ?? DEFAULT_MEDIA_IMAGE;
77
157
  const studioImage = opts.studioImage ?? DEFAULT_STUDIO_IMAGE;
78
158
  return {
@@ -83,10 +163,12 @@ export function shapeContext(opts: CreateProductOptions): ShapeContext {
83
163
  pins: {
84
164
  mediaImage,
85
165
  studioImage,
86
- studioLib: opts.studioLib ?? bareTag(studioImage),
166
+ studioLib: opts.studioLib ?? (opts.studioImage ? bareTag(studioImage) : DEFAULT_STUDIO_LIB),
87
167
  mediaLib: bareTag(mediaImage),
88
168
  },
89
169
  canon: loadCanon(),
170
+ features: resolveFeatures(opts),
171
+ studioLibs: STUDIO_LIBRARIES.filter((lib) => opts.studioLibs?.includes(lib)),
90
172
  };
91
173
  }
92
174
 
@@ -101,13 +183,16 @@ function conventionFiles(ctx: ShapeContext, shape: ShapeModule): GeneratedFile[]
101
183
  { path: "tsconfig.base.json", content: canon.tsconfigBase },
102
184
  { path: "dprint.json", content: canon.dprint },
103
185
  {
186
+ // Hand-rolled rather than JSON.stringify'd: biome keeps a short array on
187
+ // one line, and a generated repo has to be lint-clean on its first run.
104
188
  path: "tsconfig.json",
105
- content: `${JSON.stringify({ extends: "./tsconfig.base.json", include: shape.rootTsconfigInclude }, null, 2)}\n`,
106
- },
107
- {
108
- path: ".gitignore",
109
- content: `${canon.gitignoreCore}\n# Repo-specific entries go below (the block above is drift-gated verbatim).\n`,
189
+ content: `{
190
+ "extends": "./tsconfig.base.json",
191
+ "include": [${shape.rootTsconfigInclude.map((entry) => JSON.stringify(entry)).join(", ")}]
192
+ }
193
+ `,
110
194
  },
195
+ { path: ".gitignore", content: `${canon.gitignoreCore}\n${shape.gitignoreTail(ctx)}` },
111
196
  { path: ".github/workflows/checks.yml", content: fillProduct(canon.checks) },
112
197
  { path: ".github/workflows/upgrade-latest.yml", content: fillProduct(canon.upgradeLatest) },
113
198
  { path: ".github/workflows/sync-dev-kit.yml", content: fillProduct(canon.syncDevKit) },