@norskvideo/ctl-test-harness 0.1.20 → 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.
@@ -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
+ }
@@ -0,0 +1,22 @@
1
+ import { type LogPattern, type SpawnLike } from "../container-logs.js";
2
+ import type { DemoReady } from "./spec.js";
3
+ /** Thrown by a gate whose failure is final: the driver stops polling and the
4
+ * run fails with this message, teardown included. */
5
+ export declare class DemoGateFailure extends Error {
6
+ constructor(message: string);
7
+ }
8
+ /** No forbidden line in any container of the instance (`Segmentation fault`,
9
+ * say — funke's iterate.sh:156). Holds on clean logs; a hit fails the run at
10
+ * once, naming the container and the line. */
11
+ export declare function logsCleanGate(opts: {
12
+ forbid: readonly LogPattern[];
13
+ tail?: number;
14
+ spawn?: SpawnLike;
15
+ }): DemoReady;
16
+ /** The compositor's mediaIn is climbing (playout's ensure-sources.sh gate),
17
+ * read through the daemon proxy's visualiser route for this instance. */
18
+ export declare function composeAdvancingGate(opts?: {
19
+ node?: string;
20
+ settleMs?: number;
21
+ timeoutMs?: number;
22
+ }): DemoReady;
package/demo/gates.js ADDED
@@ -0,0 +1,56 @@
1
+ // Ready gates a spec composes rather than writes (05-demo s5): the lifted
2
+ // probes as `{ custom, label }` entries for `ready: [...]`. A gate returns
3
+ // false to be polled again; it throws DemoGateFailure to stop the run at once
4
+ // — a segfault is not going to un-happen by waiting.
5
+ import { composeAdvancing, DEFAULT_COMPOSE_NODE } from "../compose-advancing.js";
6
+ import { containerLogsClean } from "../container-logs.js";
7
+ /** Thrown by a gate whose failure is final: the driver stops polling and the
8
+ * run fails with this message, teardown included. */
9
+ export class DemoGateFailure extends Error {
10
+ constructor(message) {
11
+ super(message);
12
+ this.name = "DemoGateFailure";
13
+ }
14
+ }
15
+ /** No forbidden line in any container of the instance (`Segmentation fault`,
16
+ * say — funke's iterate.sh:156). Holds on clean logs; a hit fails the run at
17
+ * once, naming the container and the line. */
18
+ export function logsCleanGate(opts) {
19
+ const label = `container logs clean of ${opts.forbid.map(String).join(", ")}`;
20
+ return {
21
+ label,
22
+ custom: async (ctx) => {
23
+ const r = containerLogsClean({
24
+ instanceId: ctx.instanceId,
25
+ forbid: opts.forbid,
26
+ ...(opts.tail !== undefined ? { tail: opts.tail } : {}),
27
+ ...(opts.spawn !== undefined ? { spawn: opts.spawn } : {}),
28
+ });
29
+ if (r.clean)
30
+ return true;
31
+ const lines = r.hits.map((h) => ` ${h.container}: ${h.line}`).join("\n");
32
+ throw new DemoGateFailure(`forbidden line(s) in the instance's container logs:\n${lines}`);
33
+ },
34
+ };
35
+ }
36
+ /** The compositor's mediaIn is climbing (playout's ensure-sources.sh gate),
37
+ * read through the daemon proxy's visualiser route for this instance. */
38
+ export function composeAdvancingGate(opts = {}) {
39
+ const node = opts.node ?? DEFAULT_COMPOSE_NODE;
40
+ return {
41
+ label: `${node} advancing`,
42
+ ...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}),
43
+ custom: async (ctx) => {
44
+ const base = ctx.url({ proxy: "/instance/{id}/visualiser" });
45
+ const r = await composeAdvancing({
46
+ fetch: ctx.fetch,
47
+ base,
48
+ node,
49
+ ...(opts.settleMs !== undefined ? { settleMs: opts.settleMs } : {}),
50
+ });
51
+ if (!r.advancing)
52
+ ctx.log(`${node} not advancing yet (${r.before} -> ${r.after})`);
53
+ return r.advancing;
54
+ },
55
+ };
56
+ }
package/demo/index.d.ts CHANGED
@@ -1,6 +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 { CliResult, DemoDeps, DemoPorts, DemoRunOptions, DemoRunResult, DemoState, DemoStateStore, DemoTimeouts, ExportCheckOptions, IngestPortRow, ProcessHandle, TemplateParams, } from "./run.js";
4
- export { defaultDemoDeps, demoDown, demoPorts, demoSlug, expandHome, fileStateStore, findBrokenSymlinks, killGroup, resolveIngestPort, runDemo, runExportCheck, } from "./run.js";
5
- export type { DemoContext, DemoIngest, DemoOpen, DemoReady, DemoSource, DemoSpec, DemoTemplate, DemoUrl, } from "./spec.js";
3
+ export type { DevLoopOptions, DevLoopResult, StandaloneSource } from "./dev-loop.js";
4
+ export { devLoopDown, pumpScript, runDevLoop } from "./dev-loop.js";
5
+ export { composeAdvancingGate, DemoGateFailure, logsCleanGate } from "./gates.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";
10
+ export type { DemoContext, DemoDaemonPolicy, DemoExtra, DemoIngest, DemoLaunchContext, DemoMode, DemoOpen, DemoReady, DemoSource, DemoSpec, DemoTemplate, DemoUrl, } from "./spec.js";
6
11
  export { DemoSpecSchema, defineDemo, formatSpecIssues } from "./spec.js";
package/demo/index.js CHANGED
@@ -1,3 +1,6 @@
1
1
  export { defaultDemoCliIo, demoMain, parseDemoArgs, resolvedSpecView } from "./cli.js";
2
- export { defaultDemoDeps, demoDown, demoPorts, demoSlug, expandHome, fileStateStore, findBrokenSymlinks, killGroup, resolveIngestPort, runDemo, runExportCheck, } from "./run.js";
2
+ export { devLoopDown, pumpScript, runDevLoop } from "./dev-loop.js";
3
+ export { composeAdvancingGate, DemoGateFailure, logsCleanGate } from "./gates.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";
3
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
+ }