@norskvideo/ctl-test-harness 0.1.21 → 0.1.22

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/demo/cli.d.ts CHANGED
@@ -1,14 +1,22 @@
1
1
  #!/usr/bin/env bun
2
+ import { type DevLoopOptions, type DevLoopResult } from "./dev-loop.js";
3
+ import { type DemoUi, type PaneLayout } from "./panes.js";
2
4
  import { type DemoRunOptions, type DemoRunResult, demoPorts } from "./run.js";
3
5
  import type { DemoDaemonPolicy, DemoMode, DemoSpec } from "./spec.js";
4
6
  export interface DemoArgs {
5
- action: "up" | "check" | "down" | "spec";
7
+ /** `dev-loop <action>`: the standalone tier's verbs. */
8
+ devLoop: boolean;
9
+ action: "up" | "check" | "down" | "spec" | "ui" | "refresh";
6
10
  /** Absent: the spec's `mode`, else dev. */
7
11
  mode?: DemoMode | "standalone";
8
12
  daemon: DemoDaemonPolicy;
9
13
  exportOnly: boolean;
10
14
  json: boolean;
11
15
  spec: string;
16
+ /** `ui` only; zellij unless said. */
17
+ ui?: DemoUi;
18
+ /** Default: PUBLIC_HOST in the environment. */
19
+ publicHost?: string;
12
20
  }
13
21
  export declare function parseDemoArgs(argv: string[]): DemoArgs;
