@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,26 @@
1
+ export type FetchLike = (url: string, init?: RequestInit) => Promise<Response>;
2
+ export declare const DEFAULT_COMPOSE_NODE = "video_compose";
3
+ /** The highest mediaIn across every node of that name; 0 when none. */
4
+ export declare function maxNodeMediaIn(summary: unknown, node: string): number;
5
+ /** One sample: the highest mediaIn of `node` across every root workflow under
6
+ * `base` (`.../visualiser`). An unreachable visualiser reads as 0 — the
7
+ * caller's second sample, not a throw, decides. */
8
+ export declare function composeFrames(opts: {
9
+ fetch: FetchLike;
10
+ base: string;
11
+ node?: string;
12
+ }): Promise<number>;
13
+ export interface ComposeAdvancingResult {
14
+ advancing: boolean;
15
+ before: number;
16
+ after: number;
17
+ node: string;
18
+ }
19
+ /** Two samples `settleMs` apart: advancing when the second is above the first
20
+ * and above zero — the fallback card and a stalled feed both read flat. */
21
+ export declare function composeAdvancing(opts: {
22
+ fetch: FetchLike;
23
+ base: string;
24
+ node?: string;
25
+ settleMs?: number;
26
+ }): Promise<ComposeAdvancingResult>;
@@ -0,0 +1,61 @@
1
+ // Is a programme really reaching the compositor? The daemon reports a source
2
+ // "running" as soon as its ffmpeg sidecar is up, even if the SRT never
3
+ // connected or the frames stalled; the only honest signal is the compose
4
+ // node's own mediaIn climbing between two samples of the engine's visualiser
5
+ // (`/visualiser/workflow` -> rootWorkflows[].wfid -> `/workflow/<wfid>/summary`
6
+ // -> nodes[].mediaIn). Playout's scripts/ensure-sources.sh:52-64 as TypeScript
7
+ // (05-demo s7 step 2), pure over an injected fetch so it is unit-tested and
8
+ // reachable through whichever base the caller has: the daemon proxy's
9
+ // `/instance/<id>/visualiser`, or `<id>-media-1:6791/visualiser` on norsk-net.
10
+ export const DEFAULT_COMPOSE_NODE = "video_compose";
11
+ const DEFAULT_SETTLE_MS = 6000;
12
+ /** The highest mediaIn across every node of that name; 0 when none. */
13
+ export function maxNodeMediaIn(summary, node) {
14
+ const nodes = summary?.nodes;
15
+ if (!Array.isArray(nodes))
16
+ return 0;
17
+ let max = 0;
18
+ for (const n of nodes) {
19
+ if (n?.name !== node)
20
+ continue;
21
+ const v = Number(n.mediaIn);
22
+ if (Number.isFinite(v) && v > max)
23
+ max = v;
24
+ }
25
+ return max;
26
+ }
27
+ async function getJson(fetch, url) {
28
+ const r = await fetch(url, { signal: AbortSignal.timeout(5000) });
29
+ if (!r.ok)
30
+ throw new Error(`GET ${url} -> ${r.status}`);
31
+ return r.json();
32
+ }
33
+ /** One sample: the highest mediaIn of `node` across every root workflow under
34
+ * `base` (`.../visualiser`). An unreachable visualiser reads as 0 — the
35
+ * caller's second sample, not a throw, decides. */
36
+ export async function composeFrames(opts) {
37
+ const node = opts.node ?? DEFAULT_COMPOSE_NODE;
38
+ try {
39
+ const wf = (await getJson(opts.fetch, `${opts.base}/workflow`));
40
+ let max = 0;
41
+ for (const root of wf?.rootWorkflows ?? []) {
42
+ if (typeof root?.wfid !== "string")
43
+ continue;
44
+ const summary = await getJson(opts.fetch, `${opts.base}/workflow/${encodeURIComponent(root.wfid)}/summary`);
45
+ max = Math.max(max, maxNodeMediaIn(summary, node));
46
+ }
47
+ return max;
48
+ }
49
+ catch {
50
+ return 0;
51
+ }
52
+ }
53
+ /** Two samples `settleMs` apart: advancing when the second is above the first
54
+ * and above zero — the fallback card and a stalled feed both read flat. */
55
+ export async function composeAdvancing(opts) {
56
+ const node = opts.node ?? DEFAULT_COMPOSE_NODE;
57
+ const before = await composeFrames({ fetch: opts.fetch, base: opts.base, node });
58
+ await new Promise((r) => setTimeout(r, opts.settleMs ?? DEFAULT_SETTLE_MS));
59
+ const after = await composeFrames({ fetch: opts.fetch, base: opts.base, node });
60
+ return { advancing: after > before && after > 0, before, after, node };
61
+ }
@@ -0,0 +1,48 @@
1
+ export interface SpawnResult {
2
+ stdout: string;
3
+ stderr: string;
4
+ error?: Error;
5
+ }
6
+ export type SpawnLike = (cmd: string, args: string[], opts: {
7
+ encoding: "utf8";
8
+ timeout: number;
9
+ }) => SpawnResult;
10
+ /** `docker ps` argv listing "<name>\t<state>" for ONE instance's containers. */
11
+ export declare function instanceContainersArgs(instanceId: string): string[];
12
+ /** `docker logs` argv tailing one container. Combined stdout+stderr at the call site. */
13
+ export declare function containerLogArgs(container: string, tail?: number): string[];
14
+ export interface ContainerLog {
15
+ name: string;
16
+ state: string;
17
+ log: string;
18
+ }
19
+ /** Parse `docker ps --format "{{.Names}}\t{{.State}}"` output into rows. */
20
+ export declare function parseContainerRows(psStdout: string): {
21
+ name: string;
22
+ state: string;
23
+ }[];
24
+ /** Pure: renders captured logs as an indented block per container. */
25
+ export declare function formatContainerLogs(instanceId: string, entries: ContainerLog[]): string;
26
+ /** Tail every container of one instance. Best-effort: docker unreachable is
27
+ * an empty list, never a throw, so a diagnostic cannot mask what it describes. */
28
+ export declare function collectInstanceContainerLogs(instanceId: string, tail?: number, spawn?: SpawnLike): ContainerLog[];
29
+ /** The ready-to-print diagnostic block (empty-marker when nothing is found). */
30
+ export declare function dumpInstanceContainerLogs(instanceId: string, tail?: number, spawn?: SpawnLike): string;
31
+ export interface LogHit {
32
+ container: string;
33
+ line: string;
34
+ }
35
+ /** A string forbids a substring; a RegExp is tested per line. */
36
+ export type LogPattern = string | RegExp;
37
+ /** Every line, in any container of the instance, that matches a forbidden
38
+ * pattern. `clean` when there are none — including when docker cannot be
39
+ * reached, which is absence of evidence, not a failure of the thing probed. */
40
+ export declare function containerLogsClean(opts: {
41
+ instanceId: string;
42
+ forbid: readonly LogPattern[];
43
+ tail?: number;
44
+ spawn?: SpawnLike;
45
+ }): {
46
+ clean: boolean;
47
+ hits: LogHit[];
48
+ };
@@ -0,0 +1,101 @@
1
+ // A launched instance's container logs, by the `norsk-ctl.instance=<id>`
2
+ // label every per-instance container carries (norsk-ctl docker/constants.ts)
3
+ // — so it finds them under either naming scheme (`<id>-media-1` on the
4
+ // released ctl, `norsk-inst-<id>-media` on older ones). Lifted from funke's
5
+ // tests/integration/support/container-logs.ts (05-demo s7 step 2): the
6
+ // diagnostic dump, plus containerLogsClean — iterate.sh's segfault guard
7
+ // generalised to "no forbidden line in any container of this instance".
8
+ // Pure argv/parse/format helpers; only the two exported probes shell out,
9
+ // through an injectable spawn.
10
+ import { spawnSync } from "node:child_process";
11
+ const defaultSpawn = (cmd, args, opts) => {
12
+ const r = spawnSync(cmd, args, opts);
13
+ return {
14
+ stdout: typeof r.stdout === "string" ? r.stdout : "",
15
+ stderr: typeof r.stderr === "string" ? r.stderr : "",
16
+ ...(r.error ? { error: r.error } : {}),
17
+ };
18
+ };
19
+ const DEFAULT_TAIL = 80;
20
+ /** `docker ps` argv listing "<name>\t<state>" for ONE instance's containers. */
21
+ export function instanceContainersArgs(instanceId) {
22
+ return ["ps", "-a", "--filter", `label=norsk-ctl.instance=${instanceId}`, "--format", "{{.Names}}\t{{.State}}"];
23
+ }
24
+ /** `docker logs` argv tailing one container. Combined stdout+stderr at the call site. */
25
+ export function containerLogArgs(container, tail = DEFAULT_TAIL) {
26
+ return ["logs", "--tail", String(tail), container];
27
+ }
28
+ /** Parse `docker ps --format "{{.Names}}\t{{.State}}"` output into rows. */
29
+ export function parseContainerRows(psStdout) {
30
+ return psStdout
31
+ .split("\n")
32
+ .map((l) => l.trim())
33
+ .filter(Boolean)
34
+ .map((l) => {
35
+ const [name, state] = l.split("\t");
36
+ return { name: name ?? "?", state: state ?? "?" };
37
+ });
38
+ }
39
+ /** Pure: renders captured logs as an indented block per container. */
40
+ export function formatContainerLogs(instanceId, entries) {
41
+ if (entries.length === 0) {
42
+ return ` container logs for ${instanceId}: <none found / docker unreachable>`;
43
+ }
44
+ return entries
45
+ .map((e) => {
46
+ const body = e.log.trim() === ""
47
+ ? " <empty>"
48
+ : e.log
49
+ .trimEnd()
50
+ .split("\n")
51
+ .map((l) => ` ${l}`)
52
+ .join("\n");
53
+ return ` --- ${e.name} (${e.state}) ---\n${body}`;
54
+ })
55
+ .join("\n");
56
+ }
57
+ /** Tail every container of one instance. Best-effort: docker unreachable is
58
+ * an empty list, never a throw, so a diagnostic cannot mask what it describes. */
59
+ export function collectInstanceContainerLogs(instanceId, tail = DEFAULT_TAIL, spawn = defaultSpawn) {
60
+ let rows = [];
61
+ try {
62
+ const ps = spawn("docker", instanceContainersArgs(instanceId), { encoding: "utf8", timeout: 15_000 });
63
+ if (ps.error)
64
+ return [];
65
+ rows = parseContainerRows(ps.stdout);
66
+ }
67
+ catch {
68
+ return [];
69
+ }
70
+ const entries = [];
71
+ for (const row of rows) {
72
+ let log = "";
73
+ try {
74
+ const r = spawn("docker", containerLogArgs(row.name, tail), { encoding: "utf8", timeout: 15_000 });
75
+ log = r.error ? "<docker logs failed>" : `${r.stdout}${r.stderr}`;
76
+ }
77
+ catch {
78
+ log = "<docker logs failed>";
79
+ }
80
+ entries.push({ name: row.name, state: row.state, log });
81
+ }
82
+ return entries;
83
+ }
84
+ /** The ready-to-print diagnostic block (empty-marker when nothing is found). */
85
+ export function dumpInstanceContainerLogs(instanceId, tail = DEFAULT_TAIL, spawn = defaultSpawn) {
86
+ return formatContainerLogs(instanceId, collectInstanceContainerLogs(instanceId, tail, spawn));
87
+ }
88
+ const matches = (pattern, line) => typeof pattern === "string" ? line.includes(pattern) : pattern.test(line);
89
+ /** Every line, in any container of the instance, that matches a forbidden
90
+ * pattern. `clean` when there are none — including when docker cannot be
91
+ * reached, which is absence of evidence, not a failure of the thing probed. */
92
+ export function containerLogsClean(opts) {
93
+ const hits = [];
94
+ for (const entry of collectInstanceContainerLogs(opts.instanceId, opts.tail ?? DEFAULT_TAIL, opts.spawn)) {
95
+ for (const line of entry.log.split("\n")) {
96
+ if (opts.forbid.some((p) => matches(p, line)))
97
+ hits.push({ container: entry.name, line });
98
+ }
99
+ }
100
+ return { clean: hits.length === 0, hits };
101
+ }
package/demo/cli.d.ts CHANGED
@@ -1,12 +1,22 @@
1
1
  #!/usr/bin/env bun
