@norskvideo/ctl-test-harness 0.1.21 → 0.1.23

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.
@@ -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}`;
@@ -414,8 +421,15 @@ class DemoSession {
414
421
  join(this.storeDir, "norsk-runtime"),
415
422
  "--proxy-port",
416
423
  String(this.ports.proxyPort),
424
+ // A throwaway daemon on a banded port has nothing to protect, and its
425
+ // /instance/<id>/... routes (the visualiser a gate reads, the `open`
426
+ // URLs) must answer without a session: with oauth on, the compose gate
427
+ // read the sign-in page as "0 frames" for 180s (playout CI, 2026-08-29).
428
+ "--proxy-auth",
429
+ "none",
417
430
  "--no-http-redirect",
418
431
  "--no-start-server",
432
+ ...(this.publicHost ? ["--public-host", this.publicHost] : []),
419
433
  ]);
420
434
  const started = this.deps.startDaemon(this.storeDir, { port: this.ports.daemonPort, seedConfig: false });
421
435
  this.daemon = started.daemon;
@@ -473,8 +487,7 @@ class DemoSession {
473
487
  }
474
488
  /** The template to launch: built from the spec's input, or a name the
475
489
  * product publishes (its first default when the spec names none). */
476
- async resolveTemplate() {
477
- const t = this.spec.template;
490
+ async resolveTemplate(t = this.spec.template) {
478
491
  if (t && "build" in t) {
479
492
  const name = t.build.name ?? `${this.spec.product}-demo`;
480
493
  let inputPath;
@@ -633,13 +646,13 @@ class DemoSession {
633
646
  }
634
647
  }
635
648
  function urlResolver(o) {
636
- const host = process.env.NORSK_TEST_HOST ?? "localhost";
649
+ const host = o.host;
637
650
  const mode = netReachMode();
638
651
  return (ref) => {
639
652
  if ("url" in ref)
640
653
  return ref.url;
641
654
  if ("control" in ref)
642
- return `http://localhost:${o.ports.backendPort}${ref.control}`;
655
+ return `http://${host}:${o.ports.backendPort}${ref.control}`;
643
656
  if ("proxy" in ref)
644
657
  return `${o.proxyBase}${ref.proxy.replaceAll("{id}", o.instanceId)}`;
645
658
  return `${studioBaseFrom({ instanceId: o.instanceId, studioHostPort: o.studioHostPort }, host, mode)}${ref.studio}`;
@@ -729,6 +742,7 @@ export async function runDemo(spec, opts, deps = defaultDemoDeps(opts.cwd)) {
729
742
  daemon: policy,
730
743
  ...(opts.daemonPort !== undefined ? { daemonPort: opts.daemonPort } : {}),
731
744
  ...(opts.timeouts !== undefined ? { timeouts: opts.timeouts } : {}),
745
+ ...(opts.publicHost !== undefined ? { publicHost: opts.publicHost } : {}),
732
746
  });
733
747
  if (opts.action === "up") {
734
748
  await refuseIfUp(spec, s, deps);
@@ -808,7 +822,7 @@ export async function runDemo(spec, opts, deps = defaultDemoDeps(opts.cwd)) {
808
822
  }));
809
823
  const ctx = {
810
824
  ...launchCtx,
811
- url: urlResolver({ instanceId, ports: s.ports, studioHostPort, proxyBase: s.proxyBase }),
825
+ url: urlResolver({ instanceId, ports: s.ports, studioHostPort, proxyBase: s.proxyBase, host: s.host }),
812
826
  fetch: deps.fetch,
813
827
  };
814
828
  // Sources first: a gate such as "the switcher is composing" or "the probe
@@ -882,19 +896,7 @@ export async function runExportCheck(spec, opts, deps = defaultDemoDeps(opts.cwd
882
896
  await s.startDev();
883
897
  await s.register({ licence: false });
884
898
  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
- }
899
+ await exportStandaloneWorkdir(s, templateName, exportDir);
898
900
  deps.log(`export ok: ${templateName} -> ${exportDir}, every symlink resolves`);
899
901
  }
900
902
  catch (e) {
@@ -905,6 +907,24 @@ export async function runExportCheck(spec, opts, deps = defaultDemoDeps(opts.cwd
905
907
  throw journeyError;
906
908
  return { exportDir };
907
909
  }
910
+ /** `template export-workdir` with the spec's live-source links and params,
911
+ * then the check that rotted (05-demo s1): every symlink resolves. */
912
+ export async function exportStandaloneWorkdir(s, templateName, to) {
913
+ const { spec, cwd, deps } = s;
914
+ const argv = ["template", "export-workdir", templateName, "--to", to];
915
+ for (const [k, v] of Object.entries(spec.standalone?.params ?? {}))
916
+ argv.push("--param", `${k}=${v}`);
917
+ for (const [pkg, path] of Object.entries(spec.standalone?.links ?? {})) {
918
+ argv.push("--link-component", `${pkg}=${resolve(cwd, path)}`);
919
+ }
920
+ if (spec.standalone?.dashboards)
921
+ argv.push("--link-dashboards", resolve(cwd, spec.standalone.dashboards));
922
+ await s.cliOk(argv);
923
+ const broken = deps.brokenSymlinks(to);
924
+ if (broken.length) {
925
+ throw new Error(`exported workdir has ${broken.length} broken symlink(s):\n ${broken.join("\n ")}`);
926
+ }
927
+ }
908
928
  /** `demo down`: tear down the run `up` recorded, from any shell. */
909
929
  export async function demoDown(product, deps = defaultDemoDeps(process.cwd())) {
910
930
  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>;