@kadj-amoah/showrunner 1.1.0 → 1.1.2

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/dist/cli.js CHANGED
@@ -687,7 +687,7 @@ var HEADER = `// scripts/playwright_demo.ts
687
687
  function renderPlaywrightSpec(inputs) {
688
688
  const { manifest, recording, targetUrl, videoDir, traceDir } = inputs;
689
689
  const launchLines = [
690
- `import { chromium, firefox, webkit, expect, type BrowserContext } from 'playwright';`,
690
+ `import { chromium, firefox, webkit, type BrowserContext } from 'playwright-core';`,
691
691
  `import { mkdirSync } from 'node:fs';`,
692
692
  ``,
693
693
  `const VIDEO_DIR = ${JSON.stringify(videoDir)};`,
@@ -774,7 +774,7 @@ function renderAction(action) {
774
774
  case "wait":
775
775
  return `await page.waitForTimeout(${action.ms});`;
776
776
  case "assert_visible":
777
- return `await expect(page.locator(${JSON.stringify(flattenSelector(action.selector))}).first()).toBeVisible();`;
777
+ return `await page.locator(${JSON.stringify(flattenSelector(action.selector))}).first().waitFor({ state: 'visible' });`;
778
778
  case "press":
779
779
  return action.selector ? `await page.locator(${JSON.stringify(flattenSelector(action.selector))}).first().press(${JSON.stringify(action.key)});` : `await page.keyboard.press(${JSON.stringify(action.key)});`;
780
780
  case "custom":
@@ -1229,7 +1229,7 @@ async function generateManifest(opts) {
1229
1229
  }
1230
1230
 
1231
1231
  // src/scriptGen/domPreflight.ts
1232
- import { chromium, firefox, webkit } from "playwright";
1232
+ import { chromium, firefox, webkit } from "playwright-core";
1233
1233
 
1234
1234
  // src/recording/selectorHeuristic.ts
1235
1235
  var SELECTOR_FOR_FN_SOURCE = `
@@ -1318,7 +1318,7 @@ async function scrapeSelectorInventory(opts) {
1318
1318
  try {
1319
1319
  await page.waitForLoadState("networkidle", { timeout: networkIdleMs });
1320
1320
  } catch {
1321
- logger.debug("domPreflight: networkidle did not fire within budget \u2014 proceeding");
1321
+ logger.debug("domPreflight: networkidle did not fire within budget \xE2\u20AC\u201D proceeding");
1322
1322
  }
1323
1323
  const items = await page.evaluate(
1324
1324
  buildScrapeScript(SELECTOR_FOR_FN_SOURCE, maxItems)
@@ -2219,7 +2219,7 @@ import {
2219
2219
  chromium as chromium2,
2220
2220
  firefox as firefox2,
2221
2221
  webkit as webkit2
2222
- } from "playwright";
2222
+ } from "playwright-core";
2223
2223
 
2224
2224
  // src/manifest/alignment.ts
2225
2225
  import { readFile as readFile6 } from "fs/promises";
@@ -2330,7 +2330,7 @@ async function buildStorageState(cookiesFile, localStorageFile, configDir) {
2330
2330
  cookiesJson.origins = [...cookiesJson.origins ?? [], ...lsJson];
2331
2331
  } else {
2332
2332
  logger.warn(
2333
- `local_storage_file at ${lsAbs} is not a Playwright origins list \u2014 skipping. Use storageState() output format.`
2333
+ `local_storage_file at ${lsAbs} is not a Playwright origins list \xE2\u20AC\u201D skipping. Use storageState() output format.`
2334
2334
  );
2335
2335
  }
2336
2336
  } catch (err) {
@@ -2371,7 +2371,7 @@ function buildFormFill(auth) {
2371
2371
  } catch (err) {
2372
2372
  const cause = err instanceof Error ? err.message : String(err);
2373
2373
  throw new AuthError(
2374
- `Form auth: did not reach ${auth.success_url_pattern} within ${auth.timeout_ms}ms \u2014 ${cause}`
2374
+ `Form auth: did not reach ${auth.success_url_pattern} within ${auth.timeout_ms}ms \xE2\u20AC\u201D ${cause}`
2375
2375
  );
2376
2376
  }
2377
2377
  };