2
+ import { type DevLoopOptions, type DevLoopResult } from "./dev-loop.js";
3
+ import { type DemoUi, type PaneLayout } from "./panes.js";
2
4
  import { type DemoRunOptions, type DemoRunResult, demoPorts } from "./run.js";
3
- import type { DemoSpec } from "./spec.js";
5
+ import type { DemoDaemonPolicy, DemoMode, DemoSpec } from "./spec.js";
4
6
  export interface DemoArgs {
5
- action: "up" | "check" | "down" | "spec";
6
- mode: "dev" | "standalone";
7
+ /** `dev-loop <action>`: the standalone tier's verbs. */
8
+ devLoop: boolean;
9
+ action: "up" | "check" | "down" | "spec" | "ui" | "refresh";
10
+ /** Absent: the spec's `mode`, else dev. */
11
+ mode?: DemoMode | "standalone";
12
+ daemon: DemoDaemonPolicy;
7
13
  exportOnly: boolean;
8
14
  json: boolean;
9
15
  spec: string;
16
+ /** `ui` only; zellij unless said. */
17
+ ui?: DemoUi;
18
+ /** Default: PUBLIC_HOST in the environment. */
19
+ publicHost?: string;
10
20
  }
11
21
  export declare function parseDemoArgs(argv: string[]): DemoArgs;