14
22
  export interface ResolvedSpecView {
@@ -58,6 +66,7 @@ export interface ResolvedSpecView {
58
66
  export declare function resolvedSpecView(spec: DemoSpec, opts: {
59
67
  mode: string;
60
68
  slug?: string;
69
+ publicHost?: string;
61
70
  }): ResolvedSpecView;
62
71
  /** Everything `demoMain` touches, injectable so the dispatch is unit-tested. */
63
72
  export interface DemoCliIo {
@@ -70,6 +79,12 @@ export interface DemoCliIo {
70
79
  exportDir: string;
71
80
  }>;
72
81
  demoDown(product: string): Promise<void>;
82
+ runDevLoop(spec: DemoSpec, opts: DevLoopOptions): Promise<DevLoopResult>;
83
+ devLoopDown(product: string): Promise<void>;
84
+ /** Open the multiplexer on the rendered layout; returns its exit code. */
85
+ launchUi(ui: DemoUi, layout: PaneLayout): number;
86
+ /** For the standalone dirs (NORSK_DIR, STUDIO_DIR, TO, HOME). */
87
+ env?: Record<string, string | undefined>;
73
88
  stdout(line: string): void;
74
89
  stderr(line: string): void;
75
90
  abort?: AbortSignal;
package/demo/cli.js CHANGED
@@ -15,13 +15,21 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
15
15
  // demo check --mode standalone --export-only build + export-workdir + every symlink resolves
16
16
  // demo down tear down what `up` recorded, from any shell
17
17
  // demo spec [--json] the resolved spec: ports, template, sources, URLs
18
+ // demo ui [--ui zellij|tmux|none] [...] the same in a multiplexer: a gate pane running `demo up`,
19
+ // the instance's logs, a shell (05-demo s4 front-end 2)
20
+ // demo dev-loop up|refresh|down|ui the full standalone tier (engine + Studio from source);
21
+ // `ctl-dev-loop <verb>` is the same under a product's `bun run dev-loop`
18
22
  //
19
23
  // The spec is `tests/demo.spec.ts` in the cwd (`--spec <path>` otherwise), a
20
24
  // TypeScript module whose default export is `defineDemo({...})`. This runs
21
25
  // under bun only: the spec is TypeScript and so is every product's dev shell.
22
26
  import { resolve } from "node:path";
27
+ import { devLoopDown, runDevLoop } from "./dev-loop.js";
28
+ import { demoPaneList, devLoopPaneList, launchLayout, renderLayout, standaloneDirs, } from "./panes.js";
23
29
  import { demoDown, demoPorts, demoSlug, runDemo, runExportCheck, } from "./run.js";
24
- const USAGE = `usage: demo <up|check|down|spec> [--mode dev|image|standalone] [--daemon private|reuse] [--export-only] [--json] [--spec <path>]
30
+ const USAGE = `usage: demo <up|check|down|spec|ui> [--mode dev|image|standalone] [--daemon private|reuse] [--export-only] [--json]
31
+ [--ui zellij|tmux|none] [--public-host <host>] [--spec <path>]
32
+ demo dev-loop <up|refresh|down|ui> [--ui zellij|tmux|none] [--public-host <host>] [--spec <path>]
25
33
 
26
34
  up launch, print the URLs, hold (Ctrl-C tears down)
27
35
  check up, then tear down and exit 0/1 — what CI runs, always on a private daemon
@@ -29,6 +37,14 @@ const USAGE = `usage: demo <up|check|down|spec> [--mode dev|image|standalone] [-
29
37
  build the template, export the standalone workdir, assert every symlink resolves
30
38
  down tear down what \`up\` recorded
31
39
  spec [--json] print the resolved spec without launching anything
40
+ ui open a multiplexer: a pane running \`up\`, the instance's logs, a shell
41
+
42
+ dev-loop up the standalone tier: private daemon + dev backend, template built from source,
43
+ workdir exported with the live-source links (NORSK_DIR, STUDIO_DIR, TO name the
44
+ engine and Studio checkouts and the workdir); holds
45
+ dev-loop refresh rebuild + re-export against the held \`up\` (after a component or dashboard edit)
46
+ dev-loop down end the held \`up\` from any shell; the workdir stays
47
+ dev-loop ui open a multiplexer: engine, \`dev-loop up\`, Studio, the sources (on Enter), a shell
32
48
 
33
49
  --mode dev run the product from source (bun run dev) and add it by URL
34
50
  --mode image add the built product image — the customer path
@@ -36,14 +52,37 @@ const USAGE = `usage: demo <up|check|down|spec> [--mode dev|image|standalone] [-
36
52
  --daemon reuse your listening daemon and real store; the driver deletes the instance,
37
53
  removes the template and product, then adds again (stored templates
38
54
  are immutable by name)
55
+ --ui zellij|tmux|none the multiplexer (\`none\` prints the pane commands); zellij unless said
56
+ --public-host <host> the name other machines reach this box by: every URL, the daemon's
57
+ publicHost, Studio's MoQ preview (default: PUBLIC_HOST)
39
58
  --spec <path> the demo spec (default tests/demo.spec.ts)`;
40
59
  const DEFAULT_SPEC = "tests/demo.spec.ts";
60
+ const DEMO_ACTIONS = ["up", "check", "down", "spec", "ui"];
61
+ const DEV_LOOP_ACTIONS = ["up", "refresh", "down", "ui"];
41
62
  export function parseDemoArgs(argv) {
42
- const [action, ...rest] = argv;
43
- if (action !== "up" && action !== "check" && action !== "down" && action !== "spec") {
63
+ const devLoop = argv[0] === "dev-loop";
64
+ const [action, ...rest] = devLoop ? argv.slice(1) : argv;
65
+ const actions = devLoop ? DEV_LOOP_ACTIONS : DEMO_ACTIONS;
66
+ if (action === undefined || !actions.includes(action)) {
67
+ if (devLoop)
68
+ throw new Error(`dev-loop takes one of up|refresh|down|ui${action ? `, not '${action}'` : ""}\n${USAGE}`);
69
+ if (action === "refresh")
70
+ throw new Error(`refresh is the dev-loop's: \`dev-loop refresh\`\n${USAGE}`);
44
71
  throw new Error(action ? `unknown action '${action}'\n${USAGE}` : USAGE);
45
72
  }
46
- const args = { action, daemon: "private", exportOnly: false, json: false, spec: DEFAULT_SPEC };
73
+ const args = {
74
+ devLoop,
75
+ action: action,
76
+ daemon: "private",
77
+ exportOnly: false,
78
+ json: false,
79
+ spec: DEFAULT_SPEC,
80
+ };
81
+ if (action === "ui")
82
+ args.ui = "zellij";
83
+ const envHost = process.env.PUBLIC_HOST;
84
+ if (envHost)
85
+ args.publicHost = envHost;
47
86
  for (let i = 0; i < rest.length; i++) {
48
87
  const flag = rest[i];
49
88
  const value = () => {
@@ -76,12 +115,27 @@ export function parseDemoArgs(argv) {
76
115
  case "--spec":
77
116
  args.spec = value();
78
117
  break;
118
+ case "--ui": {
119
+ const u = value();
120
+ if (u !== "zellij" && u !== "tmux" && u !== "none")
121
+ throw new Error(`unknown ui '${u}'\n${USAGE}`);
122
+ args.ui = u;
123
+ break;
124
+ }
125
+ case "--public-host":
126
+ args.publicHost = value();
127
+ break;
79
128
  default:
80
129
  throw new Error(`unknown flag '${flag}'\n${USAGE}`);
81
130
  }
82
131
  }
83
- if (args.mode === "standalone" && !args.exportOnly && (args.action === "up" || args.action === "check")) {
84
- throw new Error("the full standalone tier (engine + Studio panes) is not yet implemented (05-demo s7 step 3); this slice has `check --mode standalone --export-only`");
132
+ if (args.ui !== undefined && args.action !== "ui")
133
+ throw new Error("--ui belongs to ui (or dev-loop ui)");
134
+ if (devLoop && (args.mode !== undefined || args.daemon !== "private" || args.exportOnly || args.json)) {
135
+ throw new Error("dev-loop takes only --ui, --public-host and --spec (no --mode, --daemon, --export-only or --json): it is always the source tier on a private daemon");
136
+ }
137
+ if (args.mode === "standalone" && !args.exportOnly && args.action !== "spec") {
138
+ throw new Error(`the full standalone tier is the dev-loop's: \`dev-loop ${args.action === "ui" ? "ui" : "up"}\` (this mode's headless gate is \`check --mode standalone --export-only\`)`);
85
139
  }
86
140
  if (args.exportOnly && args.mode !== "standalone") {
87
141
  throw new Error("--export-only belongs to --mode standalone");
@@ -98,16 +152,17 @@ export function resolvedSpecView(spec, opts) {
98
152
  const slug = opts.slug ?? demoSlug(spec.product);
99
153
  const instanceId = `demo-${slug}`;
100
154
  const ports = demoPorts(slug);
155
+ const host = opts.publicHost ?? "localhost";
101
156
  const pinned = spec.launch?.params?.STUDIO_HOST_PORT;
102
157
  const studioHostPort = pinned !== undefined ? Number(pinned) : ports.studioHostPort;
103
158
  const url = (ref) => {
104
159
  if ("url" in ref)
105
160
  return ref.url;
106
161
  if ("control" in ref)
107
- return `http://localhost:${ports.backendPort}${ref.control}`;
162
+ return `http://${host}:${ports.backendPort}${ref.control}`;
108
163
  if ("proxy" in ref)
109
- return `http://localhost:${ports.proxyPort}${ref.proxy.replaceAll("{id}", instanceId)}`;
110
- return `http://localhost:${studioHostPort}${ref.studio}`;
164
+ return `http://${host}:${ports.proxyPort}${ref.proxy.replaceAll("{id}", instanceId)}`;
165
+ return `http://${host}:${studioHostPort}${ref.studio}`;
111
166
  };
112
167
  const t = spec.template;
113
168
  const template = t && "build" in t
@@ -176,30 +231,59 @@ export async function demoMain(argv, io) {
176
231
  return 1;
177
232
  }
178
233
  const mode = args.mode ?? spec.mode ?? "dev";
234
+ const slug = demoSlug(spec.product);
235
+ const publicHost = args.publicHost !== undefined ? { publicHost: args.publicHost } : {};
179
236
  try {
237
+ if (args.devLoop) {
238
+ const dirs = standaloneDirs(spec, slug, io.env ?? process.env);
239
+ switch (args.action) {
240
+ case "up":
241
+ case "refresh":
242
+ await io.runDevLoop(spec, {
243
+ action: args.action,
244
+ cwd: io.cwd,
245
+ dirs,
246
+ ...publicHost,
247
+ ...(io.abort ? { abort: io.abort } : {}),
248
+ });
249
+ return 0;
250
+ case "down":
251
+ await io.devLoopDown(spec.product);
252
+ return 0;
253
+ default: {
254
+ const panes = devLoopPaneList(spec, { cwd: io.cwd, slug, ...dirs, ...publicHost });
255
+ return io.launchUi(args.ui ?? "zellij", { session: `${slug}-dev-loop`, tab: slug, panes });
256
+ }
257
+ }
258
+ }
180
259
  switch (args.action) {
181
260
  case "spec": {
182
- const view = resolvedSpecView(spec, { mode });
261
+ const view = resolvedSpecView(spec, { mode, ...publicHost });
183
262
  io.stdout(args.json ? JSON.stringify(view, null, 2) : renderView(view));
184
263
  return 0;
185
264
  }
186
265
  case "down":
187
266
  await io.demoDown(spec.product);
188
267
  return 0;
189
- case "up":
190
- case "check": {
268
+ case "ui": {
269
+ if (mode === "standalone")
270
+ throw new Error("the standalone tier's multiplexer is `dev-loop ui`");
271
+ const panes = demoPaneList(spec, { cwd: io.cwd, slug, mode, daemon: args.daemon, ...publicHost });
272
+ return io.launchUi(args.ui ?? "zellij", { session: `${slug}-demo`, tab: slug, panes });
273
+ }
274
+ default: {
191
275
  if (args.exportOnly) {
192
276
  await io.runExportCheck(spec, { cwd: io.cwd });
193
277
  return 0;
194
278
  }
195
- if (mode === "standalone") {
196
- throw new Error("the full standalone tier is not yet implemented (05-demo s7 step 3)");
197
- }
279
+ if (mode === "standalone")
280
+ throw new Error("the full standalone tier is `dev-loop up`");
198
281
  await io.runDemo(spec, {
199
282
  action: args.action,
200
283
  mode,
201
284
  daemon: args.daemon,
202
285
  cwd: io.cwd,
286
+ ...publicHost,
203
287
  ...(io.abort ? { abort: io.abort } : {}),
204
288
  });
205
289
  return 0;
@@ -207,7 +291,7 @@ export async function demoMain(argv, io) {
207
291
  }
208
292
  }
209
293
  catch (e) {
210
- io.stderr(`demo ${args.action} failed: ${e instanceof Error ? e.message : String(e)}`);
294
+ io.stderr(`${args.devLoop ? "dev-loop" : "demo"} ${args.action} failed: ${e instanceof Error ? e.message : String(e)}`);
211
295
  return 1;
212
296
  }
213
297
  }
@@ -242,12 +326,17 @@ export function defaultDemoCliIo() {
242
326
  const release = () => controller.abort();
243
327
  process.once("SIGINT", release);
244
328
  process.once("SIGTERM", release);
329
+ // A multiplexer session ending sends its panes SIGHUP: that is a release too.
330
+ process.once("SIGHUP", release);
245
331
  return {
246
332
  cwd: process.cwd(),
247
333
  loadSpec: loadSpecModule,
248
334
  runDemo: (spec, opts) => runDemo(spec, opts),
249
335
  runExportCheck: (spec, opts) => runExportCheck(spec, opts),
250
336
  demoDown: (product) => demoDown(product),
337
+ runDevLoop: (spec, opts) => runDevLoop(spec, opts),
338
+ devLoopDown: (product) => devLoopDown(product),
339
+ launchUi: (ui, layout) => launchLayout(ui, renderLayout(layout, ui), layout),
251
340
  stdout: (line) => console.log(line),
252
341
  stderr: (line) => console.error(line),
253
342
  abort: controller.signal,
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env bun
2
+ export {};
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bun
2
+ // `ctl-dev-loop`, the bin behind a product's `bun run dev-loop`: the
3
+ // standalone tier's verbs (`up`, `refresh`, `down`, `ui`) are `ctl-demo
4
+ // dev-loop <verb>` under another name.
5
+ import { defaultDemoCliIo, demoMain } from "./cli.js";
6
+ if (import.meta.main) {
7
+ process.exit(await demoMain(["dev-loop", ...process.argv.slice(2)], defaultDemoCliIo()));
8
+ }
@@ -0,0 +1,33 @@
1
+ import type { SourceAsset } from "../source-pump.js";
2
+ import { type StandaloneDirs } from "./panes.js";
3
+ import { type DemoDeps, type DemoTimeouts } from "./run.js";
4
+ import type { DemoSpec } from "./spec.js";
5
+ export interface DevLoopOptions {
6
+ action: "up" | "refresh";
7
+ cwd: string;
8
+ slug?: string;
9
+ /** Default: `standaloneDirs(spec, slug)` — the environment, the spec, the conventions. */
10
+ dirs?: StandaloneDirs;
11
+ publicHost?: string;
12
+ abort?: AbortSignal;
13
+ timeouts?: DemoTimeouts;
14
+ }
15
+ export interface DevLoopResult {
16
+ workdir: string;
17
+ studioUrl: string;
18
+ }
19
+ export interface StandaloneSource {
20
+ name: string;
21
+ port: number;
22
+ streamId: string;
23
+ asset: SourceAsset;
24
+ }
25
+ /** The pump script the sources pane runs on Enter: every source in
26
+ * parallel with the host's ffmpeg, into the from-source engine. A preset is
27
+ * fetched into the daemon's sample-media cache if it is not there yet; a
28
+ * mediaFile that is absent on this box pumps its fallback. */
29
+ export declare function pumpScript(sources: StandaloneSource[], fileExists: (path: string) => boolean): string;
30
+ export declare function runDevLoop(spec: DemoSpec, opts: DevLoopOptions, deps?: DemoDeps): Promise<DevLoopResult>;
31
+ /** `dev-loop down`: end the recorded run from any shell. The workdir stays —
32
+ * it is the developer's, and Studio may still be looking at it. */
33
+ export declare function devLoopDown(product: string, deps?: DemoDeps): Promise<void>;
@@ -0,0 +1,205 @@
1
+ // The full standalone tier (05-demo s7 step 3): probe's `refresh-standalone`
2
+ // and the sequencing half of `probe-demo-zellij`, for every product. The
3
+ // engine and Studio run from their checkouts (the `dev-loop ui` panes); this
4
+ // driver owns the rest — a private daemon, the dev backend on the driver's
5
+ // port, the template built from source, the workdir exported with the
6
+ // live-source links, the pump script resolved from the template's ports, and
7
+ // the stamp the Studio pane waits for. `up` holds so `refresh` can re-export
8
+ // against the same daemon and backend; `down` tears them down and leaves the
9
+ // developer's workdir where it is.
10
+ import { join } from "node:path";
11
+ import { DEFAULT_STANDALONE_STUDIO_PORT, DEV_LOOP_SOURCES, DEV_LOOP_STAMP, standaloneDirs, } from "./panes.js";
12
+ import { DemoSession, defaultDemoDeps, demoSlug, exportStandaloneWorkdir, resolveIngestPort, } from "./run.js";
13
+ /** The daemon's built-in presets and where it caches them (norsk-ctl
14
+ * backend/src/docker/constants.ts: SOURCE_PRESETS, S3_MEDIA_BASE); the
15
+ * standalone tier pumps from the same files with the host's ffmpeg. */
16
+ const SAMPLE_MEDIA_BASE = "https://s3.eu-west-1.amazonaws.com/norsk.video/media-examples/data";
17
+ const SAMPLE_MEDIA_FILES = { camera1: "action.mp4", camera2: "wildlife.ts" };
18
+ const SAMPLE_MEDIA_DIR = "$HOME/.norsk-ctl/sample-media";
19
+ function generated(pattern) {
20
+ const video = pattern === "bars" ? "smptebars=size=1280x720:rate=25" : "testsrc=size=1280x720:rate=25";
21
+ return `ffmpeg -hide_banner -loglevel warning -re -f lavfi -i "${video}" -f lavfi -i "sine=frequency=440:sample_rate=48000" -c:v libx264 -preset veryfast -tune zerolatency -g 50 -c:a aac`;
22
+ }
23
+ function pumpLines(asset, target) {
24
+ if ("mediaFile" in asset) {
25
+ return [
26
+ `ffmpeg -hide_banner -loglevel warning -re -stream_loop -1 -i "${asset.mediaFile}" -c copy -f mpegts "${target}" &`,
27
+ ];
28
+ }
29
+ if ("generate" in asset)
30
+ return [`${generated(asset.generate)} -f mpegts "${target}" &`];
31
+ const file = SAMPLE_MEDIA_FILES[asset.preset];
32
+ if (!file)
33
+ throw new Error(`unknown preset '${asset.preset}'; the daemon's presets are ${Object.keys(SAMPLE_MEDIA_FILES).join(", ")}`);
34
+ return [
35
+ `f="${SAMPLE_MEDIA_DIR}/${file}"; [ -f "$f" ] || curl -fL --create-dirs -o "$f" "${SAMPLE_MEDIA_BASE}/${file}"`,
36
+ `ffmpeg -hide_banner -loglevel warning -re -stream_loop -1 -i "$f" -c copy -f mpegts "${target}" &`,
37
+ ];
38
+ }
39
+ /** The pump script the sources pane runs on Enter: every source in
40
+ * parallel with the host's ffmpeg, into the from-source engine. A preset is
41
+ * fetched into the daemon's sample-media cache if it is not there yet; a
42
+ * mediaFile that is absent on this box pumps its fallback. */
43
+ export function pumpScript(sources, fileExists) {
44
+ const lines = [
45
+ "#!/usr/bin/env bash",
46
+ "# written by dev-loop up: the demo's sources, pumped from this box into the from-source engine",
47
+ "set -uo pipefail",
48
+ ];
49
+ for (const s of sources) {
50
+ const target = `srt://127.0.0.1:${s.port}?streamid=${s.streamId}`;
51
+ const asset = "mediaFile" in s.asset && !fileExists(s.asset.mediaFile) ? s.asset.fallback : s.asset;
52
+ const via = "mediaFile" in s.asset && !fileExists(s.asset.mediaFile) ? ` (${s.asset.mediaFile} is not on this box)` : "";
53
+ lines.push("", `# ${s.name}: ${JSON.stringify(asset)} -> ${target}${via}`, ...pumpLines(asset, target));
54
+ }
55
+ lines.push("", "wait");
56
+ return `${lines.join("\n")}\n`;
57
+ }
58
+ function standalonePort(ingest, t) {
59
+ if ("label" in ingest) {
60
+ throw new Error(`ingest label '${ingest.label}' — the standalone tier has no instance to resolve a label against; name the template parameter (ingest: { param })`);
61
+ }
62
+ if ("port" in ingest)
63
+ return ingest.port;
64
+ return resolveIngestPort(ingest, [], t);
65
+ }
66
+ /** The standalone tier's template: its own when the spec gives one, else the demo's. */
67
+ function loopTemplate(spec) {
68
+ return spec.standalone?.template ?? spec.template;
69
+ }
70
+ function studioPortOf(envText) {
71
+ const m = /^export PORT='?(\d+)/m.exec(envText ?? "");
72
+ return m ? Number(m[1]) : DEFAULT_STANDALONE_STUDIO_PORT;
73
+ }
74
+ /** Build (or reload) the template, export the workdir, write the pump script
75
+ * and finally the stamp — the order the panes rely on. */
76
+ async function exportLoop(s, templateName, workdir, refresh) {
77
+ const { spec, deps } = s;
78
+ deps.removeFile(join(workdir, DEV_LOOP_STAMP));
79
+ if (refresh) {
80
+ const t = loopTemplate(spec);
81
+ if (t && "build" in t)
82
+ await s.resolveTemplate(t);
83
+ else
84
+ await s.cliOk(["product", "reload", spec.product]);
85
+ }
86
+ await exportStandaloneWorkdir(s, templateName, workdir);
87
+ const declared = await s.declaredParams(templateName);
88
+ const t = { declared, overrides: spec.standalone?.params ?? {} };
89
+ const sources = (spec.sources ?? []).map((src) => {
90
+ const asset = src.asset ?? { preset: "camera1" };
91
+ return { name: src.name, port: standalonePort(src.ingest, t), streamId: src.streamId ?? src.name, asset };
92
+ });
93
+ deps.writeFile(join(workdir, DEV_LOOP_SOURCES), pumpScript(sources, deps.fileExists));
94
+ deps.writeFile(join(workdir, DEV_LOOP_STAMP), `${new Date().toISOString()}\n`);
95
+ return studioPortOf(deps.readFile(join(workdir, "env")));
96
+ }
97
+ function describeLoop(s, dirs, studioPort) {
98
+ const { spec } = s;
99
+ const url = `http://${s.host}:${studioPort}`;
100
+ return [
101
+ `${spec.product} standalone workdir exported to ${dirs.workdir} (template from the dev backend on :${s.ports.backendPort})`,
102
+ ` engine cd ${dirs.norskDir} && nix develop --command make media-shell`,
103
+ ` studio cd ${dirs.studioDir} && (. ${dirs.workdir}/env && npm run server-dev) -> ${url}`,
104
+ ` sources bash ${join(dirs.workdir, DEV_LOOP_SOURCES)} (into the engine, when the workflow is running)`,
105
+ ` after a shared/ edit: restart this (the dev backend does not watch shared/); a dashboard edit needs only a browser refresh`,
106
+ ];
107
+ }
108
+ async function refuseIfUp(spec, deps) {
109
+ const prev = deps.state.read(spec.product, "dev-loop");
110
+ if (!prev)
111
+ return;
112
+ if (await deps.daemonAnswers(prev.daemonPort)) {
113
+ throw new Error(`dev-loop '${spec.product}' is already up (daemon :${prev.daemonPort}, workdir ${prev.workdir}) — \`dev-loop refresh\` re-exports it, \`dev-loop down\` ends it`);
114
+ }
115
+ deps.log(`forgetting a stale dev-loop record of a run on :${prev.daemonPort} (nothing of it is left)`);
116
+ deps.state.remove(spec.product, "dev-loop");
117
+ }
118
+ export async function runDevLoop(spec, opts, deps = defaultDemoDeps(opts.cwd)) {
119
+ const slug = opts.slug ?? demoSlug(spec.product);
120
+ const dirs = opts.dirs ?? standaloneDirs(spec, slug);
121
+ const common = {
122
+ mode: "dev",
123
+ daemon: "private",
124
+ ...(opts.timeouts !== undefined ? { timeouts: opts.timeouts } : {}),
125
+ ...(opts.publicHost !== undefined ? { publicHost: opts.publicHost } : {}),
126
+ };
127
+ if (opts.action === "refresh") {
128
+ const state = deps.state.read(spec.product, "dev-loop");
129
+ if (!state || !(await deps.daemonAnswers(state.daemonPort)) || !state.templateName) {
130
+ throw new Error(`dev-loop '${spec.product}' is not up — run \`dev-loop up\` first (it holds; refresh from another pane)`);
131
+ }
132
+ const s = new DemoSession(spec, slug, opts.cwd, deps, { ...common, storeDir: state.storeDir });
133
+ const workdir = state.workdir ?? dirs.workdir;
134
+ const studioPort = await exportLoop(s, state.templateName, workdir, true);
135
+ for (const line of describeLoop(s, { ...dirs, workdir }, studioPort))
136
+ deps.log(line);
137
+ deps.log("refreshed — reload the workflow in Studio");
138
+ return { workdir, studioUrl: `http://${s.host}:${studioPort}` };
139
+ }
140
+ await refuseIfUp(spec, deps);
141
+ const s = new DemoSession(spec, slug, opts.cwd, deps, common);
142
+ let recorded = false;
143
+ let result;
144
+ let journeyError;
145
+ try {
146
+ await s.attachDaemon();
147
+ await s.startDev();
148
+ await s.register({ licence: false });
149
+ const templateName = await s.resolveTemplate(loopTemplate(spec));
150
+ const studioPort = await exportLoop(s, templateName, dirs.workdir, false);
151
+ for (const line of describeLoop(s, dirs, studioPort))
152
+ deps.log(line);
153
+ result = { workdir: dirs.workdir, studioUrl: `http://${s.host}:${studioPort}` };
154
+ const state = {
155
+ product: spec.product,
156
+ kind: "dev-loop",
157
+ storeDir: s.storeDir,
158
+ daemonPort: s.ports.daemonPort,
159
+ workdir: dirs.workdir,
160
+ templateName,
161
+ ...(s.dev?.pid !== undefined ? { devPid: s.dev.pid } : {}),
162
+ };
163
+ deps.state.write(state);
164
+ recorded = true;
165
+ deps.log("holding — Ctrl-C (or `dev-loop down` from another shell) ends it; `dev-loop refresh` re-exports");
166
+ await deps.hold(opts.abort);
167
+ }
168
+ catch (e) {
169
+ journeyError = e;
170
+ }
171
+ await s.teardown({ instances: [], handles: [] });
172
+ if (recorded)
173
+ deps.state.remove(spec.product, "dev-loop");
174
+ if (journeyError !== undefined)
175
+ throw journeyError;
176
+ return result;
177
+ }
178
+ /** `dev-loop down`: end the recorded run from any shell. The workdir stays —
179
+ * it is the developer's, and Studio may still be looking at it. */
180
+ export async function devLoopDown(product, deps = defaultDemoDeps(process.cwd())) {
181
+ const state = deps.state.read(product, "dev-loop");
182
+ if (!state) {
183
+ deps.log(`nothing recorded as up for the ${product} dev-loop`);
184
+ return;
185
+ }
186
+ const cli = (argv) => deps.cli(state.storeDir, ["--port", String(state.daemonPort), ...argv]);
187
+ try {
188
+ await deps.cleanup({
189
+ deleteInstance: (id) => cli(["instance", "delete", id, "--purge"]),
190
+ stopDaemon: () => cli(["shutdown"]),
191
+ instances: [],
192
+ daemon: null,
193
+ storeDir: state.storeDir,
194
+ containers: [],
195
+ });
196
+ }
197
+ catch (e) {
198
+ deps.log(`cleanup: ${e instanceof Error ? e.message : String(e)}; nuking the store as root`);
199
+ }
200
+ if (state.devPid !== undefined)
201
+ deps.killPid?.(state.devPid);
202
+ deps.nukeStoreAsRoot(state.storeDir);
203
+ deps.state.remove(product, "dev-loop");
204
+ deps.log(`${product} dev-loop torn down (the workdir ${state.workdir ?? ""} is untouched)`);
205
+ }
package/demo/index.d.ts CHANGED
@@ -1,7 +1,11 @@
1
1
  export type { DemoArgs, DemoCliIo, ResolvedSpecView } from "./cli.js";
2
2
  export { defaultDemoCliIo, demoMain, parseDemoArgs, resolvedSpecView } from "./cli.js";
3
+ export type { DevLoopOptions, DevLoopResult, StandaloneSource } from "./dev-loop.js";
4
+ export { devLoopDown, pumpScript, runDevLoop } from "./dev-loop.js";
3
5
  export { composeAdvancingGate, DemoGateFailure, logsCleanGate } from "./gates.js";
4
- export type { CliResult, DemoDeps, DemoPorts, DemoRunOptions, DemoRunResult, DemoState, DemoStateStore, DemoTimeouts, ExportCheckOptions, IngestPortRow, PinMismatch, ProcessHandle, TemplateParams, } from "./run.js";
5
- export { checkTemplatePins, defaultDemoDeps, demoDown, demoPorts, demoSlug, expandHome, fileStateStore, findBrokenSymlinks, killGroup, resolveIngestPort, runDemo, runExportCheck, } from "./run.js";
6
+ export type { DemoPane, DemoPaneOptions, DemoUi, DevLoopPaneOptions, PaneLayout, StandaloneDirs } from "./panes.js";
7
+ export { DEV_LOOP_SOURCES, DEV_LOOP_STAMP, demoPaneList, devLoopPaneList, launchLayout, paneArgv, paneCommandLine, paneShellLine, renderLayout, shellQuote, standaloneDirs, } from "./panes.js";
8
+ export type { CliResult, DemoDeps, DemoPorts, DemoRunOptions, DemoRunResult, DemoState, DemoStateKind, DemoStateStore, DemoTimeouts, ExportCheckOptions, IngestPortRow, PinMismatch, ProcessHandle, TemplateParams, } from "./run.js";
9
+ export { checkTemplatePins, defaultDemoDeps, demoDown, demoPorts, demoSlug, expandHome, exportStandaloneWorkdir, fileStateStore, findBrokenSymlinks, killGroup, resolveIngestPort, runDemo, runExportCheck, } from "./run.js";
6
10
  export type { DemoContext, DemoDaemonPolicy, DemoExtra, DemoIngest, DemoLaunchContext, DemoMode, DemoOpen, DemoReady, DemoSource, DemoSpec, DemoTemplate, DemoUrl, } from "./spec.js";
7
11
  export { DemoSpecSchema, defineDemo, formatSpecIssues } from "./spec.js";
package/demo/index.js CHANGED
@@ -1,4 +1,6 @@
1
1
  export { defaultDemoCliIo, demoMain, parseDemoArgs, resolvedSpecView } from "./cli.js";
2
+ export { devLoopDown, pumpScript, runDevLoop } from "./dev-loop.js";
2
3
  export { composeAdvancingGate, DemoGateFailure, logsCleanGate } from "./gates.js";
3
- export { checkTemplatePins, defaultDemoDeps, demoDown, demoPorts, demoSlug, expandHome, fileStateStore, findBrokenSymlinks, killGroup, resolveIngestPort, runDemo, runExportCheck, } from "./run.js";
4
+ export { DEV_LOOP_SOURCES, DEV_LOOP_STAMP, demoPaneList, devLoopPaneList, launchLayout, paneArgv, paneCommandLine, paneShellLine, renderLayout, shellQuote, standaloneDirs, } from "./panes.js";
5
+ export { checkTemplatePins, defaultDemoDeps, demoDown, demoPorts, demoSlug, expandHome, exportStandaloneWorkdir, fileStateStore, findBrokenSymlinks, killGroup, resolveIngestPort, runDemo, runExportCheck, } from "./run.js";
4
6
  export { DemoSpecSchema, defineDemo, formatSpecIssues } from "./spec.js";
@@ -0,0 +1,81 @@
1
+ import type { DemoDaemonPolicy, DemoMode, DemoSpec } from "./spec.js";
2
+ export type DemoUi = "zellij" | "tmux" | "none";
3
+ export interface DemoPane {
4
+ name: string;
5
+ /** Directory the pane starts in. */
6
+ cwd: string;
7
+ /** One shell line. `run`: a long-running process; `gate`: waits for
8
+ * something, then acts; `shell`: an interactive shell (command ignored).
9
+ * Every kind lands in a shell when its command exits, so a pane is never a
10
+ * dead box. */
11
+ kind: "run" | "gate" | "shell";
12
+ command: string;
13
+ /** The flake ref whose dev shell the command runs under (`.#dev`, `.`). */
14
+ flake?: string;
15
+ }
16
+ export interface PaneLayout {
17
+ session: string;
18
+ tab: string;
19
+ panes: DemoPane[];
20
+ }
21
+ /** The stamp `dev-loop up` touches once the workdir is exported (the Studio
22
+ * pane waits for it) and the pump script it writes (the sources pane runs
23
+ * it on Enter). Both live in the workdir so a re-export replaces them. */
24
+ export declare const DEV_LOOP_STAMP = ".dev-loop-exported";
25
+ export declare const DEV_LOOP_SOURCES = ".dev-loop-sources";
26
+ export declare const DEFAULT_STANDALONE_STUDIO_PORT = 8000;
27
+ export declare const INSTANCE_LABEL = "norsk-ctl.instance";
28
+ export declare const MEDIA_ROLE_LABEL = "norsk-ctl.role=media";
29
+ /** POSIX single-quoting: safe for any bytes but a NUL. */
30
+ export declare function shellQuote(s: string): string;
31
+ /** The bash line a pane runs, before any nix wrapping. */
32
+ export declare function paneShellLine(pane: DemoPane): string;
33
+ /** The pane's process: `nix develop <flake> --command bash -c <line>` when
34
+ * the pane names a flake, else bash alone. */
35
+ export declare function paneArgv(pane: DemoPane): string[];
36
+ /** `paneArgv` as one shell-quoted line (tmux, none). */
37
+ export declare function paneCommandLine(pane: DemoPane): string;
38
+ export interface DemoPaneOptions {
39
+ cwd: string;
40
+ slug: string;
41
+ mode: DemoMode;
42
+ daemon: DemoDaemonPolicy;
43
+ publicHost?: string;
44
+ /** The product's dev shell; `.#dev` is what every product flake names. */
45
+ flake?: string;
46
+ }
47
+ /** `demo ui`: the gate pane holds `demo up`, a logs pane follows the
48
+ * instance's media container (found by label, so both container naming
49
+ * schemes work), and a shell in the product's dev shell. */
50
+ export declare function demoPaneList(_spec: DemoSpec, o: DemoPaneOptions): DemoPane[];
51
+ export interface StandaloneDirs {
52
+ norskDir: string;
53
+ studioDir: string;
54
+ workdir: string;
55
+ }
56
+ /** Where the engine, Studio and the exported workdir live: the environment
57
+ * (NORSK_DIR, STUDIO_DIR, TO — this developer's box), else the spec (the
58
+ * repo's convention), else `~/dev/norsk`, `~/dev/norsk-studio`,
59
+ * `~/dev/<slug>-standalone` (what probe-demo-zellij assumed). */
60
+ export declare function standaloneDirs(spec: DemoSpec, slug: string, env?: Record<string, string | undefined>): StandaloneDirs;
61
+ export interface DevLoopPaneOptions extends StandaloneDirs {
62
+ cwd: string;
63
+ slug: string;
64
+ publicHost?: string;
65
+ flake?: string;
66
+ /** The exported workdir's Studio port (export-workdir's default). */
67
+ studioPort?: number;
68
+ }
69
+ /** `dev-loop ui`: the engine and Studio from their checkouts, the gate pane
70
+ * holding `dev-loop up`, the sources pane running the driver's resolved pump
71
+ * script on Enter, and a shell. Studio waits for the export stamp, sources
72
+ * the exported env, and — with a public host — advertises the MoQ preview
73
+ * there and drops the localhost-only cert Studio would otherwise reuse on
74
+ * remaining validity alone (probe-demo-zellij:142-149). */
75
+ export declare function devLoopPaneList(_spec: DemoSpec, o: DevLoopPaneOptions): DemoPane[];
76
+ export declare function renderLayout(layout: PaneLayout, ui: DemoUi): string;
77
+ /** Open the rendered layout: zellij's client blocks until detach and the
78
+ * session is deleted after it (a trailing line, not a trap: the panes'
79
+ * processes get SIGHUP, which the driver's hold treats as a release); tmux's
80
+ * script attaches and returns on detach; none prints. */
81
+ export declare function launchLayout(ui: DemoUi, rendered: string, layout: PaneLayout): number;
package/demo/panes.js ADDED
@@ -0,0 +1,236 @@
1
+ // The multiplexer front-end (05-demo s4 "front-end 2", s7 step 3). The pane
2
+ // list is the contract; zellij (a kdl layout), tmux (a script) and none (the
3
+ // commands, to paste) are renderers of it. Every pane runs `nix develop
4
+ // <flake> --command ...` so the launcher itself never needs a nix shell —
5
+ // which dissolves the awk-vs-bun problem probe-demo-zellij documented.
6
+ //
7
+ // Ordering never lives here: the gate pane runs `demo up` / `dev-loop up`,
8
+ // and the driver sequences everything. The other panes only wait for what the
9
+ // driver leaves on disk (the export stamp, the resolved pump script).
10
+ import { spawnSync } from "node:child_process";
11
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+ /** The stamp `dev-loop up` touches once the workdir is exported (the Studio
15
+ * pane waits for it) and the pump script it writes (the sources pane runs
16
+ * it on Enter). Both live in the workdir so a re-export replaces them. */
17
+ export const DEV_LOOP_STAMP = ".dev-loop-exported";
18
+ export const DEV_LOOP_SOURCES = ".dev-loop-sources";
19
+ export const DEFAULT_STANDALONE_STUDIO_PORT = 8000;
20
+ export const INSTANCE_LABEL = "norsk-ctl.instance";
21
+ export const MEDIA_ROLE_LABEL = "norsk-ctl.role=media";
22
+ /** POSIX single-quoting: safe for any bytes but a NUL. */
23
+ export function shellQuote(s) {
24
+ return `'${s.replaceAll("'", `'\\''`)}'`;
25
+ }
26
+ /** The bash line a pane runs, before any nix wrapping. */
27
+ export function paneShellLine(pane) {
28
+ const shell = `exec \${SHELL:-bash}`;
29
+ return pane.kind === "shell" ? shell : `${pane.command}; ${shell}`;
30
+ }
31
+ /** The pane's process: `nix develop <flake> --command bash -c <line>` when
32
+ * the pane names a flake, else bash alone. */
33
+ export function paneArgv(pane) {
34
+ const line = paneShellLine(pane);
35
+ return pane.flake ? ["nix", "develop", pane.flake, "--command", "bash", "-c", line] : ["bash", "-c", line];
36
+ }
37
+ /** `paneArgv` as one shell-quoted line (tmux, none). */
38
+ export function paneCommandLine(pane) {
39
+ return paneArgv(pane)
40
+ .map((a) => (/^[A-Za-z0-9_./#=:-]+$/.test(a) ? a : shellQuote(a)))
41
+ .join(" ");
42
+ }
43
+ const DEV_FLAKE = ".#dev";
44
+ /** `demo ui`: the gate pane holds `demo up`, a logs pane follows the
45
+ * instance's media container (found by label, so both container naming
46
+ * schemes work), and a shell in the product's dev shell. */
47
+ export function demoPaneList(_spec, o) {
48
+ const flake = o.flake ?? DEV_FLAKE;
49
+ const instanceId = `demo-${o.slug}`;
50
+ const up = ["bun run demo -- up", "--mode", o.mode];
51
+ if (o.daemon === "reuse")
52
+ up.push("--daemon", "reuse");
53
+ if (o.publicHost)
54
+ up.push("--public-host", o.publicHost);
55
+ const find = `docker ps -q -f label=${INSTANCE_LABEL}=${instanceId} -f label=${MEDIA_ROLE_LABEL}`;
56
+ return [
57
+ { name: "demo", kind: "gate", cwd: o.cwd, flake, command: up.join(" ") },
58
+ {
59
+ name: "logs",
60
+ kind: "gate",
61
+ cwd: o.cwd,
62
+ command: `echo "waiting for instance ${instanceId} ..."; until [ -n "$(${find})" ]; do sleep 1; done; docker logs -f "$(${find})"`,
63
+ },
64
+ { name: "shell", kind: "shell", cwd: o.cwd, flake, command: "" },
65
+ ];
66
+ }
67
+ function expandWith(path, home) {
68
+ if (path === "~")
69
+ return home;
70
+ if (path.startsWith("~/"))
71
+ return join(home, path.slice(2));
72
+ return path;
73
+ }
74
+ /** Where the engine, Studio and the exported workdir live: the environment
75
+ * (NORSK_DIR, STUDIO_DIR, TO — this developer's box), else the spec (the
76
+ * repo's convention), else `~/dev/norsk`, `~/dev/norsk-studio`,
77
+ * `~/dev/<slug>-standalone` (what probe-demo-zellij assumed). */
78
+ export function standaloneDirs(spec, slug, env = process.env) {
79
+ const home = env.HOME ?? "~";
80
+ const pick = (envKey, fromSpec, fallback) => expandWith(env[envKey] || fromSpec || fallback, home);
81
+ return {
82
+ norskDir: pick("NORSK_DIR", spec.standalone?.norskDir, "~/dev/norsk"),
83
+ studioDir: pick("STUDIO_DIR", spec.standalone?.studioDir, "~/dev/norsk-studio"),
84
+ workdir: pick("TO", spec.standalone?.workdir, `~/dev/${slug}-standalone`),
85
+ };
86
+ }
87
+ /** `dev-loop ui`: the engine and Studio from their checkouts, the gate pane
88
+ * holding `dev-loop up`, the sources pane running the driver's resolved pump
89
+ * script on Enter, and a shell. Studio waits for the export stamp, sources
90
+ * the exported env, and — with a public host — advertises the MoQ preview
91
+ * there and drops the localhost-only cert Studio would otherwise reuse on
92
+ * remaining validity alone (probe-demo-zellij:142-149). */
93
+ export function devLoopPaneList(_spec, o) {
94
+ const flake = o.flake ?? DEV_FLAKE;
95
+ const studioPort = o.studioPort ?? DEFAULT_STANDALONE_STUDIO_PORT;
96
+ const stamp = join(o.workdir, DEV_LOOP_STAMP);
97
+ const sources = join(o.workdir, DEV_LOOP_SOURCES);
98
+ const up = ["bun run dev-loop -- up"];
99
+ if (o.publicHost)
100
+ up.push("--public-host", o.publicHost);
101
+ const wait = `until [ -f ${stamp} ]; do sleep 1; done`;
102
+ const studio = [`echo "waiting for ${o.workdir} to be exported ..."`, wait, `. ${o.workdir}/env`];
103
+ if (o.publicHost) {
104
+ studio.push(`export PUBLIC_URL_PREFIX=http://${o.publicHost}:${studioPort}`, `rm -rf ${o.workdir}/data/moq-certs`, `echo "MoQ preview advertised as http://${o.publicHost}:${studioPort}; cert regenerating to cover ${o.publicHost}"`);
105
+ }
106
+ studio.push("npm run server-dev");
107
+ return [
108
+ { name: "norsk", kind: "run", cwd: o.norskDir, flake: ".", command: "make media-shell" },
109
+ { name: "dev-loop", kind: "gate", cwd: o.cwd, flake, command: up.join(" ") },
110
+ { name: "studio", kind: "gate", cwd: o.studioDir, flake: ".", command: studio.join("; ") },
111
+ {
112
+ name: "sources",
113
+ kind: "gate",
114
+ cwd: o.cwd,
115
+ flake,
116
+ command: `${wait}; cat ${sources}; echo "press Enter to start the sources"; read -r; bash ${sources}`,
117
+ },
118
+ { name: "shell", kind: "shell", cwd: o.cwd, flake, command: "" },
119
+ ];
120
+ }
121
+ function kdlString(s) {
122
+ return `"${s.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
123
+ }
124
+ function kdlPane(pane, indent) {
125
+ const [command, ...args] = paneArgv(pane);
126
+ return [
127
+ `${indent}pane name=${kdlString(pane.name)} cwd=${kdlString(pane.cwd)} command=${kdlString(command)} {`,
128
+ `${indent} args ${args.map(kdlString).join(" ")}`,
129
+ `${indent}}`,
130
+ ].join("\n");
131
+ }
132
+ /** Two columns, the first half of the list on the left — as probe's layout
133
+ * did (things that run on the left, things that wait on the right). The tab
134
+ * and status bars are declared so the keybinding help is always present. */
135
+ function renderZellij(layout) {
136
+ const half = Math.ceil(layout.panes.length / 2);
137
+ const column = (panes) => [
138
+ ` pane split_direction="horizontal" {`,
139
+ ...panes.map((p) => kdlPane(p, " ")),
140
+ " }",
141
+ ].join("\n");
142
+ return `${[
143
+ "// rendered by ctl-demo --ui zellij; the pane list is the contract, this file is a view of it",
144
+ "layout {",
145
+ " default_tab_template {",
146
+ " pane size=1 borderless=true {",
147
+ ' plugin location="zellij:tab-bar"',
148
+ " }",
149
+ " children",
150
+ " pane size=2 borderless=true {",
151
+ ' plugin location="zellij:status-bar"',
152
+ " }",
153
+ " }",
154
+ ` tab name=${kdlString(layout.tab)} {`,
155
+ ` pane split_direction="vertical" {`,
156
+ column(layout.panes.slice(0, half)),
157
+ column(layout.panes.slice(half)),
158
+ " }",
159
+ " }",
160
+ "}",
161
+ ].join("\n")}\n`;
162
+ }
163
+ function renderTmux(layout) {
164
+ const s = shellQuote(layout.session);
165
+ const lines = [
166
+ "#!/usr/bin/env bash",
167
+ "# rendered by ctl-demo --ui tmux; the pane list is the contract, this file is a view of it",
168
+ "set -euo pipefail",
169
+ "export NIXPKGS_ALLOW_INSECURE=1 NIXPKGS_ALLOW_UNFREE=1",
170
+ `s=${s}`,
171
+ `tmux kill-session -t "$s" 2>/dev/null || true`,
172
+ ];
173
+ layout.panes.forEach((pane, i) => {
174
+ const line = shellQuote(paneCommandLine(pane));
175
+ if (i === 0) {
176
+ lines.push(`tmux new-session -d -s "$s" -n ${shellQuote(layout.tab)} -c ${shellQuote(pane.cwd)} ${line}`);
177
+ }
178
+ else {
179
+ lines.push(`tmux split-window -t "$s:0" -c ${shellQuote(pane.cwd)} ${line}`);
180
+ }
181
+ lines.push(`tmux select-pane -t "$s:0.${i}" -T ${shellQuote(pane.name)}`);
182
+ });
183
+ lines.push(`tmux set-option -t "$s" pane-border-status top`, `tmux select-layout -t "$s:0" tiled`, `tmux select-pane -t "$s:0.0"`, `tmux attach -t "$s"`);
184
+ return `${lines.join("\n")}\n`;
185
+ }
186
+ function renderNone(layout) {
187
+ const out = [
188
+ `# ${layout.session}: one shell per block, in this order (rendered by ctl-demo --ui none)`,
189
+ "export NIXPKGS_ALLOW_INSECURE=1 NIXPKGS_ALLOW_UNFREE=1",
190
+ ];
191
+ for (const pane of layout.panes) {
192
+ out.push("", `# ${pane.name}`, `cd ${shellQuote(pane.cwd)}`, paneCommandLine(pane));
193
+ }
194
+ return `${out.join("\n")}\n`;
195
+ }
196
+ export function renderLayout(layout, ui) {
197
+ switch (ui) {
198
+ case "zellij":
199
+ return renderZellij(layout);
200
+ case "tmux":
201
+ return renderTmux(layout);
202
+ case "none":
203
+ return renderNone(layout);
204
+ }
205
+ }
206
+ /** Open the rendered layout: zellij's client blocks until detach and the
207
+ * session is deleted after it (a trailing line, not a trap: the panes'
208
+ * processes get SIGHUP, which the driver's hold treats as a release); tmux's
209
+ * script attaches and returns on detach; none prints. */
210
+ export function launchLayout(ui, rendered, layout) {
211
+ if (ui === "none") {
212
+ process.stdout.write(rendered);
213
+ return 0;
214
+ }
215
+ const dir = mkdtempSync(join(tmpdir(), "ctl-demo-ui-"));
216
+ const env = { ...process.env, NIXPKGS_ALLOW_INSECURE: "1", NIXPKGS_ALLOW_UNFREE: "1" };
217
+ try {
218
+ if (ui === "tmux") {
219
+ const script = join(dir, `${layout.session}.tmux.sh`);
220
+ writeFileSync(script, rendered);
221
+ return spawnSync("bash", [script], { stdio: "inherit", env }).status ?? 1;
222
+ }
223
+ const kdl = join(dir, `${layout.session}.kdl`);
224
+ writeFileSync(kdl, rendered);
225
+ spawnSync("zellij", ["delete-session", "--force", layout.session], { stdio: "ignore" });
226
+ const r = spawnSync("zellij", ["--new-session-with-layout", kdl, "--session", layout.session], {
227
+ stdio: "inherit",
228
+ env,
229
+ });
230
+ spawnSync("zellij", ["delete-session", "--force", layout.session], { stdio: "ignore" });
231
+ return r.status ?? 1;
232
+ }
233
+ finally {
234
+ rmSync(dir, { recursive: true, force: true });
235
+ }
236
+ }
package/demo/run.d.ts CHANGED
@@ -3,7 +3,7 @@ import { ensureRunnerOnNetwork } from "../container-net.js";
3
3
  import { type DaemonProcess, type StartDaemonOptions } from "../daemon.js";
4
4
  import { type BaseHarnessPorts } from "../harness-config.js";
5
5
  import { type SourceHandle, type SrtPumpTarget } from "../source-pump.js";
6
- import type { DemoDaemonPolicy, DemoIngest, DemoMode, DemoSpec } from "./spec.js";
6
+ import type { DemoDaemonPolicy, DemoIngest, DemoMode, DemoSpec, DemoTemplate } from "./spec.js";
7
7
  export interface CliResult {
8
8
  stdout: string;
9
9
  stderr: string;
@@ -17,6 +17,9 @@ export interface ProcessHandle {
17
17
  /** What `up` records so `down` (or a refused second `up`) can find the run. */
18
18
  export interface DemoState {
19
19
  product: string;
20
+ /** Absent on a demo's record; `dev-loop` on the standalone tier's, which
21
+ * lives beside it under its own file so the two never mistake each other. */
22
+ kind?: DemoStateKind;
20
23
  storeDir: string;
21
24
  daemonPort: number;
22
25
  instanceId?: string;
@@ -25,11 +28,15 @@ export interface DemoState {
25
28
  daemon?: DemoDaemonPolicy;
26
29
  /** Extra containers `up` started, for `down` to remove. */
27
30
  extras?: string[];
31
+ /** dev-loop: the exported workdir and the template it exported, for `refresh`. */
32
+ workdir?: string;
33
+ templateName?: string;
28
34
  }
35
+ export type DemoStateKind = "demo" | "dev-loop";
29
36
  export interface DemoStateStore {
30
- read(product: string): DemoState | null;
37
+ read(product: string, kind?: DemoStateKind): DemoState | null;
31
38
  write(state: DemoState): void;
32
- remove(product: string): void;
39
+ remove(product: string, kind?: DemoStateKind): void;
33
40
  }
34
41
  export interface DemoDeps {
35
42
  licenseFile(): string;
@@ -37,6 +44,8 @@ export interface DemoDeps {
37
44
  /** The developer's real store, for `--daemon reuse`. */
38
45
  realStoreDir(): string;
39
46
  writeFile(path: string, contents: string): void;
47
+ /** Remove a file if present (the dev-loop's export stamp). */
48
+ removeFile(path: string): void;
40
49
  /** File contents, or null when unreadable (compose.yml, manifest.seed.json,
41
50
  * config.yaml, proxy-secret). */
42
51
  readFile(path: string): string | null;
@@ -108,6 +117,9 @@ export interface DemoRunOptions {
108
117
  /** Releases an `up` hold. */
109
118
  abort?: AbortSignal;
110
119
  timeouts?: DemoTimeouts;
120
+ /** The hostname other machines reach this box by: every printed URL uses
121
+ * it, and a private daemon is initialised with it as its publicHost. */
122
+ publicHost?: string;
111
123
  }
112
124
  export interface DemoRunResult {
113
125
  instanceId: string;
@@ -176,6 +188,84 @@ export declare function fileStateStore(cwd: string): DemoStateStore;
176
188
  * own), falling back to the pid alone if it is not a group leader. */
177
189
  export declare function killGroup(pid: number): void;
178
190
  export declare function defaultDemoDeps(cwd: string): DemoDeps;
191
+ export interface SessionOptions {
192
+ mode: DemoMode;
193
+ daemon: DemoDaemonPolicy;
194
+ daemonPort?: number;
195
+ timeouts?: DemoTimeouts;
196
+ publicHost?: string;
197
+ /** Private: attach to an existing private store (a recorded run) instead of making one. */
198
+ storeDir?: string;
199
+ }
200
+ /** The pieces of a run that `runDemo`, `runExportCheck` and the dev-loop
201
+ * share: the daemon (private on its band, or the developer's), the dev
202
+ * backend on the driver's port, the registration, and the template (built
203
+ * or chosen). */
204
+ export declare class DemoSession {
205
+ readonly spec: DemoSpec;
206
+ readonly slug: string;
207
+ readonly cwd: string;
208
+ readonly deps: DemoDeps;
209
+ readonly mode: DemoMode;
210
+ readonly policy: DemoDaemonPolicy;
211
+ readonly ports: DemoPorts;
212
+ readonly storeDir: string;
213
+ /** The host every printed URL names. */
214
+ readonly host: string;
215
+ readonly publicHost: string | undefined;
216
+ /** Scheme + authority of the daemon's proxy, for `proxy` URLs. */
217
+ readonly proxyBase: string;
218
+ daemon: DaemonProcess | null;
219
+ dev: ProcessHandle | null;
220
+ extras: string[];
221
+ private readonly timeouts;
222
+ constructor(spec: DemoSpec, slug: string, cwd: string, deps: DemoDeps, opts: SessionOptions);
223
+ get devUrl(): string;
224
+ templateDir(name: string): string;
225
+ /** What the real daemon's proxy demands on `/api/*`; absent on a private daemon. */
226
+ proxySecret(): string | undefined;
227
+ cli: (argv: string[], opts?: {
228
+ output?: "json" | "yaml";
229
+ }) => Promise<CliResult>;
230
+ cliOk: (argv: string[], opts?: {
231
+ output?: "json" | "yaml";
232
+ }) => Promise<CliResult>;
233
+ /** Private: init a virgin store and start a daemon on the band. Reuse: the
234
+ * developer's daemon must already answer. */
235
+ attachDaemon(): Promise<void>;
236
+ startDev(): Promise<void>;
237
+ /** The reuse policy's mandatory sequence: `product add` refuses an existing
238
+ * name and stored templates are immutable, so the instance, every template
239
+ * the product publishes (plus the spec's built one), the product and its
240
+ * control-plane container all go first. Each step tolerates absence. */
241
+ resetRegistration(instanceId: string): Promise<void>;
242
+ register(opts: {
243
+ licence: boolean;
244
+ }): Promise<void>;
245
+ /** The template to launch: built from the spec's input, or a name the
246
+ * product publishes (its first default when the spec names none). */
247
+ resolveTemplate(t?: DemoTemplate | undefined): Promise<string>;
248
+ /** Funke's iterate.sh pin guard for every product: the stored compose must
249
+ * pin what manifest.seed.json declares. Image mode, and any reuse — a
250
+ * private dev-mode daemon renders the template fresh from source, so there
251
+ * is nothing stale to catch there. */
252
+ pinGuard(templateName: string): Promise<void>;
253
+ /** name -> stringified default, from `template show`. */
254
+ declaredParams(templateName: string): Promise<Map<string, string | undefined>>;
255
+ startExtras(instanceId: string): void;
256
+ stopExtras(): void;
257
+ waitRunning(instanceId: string): Promise<void>;
258
+ instanceListed(instanceId: string): Promise<boolean>;
259
+ ingestPorts(instanceId: string): Promise<IngestPortRow[]>;
260
+ gateTimeout(ms: number | undefined): number;
261
+ /** Tear down in reverse: sources, instance, then — private only — the
262
+ * daemon (with its proxy), the dev backend, the store. Reuse leaves the
263
+ * developer's daemon, store and registration as they are. */
264
+ teardown(opts: {
265
+ instances: string[];
266
+ handles: SourceHandle[];
267
+ }): Promise<void>;
268
+ }
179
269
  export declare function runDemo(spec: DemoSpec, opts: DemoRunOptions, deps?: DemoDeps): Promise<DemoRunResult>;
180
270
  export interface ExportCheckOptions {
181
271
  cwd: string;
@@ -190,5 +280,8 @@ export interface ExportCheckOptions {
190
280
  export declare function runExportCheck(spec: DemoSpec, opts: ExportCheckOptions, deps?: DemoDeps): Promise<{
191
281
  exportDir: string;
192
282
  }>;
283
+ /** `template export-workdir` with the spec's live-source links and params,
284
+ * then the check that rotted (05-demo s1): every symlink resolves. */
285
+ export declare function exportStandaloneWorkdir(s: DemoSession, templateName: string, to: string): Promise<void>;
193
286
  /** `demo down`: tear down the run `up` recorded, from any shell. */
194
287
  export declare function demoDown(product: string, deps?: DemoDeps): Promise<void>;
package/demo/run.js CHANGED
@@ -196,11 +196,11 @@ export function findBrokenSymlinks(dir) {
196
196
  * consumer's repo, where `down` from another shell can find it. */
197
197
  export function fileStateStore(cwd) {
198
198
  const dir = join(cwd, "test-temp", "demo");
199
- const path = (product) => join(dir, `${product}.json`);
199
+ const path = (product, kind) => join(dir, `${product}${kind === "dev-loop" ? ".dev-loop" : ""}.json`);
200
200
  return {
201
- read: (product) => {
201
+ read: (product, kind) => {
202
202
  try {
203
- return JSON.parse(readFileSync(path(product), "utf8"));
203
+ return JSON.parse(readFileSync(path(product, kind), "utf8"));
204
204
  }
205
205
  catch {
206
206
  return null;
@@ -208,9 +208,9 @@ export function fileStateStore(cwd) {
208
208
  },
209
209
  write: (state) => {
210
210
  mkdirSync(dir, { recursive: true });
211
- writeFileSync(path(state.product), `${JSON.stringify(state, null, 2)}\n`);
211
+ writeFileSync(path(state.product, state.kind), `${JSON.stringify(state, null, 2)}\n`);
212
212
  },
213
- remove: (product) => rmSync(path(product), { force: true }),
213
+ remove: (product, kind) => rmSync(path(product, kind), { force: true }),
214
214
  };
215
215
  }
216
216
  /** SIGTERM a process group (a pid startProcess spawned detached leads its
@@ -237,6 +237,7 @@ export function defaultDemoDeps(cwd) {
237
237
  storeDir: (slug) => makeStoreDir(`norsk-demo-${slug}-`),
238
238
  realStoreDir: () => process.env.NORSK_CTL_STORE_DIR ?? join(homedir(), ".norsk-ctl"),
239
239
  writeFile: (path, contents) => writeFileSync(path, contents),
240
+ removeFile: (path) => rmSync(path, { force: true }),
240
241
  readFile: (path) => {
241
242
  try {
242
243
  return readFileSync(path, "utf8");
@@ -326,10 +327,11 @@ export function defaultDemoDeps(cwd) {
326
327
  log: (line) => console.log(`[demo] ${line}`),
327
328
  };
328
329
  }
329
- /** The pieces of a run that both `runDemo` and `runExportCheck` share: the
330
- * daemon (private on its band, or the developer's), the dev backend on the
331
- * driver's port, the registration, and the template (built or chosen). */
332
- class DemoSession {
330
+ /** The pieces of a run that `runDemo`, `runExportCheck` and the dev-loop
331
+ * share: the daemon (private on its band, or the developer's), the dev
332
+ * backend on the driver's port, the registration, and the template (built
333
+ * or chosen). */
334
+ export class DemoSession {
333
335
  spec;
334
336
  slug;
335
337
  cwd;
@@ -338,6 +340,9 @@ class DemoSession {
338
340
  policy;
339
341
  ports;
340
342
  storeDir;
343
+ /** The host every printed URL names. */
344
+ host;
345
+ publicHost;
341
346
  /** Scheme + authority of the daemon's proxy, for `proxy` URLs. */
342
347
  proxyBase;
343
348
  daemon = null;
@@ -351,7 +356,9 @@ class DemoSession {
351
356
  this.deps = deps;
352
357
  this.mode = opts.mode;
353
358
  this.policy = opts.daemon;
354
- const host = process.env.NORSK_TEST_HOST ?? "localhost";
359
+ this.publicHost = opts.publicHost;
360
+ const host = opts.publicHost ?? process.env.NORSK_TEST_HOST ?? "localhost";
361
+ this.host = host;
355
362
  if (opts.daemon === "reuse") {
356
363
  const daemonPort = opts.daemonPort ?? (Number(process.env.NORSK_CTL_PORT) || DEFAULT_DAEMON_PORT);
357
364
  this.storeDir = deps.realStoreDir();
@@ -361,7 +368,7 @@ class DemoSession {
361
368
  }
362
369
  else {
363
370
  this.ports = demoPorts(slug);
364
- this.storeDir = deps.storeDir(slug);
371
+ this.storeDir = opts.storeDir ?? deps.storeDir(slug);
365
372
  // The private daemon is initialised without a cert source, so its proxy
366
373
  // speaks plain http (seen live: https:// gave 000, http:// served the page).
367
374
  this.proxyBase = `http://${host}:${this.ports.proxyPort}`;
@@ -416,6 +423,7 @@ class DemoSession {
416
423
  String(this.ports.proxyPort),
417
424
  "--no-http-redirect",
418
425
  "--no-start-server",
426
+ ...(this.publicHost ? ["--public-host", this.publicHost] : []),
419
427
  ]);
420
428
  const started = this.deps.startDaemon(this.storeDir, { port: this.ports.daemonPort, seedConfig: false });
421
429
  this.daemon = started.daemon;
@@ -473,8 +481,7 @@ class DemoSession {
473
481
  }
474
482
  /** The template to launch: built from the spec's input, or a name the
475
483
  * product publishes (its first default when the spec names none). */
476
- async resolveTemplate() {
477
- const t = this.spec.template;
484
+ async resolveTemplate(t = this.spec.template) {
478
485
  if (t && "build" in t) {
479
486
  const name = t.build.name ?? `${this.spec.product}-demo`;
480
487
  let inputPath;
@@ -633,13 +640,13 @@ class DemoSession {
633
640
  }
634
641
  }
635
642
  function urlResolver(o) {
636
- const host = process.env.NORSK_TEST_HOST ?? "localhost";
643
+ const host = o.host;
637
644
  const mode = netReachMode();
638
645
  return (ref) => {
639
646
  if ("url" in ref)
640
647
  return ref.url;
641
648
  if ("control" in ref)
642
- return `http://localhost:${o.ports.backendPort}${ref.control}`;
649
+ return `http://${host}:${o.ports.backendPort}${ref.control}`;
643
650
  if ("proxy" in ref)
644
651
  return `${o.proxyBase}${ref.proxy.replaceAll("{id}", o.instanceId)}`;
645
652
  return `${studioBaseFrom({ instanceId: o.instanceId, studioHostPort: o.studioHostPort }, host, mode)}${ref.studio}`;
@@ -729,6 +736,7 @@ export async function runDemo(spec, opts, deps = defaultDemoDeps(opts.cwd)) {
729
736
  daemon: policy,
730
737
  ...(opts.daemonPort !== undefined ? { daemonPort: opts.daemonPort } : {}),
731
738
  ...(opts.timeouts !== undefined ? { timeouts: opts.timeouts } : {}),
739
+ ...(opts.publicHost !== undefined ? { publicHost: opts.publicHost } : {}),
732
740
  });
733
741
  if (opts.action === "up") {
734
742
  await refuseIfUp(spec, s, deps);
@@ -808,7 +816,7 @@ export async function runDemo(spec, opts, deps = defaultDemoDeps(opts.cwd)) {
808
816
  }));
809
817
  const ctx = {
810
818
  ...launchCtx,
811
- url: urlResolver({ instanceId, ports: s.ports, studioHostPort, proxyBase: s.proxyBase }),
819
+ url: urlResolver({ instanceId, ports: s.ports, studioHostPort, proxyBase: s.proxyBase, host: s.host }),
812
820
  fetch: deps.fetch,
813
821
  };
814
822
  // Sources first: a gate such as "the switcher is composing" or "the probe
@@ -882,19 +890,7 @@ export async function runExportCheck(spec, opts, deps = defaultDemoDeps(opts.cwd
882
890
  await s.startDev();
883
891
  await s.register({ licence: false });
884
892
  const templateName = await s.resolveTemplate();
885
- const argv = ["template", "export-workdir", templateName, "--to", exportDir];
886
- for (const [k, v] of Object.entries(spec.standalone?.params ?? {}))
887
- argv.push("--param", `${k}=${v}`);
888
- for (const [pkg, path] of Object.entries(spec.standalone?.links ?? {})) {
889
- argv.push("--link-component", `${pkg}=${resolve(opts.cwd, path)}`);
890
- }
891
- if (spec.standalone?.dashboards)
892
- argv.push("--link-dashboards", resolve(opts.cwd, spec.standalone.dashboards));
893
- await s.cliOk(argv);
894
- const broken = deps.brokenSymlinks(exportDir);
895
- if (broken.length) {
896
- throw new Error(`exported workdir has ${broken.length} broken symlink(s):\n ${broken.join("\n ")}`);
897
- }
893
+ await exportStandaloneWorkdir(s, templateName, exportDir);
898
894
  deps.log(`export ok: ${templateName} -> ${exportDir}, every symlink resolves`);
899
895
  }
900
896
  catch (e) {
@@ -905,6 +901,24 @@ export async function runExportCheck(spec, opts, deps = defaultDemoDeps(opts.cwd
905
901
  throw journeyError;
906
902
  return { exportDir };
907
903
  }
904
+ /** `template export-workdir` with the spec's live-source links and params,
905
+ * then the check that rotted (05-demo s1): every symlink resolves. */
906
+ export async function exportStandaloneWorkdir(s, templateName, to) {
907
+ const { spec, cwd, deps } = s;
908
+ const argv = ["template", "export-workdir", templateName, "--to", to];
909
+ for (const [k, v] of Object.entries(spec.standalone?.params ?? {}))
910
+ argv.push("--param", `${k}=${v}`);
911
+ for (const [pkg, path] of Object.entries(spec.standalone?.links ?? {})) {
912
+ argv.push("--link-component", `${pkg}=${resolve(cwd, path)}`);
913
+ }
914
+ if (spec.standalone?.dashboards)
915
+ argv.push("--link-dashboards", resolve(cwd, spec.standalone.dashboards));
916
+ await s.cliOk(argv);
917
+ const broken = deps.brokenSymlinks(to);
918
+ if (broken.length) {
919
+ throw new Error(`exported workdir has ${broken.length} broken symlink(s):\n ${broken.join("\n ")}`);
920
+ }
921
+ }
908
922
  /** `demo down`: tear down the run `up` recorded, from any shell. */
909
923
  export async function demoDown(product, deps = defaultDemoDeps(process.cwd())) {
910
924
  const state = deps.state.read(product);
package/demo/spec.d.ts CHANGED
@@ -138,12 +138,22 @@ export interface DemoSpec {
138
138
  /** Runs once every gate holds. */
139
139
  after?: (ctx: DemoContext) => Promise<void>;
140
140
  open?: DemoOpen[];
141
- /** The standalone tier's live-source links, for `--mode standalone`. Paths
142
- * are relative to the repo root. */
141
+ /** The standalone tier (`dev-loop`, `check --mode standalone --export-only`):
142
+ * the live-source links (paths relative to the repo root) and, for the full
143
+ * tier, where the engine and Studio checkouts and the exported workdir live
144
+ * (absolute or `~`-rooted; NORSK_DIR / STUDIO_DIR / TO in the environment
145
+ * override them, the conventional `~/dev/...` paths apply when neither says). */
143
146
  standalone?: {
144
147
  links?: Record<string, string>;
145
148
  dashboards?: string;
146
149
  params?: Record<string, string | number>;
150
+ norskDir?: string;
151
+ studioDir?: string;
152
+ workdir?: string;
153
+ /** The template the dev-loop builds when it is not the demo's (probe:
154
+ * the standalone form assumes the box's side-loads, the demo form is
155
+ * portable). The export gate keeps the demo's, CI-portable. */
156
+ template?: DemoTemplate;
147
157
  };
148
158
  }
149
159
  export declare const DemoSpecSchema: z.ZodType<DemoSpec>;
package/demo/spec.js CHANGED
@@ -38,6 +38,21 @@ const relativePath = z
38
38
  .string()
39
39
  .min(1)
40
40
  .refine((p) => !isAbsolute(p) && !p.startsWith("~"), { message: "must be a relative path inside the repo" });
41
+ const homePath = z
42
+ .string()
43
+ .min(1)
44
+ .refine((p) => isAbsolute(p) || p === "~" || p.startsWith("~/"), {
45
+ message: "must be an absolute or ~-rooted path (a checkout outside the repo)",
46
+ });
47
+ const Template = z.union([
48
+ z.strictObject({ name: z.string().min(1) }),
49
+ z.strictObject({
50
+ build: z.strictObject({
51
+ name: z.string().min(1).optional(),
52
+ input: z.union([z.string().min(1), z.record(z.string(), z.unknown())]),
53
+ }),
54
+ }),
55
+ ]);
41
56
  export const DemoSpecSchema = z
42
57
  .strictObject({
43
58
  product: z.string().min(1),
@@ -47,17 +62,7 @@ export const DemoSpecSchema = z
47
62
  readyPath: z.string().startsWith("/").optional(),
48
63
  }),
49
64
  image: z.string().min(1).optional(),
50
- template: z
51
- .union([
52
- z.strictObject({ name: z.string().min(1) }),
53
- z.strictObject({
54
- build: z.strictObject({
55
- name: z.string().min(1).optional(),
56
- input: z.union([z.string().min(1), z.record(z.string(), z.unknown())]),
57
- }),
58
- }),
59
- ])
60
- .optional(),
65
+ template: Template.optional(),
61
66
  launch: z
62
67
  .strictObject({
63
68
  hardware: z.enum(["nvidia", "none"]).optional(),
@@ -105,6 +110,10 @@ export const DemoSpecSchema = z
105
110
  links: z.record(z.string().min(1), relativePath).optional(),
106
111
  dashboards: relativePath.optional(),
107
112
  params: Params.optional(),
113
+ norskDir: homePath.optional(),
114
+ studioDir: homePath.optional(),
115
+ workdir: homePath.optional(),
116
+ template: Template.optional(),
108
117
  })
109
118
  .optional(),
110
119
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@norskvideo/ctl-test-harness",
3
- "version": "0.1.21",
3
+ "version": "0.1.22",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -59,7 +59,8 @@
59
59
  "main": "./index.js",
60
60
  "types": "./index.d.ts",
61
61
  "bin": {
62
- "ctl-demo": "./demo/cli.js"
62
+ "ctl-demo": "./demo/cli.js",
63
+ "ctl-dev-loop": "./demo/dev-loop-cli.js"
63
64
  },
64
65
  "dependencies": {
65
66
  "@norskvideo/ctl-sdk": "^0.1.0",