@@ -2380,7 +2380,7 @@ function buildFormFill(auth) {
2380
2380
  // src/recording/actions.ts
2381
2381
  import { mkdir as mkdir5 } from "fs/promises";
2382
2382
  import { dirname as dirname5, join as join2 } from "path";
2383
- import { errors as playwrightErrors } from "playwright";
2383
+ import { errors as playwrightErrors } from "playwright-core";
2384
2384
 
2385
2385
  // src/recording/cursorOverlay.ts
2386
2386
  var CURSOR_INIT_SCRIPT = `
@@ -2454,7 +2454,7 @@ var CURSOR_INIT_SCRIPT = `
2454
2454
 
2455
2455
  if (tryInstall()) return;
2456
2456
 
2457
- console.log('[showrunner-cursor] deferring \u2014 body not ready');
2457
+ console.log('[showrunner-cursor] deferring \xE2\u20AC\u201D body not ready');
2458
2458
 
2459
2459
  const tick = () => { if (tryInstall()) cleanup(); };
2460
2460
  const observer = new MutationObserver(tick);
@@ -2738,7 +2738,7 @@ async function recordDemo(opts) {
2738
2738
  const tFirstSegmentStart = performance.now();
2739
2739
  const preSegmentOffsetSec = (tFirstSegmentStart - tRecordingStart) / 1e3;
2740
2740
  logger.info(
2741
- `pre-segment recording offset: ${preSegmentOffsetSec.toFixed(2)}s (page.goto + setup) \u2014 slice_plan will be offset by this amount`
2741
+ `pre-segment recording offset: ${preSegmentOffsetSec.toFixed(2)}s (page.goto + setup) \xE2\u20AC\u201D slice_plan will be offset by this amount`
2742
2742
  );
2743
2743
  const t0 = tFirstSegmentStart;
2744
2744
  let cursorPos = {
@@ -2817,7 +2817,7 @@ async function recordDemo(opts) {
2817
2817
  );
2818
2818
  if (adjusted < motionMs) {
2819
2819
  warnings.push(
2820
- `cursor motion for action at ${arrivalAt}s clamped from ${Math.round(motionMs)}ms to ${adjusted}ms \u2014 manifest timing tight`
2820
+ `cursor motion for action at ${arrivalAt}s clamped from ${Math.round(motionMs)}ms to ${adjusted}ms \xE2\u20AC\u201D manifest timing tight`
2821
2821
  );
2822
2822
  }
2823
2823
  motionMs = adjusted;
@@ -2865,14 +2865,14 @@ async function recordDemo(opts) {
2865
2865
  if (outcome.status === "skipped") {
2866
2866
  warnings.push(`${action.type}: ${outcome.reason}`);
2867
2867
  if (outcome.screenshot) failureScreenshots.push(outcome.screenshot);
2868
- logger.warn(`segment ${seg.id} \u2014 ${action.type} skipped`, {
2868
+ logger.warn(`segment ${seg.id} \xE2\u20AC\u201D ${action.type} skipped`, {
2869
2869
  reason: outcome.reason,
2870
2870
  screenshot: outcome.screenshot
2871
2871
  });
2872
2872
  } else if (outcome.status === "segment_failed") {
2873
2873
  failure = outcome.reason;
2874
2874
  if (outcome.screenshot) failureScreenshots.push(outcome.screenshot);
2875
- logger.error(`segment ${seg.id} \u2014 failed`, {
2875
+ logger.error(`segment ${seg.id} \xE2\u20AC\u201D failed`, {
2876
2876
  reason: outcome.reason,
2877
2877
  screenshot: outcome.screenshot
2878
2878
  });
@@ -2889,7 +2889,7 @@ async function recordDemo(opts) {
2889
2889
  await page.waitForTimeout(Math.round(remaining * 1e3));
2890
2890
  } else if (remaining < -0.5) {
2891
2891
  warnings.push(
2892
- `segment took ${elapsedInSegment.toFixed(2)}s but only ${allocated.toFixed(2)}s allocated \u2014 manifest timing tight for this segment`
2892
+ `segment took ${elapsedInSegment.toFixed(2)}s but only ${allocated.toFixed(2)}s allocated \xE2\u20AC\u201D manifest timing tight for this segment`
2893
2893
  );
2894
2894
  }
2895
2895
  const segEnd = (performance.now() - t0) / 1e3;
@@ -2924,7 +2924,7 @@ async function recordDemo(opts) {
2924
2924
  }
2925
2925
  recordingPath = dest;
2926
2926
  } else {
2927
- logger.warn("Recording context did not produce a video file \u2014 recordVideo may have been ignored");
2927
+ logger.warn("Recording context did not produce a video file \xE2\u20AC\u201D recordVideo may have been ignored");
2928
2928
  }
2929
2929
  return {
2930
2930
  recording_path: recordingPath,
@@ -2958,10 +2958,10 @@ async function resolveStorageState(browser, recording, authPlan) {
2958
2958
  var PreflightError = class extends Error {
2959
2959
  constructor(failures) {
2960
2960
  const lines = failures.map(
2961
- (f) => ` - ${f.segment}#${f.actionIndex} \u2192 [${f.selectors.join(" | ")}]`
2961
+ (f) => ` - ${f.segment}#${f.actionIndex} \xE2\u2020\u2019 [${f.selectors.join(" | ")}]`
2962
2962
  );
2963
2963
  super(
2964
- `Pre-flight check failed \u2014 ${failures.length} selector(s) did not resolve on ${failures[0]?.segment ? `the live target page` : "the page"}:
2964
+ `Pre-flight check failed \xE2\u20AC\u201D ${failures.length} selector(s) did not resolve on ${failures[0]?.segment ? `the live target page` : "the page"}:
2965
2965
  ${lines.join("\n")}
2966
2966
 
2967
2967
  Fix the manifest selectors, or set recording.preflight: false to skip this check.`
@@ -5013,6 +5013,16 @@ function resolveMaybe(p, configDir) {
5013
5013
  var STAGE_NAMES = ["comprehension", "script", "voiceover", "record", "mux"];
5014
5014
 
5015
5015
  // src/pipeline/run.ts
5016
+ var PipelineStageError = class extends Error {
5017
+ constructor(stage, message, cause) {
5018
+ super(message);
5019
+ this.stage = stage;
5020
+ this.cause = cause;
5021
+ }
5022
+ stage;
5023
+ cause;
5024
+ name = "PipelineStageError";
5025
+ };
5016
5026
  var STAGES_BY_NAME = {
5017
5027
  comprehension: comprehensionStage,
5018
5028
  script: scriptStage,
@@ -5052,7 +5062,14 @@ async function run(loaded, options = {}) {
5052
5062
  continue;
5053
5063
  }
5054
5064
  const stage = STAGES_BY_NAME[name];
5055
- const result = await stage.run(ctx);
5065
+ let result;
5066
+ try {
5067
+ result = await stage.run(ctx);
5068
+ } catch (err) {
5069
+ const message = err instanceof Error ? err.message : String(err);
5070
+ logger.event({ stage: name, status: "failed", error: message });
5071
+ throw new PipelineStageError(name, message, err);
5072
+ }
5056
5073
  results.push(result);
5057
5074
  logger.event({
5058
5075
  stage: name,
@@ -5117,10 +5134,11 @@ async function writeBuildManifest(path, payload) {
5117
5134
 
5118
5135
  // src/commands/doctor.ts
5119
5136
  import { spawn as spawn3 } from "child_process";
5120
- import { stat as stat9, access, constants } from "fs/promises";
5121
- import { isAbsolute as isAbsolute7, resolve as resolve13, dirname as dirname9 } from "path";
5137
+ import { stat as stat9, access, constants, readdir } from "fs/promises";
5138
+ import { isAbsolute as isAbsolute7, resolve as resolve13, dirname as dirname9, join as join9 } from "path";
5139
+ import { homedir, platform as osPlatform } from "os";
5122
5140
  import { request as undiciRequest } from "undici";
5123
- import { chromium as chromium3, firefox as firefox3, webkit as webkit3 } from "playwright";
5141
+ import { chromium as chromium3, firefox as firefox3, webkit as webkit3 } from "playwright-core";
5124
5142
 
5125
5143
  // src/config/providerEnv.ts
5126
5144
  function inspectProviderEnv(config) {
@@ -5188,6 +5206,7 @@ async function doctorCommand(opts) {
5188
5206
  for (const r of results) printRow(r);
5189
5207
  const summary = summarize(results);
5190
5208
  if (summary.fail > 0) {
5209
+ printFixOrder(results);
5191
5210
  logger.error(`doctor: ${summary.fail} FAIL, ${summary.warn} WARN, ${summary.pass} PASS`);
5192
5211
  } else if (summary.warn > 0) {
5193
5212
  logger.warn(`doctor: ${summary.warn} WARN, ${summary.pass} PASS`);
@@ -5213,10 +5232,18 @@ async function runDoctorChecks(configPath) {
5213
5232
  const { config, configDir } = loaded;
5214
5233
  for (const row of inspectProviderEnv(config)) {
5215
5234
  const suffix = row.stage ? ` (${row.stage} override)` : "";
5216
- results.push({
5217
- status: row.set ? "PASS" : "FAIL",
5218
- label: `${row.slot} provider=${row.provider}${suffix}, ${row.envVar} ${row.set ? "set" : "NOT set"}`
5219
- });
5235
+ if (row.set) {
5236
+ results.push({
5237
+ status: "PASS",
5238
+ label: `${row.slot} provider=${row.provider}${suffix}, ${row.envVar} set`
5239
+ });
5240
+ } else {
5241
+ results.push({
5242
+ status: "FAIL",
5243
+ label: `${row.slot} provider=${row.provider}${suffix}, ${row.envVar} NOT set`,
5244
+ detail: providerEnvHint(row.slot, row.provider, row.envVar)
5245
+ });
5246
+ }
5220
5247
  }
5221
5248
  results.push(await checkBinary("ffmpeg", ["-version"]));
5222
5249
  results.push(await checkBinary("ffprobe", ["-version"]));
@@ -5271,9 +5298,20 @@ function summarize(results) {
5271
5298
  fail: results.filter((r) => r.status === "FAIL").length
5272
5299
  };
5273
5300
  }
5301
+ function printFixOrder(results) {
5302
+ const fails = results.filter((r) => r.status === "FAIL");
5303
+ if (fails.length === 0) return;
5304
+ process.stdout.write("\n");
5305
+ logger.info("To fix:");
5306
+ fails.forEach((r, i) => {
5307
+ const hint = r.detail ? ` \u2014 ${r.detail}` : "";
5308
+ logger.info(` ${i + 1}. ${r.label}${hint}`);
5309
+ });
5310
+ process.stdout.write("\n");
5311
+ }
5274
5312
  function printRow(r) {
5275
5313
  const tag = `[${r.status}]`;
5276
- const line = `${tag} ${r.label}${r.detail ? ` \u2014 ${r.detail}` : ""}`;
5314
+ const line = `${tag} ${r.label}${r.detail ? ` \xE2\u20AC\u201D ${r.detail}` : ""}`;
5277
5315
  if (r.status === "FAIL") logger.error(line);
5278
5316
  else if (r.status === "WARN") logger.warn(line);
5279
5317
  else logger.info(line);
@@ -5286,18 +5324,44 @@ async function checkBinary(name, args) {
5286
5324
  stdout += chunk.toString("utf8");
5287
5325
  });
5288
5326
  child.on("error", () => {
5289
- resolve27({ status: "FAIL", label: `${name} not on PATH` });
5327
+ resolve27({
5328
+ status: "FAIL",
5329
+ label: `${name} not on PATH`,
5330
+ detail: installHintFor(name)
5331
+ });
5290
5332
  });
5291
5333
  child.on("exit", (code) => {
5292
5334
  if (code === 0) {
5293
5335
  const firstLine = stdout.split("\n")[0]?.trim() ?? "";
5294
5336
  resolve27({ status: "PASS", label: `${name} present`, detail: firstLine });
5295
5337
  } else {
5296
- resolve27({ status: "FAIL", label: `${name} exited with code ${code}` });
5338
+ resolve27({
5339
+ status: "FAIL",
5340
+ label: `${name} exited with code ${code}`,
5341
+ detail: installHintFor(name)
5342
+ });
5297
5343
  }
5298
5344
  });
5299
5345
  });
5300
5346
  }
5347
+ function providerEnvHint(slot, provider, envVar) {
5348
+ const dashboards = {
5349
+ anthropic: "https://console.anthropic.com/settings/keys",
5350
+ openai: "https://platform.openai.com/api-keys",
5351
+ elevenlabs: "https://elevenlabs.io/app/settings/api-keys"
5352
+ };
5353
+ const dash = dashboards[provider];
5354
+ const dashHint = dash ? ` (get a key from ${dash})` : "";
5355
+ const altHint = slot === "llm" ? ` \u2014 or switch llm.default.provider to "agent_bridge" in demo.yaml to use a local CLI agent (no API key needed)` : "";
5356
+ return `add ${envVar}=... to your project's .env file${dashHint}${altHint}`;
5357
+ }
5358
+ function installHintFor(binary) {
5359
+ const p = osPlatform();
5360
+ if (binary !== "ffmpeg" && binary !== "ffprobe") return "";
5361
+ if (p === "darwin") return "install via `brew install ffmpeg`";
5362
+ if (p === "win32") return "install via `winget install Gyan.FFmpeg` or `choco install ffmpeg`";
5363
+ return "install via `apt install ffmpeg` (Debian/Ubuntu), `pacman -S ffmpeg` (Arch), or `dnf install ffmpeg` (Fedora)";
5364
+ }
5301
5365
  async function checkPlaywrightBrowser(config) {
5302
5366
  const browserName = config.recording.browser;
5303
5367
  try {
@@ -5309,13 +5373,48 @@ async function checkPlaywrightBrowser(config) {
5309
5373
  detail: exec
5310
5374
  };
5311
5375
  } catch (err) {
5376
+ const sudoHit = await probeRootCache(browserName);
5377
+ const fallback = `run \`npx playwright install ${browserName}\``;
5378
+ if (sudoHit) {
5379
+ return {
5380
+ status: "FAIL",
5381
+ label: `playwright ${browserName} binary missing for current user, but found in root/admin cache`,
5382
+ detail: `${sudoHit} \u2014 re-run install WITHOUT sudo so the browser lands in your user cache: ${fallback}`
5383
+ };
5384
+ }
5312
5385
  return {
5313
5386
  status: "FAIL",
5314
5387
  label: `playwright ${browserName} binary missing`,
5315
- detail: `run \`npx playwright install ${browserName}\` (${err instanceof Error ? err.message : String(err)})`
5388
+ detail: `${fallback} (${err instanceof Error ? err.message : String(err)})`
5316
5389
  };
5317
5390
  }
5318
5391
  }
5392
+ async function probeRootCache(browserName) {
5393
+ const candidates = [];
5394
+ const home = homedir();
5395
+ const p = osPlatform();
5396
+ if (p === "linux") {
5397
+ candidates.push("/root/.cache/ms-playwright");
5398
+ } else if (p === "darwin") {
5399
+ candidates.push("/var/root/Library/Caches/ms-playwright");
5400
+ } else if (p === "win32") {
5401
+ const systemRoot = process.env["SystemRoot"] ?? "C:\\Windows";
5402
+ candidates.push(join9(systemRoot, "System32", "config", "systemprofile", "AppData", "Local", "ms-playwright"));
5403
+ }
5404
+ if (candidates.length === 0) return null;
5405
+ if (home === "/root" || home === "/var/root") return null;
5406
+ for (const dir of candidates) {
5407
+ try {
5408
+ const entries = await readdir(dir);
5409
+ const match = entries.find((e) => e.toLowerCase().startsWith(browserName.toLowerCase()));
5410
+ if (match) {
5411
+ return `found at ${join9(dir, match)}`;
5412
+ }
5413
+ } catch {
5414
+ }
5415
+ }
5416
+ return null;
5417
+ }
5319
5418
  async function checkTargetReachable(url) {
5320
5419
  try {
5321
5420
  const start = Date.now();
@@ -5336,10 +5435,12 @@ async function checkTargetReachable(url) {
5336
5435
  label: `target ${url} returned HTTP ${res.statusCode}`
5337
5436
  };
5338
5437
  } catch (err) {
5438
+ const reason = err instanceof Error ? err.message.trim() : String(err).trim();
5439
+ const prefix = reason ? `${reason} \u2014 ` : "";
5339
5440
  return {
5340
5441
  status: "FAIL",
5341
5442
  label: `target ${url} not reachable`,
5342
- detail: err instanceof Error ? err.message : String(err)
5443
+ detail: `${prefix}start your dev server on this URL, or change \`recording.target_url\` in demo.yaml`
5343
5444
  };
5344
5445
  }
5345
5446
  }
@@ -5347,7 +5448,11 @@ async function checkScript(label, abs) {
5347
5448
  try {
5348
5449
  await stat9(abs);
5349
5450
  } catch {
5350
- return { status: "FAIL", label: `${label} not found`, detail: abs };
5451
+ return {
5452
+ status: "FAIL",
5453
+ label: `${label} not found`,
5454
+ detail: `expected at ${abs} \u2014 re-scaffold (\`showrunner init\` writes these) or remove the entry from demo.yaml's recording.state block`
5455
+ };
5351
5456
  }
5352
5457
  if (process.platform !== "win32") {
5353
5458
  try {
@@ -5402,6 +5507,13 @@ function formatResolution(r) {
5402
5507
  }
5403
5508
 
5404
5509
  // src/commands/run.ts
5510
+ var STAGE_REMEDIATION = {
5511
+ comprehension: "Check the LLM provider env (or switch llm.default.provider to agent_bridge in demo.yaml). If you already have a product_model.json, skip this stage: --stages script,record,voiceover,mux",
5512
+ script: "Manifest generation failed. Inspect scripts/manifest.json if it was partially written. If the LLM is misbehaving, hand-author scripts/manifest.json and resume: --stages record,voiceover,mux",
5513
+ record: "Playwright recording failed. Inspect with `showrunner trace -c <config> --all` or `--segment <id>`. Demo a problem segment manually with `showrunner record-actions -c <config> --segment <id>`.",
5514
+ voiceover: "TTS synthesis or alignment failed. Check your TTS provider env. If alignment is the issue, set voiceover.alignment_strategy: best_effort in demo.yaml.",
5515
+ mux: "ffmpeg mux failed. Check the `doctor` row about free disk + RAM. Cap libx264 threads with `SHOWRUNNER_FFMPEG_THREADS=1` if RAM is tight."
5516
+ };
5405
5517
  async function runCommand2(opts) {
5406
5518
  let loaded;
5407
5519
  try {
@@ -5470,6 +5582,12 @@ async function runCommand2(opts) {
5470
5582
  buildManifest: result.buildManifestPath
5471
5583
  });
5472
5584
  } catch (err) {
5585
+ if (err instanceof PipelineStageError) {
5586
+ logger.error(`Pipeline failed in stage \`${err.stage}\`: ${err.message}`);
5587
+ const remediation = STAGE_REMEDIATION[err.stage];
5588
+ if (remediation) logger.error(`To fix: ${remediation}`);
5589
+ process.exit(1);
5590
+ }
5473
5591
  const message = err instanceof Error ? err.message : String(err);
5474
5592
  logger.error(`Pipeline failed: ${message}`);
5475
5593
  process.exit(1);
@@ -5478,7 +5596,7 @@ async function runCommand2(opts) {
5478
5596
 
5479
5597
  // src/commands/init.ts
5480
5598
  import { mkdir as mkdir11, writeFile as writeFile12, access as access2 } from "fs/promises";
5481
- import { dirname as dirname10, isAbsolute as isAbsolute8, join as join9, resolve as resolve15 } from "path";
5599
+ import { dirname as dirname10, isAbsolute as isAbsolute8, join as join10, resolve as resolve15 } from "path";
5482
5600
  var LLM_CHOICES = ["anthropic", "openai", "agent_bridge"];
5483
5601
  var TTS_CHOICES = ["elevenlabs", "openai", "custom"];
5484
5602
  var PLACEHOLDER_FILES = [
@@ -5504,7 +5622,7 @@ async function initCommand(opts) {
5504
5622
  resolution: resolvePreset(resolutionPreset)
5505
5623
  };
5506
5624
  const parent = isAbsolute8(opts.dir) ? opts.dir : resolve15(process.cwd(), opts.dir);
5507
- const projectRoot = join9(parent, opts.name);
5625
+ const projectRoot = join10(parent, opts.name);
5508
5626
  if (!opts.force && await pathExists(projectRoot)) {
5509
5627
  logger.error(
5510
5628
  `Directory already exists: ${projectRoot}. Pass --force to overwrite, or choose a different --name/--dir.`
@@ -5513,34 +5631,59 @@ async function initCommand(opts) {
5513
5631
  }
5514
5632
  await mkdir11(projectRoot, { recursive: true });
5515
5633
  for (const rel of PLACEHOLDER_FILES) {
5516
- const dest = join9(projectRoot, rel);
5634
+ const dest = join10(projectRoot, rel);
5517
5635
  await mkdir11(dirname10(dest), { recursive: true });
5518
5636
  await writeFile12(dest, "", "utf8");
5519
5637
  }
5520
- await writeFile12(join9(projectRoot, "demo.yaml"), demoYamlTemplate(resolved), "utf8");
5521
- await writeFile12(join9(projectRoot, ".env.example"), envExampleTemplate(resolved), "utf8");
5522
- await writeFile12(join9(projectRoot, ".gitignore"), gitignoreTemplate(), "utf8");
5523
- await mkdir11(join9(projectRoot, "docs"), { recursive: true });
5524
- await writeFile12(join9(projectRoot, "docs/PRD.md"), prdStubTemplate(resolved), "utf8");
5525
- await writeFile12(join9(projectRoot, "scripts/manifest.json"), starterManifest(resolved), "utf8");
5526
- await writeFile12(join9(projectRoot, "scripts/seed_demo_data.sh"), seedScript(), {
5638
+ await writeFile12(join10(projectRoot, "demo.yaml"), demoYamlTemplate(resolved), "utf8");
5639
+ await writeFile12(join10(projectRoot, ".env.example"), envExampleTemplate(resolved), "utf8");
5640
+ await writeFile12(join10(projectRoot, ".gitignore"), gitignoreTemplate(), "utf8");
5641
+ await mkdir11(join10(projectRoot, "docs"), { recursive: true });
5642
+ await writeFile12(join10(projectRoot, "docs/PRD.md"), prdStubTemplate(resolved), "utf8");
5643
+ await writeFile12(join10(projectRoot, "scripts/manifest.json"), starterManifest(resolved), "utf8");
5644
+ await writeFile12(join10(projectRoot, "scripts/seed_demo_data.sh"), seedScript(), {
5527
5645
  mode: 493
5528
5646
  });
5529
- await writeFile12(join9(projectRoot, "scripts/reset_demo_data.sh"), resetScript(), {
5647
+ await writeFile12(join10(projectRoot, "scripts/reset_demo_data.sh"), resetScript(), {
5530
5648
  mode: 493
5531
5649
  });
5532
- await writeFile12(join9(projectRoot, "scripts/teardown.sh"), teardownScript(), {
5650
+ await writeFile12(join10(projectRoot, "scripts/teardown.sh"), teardownScript(), {
5533
5651
  mode: 493
5534
5652
  });
5535
- await writeFile12(join9(projectRoot, "README.md"), readmeTemplate(resolved), "utf8");
5653
+ await writeFile12(join10(projectRoot, "README.md"), readmeTemplate(resolved), "utf8");
5536
5654
  logger.info(`Showrunner project scaffolded at ${projectRoot} (llm=${resolved.llm}, tts=${resolved.tts})`);
5537
- logger.info(`Next steps:
5538
- cd ${opts.name}
5539
- cp .env.example .env # fill in API keys
5540
- $EDITOR docs/PRD.md # replace the stub with your product brief
5541
- showrunner doctor --config demo.yaml # preflight checks
5542
- showrunner understand --config demo.yaml # build product_model.json
5543
- showrunner run --config demo.yaml # the full pipeline`);
5655
+ printNextSteps(opts.name, resolved);
5656
+ }
5657
+ function printNextSteps(projectName, resolved) {
5658
+ const envVars = requiredEnvVars(resolved);
5659
+ const lines = ["", `Next:`, ""];
5660
+ let step = 1;
5661
+ lines.push(` ${step++}. cd ${projectName}`);
5662
+ if (envVars.length > 0) {
5663
+ const keysList = envVars.join(", ");
5664
+ lines.push(` ${step++}. cp .env.example .env # then paste in: ${keysList}`);
5665
+ } else {
5666
+ lines.push(
5667
+ ` ${step++}. (no .env needed \u2014 agent_bridge LLM + ${resolved.tts} TTS don't require API keys)`
5668
+ );
5669
+ }
5670
+ lines.push(` ${step++}. $EDITOR docs/PRD.md # replace the stub with your product brief`);
5671
+ lines.push(` ${step++}. showrunner doctor -c demo.yaml`);
5672
+ lines.push(` ${step++}. showrunner run -c demo.yaml # \u2192 output/demo_final.mp4`);
5673
+ lines.push("");
5674
+ lines.push(
5675
+ `Optional: \`showrunner understand -c demo.yaml --interactive\` if you'd rather answer five questions than write the PRD upfront.`
5676
+ );
5677
+ lines.push("");
5678
+ process.stdout.write(lines.join("\n"));
5679
+ }
5680
+ function requiredEnvVars(resolved) {
5681
+ const vars = /* @__PURE__ */ new Set();
5682
+ if (resolved.llm === "anthropic") vars.add("ANTHROPIC_API_KEY");
5683
+ if (resolved.llm === "openai") vars.add("OPENAI_API_KEY");
5684
+ if (resolved.tts === "elevenlabs") vars.add("ELEVENLABS_API_KEY");
5685
+ if (resolved.tts === "openai") vars.add("OPENAI_API_KEY");
5686
+ return [...vars];
5544
5687
  }
5545
5688
  function validateChoice(value, allowed, defaultValue, flagName) {
5546
5689
  if (!value) return defaultValue;
@@ -5971,6 +6114,50 @@ and \`at_word\` actions degrade to \`at\`).
5971
6114
  `;
5972
6115
  }
5973
6116
 
6117
+ // src/commands/installBrowser.ts
6118
+ import { spawn as spawn4 } from "child_process";
6119
+ import { access as access3 } from "fs/promises";
6120
+ import { fileURLToPath } from "url";
6121
+ import { dirname as dirname11, join as join11 } from "path";
6122
+ var DEFAULT_BROWSER = "chromium";
6123
+ async function installBrowserCommand(opts) {
6124
+ const browser = opts.browser ?? DEFAULT_BROWSER;
6125
+ const cli = await resolvePlaywrightCoreCli();
6126
+ logger.info(`installing Playwright ${browser} (via bundled playwright-core, no project required)`);
6127
+ const child = spawn4(process.execPath, [cli, "install", browser], {
6128
+ stdio: "inherit",
6129
+ env: process.env
6130
+ });
6131
+ const code = await new Promise((resolve27, reject) => {
6132
+ child.on("error", reject);
6133
+ child.on("close", (c) => resolve27(c ?? 0));
6134
+ });
6135
+ if (code !== 0) {
6136
+ logger.error(`playwright install exited with code ${code}`);
6137
+ process.exit(code);
6138
+ }
6139
+ logger.info(`${browser} installed. Try \`showrunner doctor -c demo.yaml\` next.`);
6140
+ }
6141
+ async function resolvePlaywrightCoreCli() {
6142
+ const here = fileURLToPath(import.meta.url);
6143
+ let dir = dirname11(here);
6144
+ const root = dir.split(/[\\/]/)[0] + "/";
6145
+ while (dir && dir !== root) {
6146
+ const candidate = join11(dir, "node_modules", "playwright-core", "cli.js");
6147
+ try {
6148
+ await access3(candidate);
6149
+ return candidate;
6150
+ } catch {
6151
+ }
6152
+ const parent = dirname11(dir);
6153
+ if (parent === dir) break;
6154
+ dir = parent;
6155
+ }
6156
+ throw new Error(
6157
+ "Could not locate playwright-core CLI inside Showrunner's node_modules. This is a packaging bug \u2014 please file an issue."
6158
+ );
6159
+ }
6160
+
5974
6161
  // src/commands/validate.ts
5975
6162
  import { stat as stat10 } from "fs/promises";
5976
6163
  import { isAbsolute as isAbsolute9, resolve as resolve16 } from "path";
@@ -6123,8 +6310,8 @@ async function pathExists2(p) {
6123
6310
 
6124
6311
  // src/commands/rerunSegment.ts
6125
6312
  import { mkdir as mkdir12, rename as rename3, writeFile as writeFile13 } from "fs/promises";
6126
- import { join as join10, resolve as resolve19 } from "path";
6127
- import { chromium as chromium4, firefox as firefox4, webkit as webkit4 } from "playwright";
6313
+ import { join as join12, resolve as resolve19 } from "path";
6314
+ import { chromium as chromium4, firefox as firefox4, webkit as webkit4 } from "playwright-core";
6128
6315
  var RERUN_BUFFER_MS = 500;
6129
6316
  var browserMap4 = { chromium: chromium4, firefox: firefox4, webkit: webkit4 };
6130
6317
  async function rerunSegmentCommand(opts) {
@@ -6242,17 +6429,17 @@ async function rerunSegmentCommand(opts) {
6242
6429
  await page.waitForTimeout(config.recording.segment_buffer_ms);
6243
6430
  const traceDir = resolve19(configDir, config.recording.trace_dir);
6244
6431
  await mkdir12(traceDir, { recursive: true });
6245
- await ctx.tracing.stopChunk({ path: join10(traceDir, `${segment.id}.zip`) });
6432
+ await ctx.tracing.stopChunk({ path: join12(traceDir, `${segment.id}.zip`) });
6246
6433
  const videoHandle = page.video();
6247
6434
  await ctx.close();
6248
6435
  if (!videoHandle) {
6249
- logger.error("No video captured \u2014 recordVideo may have been ignored");
6436
+ logger.error("No video captured \xE2\u20AC\u201D recordVideo may have been ignored");
6250
6437
  process.exit(1);
6251
6438
  }
6252
6439
  const original = await videoHandle.path();
6253
- const dest = join10(videoDir, `${segment.id}.webm`);
6440
+ const dest = join12(videoDir, `${segment.id}.webm`);
6254
6441
  await rename3(original, dest);
6255
- const metadataPath = join10(videoDir, `${segment.id}.rerun.json`);
6442
+ const metadataPath = join12(videoDir, `${segment.id}.rerun.json`);
6256
6443
  await writeFile13(
6257
6444
  metadataPath,
6258
6445
  JSON.stringify(
@@ -6270,7 +6457,7 @@ async function rerunSegmentCommand(opts) {
6270
6457
  ) + "\n",
6271
6458
  "utf8"
6272
6459
  );
6273
- logger.info(`Segment ${segment.id} re-recorded \u2192 ${dest}`, { metadata: metadataPath });
6460
+ logger.info(`Segment ${segment.id} re-recorded \xE2\u2020\u2019 ${dest}`, { metadata: metadataPath });
6274
6461
  if (failure) {
6275
6462
  process.exit(1);
6276
6463
  }
@@ -6281,7 +6468,7 @@ async function rerunSegmentCommand(opts) {
6281
6468
 
6282
6469
  // src/commands/captureAuth.ts
6283
6470
  import { mkdir as mkdir14 } from "fs/promises";
6284
- import { dirname as dirname11, isAbsolute as isAbsolute10, resolve as resolve21 } from "path";
6471
+ import { dirname as dirname12, isAbsolute as isAbsolute10, resolve as resolve21 } from "path";
6285
6472
  import { createInterface } from "readline/promises";
6286
6473
 
6287
6474
  // src/recording/headed.ts
@@ -6291,7 +6478,7 @@ import {
6291
6478
  chromium as chromium5,
6292
6479
  firefox as firefox5,
6293
6480
  webkit as webkit5
6294
- } from "playwright";
6481
+ } from "playwright-core";
6295
6482
  var browserMap5 = { chromium: chromium5, firefox: firefox5, webkit: webkit5 };
6296
6483
  async function launchHeadedSession(opts) {
6297
6484
  const { recording, configDir } = opts;
@@ -6368,7 +6555,7 @@ async function captureAuthCommand(opts) {
6368
6555
  }
6369
6556
  const cookiesRel = opts.outputCookies ?? "./auth/session.json";
6370
6557
  const cookiesPath = isAbsolute10(cookiesRel) ? cookiesRel : resolve21(loaded.configDir, cookiesRel);
6371
- await mkdir14(dirname11(cookiesPath), { recursive: true });
6558
+ await mkdir14(dirname12(cookiesPath), { recursive: true });
6372
6559
  logger.info("Launching headed browser for auth capture", {
6373
6560
  target: loaded.config.recording.target_url,
6374
6561
  output: cookiesPath
@@ -6408,8 +6595,8 @@ Then re-run \`showrunner run --config <demo.yaml>\`.
6408
6595
  }
6409
6596
 
6410
6597
  // src/commands/trace.ts
6411
- import { readdir, stat as stat12 } from "fs/promises";
6412
- import { isAbsolute as isAbsolute11, join as join11, resolve as resolve22 } from "path";
6598
+ import { readdir as readdir2, stat as stat12 } from "fs/promises";
6599
+ import { isAbsolute as isAbsolute11, join as join13, resolve as resolve22 } from "path";
6413
6600
  async function traceCommand(opts) {
6414
6601
  let loaded;
6415
6602
  try {
@@ -6423,7 +6610,7 @@ async function traceCommand(opts) {
6423
6610
  }
6424
6611
  const traceDir = resolve22(loaded.configDir, loaded.config.recording.trace_dir);
6425
6612
  const videoDir = resolve22(loaded.configDir, loaded.config.recording.output_dir);
6426
- const slicePlanPath = join11(videoDir, "slice_plan.json");
6613
+ const slicePlanPath = join13(videoDir, "slice_plan.json");
6427
6614
  if (opts.all) {
6428
6615
  let plan;
6429
6616
  try {
@@ -6434,14 +6621,14 @@ async function traceCommand(opts) {
6434
6621
  process.exit(1);
6435
6622
  }
6436
6623
  for (const seg of plan.segments) {
6437
- const tracePath = seg.trace_path && isAbsolute11(seg.trace_path) ? seg.trace_path : seg.trace_path ? resolve22(loaded.configDir, seg.trace_path) : join11(traceDir, `${seg.id}.zip`);
6624
+ const tracePath = seg.trace_path && isAbsolute11(seg.trace_path) ? seg.trace_path : seg.trace_path ? resolve22(loaded.configDir, seg.trace_path) : join13(traceDir, `${seg.id}.zip`);
6438
6625
  logger.info(`Opening trace for ${seg.id}`, { path: tracePath });
6439
6626
  await openTrace(tracePath);
6440
6627
  }
6441
6628
  return;
6442
6629
  }
6443
6630
  if (opts.segment) {
6444
- const tracePath = join11(traceDir, `${opts.segment}.zip`);
6631
+ const tracePath = join13(traceDir, `${opts.segment}.zip`);
6445
6632
  if (!await fileExists8(tracePath)) {
6446
6633
  logger.error(`No trace found at ${tracePath}`);
6447
6634
  process.exit(1);
@@ -6482,7 +6669,7 @@ async function openTrace(tracePath) {
6482
6669
  }
6483
6670
  async function listTraces(dir) {
6484
6671
  try {
6485
- const entries = await readdir(dir);
6672
+ const entries = await readdir2(dir);
6486
6673
  return entries.filter((e) => e.endsWith(".zip")).sort();
6487
6674
  } catch {
6488
6675
  return [];
@@ -6546,7 +6733,7 @@ async function fileExists9(path) {
6546
6733
 
6547
6734
  // src/commands/understand.ts
6548
6735
  import { mkdir as mkdir15, readFile as readFile10, writeFile as writeFile14 } from "fs/promises";
6549
- import { dirname as dirname12, isAbsolute as isAbsolute12, resolve as resolve24 } from "path";
6736
+ import { dirname as dirname13, isAbsolute as isAbsolute12, resolve as resolve24 } from "path";
6550
6737
 
6551
6738
  // src/productModel/prompts.ts
6552
6739
  var PRODUCT_MODEL_SYSTEM_PROMPT = `You build product_model.json for Showrunner, an automated product demo recorder.
@@ -6751,7 +6938,7 @@ async function understandCommand(opts) {
6751
6938
  }
6752
6939
  }
6753
6940
  const outputPath = isAbsolute12(outputRel) ? outputRel : resolve24(configDir, outputRel);
6754
- await mkdir15(dirname12(outputPath), { recursive: true });
6941
+ await mkdir15(dirname13(outputPath), { recursive: true });
6755
6942
  let productModel;
6756
6943
  const provider = resolveDefaultLLMProvider({ configDir, llm: llmConfig });
6757
6944
  try {
@@ -6798,8 +6985,8 @@ async function understandCommand(opts) {
6798
6985
  }
6799
6986
 
6800
6987
  // src/commands/instrument.ts
6801
- import { mkdir as mkdir16, readdir as readdir2, writeFile as writeFile15 } from "fs/promises";
6802
- import { dirname as dirname13, isAbsolute as isAbsolute13, join as join12, resolve as resolve25, relative as relative2 } from "path";
6988
+ import { mkdir as mkdir16, readdir as readdir3, writeFile as writeFile15 } from "fs/promises";
6989
+ import { dirname as dirname14, isAbsolute as isAbsolute13, join as join14, resolve as resolve25, relative as relative2 } from "path";
6803
6990
 
6804
6991
  // src/instrument/scan.ts
6805
6992
  import { readFile as readFile11 } from "fs/promises";
@@ -7022,9 +7209,9 @@ async function instrumentCommand(opts) {
7022
7209
  const filesToScan = /* @__PURE__ */ new Set();
7023
7210
  const IGNORED_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", "dist", "build", ".next", "out"]);
7024
7211
  async function walk(root, accept) {
7025
- const entries = await readdir2(root, { withFileTypes: true });
7212
+ const entries = await readdir3(root, { withFileTypes: true });
7026
7213
  for (const entry of entries) {
7027
- const abs = join12(root, entry.name);
7214
+ const abs = join14(root, entry.name);
7028
7215
  if (entry.isDirectory()) {
7029
7216
  if (IGNORED_DIRS.has(entry.name)) continue;
7030
7217
  await walk(abs, accept);
@@ -7081,7 +7268,7 @@ async function instrumentCommand(opts) {
7081
7268
  logger.warn(`Skipped suggestion at ${s.file}:${s.line} \u2014 ${s.reason}`);
7082
7269
  }
7083
7270
  const outputPath = isAbsolute13(opts.output) ? opts.output : resolve25(loaded.configDir, opts.output);
7084
- await mkdir16(dirname13(outputPath), { recursive: true });
7271
+ await mkdir16(dirname14(outputPath), { recursive: true });
7085
7272
  await writeFile15(outputPath, patch, "utf8");
7086
7273
  logger.info("Wrote instrumentation patch", { path: outputPath, suggestions: suggestions.length });
7087
7274
  process.stdout.write(`
@@ -7163,7 +7350,7 @@ var IN_PAGE_SCRIPT = `
7163
7350
  send('submit', el, null);
7164
7351
  }, true);
7165
7352
 
7166
- // Scroll capture \u2014 debounce 250ms, coalesce within window, emit direction + accumulated deltaY.
7353
+ // Scroll capture \xE2\u20AC\u201D debounce 250ms, coalesce within window, emit direction + accumulated deltaY.
7167
7354
  let scrollLastY = window.scrollY;
7168
7355
  let scrollAccum = 0;
7169
7356
  let scrollTimer = null;
@@ -7186,7 +7373,7 @@ var IN_PAGE_SCRIPT = `
7186
7373
  const delta = currentY - scrollLastY;
7187
7374
  scrollLastY = currentY;
7188
7375
  if (delta === 0) return;
7189
- // Direction change \u2192 flush the previous burst immediately.
7376
+ // Direction change \xE2\u2020\u2019 flush the previous burst immediately.
7190
7377
  if (scrollAccum !== 0 && Math.sign(delta) !== Math.sign(scrollAccum)) {
7191
7378
  if (scrollTimer !== null) { clearTimeout(scrollTimer); scrollTimer = null; }
7192
7379
  flushScroll();
@@ -7416,10 +7603,12 @@ async function fileExists10(path) {
7416
7603
 
7417
7604
  // src/cli.ts
7418
7605
  var program = new Command();
7419
- program.name("showrunner").description("Automated product demo recording & production tool").version("1.1.0").option("--json", "emit structured JSON logs to stdout").option("--log-level <level>", "log level (debug|info|warn|error)").hook("preAction", (thisCmd) => {
7606
+ program.name("showrunner").description("Automated product demo recording & production tool").version("1.1.2").option("--json", "emit structured JSON logs to stdout").option("--log-level <level>", "log level (debug|info|warn|error)").hook("preAction", (thisCmd) => {
7420
7607
  const opts = thisCmd.opts();
7421
7608
  if (opts.json) logger.setJson(true);
7422
7609
  if (opts.logLevel) logger.setLevel(opts.logLevel);
7610
+ }).action(async () => {
7611
+ await printWelcome();
7423
7612
  });
7424
7613
  var stageChoices = [...STAGE_NAMES];
7425
7614
  program.command("run").description("Run the full Showrunner pipeline").requiredOption("-c, --config <path>", "path to demo.yaml").addOption(
@@ -7447,6 +7636,7 @@ program.command("init").description("Scaffold a new Showrunner project").option(
7447
7636
  "scaffold resolution: low (854x480) | standard (720p) | high (1080p) | extreme (4K)",
7448
7637
  "standard"
7449
7638
  ).action(initCommand);
7639
+ program.command("install-browser").description("Install the Playwright browser binary (chromium by default) \u2014 wraps playwright-core install").option("--browser <name>", "browser to install: chromium | firefox | webkit", "chromium").action(installBrowserCommand);
7450
7640
  program.command("doctor").description("Run preflight checks on the current config + environment").requiredOption("-c, --config <path>", "path to demo.yaml").option("--json", "emit results as JSON instead of human-readable rows").action(doctorCommand);
7451
7641
  program.command("validate").description("Validate a demo.yaml config file").requiredOption("-c, --config <path>", "path to demo.yaml").option("--strict", "exit nonzero on any warning (e.g. missing provider env var)").action(validateCommand);
7452
7642
  program.command("understand").description("Build product_model.json from documents or interactive Q&A").option("-c, --config <path>", "path to demo.yaml").option("--interactive", "use interactive Q&A mode").option("--output <path>", "output path for product_model.json").action(understandCommand);
@@ -7463,6 +7653,60 @@ program.parseAsync(process.argv).catch((err) => {
7463
7653
  logger.error(message);
7464
7654
  process.exit(1);
7465
7655
  });
7656
+ async function printWelcome() {
7657
+ const { access: access4 } = await import("fs/promises");
7658
+ const { resolve: resolve27 } = await import("path");
7659
+ const demoYaml = resolve27(process.cwd(), "demo.yaml");
7660
+ let inProject = false;
7661
+ try {
7662
+ await access4(demoYaml);
7663
+ inProject = true;
7664
+ } catch {
7665
+ }
7666
+ const browserMissing = await isChromiumMissing();
7667
+ const lines = ["", `Showrunner v1.1.2 \u2014 automated product-demo recording & production`, ""];
7668
+ if (browserMissing) {
7669
+ lines.push(`First-time setup: install the recording browser (one-off, ~150 MB):`);
7670
+ lines.push(``);
7671
+ lines.push(` showrunner install-browser # wraps Playwright; no "install dependencies first" warning`);
7672
+ lines.push(``);
7673
+ }
7674
+ if (inProject) {
7675
+ lines.push(`Detected demo.yaml in this directory. Likely next:`);
7676
+ lines.push(``);
7677
+ lines.push(` showrunner doctor -c demo.yaml # preflight checks`);
7678
+ lines.push(` showrunner run -c demo.yaml # run the full pipeline`);
7679
+ lines.push(``);
7680
+ lines.push(`Other commands: \`showrunner --help\``);
7681
+ } else {
7682
+ lines.push(`No demo.yaml here. To scaffold a new project:`);
7683
+ lines.push(``);
7684
+ lines.push(` showrunner init --name my-demo --url http://localhost:3000`);
7685
+ lines.push(``);
7686
+ lines.push(`Then inside the new directory:`);
7687
+ lines.push(``);
7688
+ lines.push(` cd my-demo`);
7689
+ lines.push(` cp .env.example .env # paste provider keys, or use agent_bridge (no keys)`);
7690
+ lines.push(` $EDITOR docs/PRD.md # write your product brief`);
7691
+ lines.push(` showrunner doctor -c demo.yaml # preflight`);
7692
+ lines.push(` showrunner run -c demo.yaml # full pipeline \u2192 output/demo_final.mp4`);
7693
+ lines.push(``);
7694
+ lines.push(`See all commands: \`showrunner --help\``);
7695
+ }
7696
+ lines.push("");
7697
+ process.stdout.write(lines.join("\n"));
7698
+ }
7699
+ async function isChromiumMissing() {
7700
+ try {
7701
+ const { chromium: chromium6 } = await import("playwright-core");
7702
+ const exec = chromium6.executablePath();
7703
+ const { stat: stat15 } = await import("fs/promises");
7704
+ await stat15(exec);
7705
+ return false;
7706
+ } catch {
7707
+ return true;
7708
+ }
7709
+ }
7466
7710
  function parseStages(value) {
7467
7711
  const requested = value.split(",").map((s) => s.trim());
7468
7712
  const invalid = requested.filter((s) => !stageChoices.includes(s));