prowl-tools 0.1.8 → 0.1.9

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.cjs CHANGED
@@ -1128,12 +1128,12 @@ var init_visual = __esm({
1128
1128
  });
1129
1129
 
1130
1130
  // src/cli/program.ts
1131
- var import_commander14 = require("commander");
1131
+ var import_commander15 = require("commander");
1132
1132
 
1133
1133
  // package.json
1134
1134
  var package_default = {
1135
1135
  name: "prowl-tools",
1136
- version: "0.1.8",
1136
+ version: "0.1.9",
1137
1137
  description: "E2E testing for native macOS apps and web apps from declarative YAML hunts.",
1138
1138
  type: "module",
1139
1139
  license: "Apache-2.0",
@@ -1950,10 +1950,15 @@ ${macdriverBuildInstructions()}`
1950
1950
  ${macdriverBuildInstructions()}`);
1951
1951
  }
1952
1952
  var DEFAULT_REQUEST_TIMEOUT_MS = 3e4;
1953
+ function errorMessage(error) {
1954
+ return error instanceof Error ? error.message : String(error);
1955
+ }
1953
1956
  var SpawnMacHelperClient = class {
1954
1957
  child;
1955
1958
  pending = /* @__PURE__ */ new Map();
1956
1959
  requestTimeoutMs;
1960
+ onEvent;
1961
+ onEventError;
1957
1962
  stdoutBuffer = "";
1958
1963
  stderrBuffer = "";
1959
1964
  nextId = 1;
@@ -1961,6 +1966,9 @@ var SpawnMacHelperClient = class {
1961
1966
  terminalError;
1962
1967
  constructor(binaryPath, options = {}) {
1963
1968
  this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
1969
+ this.onEvent = options.onEvent;
1970
+ this.onEventError = options.onEventError ?? ((message) => process.stderr.write(`${message}
1971
+ `));
1964
1972
  this.child = (0, import_node_child_process2.spawn)(binaryPath, ["serve"], { stdio: ["pipe", "pipe", "pipe"] });
1965
1973
  this.child.stdout?.setEncoding("utf-8");
1966
1974
  this.child.stderr?.setEncoding("utf-8");
@@ -1997,6 +2005,10 @@ var SpawnMacHelperClient = class {
1997
2005
  } catch {
1998
2006
  return;
1999
2007
  }
2008
+ if (typeof message.event === "string" && typeof message.id !== "number") {
2009
+ this.handleEvent(message);
2010
+ return;
2011
+ }
2000
2012
  const id = typeof message.id === "number" ? message.id : void 0;
2001
2013
  if (id === void 0) {
2002
2014
  return;
@@ -2013,6 +2025,18 @@ var SpawnMacHelperClient = class {
2013
2025
  pending.reject(new Error(typeof message.error === "string" ? message.error : "prowl-macdriver error"));
2014
2026
  }
2015
2027
  }
2028
+ handleEvent(event) {
2029
+ try {
2030
+ this.onEvent?.(event);
2031
+ } catch (error) {
2032
+ try {
2033
+ this.onEventError(
2034
+ `prowl-macdriver event sink failed for event "${event.event}": ${errorMessage(error)}`
2035
+ );
2036
+ } catch {
2037
+ }
2038
+ }
2039
+ }
2016
2040
  failAll(error) {
2017
2041
  for (const pending of this.pending.values()) {
2018
2042
  clearTimeout(pending.timer);
@@ -2924,12 +2948,12 @@ var Uia2Transport = class {
2924
2948
  * {@link Uia2HttpError} on a non-2xx response, or a timeout error when the
2925
2949
  * per-request deadline elapses.
2926
2950
  */
2927
- async request(method, path27, body, timeoutMs) {
2951
+ async request(method, path28, body, timeoutMs) {
2928
2952
  const requestTimeoutMs = timeoutMs ?? this.requestTimeoutMs;
2929
2953
  const controller = new AbortController();
2930
2954
  const timer = setTimeout(() => controller.abort(), requestTimeoutMs);
2931
2955
  timer.unref?.();
2932
- const url = `${this.baseUrl}${path27}`;
2956
+ const url = `${this.baseUrl}${path28}`;
2933
2957
  let response;
2934
2958
  try {
2935
2959
  response = await this.fetchImpl(url, {
@@ -2941,7 +2965,7 @@ var Uia2Transport = class {
2941
2965
  } catch (error) {
2942
2966
  if (controller.signal.aborted) {
2943
2967
  const shown = requestTimeoutMs >= 1e3 ? `${Math.round(requestTimeoutMs / 1e3)}s` : `${requestTimeoutMs}ms`;
2944
- throw new Error(`uiautomator2 request ${method} ${path27} timed out after ${shown}`);
2968
+ throw new Error(`uiautomator2 request ${method} ${path28} timed out after ${shown}`);
2945
2969
  }
2946
2970
  throw error instanceof Error ? error : new Error(String(error));
2947
2971
  } finally {
@@ -2952,7 +2976,7 @@ var Uia2Transport = class {
2952
2976
  if (!response.ok) {
2953
2977
  const wdError = extractWebdriverError(parsed);
2954
2978
  throw new Uia2HttpError(
2955
- `uiautomator2 ${method} ${path27} failed (${response.status})${wdError ? `: ${wdError}` : ""}`,
2979
+ `uiautomator2 ${method} ${path28} failed (${response.status})${wdError ? `: ${wdError}` : ""}`,
2956
2980
  response.status,
2957
2981
  wdError
2958
2982
  );
@@ -3035,10 +3059,10 @@ function sleep(ms) {
3035
3059
  }
3036
3060
  function createUia2AgentClient(transport, sessionId, options = {}) {
3037
3061
  const base = `/session/${sessionId}`;
3038
- async function locate(query, path27) {
3062
+ async function locate(query, path28) {
3039
3063
  return transport.request(
3040
3064
  "POST",
3041
- `${base}${path27}`,
3065
+ `${base}${path28}`,
3042
3066
  androidQueryToLocator(query, { appPackage: options.appPackage })
3043
3067
  );
3044
3068
  }
@@ -3839,15 +3863,15 @@ var WdaTransport = class {
3839
3863
  * {@link WdaHttpError} on a non-2xx response, or a timeout error when the
3840
3864
  * per-request deadline elapses.
3841
3865
  */
3842
- async requestFull(method, path27, body, timeoutMs) {
3866
+ async requestFull(method, path28, body, timeoutMs) {
3843
3867
  const requestTimeoutMs = timeoutMs ?? this.requestTimeoutMs;
3844
3868
  const controller = new AbortController();
3845
3869
  const timer = setTimeout(() => controller.abort(), requestTimeoutMs);
3846
3870
  timer.unref?.();
3847
- const url = `${this.baseUrl}${path27}`;
3871
+ const url = `${this.baseUrl}${path28}`;
3848
3872
  const timeoutError = () => {
3849
3873
  const shown = requestTimeoutMs >= 1e3 ? `${Math.round(requestTimeoutMs / 1e3)}s` : `${requestTimeoutMs}ms`;
3850
- return new Error(`WebDriverAgent request ${method} ${path27} timed out after ${shown}`);
3874
+ return new Error(`WebDriverAgent request ${method} ${path28} timed out after ${shown}`);
3851
3875
  };
3852
3876
  let response;
3853
3877
  try {
@@ -3879,7 +3903,7 @@ var WdaTransport = class {
3879
3903
  if (!response.ok) {
3880
3904
  const wdError = extractWebdriverError2(parsed);
3881
3905
  throw new WdaHttpError(
3882
- `WebDriverAgent ${method} ${path27} failed (${response.status})${wdError ? `: ${wdError}` : ""}`,
3906
+ `WebDriverAgent ${method} ${path28} failed (${response.status})${wdError ? `: ${wdError}` : ""}`,
3883
3907
  response.status,
3884
3908
  wdError
3885
3909
  );
@@ -3887,8 +3911,8 @@ var WdaTransport = class {
3887
3911
  return parsed;
3888
3912
  }
3889
3913
  /** Like {@link requestFull} but returns just the `value` field. */
3890
- async request(method, path27, body, timeoutMs) {
3891
- const parsed = await this.requestFull(method, path27, body, timeoutMs);
3914
+ async request(method, path28, body, timeoutMs) {
3915
+ const parsed = await this.requestFull(method, path28, body, timeoutMs);
3892
3916
  return parsed?.value;
3893
3917
  }
3894
3918
  };
@@ -3964,8 +3988,8 @@ function sleep2(ms) {
3964
3988
  }
3965
3989
  function createWdaAgentClient(transport, sessionId) {
3966
3990
  const base = `/session/${sessionId}`;
3967
- async function locate(query, path27) {
3968
- return transport.request("POST", `${base}${path27}`, iosQueryToLocator(query));
3991
+ async function locate(query, path28) {
3992
+ return transport.request("POST", `${base}${path28}`, iosQueryToLocator(query));
3969
3993
  }
3970
3994
  return {
3971
3995
  async findElement(query) {
@@ -7189,6 +7213,7 @@ function buildRunCommand() {
7189
7213
 
7190
7214
  // src/cli/commands/init.ts
7191
7215
  var import_node_fs15 = __toESM(require("fs"), 1);
7216
+ var import_node_os5 = __toESM(require("os"), 1);
7192
7217
  var import_node_path16 = __toESM(require("path"), 1);
7193
7218
  var import_node_url2 = require("url");
7194
7219
  var import_commander2 = require("commander");
@@ -7214,6 +7239,192 @@ function copyFile(source, destination) {
7214
7239
  import_node_fs15.default.mkdirSync(import_node_path16.default.dirname(destination), { recursive: true });
7215
7240
  import_node_fs15.default.copyFileSync(source, destination);
7216
7241
  }
7242
+ function isInsideDir(parent, child) {
7243
+ const relative = import_node_path16.default.relative(parent, child);
7244
+ return relative.length > 0 && !relative.startsWith("..") && !import_node_path16.default.isAbsolute(relative);
7245
+ }
7246
+ function lstatIfExists(target) {
7247
+ try {
7248
+ return import_node_fs15.default.lstatSync(target);
7249
+ } catch (error) {
7250
+ const code = error.code;
7251
+ if (code === "ENOENT" || code === "ENOTDIR") {
7252
+ return null;
7253
+ }
7254
+ throw error;
7255
+ }
7256
+ }
7257
+ function errorMessage2(error) {
7258
+ return error instanceof Error ? error.message : String(error);
7259
+ }
7260
+ function assertNoSymlinkDestination(prowlDir, destination) {
7261
+ const relative = import_node_path16.default.relative(prowlDir, destination);
7262
+ if (relative.startsWith("..") || import_node_path16.default.isAbsolute(relative)) {
7263
+ throw new Error(`Refusing to write outside ${CONFIG_DIR}: ${destination}`);
7264
+ }
7265
+ const parts = relative.split(import_node_path16.default.sep).filter((part) => part.length > 0);
7266
+ let current = prowlDir;
7267
+ const rootStat = lstatIfExists(current);
7268
+ if (rootStat?.isSymbolicLink()) {
7269
+ throw new Error(
7270
+ `${CONFIG_DIR} scaffold destination path contains a symlink: ${current}. Replace it with a real file or directory before running prowl init --force.`
7271
+ );
7272
+ }
7273
+ if (rootStat && !rootStat.isDirectory()) {
7274
+ throw new Error(`${CONFIG_DIR} scaffold path is not a directory: ${current}`);
7275
+ }
7276
+ for (let index = 0; index < parts.length; index += 1) {
7277
+ current = import_node_path16.default.join(current, parts[index] ?? "");
7278
+ const stat = lstatIfExists(current);
7279
+ if (stat?.isSymbolicLink()) {
7280
+ throw new Error(
7281
+ `${CONFIG_DIR} scaffold destination path contains a symlink: ${current}. Replace it with a real file or directory before running prowl init --force.`
7282
+ );
7283
+ }
7284
+ if (stat && index < parts.length - 1 && !stat.isDirectory()) {
7285
+ throw new Error(`${CONFIG_DIR} scaffold parent path is not a directory: ${current}`);
7286
+ }
7287
+ }
7288
+ }
7289
+ function gitignoreTemplate() {
7290
+ return [
7291
+ "# Run artifacts (screenshots, logs, reports)",
7292
+ "runs/",
7293
+ "",
7294
+ "# Auth state (tokens, cookies)",
7295
+ "auth-state.json",
7296
+ "",
7297
+ "# Environment variables (credentials)",
7298
+ ".env",
7299
+ ""
7300
+ ].join("\n");
7301
+ }
7302
+ function stageScaffoldTemplates(examplesDir) {
7303
+ const stageDir = import_node_fs15.default.mkdtempSync(import_node_path16.default.join(import_node_os5.default.tmpdir(), "prowl-init-"));
7304
+ const files = [];
7305
+ try {
7306
+ copyFile(import_node_path16.default.join(examplesDir, "config.yml"), import_node_path16.default.join(stageDir, "config.yml"));
7307
+ files.push({ source: import_node_path16.default.join(stageDir, "config.yml"), relativePath: "config.yml" });
7308
+ const exampleHuntsDir = import_node_path16.default.join(examplesDir, "hunts");
7309
+ const huntFiles = import_node_fs15.default.readdirSync(exampleHuntsDir).filter((f) => f.endsWith(".yml"));
7310
+ for (const huntFile of huntFiles) {
7311
+ const relativePath = import_node_path16.default.join("hunts", huntFile);
7312
+ copyFile(import_node_path16.default.join(exampleHuntsDir, huntFile), import_node_path16.default.join(stageDir, relativePath));
7313
+ files.push({ source: import_node_path16.default.join(stageDir, relativePath), relativePath });
7314
+ }
7315
+ const gitignorePath = import_node_path16.default.join(stageDir, ".gitignore");
7316
+ import_node_fs15.default.writeFileSync(gitignorePath, gitignoreTemplate());
7317
+ files.push({ source: gitignorePath, relativePath: ".gitignore" });
7318
+ return { stageDir, files };
7319
+ } catch (error) {
7320
+ import_node_fs15.default.rmSync(stageDir, { recursive: true, force: true });
7321
+ throw error;
7322
+ }
7323
+ }
7324
+ function trackCreatedParentDirs(destination, prowlDir, createdDirs) {
7325
+ let dir = import_node_path16.default.dirname(destination);
7326
+ while (isInsideDir(prowlDir, dir) && !import_node_fs15.default.existsSync(dir)) {
7327
+ createdDirs.add(dir);
7328
+ dir = import_node_path16.default.dirname(dir);
7329
+ }
7330
+ }
7331
+ function prepareDestinationRollback(prowlDir, files) {
7332
+ const backupDir = import_node_fs15.default.mkdtempSync(import_node_path16.default.join(import_node_os5.default.tmpdir(), "prowl-init-rollback-"));
7333
+ const backups = [];
7334
+ const createdFiles = [];
7335
+ const createdDirs = /* @__PURE__ */ new Set();
7336
+ const prowlDirExisted = import_node_fs15.default.existsSync(prowlDir);
7337
+ try {
7338
+ for (const file of files) {
7339
+ const destination = import_node_path16.default.join(prowlDir, file.relativePath);
7340
+ assertNoSymlinkDestination(prowlDir, destination);
7341
+ const stat = lstatIfExists(destination);
7342
+ if (stat) {
7343
+ if (!stat.isFile()) {
7344
+ throw new Error(`${CONFIG_DIR} scaffold destination is not a regular file: ${destination}`);
7345
+ }
7346
+ const backup = import_node_path16.default.join(backupDir, file.relativePath);
7347
+ copyFile(destination, backup);
7348
+ backups.push({ destination, backup });
7349
+ } else {
7350
+ createdFiles.push(destination);
7351
+ }
7352
+ if (prowlDirExisted) {
7353
+ trackCreatedParentDirs(destination, prowlDir, createdDirs);
7354
+ }
7355
+ }
7356
+ return {
7357
+ prowlDirExisted,
7358
+ backupDir,
7359
+ backups,
7360
+ createdFiles,
7361
+ createdDirs: [...createdDirs].sort((a, b) => b.length - a.length)
7362
+ };
7363
+ } catch (error) {
7364
+ import_node_fs15.default.rmSync(backupDir, { recursive: true, force: true });
7365
+ throw error;
7366
+ }
7367
+ }
7368
+ function rollbackDestination(prowlDir, rollback) {
7369
+ if (!rollback.prowlDirExisted) {
7370
+ import_node_fs15.default.rmSync(prowlDir, { recursive: true, force: true });
7371
+ return;
7372
+ }
7373
+ for (const file of rollback.createdFiles) {
7374
+ import_node_fs15.default.rmSync(file, { force: true });
7375
+ }
7376
+ for (const backup of rollback.backups) {
7377
+ try {
7378
+ copyFile(backup.backup, backup.destination);
7379
+ } catch (error) {
7380
+ throw new Error(
7381
+ `Failed to restore ${backup.destination} from backup ${backup.backup}: ${errorMessage2(error)}`
7382
+ );
7383
+ }
7384
+ }
7385
+ for (const dir of rollback.createdDirs) {
7386
+ try {
7387
+ import_node_fs15.default.rmdirSync(dir);
7388
+ } catch {
7389
+ }
7390
+ }
7391
+ }
7392
+ function scaffoldProwlDir(root) {
7393
+ const prowlDir = import_node_path16.default.join(root, CONFIG_DIR);
7394
+ const packageRoot = getPackageRoot2();
7395
+ const examplesDir = import_node_path16.default.join(packageRoot, "examples");
7396
+ const exampleConfig = import_node_path16.default.join(examplesDir, "config.yml");
7397
+ const exampleHuntsDir = import_node_path16.default.join(examplesDir, "hunts");
7398
+ if (!import_node_fs15.default.existsSync(exampleConfig) || !import_node_fs15.default.existsSync(exampleHuntsDir)) {
7399
+ throw new Error("Examples not found in package. Reinstall prowl-tools.");
7400
+ }
7401
+ const staged = stageScaffoldTemplates(examplesDir);
7402
+ let rollback = null;
7403
+ let keepBackup = false;
7404
+ try {
7405
+ rollback = prepareDestinationRollback(prowlDir, staged.files);
7406
+ for (const file of staged.files) {
7407
+ copyFile(file.source, import_node_path16.default.join(prowlDir, file.relativePath));
7408
+ }
7409
+ } catch (error) {
7410
+ if (rollback) {
7411
+ try {
7412
+ rollbackDestination(prowlDir, rollback);
7413
+ } catch (rollbackError) {
7414
+ keepBackup = true;
7415
+ throw new Error(
7416
+ `Scaffold failed: ${errorMessage2(error)}. Rollback failed: ${errorMessage2(rollbackError)}. Backup preserved at ${rollback.backupDir}.`
7417
+ );
7418
+ }
7419
+ }
7420
+ throw error;
7421
+ } finally {
7422
+ if (rollback && !keepBackup) {
7423
+ import_node_fs15.default.rmSync(rollback.backupDir, { recursive: true, force: true });
7424
+ }
7425
+ import_node_fs15.default.rmSync(staged.stageDir, { recursive: true, force: true });
7426
+ }
7427
+ }
7217
7428
  function buildInitCommand() {
7218
7429
  const command = new import_commander2.Command("init").option("--force", `Overwrite existing ${CONFIG_DIR} directory`).action((options) => {
7219
7430
  const root = process.cwd();
@@ -7227,39 +7438,18 @@ function buildInitCommand() {
7227
7438
  process.exitCode = 1;
7228
7439
  return;
7229
7440
  }
7230
- const packageRoot = getPackageRoot2();
7231
- const examplesDir = import_node_path16.default.join(packageRoot, "examples");
7232
- const exampleConfig = import_node_path16.default.join(examplesDir, "config.yml");
7233
- const exampleHuntsDir = import_node_path16.default.join(examplesDir, "hunts");
7234
- if (!import_node_fs15.default.existsSync(exampleConfig) || !import_node_fs15.default.existsSync(exampleHuntsDir)) {
7235
- console.error(import_chalk4.default.red("Examples not found in package. Reinstall prowl-tools."));
7441
+ try {
7442
+ scaffoldProwlDir(root);
7443
+ } catch (error) {
7444
+ console.error(import_chalk4.default.red(error instanceof Error ? error.message : "init failed"));
7236
7445
  process.exitCode = 1;
7237
7446
  return;
7238
7447
  }
7239
- copyFile(exampleConfig, import_node_path16.default.join(prowlDir, "config.yml"));
7240
- const huntFiles = import_node_fs15.default.readdirSync(exampleHuntsDir).filter((f) => f.endsWith(".yml"));
7241
- for (const huntFile of huntFiles) {
7242
- copyFile(
7243
- import_node_path16.default.join(exampleHuntsDir, huntFile),
7244
- import_node_path16.default.join(prowlDir, "hunts", huntFile)
7245
- );
7246
- }
7247
- const gitignore = [
7248
- "# Run artifacts (screenshots, logs, reports)",
7249
- "runs/",
7250
- "",
7251
- "# Auth state (tokens, cookies)",
7252
- "auth-state.json",
7253
- "",
7254
- "# Environment variables (credentials)",
7255
- ".env",
7256
- ""
7257
- ].join("\n");
7258
- import_node_fs15.default.writeFileSync(import_node_path16.default.join(prowlDir, ".gitignore"), gitignore);
7259
7448
  console.log(welcomeBanner());
7260
7449
  console.log(import_chalk4.default.green(` Initialized ${CONFIG_DIR} directory.`));
7261
7450
  console.log(import_chalk4.default.gray(" Run ") + import_chalk4.default.bold("prowl run hello") + import_chalk4.default.gray(" to get started."));
7262
- console.log(import_chalk4.default.gray(" See ") + import_chalk4.default.cyan(`${CONFIG_DIR}/hunts/login-flow.yml`) + import_chalk4.default.gray(" for a fuller example.") + "\n");
7451
+ console.log(import_chalk4.default.gray(" See ") + import_chalk4.default.cyan(`${CONFIG_DIR}/hunts/login-flow.yml`) + import_chalk4.default.gray(" (auth) and ") + import_chalk4.default.cyan(`${CONFIG_DIR}/hunts/form.yml`) + import_chalk4.default.gray(" (web forms) for fuller examples."));
7452
+ console.log(import_chalk4.default.gray(" Desktop-first? ") + import_chalk4.default.cyan(`${CONFIG_DIR}/hunts/macos-hello.yml`) + import_chalk4.default.gray(" is a macOS starter (experimental \u2014 see its comments to enable).") + "\n");
7263
7453
  });
7264
7454
  return command;
7265
7455
  }
@@ -9463,7 +9653,7 @@ async function runHuntTool(args, configPath) {
9463
9653
 
9464
9654
  // src/mcp/projects.ts
9465
9655
  var import_node_fs21 = __toESM(require("fs"), 1);
9466
- var import_node_os5 = __toESM(require("os"), 1);
9656
+ var import_node_os6 = __toESM(require("os"), 1);
9467
9657
  var import_node_path25 = __toESM(require("path"), 1);
9468
9658
  var import_yaml3 = __toESM(require("yaml"), 1);
9469
9659
  var import_zod2 = require("zod");
@@ -9476,10 +9666,10 @@ var projectRegistrySchema = import_zod2.z.object({
9476
9666
  projects: import_zod2.z.record(import_zod2.z.string().min(1), projectEntrySchema)
9477
9667
  }).strict();
9478
9668
  function defaultRegistryPath() {
9479
- return import_node_path25.default.join(import_node_os5.default.homedir(), ".prowl", "projects.yml");
9669
+ return import_node_path25.default.join(import_node_os6.default.homedir(), ".prowl", "projects.yml");
9480
9670
  }
9481
9671
  function legacyRegistryPath() {
9482
- return import_node_path25.default.join(import_node_os5.default.homedir(), ".prowlqa", "projects.yml");
9672
+ return import_node_path25.default.join(import_node_os6.default.homedir(), ".prowlqa", "projects.yml");
9483
9673
  }
9484
9674
  function resolveProjectConfigPath(root) {
9485
9675
  const preferred = import_node_path25.default.join(root, ".prowl", "config.yml");
@@ -9533,7 +9723,7 @@ function listRegisteredProjects(registry) {
9533
9723
  function textResult(data) {
9534
9724
  return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
9535
9725
  }
9536
- function errorMessage(error) {
9726
+ function errorMessage3(error) {
9537
9727
  return error instanceof Error ? error.message : String(error);
9538
9728
  }
9539
9729
  function safeStringify(value) {
@@ -9547,7 +9737,7 @@ async function toolResult(toolName, args, action) {
9547
9737
  try {
9548
9738
  return textResult(await action());
9549
9739
  } catch (error) {
9550
- throw new Error(`${toolName} failed for args=${safeStringify(args)}: ${errorMessage(error)}`, {
9740
+ throw new Error(`${toolName} failed for args=${safeStringify(args)}: ${errorMessage3(error)}`, {
9551
9741
  cause: error
9552
9742
  });
9553
9743
  }
@@ -9624,7 +9814,7 @@ async function startMcpServer(options = {}) {
9624
9814
  }
9625
9815
 
9626
9816
  // src/cli/commands/mcp.ts
9627
- function errorMessage2(error) {
9817
+ function errorMessage4(error) {
9628
9818
  return error instanceof Error ? error.message : String(error);
9629
9819
  }
9630
9820
  function buildMcpCommand() {
@@ -9635,7 +9825,7 @@ function buildMcpCommand() {
9635
9825
  try {
9636
9826
  await startMcpServer({ registryPath: options.projects });
9637
9827
  } catch (error) {
9638
- console.error(`Failed to start MCP server: ${errorMessage2(error)}`);
9828
+ console.error(`Failed to start MCP server: ${errorMessage4(error)}`);
9639
9829
  process.exit(1);
9640
9830
  }
9641
9831
  });
@@ -9649,7 +9839,7 @@ var import_commander13 = require("commander");
9649
9839
  var import_node_child_process7 = require("child_process");
9650
9840
  var import_node_crypto3 = require("crypto");
9651
9841
  var import_node_fs22 = __toESM(require("fs"), 1);
9652
- var import_node_os6 = __toESM(require("os"), 1);
9842
+ var import_node_os7 = __toESM(require("os"), 1);
9653
9843
  var import_node_path26 = __toESM(require("path"), 1);
9654
9844
  var import_node_util = require("util");
9655
9845
  var execFileAsync = (0, import_node_util.promisify)(import_node_child_process7.execFile);
@@ -9815,7 +10005,7 @@ The download was rejected and discarded; re-run the install, and report it if it
9815
10005
  }
9816
10006
  async function installMacdriver(options = {}) {
9817
10007
  const version = validateMacdriverVersion(options.version ?? MACDRIVER_VERSION);
9818
- const homedir = options.homedir ?? import_node_os6.default.homedir();
10008
+ const homedir = options.homedir ?? import_node_os7.default.homedir();
9819
10009
  const listArchiveEntries = options.listArchiveEntries ?? zipinfoArchiveLister;
9820
10010
  const extract = options.extract ?? dittoExtractor;
9821
10011
  const verifySignature = options.verifySignature ?? codesignVerifier;
@@ -9895,7 +10085,7 @@ var runVersionProbe = async (binaryPath) => {
9895
10085
  };
9896
10086
  async function collectMacdriverStatus(options = {}) {
9897
10087
  const env = options.env ?? process.env;
9898
- const homedir = options.homedir ?? import_node_os6.default.homedir();
10088
+ const homedir = options.homedir ?? import_node_os7.default.homedir();
9899
10089
  const probe = options.probe ?? runVersionProbe;
9900
10090
  let resolved = null;
9901
10091
  try {
@@ -10008,10 +10198,402 @@ function buildMacdriverCommand() {
10008
10198
  return command;
10009
10199
  }
10010
10200
 
10201
+ // src/cli/commands/doctor.ts
10202
+ var import_chalk16 = __toESM(require("chalk"), 1);
10203
+ var import_commander14 = require("commander");
10204
+
10205
+ // src/doctor/checks.ts
10206
+ var import_node_child_process8 = require("child_process");
10207
+ var import_node_module3 = require("module");
10208
+ var import_node_fs23 = __toESM(require("fs"), 1);
10209
+ var import_node_path27 = __toESM(require("path"), 1);
10210
+ var import_node_util2 = require("util");
10211
+ init_loader();
10212
+ var import_meta5 = {};
10213
+ var execFileAsync2 = (0, import_node_util2.promisify)(import_node_child_process8.execFile);
10214
+ var MIN_NODE_MAJOR = 20;
10215
+ var CHECK_NODE = "Node.js version";
10216
+ var CHECK_PLAYWRIGHT = "Playwright installed";
10217
+ var CHECK_CHROMIUM = "Chromium browser";
10218
+ var CHECK_PROWL_DIR = ".prowl/ directory";
10219
+ var CHECK_CONFIG = "config.yml valid";
10220
+ var CHECK_MACOS = "macOS helper (prowl-macdriver)";
10221
+ var CHECK_IOS = "iOS tooling (xcrun simctl)";
10222
+ var CHECK_ANDROID = "Android tooling (adb)";
10223
+ var INSTALL_CHROMIUM_TIMEOUT_MS = 3e5;
10224
+ function nodeMajor(version) {
10225
+ const cleaned = version.startsWith("v") ? version.slice(1) : version;
10226
+ return Number.parseInt(cleaned.split(".")[0] ?? "", 10);
10227
+ }
10228
+ function checkNode(nodeVersion) {
10229
+ const major = nodeMajor(nodeVersion);
10230
+ if (Number.isFinite(major) && major >= MIN_NODE_MAJOR) {
10231
+ return {
10232
+ name: CHECK_NODE,
10233
+ status: "pass",
10234
+ message: `Node ${nodeVersion} (>= ${MIN_NODE_MAJOR} required)`,
10235
+ fixable: false
10236
+ };
10237
+ }
10238
+ return {
10239
+ name: CHECK_NODE,
10240
+ status: "fail",
10241
+ 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.`,
10242
+ fixable: false
10243
+ };
10244
+ }
10245
+ function checkPlaywright(probe) {
10246
+ if (probe.playwrightInstalled) {
10247
+ return { name: CHECK_PLAYWRIGHT, status: "pass", message: "Playwright is installed", fixable: false };
10248
+ }
10249
+ return {
10250
+ name: CHECK_PLAYWRIGHT,
10251
+ status: "fail",
10252
+ 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).",
10253
+ fixable: false
10254
+ };
10255
+ }
10256
+ function checkChromium(probe) {
10257
+ if (!probe.playwrightInstalled) {
10258
+ return {
10259
+ name: CHECK_CHROMIUM,
10260
+ status: "skip",
10261
+ message: "Skipped \u2014 Playwright is not installed",
10262
+ fixable: false
10263
+ };
10264
+ }
10265
+ if (probe.chromiumInstalled) {
10266
+ return {
10267
+ name: CHECK_CHROMIUM,
10268
+ status: "pass",
10269
+ message: `Chromium is available${probe.chromiumPath ? ` (${probe.chromiumPath})` : ""}`,
10270
+ fixable: false
10271
+ };
10272
+ }
10273
+ return {
10274
+ name: CHECK_CHROMIUM,
10275
+ status: "fail",
10276
+ message: "Chromium is not installed. Run `npx playwright install chromium` (or `prowl doctor --fix`).",
10277
+ fixable: true
10278
+ };
10279
+ }
10280
+ function checkProwlDir(exists) {
10281
+ if (exists) {
10282
+ return { name: CHECK_PROWL_DIR, status: "pass", message: `${CONFIG_DIR}/ exists`, fixable: false };
10283
+ }
10284
+ return {
10285
+ name: CHECK_PROWL_DIR,
10286
+ status: "warn",
10287
+ message: `No ${CONFIG_DIR}/ directory here. Run \`prowl init\` (or \`prowl doctor --fix\`) to scaffold config and starter hunts.`,
10288
+ fixable: true
10289
+ };
10290
+ }
10291
+ function checkConfig(prowlDirExists, loadConfig2) {
10292
+ if (!prowlDirExists) {
10293
+ return {
10294
+ result: {
10295
+ name: CHECK_CONFIG,
10296
+ status: "skip",
10297
+ message: `Skipped \u2014 no ${CONFIG_DIR}/ directory`,
10298
+ fixable: false
10299
+ },
10300
+ config: null
10301
+ };
10302
+ }
10303
+ try {
10304
+ const { config } = loadConfig2();
10305
+ return {
10306
+ result: {
10307
+ name: CHECK_CONFIG,
10308
+ status: "pass",
10309
+ message: `Valid (target: ${config.target.type})`,
10310
+ fixable: false
10311
+ },
10312
+ config
10313
+ };
10314
+ } catch (error) {
10315
+ const detail = error instanceof Error ? error.message : String(error);
10316
+ return {
10317
+ result: {
10318
+ name: CHECK_CONFIG,
10319
+ status: "fail",
10320
+ message: `Invalid config: ${detail}`,
10321
+ fixable: false
10322
+ },
10323
+ config: null
10324
+ };
10325
+ }
10326
+ }
10327
+ function macdriverSourceLabel(source) {
10328
+ switch (source) {
10329
+ case "env":
10330
+ return "PROWL_MACDRIVER_BIN override";
10331
+ case "user-install":
10332
+ return "user install (~/.prowl/macdriver)";
10333
+ case "source-build":
10334
+ return "repo source build";
10335
+ }
10336
+ }
10337
+ async function checkMacTarget(collect2, env) {
10338
+ let status;
10339
+ try {
10340
+ status = await collect2({ env });
10341
+ } catch (error) {
10342
+ const detail = error instanceof Error ? error.message : String(error);
10343
+ return {
10344
+ name: CHECK_MACOS,
10345
+ status: "fail",
10346
+ message: `Could not inspect prowl-macdriver status (${detail}). Run \`prowl macdriver status\` for details.`,
10347
+ fixable: false
10348
+ };
10349
+ }
10350
+ if (status.resolved && status.probedVersion) {
10351
+ return {
10352
+ name: CHECK_MACOS,
10353
+ status: "pass",
10354
+ message: `Resolved via ${macdriverSourceLabel(status.resolved.source)}: ${status.resolved.path} (reports ${status.probedVersion}). Verify Accessibility/Screen Recording permissions with \`prowl macdriver status\`.`,
10355
+ fixable: false
10356
+ };
10357
+ }
10358
+ if (status.resolved) {
10359
+ return {
10360
+ name: CHECK_MACOS,
10361
+ status: "fail",
10362
+ message: `prowl-macdriver resolved via ${macdriverSourceLabel(status.resolved.source)} (${status.resolved.path}), but the helper version probe failed. Run \`prowl macdriver status\` for details.`,
10363
+ fixable: false
10364
+ };
10365
+ }
10366
+ return {
10367
+ name: CHECK_MACOS,
10368
+ status: "fail",
10369
+ message: "prowl-macdriver helper not found. Install it with `prowl macdriver install`, then check permissions with `prowl macdriver status`.",
10370
+ fixable: false
10371
+ };
10372
+ }
10373
+ async function checkIosTarget(platform, run) {
10374
+ if (platform !== "darwin") {
10375
+ return {
10376
+ name: CHECK_IOS,
10377
+ status: "fail",
10378
+ message: `The iOS target requires macOS; xcrun simctl is unavailable on ${platform}.`,
10379
+ fixable: false
10380
+ };
10381
+ }
10382
+ try {
10383
+ await run("xcrun", ["simctl", "help"]);
10384
+ return { name: CHECK_IOS, status: "pass", message: "xcrun simctl is reachable", fixable: false };
10385
+ } catch (error) {
10386
+ const detail = error instanceof Error ? error.message : String(error);
10387
+ return {
10388
+ name: CHECK_IOS,
10389
+ status: "fail",
10390
+ message: `xcrun simctl is not reachable (${detail}). Install Xcode and its command-line tools (\`xcode-select --install\`), then open Xcode once to finish setup.`,
10391
+ fixable: false
10392
+ };
10393
+ }
10394
+ }
10395
+ async function checkAndroidTarget(run) {
10396
+ try {
10397
+ await run("adb", ["version"]);
10398
+ return { name: CHECK_ANDROID, status: "pass", message: "adb is reachable", fixable: false };
10399
+ } catch (error) {
10400
+ const detail = error instanceof Error ? error.message : String(error);
10401
+ return {
10402
+ name: CHECK_ANDROID,
10403
+ status: "fail",
10404
+ 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).`,
10405
+ fixable: false
10406
+ };
10407
+ }
10408
+ }
10409
+ async function runDoctorChecks(deps) {
10410
+ const results = [];
10411
+ const probe = await deps.probePlaywright();
10412
+ results.push(checkNode(deps.nodeVersion));
10413
+ results.push(checkPlaywright(probe));
10414
+ results.push(checkChromium(probe));
10415
+ const prowlExists = deps.prowlDirExists(deps.cwd);
10416
+ results.push(checkProwlDir(prowlExists));
10417
+ const { result: configResult, config } = checkConfig(prowlExists, deps.loadConfig);
10418
+ results.push(configResult);
10419
+ if (config) {
10420
+ switch (config.target.type) {
10421
+ case "macos":
10422
+ results.push(await checkMacTarget(deps.collectMacdriverStatus, deps.env));
10423
+ break;
10424
+ case "ios":
10425
+ results.push(await checkIosTarget(deps.platform, deps.runCommand));
10426
+ break;
10427
+ case "android":
10428
+ results.push(await checkAndroidTarget(deps.runCommand));
10429
+ break;
10430
+ default:
10431
+ break;
10432
+ }
10433
+ }
10434
+ return results;
10435
+ }
10436
+ async function runDoctor(deps, options = {}) {
10437
+ let results = await runDoctorChecks(deps);
10438
+ const fixes = [];
10439
+ if (options.fix) {
10440
+ for (const result of results) {
10441
+ if (!result.fixable || result.status === "pass" || result.status === "skip") {
10442
+ continue;
10443
+ }
10444
+ try {
10445
+ if (result.name === CHECK_CHROMIUM) {
10446
+ await deps.installChromium();
10447
+ fixes.push({ name: result.name });
10448
+ } else if (result.name === CHECK_PROWL_DIR) {
10449
+ deps.scaffoldProwlDir(deps.cwd);
10450
+ fixes.push({ name: result.name });
10451
+ }
10452
+ } catch (error) {
10453
+ fixes.push({
10454
+ name: result.name,
10455
+ error: error instanceof Error ? error.message : String(error)
10456
+ });
10457
+ }
10458
+ }
10459
+ if (fixes.length > 0) {
10460
+ results = await runDoctorChecks(deps);
10461
+ }
10462
+ }
10463
+ const ok = !results.some((result) => result.status === "fail");
10464
+ return { results, fixes, ok };
10465
+ }
10466
+ async function defaultProbePlaywright() {
10467
+ try {
10468
+ const pw = await import("playwright");
10469
+ let chromiumPath = null;
10470
+ try {
10471
+ chromiumPath = pw.chromium.executablePath();
10472
+ } catch {
10473
+ chromiumPath = null;
10474
+ }
10475
+ const chromiumInstalled = chromiumPath ? import_node_fs23.default.existsSync(chromiumPath) : false;
10476
+ return { playwrightInstalled: true, chromiumPath, chromiumInstalled };
10477
+ } catch {
10478
+ return { playwrightInstalled: false, chromiumPath: null, chromiumInstalled: false };
10479
+ }
10480
+ }
10481
+ var defaultRunCommand = async (file, args) => execFileAsync2(file, args, { timeout: 5e3 });
10482
+ function createLocalRequire() {
10483
+ if (typeof __filename === "string") {
10484
+ return (0, import_node_module3.createRequire)(__filename);
10485
+ }
10486
+ return (0, import_node_module3.createRequire)(import_meta5.url);
10487
+ }
10488
+ function resolvePlaywrightCliPath(requireFn = createLocalRequire()) {
10489
+ return import_node_path27.default.join(import_node_path27.default.dirname(requireFn.resolve("playwright/package.json")), "cli.js");
10490
+ }
10491
+ function playwrightInstallChromiumCommand() {
10492
+ return {
10493
+ file: process.execPath,
10494
+ args: [resolvePlaywrightCliPath(), "install", "chromium"]
10495
+ };
10496
+ }
10497
+ async function defaultInstallChromium() {
10498
+ const command = playwrightInstallChromiumCommand();
10499
+ await execFileAsync2(command.file, command.args, { timeout: INSTALL_CHROMIUM_TIMEOUT_MS });
10500
+ }
10501
+ function defaultDoctorDeps() {
10502
+ return {
10503
+ nodeVersion: process.versions.node,
10504
+ platform: process.platform,
10505
+ cwd: process.cwd(),
10506
+ env: process.env,
10507
+ probePlaywright: defaultProbePlaywright,
10508
+ runCommand: defaultRunCommand,
10509
+ prowlDirExists: (cwd) => import_node_fs23.default.existsSync(import_node_path27.default.join(cwd, CONFIG_DIR)),
10510
+ loadConfig: () => loadConfig(),
10511
+ collectMacdriverStatus,
10512
+ installChromium: defaultInstallChromium,
10513
+ scaffoldProwlDir
10514
+ };
10515
+ }
10516
+
10517
+ // src/cli/commands/doctor.ts
10518
+ function symbol(status) {
10519
+ switch (status) {
10520
+ case "pass":
10521
+ return import_chalk16.default.green("\u2713");
10522
+ // ✓
10523
+ case "fail":
10524
+ return import_chalk16.default.red("\u2717");
10525
+ // ✗
10526
+ case "warn":
10527
+ return import_chalk16.default.yellow("\u26A0");
10528
+ // ⚠
10529
+ case "skip":
10530
+ return import_chalk16.default.gray("\u25CB");
10531
+ }
10532
+ }
10533
+ function renderResult(result) {
10534
+ console.log(` ${symbol(result.status)} ${import_chalk16.default.bold(result.name)}`);
10535
+ console.log(` ${import_chalk16.default.gray(result.message)}`);
10536
+ }
10537
+ function renderReport(report, fixRequested) {
10538
+ console.log(import_chalk16.default.bold("\nprowl doctor\n"));
10539
+ for (const result of report.results) {
10540
+ renderResult(result);
10541
+ }
10542
+ if (fixRequested) {
10543
+ console.log("");
10544
+ if (report.fixes.length === 0) {
10545
+ console.log(import_chalk16.default.gray(" No auto-fixable issues to repair."));
10546
+ } else {
10547
+ console.log(import_chalk16.default.bold(" Attempted fixes:"));
10548
+ for (const fix of report.fixes) {
10549
+ if (fix.error) {
10550
+ console.log(` ${import_chalk16.default.red("\u2717")} ${fix.name} \u2014 ${import_chalk16.default.gray(fix.error)}`);
10551
+ } else {
10552
+ console.log(` ${import_chalk16.default.green("\u2713")} ${fix.name}`);
10553
+ }
10554
+ }
10555
+ }
10556
+ }
10557
+ const counts = { pass: 0, warn: 0, fail: 0, skip: 0 };
10558
+ for (const result of report.results) {
10559
+ counts[result.status] += 1;
10560
+ }
10561
+ const parts = [
10562
+ import_chalk16.default.green(`${counts.pass} passed`),
10563
+ counts.warn > 0 ? import_chalk16.default.yellow(`${counts.warn} warning${counts.warn === 1 ? "" : "s"}`) : null,
10564
+ counts.fail > 0 ? import_chalk16.default.red(`${counts.fail} failed`) : null,
10565
+ counts.skip > 0 ? import_chalk16.default.gray(`${counts.skip} skipped`) : null
10566
+ ].filter((part) => part !== null);
10567
+ console.log("\n " + parts.join(import_chalk16.default.gray(", ")));
10568
+ if (counts.fail > 0) {
10569
+ console.log(import_chalk16.default.red.bold(" Environment has problems \u2014 fix the failures above, then re-run `prowl doctor`.\n"));
10570
+ } else if (counts.warn > 0) {
10571
+ console.log(import_chalk16.default.yellow.bold(" Environment is usable, with warnings.\n"));
10572
+ } else {
10573
+ console.log(import_chalk16.default.green.bold(" Environment is healthy.\n"));
10574
+ }
10575
+ }
10576
+ function buildDoctorCommand(deps = defaultDoctorDeps()) {
10577
+ return new import_commander14.Command("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) => {
10578
+ try {
10579
+ const report = await runDoctor(deps, { fix: options.fix });
10580
+ renderReport(report, Boolean(options.fix));
10581
+ if (!report.ok) {
10582
+ process.exitCode = 1;
10583
+ }
10584
+ } catch (error) {
10585
+ const message = error instanceof Error ? error.message : "doctor failed";
10586
+ console.error(import_chalk16.default.red(`Error: ${message}
10587
+ Re-run \`prowl doctor\`; if it fails again, report the error.`));
10588
+ process.exitCode = 1;
10589
+ }
10590
+ });
10591
+ }
10592
+
10011
10593
  // src/cli/program.ts
10012
10594
  var CLI_VERSION = package_default.version;
10013
10595
  function buildProgram() {
10014
- const program2 = new import_commander14.Command();
10596
+ const program2 = new import_commander15.Command();
10015
10597
  program2.name("prowl").description("CLI-first QA testing tool for deterministic Playwright flows").version(CLI_VERSION);
10016
10598
  program2.addCommand(buildRunCommand());
10017
10599
  program2.addCommand(buildCiCommand());
@@ -10026,6 +10608,7 @@ function buildProgram() {
10026
10608
  program2.addCommand(buildFlakyCommand());
10027
10609
  program2.addCommand(buildMcpCommand());
10028
10610
  program2.addCommand(buildMacdriverCommand());
10611
+ program2.addCommand(buildDoctorCommand());
10029
10612
  return program2;
10030
10613
  }
10031
10614