prowl-tools 0.1.8 → 0.1.10

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/index.js CHANGED
@@ -28,7 +28,7 @@ import {
28
28
  saveStorageState,
29
29
  tccGuidance,
30
30
  updateBacklogFromSuite
31
- } from "./chunk-CWLRDV5P.js";
31
+ } from "./chunk-SRWAF2RI.js";
32
32
  import {
33
33
  CONFIG_DIR,
34
34
  findConfigPath,
@@ -38,15 +38,15 @@ import {
38
38
  loadHuntTags,
39
39
  normalizeHuntName,
40
40
  resolveViewport
41
- } from "./chunk-JFJQNJSJ.js";
41
+ } from "./chunk-TTZ2IL3P.js";
42
42
 
43
43
  // src/cli/program.ts
44
- import { Command as Command14 } from "commander";
44
+ import { Command as Command15 } from "commander";
45
45
 
46
46
  // package.json
47
47
  var package_default = {
48
48
  name: "prowl-tools",
49
- version: "0.1.8",
49
+ version: "0.1.10",
50
50
  description: "E2E testing for native macOS apps and web apps from declarative YAML hunts.",
51
51
  type: "module",
52
52
  license: "Apache-2.0",
@@ -105,7 +105,8 @@ var package_default = {
105
105
  build: "tsup",
106
106
  lint: "eslint .",
107
107
  test: "vitest run",
108
- "test:watch": "vitest"
108
+ "test:watch": "vitest",
109
+ "audit:licenses": "node scripts/audit-licenses.mjs"
109
110
  },
110
111
  dependencies: {
111
112
  "@modelcontextprotocol/sdk": "^1.29.0",
@@ -129,6 +130,7 @@ var package_default = {
129
130
  "@typescript-eslint/eslint-plugin": "^7.18.0",
130
131
  "@typescript-eslint/parser": "^7.18.0",
131
132
  eslint: "^8.57.1",
133
+ "license-checker-rseidelsohn": "^4.4.2",
132
134
  tsup: "^8.3.5",
133
135
  typescript: "^5.7.3",
134
136
  vitest: "^2.1.8"
@@ -182,6 +184,10 @@ function describeStep(step) {
182
184
  if ("waitForSelector" in step) return `waitForSelector "${step.waitForSelector.selector}"`;
183
185
  if ("waitForUrl" in step) return `waitForUrl "${step.waitForUrl.value}"`;
184
186
  if ("waitForNetworkIdle" in step) return "waitForNetworkIdle";
187
+ if ("waitForResponse" in step) {
188
+ const statusSuffix = step.waitForResponse.status !== void 0 ? ` (status ${step.waitForResponse.status})` : "";
189
+ return `waitForResponse "${step.waitForResponse.url}"${statusSuffix}`;
190
+ }
185
191
  if ("hover" in step) return `hover "${step.hover.selector}"`;
186
192
  if ("scroll" in step) return `scroll ${step.scroll.direction} ${step.scroll.amount ?? 500}px`;
187
193
  if ("scrollTo" in step) return `scrollTo "${step.scrollTo.selector}"`;
@@ -245,6 +251,10 @@ function printHuntSummary(result, runDir) {
245
251
  const duration = chalk.gray(`(${result.durationMs}ms)`);
246
252
  console.log(`
247
253
  ${status} ${chalk.bold(result.hunt)} ${duration} ${stepCount}`);
254
+ if (result.retrySummary) {
255
+ const color = result.status === "pass" ? chalk.yellow : chalk.red;
256
+ console.log(` ${color(result.retrySummary)}`);
257
+ }
248
258
  console.log(` ${chalk.gray("Artifacts:")} ${runDir}
249
259
  `);
250
260
  }
@@ -354,6 +364,7 @@ function buildRunCommand() {
354
364
 
355
365
  // src/cli/commands/init.ts
356
366
  import fs from "fs";
367
+ import os from "os";
357
368
  import path from "path";
358
369
  import { fileURLToPath } from "url";
359
370
  import { Command as Command2 } from "commander";
@@ -377,6 +388,192 @@ function copyFile(source, destination) {
377
388
  fs.mkdirSync(path.dirname(destination), { recursive: true });
378
389
  fs.copyFileSync(source, destination);
379
390
  }
391
+ function isInsideDir(parent, child) {
392
+ const relative = path.relative(parent, child);
393
+ return relative.length > 0 && !relative.startsWith("..") && !path.isAbsolute(relative);
394
+ }
395
+ function lstatIfExists(target) {
396
+ try {
397
+ return fs.lstatSync(target);
398
+ } catch (error) {
399
+ const code = error.code;
400
+ if (code === "ENOENT" || code === "ENOTDIR") {
401
+ return null;
402
+ }
403
+ throw error;
404
+ }
405
+ }
406
+ function errorMessage(error) {
407
+ return error instanceof Error ? error.message : String(error);
408
+ }
409
+ function assertNoSymlinkDestination(prowlDir, destination) {
410
+ const relative = path.relative(prowlDir, destination);
411
+ if (relative.startsWith("..") || path.isAbsolute(relative)) {
412
+ throw new Error(`Refusing to write outside ${CONFIG_DIR}: ${destination}`);
413
+ }
414
+ const parts = relative.split(path.sep).filter((part) => part.length > 0);
415
+ let current = prowlDir;
416
+ const rootStat = lstatIfExists(current);
417
+ if (rootStat?.isSymbolicLink()) {
418
+ throw new Error(
419
+ `${CONFIG_DIR} scaffold destination path contains a symlink: ${current}. Replace it with a real file or directory before running prowl init --force.`
420
+ );
421
+ }
422
+ if (rootStat && !rootStat.isDirectory()) {
423
+ throw new Error(`${CONFIG_DIR} scaffold path is not a directory: ${current}`);
424
+ }
425
+ for (let index = 0; index < parts.length; index += 1) {
426
+ current = path.join(current, parts[index] ?? "");
427
+ const stat = lstatIfExists(current);
428
+ if (stat?.isSymbolicLink()) {
429
+ throw new Error(
430
+ `${CONFIG_DIR} scaffold destination path contains a symlink: ${current}. Replace it with a real file or directory before running prowl init --force.`
431
+ );
432
+ }
433
+ if (stat && index < parts.length - 1 && !stat.isDirectory()) {
434
+ throw new Error(`${CONFIG_DIR} scaffold parent path is not a directory: ${current}`);
435
+ }
436
+ }
437
+ }
438
+ function gitignoreTemplate() {
439
+ return [
440
+ "# Run artifacts (screenshots, logs, reports)",
441
+ "runs/",
442
+ "",
443
+ "# Auth state (tokens, cookies)",
444
+ "auth-state.json",
445
+ "",
446
+ "# Environment variables (credentials)",
447
+ ".env",
448
+ ""
449
+ ].join("\n");
450
+ }
451
+ function stageScaffoldTemplates(examplesDir) {
452
+ const stageDir = fs.mkdtempSync(path.join(os.tmpdir(), "prowl-init-"));
453
+ const files = [];
454
+ try {
455
+ copyFile(path.join(examplesDir, "config.yml"), path.join(stageDir, "config.yml"));
456
+ files.push({ source: path.join(stageDir, "config.yml"), relativePath: "config.yml" });
457
+ const exampleHuntsDir = path.join(examplesDir, "hunts");
458
+ const huntFiles = fs.readdirSync(exampleHuntsDir).filter((f) => f.endsWith(".yml"));
459
+ for (const huntFile of huntFiles) {
460
+ const relativePath = path.join("hunts", huntFile);
461
+ copyFile(path.join(exampleHuntsDir, huntFile), path.join(stageDir, relativePath));
462
+ files.push({ source: path.join(stageDir, relativePath), relativePath });
463
+ }
464
+ const gitignorePath = path.join(stageDir, ".gitignore");
465
+ fs.writeFileSync(gitignorePath, gitignoreTemplate());
466
+ files.push({ source: gitignorePath, relativePath: ".gitignore" });
467
+ return { stageDir, files };
468
+ } catch (error) {
469
+ fs.rmSync(stageDir, { recursive: true, force: true });
470
+ throw error;
471
+ }
472
+ }
473
+ function trackCreatedParentDirs(destination, prowlDir, createdDirs) {
474
+ let dir = path.dirname(destination);
475
+ while (isInsideDir(prowlDir, dir) && !fs.existsSync(dir)) {
476
+ createdDirs.add(dir);
477
+ dir = path.dirname(dir);
478
+ }
479
+ }
480
+ function prepareDestinationRollback(prowlDir, files) {
481
+ const backupDir = fs.mkdtempSync(path.join(os.tmpdir(), "prowl-init-rollback-"));
482
+ const backups = [];
483
+ const createdFiles = [];
484
+ const createdDirs = /* @__PURE__ */ new Set();
485
+ const prowlDirExisted = fs.existsSync(prowlDir);
486
+ try {
487
+ for (const file of files) {
488
+ const destination = path.join(prowlDir, file.relativePath);
489
+ assertNoSymlinkDestination(prowlDir, destination);
490
+ const stat = lstatIfExists(destination);
491
+ if (stat) {
492
+ if (!stat.isFile()) {
493
+ throw new Error(`${CONFIG_DIR} scaffold destination is not a regular file: ${destination}`);
494
+ }
495
+ const backup = path.join(backupDir, file.relativePath);
496
+ copyFile(destination, backup);
497
+ backups.push({ destination, backup });
498
+ } else {
499
+ createdFiles.push(destination);
500
+ }
501
+ if (prowlDirExisted) {
502
+ trackCreatedParentDirs(destination, prowlDir, createdDirs);
503
+ }
504
+ }
505
+ return {
506
+ prowlDirExisted,
507
+ backupDir,
508
+ backups,
509
+ createdFiles,
510
+ createdDirs: [...createdDirs].sort((a, b) => b.length - a.length)
511
+ };
512
+ } catch (error) {
513
+ fs.rmSync(backupDir, { recursive: true, force: true });
514
+ throw error;
515
+ }
516
+ }
517
+ function rollbackDestination(prowlDir, rollback) {
518
+ if (!rollback.prowlDirExisted) {
519
+ fs.rmSync(prowlDir, { recursive: true, force: true });
520
+ return;
521
+ }
522
+ for (const file of rollback.createdFiles) {
523
+ fs.rmSync(file, { force: true });
524
+ }
525
+ for (const backup of rollback.backups) {
526
+ try {
527
+ copyFile(backup.backup, backup.destination);
528
+ } catch (error) {
529
+ throw new Error(
530
+ `Failed to restore ${backup.destination} from backup ${backup.backup}: ${errorMessage(error)}`
531
+ );
532
+ }
533
+ }
534
+ for (const dir of rollback.createdDirs) {
535
+ try {
536
+ fs.rmdirSync(dir);
537
+ } catch {
538
+ }
539
+ }
540
+ }
541
+ function scaffoldProwlDir(root) {
542
+ const prowlDir = path.join(root, CONFIG_DIR);
543
+ const packageRoot = getPackageRoot();
544
+ const examplesDir = path.join(packageRoot, "examples");
545
+ const exampleConfig = path.join(examplesDir, "config.yml");
546
+ const exampleHuntsDir = path.join(examplesDir, "hunts");
547
+ if (!fs.existsSync(exampleConfig) || !fs.existsSync(exampleHuntsDir)) {
548
+ throw new Error("Examples not found in package. Reinstall prowl-tools.");
549
+ }
550
+ const staged = stageScaffoldTemplates(examplesDir);
551
+ let rollback = null;
552
+ let keepBackup = false;
553
+ try {
554
+ rollback = prepareDestinationRollback(prowlDir, staged.files);
555
+ for (const file of staged.files) {
556
+ copyFile(file.source, path.join(prowlDir, file.relativePath));
557
+ }
558
+ } catch (error) {
559
+ if (rollback) {
560
+ try {
561
+ rollbackDestination(prowlDir, rollback);
562
+ } catch (rollbackError) {
563
+ keepBackup = true;
564
+ throw new Error(
565
+ `Scaffold failed: ${errorMessage(error)}. Rollback failed: ${errorMessage(rollbackError)}. Backup preserved at ${rollback.backupDir}.`
566
+ );
567
+ }
568
+ }
569
+ throw error;
570
+ } finally {
571
+ if (rollback && !keepBackup) {
572
+ fs.rmSync(rollback.backupDir, { recursive: true, force: true });
573
+ }
574
+ fs.rmSync(staged.stageDir, { recursive: true, force: true });
575
+ }
576
+ }
380
577
  function buildInitCommand() {
381
578
  const command = new Command2("init").option("--force", `Overwrite existing ${CONFIG_DIR} directory`).action((options) => {
382
579
  const root = process.cwd();
@@ -390,39 +587,18 @@ function buildInitCommand() {
390
587
  process.exitCode = 1;
391
588
  return;
392
589
  }
393
- const packageRoot = getPackageRoot();
394
- const examplesDir = path.join(packageRoot, "examples");
395
- const exampleConfig = path.join(examplesDir, "config.yml");
396
- const exampleHuntsDir = path.join(examplesDir, "hunts");
397
- if (!fs.existsSync(exampleConfig) || !fs.existsSync(exampleHuntsDir)) {
398
- console.error(chalk4.red("Examples not found in package. Reinstall prowl-tools."));
590
+ try {
591
+ scaffoldProwlDir(root);
592
+ } catch (error) {
593
+ console.error(chalk4.red(error instanceof Error ? error.message : "init failed"));
399
594
  process.exitCode = 1;
400
595
  return;
401
596
  }
402
- copyFile(exampleConfig, path.join(prowlDir, "config.yml"));
403
- const huntFiles = fs.readdirSync(exampleHuntsDir).filter((f) => f.endsWith(".yml"));
404
- for (const huntFile of huntFiles) {
405
- copyFile(
406
- path.join(exampleHuntsDir, huntFile),
407
- path.join(prowlDir, "hunts", huntFile)
408
- );
409
- }
410
- const gitignore = [
411
- "# Run artifacts (screenshots, logs, reports)",
412
- "runs/",
413
- "",
414
- "# Auth state (tokens, cookies)",
415
- "auth-state.json",
416
- "",
417
- "# Environment variables (credentials)",
418
- ".env",
419
- ""
420
- ].join("\n");
421
- fs.writeFileSync(path.join(prowlDir, ".gitignore"), gitignore);
422
597
  console.log(welcomeBanner());
423
598
  console.log(chalk4.green(` Initialized ${CONFIG_DIR} directory.`));
424
599
  console.log(chalk4.gray(" Run ") + chalk4.bold("prowl run hello") + chalk4.gray(" to get started."));
425
- console.log(chalk4.gray(" See ") + chalk4.cyan(`${CONFIG_DIR}/hunts/login-flow.yml`) + chalk4.gray(" for a fuller example.") + "\n");
600
+ console.log(chalk4.gray(" See ") + chalk4.cyan(`${CONFIG_DIR}/hunts/login-flow.yml`) + chalk4.gray(" (auth) and ") + chalk4.cyan(`${CONFIG_DIR}/hunts/form.yml`) + chalk4.gray(" (web forms) for fuller examples."));
601
+ console.log(chalk4.gray(" Desktop-first? ") + chalk4.cyan(`${CONFIG_DIR}/hunts/macos-hello.yml`) + chalk4.gray(" is a macOS starter (experimental \u2014 see its comments to enable).") + "\n");
426
602
  });
427
603
  return command;
428
604
  }
@@ -1194,7 +1370,7 @@ function buildGenerateCommand() {
1194
1370
  } else if (options.output) {
1195
1371
  let configDir;
1196
1372
  try {
1197
- const { loadConfig: loadConfig2 } = await import("./loader-JTHA4BYG.js");
1373
+ const { loadConfig: loadConfig2 } = await import("./loader-CAE4FPBK.js");
1198
1374
  const result = loadConfig2(options.config);
1199
1375
  configDir = result.configDir;
1200
1376
  } catch {
@@ -1260,6 +1436,7 @@ function buildHistoryCommand() {
1260
1436
  }
1261
1437
  console.log();
1262
1438
  console.log(` ${chalk12.bold(huntName)} \u2014 last ${recent.length} of ${entries.length} runs`);
1439
+ console.log(` ${chalk12.gray(formatRetryFrequency(recent))}`);
1263
1440
  console.log();
1264
1441
  console.log(formatTable(recent));
1265
1442
  console.log();
@@ -1275,12 +1452,23 @@ function buildHistoryCommand() {
1275
1452
  });
1276
1453
  return command;
1277
1454
  }
1455
+ function formatRetryFrequency(entries) {
1456
+ const retried = entries.filter((entry) => (entry.retries ?? 0) > 0).length;
1457
+ if (retried === 0) {
1458
+ return "No retries in these runs";
1459
+ }
1460
+ return `Retried in ${retried} of ${entries.length} runs`;
1461
+ }
1462
+ function formatRetries(retries) {
1463
+ return retries && retries > 0 ? String(retries) : "-";
1464
+ }
1278
1465
  function formatTable(entries) {
1279
- const headers = ["Status", "Started", "Duration"];
1466
+ const headers = ["Status", "Started", "Duration", "Retries"];
1280
1467
  const rows = entries.map((entry) => [
1281
1468
  formatStatus(entry.status),
1282
1469
  entry.startedAt,
1283
- formatDuration(entry.durationMs)
1470
+ formatDuration(entry.durationMs),
1471
+ formatRetries(entry.retries)
1284
1472
  ]);
1285
1473
  const widths = headers.map(
1286
1474
  (header, index) => Math.max(
@@ -1448,7 +1636,7 @@ async function runHuntTool(args, configPath) {
1448
1636
 
1449
1637
  // src/mcp/projects.ts
1450
1638
  import fs5 from "fs";
1451
- import os from "os";
1639
+ import os2 from "os";
1452
1640
  import path7 from "path";
1453
1641
  import yaml from "yaml";
1454
1642
  import { z } from "zod";
@@ -1461,10 +1649,10 @@ var projectRegistrySchema = z.object({
1461
1649
  projects: z.record(z.string().min(1), projectEntrySchema)
1462
1650
  }).strict();
1463
1651
  function defaultRegistryPath() {
1464
- return path7.join(os.homedir(), ".prowl", "projects.yml");
1652
+ return path7.join(os2.homedir(), ".prowl", "projects.yml");
1465
1653
  }
1466
1654
  function legacyRegistryPath() {
1467
- return path7.join(os.homedir(), ".prowlqa", "projects.yml");
1655
+ return path7.join(os2.homedir(), ".prowlqa", "projects.yml");
1468
1656
  }
1469
1657
  function resolveProjectConfigPath(root) {
1470
1658
  const preferred = path7.join(root, ".prowl", "config.yml");
@@ -1518,7 +1706,7 @@ function listRegisteredProjects(registry) {
1518
1706
  function textResult(data) {
1519
1707
  return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
1520
1708
  }
1521
- function errorMessage(error) {
1709
+ function errorMessage2(error) {
1522
1710
  return error instanceof Error ? error.message : String(error);
1523
1711
  }
1524
1712
  function safeStringify(value) {
@@ -1532,7 +1720,7 @@ async function toolResult(toolName, args, action) {
1532
1720
  try {
1533
1721
  return textResult(await action());
1534
1722
  } catch (error) {
1535
- throw new Error(`${toolName} failed for args=${safeStringify(args)}: ${errorMessage(error)}`, {
1723
+ throw new Error(`${toolName} failed for args=${safeStringify(args)}: ${errorMessage2(error)}`, {
1536
1724
  cause: error
1537
1725
  });
1538
1726
  }
@@ -1609,7 +1797,7 @@ async function startMcpServer(options = {}) {
1609
1797
  }
1610
1798
 
1611
1799
  // src/cli/commands/mcp.ts
1612
- function errorMessage2(error) {
1800
+ function errorMessage3(error) {
1613
1801
  return error instanceof Error ? error.message : String(error);
1614
1802
  }
1615
1803
  function buildMcpCommand() {
@@ -1620,7 +1808,7 @@ function buildMcpCommand() {
1620
1808
  try {
1621
1809
  await startMcpServer({ registryPath: options.projects });
1622
1810
  } catch (error) {
1623
- console.error(`Failed to start MCP server: ${errorMessage2(error)}`);
1811
+ console.error(`Failed to start MCP server: ${errorMessage3(error)}`);
1624
1812
  process.exit(1);
1625
1813
  }
1626
1814
  });
@@ -1699,10 +1887,400 @@ function buildMacdriverCommand() {
1699
1887
  return command;
1700
1888
  }
1701
1889
 
1890
+ // src/cli/commands/doctor.ts
1891
+ import chalk15 from "chalk";
1892
+ import { Command as Command14 } from "commander";
1893
+
1894
+ // src/doctor/checks.ts
1895
+ import { execFile } from "child_process";
1896
+ import { createRequire } from "module";
1897
+ import fs6 from "fs";
1898
+ import path8 from "path";
1899
+ import { promisify } from "util";
1900
+ var execFileAsync = promisify(execFile);
1901
+ var MIN_NODE_MAJOR = 20;
1902
+ var CHECK_NODE = "Node.js version";
1903
+ var CHECK_PLAYWRIGHT = "Playwright installed";
1904
+ var CHECK_CHROMIUM = "Chromium browser";
1905
+ var CHECK_PROWL_DIR = ".prowl/ directory";
1906
+ var CHECK_CONFIG = "config.yml valid";
1907
+ var CHECK_MACOS = "macOS helper (prowl-macdriver)";
1908
+ var CHECK_IOS = "iOS tooling (xcrun simctl)";
1909
+ var CHECK_ANDROID = "Android tooling (adb)";
1910
+ var INSTALL_CHROMIUM_TIMEOUT_MS = 3e5;
1911
+ function nodeMajor(version) {
1912
+ const cleaned = version.startsWith("v") ? version.slice(1) : version;
1913
+ return Number.parseInt(cleaned.split(".")[0] ?? "", 10);
1914
+ }
1915
+ function checkNode(nodeVersion) {
1916
+ const major = nodeMajor(nodeVersion);
1917
+ if (Number.isFinite(major) && major >= MIN_NODE_MAJOR) {
1918
+ return {
1919
+ name: CHECK_NODE,
1920
+ status: "pass",
1921
+ message: `Node ${nodeVersion} (>= ${MIN_NODE_MAJOR} required)`,
1922
+ fixable: false
1923
+ };
1924
+ }
1925
+ return {
1926
+ name: CHECK_NODE,
1927
+ status: "fail",
1928
+ message: `Node ${nodeVersion} is too old \u2014 Prowl needs Node >= ${MIN_NODE_MAJOR}. Upgrade with nvm (\`nvm install 20\`), Homebrew (\`brew install node\`), or nodejs.org.`,
1929
+ fixable: false
1930
+ };
1931
+ }
1932
+ function checkPlaywright(probe) {
1933
+ if (probe.playwrightInstalled) {
1934
+ return { name: CHECK_PLAYWRIGHT, status: "pass", message: "Playwright is installed", fixable: false };
1935
+ }
1936
+ return {
1937
+ name: CHECK_PLAYWRIGHT,
1938
+ status: "fail",
1939
+ message: "Playwright is not installed. It ships as a dependency of prowl-tools \u2014 reinstall with `npm install` (local) or `npm install -g prowl-tools` (global).",
1940
+ fixable: false
1941
+ };
1942
+ }
1943
+ function checkChromium(probe) {
1944
+ if (!probe.playwrightInstalled) {
1945
+ return {
1946
+ name: CHECK_CHROMIUM,
1947
+ status: "skip",
1948
+ message: "Skipped \u2014 Playwright is not installed",
1949
+ fixable: false
1950
+ };
1951
+ }
1952
+ if (probe.chromiumInstalled) {
1953
+ return {
1954
+ name: CHECK_CHROMIUM,
1955
+ status: "pass",
1956
+ message: `Chromium is available${probe.chromiumPath ? ` (${probe.chromiumPath})` : ""}`,
1957
+ fixable: false
1958
+ };
1959
+ }
1960
+ return {
1961
+ name: CHECK_CHROMIUM,
1962
+ status: "fail",
1963
+ message: "Chromium is not installed. Run `npx playwright install chromium` (or `prowl doctor --fix`).",
1964
+ fixable: true
1965
+ };
1966
+ }
1967
+ function checkProwlDir(exists) {
1968
+ if (exists) {
1969
+ return { name: CHECK_PROWL_DIR, status: "pass", message: `${CONFIG_DIR}/ exists`, fixable: false };
1970
+ }
1971
+ return {
1972
+ name: CHECK_PROWL_DIR,
1973
+ status: "warn",
1974
+ message: `No ${CONFIG_DIR}/ directory here. Run \`prowl init\` (or \`prowl doctor --fix\`) to scaffold config and starter hunts.`,
1975
+ fixable: true
1976
+ };
1977
+ }
1978
+ function checkConfig(prowlDirExists, loadConfig2) {
1979
+ if (!prowlDirExists) {
1980
+ return {
1981
+ result: {
1982
+ name: CHECK_CONFIG,
1983
+ status: "skip",
1984
+ message: `Skipped \u2014 no ${CONFIG_DIR}/ directory`,
1985
+ fixable: false
1986
+ },
1987
+ config: null
1988
+ };
1989
+ }
1990
+ try {
1991
+ const { config } = loadConfig2();
1992
+ return {
1993
+ result: {
1994
+ name: CHECK_CONFIG,
1995
+ status: "pass",
1996
+ message: `Valid (target: ${config.target.type})`,
1997
+ fixable: false
1998
+ },
1999
+ config
2000
+ };
2001
+ } catch (error) {
2002
+ const detail = error instanceof Error ? error.message : String(error);
2003
+ return {
2004
+ result: {
2005
+ name: CHECK_CONFIG,
2006
+ status: "fail",
2007
+ message: `Invalid config: ${detail}`,
2008
+ fixable: false
2009
+ },
2010
+ config: null
2011
+ };
2012
+ }
2013
+ }
2014
+ function macdriverSourceLabel(source) {
2015
+ switch (source) {
2016
+ case "env":
2017
+ return "PROWL_MACDRIVER_BIN override";
2018
+ case "user-install":
2019
+ return "user install (~/.prowl/macdriver)";
2020
+ case "source-build":
2021
+ return "repo source build";
2022
+ }
2023
+ }
2024
+ async function checkMacTarget(collect, env) {
2025
+ let status;
2026
+ try {
2027
+ status = await collect({ env });
2028
+ } catch (error) {
2029
+ const detail = error instanceof Error ? error.message : String(error);
2030
+ return {
2031
+ name: CHECK_MACOS,
2032
+ status: "fail",
2033
+ message: `Could not inspect prowl-macdriver status (${detail}). Run \`prowl macdriver status\` for details.`,
2034
+ fixable: false
2035
+ };
2036
+ }
2037
+ if (status.resolved && status.probedVersion) {
2038
+ return {
2039
+ name: CHECK_MACOS,
2040
+ status: "pass",
2041
+ message: `Resolved via ${macdriverSourceLabel(status.resolved.source)}: ${status.resolved.path} (reports ${status.probedVersion}). Verify Accessibility/Screen Recording permissions with \`prowl macdriver status\`.`,
2042
+ fixable: false
2043
+ };
2044
+ }
2045
+ if (status.resolved) {
2046
+ return {
2047
+ name: CHECK_MACOS,
2048
+ status: "fail",
2049
+ message: `prowl-macdriver resolved via ${macdriverSourceLabel(status.resolved.source)} (${status.resolved.path}), but the helper version probe failed. Run \`prowl macdriver status\` for details.`,
2050
+ fixable: false
2051
+ };
2052
+ }
2053
+ return {
2054
+ name: CHECK_MACOS,
2055
+ status: "fail",
2056
+ message: "prowl-macdriver helper not found. Install it with `prowl macdriver install`, then check permissions with `prowl macdriver status`.",
2057
+ fixable: false
2058
+ };
2059
+ }
2060
+ async function checkIosTarget(platform, run) {
2061
+ if (platform !== "darwin") {
2062
+ return {
2063
+ name: CHECK_IOS,
2064
+ status: "fail",
2065
+ message: `The iOS target requires macOS; xcrun simctl is unavailable on ${platform}.`,
2066
+ fixable: false
2067
+ };
2068
+ }
2069
+ try {
2070
+ await run("xcrun", ["simctl", "help"]);
2071
+ return { name: CHECK_IOS, status: "pass", message: "xcrun simctl is reachable", fixable: false };
2072
+ } catch (error) {
2073
+ const detail = error instanceof Error ? error.message : String(error);
2074
+ return {
2075
+ name: CHECK_IOS,
2076
+ status: "fail",
2077
+ message: `xcrun simctl is not reachable (${detail}). Install Xcode and its command-line tools (\`xcode-select --install\`), then open Xcode once to finish setup.`,
2078
+ fixable: false
2079
+ };
2080
+ }
2081
+ }
2082
+ async function checkAndroidTarget(run) {
2083
+ try {
2084
+ await run("adb", ["version"]);
2085
+ return { name: CHECK_ANDROID, status: "pass", message: "adb is reachable", fixable: false };
2086
+ } catch (error) {
2087
+ const detail = error instanceof Error ? error.message : String(error);
2088
+ return {
2089
+ name: CHECK_ANDROID,
2090
+ status: "fail",
2091
+ message: `adb is not reachable (${detail}). Install Android platform-tools and add it to your PATH (e.g. \`brew install --cask android-platform-tools\`, or via Android Studio's SDK Manager).`,
2092
+ fixable: false
2093
+ };
2094
+ }
2095
+ }
2096
+ async function runDoctorChecks(deps) {
2097
+ const results = [];
2098
+ const probe = await deps.probePlaywright();
2099
+ results.push(checkNode(deps.nodeVersion));
2100
+ results.push(checkPlaywright(probe));
2101
+ results.push(checkChromium(probe));
2102
+ const prowlExists = deps.prowlDirExists(deps.cwd);
2103
+ results.push(checkProwlDir(prowlExists));
2104
+ const { result: configResult, config } = checkConfig(prowlExists, deps.loadConfig);
2105
+ results.push(configResult);
2106
+ if (config) {
2107
+ switch (config.target.type) {
2108
+ case "macos":
2109
+ results.push(await checkMacTarget(deps.collectMacdriverStatus, deps.env));
2110
+ break;
2111
+ case "ios":
2112
+ results.push(await checkIosTarget(deps.platform, deps.runCommand));
2113
+ break;
2114
+ case "android":
2115
+ results.push(await checkAndroidTarget(deps.runCommand));
2116
+ break;
2117
+ default:
2118
+ break;
2119
+ }
2120
+ }
2121
+ return results;
2122
+ }
2123
+ async function runDoctor(deps, options = {}) {
2124
+ let results = await runDoctorChecks(deps);
2125
+ const fixes = [];
2126
+ if (options.fix) {
2127
+ for (const result of results) {
2128
+ if (!result.fixable || result.status === "pass" || result.status === "skip") {
2129
+ continue;
2130
+ }
2131
+ try {
2132
+ if (result.name === CHECK_CHROMIUM) {
2133
+ await deps.installChromium();
2134
+ fixes.push({ name: result.name });
2135
+ } else if (result.name === CHECK_PROWL_DIR) {
2136
+ deps.scaffoldProwlDir(deps.cwd);
2137
+ fixes.push({ name: result.name });
2138
+ }
2139
+ } catch (error) {
2140
+ fixes.push({
2141
+ name: result.name,
2142
+ error: error instanceof Error ? error.message : String(error)
2143
+ });
2144
+ }
2145
+ }
2146
+ if (fixes.length > 0) {
2147
+ results = await runDoctorChecks(deps);
2148
+ }
2149
+ }
2150
+ const ok = !results.some((result) => result.status === "fail");
2151
+ return { results, fixes, ok };
2152
+ }
2153
+ async function defaultProbePlaywright() {
2154
+ try {
2155
+ const pw = await import("playwright");
2156
+ let chromiumPath = null;
2157
+ try {
2158
+ chromiumPath = pw.chromium.executablePath();
2159
+ } catch {
2160
+ chromiumPath = null;
2161
+ }
2162
+ const chromiumInstalled = chromiumPath ? fs6.existsSync(chromiumPath) : false;
2163
+ return { playwrightInstalled: true, chromiumPath, chromiumInstalled };
2164
+ } catch {
2165
+ return { playwrightInstalled: false, chromiumPath: null, chromiumInstalled: false };
2166
+ }
2167
+ }
2168
+ var defaultRunCommand = async (file, args) => execFileAsync(file, args, { timeout: 5e3 });
2169
+ function createLocalRequire() {
2170
+ if (typeof __filename === "string") {
2171
+ return createRequire(__filename);
2172
+ }
2173
+ return createRequire(import.meta.url);
2174
+ }
2175
+ function resolvePlaywrightCliPath(requireFn = createLocalRequire()) {
2176
+ return path8.join(path8.dirname(requireFn.resolve("playwright/package.json")), "cli.js");
2177
+ }
2178
+ function playwrightInstallChromiumCommand() {
2179
+ return {
2180
+ file: process.execPath,
2181
+ args: [resolvePlaywrightCliPath(), "install", "chromium"]
2182
+ };
2183
+ }
2184
+ async function defaultInstallChromium() {
2185
+ const command = playwrightInstallChromiumCommand();
2186
+ await execFileAsync(command.file, command.args, { timeout: INSTALL_CHROMIUM_TIMEOUT_MS });
2187
+ }
2188
+ function defaultDoctorDeps() {
2189
+ return {
2190
+ nodeVersion: process.versions.node,
2191
+ platform: process.platform,
2192
+ cwd: process.cwd(),
2193
+ env: process.env,
2194
+ probePlaywright: defaultProbePlaywright,
2195
+ runCommand: defaultRunCommand,
2196
+ prowlDirExists: (cwd) => fs6.existsSync(path8.join(cwd, CONFIG_DIR)),
2197
+ loadConfig: () => loadConfig(),
2198
+ collectMacdriverStatus,
2199
+ installChromium: defaultInstallChromium,
2200
+ scaffoldProwlDir
2201
+ };
2202
+ }
2203
+
2204
+ // src/cli/commands/doctor.ts
2205
+ function symbol(status) {
2206
+ switch (status) {
2207
+ case "pass":
2208
+ return chalk15.green("\u2713");
2209
+ // ✓
2210
+ case "fail":
2211
+ return chalk15.red("\u2717");
2212
+ // ✗
2213
+ case "warn":
2214
+ return chalk15.yellow("\u26A0");
2215
+ // ⚠
2216
+ case "skip":
2217
+ return chalk15.gray("\u25CB");
2218
+ }
2219
+ }
2220
+ function renderResult(result) {
2221
+ console.log(` ${symbol(result.status)} ${chalk15.bold(result.name)}`);
2222
+ console.log(` ${chalk15.gray(result.message)}`);
2223
+ }
2224
+ function renderReport(report, fixRequested) {
2225
+ console.log(chalk15.bold("\nprowl doctor\n"));
2226
+ for (const result of report.results) {
2227
+ renderResult(result);
2228
+ }
2229
+ if (fixRequested) {
2230
+ console.log("");
2231
+ if (report.fixes.length === 0) {
2232
+ console.log(chalk15.gray(" No auto-fixable issues to repair."));
2233
+ } else {
2234
+ console.log(chalk15.bold(" Attempted fixes:"));
2235
+ for (const fix of report.fixes) {
2236
+ if (fix.error) {
2237
+ console.log(` ${chalk15.red("\u2717")} ${fix.name} \u2014 ${chalk15.gray(fix.error)}`);
2238
+ } else {
2239
+ console.log(` ${chalk15.green("\u2713")} ${fix.name}`);
2240
+ }
2241
+ }
2242
+ }
2243
+ }
2244
+ const counts = { pass: 0, warn: 0, fail: 0, skip: 0 };
2245
+ for (const result of report.results) {
2246
+ counts[result.status] += 1;
2247
+ }
2248
+ const parts = [
2249
+ chalk15.green(`${counts.pass} passed`),
2250
+ counts.warn > 0 ? chalk15.yellow(`${counts.warn} warning${counts.warn === 1 ? "" : "s"}`) : null,
2251
+ counts.fail > 0 ? chalk15.red(`${counts.fail} failed`) : null,
2252
+ counts.skip > 0 ? chalk15.gray(`${counts.skip} skipped`) : null
2253
+ ].filter((part) => part !== null);
2254
+ console.log("\n " + parts.join(chalk15.gray(", ")));
2255
+ if (counts.fail > 0) {
2256
+ console.log(chalk15.red.bold(" Environment has problems \u2014 fix the failures above, then re-run `prowl doctor`.\n"));
2257
+ } else if (counts.warn > 0) {
2258
+ console.log(chalk15.yellow.bold(" Environment is usable, with warnings.\n"));
2259
+ } else {
2260
+ console.log(chalk15.green.bold(" Environment is healthy.\n"));
2261
+ }
2262
+ }
2263
+ function buildDoctorCommand(deps = defaultDoctorDeps()) {
2264
+ return new Command14("doctor").description("Check that your environment is set up to run Prowl hunts").option("--fix", "Attempt safe auto-repairs: install Chromium and scaffold a missing .prowl/").action(async (options) => {
2265
+ try {
2266
+ const report = await runDoctor(deps, { fix: options.fix });
2267
+ renderReport(report, Boolean(options.fix));
2268
+ if (!report.ok) {
2269
+ process.exitCode = 1;
2270
+ }
2271
+ } catch (error) {
2272
+ const message = error instanceof Error ? error.message : "doctor failed";
2273
+ console.error(chalk15.red(`Error: ${message}
2274
+ Re-run \`prowl doctor\`; if it fails again, report the error.`));
2275
+ process.exitCode = 1;
2276
+ }
2277
+ });
2278
+ }
2279
+
1702
2280
  // src/cli/program.ts
1703
2281
  var CLI_VERSION = package_default.version;
1704
2282
  function buildProgram() {
1705
- const program2 = new Command14();
2283
+ const program2 = new Command15();
1706
2284
  program2.name("prowl").description("CLI-first QA testing tool for deterministic Playwright flows").version(CLI_VERSION);
1707
2285
  program2.addCommand(buildRunCommand());
1708
2286
  program2.addCommand(buildCiCommand());
@@ -1717,6 +2295,7 @@ function buildProgram() {
1717
2295
  program2.addCommand(buildFlakyCommand());
1718
2296
  program2.addCommand(buildMcpCommand());
1719
2297
  program2.addCommand(buildMacdriverCommand());
2298
+ program2.addCommand(buildDoctorCommand());
1720
2299
  return program2;
1721
2300
  }
1722
2301