@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.
- package/compose-advancing.d.ts +14 -2
- package/compose-advancing.js +26 -10
- package/demo/cli.d.ts +16 -1
- package/demo/cli.js +105 -16
- package/demo/dev-loop-cli.d.ts +2 -0
- package/demo/dev-loop-cli.js +8 -0
- package/demo/dev-loop.d.ts +33 -0
- package/demo/dev-loop.js +205 -0
- package/demo/gates.js +3 -1
- package/demo/index.d.ts +6 -2
- package/demo/index.js +3 -1
- package/demo/panes.d.ts +81 -0
- package/demo/panes.js +236 -0
- package/demo/run.d.ts +96 -3
- package/demo/run.js +49 -29
- package/demo/spec.d.ts +12 -2
- package/demo/spec.js +20 -11
- package/package.json +3 -2
package/demo/panes.d.ts
ADDED
|
@@ -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
|
|
330
|
-
* daemon (private on its band, or the developer's), the dev
|
|
331
|
-
* driver's port, the registration, and the template (built
|
|
332
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
|
142
|
-
*
|
|
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>;
|