@norskvideo/ctl-test-harness 0.1.21 → 0.1.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/demo/cli.d.ts +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/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 +43 -29
- package/demo/spec.d.ts +12 -2
- package/demo/spec.js +20 -11
- package/package.json +3 -2
package/demo/cli.d.ts
CHANGED
|
@@ -1,14 +1,22 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
+
import { type DevLoopOptions, type DevLoopResult } from "./dev-loop.js";
|
|
3
|
+
import { type DemoUi, type PaneLayout } from "./panes.js";
|
|
2
4
|
import { type DemoRunOptions, type DemoRunResult, demoPorts } from "./run.js";
|
|
3
5
|
import type { DemoDaemonPolicy, DemoMode, DemoSpec } from "./spec.js";
|
|
4
6
|
export interface DemoArgs {
|
|
5
|
-
|
|
7
|
+
/** `dev-loop <action>`: the standalone tier's verbs. */
|
|
8
|
+
devLoop: boolean;
|
|
9
|
+
action: "up" | "check" | "down" | "spec" | "ui" | "refresh";
|
|
6
10
|
/** Absent: the spec's `mode`, else dev. */
|
|
7
11
|
mode?: DemoMode | "standalone";
|
|
8
12
|
daemon: DemoDaemonPolicy;
|
|
9
13
|
exportOnly: boolean;
|
|
10
14
|
json: boolean;
|
|
11
15
|
spec: string;
|
|
16
|
+
/** `ui` only; zellij unless said. */
|
|
17
|
+
ui?: DemoUi;
|
|
18
|
+
/** Default: PUBLIC_HOST in the environment. */
|
|
19
|
+
publicHost?: string;
|
|
12
20
|
}
|
|
13
21
|
export declare function parseDemoArgs(argv: string[]): DemoArgs;
|
|
14
22
|
export interface ResolvedSpecView {
|
|
@@ -58,6 +66,7 @@ export interface ResolvedSpecView {
|
|
|
58
66
|
export declare function resolvedSpecView(spec: DemoSpec, opts: {
|
|
59
67
|
mode: string;
|
|
60
68
|
slug?: string;
|
|
69
|
+
publicHost?: string;
|
|
61
70
|
}): ResolvedSpecView;
|
|
62
71
|
/** Everything `demoMain` touches, injectable so the dispatch is unit-tested. */
|
|
63
72
|
export interface DemoCliIo {
|
|
@@ -70,6 +79,12 @@ export interface DemoCliIo {
|
|
|
70
79
|
exportDir: string;
|
|
71
80
|
}>;
|
|
72
81
|
demoDown(product: string): Promise<void>;
|
|
82
|
+
runDevLoop(spec: DemoSpec, opts: DevLoopOptions): Promise<DevLoopResult>;
|
|
83
|
+
devLoopDown(product: string): Promise<void>;
|
|
84
|
+
/** Open the multiplexer on the rendered layout; returns its exit code. */
|
|
85
|
+
launchUi(ui: DemoUi, layout: PaneLayout): number;
|
|
86
|
+
/** For the standalone dirs (NORSK_DIR, STUDIO_DIR, TO, HOME). */
|
|
87
|
+
env?: Record<string, string | undefined>;
|
|
73
88
|
stdout(line: string): void;
|
|
74
89
|
stderr(line: string): void;
|
|
75
90
|
abort?: AbortSignal;
|
package/demo/cli.js
CHANGED
|
@@ -15,13 +15,21 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
|
|
|
15
15
|
// demo check --mode standalone --export-only build + export-workdir + every symlink resolves
|
|
16
16
|
// demo down tear down what `up` recorded, from any shell
|
|
17
17
|
// demo spec [--json] the resolved spec: ports, template, sources, URLs
|
|
18
|
+
// demo ui [--ui zellij|tmux|none] [...] the same in a multiplexer: a gate pane running `demo up`,
|
|
19
|
+
// the instance's logs, a shell (05-demo s4 front-end 2)
|
|
20
|
+
// demo dev-loop up|refresh|down|ui the full standalone tier (engine + Studio from source);
|
|
21
|
+
// `ctl-dev-loop <verb>` is the same under a product's `bun run dev-loop`
|
|
18
22
|
//
|
|
19
23
|
// The spec is `tests/demo.spec.ts` in the cwd (`--spec <path>` otherwise), a
|
|
20
24
|
// TypeScript module whose default export is `defineDemo({...})`. This runs
|
|
21
25
|
// under bun only: the spec is TypeScript and so is every product's dev shell.
|
|
22
26
|
import { resolve } from "node:path";
|
|
27
|
+
import { devLoopDown, runDevLoop } from "./dev-loop.js";
|
|
28
|
+
import { demoPaneList, devLoopPaneList, launchLayout, renderLayout, standaloneDirs, } from "./panes.js";
|
|
23
29
|
import { demoDown, demoPorts, demoSlug, runDemo, runExportCheck, } from "./run.js";
|
|
24
|
-
const USAGE = `usage: demo <up|check|down|spec> [--mode dev|image|standalone] [--daemon private|reuse] [--export-only] [--json]
|
|
30
|
+
const USAGE = `usage: demo <up|check|down|spec|ui> [--mode dev|image|standalone] [--daemon private|reuse] [--export-only] [--json]
|
|
31
|
+
[--ui zellij|tmux|none] [--public-host <host>] [--spec <path>]
|
|
32
|
+
demo dev-loop <up|refresh|down|ui> [--ui zellij|tmux|none] [--public-host <host>] [--spec <path>]
|
|
25
33
|
|
|
26
34
|
up launch, print the URLs, hold (Ctrl-C tears down)
|
|
27
35
|
check up, then tear down and exit 0/1 — what CI runs, always on a private daemon
|
|
@@ -29,6 +37,14 @@ const USAGE = `usage: demo <up|check|down|spec> [--mode dev|image|standalone] [-
|
|
|
29
37
|
build the template, export the standalone workdir, assert every symlink resolves
|
|
30
38
|
down tear down what \`up\` recorded
|
|
31
39
|
spec [--json] print the resolved spec without launching anything
|
|
40
|
+
ui open a multiplexer: a pane running \`up\`, the instance's logs, a shell
|
|
41
|
+
|
|
42
|
+
dev-loop up the standalone tier: private daemon + dev backend, template built from source,
|
|
43
|
+
workdir exported with the live-source links (NORSK_DIR, STUDIO_DIR, TO name the
|
|
44
|
+
engine and Studio checkouts and the workdir); holds
|
|
45
|
+
dev-loop refresh rebuild + re-export against the held \`up\` (after a component or dashboard edit)
|
|
46
|
+
dev-loop down end the held \`up\` from any shell; the workdir stays
|
|
47
|
+
dev-loop ui open a multiplexer: engine, \`dev-loop up\`, Studio, the sources (on Enter), a shell
|
|
32
48
|
|
|
33
49
|
--mode dev run the product from source (bun run dev) and add it by URL
|
|
34
50
|
--mode image add the built product image — the customer path
|
|
@@ -36,14 +52,37 @@ const USAGE = `usage: demo <up|check|down|spec> [--mode dev|image|standalone] [-
|
|
|
36
52
|
--daemon reuse your listening daemon and real store; the driver deletes the instance,
|
|
37
53
|
removes the template and product, then adds again (stored templates
|
|
38
54
|
are immutable by name)
|
|
55
|
+
--ui zellij|tmux|none the multiplexer (\`none\` prints the pane commands); zellij unless said
|
|
56
|
+
--public-host <host> the name other machines reach this box by: every URL, the daemon's
|
|
57
|
+
publicHost, Studio's MoQ preview (default: PUBLIC_HOST)
|
|
39
58
|
--spec <path> the demo spec (default tests/demo.spec.ts)`;
|
|
40
59
|
const DEFAULT_SPEC = "tests/demo.spec.ts";
|
|
60
|
+
const DEMO_ACTIONS = ["up", "check", "down", "spec", "ui"];
|
|
61
|
+
const DEV_LOOP_ACTIONS = ["up", "refresh", "down", "ui"];
|
|
41
62
|
export function parseDemoArgs(argv) {
|
|
42
|
-
const [
|
|
43
|
-
|
|
63
|
+
const devLoop = argv[0] === "dev-loop";
|
|
64
|
+
const [action, ...rest] = devLoop ? argv.slice(1) : argv;
|
|
65
|
+
const actions = devLoop ? DEV_LOOP_ACTIONS : DEMO_ACTIONS;
|
|
66
|
+
if (action === undefined || !actions.includes(action)) {
|
|
67
|
+
if (devLoop)
|
|
68
|
+
throw new Error(`dev-loop takes one of up|refresh|down|ui${action ? `, not '${action}'` : ""}\n${USAGE}`);
|
|
69
|
+
if (action === "refresh")
|
|
70
|
+
throw new Error(`refresh is the dev-loop's: \`dev-loop refresh\`\n${USAGE}`);
|
|
44
71
|
throw new Error(action ? `unknown action '${action}'\n${USAGE}` : USAGE);
|
|
45
72
|
}
|
|
46
|
-
const args = {
|
|
73
|
+
const args = {
|
|
74
|
+
devLoop,
|
|
75
|
+
action: action,
|
|
76
|
+
daemon: "private",
|
|
77
|
+
exportOnly: false,
|
|
78
|
+
json: false,
|
|
79
|
+
spec: DEFAULT_SPEC,
|
|
80
|
+
};
|
|
81
|
+
if (action === "ui")
|
|
82
|
+
args.ui = "zellij";
|
|
83
|
+
const envHost = process.env.PUBLIC_HOST;
|
|
84
|
+
if (envHost)
|
|
85
|
+
args.publicHost = envHost;
|
|
47
86
|
for (let i = 0; i < rest.length; i++) {
|
|
48
87
|
const flag = rest[i];
|
|
49
88
|
const value = () => {
|
|
@@ -76,12 +115,27 @@ export function parseDemoArgs(argv) {
|
|
|
76
115
|
case "--spec":
|
|
77
116
|
args.spec = value();
|
|
78
117
|
break;
|
|
118
|
+
case "--ui": {
|
|
119
|
+
const u = value();
|
|
120
|
+
if (u !== "zellij" && u !== "tmux" && u !== "none")
|
|
121
|
+
throw new Error(`unknown ui '${u}'\n${USAGE}`);
|
|
122
|
+
args.ui = u;
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
case "--public-host":
|
|
126
|
+
args.publicHost = value();
|
|
127
|
+
break;
|
|
79
128
|
default:
|
|
80
129
|
throw new Error(`unknown flag '${flag}'\n${USAGE}`);
|
|
81
130
|
}
|
|
82
131
|
}
|
|
83
|
-
if (args.
|
|
84
|
-
throw new Error("
|
|
132
|
+
if (args.ui !== undefined && args.action !== "ui")
|
|
133
|
+
throw new Error("--ui belongs to ui (or dev-loop ui)");
|
|
134
|
+
if (devLoop && (args.mode !== undefined || args.daemon !== "private" || args.exportOnly || args.json)) {
|
|
135
|
+
throw new Error("dev-loop takes only --ui, --public-host and --spec (no --mode, --daemon, --export-only or --json): it is always the source tier on a private daemon");
|
|
136
|
+
}
|
|
137
|
+
if (args.mode === "standalone" && !args.exportOnly && args.action !== "spec") {
|
|
138
|
+
throw new Error(`the full standalone tier is the dev-loop's: \`dev-loop ${args.action === "ui" ? "ui" : "up"}\` (this mode's headless gate is \`check --mode standalone --export-only\`)`);
|
|
85
139
|
}
|
|
86
140
|
if (args.exportOnly && args.mode !== "standalone") {
|
|
87
141
|
throw new Error("--export-only belongs to --mode standalone");
|
|
@@ -98,16 +152,17 @@ export function resolvedSpecView(spec, opts) {
|
|
|
98
152
|
const slug = opts.slug ?? demoSlug(spec.product);
|
|
99
153
|
const instanceId = `demo-${slug}`;
|
|
100
154
|
const ports = demoPorts(slug);
|
|
155
|
+
const host = opts.publicHost ?? "localhost";
|
|
101
156
|
const pinned = spec.launch?.params?.STUDIO_HOST_PORT;
|
|
102
157
|
const studioHostPort = pinned !== undefined ? Number(pinned) : ports.studioHostPort;
|
|
103
158
|
const url = (ref) => {
|
|
104
159
|
if ("url" in ref)
|
|
105
160
|
return ref.url;
|
|
106
161
|
if ("control" in ref)
|
|
107
|
-
return `http
|
|
162
|
+
return `http://${host}:${ports.backendPort}${ref.control}`;
|
|
108
163
|
if ("proxy" in ref)
|
|
109
|
-
return `http
|
|
110
|
-
return `http
|
|
164
|
+
return `http://${host}:${ports.proxyPort}${ref.proxy.replaceAll("{id}", instanceId)}`;
|
|
165
|
+
return `http://${host}:${studioHostPort}${ref.studio}`;
|
|
111
166
|
};
|
|
112
167
|
const t = spec.template;
|
|
113
168
|
const template = t && "build" in t
|
|
@@ -176,30 +231,59 @@ export async function demoMain(argv, io) {
|
|
|
176
231
|
return 1;
|
|
177
232
|
}
|
|
178
233
|
const mode = args.mode ?? spec.mode ?? "dev";
|
|
234
|
+
const slug = demoSlug(spec.product);
|
|
235
|
+
const publicHost = args.publicHost !== undefined ? { publicHost: args.publicHost } : {};
|
|
179
236
|
try {
|
|
237
|
+
if (args.devLoop) {
|
|
238
|
+
const dirs = standaloneDirs(spec, slug, io.env ?? process.env);
|
|
239
|
+
switch (args.action) {
|
|
240
|
+
case "up":
|
|
241
|
+
case "refresh":
|
|
242
|
+
await io.runDevLoop(spec, {
|
|
243
|
+
action: args.action,
|
|
244
|
+
cwd: io.cwd,
|
|
245
|
+
dirs,
|
|
246
|
+
...publicHost,
|
|
247
|
+
...(io.abort ? { abort: io.abort } : {}),
|
|
248
|
+
});
|
|
249
|
+
return 0;
|
|
250
|
+
case "down":
|
|
251
|
+
await io.devLoopDown(spec.product);
|
|
252
|
+
return 0;
|
|
253
|
+
default: {
|
|
254
|
+
const panes = devLoopPaneList(spec, { cwd: io.cwd, slug, ...dirs, ...publicHost });
|
|
255
|
+
return io.launchUi(args.ui ?? "zellij", { session: `${slug}-dev-loop`, tab: slug, panes });
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
180
259
|
switch (args.action) {
|
|
181
260
|
case "spec": {
|
|
182
|
-
const view = resolvedSpecView(spec, { mode });
|
|
261
|
+
const view = resolvedSpecView(spec, { mode, ...publicHost });
|
|
183
262
|
io.stdout(args.json ? JSON.stringify(view, null, 2) : renderView(view));
|
|
184
263
|
return 0;
|
|
185
264
|
}
|
|
186
265
|
case "down":
|
|
187
266
|
await io.demoDown(spec.product);
|
|
188
267
|
return 0;
|
|
189
|
-
case "
|
|
190
|
-
|
|
268
|
+
case "ui": {
|
|
269
|
+
if (mode === "standalone")
|
|
270
|
+
throw new Error("the standalone tier's multiplexer is `dev-loop ui`");
|
|
271
|
+
const panes = demoPaneList(spec, { cwd: io.cwd, slug, mode, daemon: args.daemon, ...publicHost });
|
|
272
|
+
return io.launchUi(args.ui ?? "zellij", { session: `${slug}-demo`, tab: slug, panes });
|
|
273
|
+
}
|
|
274
|
+
default: {
|
|
191
275
|
if (args.exportOnly) {
|
|
192
276
|
await io.runExportCheck(spec, { cwd: io.cwd });
|
|
193
277
|
return 0;
|
|
194
278
|
}
|
|
195
|
-
if (mode === "standalone")
|
|
196
|
-
throw new Error("the full standalone tier is
|
|
197
|
-
}
|
|
279
|
+
if (mode === "standalone")
|
|
280
|
+
throw new Error("the full standalone tier is `dev-loop up`");
|
|
198
281
|
await io.runDemo(spec, {
|
|
199
282
|
action: args.action,
|
|
200
283
|
mode,
|
|
201
284
|
daemon: args.daemon,
|
|
202
285
|
cwd: io.cwd,
|
|
286
|
+
...publicHost,
|
|
203
287
|
...(io.abort ? { abort: io.abort } : {}),
|
|
204
288
|
});
|
|
205
289
|
return 0;
|
|
@@ -207,7 +291,7 @@ export async function demoMain(argv, io) {
|
|
|
207
291
|
}
|
|
208
292
|
}
|
|
209
293
|
catch (e) {
|
|
210
|
-
io.stderr(
|
|
294
|
+
io.stderr(`${args.devLoop ? "dev-loop" : "demo"} ${args.action} failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
211
295
|
return 1;
|
|
212
296
|
}
|
|
213
297
|
}
|
|
@@ -242,12 +326,17 @@ export function defaultDemoCliIo() {
|
|
|
242
326
|
const release = () => controller.abort();
|
|
243
327
|
process.once("SIGINT", release);
|
|
244
328
|
process.once("SIGTERM", release);
|
|
329
|
+
// A multiplexer session ending sends its panes SIGHUP: that is a release too.
|
|
330
|
+
process.once("SIGHUP", release);
|
|
245
331
|
return {
|
|
246
332
|
cwd: process.cwd(),
|
|
247
333
|
loadSpec: loadSpecModule,
|
|
248
334
|
runDemo: (spec, opts) => runDemo(spec, opts),
|
|
249
335
|
runExportCheck: (spec, opts) => runExportCheck(spec, opts),
|
|
250
336
|
demoDown: (product) => demoDown(product),
|
|
337
|
+
runDevLoop: (spec, opts) => runDevLoop(spec, opts),
|
|
338
|
+
devLoopDown: (product) => devLoopDown(product),
|
|
339
|
+
launchUi: (ui, layout) => launchLayout(ui, renderLayout(layout, ui), layout),
|
|
251
340
|
stdout: (line) => console.log(line),
|
|
252
341
|
stderr: (line) => console.error(line),
|
|
253
342
|
abort: controller.signal,
|
|
@@ -0,0 +1,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>;
|
package/demo/dev-loop.js
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// The full standalone tier (05-demo s7 step 3): probe's `refresh-standalone`
|
|
2
|
+
// and the sequencing half of `probe-demo-zellij`, for every product. The
|
|
3
|
+
// engine and Studio run from their checkouts (the `dev-loop ui` panes); this
|
|
4
|
+
// driver owns the rest — a private daemon, the dev backend on the driver's
|
|
5
|
+
// port, the template built from source, the workdir exported with the
|
|
6
|
+
// live-source links, the pump script resolved from the template's ports, and
|
|
7
|
+
// the stamp the Studio pane waits for. `up` holds so `refresh` can re-export
|
|
8
|
+
// against the same daemon and backend; `down` tears them down and leaves the
|
|
9
|
+
// developer's workdir where it is.
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { DEFAULT_STANDALONE_STUDIO_PORT, DEV_LOOP_SOURCES, DEV_LOOP_STAMP, standaloneDirs, } from "./panes.js";
|
|
12
|
+
import { DemoSession, defaultDemoDeps, demoSlug, exportStandaloneWorkdir, resolveIngestPort, } from "./run.js";
|
|
13
|
+
/** The daemon's built-in presets and where it caches them (norsk-ctl
|
|
14
|
+
* backend/src/docker/constants.ts: SOURCE_PRESETS, S3_MEDIA_BASE); the
|
|
15
|
+
* standalone tier pumps from the same files with the host's ffmpeg. */
|
|
16
|
+
const SAMPLE_MEDIA_BASE = "https://s3.eu-west-1.amazonaws.com/norsk.video/media-examples/data";
|
|
17
|
+
const SAMPLE_MEDIA_FILES = { camera1: "action.mp4", camera2: "wildlife.ts" };
|
|
18
|
+
const SAMPLE_MEDIA_DIR = "$HOME/.norsk-ctl/sample-media";
|
|
19
|
+
function generated(pattern) {
|
|
20
|
+
const video = pattern === "bars" ? "smptebars=size=1280x720:rate=25" : "testsrc=size=1280x720:rate=25";
|
|
21
|
+
return `ffmpeg -hide_banner -loglevel warning -re -f lavfi -i "${video}" -f lavfi -i "sine=frequency=440:sample_rate=48000" -c:v libx264 -preset veryfast -tune zerolatency -g 50 -c:a aac`;
|
|
22
|
+
}
|
|
23
|
+
function pumpLines(asset, target) {
|
|
24
|
+
if ("mediaFile" in asset) {
|
|
25
|
+
return [
|
|
26
|
+
`ffmpeg -hide_banner -loglevel warning -re -stream_loop -1 -i "${asset.mediaFile}" -c copy -f mpegts "${target}" &`,
|
|
27
|
+
];
|
|
28
|
+
}
|
|
29
|
+
if ("generate" in asset)
|
|
30
|
+
return [`${generated(asset.generate)} -f mpegts "${target}" &`];
|
|
31
|
+
const file = SAMPLE_MEDIA_FILES[asset.preset];
|
|
32
|
+
if (!file)
|
|
33
|
+
throw new Error(`unknown preset '${asset.preset}'; the daemon's presets are ${Object.keys(SAMPLE_MEDIA_FILES).join(", ")}`);
|
|
34
|
+
return [
|
|
35
|
+
`f="${SAMPLE_MEDIA_DIR}/${file}"; [ -f "$f" ] || curl -fL --create-dirs -o "$f" "${SAMPLE_MEDIA_BASE}/${file}"`,
|
|
36
|
+
`ffmpeg -hide_banner -loglevel warning -re -stream_loop -1 -i "$f" -c copy -f mpegts "${target}" &`,
|
|
37
|
+
];
|
|
38
|
+
}
|
|
39
|
+
/** The pump script the sources pane runs on Enter: every source in
|
|
40
|
+
* parallel with the host's ffmpeg, into the from-source engine. A preset is
|
|
41
|
+
* fetched into the daemon's sample-media cache if it is not there yet; a
|
|
42
|
+
* mediaFile that is absent on this box pumps its fallback. */
|
|
43
|
+
export function pumpScript(sources, fileExists) {
|
|
44
|
+
const lines = [
|
|
45
|
+
"#!/usr/bin/env bash",
|
|
46
|
+
"# written by dev-loop up: the demo's sources, pumped from this box into the from-source engine",
|
|
47
|
+
"set -uo pipefail",
|
|
48
|
+
];
|
|
49
|
+
for (const s of sources) {
|
|
50
|
+
const target = `srt://127.0.0.1:${s.port}?streamid=${s.streamId}`;
|
|
51
|
+
const asset = "mediaFile" in s.asset && !fileExists(s.asset.mediaFile) ? s.asset.fallback : s.asset;
|
|
52
|
+
const via = "mediaFile" in s.asset && !fileExists(s.asset.mediaFile) ? ` (${s.asset.mediaFile} is not on this box)` : "";
|
|
53
|
+
lines.push("", `# ${s.name}: ${JSON.stringify(asset)} -> ${target}${via}`, ...pumpLines(asset, target));
|
|
54
|
+
}
|
|
55
|
+
lines.push("", "wait");
|
|
56
|
+
return `${lines.join("\n")}\n`;
|
|
57
|
+
}
|
|
58
|
+
function standalonePort(ingest, t) {
|
|
59
|
+
if ("label" in ingest) {
|
|
60
|
+
throw new Error(`ingest label '${ingest.label}' — the standalone tier has no instance to resolve a label against; name the template parameter (ingest: { param })`);
|
|
61
|
+
}
|
|
62
|
+
if ("port" in ingest)
|
|
63
|
+
return ingest.port;
|
|
64
|
+
return resolveIngestPort(ingest, [], t);
|
|
65
|
+
}
|
|
66
|
+
/** The standalone tier's template: its own when the spec gives one, else the demo's. */
|
|
67
|
+
function loopTemplate(spec) {
|
|
68
|
+
return spec.standalone?.template ?? spec.template;
|
|
69
|
+
}
|
|
70
|
+
function studioPortOf(envText) {
|
|
71
|
+
const m = /^export PORT='?(\d+)/m.exec(envText ?? "");
|
|
72
|
+
return m ? Number(m[1]) : DEFAULT_STANDALONE_STUDIO_PORT;
|
|
73
|
+
}
|
|
74
|
+
/** Build (or reload) the template, export the workdir, write the pump script
|
|
75
|
+
* and finally the stamp — the order the panes rely on. */
|
|
76
|
+
async function exportLoop(s, templateName, workdir, refresh) {
|
|
77
|
+
const { spec, deps } = s;
|
|
78
|
+
deps.removeFile(join(workdir, DEV_LOOP_STAMP));
|
|
79
|
+
if (refresh) {
|
|
80
|
+
const t = loopTemplate(spec);
|
|
81
|
+
if (t && "build" in t)
|
|
82
|
+
await s.resolveTemplate(t);
|
|
83
|
+
else
|
|
84
|
+
await s.cliOk(["product", "reload", spec.product]);
|
|
85
|
+
}
|
|
86
|
+
await exportStandaloneWorkdir(s, templateName, workdir);
|
|
87
|
+
const declared = await s.declaredParams(templateName);
|
|
88
|
+
const t = { declared, overrides: spec.standalone?.params ?? {} };
|
|
89
|
+
const sources = (spec.sources ?? []).map((src) => {
|
|
90
|
+
const asset = src.asset ?? { preset: "camera1" };
|
|
91
|
+
return { name: src.name, port: standalonePort(src.ingest, t), streamId: src.streamId ?? src.name, asset };
|
|
92
|
+
});
|
|
93
|
+
deps.writeFile(join(workdir, DEV_LOOP_SOURCES), pumpScript(sources, deps.fileExists));
|
|
94
|
+
deps.writeFile(join(workdir, DEV_LOOP_STAMP), `${new Date().toISOString()}\n`);
|
|
95
|
+
return studioPortOf(deps.readFile(join(workdir, "env")));
|
|
96
|
+
}
|
|
97
|
+
function describeLoop(s, dirs, studioPort) {
|
|
98
|
+
const { spec } = s;
|
|
99
|
+
const url = `http://${s.host}:${studioPort}`;
|
|
100
|
+
return [
|
|
101
|
+
`${spec.product} standalone workdir exported to ${dirs.workdir} (template from the dev backend on :${s.ports.backendPort})`,
|
|
102
|
+
` engine cd ${dirs.norskDir} && nix develop --command make media-shell`,
|
|
103
|
+
` studio cd ${dirs.studioDir} && (. ${dirs.workdir}/env && npm run server-dev) -> ${url}`,
|
|
104
|
+
` sources bash ${join(dirs.workdir, DEV_LOOP_SOURCES)} (into the engine, when the workflow is running)`,
|
|
105
|
+
` after a shared/ edit: restart this (the dev backend does not watch shared/); a dashboard edit needs only a browser refresh`,
|
|
106
|
+
];
|
|
107
|
+
}
|
|
108
|
+
async function refuseIfUp(spec, deps) {
|
|
109
|
+
const prev = deps.state.read(spec.product, "dev-loop");
|
|
110
|
+
if (!prev)
|
|
111
|
+
return;
|
|
112
|
+
if (await deps.daemonAnswers(prev.daemonPort)) {
|
|
113
|
+
throw new Error(`dev-loop '${spec.product}' is already up (daemon :${prev.daemonPort}, workdir ${prev.workdir}) — \`dev-loop refresh\` re-exports it, \`dev-loop down\` ends it`);
|
|
114
|
+
}
|
|
115
|
+
deps.log(`forgetting a stale dev-loop record of a run on :${prev.daemonPort} (nothing of it is left)`);
|
|
116
|
+
deps.state.remove(spec.product, "dev-loop");
|
|
117
|
+
}
|
|
118
|
+
export async function runDevLoop(spec, opts, deps = defaultDemoDeps(opts.cwd)) {
|
|
119
|
+
const slug = opts.slug ?? demoSlug(spec.product);
|
|
120
|
+
const dirs = opts.dirs ?? standaloneDirs(spec, slug);
|
|
121
|
+
const common = {
|
|
122
|
+
mode: "dev",
|
|
123
|
+
daemon: "private",
|
|
124
|
+
...(opts.timeouts !== undefined ? { timeouts: opts.timeouts } : {}),
|
|
125
|
+
...(opts.publicHost !== undefined ? { publicHost: opts.publicHost } : {}),
|
|
126
|
+
};
|
|
127
|
+
if (opts.action === "refresh") {
|
|
128
|
+
const state = deps.state.read(spec.product, "dev-loop");
|
|
129
|
+
if (!state || !(await deps.daemonAnswers(state.daemonPort)) || !state.templateName) {
|
|
130
|
+
throw new Error(`dev-loop '${spec.product}' is not up — run \`dev-loop up\` first (it holds; refresh from another pane)`);
|
|
131
|
+
}
|
|
132
|
+
const s = new DemoSession(spec, slug, opts.cwd, deps, { ...common, storeDir: state.storeDir });
|
|
133
|
+
const workdir = state.workdir ?? dirs.workdir;
|
|
134
|
+
const studioPort = await exportLoop(s, state.templateName, workdir, true);
|
|
135
|
+
for (const line of describeLoop(s, { ...dirs, workdir }, studioPort))
|
|
136
|
+
deps.log(line);
|
|
137
|
+
deps.log("refreshed — reload the workflow in Studio");
|
|
138
|
+
return { workdir, studioUrl: `http://${s.host}:${studioPort}` };
|
|
139
|
+
}
|
|
140
|
+
await refuseIfUp(spec, deps);
|
|
141
|
+
const s = new DemoSession(spec, slug, opts.cwd, deps, common);
|
|
142
|
+
let recorded = false;
|
|
143
|
+
let result;
|
|
144
|
+
let journeyError;
|
|
145
|
+
try {
|
|
146
|
+
await s.attachDaemon();
|
|
147
|
+
await s.startDev();
|
|
148
|
+
await s.register({ licence: false });
|
|
149
|
+
const templateName = await s.resolveTemplate(loopTemplate(spec));
|
|
150
|
+
const studioPort = await exportLoop(s, templateName, dirs.workdir, false);
|
|
151
|
+
for (const line of describeLoop(s, dirs, studioPort))
|
|
152
|
+
deps.log(line);
|
|
153
|
+
result = { workdir: dirs.workdir, studioUrl: `http://${s.host}:${studioPort}` };
|
|
154
|
+
const state = {
|
|
155
|
+
product: spec.product,
|
|
156
|
+
kind: "dev-loop",
|
|
157
|
+
storeDir: s.storeDir,
|
|
158
|
+
daemonPort: s.ports.daemonPort,
|
|
159
|
+
workdir: dirs.workdir,
|
|
160
|
+
templateName,
|
|
161
|
+
...(s.dev?.pid !== undefined ? { devPid: s.dev.pid } : {}),
|
|
162
|
+
};
|
|
163
|
+
deps.state.write(state);
|
|
164
|
+
recorded = true;
|
|
165
|
+
deps.log("holding — Ctrl-C (or `dev-loop down` from another shell) ends it; `dev-loop refresh` re-exports");
|
|
166
|
+
await deps.hold(opts.abort);
|
|
167
|
+
}
|
|
168
|
+
catch (e) {
|
|
169
|
+
journeyError = e;
|
|
170
|
+
}
|
|
171
|
+
await s.teardown({ instances: [], handles: [] });
|
|
172
|
+
if (recorded)
|
|
173
|
+
deps.state.remove(spec.product, "dev-loop");
|
|
174
|
+
if (journeyError !== undefined)
|
|
175
|
+
throw journeyError;
|
|
176
|
+
return result;
|
|
177
|
+
}
|
|
178
|
+
/** `dev-loop down`: end the recorded run from any shell. The workdir stays —
|
|
179
|
+
* it is the developer's, and Studio may still be looking at it. */
|
|
180
|
+
export async function devLoopDown(product, deps = defaultDemoDeps(process.cwd())) {
|
|
181
|
+
const state = deps.state.read(product, "dev-loop");
|
|
182
|
+
if (!state) {
|
|
183
|
+
deps.log(`nothing recorded as up for the ${product} dev-loop`);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
const cli = (argv) => deps.cli(state.storeDir, ["--port", String(state.daemonPort), ...argv]);
|
|
187
|
+
try {
|
|
188
|
+
await deps.cleanup({
|
|
189
|
+
deleteInstance: (id) => cli(["instance", "delete", id, "--purge"]),
|
|
190
|
+
stopDaemon: () => cli(["shutdown"]),
|
|
191
|
+
instances: [],
|
|
192
|
+
daemon: null,
|
|
193
|
+
storeDir: state.storeDir,
|
|
194
|
+
containers: [],
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
catch (e) {
|
|
198
|
+
deps.log(`cleanup: ${e instanceof Error ? e.message : String(e)}; nuking the store as root`);
|
|
199
|
+
}
|
|
200
|
+
if (state.devPid !== undefined)
|
|
201
|
+
deps.killPid?.(state.devPid);
|
|
202
|
+
deps.nukeStoreAsRoot(state.storeDir);
|
|
203
|
+
deps.state.remove(product, "dev-loop");
|
|
204
|
+
deps.log(`${product} dev-loop torn down (the workdir ${state.workdir ?? ""} is untouched)`);
|
|
205
|
+
}
|
package/demo/index.d.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
export type { DemoArgs, DemoCliIo, ResolvedSpecView } from "./cli.js";
|
|
2
2
|
export { defaultDemoCliIo, demoMain, parseDemoArgs, resolvedSpecView } from "./cli.js";
|
|
3
|
+
export type { DevLoopOptions, DevLoopResult, StandaloneSource } from "./dev-loop.js";
|
|
4
|
+
export { devLoopDown, pumpScript, runDevLoop } from "./dev-loop.js";
|
|
3
5
|
export { composeAdvancingGate, DemoGateFailure, logsCleanGate } from "./gates.js";
|
|
4
|
-
export type {
|
|
5
|
-
export {
|
|
6
|
+
export type { DemoPane, DemoPaneOptions, DemoUi, DevLoopPaneOptions, PaneLayout, StandaloneDirs } from "./panes.js";
|
|
7
|
+
export { DEV_LOOP_SOURCES, DEV_LOOP_STAMP, demoPaneList, devLoopPaneList, launchLayout, paneArgv, paneCommandLine, paneShellLine, renderLayout, shellQuote, standaloneDirs, } from "./panes.js";
|
|
8
|
+
export type { CliResult, DemoDeps, DemoPorts, DemoRunOptions, DemoRunResult, DemoState, DemoStateKind, DemoStateStore, DemoTimeouts, ExportCheckOptions, IngestPortRow, PinMismatch, ProcessHandle, TemplateParams, } from "./run.js";
|
|
9
|
+
export { checkTemplatePins, defaultDemoDeps, demoDown, demoPorts, demoSlug, expandHome, exportStandaloneWorkdir, fileStateStore, findBrokenSymlinks, killGroup, resolveIngestPort, runDemo, runExportCheck, } from "./run.js";
|
|
6
10
|
export type { DemoContext, DemoDaemonPolicy, DemoExtra, DemoIngest, DemoLaunchContext, DemoMode, DemoOpen, DemoReady, DemoSource, DemoSpec, DemoTemplate, DemoUrl, } from "./spec.js";
|
|
7
11
|
export { DemoSpecSchema, defineDemo, formatSpecIssues } from "./spec.js";
|
package/demo/index.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export { defaultDemoCliIo, demoMain, parseDemoArgs, resolvedSpecView } from "./cli.js";
|
|
2
|
+
export { devLoopDown, pumpScript, runDevLoop } from "./dev-loop.js";
|
|
2
3
|
export { composeAdvancingGate, DemoGateFailure, logsCleanGate } from "./gates.js";
|
|
3
|
-
export {
|
|
4
|
+
export { DEV_LOOP_SOURCES, DEV_LOOP_STAMP, demoPaneList, devLoopPaneList, launchLayout, paneArgv, paneCommandLine, paneShellLine, renderLayout, shellQuote, standaloneDirs, } from "./panes.js";
|
|
5
|
+
export { checkTemplatePins, defaultDemoDeps, demoDown, demoPorts, demoSlug, expandHome, exportStandaloneWorkdir, fileStateStore, findBrokenSymlinks, killGroup, resolveIngestPort, runDemo, runExportCheck, } from "./run.js";
|
|
4
6
|
export { DemoSpecSchema, defineDemo, formatSpecIssues } from "./spec.js";
|
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}`;
|
|
@@ -416,6 +423,7 @@ class DemoSession {
|
|
|
416
423
|
String(this.ports.proxyPort),
|
|
417
424
|
"--no-http-redirect",
|
|
418
425
|
"--no-start-server",
|
|
426
|
+
...(this.publicHost ? ["--public-host", this.publicHost] : []),
|
|
419
427
|
]);
|
|
420
428
|
const started = this.deps.startDaemon(this.storeDir, { port: this.ports.daemonPort, seedConfig: false });
|
|
421
429
|
this.daemon = started.daemon;
|
|
@@ -473,8 +481,7 @@ class DemoSession {
|
|
|
473
481
|
}
|
|
474
482
|
/** The template to launch: built from the spec's input, or a name the
|
|
475
483
|
* product publishes (its first default when the spec names none). */
|
|
476
|
-
async resolveTemplate() {
|
|
477
|
-
const t = this.spec.template;
|
|
484
|
+
async resolveTemplate(t = this.spec.template) {
|
|
478
485
|
if (t && "build" in t) {
|
|
479
486
|
const name = t.build.name ?? `${this.spec.product}-demo`;
|
|
480
487
|
let inputPath;
|
|
@@ -633,13 +640,13 @@ class DemoSession {
|
|
|
633
640
|
}
|
|
634
641
|
}
|
|
635
642
|
function urlResolver(o) {
|
|
636
|
-
const host =
|
|
643
|
+
const host = o.host;
|
|
637
644
|
const mode = netReachMode();
|
|
638
645
|
return (ref) => {
|
|
639
646
|
if ("url" in ref)
|
|
640
647
|
return ref.url;
|
|
641
648
|
if ("control" in ref)
|
|
642
|
-
return `http
|
|
649
|
+
return `http://${host}:${o.ports.backendPort}${ref.control}`;
|
|
643
650
|
if ("proxy" in ref)
|
|
644
651
|
return `${o.proxyBase}${ref.proxy.replaceAll("{id}", o.instanceId)}`;
|
|
645
652
|
return `${studioBaseFrom({ instanceId: o.instanceId, studioHostPort: o.studioHostPort }, host, mode)}${ref.studio}`;
|
|
@@ -729,6 +736,7 @@ export async function runDemo(spec, opts, deps = defaultDemoDeps(opts.cwd)) {
|
|
|
729
736
|
daemon: policy,
|
|
730
737
|
...(opts.daemonPort !== undefined ? { daemonPort: opts.daemonPort } : {}),
|
|
731
738
|
...(opts.timeouts !== undefined ? { timeouts: opts.timeouts } : {}),
|
|
739
|
+
...(opts.publicHost !== undefined ? { publicHost: opts.publicHost } : {}),
|
|
732
740
|
});
|
|
733
741
|
if (opts.action === "up") {
|
|
734
742
|
await refuseIfUp(spec, s, deps);
|
|
@@ -808,7 +816,7 @@ export async function runDemo(spec, opts, deps = defaultDemoDeps(opts.cwd)) {
|
|
|
808
816
|
}));
|
|
809
817
|
const ctx = {
|
|
810
818
|
...launchCtx,
|
|
811
|
-
url: urlResolver({ instanceId, ports: s.ports, studioHostPort, proxyBase: s.proxyBase }),
|
|
819
|
+
url: urlResolver({ instanceId, ports: s.ports, studioHostPort, proxyBase: s.proxyBase, host: s.host }),
|
|
812
820
|
fetch: deps.fetch,
|
|
813
821
|
};
|
|
814
822
|
// Sources first: a gate such as "the switcher is composing" or "the probe
|
|
@@ -882,19 +890,7 @@ export async function runExportCheck(spec, opts, deps = defaultDemoDeps(opts.cwd
|
|
|
882
890
|
await s.startDev();
|
|
883
891
|
await s.register({ licence: false });
|
|
884
892
|
const templateName = await s.resolveTemplate();
|
|
885
|
-
|
|
886
|
-
for (const [k, v] of Object.entries(spec.standalone?.params ?? {}))
|
|
887
|
-
argv.push("--param", `${k}=${v}`);
|
|
888
|
-
for (const [pkg, path] of Object.entries(spec.standalone?.links ?? {})) {
|
|
889
|
-
argv.push("--link-component", `${pkg}=${resolve(opts.cwd, path)}`);
|
|
890
|
-
}
|
|
891
|
-
if (spec.standalone?.dashboards)
|
|
892
|
-
argv.push("--link-dashboards", resolve(opts.cwd, spec.standalone.dashboards));
|
|
893
|
-
await s.cliOk(argv);
|
|
894
|
-
const broken = deps.brokenSymlinks(exportDir);
|
|
895
|
-
if (broken.length) {
|
|
896
|
-
throw new Error(`exported workdir has ${broken.length} broken symlink(s):\n ${broken.join("\n ")}`);
|
|
897
|
-
}
|
|
893
|
+
await exportStandaloneWorkdir(s, templateName, exportDir);
|
|
898
894
|
deps.log(`export ok: ${templateName} -> ${exportDir}, every symlink resolves`);
|
|
899
895
|
}
|
|
900
896
|
catch (e) {
|
|
@@ -905,6 +901,24 @@ export async function runExportCheck(spec, opts, deps = defaultDemoDeps(opts.cwd
|
|
|
905
901
|
throw journeyError;
|
|
906
902
|
return { exportDir };
|
|
907
903
|
}
|
|
904
|
+
/** `template export-workdir` with the spec's live-source links and params,
|
|
905
|
+
* then the check that rotted (05-demo s1): every symlink resolves. */
|
|
906
|
+
export async function exportStandaloneWorkdir(s, templateName, to) {
|
|
907
|
+
const { spec, cwd, deps } = s;
|
|
908
|
+
const argv = ["template", "export-workdir", templateName, "--to", to];
|
|
909
|
+
for (const [k, v] of Object.entries(spec.standalone?.params ?? {}))
|
|
910
|
+
argv.push("--param", `${k}=${v}`);
|
|
911
|
+
for (const [pkg, path] of Object.entries(spec.standalone?.links ?? {})) {
|
|
912
|
+
argv.push("--link-component", `${pkg}=${resolve(cwd, path)}`);
|
|
913
|
+
}
|
|
914
|
+
if (spec.standalone?.dashboards)
|
|
915
|
+
argv.push("--link-dashboards", resolve(cwd, spec.standalone.dashboards));
|
|
916
|
+
await s.cliOk(argv);
|
|
917
|
+
const broken = deps.brokenSymlinks(to);
|
|
918
|
+
if (broken.length) {
|
|
919
|
+
throw new Error(`exported workdir has ${broken.length} broken symlink(s):\n ${broken.join("\n ")}`);
|
|
920
|
+
}
|
|
921
|
+
}
|
|
908
922
|
/** `demo down`: tear down the run `up` recorded, from any shell. */
|
|
909
923
|
export async function demoDown(product, deps = defaultDemoDeps(process.cwd())) {
|
|
910
924
|
const state = deps.state.read(product);
|
package/demo/spec.d.ts
CHANGED
|
@@ -138,12 +138,22 @@ export interface DemoSpec {
|
|
|
138
138
|
/** Runs once every gate holds. */
|
|
139
139
|
after?: (ctx: DemoContext) => Promise<void>;
|
|
140
140
|
open?: DemoOpen[];
|
|
141
|
-
/** The standalone tier
|
|
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>;
|
package/demo/spec.js
CHANGED
|
@@ -38,6 +38,21 @@ const relativePath = z
|
|
|
38
38
|
.string()
|
|
39
39
|
.min(1)
|
|
40
40
|
.refine((p) => !isAbsolute(p) && !p.startsWith("~"), { message: "must be a relative path inside the repo" });
|
|
41
|
+
const homePath = z
|
|
42
|
+
.string()
|
|
43
|
+
.min(1)
|
|
44
|
+
.refine((p) => isAbsolute(p) || p === "~" || p.startsWith("~/"), {
|
|
45
|
+
message: "must be an absolute or ~-rooted path (a checkout outside the repo)",
|
|
46
|
+
});
|
|
47
|
+
const Template = z.union([
|
|
48
|
+
z.strictObject({ name: z.string().min(1) }),
|
|
49
|
+
z.strictObject({
|
|
50
|
+
build: z.strictObject({
|
|
51
|
+
name: z.string().min(1).optional(),
|
|
52
|
+
input: z.union([z.string().min(1), z.record(z.string(), z.unknown())]),
|
|
53
|
+
}),
|
|
54
|
+
}),
|
|
55
|
+
]);
|
|
41
56
|
export const DemoSpecSchema = z
|
|
42
57
|
.strictObject({
|
|
43
58
|
product: z.string().min(1),
|
|
@@ -47,17 +62,7 @@ export const DemoSpecSchema = z
|
|
|
47
62
|
readyPath: z.string().startsWith("/").optional(),
|
|
48
63
|
}),
|
|
49
64
|
image: z.string().min(1).optional(),
|
|
50
|
-
template:
|
|
51
|
-
.union([
|
|
52
|
-
z.strictObject({ name: z.string().min(1) }),
|
|
53
|
-
z.strictObject({
|
|
54
|
-
build: z.strictObject({
|
|
55
|
-
name: z.string().min(1).optional(),
|
|
56
|
-
input: z.union([z.string().min(1), z.record(z.string(), z.unknown())]),
|
|
57
|
-
}),
|
|
58
|
-
}),
|
|
59
|
-
])
|
|
60
|
-
.optional(),
|
|
65
|
+
template: Template.optional(),
|
|
61
66
|
launch: z
|
|
62
67
|
.strictObject({
|
|
63
68
|
hardware: z.enum(["nvidia", "none"]).optional(),
|
|
@@ -105,6 +110,10 @@ export const DemoSpecSchema = z
|
|
|
105
110
|
links: z.record(z.string().min(1), relativePath).optional(),
|
|
106
111
|
dashboards: relativePath.optional(),
|
|
107
112
|
params: Params.optional(),
|
|
113
|
+
norskDir: homePath.optional(),
|
|
114
|
+
studioDir: homePath.optional(),
|
|
115
|
+
workdir: homePath.optional(),
|
|
116
|
+
template: Template.optional(),
|
|
108
117
|
})
|
|
109
118
|
.optional(),
|
|
110
119
|
})
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@norskvideo/ctl-test-harness",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.22",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": {
|
|
@@ -59,7 +59,8 @@
|
|
|
59
59
|
"main": "./index.js",
|
|
60
60
|
"types": "./index.d.ts",
|
|
61
61
|
"bin": {
|
|
62
|
-
"ctl-demo": "./demo/cli.js"
|
|
62
|
+
"ctl-demo": "./demo/cli.js",
|
|
63
|
+
"ctl-dev-loop": "./demo/dev-loop-cli.js"
|
|
63
64
|
},
|
|
64
65
|
"dependencies": {
|
|
65
66
|
"@norskvideo/ctl-sdk": "^0.1.0",
|