proof-artifacts 0.1.0-preview.0

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,2049 @@
1
+ #!/usr/bin/env node
2
+ import { execFileSync, spawnSync } from "node:child_process";
3
+ import {
4
+ cpSync,
5
+ existsSync,
6
+ mkdirSync,
7
+ readdirSync,
8
+ realpathSync,
9
+ readFileSync,
10
+ rmSync,
11
+ statSync,
12
+ writeFileSync,
13
+ } from "node:fs";
14
+ import { homedir } from "node:os";
15
+ import path from "node:path";
16
+ import { fileURLToPath } from "node:url";
17
+
18
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
19
+ const rootDir = path.resolve(__dirname, "..");
20
+ const defaultImage = "proof-artifacts/desktop:0.1.0";
21
+ const defaultName = "default";
22
+ const defaultWidth = "1280";
23
+ const defaultHeight = "800";
24
+
25
+ const args = process.argv.slice(2);
26
+ const command = args[0] ?? "help";
27
+
28
+ function printHelp() {
29
+ console.log(`proof-artifacts
30
+
31
+ Proof artifacts for coding agents using isolated Linux desktops.
32
+
33
+ Usage:
34
+ proof-artifacts doctor [--url URL] [--name NAME] [--deep] [--project PATH] [--keep] [--rebuild] [--allow-unsafe-url]
35
+ proof-artifacts detect [--project PATH] [--json]
36
+ proof-artifacts smoke [--name NAME] [--project PATH] [--url URL] [--keep] [--rebuild]
37
+ proof-artifacts start [--name NAME] [--project PATH] [--url URL] [--fullscreen] [--width 1280] [--height 800] [--novnc-port auto] [--network bridge|host] [--replace] [--image IMAGE] [--pull] [--no-build] [--rebuild]
38
+ proof-artifacts launch [--name NAME] [--window-match TEXT] [--wait-ms 10000] [--no-wait] -- <command...>
39
+ proof-artifacts open <url> [--name NAME] [--fullscreen] [--no-fullscreen] [--allow-unsafe-url]
40
+ proof-artifacts screenshot [--name NAME] [--label LABEL]
41
+ proof-artifacts record start [--name NAME] [--label LABEL]
42
+ proof-artifacts record stop [--name NAME]
43
+ proof-artifacts report [--name NAME]
44
+ proof-artifacts list [--json]
45
+ proof-artifacts status [--name NAME] [--json]
46
+ proof-artifacts artifacts [--name NAME]
47
+ proof-artifacts cleanup [--max-age-hours 24] [--all] [--dry-run] [--delete-artifacts]
48
+ proof-artifacts exec [--name NAME] [--stdin] -- <command...>
49
+ proof-artifacts stop [--name NAME] [--all]
50
+ proof-artifacts install-skill
51
+
52
+ Aliases:
53
+ proof is the same CLI.
54
+ `);
55
+ }
56
+
57
+ function parseOptions(input) {
58
+ const opts = {};
59
+ const rest = [];
60
+ for (let i = 0; i < input.length; i += 1) {
61
+ const arg = input[i];
62
+ if (arg === "--") {
63
+ rest.push(...input.slice(i + 1));
64
+ break;
65
+ }
66
+ if (arg.startsWith("--")) {
67
+ const [rawKey, inlineValue] = arg.slice(2).split("=", 2);
68
+ if (inlineValue !== undefined) {
69
+ opts[rawKey] = inlineValue;
70
+ continue;
71
+ }
72
+ const next = input[i + 1];
73
+ if (!next || next.startsWith("--")) {
74
+ opts[rawKey] = "true";
75
+ } else {
76
+ opts[rawKey] = next;
77
+ i += 1;
78
+ }
79
+ continue;
80
+ }
81
+ rest.push(arg);
82
+ }
83
+ return { opts, rest };
84
+ }
85
+
86
+ function isTruthy(value) {
87
+ return value === true || value === "true" || value === "1" || value === "yes";
88
+ }
89
+
90
+ function safeName(name) {
91
+ const normalized = String(name || defaultName)
92
+ .toLowerCase()
93
+ .replace(/[^a-z0-9_.-]+/g, "-")
94
+ .replace(/^-+|-+$/g, "")
95
+ || defaultName;
96
+ return normalized === "." || normalized === ".." ? defaultName : normalized;
97
+ }
98
+
99
+ function containerName(name) {
100
+ return `proof-artifacts-${safeName(name)}`;
101
+ }
102
+
103
+ function localSessionDir(name) {
104
+ return path.resolve(process.cwd(), ".proof-artifacts", "sessions", safeName(name));
105
+ }
106
+
107
+ function globalStateDir() {
108
+ return path.join(homedir(), ".proof-artifacts", "sessions");
109
+ }
110
+
111
+ function sessionIndexPath(name) {
112
+ return path.join(globalStateDir(), `${safeName(name)}.json`);
113
+ }
114
+
115
+ function sessionDir(name) {
116
+ const local = localSessionDir(name);
117
+ if (existsSync(path.join(local, "manifest.json")) || existsSync(path.join(local, "session.json"))) {
118
+ return local;
119
+ }
120
+ const index = readJsonFile(sessionIndexPath(name));
121
+ if (index?.artifacts) return path.resolve(index.artifacts);
122
+ return local;
123
+ }
124
+
125
+ function manifestPath(name) {
126
+ return path.join(sessionDir(name), "manifest.json");
127
+ }
128
+
129
+ function legacySessionPath(name) {
130
+ return path.join(sessionDir(name), "session.json");
131
+ }
132
+
133
+ function ensureDocker() {
134
+ const result = spawnSync("docker", ["version"], { stdio: "ignore" });
135
+ if (result.status !== 0) {
136
+ throw new Error("Docker is required. Install Docker Engine on the VPS and make sure this user can run `docker`.");
137
+ }
138
+ }
139
+
140
+ function run(commandName, commandArgs, options = {}) {
141
+ return execFileSync(commandName, commandArgs, {
142
+ stdio: options.stdio ?? "inherit",
143
+ encoding: options.encoding ?? "utf8",
144
+ ...options,
145
+ });
146
+ }
147
+
148
+ function docker(argsForDocker, options = {}) {
149
+ ensureDocker();
150
+ return run("docker", argsForDocker, options);
151
+ }
152
+
153
+ function dockerTry(argsForDocker, options = {}) {
154
+ ensureDocker();
155
+ return spawnSync("docker", argsForDocker, {
156
+ encoding: "utf8",
157
+ stdio: options.stdio ?? "pipe",
158
+ ...options,
159
+ });
160
+ }
161
+
162
+ function containerExists(name) {
163
+ const output = docker(["ps", "-a", "--filter", `name=^/${containerName(name)}$`, "--format", "{{.Names}}"], {
164
+ stdio: "pipe",
165
+ }).trim();
166
+ return output === containerName(name);
167
+ }
168
+
169
+ function containerRunning(name) {
170
+ const output = docker(["ps", "--filter", `name=^/${containerName(name)}$`, "--format", "{{.Names}}"], {
171
+ stdio: "pipe",
172
+ }).trim();
173
+ return output === containerName(name);
174
+ }
175
+
176
+ function managedContainerExists(name) {
177
+ if (!containerExists(name)) return false;
178
+ const result = dockerTry([
179
+ "inspect",
180
+ "--format",
181
+ "{{ index .Config.Labels \"proof-artifacts.managed\" }}",
182
+ containerName(name),
183
+ ]);
184
+ return result.status === 0 && result.stdout.trim() === "true";
185
+ }
186
+
187
+ function managedContainerArtifactDir(name) {
188
+ const result = dockerTry([
189
+ "inspect",
190
+ "--format",
191
+ "{{ index .Config.Labels \"proof-artifacts.artifacts\" }}",
192
+ containerName(name),
193
+ ]);
194
+ return result.status === 0 && result.stdout.trim() ? path.resolve(result.stdout.trim()) : null;
195
+ }
196
+
197
+ function sameResolvedPath(left, right) {
198
+ return canonicalPath(left) === canonicalPath(right);
199
+ }
200
+
201
+ function imageExists(image) {
202
+ return dockerTry(["image", "inspect", image], { stdio: "ignore" }).status === 0;
203
+ }
204
+
205
+ function buildImage(image) {
206
+ docker(["build", "-t", image, path.join(rootDir, "runtime")]);
207
+ }
208
+
209
+ function ensureRuntimeImage(opts = {}) {
210
+ const requestedImage = opts.image ?? process.env.PROOF_ARTIFACTS_IMAGE ?? defaultImage;
211
+ if (isTruthy(opts.rebuild)) {
212
+ buildImage(requestedImage);
213
+ return { image: requestedImage, requestedImage, imageSource: "rebuilt" };
214
+ }
215
+
216
+ if (imageExists(requestedImage)) {
217
+ return { image: requestedImage, requestedImage, imageSource: "local" };
218
+ }
219
+
220
+ if (requestedImage !== defaultImage && !isTruthy(opts.pull)) {
221
+ throw new Error(`Runtime image ${requestedImage} is not available locally. Build it locally first or pass --pull to pull a remote image explicitly.`);
222
+ }
223
+
224
+ const shouldPull = isTruthy(opts.pull);
225
+ if (shouldPull) {
226
+ console.log(`Runtime image not found locally. Pulling ${requestedImage}...`);
227
+ const pull = dockerTry(["pull", requestedImage], { stdio: "inherit" });
228
+ if (pull.status === 0) {
229
+ return { image: requestedImage, requestedImage, imageSource: "pulled" };
230
+ }
231
+ if (isTruthy(opts["no-build"])) {
232
+ throw new Error(`Could not pull runtime image ${requestedImage}, and --no-build was set.`);
233
+ }
234
+ console.log(`Pull failed; building local runtime image ${defaultImage} instead.`);
235
+ }
236
+
237
+ if (isTruthy(opts["no-build"])) {
238
+ throw new Error(`Runtime image ${requestedImage} is not available locally, and --no-build was set.`);
239
+ }
240
+
241
+ buildImage(defaultImage);
242
+ return {
243
+ image: defaultImage,
244
+ requestedImage,
245
+ imageSource: requestedImage === defaultImage ? "built" : "built-fallback",
246
+ };
247
+ }
248
+
249
+ function readJsonFile(file) {
250
+ if (!existsSync(file)) return undefined;
251
+ return JSON.parse(readFileSync(file, "utf8"));
252
+ }
253
+
254
+ function readPackageJson(project) {
255
+ return readJsonFile(path.join(project, "package.json")) ?? {};
256
+ }
257
+
258
+ function dependencyNames(pkg) {
259
+ return new Set(Object.keys({
260
+ ...(pkg.dependencies ?? {}),
261
+ ...(pkg.devDependencies ?? {}),
262
+ ...(pkg.optionalDependencies ?? {}),
263
+ }));
264
+ }
265
+
266
+ function scriptsMatching(pkg, patterns) {
267
+ const scripts = pkg.scripts ?? {};
268
+ return Object.entries(scripts)
269
+ .filter(([, script]) => patterns.some((pattern) => pattern.test(script)))
270
+ .map(([name]) => `npm run ${name}`);
271
+ }
272
+
273
+ function likelyDevServerScripts(pkg) {
274
+ const scripts = pkg.scripts ?? {};
275
+ const preferredNames = ["dev", "start", "serve"];
276
+ const excludedNames = /(test|coverage|lint|format|release|build|typecheck)/i;
277
+ const devServerCommand = /\b(vite|next|react-scripts|astro|svelte-kit|remix|webpack(?:-dev-server)?|webpack\s+serve)\b/i;
278
+ const matches = [];
279
+ for (const [name, script] of Object.entries(scripts)) {
280
+ if (excludedNames.test(name)) continue;
281
+ if (devServerCommand.test(script)) {
282
+ matches.push(`npm run ${name}`);
283
+ }
284
+ }
285
+ return [...new Set(matches)].sort((a, b) => {
286
+ const aName = a.replace(/^npm run /, "");
287
+ const bName = b.replace(/^npm run /, "");
288
+ const aRank = preferredNames.includes(aName) ? preferredNames.indexOf(aName) : preferredNames.length;
289
+ const bRank = preferredNames.includes(bName) ? preferredNames.indexOf(bName) : preferredNames.length;
290
+ return aRank - bRank || aName.localeCompare(bName);
291
+ });
292
+ }
293
+
294
+ function fileExists(project, relativePath) {
295
+ return existsSync(path.join(project, relativePath));
296
+ }
297
+
298
+ function findFiles(project, matcher, maxDepth = 3) {
299
+ const matches = [];
300
+ const skip = new Set([".git", "node_modules", ".proof-artifacts", "dist", "build", ".next"]);
301
+ function walk(dir, depth) {
302
+ if (depth > maxDepth || matches.length > 20) return;
303
+ for (const entry of readdirSync(dir)) {
304
+ if (skip.has(entry)) continue;
305
+ const full = path.join(dir, entry);
306
+ let stat;
307
+ try {
308
+ stat = statSync(full);
309
+ } catch {
310
+ continue;
311
+ }
312
+ if (stat.isDirectory()) {
313
+ walk(full, depth + 1);
314
+ } else if (matcher(path.relative(project, full))) {
315
+ matches.push(path.relative(project, full));
316
+ }
317
+ }
318
+ }
319
+ if (existsSync(project)) walk(project, 0);
320
+ return matches.sort();
321
+ }
322
+
323
+ function findDirs(project, matcher, maxDepth = 3) {
324
+ const matches = [];
325
+ const skip = new Set([".git", "node_modules", ".proof-artifacts", "dist", "build", ".next"]);
326
+ function walk(dir, depth) {
327
+ if (depth > maxDepth || matches.length > 20) return;
328
+ for (const entry of readdirSync(dir)) {
329
+ if (skip.has(entry)) continue;
330
+ const full = path.join(dir, entry);
331
+ let stat;
332
+ try {
333
+ stat = statSync(full);
334
+ } catch {
335
+ continue;
336
+ }
337
+ if (!stat.isDirectory()) continue;
338
+ const relative = path.relative(project, full);
339
+ if (matcher(relative)) matches.push(relative);
340
+ walk(full, depth + 1);
341
+ }
342
+ }
343
+ if (existsSync(project)) walk(project, 0);
344
+ return matches.sort();
345
+ }
346
+
347
+ function detectProject(project) {
348
+ const resolved = path.resolve(project);
349
+ if (!existsSync(resolved)) throw new Error(`Project path does not exist: ${resolved}`);
350
+
351
+ const pkg = readPackageJson(resolved);
352
+ const deps = dependencyNames(pkg);
353
+ const scripts = pkg.scripts ?? {};
354
+ const findings = [];
355
+
356
+ const electronScripts = scriptsMatching(pkg, [/electron/i]);
357
+ const otherDesktopScripts = scriptsMatching(pkg, [/tauri/i, /neutralino/i, /nw(\s|$)/i]);
358
+ const desktopScripts = [...new Set([...electronScripts, ...otherDesktopScripts])];
359
+ const webScripts = likelyDevServerScripts(pkg);
360
+
361
+ if (deps.has("electron") || electronScripts.length > 0) {
362
+ findings.push({
363
+ kind: "linux-desktop",
364
+ confidence: "high",
365
+ canRunHere: true,
366
+ reason: "Electron project detected. If Linux dependencies install cleanly, it can run inside the proof-artifacts desktop.",
367
+ commands: desktopScripts.length > 0 ? desktopScripts : ["npm start"],
368
+ next: "Start a session, then use `proof-artifacts launch --name <task> --window-match <app-name> -- <command>`.",
369
+ });
370
+ }
371
+
372
+ if (deps.has("@tauri-apps/cli") || deps.has("@tauri-apps/api") || fileExists(resolved, "src-tauri/tauri.conf.json")) {
373
+ findings.push({
374
+ kind: "linux-desktop",
375
+ confidence: "medium",
376
+ canRunHere: true,
377
+ reason: "Tauri project detected. It may run on Linux, but the desktop container may need extra system libraries/Rust tooling.",
378
+ commands: desktopScripts.length > 0 ? desktopScripts : ["npm run tauri dev"],
379
+ next: "Try launching after dependencies are installed; if system libraries are missing, this runtime image may need project-specific packages.",
380
+ });
381
+ }
382
+
383
+ if (webScripts.length > 0 || deps.has("next") || deps.has("vite") || deps.has("@sveltejs/kit")) {
384
+ findings.push({
385
+ kind: "web",
386
+ confidence: webScripts.length > 0 ? "high" : "medium",
387
+ canRunHere: true,
388
+ reason: "Web dev server scripts/dependencies detected.",
389
+ commands: webScripts.length > 0 ? webScripts : ["npm run dev", "npm start"],
390
+ next: "Run the dev server on the VPS host, then start with `proof-artifacts start --url http://127.0.0.1:<port>` and open that URL.",
391
+ });
392
+ }
393
+
394
+ const xcodeDirs = findDirs(resolved, (file) => file.endsWith(".xcodeproj") || file.endsWith(".xcworkspace"), 2);
395
+ if (xcodeDirs.length > 0 || fileExists(resolved, "Podfile")) {
396
+ findings.push({
397
+ kind: "apple",
398
+ confidence: "high",
399
+ canRunHere: false,
400
+ reason: "Xcode/macOS/iOS project markers detected. The current runtime is Linux Docker/Xvfb, not macOS.",
401
+ commands: [],
402
+ next: "Use a macOS runner for macOS apps or iOS Simulator testing.",
403
+ });
404
+ }
405
+
406
+ const windowsFiles = findFiles(resolved, (file) => file.endsWith(".sln") || file.endsWith(".vcxproj") || file.endsWith(".wixproj"), 3);
407
+ if (windowsFiles.length > 0) {
408
+ findings.push({
409
+ kind: "windows",
410
+ confidence: "medium",
411
+ canRunHere: false,
412
+ reason: "Windows project markers detected. The current runtime is Linux Docker/Xvfb.",
413
+ commands: [],
414
+ next: "Use a Windows runner for native Windows desktop testing. Wine/VM support is intentionally out of scope for v0.",
415
+ });
416
+ }
417
+
418
+ if (fileExists(resolved, "android/build.gradle") || fileExists(resolved, "android/app/build.gradle") || fileExists(resolved, "gradlew")) {
419
+ findings.push({
420
+ kind: "android",
421
+ confidence: "medium",
422
+ canRunHere: false,
423
+ reason: "Android/Gradle project markers detected. The current runtime does not include an Android emulator.",
424
+ commands: [],
425
+ next: "Use an Android-capable runner/emulator backend later; do not try to force it through the desktop container.",
426
+ });
427
+ }
428
+
429
+ if (findings.length === 0) {
430
+ findings.push({
431
+ kind: "unknown",
432
+ confidence: "low",
433
+ canRunHere: false,
434
+ reason: "No known web, Linux desktop, mobile, macOS, or Windows project markers were detected.",
435
+ commands: [],
436
+ next: "Inspect project docs/package scripts manually before attempting visual testing.",
437
+ });
438
+ }
439
+
440
+ return { project: resolved, findings };
441
+ }
442
+
443
+ function readSessionMeta(name) {
444
+ return readJsonFile(manifestPath(name)) ?? readJsonFile(legacySessionPath(name)) ?? {};
445
+ }
446
+
447
+ function writeSessionMeta(name, meta) {
448
+ const dir = meta.artifacts ? path.resolve(meta.artifacts) : sessionDir(name);
449
+ writeSessionMetaInDir(name, dir, meta);
450
+ }
451
+
452
+ function writeSessionMetaInDir(name, dir, meta) {
453
+ mkdirSync(dir, { recursive: true });
454
+ const normalized = {
455
+ schemaVersion: 1,
456
+ events: [],
457
+ screenshots: [],
458
+ recordings: [],
459
+ ...meta,
460
+ };
461
+ const persisted = redactSensitive(normalized);
462
+ const payload = `${JSON.stringify(persisted, null, 2)}\n`;
463
+ writeFileSync(path.join(dir, "manifest.json"), payload);
464
+ writeFileSync(path.join(dir, "session.json"), payload);
465
+ mkdirSync(globalStateDir(), { recursive: true });
466
+ writeFileSync(sessionIndexPath(name), `${JSON.stringify({
467
+ name: safeName(name),
468
+ artifacts: dir,
469
+ project: persisted.project,
470
+ container: persisted.container,
471
+ updatedAt: new Date().toISOString(),
472
+ }, null, 2)}\n`);
473
+ }
474
+
475
+ function updateSessionMeta(name, updater) {
476
+ const current = readSessionMeta(name);
477
+ const next = updater({
478
+ schemaVersion: 1,
479
+ events: [],
480
+ screenshots: [],
481
+ recordings: [],
482
+ ...current,
483
+ });
484
+ writeSessionMeta(name, next);
485
+ return next;
486
+ }
487
+
488
+ function updateSessionMetaInDir(name, dir, updater) {
489
+ const current = readJsonFile(path.join(dir, "manifest.json")) ?? readJsonFile(path.join(dir, "session.json")) ?? {};
490
+ const next = updater({
491
+ schemaVersion: 1,
492
+ events: [],
493
+ screenshots: [],
494
+ recordings: [],
495
+ ...current,
496
+ });
497
+ writeSessionMetaInDir(name, dir, next);
498
+ return next;
499
+ }
500
+
501
+ function addEvent(name, type, details = {}) {
502
+ updateSessionMeta(name, (meta) => ({
503
+ ...meta,
504
+ events: [
505
+ ...(meta.events ?? []),
506
+ {
507
+ type,
508
+ at: new Date().toISOString(),
509
+ ...details,
510
+ },
511
+ ],
512
+ }));
513
+ }
514
+
515
+ function requireRunning(name) {
516
+ const status = containerStatus(name, sessionDir(name));
517
+ if (status.state === "conflict") {
518
+ throw new Error(`Session "${safeName(name)}" has a same-name container for different artifacts: ${status.containerArtifacts}. Use a unique --name or run from the matching project.`);
519
+ }
520
+ if (status.state !== "running") {
521
+ throw new Error(`Session "${safeName(name)}" is not running. Start it with: proof-artifacts start --name ${safeName(name)}`);
522
+ }
523
+ }
524
+
525
+ function timestamp() {
526
+ return new Date().toISOString().replace(/[:.]/g, "-");
527
+ }
528
+
529
+ function validateDimension(value, label) {
530
+ const text = String(value);
531
+ if (!/^[1-9][0-9]{1,4}$/.test(text)) {
532
+ throw new Error(`${label} must be a positive integer.`);
533
+ }
534
+ return text;
535
+ }
536
+
537
+ function parsePort(value) {
538
+ const port = Number.parseInt(String(value), 10);
539
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
540
+ throw new Error(`Invalid noVNC port: ${value}`);
541
+ }
542
+ return port;
543
+ }
544
+
545
+ function parseNetworkMode(value) {
546
+ const mode = value ?? "bridge";
547
+ if (mode !== "bridge" && mode !== "host") {
548
+ throw new Error("--network must be either bridge or host.");
549
+ }
550
+ return mode;
551
+ }
552
+
553
+ function isLocalhostUrl(value) {
554
+ if (!value) return false;
555
+ try {
556
+ const parsed = new URL(String(value));
557
+ return ["127.0.0.1", "localhost", "[::1]", "::1", "0.0.0.0"].includes(parsed.hostname);
558
+ } catch {
559
+ return false;
560
+ }
561
+ }
562
+
563
+ function isBlockedMetadataUrl(value) {
564
+ if (!value) return false;
565
+ try {
566
+ const hostname = new URL(String(value)).hostname
567
+ .toLowerCase()
568
+ .replace(/^\[|\]$/g, "")
569
+ .replace(/\.$/, "");
570
+ return hostname.startsWith("169.254.")
571
+ || hostname.startsWith("::ffff:a9fe:")
572
+ || hostname === "fd00:ec2::254"
573
+ || hostname === "fe80::a9fe:a9fe"
574
+ || hostname === "metadata.google.internal"
575
+ || hostname === "metadata";
576
+ } catch {
577
+ return false;
578
+ }
579
+ }
580
+
581
+ function assertUrlAllowed(value, opts = {}) {
582
+ if (isBlockedMetadataUrl(value) && !isTruthy(opts["allow-unsafe-url"])) {
583
+ throw new Error(`Refusing to access cloud metadata URL: ${value}. Pass --allow-unsafe-url if you intentionally need this.`);
584
+ }
585
+ }
586
+
587
+ function tcpPortAvailable(port) {
588
+ const script = `
589
+ const net = require("node:net");
590
+ const port = Number(process.argv[1]);
591
+ const server = net.createServer();
592
+ server.once("error", () => process.exit(1));
593
+ server.listen(port, "127.0.0.1", () => server.close(() => process.exit(0)));
594
+ `;
595
+ return spawnSync(process.execPath, ["-e", script, String(port)], { stdio: "ignore" }).status === 0;
596
+ }
597
+
598
+ function findAvailablePort(startPort) {
599
+ const start = parsePort(startPort);
600
+ for (let port = start; port < start + 100; port += 1) {
601
+ if (tcpPortAvailable(port)) return port;
602
+ }
603
+ throw new Error(`Could not find an available localhost port starting at ${start}.`);
604
+ }
605
+
606
+ function findAvailableVncPorts() {
607
+ for (let noVncPort = 6080; noVncPort < 6180; noVncPort += 1) {
608
+ const vncPort = noVncPort - 180;
609
+ if (tcpPortAvailable(noVncPort) && tcpPortAvailable(vncPort)) {
610
+ return { noVncPort, vncPort };
611
+ }
612
+ }
613
+ throw new Error("Could not find available localhost ports for noVNC/VNC host-network mode.");
614
+ }
615
+
616
+ function mappedNoVncPort(name) {
617
+ const output = docker(["port", containerName(name), "6080/tcp"], { stdio: "pipe" }).trim();
618
+ const match = output.match(/127\.0\.0\.1:(\d+)/) ?? output.match(/0\.0\.0\.0:(\d+)/) ?? output.match(/:(\d+)$/);
619
+ if (!match) {
620
+ throw new Error(`Could not determine noVNC host port for session "${safeName(name)}".`);
621
+ }
622
+ return Number.parseInt(match[1], 10);
623
+ }
624
+
625
+ function dockerImageId(image) {
626
+ const result = dockerTry(["image", "inspect", image, "--format", "{{.Id}}"]);
627
+ return result.status === 0 ? result.stdout.trim() : null;
628
+ }
629
+
630
+ function hostUserSpec() {
631
+ if (typeof process.getuid !== "function" || typeof process.getgid !== "function") {
632
+ return null;
633
+ }
634
+ return `${process.getuid()}:${process.getgid()}`;
635
+ }
636
+
637
+ function wait(ms) {
638
+ run(process.execPath, ["-e", `setTimeout(() => {}, ${Number(ms) || 0})`], { stdio: "ignore" });
639
+ }
640
+
641
+ function waitForHttp(url, timeoutMs = 10000) {
642
+ const script = `
643
+ const http = require("node:http");
644
+ const https = require("node:https");
645
+ const { URL } = require("node:url");
646
+ const target = new URL(process.argv[1]);
647
+ const client = target.protocol === "https:" ? https : http;
648
+ const deadline = Date.now() + Number(process.argv[2]);
649
+ function attempt() {
650
+ const req = client.get(target, (res) => {
651
+ res.resume();
652
+ if (res.statusCode >= 200 && res.statusCode < 500) process.exit(0);
653
+ retry();
654
+ });
655
+ req.on("error", retry);
656
+ req.setTimeout(1000, () => {
657
+ req.destroy();
658
+ retry();
659
+ });
660
+ }
661
+ function retry() {
662
+ if (Date.now() > deadline) process.exit(1);
663
+ setTimeout(attempt, 250);
664
+ }
665
+ attempt();
666
+ `;
667
+ const result = spawnSync(process.execPath, ["-e", script, url, String(timeoutMs)], { stdio: "ignore" });
668
+ return result.status === 0;
669
+ }
670
+
671
+ function assertFileMinSize(file, minBytes, label) {
672
+ if (!existsSync(file)) {
673
+ throw new Error(`${label} was not created: ${file}`);
674
+ }
675
+ const size = statSync(file).size;
676
+ if (size < minBytes) {
677
+ throw new Error(`${label} is too small (${size} bytes): ${file}`);
678
+ }
679
+ }
680
+
681
+ function escapeHtml(value) {
682
+ return String(value)
683
+ .replace(/&/g, "&amp;")
684
+ .replace(/</g, "&lt;")
685
+ .replace(/>/g, "&gt;")
686
+ .replace(/"/g, "&quot;");
687
+ }
688
+
689
+ function redactSensitive(value) {
690
+ const sensitiveKey = /(token|secret|key|password|passwd|auth|cookie|credential|session)/i;
691
+ if (Array.isArray(value)) return value.map((item) => redactSensitive(item));
692
+ if (value && typeof value === "object") {
693
+ return Object.fromEntries(Object.entries(value).map(([key, entry]) => [
694
+ key,
695
+ sensitiveKey.test(key) ? "[redacted]" : redactSensitive(entry),
696
+ ]));
697
+ }
698
+ if (typeof value === "string") {
699
+ return value
700
+ .replace(/([a-z][a-z0-9+.-]*:\/\/)([^:@/\s]+):([^@/\s]+)@/gi, "$1[redacted-user]:[redacted-password]@")
701
+ .replace(/([?&][^?&=\s]*(?:token|key|auth|signature|password|secret|credential|cookie)[^?&=\s]*=)[^&\s]+/gi, "$1[redacted]")
702
+ .replace(/\b([A-Z0-9_]*(?:TOKEN|SECRET|KEY|PASSWORD|PASSWD|AUTH|COOKIE|CREDENTIAL|SESSION)[A-Z0-9_]*=)(?:"[^"]*"|'[^']*'|[^\s]+)/gi, "$1[redacted]")
703
+ .replace(/(^|\s)(--?(?:token|secret|key|password|passwd|auth|cookie|credential|session)(?:=|\s+))(?:"[^"]*"|'[^']*'|[^\s]+)/gi, "$1$2[redacted]")
704
+ .replace(/\b(?:Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+/gi, "[redacted-auth]")
705
+ .replace(/\bgh[pousr]_[A-Za-z0-9_]{20,}\b/g, "[redacted-github-token]")
706
+ .replace(/\bnpm_[A-Za-z0-9]{20,}\b/g, "[redacted-npm-token]")
707
+ .replace(/\bsk-[A-Za-z0-9_-]{20,}\b/g, "[redacted-openai-token]")
708
+ .replace(/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g, "[redacted-jwt]");
709
+ }
710
+ return value;
711
+ }
712
+
713
+ function listFiles(dir) {
714
+ if (!existsSync(dir)) return [];
715
+ return readdirSync(dir)
716
+ .filter((file) => statSync(path.join(dir, file)).isFile())
717
+ .sort();
718
+ }
719
+
720
+ function fileInfo(root, folder, file) {
721
+ const absolute = path.join(root, folder, file);
722
+ const stat = statSync(absolute);
723
+ const size = stat.size;
724
+ const info = {
725
+ file,
726
+ relativePath: path.join(folder, file),
727
+ size,
728
+ sizeLabel: formatBytes(size),
729
+ mtimeMs: stat.mtimeMs,
730
+ modifiedAt: stat.mtime.toISOString(),
731
+ };
732
+ if (file.endsWith(".mp4")) {
733
+ const durationSeconds = mediaDurationSeconds(absolute);
734
+ if (durationSeconds !== null) {
735
+ info.durationSeconds = durationSeconds;
736
+ info.durationLabel = formatDuration(durationSeconds * 1000);
737
+ }
738
+ }
739
+ return info;
740
+ }
741
+
742
+ function formatBytes(bytes) {
743
+ if (bytes < 1024) return `${bytes} B`;
744
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
745
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
746
+ }
747
+
748
+ function durationMs(start, end) {
749
+ const started = Date.parse(start ?? "");
750
+ const ended = Date.parse(end ?? "");
751
+ if (!Number.isFinite(started) || !Number.isFinite(ended) || ended < started) return null;
752
+ return ended - started;
753
+ }
754
+
755
+ function formatDuration(ms) {
756
+ if (ms === null || ms === undefined) return "unknown";
757
+ if (ms < 1000) return `${ms} ms`;
758
+ const seconds = Math.round(ms / 100) / 10;
759
+ if (seconds < 60) return `${seconds.toFixed(seconds % 1 === 0 ? 0 : 1)} s`;
760
+ const minutes = Math.floor(seconds / 60);
761
+ const remainder = Math.round(seconds % 60);
762
+ return `${minutes} min ${remainder} s`;
763
+ }
764
+
765
+ function mediaDurationSeconds(file) {
766
+ const result = spawnSync("ffprobe", [
767
+ "-v", "error",
768
+ "-show_entries", "format=duration",
769
+ "-of", "default=noprint_wrappers=1:nokey=1",
770
+ file,
771
+ ], { encoding: "utf8", stdio: "pipe" });
772
+ if (result.status !== 0) return null;
773
+ const duration = Number.parseFloat(result.stdout.trim());
774
+ return Number.isFinite(duration) ? duration : null;
775
+ }
776
+
777
+ function artifactCounts(dir) {
778
+ return {
779
+ screenshots: listFiles(path.join(dir, "screenshots")).filter((file) => file.endsWith(".png")).length,
780
+ recordings: listFiles(path.join(dir, "recordings")).filter((file) => file.endsWith(".mp4")).length,
781
+ logs: listFiles(path.join(dir, "recordings")).filter((file) => file.endsWith(".log")).length
782
+ + listFiles(path.join(dir, "logs")).filter((file) => file.endsWith(".log")).length,
783
+ };
784
+ }
785
+
786
+ function reachableFromContainer(name, url) {
787
+ const result = dockerTry([
788
+ "exec",
789
+ containerName(name),
790
+ "curl",
791
+ "-sS",
792
+ "-o",
793
+ "/dev/null",
794
+ "-w",
795
+ "%{http_code}",
796
+ "--max-time",
797
+ "3",
798
+ url,
799
+ ]);
800
+ const statusCode = Number.parseInt(result.stdout.trim(), 10);
801
+ return {
802
+ ok: result.status === 0 && statusCode >= 200 && statusCode < 500,
803
+ statusCode: Number.isFinite(statusCode) ? statusCode : null,
804
+ stderr: result.stderr?.trim(),
805
+ };
806
+ }
807
+
808
+ function hostGatewayCandidate(url) {
809
+ const parsed = new URL(url);
810
+ if (!isLocalhostUrl(url)) return null;
811
+ parsed.hostname = "host.docker.internal";
812
+ return parsed.toString();
813
+ }
814
+
815
+ function diagnoseUrlAccess(name, url, opts = {}) {
816
+ let parsed;
817
+ try {
818
+ parsed = new URL(url);
819
+ } catch {
820
+ throw new Error(`Invalid --url value: ${url}`);
821
+ }
822
+ assertUrlAllowed(parsed.toString(), opts);
823
+
824
+ console.log(`Host URL check: ${url}`);
825
+ if (waitForHttp(url, 3000)) {
826
+ console.log("Host can reach URL.");
827
+ } else {
828
+ console.log("Host could not reach URL within 3s.");
829
+ }
830
+
831
+ if (opts.dockerAvailable === false) {
832
+ console.log("Docker is unavailable, so container reachability was not checked.");
833
+ return;
834
+ }
835
+
836
+ if (!containerRunning(name)) {
837
+ console.log(`Session "${safeName(name)}" is not running, so container reachability was not checked.`);
838
+ console.log(`Start one with: proof-artifacts start --name ${safeName(name)} --project . --url ${url}`);
839
+ return;
840
+ }
841
+
842
+ const meta = readSessionMeta(name);
843
+ const candidates = [url];
844
+ const gateway = hostGatewayCandidate(url);
845
+ if (gateway && meta.network !== "host" && !candidates.includes(gateway)) candidates.push(gateway);
846
+
847
+ console.log(`Container URL checks for session "${safeName(name)}" (${meta.network ?? "unknown"} network):`);
848
+ let anyWorked = false;
849
+ for (const candidate of candidates) {
850
+ const result = reachableFromContainer(name, candidate);
851
+ anyWorked = anyWorked || result.ok;
852
+ const statusLabel = result.statusCode ? ` (${result.statusCode})` : "";
853
+ console.log(`- ${candidate}: ${result.ok ? "ok" : "failed"}${statusLabel}`);
854
+ if (!result.ok && result.stderr) {
855
+ console.log(` ${result.stderr.split("\n").slice(-1)[0]}`);
856
+ }
857
+ }
858
+
859
+ if (!anyWorked && isLocalhostUrl(url)) {
860
+ if (meta.network === "host") {
861
+ console.log("Host-network session could not reach the host URL. Check whether the app is still running and bound to localhost.");
862
+ } else {
863
+ console.log("Bridge networking could not reach this host-local app.");
864
+ console.log(`Recommended VPS fallback: proof-artifacts stop --name ${safeName(name)}`);
865
+ console.log(`Then start: proof-artifacts start --name ${safeName(name)} --project ${meta.project ?? "."} --url ${url}`);
866
+ console.log(`Then open: proof-artifacts open ${url} --name ${safeName(name)}`);
867
+ }
868
+ }
869
+ }
870
+
871
+ function hasSessionManifest(dir, name) {
872
+ const meta = readJsonFile(path.join(dir, "manifest.json")) ?? readJsonFile(path.join(dir, "session.json"));
873
+ return meta?.name === safeName(name);
874
+ }
875
+
876
+ function safeArtifactDeleteDir(dir, name) {
877
+ const resolved = path.resolve(dir);
878
+ const expectedSuffix = path.join(".proof-artifacts", "sessions", safeName(name));
879
+ return hasSessionManifest(resolved, name) && resolved.endsWith(expectedSuffix);
880
+ }
881
+
882
+ function collectSessions() {
883
+ const sessions = new Map();
884
+ const addSession = (name, dir, source) => {
885
+ const safe = safeName(name);
886
+ const resolved = path.resolve(dir);
887
+ const key = `${safe}:${resolved}`;
888
+ const existing = sessions.get(key);
889
+ sessions.set(key, {
890
+ name: safe,
891
+ dir: resolved,
892
+ source: existing ? `${existing.source}+${source}` : source,
893
+ });
894
+ };
895
+ const localRoot = path.resolve(process.cwd(), ".proof-artifacts", "sessions");
896
+ if (existsSync(localRoot)) {
897
+ for (const entry of readdirSync(localRoot)) {
898
+ const dir = path.join(localRoot, entry);
899
+ if (statSync(dir).isDirectory()) {
900
+ addSession(entry, dir, "local");
901
+ }
902
+ }
903
+ }
904
+ if (existsSync(globalStateDir())) {
905
+ for (const entry of readdirSync(globalStateDir())) {
906
+ if (!entry.endsWith(".json")) continue;
907
+ const name = safeName(entry.slice(0, -5));
908
+ const index = readJsonFile(path.join(globalStateDir(), entry));
909
+ if (index?.artifacts) {
910
+ addSession(name, index.artifacts, "global");
911
+ }
912
+ }
913
+ }
914
+ return [...sessions.values()].sort((a, b) => a.name.localeCompare(b.name) || a.source.localeCompare(b.source));
915
+ }
916
+
917
+ function containerStatus(name, expectedArtifacts = null) {
918
+ try {
919
+ if (!containerExists(name)) return { state: "missing", managed: false };
920
+ const managed = managedContainerExists(name);
921
+ const containerArtifacts = managedContainerArtifactDir(name);
922
+ if (expectedArtifacts && containerArtifacts && !sameResolvedPath(containerArtifacts, expectedArtifacts)) {
923
+ return {
924
+ state: "conflict",
925
+ managed,
926
+ containerArtifacts,
927
+ expectedArtifacts: path.resolve(expectedArtifacts),
928
+ };
929
+ }
930
+ const result = dockerTry(["inspect", "--format", "{{.State.Status}}", containerName(name)]);
931
+ return {
932
+ state: result.status === 0 ? result.stdout.trim() : "unknown",
933
+ managed,
934
+ containerArtifacts,
935
+ };
936
+ } catch (error) {
937
+ return { state: "unknown", managed: false, error: error.message };
938
+ }
939
+ }
940
+
941
+ function sessionStatusSummary(name, dir = sessionDir(name)) {
942
+ const meta = readJsonFile(path.join(dir, "manifest.json")) ?? readJsonFile(path.join(dir, "session.json")) ?? {};
943
+ const status = containerStatus(name, dir);
944
+ const state = lifecycleState(meta, status.state);
945
+ const counts = existsSync(dir) ? artifactCounts(dir) : { screenshots: 0, recordings: 0, logs: 0 };
946
+ const noVncReachable = state === "running" && meta.noVncUrl
947
+ ? waitForHttp(meta.noVncUrl, 1000)
948
+ : false;
949
+ return {
950
+ name: safeName(meta.name ?? name),
951
+ state,
952
+ managed: status.managed,
953
+ dockerError: status.error,
954
+ artifacts: dir,
955
+ artifactsExist: existsSync(dir),
956
+ project: meta.project ?? null,
957
+ noVncUrl: meta.noVncUrl ?? null,
958
+ noVncReachable,
959
+ network: meta.network ?? null,
960
+ targetUrl: meta.targetUrl ?? null,
961
+ browserFullscreen: meta.browserFullscreen ?? false,
962
+ startedAt: meta.startedAt ?? null,
963
+ stoppedAt: meta.stoppedAt ?? null,
964
+ durationMs: durationMs(meta.startedAt, meta.stoppedAt ?? (state === "running" ? new Date().toISOString() : null)),
965
+ activeRecording: meta.activeRecording ?? null,
966
+ counts,
967
+ events: meta.events ?? [],
968
+ };
969
+ }
970
+
971
+ function canonicalPath(file) {
972
+ return existsSync(file) ? realpathSync(file) : path.resolve(file);
973
+ }
974
+
975
+ function latestArtifact(items) {
976
+ if (items.length === 0) return null;
977
+ return [...items].sort((a, b) => {
978
+ const timeDiff = (a.mtimeMs ?? 0) - (b.mtimeMs ?? 0);
979
+ return timeDiff || a.file.localeCompare(b.file);
980
+ }).at(-1);
981
+ }
982
+
983
+ function lifecycleState(meta, dockerState) {
984
+ if (meta.stoppedAt) return "stopped";
985
+ if (dockerState === "running") return "running";
986
+ if (meta.name && dockerState === "missing") return "stale";
987
+ return dockerState ?? "unknown";
988
+ }
989
+
990
+ function buildReportSummary(meta, artifacts, state = null) {
991
+ const events = meta.events ?? [];
992
+ const failures = events.filter((event) => event.failedAt || (typeof event.status === "number" && event.status !== 0));
993
+ const eventCounts = events.reduce((counts, event) => {
994
+ const type = event.type ?? "event";
995
+ counts[type] = (counts[type] ?? 0) + 1;
996
+ return counts;
997
+ }, {});
998
+ const endedAt = meta.stoppedAt ?? (meta.activeRecording ? null : meta.updatedAt);
999
+ const elapsedMs = durationMs(meta.startedAt, meta.stoppedAt ?? new Date().toISOString());
1000
+ return {
1001
+ status: state ?? (meta.stoppedAt ? "stopped" : "active-or-stale"),
1002
+ startedAt: meta.startedAt ?? null,
1003
+ stoppedAt: meta.stoppedAt ?? null,
1004
+ elapsedMs,
1005
+ elapsedLabel: formatDuration(elapsedMs),
1006
+ targetUrl: meta.targetUrl ?? null,
1007
+ network: meta.network ?? null,
1008
+ noVncUrl: meta.noVncUrl ?? null,
1009
+ project: meta.project ?? null,
1010
+ browserFullscreen: meta.browserFullscreen ?? false,
1011
+ counts: {
1012
+ screenshots: artifacts.screenshots.length,
1013
+ recordings: artifacts.recordings.length,
1014
+ logs: artifacts.logs.length,
1015
+ events: events.length,
1016
+ failures: failures.length,
1017
+ },
1018
+ latest: {
1019
+ screenshot: latestArtifact(artifacts.screenshots),
1020
+ recording: latestArtifact(artifacts.recordings),
1021
+ },
1022
+ activeRecording: meta.activeRecording ?? null,
1023
+ lastEvent: events.at(-1) ?? null,
1024
+ failures,
1025
+ eventCounts,
1026
+ endedAt,
1027
+ };
1028
+ }
1029
+
1030
+ function generateReport(name) {
1031
+ const safe = safeName(name);
1032
+ const dir = sessionDir(safe);
1033
+ const meta = readSessionMeta(safe);
1034
+ if (!meta.name) {
1035
+ throw new Error(`Session "${safe}" was not found.`);
1036
+ }
1037
+ if (safeName(meta.name) !== safe) {
1038
+ throw new Error(`Session manifest mismatch: expected "${safe}", found "${meta.name}".`);
1039
+ }
1040
+ if (meta.artifacts && canonicalPath(meta.artifacts) !== canonicalPath(dir)) {
1041
+ throw new Error(`Session artifact path mismatch for "${safe}". Refusing to generate a report from stale metadata.`);
1042
+ }
1043
+ const redactedMeta = redactSensitive(meta);
1044
+ const screenshots = listFiles(path.join(dir, "screenshots")).map((file) => fileInfo(dir, "screenshots", file));
1045
+ const recordings = listFiles(path.join(dir, "recordings"))
1046
+ .filter((file) => file.endsWith(".mp4"))
1047
+ .map((file) => fileInfo(dir, "recordings", file));
1048
+ const logs = [
1049
+ ...listFiles(path.join(dir, "recordings"))
1050
+ .filter((file) => file.endsWith(".log"))
1051
+ .map((file) => fileInfo(dir, "recordings", file)),
1052
+ ...listFiles(path.join(dir, "logs"))
1053
+ .filter((file) => file.endsWith(".log"))
1054
+ .map((file) => fileInfo(dir, "logs", file)),
1055
+ ];
1056
+ const status = containerStatus(safe, dir);
1057
+ const summary = buildReportSummary(redactedMeta, { screenshots, recordings, logs }, lifecycleState(meta, status.state));
1058
+ const report = {
1059
+ generatedAt: new Date().toISOString(),
1060
+ summary,
1061
+ session: redactedMeta,
1062
+ artifacts: {
1063
+ root: dir,
1064
+ screenshots,
1065
+ recordings,
1066
+ logs,
1067
+ },
1068
+ };
1069
+ mkdirSync(dir, { recursive: true });
1070
+ writeFileSync(path.join(dir, "report.json"), `${JSON.stringify(report, null, 2)}\n`);
1071
+
1072
+ const html = `<!doctype html>
1073
+ <html lang="en">
1074
+ <head>
1075
+ <meta charset="utf-8">
1076
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1077
+ <title>Proof Artifacts Report - ${escapeHtml(safe)}</title>
1078
+ <style>
1079
+ body { font-family: ui-sans-serif, system-ui, sans-serif; margin: 32px; color: #18181b; background: #fafaf9; }
1080
+ main { max-width: 960px; margin: 0 auto; }
1081
+ h1 { font-size: 32px; margin-bottom: 4px; }
1082
+ h2 { margin-top: 32px; border-top: 1px solid #d6d3d1; padding-top: 20px; }
1083
+ code, pre { background: #f5f5f4; border: 1px solid #e7e5e4; border-radius: 8px; }
1084
+ code { padding: 2px 5px; }
1085
+ pre { padding: 14px; overflow: auto; }
1086
+ img { max-width: 100%; border: 1px solid #d6d3d1; border-radius: 12px; background: white; }
1087
+ video { width: 100%; border: 1px solid #d6d3d1; border-radius: 12px; background: #18181b; }
1088
+ .grid { display: grid; gap: 16px; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); }
1089
+ .card { background: white; border: 1px solid #e7e5e4; border-radius: 14px; padding: 16px; }
1090
+ .metric { font-size: 28px; font-weight: 750; margin: 4px 0; }
1091
+ .muted { color: #57534e; }
1092
+ .fail { color: #991b1b; }
1093
+ .timeline { list-style: none; padding: 0; }
1094
+ .timeline li { padding: 10px 0; border-bottom: 1px solid #e7e5e4; }
1095
+ </style>
1096
+ </head>
1097
+ <body>
1098
+ <main>
1099
+ <p class="muted">Generated ${escapeHtml(report.generatedAt)}</p>
1100
+ <h1>${escapeHtml(safe)}</h1>
1101
+ <p><strong>Project:</strong> <code>${escapeHtml(redactedMeta.project ?? "unknown")}</code></p>
1102
+ <p><strong>noVNC:</strong> <code>${escapeHtml(redactedMeta.noVncUrl ?? "unknown")}</code></p>
1103
+ <p><strong>Artifacts:</strong> <code>${escapeHtml(dir)}</code></p>
1104
+
1105
+ <h2>Summary</h2>
1106
+ <div class="grid">
1107
+ <div class="card"><p class="muted">Status</p><p class="metric">${escapeHtml(summary.status)}</p><p>${escapeHtml(summary.elapsedLabel)}</p></div>
1108
+ <div class="card"><p class="muted">Screenshots</p><p class="metric">${summary.counts.screenshots}</p><p>${summary.latest.screenshot ? escapeHtml(summary.latest.screenshot.file) : "none"}</p></div>
1109
+ <div class="card"><p class="muted">Recordings</p><p class="metric">${summary.counts.recordings}</p><p>${summary.latest.recording ? `${escapeHtml(summary.latest.recording.file)} · ${escapeHtml(summary.latest.recording.durationLabel ?? summary.latest.recording.sizeLabel)}` : "none"}</p></div>
1110
+ <div class="card"><p class="muted">Failures</p><p class="metric ${summary.counts.failures > 0 ? "fail" : ""}">${summary.counts.failures}</p><p>${summary.lastEvent ? `Last event: ${escapeHtml(summary.lastEvent.type ?? "event")}` : "No events"}</p></div>
1111
+ </div>
1112
+
1113
+ ${summary.failures.length === 0 ? "" : `
1114
+ <h2>Failure Context</h2>
1115
+ <ol class="timeline">
1116
+ ${summary.failures.map((event) => `<li><strong>${escapeHtml(event.type ?? "event")}</strong> <span class="muted">${escapeHtml(event.failedAt ?? event.at ?? "")}</span><br><code>${escapeHtml(JSON.stringify(event))}</code></li>`).join("")}
1117
+ </ol>`}
1118
+
1119
+ <h2>Screenshots</h2>
1120
+ <div class="grid">
1121
+ ${screenshots.length === 0 ? "<p>No screenshots captured.</p>" : screenshots.map((item) => `
1122
+ <figure class="card">
1123
+ <img src="screenshots/${encodeURIComponent(item.file)}" alt="${escapeHtml(item.file)}">
1124
+ <figcaption>${escapeHtml(item.file)} · ${escapeHtml(item.sizeLabel)}</figcaption>
1125
+ </figure>`).join("")}
1126
+ </div>
1127
+
1128
+ <h2>Recordings</h2>
1129
+ <div class="grid">
1130
+ ${recordings.length === 0 ? "<p>No recordings captured.</p>" : recordings.map((item) => `
1131
+ <div class="card">
1132
+ <video controls src="recordings/${encodeURIComponent(item.file)}"></video>
1133
+ <p><a href="recordings/${encodeURIComponent(item.file)}">${escapeHtml(item.file)}</a> · ${escapeHtml(item.sizeLabel)}</p>
1134
+ </div>`).join("")}
1135
+ </div>
1136
+
1137
+ <h2>Timeline</h2>
1138
+ ${(redactedMeta.events ?? []).length === 0 ? "<p>No events recorded.</p>" : `
1139
+ <ol class="timeline">
1140
+ ${(redactedMeta.events ?? []).map((event) => `
1141
+ <li><strong>${escapeHtml(event.type ?? "event")}</strong> <span class="muted">${escapeHtml(event.at ?? "")}</span><br><code>${escapeHtml(JSON.stringify(event))}</code></li>`).join("")}
1142
+ </ol>`}
1143
+
1144
+ <h2>Logs</h2>
1145
+ ${logs.length === 0 ? "<p>No logs captured.</p>" : `
1146
+ <ul>${logs.map((item) => `<li><a href="${escapeHtml(item.relativePath.split(path.sep).map(encodeURIComponent).join("/"))}">${escapeHtml(item.relativePath)}</a> · ${escapeHtml(item.sizeLabel)}</li>`).join("")}</ul>`}
1147
+
1148
+ <h2>Manifest</h2>
1149
+ <pre>${escapeHtml(JSON.stringify(redactedMeta, null, 2))}</pre>
1150
+ </main>
1151
+ </body>
1152
+ </html>
1153
+ `;
1154
+ writeFileSync(path.join(dir, "report.html"), html);
1155
+ return {
1156
+ reportJson: path.join(dir, "report.json"),
1157
+ reportHtml: path.join(dir, "report.html"),
1158
+ };
1159
+ }
1160
+
1161
+ function cmdDoctor(input = []) {
1162
+ const { opts } = parseOptions(input);
1163
+ const checks = [];
1164
+ const addCheck = (status, label, detail = "") => checks.push({ status, label, detail });
1165
+ const nodeMajor = Number.parseInt(process.versions.node.split(".")[0], 10);
1166
+
1167
+ addCheck(nodeMajor >= 18 ? "pass" : "fail", "Node.js >= 18", process.version);
1168
+ addCheck(process.platform === "linux" ? "pass" : "warn", "Host OS", `${process.platform}/${process.arch}; Linux VPS is the supported v0 target`);
1169
+ addCheck(existsSync(path.join(rootDir, "runtime", "Dockerfile")) ? "pass" : "fail", "Runtime Dockerfile", path.join(rootDir, "runtime", "Dockerfile"));
1170
+ addCheck(existsSync(path.join(rootDir, "runtime", "entrypoint.sh")) ? "pass" : "fail", "Runtime entrypoint", path.join(rootDir, "runtime", "entrypoint.sh"));
1171
+
1172
+ const dockerCli = spawnSync("docker", ["version"], { encoding: "utf8", stdio: "pipe" });
1173
+ const dockerAvailable = dockerCli.status === 0;
1174
+ addCheck(dockerAvailable ? "pass" : "fail", "Docker CLI", dockerAvailable ? "available" : "docker version failed");
1175
+ if (dockerAvailable) {
1176
+ const dockerDaemon = spawnSync("docker", ["info"], { encoding: "utf8", stdio: "pipe" });
1177
+ addCheck(dockerDaemon.status === 0 ? "pass" : "fail", "Docker daemon", dockerDaemon.status === 0 ? "reachable" : dockerDaemon.stderr.trim());
1178
+ addCheck(imageExists(defaultImage) ? "pass" : "warn", "Default runtime image", imageExists(defaultImage) ? defaultImage : "not built yet; first start/smoke can build it");
1179
+ }
1180
+
1181
+ try {
1182
+ const ports = findAvailableVncPorts();
1183
+ addCheck("pass", "Host-network noVNC/VNC ports", `${ports.noVncPort}/${ports.vncPort} available`);
1184
+ } catch (error) {
1185
+ addCheck("warn", "Host-network noVNC/VNC ports", error.message);
1186
+ }
1187
+
1188
+ const name = safeName(opts.name);
1189
+ if (dockerAvailable && opts.name) {
1190
+ const dir = sessionDir(name);
1191
+ const status = containerStatus(name, dir);
1192
+ addCheck(status.state === "running" ? "pass" : "warn", `Session "${name}"`, `container ${status.state}${status.managed ? ", managed" : ""}`);
1193
+ if (status.state === "running") {
1194
+ const runtime = dockerTry([
1195
+ "exec",
1196
+ "-e", "DISPLAY=:99",
1197
+ containerName(name),
1198
+ "sh",
1199
+ "-lc",
1200
+ "for bin in curl chromium ffmpeg wmctrl xdotool xdpyinfo x11vnc websockify; do command -v $bin >/dev/null || { echo missing $bin >&2; exit 10; }; done; xdpyinfo -display :99 >/dev/null; pgrep Xvfb >/dev/null; pgrep websockify >/dev/null; pgrep x11vnc >/dev/null",
1201
+ ]);
1202
+ addCheck(runtime.status === 0 ? "pass" : "fail", "Runtime desktop tools", runtime.status === 0 ? "available" : (runtime.stderr.trim() || `exit ${runtime.status}`));
1203
+ const meta = readSessionMeta(name);
1204
+ if (meta.noVncUrl) {
1205
+ addCheck(waitForHttp(meta.noVncUrl, 3000) ? "pass" : "warn", "Session noVNC", meta.noVncUrl);
1206
+ }
1207
+ }
1208
+ }
1209
+
1210
+ console.log("Proof Artifacts doctor");
1211
+ console.log("This Linux-first v0 uses Docker, Xvfb, Chromium, noVNC, and ffmpeg inside the desktop container.");
1212
+ for (const check of checks) {
1213
+ const icon = check.status === "pass" ? "PASS" : check.status === "warn" ? "WARN" : "FAIL";
1214
+ console.log(`[${icon}] ${check.label}${check.detail ? ` - ${check.detail}` : ""}`);
1215
+ }
1216
+
1217
+ if (opts.url) {
1218
+ console.log("");
1219
+ diagnoseUrlAccess(name, String(opts.url), { ...opts, dockerAvailable });
1220
+ }
1221
+
1222
+ if (isTruthy(opts.deep)) {
1223
+ console.log("");
1224
+ console.log("Deep doctor: running disposable smoke test.");
1225
+ const deepName = safeName(opts.name ? `${opts.name}-doctor-${timestamp()}` : `doctor-${timestamp()}`);
1226
+ const smokeArgs = ["--name", deepName, "--project", String(opts.project ?? process.cwd())];
1227
+ if (opts.url) smokeArgs.push("--url", String(opts.url));
1228
+ if (opts.keep) smokeArgs.push("--keep");
1229
+ if (opts.rebuild) smokeArgs.push("--rebuild");
1230
+ cmdSmoke(smokeArgs);
1231
+ }
1232
+
1233
+ if (checks.some((check) => check.status === "fail")) {
1234
+ process.exitCode = 1;
1235
+ }
1236
+ }
1237
+
1238
+ function cmdDetect(input) {
1239
+ const { opts } = parseOptions(input);
1240
+ const result = detectProject(opts.project ?? process.cwd());
1241
+ if (isTruthy(opts.json)) {
1242
+ console.log(JSON.stringify(result, null, 2));
1243
+ return;
1244
+ }
1245
+ console.log(`Project: ${result.project}`);
1246
+ for (const finding of result.findings) {
1247
+ console.log("");
1248
+ console.log(`${finding.kind} (${finding.confidence})`);
1249
+ console.log(`Can run in Linux desktop runtime: ${finding.canRunHere ? "yes" : "no"}`);
1250
+ console.log(`Reason: ${finding.reason}`);
1251
+ if (finding.commands.length > 0) {
1252
+ console.log("Likely commands:");
1253
+ for (const commandText of finding.commands) {
1254
+ console.log(`- ${commandText}`);
1255
+ }
1256
+ }
1257
+ console.log(`Next: ${finding.next}`);
1258
+ }
1259
+ }
1260
+
1261
+ function cmdStart(input) {
1262
+ const { opts } = parseOptions(input);
1263
+ const name = safeName(opts.name);
1264
+ const width = validateDimension(opts.width ?? defaultWidth, "width");
1265
+ const height = validateDimension(opts.height ?? defaultHeight, "height");
1266
+ const project = path.resolve(opts.project ?? process.cwd());
1267
+ const artifacts = localSessionDir(name);
1268
+ const inferredNetwork = opts.network ?? (isLocalhostUrl(opts.url) ? "host" : "bridge");
1269
+ const networkMode = parseNetworkMode(inferredNetwork);
1270
+ const requestedPort = opts["novnc-port"] ?? "auto";
1271
+ const autoHostPorts = networkMode === "host" && requestedPort === "auto" && !opts["vnc-port"]
1272
+ ? findAvailableVncPorts()
1273
+ : null;
1274
+ const hostNoVncPort = networkMode === "host"
1275
+ ? (autoHostPorts?.noVncPort ?? (requestedPort === "auto" ? findAvailablePort(6080) : parsePort(requestedPort)))
1276
+ : null;
1277
+ const hostVncPort = networkMode === "host"
1278
+ ? (autoHostPorts?.vncPort ?? parsePort(opts["vnc-port"] ?? String(hostNoVncPort - 180)))
1279
+ : null;
1280
+ const portMapping = networkMode === "bridge"
1281
+ ? (requestedPort === "auto" ? "127.0.0.1::6080" : `127.0.0.1:${parsePort(requestedPort)}:6080`)
1282
+ : null;
1283
+
1284
+ if (networkMode === "host" && !autoHostPorts) {
1285
+ if (!tcpPortAvailable(hostNoVncPort)) {
1286
+ throw new Error(`Host noVNC port ${hostNoVncPort} is already in use. Choose another --novnc-port.`);
1287
+ }
1288
+ if (!tcpPortAvailable(hostVncPort)) {
1289
+ throw new Error(`Host VNC port ${hostVncPort} is already in use. Choose another --vnc-port.`);
1290
+ }
1291
+ }
1292
+
1293
+ if (!existsSync(project)) {
1294
+ throw new Error(`Project path does not exist: ${project}`);
1295
+ }
1296
+
1297
+ if (containerRunning(name) && !isTruthy(opts.replace)) {
1298
+ throw new Error(`Session "${name}" is already running. Use a unique --name, stop it first, or pass --replace to remove it.`);
1299
+ }
1300
+
1301
+ if (containerExists(name)) {
1302
+ if (!managedContainerExists(name)) {
1303
+ throw new Error(`Container ${containerName(name)} exists but is not managed by proof-artifacts. Rename or remove it manually before starting this session.`);
1304
+ }
1305
+ const containerArtifacts = managedContainerArtifactDir(name);
1306
+ if (containerArtifacts && !sameResolvedPath(containerArtifacts, artifacts)) {
1307
+ throw new Error(`Container ${containerName(name)} belongs to different artifacts: ${containerArtifacts}. Use a unique --name or stop it from the matching project.`);
1308
+ }
1309
+ docker(["rm", "-f", containerName(name)]);
1310
+ }
1311
+
1312
+ const imageInfo = ensureRuntimeImage(opts);
1313
+
1314
+ mkdirSync(artifacts, { recursive: true });
1315
+
1316
+ const userSpec = hostUserSpec();
1317
+ const dockerRunArgs = [
1318
+ "run",
1319
+ "-d",
1320
+ "--name", containerName(name),
1321
+ "--label", "proof-artifacts.managed=true",
1322
+ "--label", `proof-artifacts.session=${name}`,
1323
+ "--label", `proof-artifacts.project=${project}`,
1324
+ "--label", `proof-artifacts.artifacts=${artifacts}`,
1325
+ "-e", `WIDTH=${width}`,
1326
+ "-e", `HEIGHT=${height}`,
1327
+ "-e", "DISPLAY=:99",
1328
+ "-e", `NOVNC_PORT=${networkMode === "host" ? hostNoVncPort : 6080}`,
1329
+ "-e", `NOVNC_LISTEN=${networkMode === "host" ? "127.0.0.1" : "0.0.0.0"}`,
1330
+ "-e", `VNC_PORT=${networkMode === "host" ? hostVncPort : 5900}`,
1331
+ "--mount", `type=bind,src=${project},dst=/workspace`,
1332
+ "--mount", `type=bind,src=${artifacts},dst=/artifacts`,
1333
+ imageInfo.image,
1334
+ ];
1335
+ if (networkMode === "host") {
1336
+ dockerRunArgs.splice(6, 0, "--network", "host");
1337
+ } else {
1338
+ dockerRunArgs.splice(6, 0, "--add-host", "host.docker.internal:host-gateway", "-p", portMapping);
1339
+ }
1340
+ if (userSpec) {
1341
+ dockerRunArgs.splice(6, 0, "--user", userSpec);
1342
+ }
1343
+
1344
+ const result = dockerTry(dockerRunArgs);
1345
+
1346
+ if (result.status !== 0) {
1347
+ if (containerExists(name)) {
1348
+ docker(["rm", "-f", containerName(name)], { stdio: "ignore" });
1349
+ }
1350
+ const stderr = result.stderr?.trim();
1351
+ throw new Error(stderr || "Failed to start desktop container.");
1352
+ }
1353
+
1354
+ const selectedPort = networkMode === "host" ? hostNoVncPort : mappedNoVncPort(name);
1355
+ const meta = {
1356
+ schemaVersion: 1,
1357
+ name,
1358
+ width,
1359
+ height,
1360
+ project,
1361
+ artifacts,
1362
+ noVncUrl: `http://127.0.0.1:${selectedPort}/vnc.html`,
1363
+ noVncPort: selectedPort,
1364
+ noVncListen: networkMode === "host" ? "127.0.0.1" : "docker-published-127.0.0.1",
1365
+ network: networkMode,
1366
+ targetUrl: opts.url ?? null,
1367
+ networkInferredFromUrl: opts.network ? false : isLocalhostUrl(opts.url),
1368
+ browserFullscreen: isTruthy(opts.fullscreen),
1369
+ vncPort: networkMode === "host" ? hostVncPort : 5900,
1370
+ container: containerName(name),
1371
+ containerUser: userSpec ?? "default",
1372
+ image: imageInfo.image,
1373
+ imageId: dockerImageId(imageInfo.image),
1374
+ requestedImage: imageInfo.requestedImage,
1375
+ imageSource: imageInfo.imageSource,
1376
+ startedAt: new Date().toISOString(),
1377
+ stoppedAt: null,
1378
+ events: [],
1379
+ screenshots: [],
1380
+ recordings: [],
1381
+ };
1382
+ writeSessionMeta(name, meta);
1383
+ addEvent(name, "start", {
1384
+ container: containerName(name),
1385
+ image: imageInfo.image,
1386
+ imageSource: imageInfo.imageSource,
1387
+ noVncPort: selectedPort,
1388
+ });
1389
+ if (waitForHttp(meta.noVncUrl, 15000)) {
1390
+ addEvent(name, "novnc-ready", { url: meta.noVncUrl });
1391
+ } else {
1392
+ addEvent(name, "novnc-not-ready", { url: meta.noVncUrl, warning: "noVNC did not respond within 15s" });
1393
+ console.log("Warning: noVNC did not respond within 15s. The container may still be starting.");
1394
+ }
1395
+
1396
+ console.log(`Started desktop session "${name}".`);
1397
+ console.log(`noVNC: http://127.0.0.1:${selectedPort}/vnc.html`);
1398
+ if (opts.url) {
1399
+ console.log(`Target URL: ${opts.url}`);
1400
+ console.log(`Open it with: proof-artifacts open ${opts.url} --name ${name}${isTruthy(opts.fullscreen) ? " --fullscreen" : ""}`);
1401
+ }
1402
+ console.log(`Artifacts: ${artifacts}`);
1403
+ }
1404
+
1405
+ function cmdLaunch(input) {
1406
+ const { opts, rest } = parseOptions(input);
1407
+ const name = safeName(opts.name);
1408
+ const commandToRun = rest;
1409
+ if (commandToRun.length === 0) {
1410
+ throw new Error("Missing command. Usage: proof-artifacts launch [--name NAME] [--window-match TEXT] -- <command...>");
1411
+ }
1412
+ requireRunning(name);
1413
+
1414
+ const label = safeName(opts.label ?? `launch-${timestamp()}`);
1415
+ const logFile = `/artifacts/logs/${label}.launch.log`;
1416
+ const result = dockerTry([
1417
+ "exec",
1418
+ "-d",
1419
+ "-e", "DISPLAY=:99",
1420
+ "-e", "HOME=/tmp",
1421
+ "-e", "XDG_CONFIG_HOME=/tmp/app-config",
1422
+ "-e", "XDG_CACHE_HOME=/tmp/app-cache",
1423
+ "-w", "/workspace",
1424
+ containerName(name),
1425
+ "sh",
1426
+ "-lc",
1427
+ `mkdir -p /tmp/app-config /tmp/app-cache /artifacts/logs && exec "$@" >${logFile} 2>&1`,
1428
+ "proof-artifacts-launch",
1429
+ ...commandToRun,
1430
+ ]);
1431
+ if (result.status !== 0) {
1432
+ addEvent(name, "launch", {
1433
+ command: commandToRun.join(" "),
1434
+ status: result.status,
1435
+ signal: result.signal,
1436
+ failedAt: new Date().toISOString(),
1437
+ });
1438
+ throw new Error(`Launch failed in session "${name}": ${commandToRun.join(" ")}`);
1439
+ }
1440
+
1441
+ const windowMatch = opts["window-match"] ?? path.basename(commandToRun[0]);
1442
+ const waitMs = Number.parseInt(String(opts["wait-ms"] ?? "10000"), 10);
1443
+ let windowFound = false;
1444
+ if (!isTruthy(opts["no-wait"]) && windowMatch) {
1445
+ const waitResult = dockerTry([
1446
+ "exec",
1447
+ "-e", "DISPLAY=:99",
1448
+ "-e", `WINDOW_MATCH=${windowMatch}`,
1449
+ "-e", `WAIT_MS=${Number.isFinite(waitMs) ? waitMs : 10000}`,
1450
+ containerName(name),
1451
+ "sh",
1452
+ "-lc",
1453
+ "deadline=$(( $(date +%s%3N) + WAIT_MS )); while [ $(date +%s%3N) -lt $deadline ]; do wid=$(xdotool search --onlyvisible --name \"$WINDOW_MATCH\" 2>/dev/null | tail -n 1 || true); if [ -n \"$wid\" ]; then xdotool windowactivate \"$wid\" windowraise \"$wid\" >/dev/null 2>&1 || true; exit 0; fi; sleep 0.2; done; exit 1",
1454
+ ], { stdio: "pipe" });
1455
+ windowFound = waitResult.status === 0;
1456
+ }
1457
+
1458
+ addEvent(name, "launch", {
1459
+ command: commandToRun.join(" "),
1460
+ label,
1461
+ logFile: path.join(sessionDir(name), "logs", `${label}.launch.log`),
1462
+ windowMatch,
1463
+ windowFound,
1464
+ status: 0,
1465
+ });
1466
+ if (!isTruthy(opts["no-wait"]) && !windowFound) {
1467
+ addEvent(name, "launch-window-missing", {
1468
+ command: commandToRun.join(" "),
1469
+ label,
1470
+ logFile: path.join(sessionDir(name), "logs", `${label}.launch.log`),
1471
+ windowMatch,
1472
+ waitMs: Number.isFinite(waitMs) ? waitMs : 10000,
1473
+ failedAt: new Date().toISOString(),
1474
+ });
1475
+ throw new Error(`Launch command started, but no visible window matched "${windowMatch}". Use --window-match with the app title or --no-wait.`);
1476
+ }
1477
+ console.log(`Launched in session "${name}": ${commandToRun.join(" ")}`);
1478
+ console.log(path.join(sessionDir(name), "logs", `${label}.launch.log`));
1479
+ }
1480
+
1481
+ function cmdOpen(input) {
1482
+ const { opts, rest } = parseOptions(input);
1483
+ const name = safeName(opts.name);
1484
+ const url = rest[0];
1485
+ if (!url) throw new Error("Missing URL. Usage: proof-artifacts open <url>");
1486
+ assertUrlAllowed(url, opts);
1487
+ requireRunning(name);
1488
+ const meta = readSessionMeta(name);
1489
+ const fullscreen = isTruthy(opts.fullscreen) || (meta.browserFullscreen && !isTruthy(opts["no-fullscreen"]));
1490
+ const forceWindowed = isTruthy(opts["no-fullscreen"]);
1491
+ const result = dockerTry([
1492
+ "exec",
1493
+ "-e", "DISPLAY=:99",
1494
+ "-e", `TARGET_URL=${url}`,
1495
+ "-e", `BROWSER_FULLSCREEN=${fullscreen ? "true" : "false"}`,
1496
+ "-e", `BROWSER_FORCE_WINDOWED=${forceWindowed ? "true" : "false"}`,
1497
+ "-e", `SCREEN_WIDTH=${validateDimension(meta.width ?? defaultWidth, "width")}`,
1498
+ "-e", `SCREEN_HEIGHT=${validateDimension(meta.height ?? defaultHeight, "height")}`,
1499
+ "-e", "HOME=/tmp",
1500
+ "-e", "XDG_CONFIG_HOME=/tmp/chromium-config",
1501
+ "-e", "XDG_CACHE_HOME=/tmp/chromium-cache",
1502
+ containerName(name),
1503
+ "sh",
1504
+ "-lc",
1505
+ `mkdir -p /tmp/chromium-profile /tmp/chromium-config /tmp/chromium-cache
1506
+ if [ "$BROWSER_FULLSCREEN" = "true" ] || [ "$BROWSER_FORCE_WINDOWED" = "true" ]; then
1507
+ pkill -TERM chromium >/dev/null 2>&1 || true
1508
+ sleep 0.4
1509
+ fi
1510
+ if [ "$BROWSER_FULLSCREEN" = "true" ]; then
1511
+ chromium --test-type --no-sandbox --disable-dev-shm-usage --disable-crash-reporter --disable-breakpad --user-data-dir=/tmp/chromium-profile --new-window --window-position=0,0 --window-size="\${SCREEN_WIDTH},\${SCREEN_HEIGHT}" "$TARGET_URL" >/tmp/chromium.log 2>&1 &
1512
+ else
1513
+ chromium --test-type --no-sandbox --disable-dev-shm-usage --disable-crash-reporter --disable-breakpad --user-data-dir=/tmp/chromium-profile --new-window "$TARGET_URL" >/tmp/chromium.log 2>&1 &
1514
+ fi
1515
+ for i in $(seq 1 100); do
1516
+ wid=$(xdotool search --onlyvisible --class Chromium 2>/dev/null | tail -n 1 || true)
1517
+ if [ -z "$wid" ]; then
1518
+ wid=$(xdotool search --onlyvisible --name Chromium 2>/dev/null | tail -n 1 || true)
1519
+ fi
1520
+ if [ -n "$wid" ]; then
1521
+ xdotool windowactivate "$wid" windowraise "$wid" >/dev/null 2>&1 || true
1522
+ if [ "$BROWSER_FULLSCREEN" = "true" ]; then
1523
+ if command -v wmctrl >/dev/null 2>&1; then
1524
+ wmctrl -i -r "$wid" -b add,fullscreen >/dev/null 2>&1 || true
1525
+ wmctrl -i -r "$wid" -b add,maximized_vert,maximized_horz >/dev/null 2>&1 || true
1526
+ fi
1527
+ xdotool windowmove "$wid" 0 0 >/dev/null 2>&1 || true
1528
+ xdotool windowsize "$wid" "$SCREEN_WIDTH" "$SCREEN_HEIGHT" >/dev/null 2>&1 || true
1529
+ else
1530
+ if command -v wmctrl >/dev/null 2>&1; then
1531
+ wmctrl -i -r "$wid" -b remove,fullscreen >/dev/null 2>&1 || true
1532
+ fi
1533
+ fi
1534
+ exit 0
1535
+ fi
1536
+ sleep 0.1
1537
+ done
1538
+ cat /tmp/chromium.log >&2
1539
+ exit 1`,
1540
+ ]);
1541
+ if (result.status !== 0) {
1542
+ addEvent(name, "open", {
1543
+ url,
1544
+ fullscreen,
1545
+ status: result.status,
1546
+ signal: result.signal,
1547
+ stderr: result.stderr?.trim(),
1548
+ failedAt: new Date().toISOString(),
1549
+ });
1550
+ throw new Error(`Failed to open ${url} in session "${name}".`);
1551
+ }
1552
+ addEvent(name, "open", { url, fullscreen });
1553
+ console.log(`Opened ${url} in session "${name}"${fullscreen ? " (fullscreen)" : ""}.`);
1554
+ }
1555
+
1556
+ function cmdScreenshot(input) {
1557
+ const { opts } = parseOptions(input);
1558
+ const name = safeName(opts.name);
1559
+ const label = safeName(opts.label ?? `screenshot-${timestamp()}`);
1560
+ const meta = readSessionMeta(name);
1561
+ const width = validateDimension(meta.width ?? defaultWidth, "width");
1562
+ const height = validateDimension(meta.height ?? defaultHeight, "height");
1563
+ const target = `/artifacts/screenshots/${label}.png`;
1564
+ requireRunning(name);
1565
+ docker([
1566
+ "exec",
1567
+ "-e", "DISPLAY=:99",
1568
+ containerName(name),
1569
+ "sh",
1570
+ "-lc",
1571
+ `mkdir -p /artifacts/screenshots && ffmpeg -y -f x11grab -video_size ${width}x${height} -i :99.0 -frames:v 1 ${target} >/tmp/screenshot.log 2>&1`,
1572
+ ]);
1573
+ const file = path.join(sessionDir(name), "screenshots", `${label}.png`);
1574
+ updateSessionMeta(name, (current) => ({
1575
+ ...current,
1576
+ screenshots: [
1577
+ ...(current.screenshots ?? []),
1578
+ { label, file, capturedAt: new Date().toISOString() },
1579
+ ],
1580
+ events: [
1581
+ ...(current.events ?? []),
1582
+ { type: "screenshot", at: new Date().toISOString(), label, file },
1583
+ ],
1584
+ }));
1585
+ console.log(file);
1586
+ }
1587
+
1588
+ function cmdRecord(input) {
1589
+ const subcommand = input[0];
1590
+ const { opts } = parseOptions(input.slice(1));
1591
+ const name = safeName(opts.name);
1592
+ requireRunning(name);
1593
+
1594
+ if (subcommand === "start") {
1595
+ const label = safeName(opts.label ?? `recording-${timestamp()}`);
1596
+ const meta = readSessionMeta(name);
1597
+ const width = validateDimension(meta.width ?? defaultWidth, "width");
1598
+ const height = validateDimension(meta.height ?? defaultHeight, "height");
1599
+ docker([
1600
+ "exec",
1601
+ "-e", "DISPLAY=:99",
1602
+ "-e", `RECORDING_LABEL=${label}`,
1603
+ "-e", `VIDEO_SIZE=${width}x${height}`,
1604
+ containerName(name),
1605
+ "sh",
1606
+ "-lc",
1607
+ "test ! -f /tmp/proof-artifacts-recording.pid || { echo recording already running >&2; exit 2; }; mkdir -p /artifacts/recordings; nohup ffmpeg -y -f x11grab -framerate 30 -video_size \"$VIDEO_SIZE\" -i :99.0 -c:v libx264 -preset ultrafast -crf 23 -pix_fmt yuv420p \"/artifacts/recordings/$RECORDING_LABEL.mp4\" >/artifacts/recordings/$RECORDING_LABEL.log 2>&1 & echo $! >/tmp/proof-artifacts-recording.pid; echo \"$RECORDING_LABEL\" >/tmp/proof-artifacts-recording.label",
1608
+ ]);
1609
+ const file = path.join(sessionDir(name), "recordings", `${label}.mp4`);
1610
+ updateSessionMeta(name, (current) => ({
1611
+ ...current,
1612
+ activeRecording: { label, file, startedAt: new Date().toISOString() },
1613
+ events: [
1614
+ ...(current.events ?? []),
1615
+ { type: "record-start", at: new Date().toISOString(), label, file },
1616
+ ],
1617
+ }));
1618
+ console.log(`Recording started: ${file}`);
1619
+ return;
1620
+ }
1621
+
1622
+ if (subcommand === "stop") {
1623
+ const meta = readSessionMeta(name);
1624
+ const active = meta.activeRecording;
1625
+ docker([
1626
+ "exec",
1627
+ containerName(name),
1628
+ "sh",
1629
+ "-lc",
1630
+ "test -f /tmp/proof-artifacts-recording.pid || { echo no recording running >&2; exit 2; }; kill -INT \"$(cat /tmp/proof-artifacts-recording.pid)\"; sleep 1; rm -f /tmp/proof-artifacts-recording.pid; cat /tmp/proof-artifacts-recording.label; rm -f /tmp/proof-artifacts-recording.label",
1631
+ ]);
1632
+ updateSessionMeta(name, (current) => {
1633
+ const stoppedAt = new Date().toISOString();
1634
+ const recording = current.activeRecording
1635
+ ? { ...current.activeRecording, stoppedAt, durationMs: durationMs(current.activeRecording.startedAt, stoppedAt) }
1636
+ : { label: "unknown", stoppedAt };
1637
+ const { activeRecording, ...rest } = current;
1638
+ return {
1639
+ ...rest,
1640
+ recordings: [...(current.recordings ?? []), recording],
1641
+ events: [
1642
+ ...(current.events ?? []),
1643
+ { type: "record-stop", at: stoppedAt, label: active?.label ?? "unknown", file: active?.file },
1644
+ ],
1645
+ };
1646
+ });
1647
+ console.log(`Recording stopped. Artifacts: ${path.join(sessionDir(name), "recordings")}`);
1648
+ return;
1649
+ }
1650
+
1651
+ throw new Error("Usage: proof-artifacts record start|stop");
1652
+ }
1653
+
1654
+ function cmdReport(input) {
1655
+ const { opts } = parseOptions(input);
1656
+ const name = safeName(opts.name);
1657
+ const report = generateReport(name);
1658
+ console.log(report.reportHtml);
1659
+ console.log(report.reportJson);
1660
+ }
1661
+
1662
+ function cmdExec(input) {
1663
+ const { opts, rest } = parseOptions(input);
1664
+ const name = safeName(opts.name);
1665
+ const useStdin = isTruthy(opts.stdin);
1666
+ const commandToRun = rest;
1667
+ if (commandToRun.length === 0) throw new Error("Missing command. Usage: proof-artifacts exec [--stdin] -- <command...>");
1668
+ requireRunning(name);
1669
+ const result = dockerTry([
1670
+ "exec",
1671
+ ...(useStdin ? ["-i"] : []),
1672
+ "-e", "DISPLAY=:99",
1673
+ "-w", "/workspace",
1674
+ containerName(name),
1675
+ ...commandToRun,
1676
+ ], { stdio: "inherit" });
1677
+ if (result.status !== 0) {
1678
+ addEvent(name, "exec", {
1679
+ command: commandToRun.join(" "),
1680
+ stdin: useStdin,
1681
+ status: result.status,
1682
+ signal: result.signal,
1683
+ failedAt: new Date().toISOString(),
1684
+ });
1685
+ throw new Error(`Command failed in session "${name}": ${commandToRun.join(" ")}`);
1686
+ }
1687
+ addEvent(name, "exec", { command: commandToRun.join(" "), stdin: useStdin, status: 0 });
1688
+ }
1689
+
1690
+ function cmdArtifacts(input) {
1691
+ const { opts } = parseOptions(input);
1692
+ const name = safeName(opts.name);
1693
+ console.log(sessionDir(name));
1694
+ }
1695
+
1696
+ function cmdList(input) {
1697
+ const { opts } = parseOptions(input);
1698
+ const sessions = collectSessions().map((session) => {
1699
+ const summary = sessionStatusSummary(session.name, session.dir);
1700
+ return redactSensitive({
1701
+ source: session.source,
1702
+ ...summary,
1703
+ events: undefined,
1704
+ });
1705
+ });
1706
+ if (isTruthy(opts.json)) {
1707
+ console.log(JSON.stringify(sessions, null, 2));
1708
+ return;
1709
+ }
1710
+ if (sessions.length === 0) {
1711
+ console.log("No proof-artifacts sessions found.");
1712
+ return;
1713
+ }
1714
+ for (const session of sessions) {
1715
+ const counts = session.counts ?? { screenshots: 0, recordings: 0, logs: 0 };
1716
+ console.log(`${session.name} [${session.state}] ${session.source}`);
1717
+ console.log(` artifacts: ${session.artifacts}${session.artifactsExist ? "" : " (missing)"}`);
1718
+ console.log(` project: ${session.project ?? "unknown"}`);
1719
+ console.log(` noVNC: ${session.noVncUrl ?? "unknown"}${session.noVncReachable ? " (reachable)" : ""}`);
1720
+ console.log(` captured: ${counts.screenshots} screenshot(s), ${counts.recordings} recording(s), ${counts.logs} log(s)`);
1721
+ if (session.startedAt) console.log(` started: ${session.startedAt}`);
1722
+ if (session.stoppedAt) console.log(` stopped: ${session.stoppedAt}`);
1723
+ }
1724
+ }
1725
+
1726
+ function cmdStatus(input) {
1727
+ const { opts } = parseOptions(input);
1728
+ const name = safeName(opts.name);
1729
+ const dir = sessionDir(name);
1730
+ const meta = readJsonFile(path.join(dir, "manifest.json")) ?? readJsonFile(path.join(dir, "session.json"));
1731
+ const status = sessionStatusSummary(name, dir);
1732
+ if (!meta?.name && (status.state === "missing" || status.state === "unknown")) {
1733
+ throw new Error(`Session "${name}" was not found.`);
1734
+ }
1735
+ const output = redactSensitive(status);
1736
+ if (isTruthy(opts.json)) {
1737
+ console.log(JSON.stringify(output, null, 2));
1738
+ return;
1739
+ }
1740
+ console.log(`${output.name} [${output.state}]${output.managed ? " managed" : ""}`);
1741
+ console.log(`Artifacts: ${output.artifacts}${output.artifactsExist ? "" : " (missing)"}`);
1742
+ console.log(`Project: ${output.project ?? "unknown"}`);
1743
+ console.log(`Network: ${output.network ?? "unknown"}`);
1744
+ console.log(`Target URL: ${output.targetUrl ?? "none"}`);
1745
+ console.log(`noVNC: ${output.noVncUrl ?? "unknown"}${output.noVncReachable ? " (reachable)" : ""}`);
1746
+ console.log(`Started: ${output.startedAt ?? "unknown"}`);
1747
+ console.log(`Stopped: ${output.stoppedAt ?? "not recorded"}`);
1748
+ console.log(`Elapsed: ${formatDuration(output.durationMs)}`);
1749
+ console.log(`Artifacts captured: ${output.counts.screenshots} screenshot(s), ${output.counts.recordings} recording(s), ${output.counts.logs} log(s)`);
1750
+ if (output.activeRecording) {
1751
+ console.log(`Active recording: ${output.activeRecording.label ?? "unknown"}`);
1752
+ }
1753
+ const recentEvents = (output.events ?? []).slice(-5);
1754
+ if (recentEvents.length > 0) {
1755
+ console.log("Recent events:");
1756
+ for (const event of recentEvents) {
1757
+ console.log(`- ${event.at ?? ""} ${event.type ?? "event"}`);
1758
+ }
1759
+ }
1760
+ }
1761
+
1762
+ function managedContainerNames() {
1763
+ const result = dockerTry(["ps", "-a", "--filter", "label=proof-artifacts.managed=true", "--format", "{{.Names}}"]);
1764
+ if (result.status !== 0) throw new Error(result.stderr.trim() || "Could not list managed proof-artifacts containers.");
1765
+ return result.stdout.split("\n").map((line) => line.trim()).filter(Boolean);
1766
+ }
1767
+
1768
+ function managedSessionNameForContainer(name) {
1769
+ const result = dockerTry([
1770
+ "inspect",
1771
+ "--format",
1772
+ "{{ index .Config.Labels \"proof-artifacts.session\" }}",
1773
+ name,
1774
+ ]);
1775
+ return result.status === 0 && result.stdout.trim() ? safeName(result.stdout.trim()) : safeName(name.replace(/^proof-artifacts-/, ""));
1776
+ }
1777
+
1778
+ function cmdStop(input) {
1779
+ const { opts } = parseOptions(input);
1780
+ if (isTruthy(opts.all)) {
1781
+ const containers = managedContainerNames();
1782
+ if (containers.length === 0) {
1783
+ console.log("No managed proof-artifacts containers are running or stopped.");
1784
+ return;
1785
+ }
1786
+ for (const name of containers) {
1787
+ const sessionName = managedSessionNameForContainer(name);
1788
+ const artifactDir = managedContainerArtifactDir(sessionName);
1789
+ docker(["rm", "-f", name]);
1790
+ const updateMeta = artifactDir && existsSync(artifactDir)
1791
+ ? (updater) => updateSessionMetaInDir(sessionName, artifactDir, updater)
1792
+ : (updater) => updateSessionMeta(sessionName, updater);
1793
+ updateMeta((current) => ({
1794
+ ...current,
1795
+ stoppedAt: new Date().toISOString(),
1796
+ events: [
1797
+ ...(current.events ?? []),
1798
+ { type: "stop", at: new Date().toISOString(), container: name, all: true },
1799
+ ],
1800
+ }));
1801
+ console.log(`Stopped managed desktop session "${sessionName}" (${name}).`);
1802
+ }
1803
+ console.log("Artifacts were kept.");
1804
+ return;
1805
+ }
1806
+
1807
+ const name = safeName(opts.name);
1808
+ const status = containerStatus(name, sessionDir(name));
1809
+ if (status.state === "conflict") {
1810
+ throw new Error(`Session "${name}" has a same-name container for different artifacts: ${status.containerArtifacts}. Refusing to remove it from this project.`);
1811
+ }
1812
+ if (status.state !== "missing" && status.state !== "unknown") {
1813
+ if (!status.managed) {
1814
+ throw new Error(`Container ${containerName(name)} exists but is not managed by proof-artifacts. Refusing to remove it.`);
1815
+ }
1816
+ docker(["rm", "-f", containerName(name)]);
1817
+ updateSessionMeta(name, (current) => ({
1818
+ ...current,
1819
+ stoppedAt: new Date().toISOString(),
1820
+ events: [
1821
+ ...(current.events ?? []),
1822
+ { type: "stop", at: new Date().toISOString(), container: containerName(name) },
1823
+ ],
1824
+ }));
1825
+ console.log(`Stopped desktop session "${name}".`);
1826
+ } else {
1827
+ const meta = readSessionMeta(name);
1828
+ if (meta.name) {
1829
+ updateSessionMeta(name, (current) => ({
1830
+ ...current,
1831
+ stoppedAt: new Date().toISOString(),
1832
+ events: [
1833
+ ...(current.events ?? []),
1834
+ { type: "stop", at: new Date().toISOString(), container: containerName(name), alreadyMissing: true },
1835
+ ],
1836
+ }));
1837
+ }
1838
+ console.log(`Session "${name}" is not running.`);
1839
+ }
1840
+ }
1841
+
1842
+ function cmdCleanup(input) {
1843
+ const { opts } = parseOptions(input);
1844
+ const dryRun = isTruthy(opts["dry-run"]);
1845
+ const removeAll = isTruthy(opts.all);
1846
+ const deleteArtifacts = isTruthy(opts["delete-artifacts"]);
1847
+ const maxAgeHours = Number.parseFloat(opts["max-age-hours"] ?? "24");
1848
+ if (!removeAll && (!Number.isFinite(maxAgeHours) || maxAgeHours < 0)) {
1849
+ throw new Error("--max-age-hours must be a non-negative number.");
1850
+ }
1851
+ const cutoff = Date.now() - maxAgeHours * 60 * 60 * 1000;
1852
+ let removed = 0;
1853
+ for (const session of collectSessions()) {
1854
+ const { name, dir, source } = session;
1855
+ if (!existsSync(dir)) continue;
1856
+ const meta = readJsonFile(path.join(dir, "manifest.json")) ?? readJsonFile(path.join(dir, "session.json")) ?? {};
1857
+ const status = containerStatus(name, dir);
1858
+ if (!removeAll && (status.state === "running" || status.state === "unknown" || status.state === "conflict" || meta.activeRecording)) continue;
1859
+ const referenceTime = Date.parse(meta.stoppedAt ?? meta.startedAt ?? statSync(dir).mtime.toISOString());
1860
+ const expired = removeAll || referenceTime < cutoff;
1861
+ if (!expired) continue;
1862
+
1863
+ const action = dryRun ? "Would clean" : "Cleaning";
1864
+ console.log(`${action} ${source} session "${name}" (${dir})`);
1865
+ if (!dryRun) {
1866
+ if (source.includes("global") && managedContainerExists(name)) {
1867
+ docker(["rm", "-f", containerName(name)], { stdio: "ignore" });
1868
+ }
1869
+ if (deleteArtifacts) {
1870
+ if (!safeArtifactDeleteDir(dir, name)) {
1871
+ throw new Error(`Refusing to delete artifacts for "${name}" because ${dir} is not a verified session artifact directory.`);
1872
+ }
1873
+ rmSync(dir, { recursive: true, force: true });
1874
+ if (source.includes("global")) {
1875
+ rmSync(sessionIndexPath(name), { force: true });
1876
+ }
1877
+ }
1878
+ }
1879
+ removed += 1;
1880
+ }
1881
+ const artifactNote = deleteArtifacts ? "containers and artifacts" : "containers only; artifacts kept";
1882
+ console.log(`${dryRun ? "Matched" : "Cleaned"} ${removed} session(s) (${artifactNote}).`);
1883
+ }
1884
+
1885
+ function cmdSmoke(input) {
1886
+ const { opts } = parseOptions(input);
1887
+ const name = safeName(opts.name ?? `smoke-${timestamp()}`);
1888
+ const defaultSmokePage = "<!doctype html><title>Proof Artifacts Smoke</title><style>body{font:32px system-ui;margin:48px;background:#f7f0df;color:#18130a}button{font:inherit;padding:16px 22px}</style><h1>Proof Artifacts Smoke</h1><p id=status>ready</p><button onclick=\"status.textContent='clicked '+new Date().toISOString()\">Click me</button>";
1889
+ const url = opts.url ?? `data:text/html,${encodeURIComponent(defaultSmokePage)}`;
1890
+ const project = path.resolve(opts.project ?? process.cwd());
1891
+ const keep = isTruthy(opts.keep);
1892
+ const startArgs = [
1893
+ "--name", name,
1894
+ "--project", project,
1895
+ "--width", String(opts.width ?? defaultWidth),
1896
+ "--height", String(opts.height ?? defaultHeight),
1897
+ ];
1898
+ if (opts["novnc-port"]) startArgs.push("--novnc-port", String(opts["novnc-port"]));
1899
+ if (opts.url) startArgs.push("--url", String(opts.url));
1900
+ if (opts.network) startArgs.push("--network", String(opts.network));
1901
+ if (opts["vnc-port"]) startArgs.push("--vnc-port", String(opts["vnc-port"]));
1902
+ if (opts.image) startArgs.push("--image", String(opts.image));
1903
+ if (opts.pull) startArgs.push("--pull");
1904
+ if (opts["no-build"]) startArgs.push("--no-build");
1905
+ if (opts.rebuild) startArgs.push("--rebuild");
1906
+
1907
+ let smokePhase = "init";
1908
+ try {
1909
+ smokePhase = "doctor";
1910
+ console.log("Smoke: checking Docker...");
1911
+ cmdDoctor();
1912
+ smokePhase = "start";
1913
+ console.log("Smoke: starting desktop...");
1914
+ cmdStart(startArgs);
1915
+ smokePhase = "novnc";
1916
+ const meta = readSessionMeta(name);
1917
+ if (meta.noVncUrl && waitForHttp(meta.noVncUrl, 15000)) {
1918
+ addEvent(name, "smoke-novnc-ready", { url: meta.noVncUrl });
1919
+ console.log(`Smoke: noVNC responded at ${meta.noVncUrl}`);
1920
+ } else {
1921
+ throw new Error(`noVNC did not respond at ${meta.noVncUrl}`);
1922
+ }
1923
+ smokePhase = "open";
1924
+ console.log("Smoke: opening browser...");
1925
+ cmdOpen([url, "--name", name]);
1926
+ wait(1000);
1927
+ smokePhase = "screenshot";
1928
+ console.log("Smoke: taking screenshot...");
1929
+ cmdScreenshot(["--name", name, "--label", "smoke-page"]);
1930
+ smokePhase = "record";
1931
+ console.log("Smoke: recording a short interaction...");
1932
+ cmdRecord(["start", "--name", name, "--label", "smoke-recording"]);
1933
+ smokePhase = "interaction";
1934
+ docker([
1935
+ "exec",
1936
+ "-e", "DISPLAY=:99",
1937
+ containerName(name),
1938
+ "sh",
1939
+ "-lc",
1940
+ "xdotool mousemove 120 120 click 1 mousemove 420 260 key Tab key Tab >/tmp/smoke-interaction.log 2>&1",
1941
+ ]);
1942
+ wait(1500);
1943
+ smokePhase = "record-stop";
1944
+ cmdRecord(["stop", "--name", name]);
1945
+ const artifactRoot = sessionDir(name);
1946
+ smokePhase = "validate-artifacts";
1947
+ assertFileMinSize(path.join(artifactRoot, "screenshots", "smoke-page.png"), 1000, "Smoke screenshot");
1948
+ assertFileMinSize(path.join(artifactRoot, "recordings", "smoke-recording.mp4"), 1000, "Smoke recording");
1949
+ addEvent(name, "smoke-pass");
1950
+ const report = generateReport(name);
1951
+ console.log("Smoke passed.");
1952
+ console.log(report.reportHtml);
1953
+ } catch (error) {
1954
+ if (existsSync(manifestPath(name))) {
1955
+ try {
1956
+ addEvent(name, "smoke-fail", {
1957
+ phase: smokePhase,
1958
+ error: error.message,
1959
+ status: 1,
1960
+ failedAt: new Date().toISOString(),
1961
+ });
1962
+ } catch {
1963
+ // Preserve the original smoke failure; failure logging is best-effort.
1964
+ }
1965
+ }
1966
+ throw error;
1967
+ } finally {
1968
+ if (!keep && containerExists(name)) {
1969
+ cmdStop(["--name", name]);
1970
+ }
1971
+ if (existsSync(manifestPath(name))) {
1972
+ generateReport(name);
1973
+ }
1974
+ }
1975
+ }
1976
+
1977
+ function cmdInstallSkill() {
1978
+ const source = path.join(rootDir, "skills", "proof-artifacts");
1979
+ const target = path.join(homedir(), ".codex", "skills", "proof-artifacts");
1980
+ rmSync(target, { recursive: true, force: true });
1981
+ mkdirSync(path.dirname(target), { recursive: true });
1982
+ cpSync(source, target, { recursive: true });
1983
+ console.log(`Installed Codex skill: ${target}`);
1984
+ }
1985
+
1986
+ try {
1987
+ switch (command) {
1988
+ case "help":
1989
+ case "--help":
1990
+ case "-h":
1991
+ printHelp();
1992
+ break;
1993
+ case "doctor":
1994
+ cmdDoctor(args.slice(1));
1995
+ break;
1996
+ case "detect":
1997
+ cmdDetect(args.slice(1));
1998
+ break;
1999
+ case "smoke":
2000
+ cmdSmoke(args.slice(1));
2001
+ break;
2002
+ case "start":
2003
+ cmdStart(args.slice(1));
2004
+ break;
2005
+ case "launch":
2006
+ cmdLaunch(args.slice(1));
2007
+ break;
2008
+ case "open":
2009
+ cmdOpen(args.slice(1));
2010
+ break;
2011
+ case "screenshot":
2012
+ cmdScreenshot(args.slice(1));
2013
+ break;
2014
+ case "record":
2015
+ cmdRecord(args.slice(1));
2016
+ break;
2017
+ case "report":
2018
+ cmdReport(args.slice(1));
2019
+ break;
2020
+ case "list":
2021
+ cmdList(args.slice(1));
2022
+ break;
2023
+ case "status":
2024
+ cmdStatus(args.slice(1));
2025
+ break;
2026
+ case "exec":
2027
+ cmdExec(args.slice(1));
2028
+ break;
2029
+ case "artifacts":
2030
+ cmdArtifacts(args.slice(1));
2031
+ break;
2032
+ case "cleanup":
2033
+ cmdCleanup(args.slice(1));
2034
+ break;
2035
+ case "stop":
2036
+ cmdStop(args.slice(1));
2037
+ break;
2038
+ case "install-skill":
2039
+ case "install-codex-skill":
2040
+ cmdInstallSkill();
2041
+ break;
2042
+ default:
2043
+ printHelp();
2044
+ process.exitCode = 1;
2045
+ }
2046
+ } catch (error) {
2047
+ console.error(error.message);
2048
+ process.exitCode = 1;
2049
+ }