@youdie006/prodex 0.8.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -29,11 +29,13 @@ The most common way to use `prodex` is standalone — get a ChatGPT Pro answer o
29
29
  ```bash
30
30
  npm install -g @youdie006/prodex # needs Node 20+, git, ripgrep
31
31
 
32
- prodex pro browser login # opens a dedicated Chrome; log in to ChatGPT there
33
- prodex pro browser ask --file src/auth.ts "Review this for security holes"
32
+ prodex pro browser login # opens a dedicated Chrome and waits until your ChatGPT login is READY
33
+ prodex ask --file src/auth.ts "Review this for security holes"
34
34
  ```
35
35
 
36
- The answer prints to your terminal and is saved under `.bridge/` for later. `prodex` drives the picker you can see and deliberately will not send into a window you cannot watch but a dedicated Chrome window left non-minimized (even behind your editor) counts as watchable, so it sends quietly in the background without stealing focus. Just don't minimize it or switch that window to another tab. Add `--pro-mode 확장` for extended reasoning, or `--file` more than once to attach several files. See [First Pro Login](#first-pro-login) for the full flow, and the [FAQ](#faq) if a send stops.
36
+ `prodex ask` is the short form of `prodex pro browser ask`; the full form and every flag work identically. In an interactive terminal, `login` keeps watching the opened window and tells you exactly which manual step is still missing (log in, clear a check, open a chat) until it reports READY. While ChatGPT thinks, `prodex` prints progress to stderr (connecting, prompt sent, elapsed seconds while generating), so a multi-minute Pro answer never looks frozen.
37
+
38
+ The answer prints to your terminal and is saved under `.bridge/` for later (`prodex pro latest` re-prints it). `prodex` drives the picker you can see and deliberately will not send into a window you cannot watch — but a dedicated Chrome window left non-minimized (even behind your editor) counts as watchable, so it sends quietly in the background without stealing focus. Just don't minimize it or switch that window to another tab. Add `--pro-mode 확장` for extended reasoning, or `--file` more than once to attach several files. See [First Pro Login](#first-pro-login) for the full flow, and the [FAQ](#faq) if a send stops.
37
39
 
38
40
  ## Core Shape
39
41
 
@@ -97,9 +99,11 @@ Not implemented:
97
99
  - Shell execution tools.
98
100
  - Automatic public tunnel setup.
99
101
 