12
22
  export interface ResolvedSpecView {
@@ -27,6 +37,8 @@ export interface ResolvedSpecView {
27
37
  };
28
38
  launch?: DemoSpec["launch"];
29
39
  prerequisites?: DemoSpec["prerequisites"];
40
+ extras: string[];
41
+ beforeLaunch: boolean;
30
42
  sources: Array<{
31
43
  name: string;
32
44
  asset: NonNullable<DemoSpec["sources"]>[number]["asset"];
@@ -54,6 +66,7 @@ export interface ResolvedSpecView {
54
66
  export declare function resolvedSpecView(spec: DemoSpec, opts: {
55
67
  mode: string;
56
68
  slug?: string;
69
+ publicHost?: string;
57
70
  }): ResolvedSpecView;
58
71
  /** Everything `demoMain` touches, injectable so the dispatch is unit-tested. */
59
72
  export interface DemoCliIo {
@@ -66,6 +79,12 @@ export interface DemoCliIo {
66
79
  exportDir: string;
67
80
  }>;
68
81
  demoDown(product: string): Promise<void>;
82
+ runDevLoop(spec: DemoSpec, opts: DevLoopOptions): Promise<DevLoopResult>;
83
+ devLoopDown(product: string): Promise<void>;
84
+ /** Open the multiplexer on the rendered layout; returns its exit code. */
85
+ launchUi(ui: DemoUi, layout: PaneLayout): number;
86
+ /** For the standalone dirs (NORSK_DIR, STUDIO_DIR, TO, HOME). */
87
+ env?: Record<string, string | undefined>;
69
88
  stdout(line: string): void;
70
89
  stderr(line: string): void;
71
90
  abort?: AbortSignal;
package/demo/cli.js CHANGED
@@ -9,34 +9,80 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
9
9
  };
10
10
  // `ctl-demo`, the bin behind every product's `bun run demo` (05-demo s4):
11
11
  //
12
- // demo up [--mode dev] launch, print the URLs, hold; Ctrl-C tears down
13
- // demo check [--mode dev] the same, then tear down and exit 0/1 (what CI runs)
12
+ // demo up [--mode dev|image] [--daemon private|reuse]
13
+ // launch, print the URLs, hold; Ctrl-C tears down
14
+ // demo check [--mode dev|image] the same, then tear down and exit 0/1 (what CI runs)
14
15
  // demo check --mode standalone --export-only build + export-workdir + every symlink resolves
15
16
  // demo down tear down what `up` recorded, from any shell
16
17
  // demo spec [--json] the resolved spec: ports, template, sources, URLs
18
+ // demo ui [--ui zellij|tmux|none] [...] the same in a multiplexer: a gate pane running `demo up`,
19
+ // the instance's logs, a shell (05-demo s4 front-end 2)
20
+ // demo dev-loop up|refresh|down|ui the full standalone tier (engine + Studio from source);
21
+ // `ctl-dev-loop <verb>` is the same under a product's `bun run dev-loop`
17
22
  //
18
23
  // The spec is `tests/demo.spec.ts` in the cwd (`--spec <path>` otherwise), a
19
24
  // TypeScript module whose default export is `defineDemo({...})`. This runs
20
25
  // under bun only: the spec is TypeScript and so is every product's dev shell.
21
26
  import { resolve } from "node:path";
27
+ import { devLoopDown, runDevLoop } from "./dev-loop.js";
28
+ import { demoPaneList, devLoopPaneList, launchLayout, renderLayout, standaloneDirs, } from "./panes.js";
22
29
  import { demoDown, demoPorts, demoSlug, runDemo, runExportCheck, } from "./run.js";
23
- const USAGE = `usage: demo <up|check|down|spec> [--mode dev|standalone] [--export-only] [--json] [--spec <path>]
30
+ const USAGE = `usage: demo <up|check|down|spec|ui> [--mode dev|image|standalone] [--daemon private|reuse] [--export-only] [--json]
31
+ [--ui zellij|tmux|none] [--public-host <host>] [--spec <path>]
32
+ demo dev-loop <up|refresh|down|ui> [--ui zellij|tmux|none] [--public-host <host>] [--spec <path>]
24
33
 
25
- up launch on a private daemon, print the URLs, hold (Ctrl-C tears down)
26
- check up, then tear down and exit 0/1 — what CI runs
34
+ up launch, print the URLs, hold (Ctrl-C tears down)
35
+ check up, then tear down and exit 0/1 — what CI runs, always on a private daemon
27
36
  check --mode standalone --export-only
28
37
  build the template, export the standalone workdir, assert every symlink resolves
29
38
  down tear down what \`up\` recorded
30
39
  spec [--json] print the resolved spec without launching anything
40
+ ui open a multiplexer: a pane running \`up\`, the instance's logs, a shell
31
41
 
42
+ dev-loop up the standalone tier: private daemon + dev backend, template built from source,
43
+ workdir exported with the live-source links (NORSK_DIR, STUDIO_DIR, TO name the
44
+ engine and Studio checkouts and the workdir); holds
45
+ dev-loop refresh rebuild + re-export against the held \`up\` (after a component or dashboard edit)
46
+ dev-loop down end the held \`up\` from any shell; the workdir stays
47
+ dev-loop ui open a multiplexer: engine, \`dev-loop up\`, Studio, the sources (on Enter), a shell
48
+
49
+ --mode dev run the product from source (bun run dev) and add it by URL
50
+ --mode image add the built product image — the customer path
51
+ --daemon private a throwaway daemon on its own store (default)
52
+ --daemon reuse your listening daemon and real store; the driver deletes the instance,
53
+ removes the template and product, then adds again (stored templates
54
+ are immutable by name)
55
+ --ui zellij|tmux|none the multiplexer (\`none\` prints the pane commands); zellij unless said
56
+ --public-host <host> the name other machines reach this box by: every URL, the daemon's
57
+ publicHost, Studio's MoQ preview (default: PUBLIC_HOST)
32
58
  --spec <path> the demo spec (default tests/demo.spec.ts)`;
33
59
  const DEFAULT_SPEC = "tests/demo.spec.ts";
60
+ const DEMO_ACTIONS = ["up", "check", "down", "spec", "ui"];
61
+ const DEV_LOOP_ACTIONS = ["up", "refresh", "down", "ui"];
34
62
  export function parseDemoArgs(argv) {
35
- const [action, ...rest] = argv;
36
- if (action !== "up" && action !== "check" && action !== "down" && action !== "spec") {
63
+ const devLoop = argv[0] === "dev-loop";
64
+ const [action, ...rest] = devLoop ? argv.slice(1) : argv;
65
+ const actions = devLoop ? DEV_LOOP_ACTIONS : DEMO_ACTIONS;
66
+ if (action === undefined || !actions.includes(action)) {
67
+ if (devLoop)
68
+ throw new Error(`dev-loop takes one of up|refresh|down|ui${action ? `, not '${action}'` : ""}\n${USAGE}`);
69
+ if (action === "refresh")
70
+ throw new Error(`refresh is the dev-loop's: \`dev-loop refresh\`\n${USAGE}`);
37
71
  throw new Error(action ? `unknown action '${action}'\n${USAGE}` : USAGE);
38
72
  }
39
- const args = { action, mode: "dev", exportOnly: false, json: false, spec: DEFAULT_SPEC };
73
+ const args = {
74
+ devLoop,
75
+ action: action,
76
+ daemon: "private",
77
+ exportOnly: false,
78
+ json: false,
79
+ spec: DEFAULT_SPEC,
80
+ };
81
+ if (action === "ui")
82
+ args.ui = "zellij";
83
+ const envHost = process.env.PUBLIC_HOST;
84
+ if (envHost)
85
+ args.publicHost = envHost;
40
86
  for (let i = 0; i < rest.length; i++) {
41
87
  const flag = rest[i];
42
88
  const value = () => {
@@ -48,13 +94,18 @@ export function parseDemoArgs(argv) {
48
94
  switch (flag) {
49
95
  case "--mode": {
50
96
  const m = value();
51
- if (m === "image")
52
- throw new Error("--mode image is not yet implemented (05-demo s7 step 2); use --mode dev");
53
- if (m !== "dev" && m !== "standalone")
97
+ if (m !== "dev" && m !== "image" && m !== "standalone")
54
98
  throw new Error(`unknown mode '${m}'\n${USAGE}`);
55
99
  args.mode = m;
56
100
  break;
57
101
  }
102
+ case "--daemon": {
103
+ const d = value();
104
+ if (d !== "private" && d !== "reuse")
105
+ throw new Error(`unknown daemon policy '${d}'\n${USAGE}`);
106
+ args.daemon = d;
107
+ break;
108
+ }
58
109
  case "--export-only":
59
110
  args.exportOnly = true;
60
111
  break;
@@ -64,16 +115,34 @@ export function parseDemoArgs(argv) {
64
115
  case "--spec":
65
116
  args.spec = value();
66
117
  break;
118
+ case "--ui": {
119
+ const u = value();
120
+ if (u !== "zellij" && u !== "tmux" && u !== "none")
121
+ throw new Error(`unknown ui '${u}'\n${USAGE}`);
122
+ args.ui = u;
123
+ break;
124
+ }
125
+ case "--public-host":
126
+ args.publicHost = value();
127
+ break;
67
128
  default:
68
129
  throw new Error(`unknown flag '${flag}'\n${USAGE}`);
69
130
  }
70
131
  }
71
- if (args.mode === "standalone" && !args.exportOnly && (args.action === "up" || args.action === "check")) {
72
- throw new Error("the full standalone tier (engine + Studio panes) is not yet implemented (05-demo s7 step 3); this slice has `check --mode standalone --export-only`");
132
+ if (args.ui !== undefined && args.action !== "ui")
133
+ throw new Error("--ui belongs to ui (or dev-loop ui)");
134
+ if (devLoop && (args.mode !== undefined || args.daemon !== "private" || args.exportOnly || args.json)) {
135
+ throw new Error("dev-loop takes only --ui, --public-host and --spec (no --mode, --daemon, --export-only or --json): it is always the source tier on a private daemon");
136
+ }
137
+ if (args.mode === "standalone" && !args.exportOnly && args.action !== "spec") {
138
+ throw new Error(`the full standalone tier is the dev-loop's: \`dev-loop ${args.action === "ui" ? "ui" : "up"}\` (this mode's headless gate is \`check --mode standalone --export-only\`)`);
73
139
  }
74
140
  if (args.exportOnly && args.mode !== "standalone") {
75
141
  throw new Error("--export-only belongs to --mode standalone");
76
142
  }
143
+ if (args.action === "check" && args.daemon === "reuse") {
144
+ throw new Error("check runs on a private daemon (it is what CI runs); to reload your own daemon use `up --daemon reuse`");
145
+ }
77
146
  return args;
78
147
  }
79
148
  /** `demo spec --json`: what will run, resolved as far as it can be without a
@@ -83,16 +152,17 @@ export function resolvedSpecView(spec, opts) {
83
152
  const slug = opts.slug ?? demoSlug(spec.product);
84
153
  const instanceId = `demo-${slug}`;
85
154
  const ports = demoPorts(slug);
155
+ const host = opts.publicHost ?? "localhost";
86
156
  const pinned = spec.launch?.params?.STUDIO_HOST_PORT;
87
157
  const studioHostPort = pinned !== undefined ? Number(pinned) : ports.studioHostPort;
88
158
  const url = (ref) => {
89
159
  if ("url" in ref)
90
160
  return ref.url;
91
161
  if ("control" in ref)
92
- return `http://localhost:${ports.backendPort}${ref.control}`;
162
+ return `http://${host}:${ports.backendPort}${ref.control}`;
93
163
  if ("proxy" in ref)
94
- return `http://localhost:${ports.proxyPort}${ref.proxy.replaceAll("{id}", instanceId)}`;
95
- return `http://localhost:${studioHostPort}${ref.studio}`;
164
+ return `http://${host}:${ports.proxyPort}${ref.proxy.replaceAll("{id}", instanceId)}`;
165
+ return `http://${host}:${studioHostPort}${ref.studio}`;
96
166
  };
97
167
  const t = spec.template;
98
168
  const template = t && "build" in t
@@ -109,6 +179,8 @@ export function resolvedSpecView(spec, opts) {
109
179
  template,
110
180
  ...(spec.launch !== undefined ? { launch: spec.launch } : {}),
111
181
  ...(spec.prerequisites !== undefined ? { prerequisites: spec.prerequisites } : {}),
182
+ extras: (spec.extras ?? []).map((e) => e.name),
183
+ beforeLaunch: spec.beforeLaunch !== undefined,
112
184
  sources: (spec.sources ?? []).map((s) => ({
113
185
  name: s.name,
114
186
  asset: s.asset ?? { preset: "camera1" },
@@ -158,26 +230,60 @@ export async function demoMain(argv, io) {
158
230
  }
159
231
  return 1;
160
232
  }
233
+ const mode = args.mode ?? spec.mode ?? "dev";
234
+ const slug = demoSlug(spec.product);
235
+ const publicHost = args.publicHost !== undefined ? { publicHost: args.publicHost } : {};
161
236
  try {
237
+ if (args.devLoop) {
238
+ const dirs = standaloneDirs(spec, slug, io.env ?? process.env);
239
+ switch (args.action) {
240
+ case "up":
241
+ case "refresh":
242
+ await io.runDevLoop(spec, {
243
+ action: args.action,
244
+ cwd: io.cwd,
245
+ dirs,
246
+ ...publicHost,
247
+ ...(io.abort ? { abort: io.abort } : {}),
248
+ });
249
+ return 0;
250
+ case "down":
251
+ await io.devLoopDown(spec.product);
252
+ return 0;
253
+ default: {
254
+ const panes = devLoopPaneList(spec, { cwd: io.cwd, slug, ...dirs, ...publicHost });
255
+ return io.launchUi(args.ui ?? "zellij", { session: `${slug}-dev-loop`, tab: slug, panes });
256
+ }
257
+ }
258
+ }
162
259
  switch (args.action) {
163
260
  case "spec": {
164
- const view = resolvedSpecView(spec, { mode: args.mode });
261
+ const view = resolvedSpecView(spec, { mode, ...publicHost });
165
262
  io.stdout(args.json ? JSON.stringify(view, null, 2) : renderView(view));
166
263
  return 0;
167
264
  }
168
265
  case "down":
169
266
  await io.demoDown(spec.product);
170
267
  return 0;
171
- case "up":
172
- case "check": {
268
+ case "ui": {
269
+ if (mode === "standalone")
270
+ throw new Error("the standalone tier's multiplexer is `dev-loop ui`");
271
+ const panes = demoPaneList(spec, { cwd: io.cwd, slug, mode, daemon: args.daemon, ...publicHost });
272
+ return io.launchUi(args.ui ?? "zellij", { session: `${slug}-demo`, tab: slug, panes });
273
+ }
274
+ default: {
173
275
  if (args.exportOnly) {
174
276
  await io.runExportCheck(spec, { cwd: io.cwd });
175
277
  return 0;
176
278
  }
279
+ if (mode === "standalone")
280
+ throw new Error("the full standalone tier is `dev-loop up`");
177
281
  await io.runDemo(spec, {
178
282
  action: args.action,
179
- mode: "dev",
283
+ mode,
284
+ daemon: args.daemon,
180
285
  cwd: io.cwd,
286
+ ...publicHost,
181
287
  ...(io.abort ? { abort: io.abort } : {}),
182
288
  });
183
289
  return 0;
@@ -185,7 +291,7 @@ export async function demoMain(argv, io) {
185
291
  }
186
292
  }
187
293
  catch (e) {
188
- io.stderr(`demo ${args.action} failed: ${e instanceof Error ? e.message : String(e)}`);
294
+ io.stderr(`${args.devLoop ? "dev-loop" : "demo"} ${args.action} failed: ${e instanceof Error ? e.message : String(e)}`);
189
295
  return 1;
190
296
  }
191
297
  }
@@ -195,6 +301,10 @@ function renderView(v) {
195
301
  ` daemon :${v.ports.daemonPort} dev backend :${v.ports.backendPort} proxy :${v.ports.proxyPort} studio :${v.ports.studioHostPort}`,
196
302
  ` template ${"name" in v.template ? v.template.name : `build ${v.template.build.name}`}`,
197
303
  ];
304
+ if (v.image)
305
+ lines.push(` image ${v.image}`);
306
+ for (const e of v.extras)
307
+ lines.push(` extra ${e}`);
198
308
  for (const s of v.sources)
199
309
  lines.push(` source ${s.name}: ${JSON.stringify(s.asset)} -> ingest ${JSON.stringify(s.ingest)}`);
200
310
  for (const g of v.ready)
@@ -216,12 +326,17 @@ export function defaultDemoCliIo() {
216
326
  const release = () => controller.abort();
217
327
  process.once("SIGINT", release);
218
328
  process.once("SIGTERM", release);
329
+ // A multiplexer session ending sends its panes SIGHUP: that is a release too.
330
+ process.once("SIGHUP", release);
219
331
  return {
220
332
  cwd: process.cwd(),
221
333
  loadSpec: loadSpecModule,
222
334
  runDemo: (spec, opts) => runDemo(spec, opts),
223
335
  runExportCheck: (spec, opts) => runExportCheck(spec, opts),
224
336
  demoDown: (product) => demoDown(product),
337
+ runDevLoop: (spec, opts) => runDevLoop(spec, opts),
338
+ devLoopDown: (product) => devLoopDown(product),
339
+ launchUi: (ui, layout) => launchLayout(ui, renderLayout(layout, ui), layout),
225
340
  stdout: (line) => console.log(line),
226
341
  stderr: (line) => console.error(line),
227
342
  abort: controller.signal,
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env bun
2
+ export {};
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bun
2
+ // `ctl-dev-loop`, the bin behind a product's `bun run dev-loop`: the
3
+ // standalone tier's verbs (`up`, `refresh`, `down`, `ui`) are `ctl-demo
4
+ // dev-loop <verb>` under another name.
5
+ import { defaultDemoCliIo, demoMain } from "./cli.js";
6
+ if (import.meta.main) {
7
+ process.exit(await demoMain(["dev-loop", ...process.argv.slice(2)], defaultDemoCliIo()));
8
+ }
@@ -0,0 +1,33 @@
1
+ import type { SourceAsset } from "../source-pump.js";
2
+ import { type StandaloneDirs } from "./panes.js";
3
+ import { type DemoDeps, type DemoTimeouts } from "./run.js";
4
+ import type { DemoSpec } from "./spec.js";
5
+ export interface DevLoopOptions {
6
+ action: "up" | "refresh";
7
+ cwd: string;
8
+ slug?: string;
9
+ /** Default: `standaloneDirs(spec, slug)` — the environment, the spec, the conventions. */
10
+ dirs?: StandaloneDirs;
11
+ publicHost?: string;
12
+ abort?: AbortSignal;
13
+ timeouts?: DemoTimeouts;
14
+ }
15
+ export interface DevLoopResult {
16
+ workdir: string;
17
+ studioUrl: string;
18
+ }
19
+ export interface StandaloneSource {
20
+ name: string;
21
+ port: number;
22
+ streamId: string;
23
+ asset: SourceAsset;
24
+ }
25
+ /** The pump script the sources pane runs on Enter: every source in
26
+ * parallel with the host's ffmpeg, into the from-source engine. A preset is
27
+ * fetched into the daemon's sample-media cache if it is not there yet; a
28
+ * mediaFile that is absent on this box pumps its fallback. */
29
+ export declare function pumpScript(sources: StandaloneSource[], fileExists: (path: string) => boolean): string;
30
+ export declare function runDevLoop(spec: DemoSpec, opts: DevLoopOptions, deps?: DemoDeps): Promise<DevLoopResult>;
31
+ /** `dev-loop down`: end the recorded run from any shell. The workdir stays —
32
+ * it is the developer's, and Studio may still be looking at it. */
33
+ export declare function devLoopDown(product: string, deps?: DemoDeps): Promise<void>;