100
- ## Quick Start
102
+ ## Agent Bridge Quick Start
103
+
104
+ This section connects coding agents (Claude, Codex, ChatGPT Projects) to the bridge over MCP. It is not required for the standalone terminal flow above — if you only want Pro answers in your terminal, the [Quickstart](#quickstart-a-pro-second-opinion-from-your-terminal) is complete on its own.
101
105
 
102
- Requires Node.js 20 or newer, `git`, and `ripgrep` (`rg`) on PATH. The optional visible-browser adapter also needs a Chromium-family browser (`google-chrome`, `chromium`, `chromium-browser`, `microsoft-edge`, `brave-browser`) or `PRODEX_CHROME=/path/to/browser`.
106
+ Requires Node.js 20 or newer, `git`, and `ripgrep` (`rg`) on PATH. The optional visible-browser adapter needs a Chromium-family browser: PATH binaries (`google-chrome`, `chromium`, `chromium-browser`, `microsoft-edge`, `brave-browser`), standard macOS app bundles, Windows Program Files/LOCALAPPDATA installs, and Windows-host browsers under WSL are all probed automatically; anything else via `PRODEX_CHROME=/path/to/browser`.
103
107
 
104
108
  Install from npm — **note the scope**. The unscoped `prodex` on npm is an unrelated third-party package; do **not** install it. Use the scoped name:
105
109
 
@@ -153,7 +157,7 @@ prodex pro browser check
153
157
  prodex pro browser smoke --cwd /absolute/path/to/your/repo
154
158
  ```
155
159
 
156
- If you use a non-default debug port or Chrome profile, pass it to `login`; the printed follow-up `check` and `smoke` commands keep the matching `--port`. If you launch from outside the repo you want to inspect, pass `--cwd /absolute/path/to/your/repo` to `login`, `check`, or `smoke` so the command targets the same bridge. On slower first launches, add `--launch-timeout-ms 12000`.
160
+ If you use a non-default debug port or Chrome profile, pass it to `login`; the printed follow-up `check` and `smoke` commands keep the matching `--port`. To stop repeating `--port` on every command, export `PRODEX_CDP_PORT=<port>` once — explicit `--port` still wins. If you launch from outside the repo you want to inspect, pass `--cwd /absolute/path/to/your/repo` to `login`, `check`, or `smoke` so the command targets the same bridge. On slower first launches, add `--launch-timeout-ms 12000`.
157
161
 
158
162
  For a source checkout, keep the follow-up commands in source-checkout form too:
159
163
 
@@ -170,7 +174,7 @@ node "$SOURCE_CLI" pro browser smoke --source-cli "$SOURCE_CLI" --cwd /absolute/
170
174
  What happens:
171
175
 
172
176
  - `login --dry-run` prints the dedicated Chrome profile, debug URL, and next commands without opening a browser.
173
- - `login` opens that dedicated Chrome profile at ChatGPT.
177
+ - `login` opens that dedicated Chrome profile at ChatGPT. In an interactive terminal it then waits (default 5 minutes; `--no-wait` skips, `--wait-timeout-ms` tunes) and narrates which manual step is still missing until it reports READY; scripts and agents get the immediate return unless they pass `--wait`.
174
178
  - You log in manually in the visible browser.
175
179
  - If ChatGPT asks for captcha, Cloudflare/human verification, permission, or account verification, handle it in that browser.
176
180
  - If ChatGPT shows a usage limit, message limit, model limit, or rate limit, wait for the reset or choose an available model in the browser.
@@ -184,11 +188,11 @@ What happens:
184
188
 
185
189
  You can close that Chrome window after check/smoke or when you are done. The next time you need it, run `pro browser login` or `pro browser check` again. `check` will tell you what to do if the browser is closed.
186
190
 
187
- Actual explicit visible-browser consult:
191
+ Actual explicit visible-browser consult (`prodex ask` is the short form of `prodex pro browser ask`):
188
192
 
189
193
  ```bash
190
194
  cd /absolute/path/to/your/repo
191
- prodex pro browser ask --cwd /absolute/path/to/your/repo --file README.md "Review the project positioning"
195
+ prodex ask --file README.md "Review the project positioning"
192
196
  prodex pro latest
193
197
  prodex results show latest
194
198
  prodex results artifact latest
@@ -386,9 +390,26 @@ npm run dev -- tasks list
386
390
 
387
391
  **Windows / macOS?** The code targets all three platforms, but the visible-browser adapter is exercised most on Linux; open an issue with details if a browser step misbehaves on macOS or Windows.
388
392
 
393
+ ## Releasing
394
+
395
+ Publishing to npm runs entirely in CI with **no long-lived token** — auth is npm [trusted publishing](https://docs.npmjs.com/trusted-publishers) (OIDC), so nothing needs to store or paste an `NPM_TOKEN`, and every release carries a verifiable `--provenance` attestation.
396
+
397
+ Release flow:
398
+
399
+ ```bash
400
+ # 1. bump version + update CHANGELOG on main, commit, push main
401
+ # 2. tag the release and push the tag — CI publishes it
402
+ git tag v0.8.2
403
+ git push origin v0.8.2
404
+ ```
405
+
406
+ `.github/workflows/publish.yml` fires on a `v*.*.*` tag: it checks out, installs, verifies the tag equals `package.json`'s version, runs `release:verify`, and publishes with `npm publish --provenance --access public`. The tag/version guard prevents publishing a mismatched version.
407
+
408
+ One-time setup (owner, on npmjs.com): open the package → Settings → Trusted Publishing → add a GitHub Actions publisher for repo `youdie006/prodex` and workflow `publish.yml`. After that, no npm tokens are needed anywhere; revoke any previously issued automation tokens.
409
+
389
410
  ## Release Checks
390
411
 
391
- GitHub Actions runs `npm ci`, `npm run release:check`, and `npm run release:verify` on pushes to `main` and pull requests. The workflow installs `ripgrep` because the repo-search smoke checks require `rg`. It verifies release readiness only; it does not publish anything.
412
+ GitHub Actions runs `npm ci`, `npm run build`, `npm run release:check`, and `npm run release:verify` on pushes to `main` and pull requests. The workflow installs `ripgrep` because the repo-search smoke checks require `rg`. It verifies release readiness only; it does not publish anything.
392
413
 
393
414
  Before sharing a package tarball, run:
394
415
 
@@ -40,6 +40,29 @@ export type ChatGptProMode = "기본" | "확장";
40
40
  export declare function parseReasoningEffort(raw: string): ChatGptReasoningEffort;
41
41
  /** Normalize a CLI Pro sub-mode value onto the exact ChatGPT menu label. */
42
42
  export declare function parseProMode(raw: string): ChatGptProMode;
43
+ export declare const DEFAULT_CDP_PORT = 9333;
44
+ /**
45
+ * Stability policy for the answer poll loop. Returns true once the observed
46
+ * (answer, generating) stream should be accepted as final:
47
+ * - normal answers: two consecutive identical, non-generating polls;
48
+ * - answers whose tail looks like the streaming caret: six additional
49
+ * confirmations (~6s at the 1s poll cadence), so a lingering caret gets
50
+ * dropped by ChatGPT's finalization re-render first, while an answer that
51
+ * genuinely ends with an underscore is still accepted after the extra wait.
52
+ */
53
+ export declare function createChatGptAnswerStabilityTracker(): (answer: string, generating: boolean) => boolean;
54
+ /**
55
+ * Resolve the Chrome DevTools port: explicit flag > PRODEX_CDP_PORT env >
56
+ * default 9333. The env var exists so a non-default port picked at login does
57
+ * not have to be repeated as --port on every later command.
58
+ */
59
+ export declare function resolveCdpPort(explicit?: number): number;
60
+ export type SendChatGptProgressPhase = "connecting" | "tab_ready" | "selecting" | "sent" | "waiting" | "answered";
61
+ export interface SendChatGptProgressEvent {
62
+ phase: SendChatGptProgressPhase;
63
+ elapsedMs: number;
64
+ detail?: string;
65
+ }
43
66
  export interface SendChatGptPromptOptions {
44
67
  port?: number;
45
68
  prompt: string;
@@ -55,6 +78,8 @@ export interface SendChatGptPromptOptions {
55
78
  proMode?: ChatGptProMode;
56
79
  /** Reasoning effort, used for non-Pro models. */
57
80
  effort?: ChatGptReasoningEffort;
81
+ /** Progress callback so long sends can report phase + elapsed instead of staying silent. */
82
+ onProgress?: (event: SendChatGptProgressEvent) => void;
58
83
  }
59
84
  export interface SendChatGptPromptResult {
60
85
  url: string;
@@ -132,6 +157,13 @@ export declare function assertChatGptTargetTabAvailable(targetUrl: string): void
132
157
  export declare function assertChatGptPageAvailable(): never;
133
158
  export declare function assertChatGptReadyForPrompt(loggedInLikely: boolean, hasComposer: boolean): void;
134
159
  export declare function chatGptVisibilityBlocker(visibilityState: string | undefined, url: string | undefined): ChatGptBrowserStatus["blocker"] | undefined;
160
+ /**
161
+ * Environment for the browser spawn. On Linux shells without DISPLAY or
162
+ * WAYLAND_DISPLAY (common in WSL where a non-login shell does not export
163
+ * them), a present WSLg X socket means a desktop is actually available -
164
+ * inject DISPLAY=:0 so Chrome does not exit immediately with code 1.
165
+ */
166
+ export declare function browserLaunchEnv(platform?: NodeJS.Platform, env?: Record<string, string | undefined>, hasX0Socket?: () => boolean): Record<string, string | undefined>;
135
167
  export declare function openChatGptBrowser(options?: ChatGptBrowserOptions): ChatGptBrowserLaunch;
136
168
  export declare function getChatGptBrowserStatus(options?: {
137
169
  port?: number;
@@ -169,4 +201,10 @@ export declare function statusExpression(): string;
169
201
  export declare function prepareComposerExpression(): string;
170
202
  export declare function composerTextStateExpression(): string;
171
203
  export declare function submitExpression(): string;
204
+ /**
205
+ * Ordered browser candidates for the current platform: PATH binary names
206
+ * first, then well-known absolute install locations (macOS app bundles,
207
+ * Windows Program Files/LOCALAPPDATA, and Windows-host browsers under WSL).
208
+ */
209
+ export declare function chromeCommandCandidates(platform?: NodeJS.Platform, env?: Record<string, string | undefined>): string[];
172
210
  export {};
@@ -60,6 +60,60 @@ export function parseProMode(raw) {
60
60
  return alias;
61
61
  throw new Error(`--pro-mode must be one of ${PRO_MODES.join(", ")}`);
62
62
  }
63
+ export const DEFAULT_CDP_PORT = 9333;
64
+ // The ChatGPT streaming caret renders as a literal text character at the end
65
+ // of the growing assistant message (a plain "_" in the current UI; "▍" and
66
+ // friends historically). Measured live 2026-07-06: the caret can stay in
67
+ // innerText for a beat AFTER the stop button disappears, so a short answer can
68
+ // look stable and non-generating while still carrying the caret.
69
+ const TRAILING_CARET_SUSPECT = /[_▍▌█▊▋]$/;
70
+ /**
71
+ * Stability policy for the answer poll loop. Returns true once the observed
72
+ * (answer, generating) stream should be accepted as final:
73
+ * - normal answers: two consecutive identical, non-generating polls;
74
+ * - answers whose tail looks like the streaming caret: six additional
75
+ * confirmations (~6s at the 1s poll cadence), so a lingering caret gets
76
+ * dropped by ChatGPT's finalization re-render first, while an answer that
77
+ * genuinely ends with an underscore is still accepted after the extra wait.
78
+ */
79
+ export function createChatGptAnswerStabilityTracker() {
80
+ let stableAnswer;
81
+ let confirmations = 0;
82
+ return (answer, generating) => {
83
+ if (generating) {
84
+ // A generating poll invalidates any baseline: the text may still grow.
85
+ stableAnswer = undefined;
86
+ confirmations = 0;
87
+ return false;
88
+ }
89
+ if (answer !== stableAnswer) {
90
+ stableAnswer = answer;
91
+ confirmations = 0;
92
+ return false;
93
+ }
94
+ confirmations += 1;
95
+ const required = TRAILING_CARET_SUSPECT.test(answer) ? 8 : 2;
96
+ return confirmations >= required;
97
+ };
98
+ }
99
+ /**
100
+ * Resolve the Chrome DevTools port: explicit flag > PRODEX_CDP_PORT env >
101
+ * default 9333. The env var exists so a non-default port picked at login does
102
+ * not have to be repeated as --port on every later command.
103
+ */
104
+ export function resolveCdpPort(explicit) {
105
+ if (explicit !== undefined)
106
+ return explicit;
107
+ const fromEnv = process.env.PRODEX_CDP_PORT;
108
+ if (fromEnv !== undefined && fromEnv !== "") {
109
+ const parsed = Number(fromEnv);
110
+ if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 65_535) {
111
+ throw new Error(`PRODEX_CDP_PORT must be an integer between 1 and 65535, got: ${fromEnv}`);
112
+ }
113
+ return parsed;
114
+ }
115
+ return DEFAULT_CDP_PORT;
116
+ }
63
117
  export const CHATGPT_RUNTIME_BLOCKER_TEXT_EXCLUDED_ANCESTORS = '[data-message-author-role],script,style,noscript,[aria-hidden="true"],div[role="textbox"],textarea,[contenteditable="true"]';
64
118
  export const CHATGPT_COMPOSER_CANDIDATE_EXCLUDED_ANCESTORS = '[data-message-author-role],script,style,noscript,[aria-hidden="true"]';
65
119
  export const PRODEX_ACTIVE_COMPOSER_ATTRIBUTE = "data-prodex-active-composer";
@@ -398,16 +452,36 @@ export function chatGptVisibilityBlocker(visibilityState, url) {
398
452
  next_step: `Select ${url ?? "the ChatGPT tab"} in the dedicated browser, then retry.`
399
453
  };
400
454
  }
455
+ /**
456
+ * Environment for the browser spawn. On Linux shells without DISPLAY or
457
+ * WAYLAND_DISPLAY (common in WSL where a non-login shell does not export
458
+ * them), a present WSLg X socket means a desktop is actually available -
459
+ * inject DISPLAY=:0 so Chrome does not exit immediately with code 1.
460
+ */
461
+ export function browserLaunchEnv(platform = process.platform, env = process.env, hasX0Socket = () => {
462
+ try {
463
+ statSync("/tmp/.X11-unix/X0");
464
+ return true;
465
+ }
466
+ catch {
467
+ return false;
468
+ }
469
+ }) {
470
+ if (platform === "linux" && !env.DISPLAY && !env.WAYLAND_DISPLAY && hasX0Socket()) {
471
+ return { ...env, DISPLAY: ":0" };
472
+ }
473
+ return env;
474
+ }
401
475
  export function openChatGptBrowser(options = {}) {
402
476
  const command = resolveChromeCommand();
403
- const port = options.port ?? 9333;
477
+ const port = resolveCdpPort(options.port);
404
478
  const profileDir = options.profileDir ?? defaultChatGptProfileDir();
405
479
  const args = buildChromeLaunchArgs({
406
480
  port,
407
481
  profileDir,
408
482
  url: options.url ?? "https://chatgpt.com/"
409
483
  });
410
- const child = spawn(command, args, { detached: true, stdio: "ignore" });
484
+ const child = spawn(command, args, { detached: true, stdio: "ignore", env: browserLaunchEnv() });
411
485
  let earlyExit;
412
486
  const earlyExitWaiters = new Set();
413
487
  const recordEarlyExit = (exit) => {
@@ -444,7 +518,7 @@ export function openChatGptBrowser(options = {}) {
444
518
  };
445
519
  }
446
520
  export async function getChatGptBrowserStatus(options = {}) {
447
- const port = options.port ?? 9333;
521
+ const port = resolveCdpPort(options.port);
448
522
  const page = await findChatGptPage(port, options.timeoutMs ?? 1500);
449
523
  if (!page.ok) {
450
524
  return {
@@ -846,8 +920,20 @@ async function selectProject(cdp, options) {
846
920
  }
847
921
  }
848
922
  export async function sendChatGptPrompt(options) {
849
- const port = options.port ?? 9333;
923
+ const port = resolveCdpPort(options.port);
850
924
  const timeoutMs = options.timeoutMs ?? 90_000;
925
+ const sendStartedAt = Date.now();
926
+ const emitProgress = (phase, detail) => {
927
+ if (!options.onProgress)
928
+ return;
929
+ try {
930
+ options.onProgress({ phase, elapsedMs: Date.now() - sendStartedAt, ...(detail !== undefined ? { detail } : {}) });
931
+ }
932
+ catch {
933
+ // Progress reporting must never break the send itself.
934
+ }
935
+ };
936
+ emitProgress("connecting", `port ${port}`);
851
937
  const normalizedTargetUrl = options.targetUrl ? normalizeChatGptTargetUrl(options.targetUrl) : undefined;
852
938
  const pageResult = await findChatGptPage(port, computePageDiscoveryTimeout(timeoutMs), normalizedTargetUrl);
853
939
  if (!pageResult.ok) {
@@ -877,6 +963,19 @@ export async function sendChatGptPrompt(options) {
877
963
  if (busyBlocker) {
878
964
  throw new ChatGptBrowserBlockerError(busyBlocker);
879
965
  }
966
+ emitProgress("tab_ready");
967
+ // Progress details deliberately avoid project names (receipts redact them too).
968
+ const selectionSummary = [
969
+ options.model !== undefined ? `model=${options.model}` : undefined,
970
+ options.proMode !== undefined ? `pro_mode=${options.proMode}` : undefined,
971
+ options.effort !== undefined ? `effort=${options.effort}` : undefined,
972
+ options.project !== undefined ? "project=set" : undefined,
973
+ options.projectNew !== undefined ? "project_new=set" : undefined
974
+ ]
975
+ .filter((part) => part !== undefined)
976
+ .join(" ");
977
+ if (selectionSummary)
978
+ emitProgress("selecting", selectionSummary);
880
979
  let beforeSubmit;
881
980
  let submitButtonFound = false;
882
981
  const cdp = await connectCdp(page.webSocketDebuggerUrl);
@@ -905,6 +1004,7 @@ export async function sendChatGptPrompt(options) {
905
1004
  finally {
906
1005
  cdp.close();
907
1006
  }
1007
+ emitProgress("sent", `timeout ${Math.round(timeoutMs / 1000)}s`);
908
1008
  const started = Date.now();
909
1009
  const acceptDeadline = computePromptAcceptanceDeadline(timeoutMs, started);
910
1010
  let accepted = false;
@@ -919,6 +1019,7 @@ export async function sendChatGptPrompt(options) {
919
1019
  accepted = true;
920
1020
  break;
921
1021
  }
1022
+ emitProgress("waiting", "prompt posting");
922
1023
  }
923
1024
  if (!accepted) {
924
1025
  // A successful submit clears the composer, so text still sitting there means
@@ -933,34 +1034,26 @@ export async function sendChatGptPrompt(options) {
933
1034
  }
934
1035
  throw acceptanceTimeoutError({ timeoutMs, composerStillHasText, submitButtonFound });
935
1036
  }
936
- let stableAnswer;
937
- let stableConfirmations = 0;
1037
+ const answerIsStable = createChatGptAnswerStabilityTracker();
938
1038
  while (Date.now() - started < timeoutMs) {
939
1039
  await sleep(1000);
940
1040
  finalState = await evaluateOnPage(page, answerExpression());
941
1041
  const runtimeBlocker = chatGptBlockerFromAnswerState(finalState);
942
1042
  if (runtimeBlocker)
943
1043
  throw new ChatGptBrowserBlockerError(runtimeBlocker);
1044
+ emitProgress("waiting", finalState.generating ? "generating" : "stabilizing");
944
1045
  if (!hasFreshChatGptAnswer(beforeSubmit.assistantMessageCount, finalState))
945
1046
  continue;
946
- // A "fresh" answer must also be stable: ChatGPT can momentarily look done mid-stream, so accept
947
- // it only once the text stops changing across polls. Otherwise trailing tokens (e.g. the final
948
- // "_OK" of the smoke token) get dropped.
949
- if (finalState.answer === stableAnswer && !finalState.generating) {
950
- stableConfirmations += 1;
951
- // Require two consecutive stable, non-generating polls so a mid-stream
952
- // pause (identical text across one interval while more tokens are still
953
- // coming) is not mistaken for completion.
954
- if (stableConfirmations >= 2)
955
- break;
956
- }
957
- else {
958
- stableAnswer = finalState.answer;
959
- stableConfirmations = 0;
960
- }
1047
+ // A "fresh" answer must also be stable: ChatGPT can momentarily look done
1048
+ // mid-stream, and the streaming caret renders as a literal trailing
1049
+ // character that can outlive the stop button. The tracker requires extra
1050
+ // confirmations for caret-suspect tails (see its doc comment).
1051
+ if (answerIsStable(finalState.answer, finalState.generating))
1052
+ break;
961
1053
  }
962
1054
  const completed = finalState;
963
1055
  if (completed && hasFreshChatGptAnswer(beforeSubmit.assistantMessageCount, completed)) {
1056
+ emitProgress("answered");
964
1057
  return {
965
1058
  url: completed.url,
966
1059
  title: completed.title,
@@ -973,6 +1066,7 @@ export async function sendChatGptPrompt(options) {
973
1066
  // (a truncated-but-real Pro answer is far more useful than losing it) and
974
1067
  // flag it so the caller records the incompleteness and the retry hint.
975
1068
  if (completed && hasPartialChatGptAnswer(beforeSubmit.assistantMessageCount, completed)) {
1069
+ emitProgress("answered", "partial");
976
1070
  return {
977
1071
  url: completed.url,
978
1072
  title: completed.title,
@@ -1002,7 +1096,7 @@ export function modelMenuOptionsExpression() {
1002
1096
  // and press Escape. Nothing is clicked inside the menu, so the user's model
1003
1097
  // selection is never changed.
1004
1098
  export async function listChatGptModelOptions(input = {}) {
1005
- const port = input.port ?? 9333;
1099
+ const port = resolveCdpPort(input.port);
1006
1100
  const timeoutMs = input.timeoutMs ?? 15_000;
1007
1101
  const pageResult = await findChatGptPage(port, computePageDiscoveryTimeout(timeoutMs), undefined);
1008
1102
  if (!pageResult.ok) {
@@ -1424,13 +1518,62 @@ function answerExpression() {
1424
1518
  function enterKeyEvent(type) {
1425
1519
  return { type, key: "Enter", code: "Enter", windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13 };
1426
1520
  }
1521
+ const CHROME_PATH_BINARY_NAMES = ["google-chrome", "chromium", "chromium-browser", "microsoft-edge", "brave-browser"];
1522
+ const DARWIN_CHROME_PATHS = [
1523
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
1524
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
1525
+ "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
1526
+ "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"
1527
+ ];
1528
+ function win32ChromePaths(env) {
1529
+ return [
1530
+ "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
1531
+ "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
1532
+ ...(env.LOCALAPPDATA ? [`${env.LOCALAPPDATA}\\Google\\Chrome\\Application\\chrome.exe`] : []),
1533
+ "C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe",
1534
+ "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe"
1535
+ ];
1536
+ }
1537
+ const WSL_WINDOWS_CHROME_PATHS = [
1538
+ "/mnt/c/Program Files/Google/Chrome/Application/chrome.exe",
1539
+ "/mnt/c/Program Files (x86)/Google/Chrome/Application/chrome.exe",
1540
+ "/mnt/c/Program Files/Microsoft/Edge/Application/msedge.exe",
1541
+ "/mnt/c/Program Files (x86)/Microsoft/Edge/Application/msedge.exe"
1542
+ ];
1543
+ /**
1544
+ * Ordered browser candidates for the current platform: PATH binary names
1545
+ * first, then well-known absolute install locations (macOS app bundles,
1546
+ * Windows Program Files/LOCALAPPDATA, and Windows-host browsers under WSL).
1547
+ */
1548
+ export function chromeCommandCandidates(platform = process.platform, env = process.env) {
1549
+ const candidates = [...CHROME_PATH_BINARY_NAMES];
1550
+ if (platform === "darwin")
1551
+ candidates.push(...DARWIN_CHROME_PATHS);
1552
+ if (platform === "win32")
1553
+ candidates.push(...win32ChromePaths(env));
1554
+ if (platform === "linux" && env.WSL_DISTRO_NAME)
1555
+ candidates.push(...WSL_WINDOWS_CHROME_PATHS);
1556
+ return candidates;
1557
+ }
1427
1558
  function resolveChromeCommand() {
1428
1559
  const fromEnv = process.env.PRODEX_CHROME;
1429
1560
  if (fromEnv) {
1430
1561
  assertChromeCommandAvailable(fromEnv, "PRODEX_CHROME");
1431
1562
  return fromEnv;
1432
1563
  }
1433
- for (const command of ["google-chrome", "chromium", "chromium-browser", "microsoft-edge", "brave-browser"]) {
1564
+ for (const command of chromeCommandCandidates()) {
1565
+ if (isPathLikeCommand(command)) {
1566
+ try {
1567
+ if (!statSync(command).isFile())
1568
+ continue;
1569
+ }
1570
+ catch {
1571
+ continue;
1572
+ }
1573
+ if (hasChromeLikeVersion(command))
1574
+ return command;
1575
+ continue;
1576
+ }
1434
1577
  if (isCommandOnPath(command) && hasChromeLikeVersion(command))
1435
1578
  return command;
1436
1579
  }