@youdie006/prodex 0.8.3 → 0.9.1

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
@@ -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,13 @@ 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
+ * WSL detection cannot rely on WSL_DISTRO_NAME alone: non-login shells
209
+ * (measured live) may not carry it, so WSL_INTEROP and the kernel string are
210
+ * probed too.
211
+ */
212
+ export declare function chromeCommandCandidates(platform?: NodeJS.Platform, env?: Record<string, string | undefined>, isWsl?: () => boolean): string[];
172
213
  export {};
@@ -1,5 +1,5 @@
1
1
  import { spawn, spawnSync } from "node:child_process";
2
- import { accessSync, constants, statSync } from "node:fs";
2
+ import { accessSync, constants, readFileSync, statSync } from "node:fs";
3
3
  import path from "node:path";
4
4
  import os from "node:os";
5
5
  export class ChatGptBrowserBlockerError extends Error {
@@ -60,6 +60,62 @@ 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
+ // Trim before the caret check: the caller trims the returned answer, so a
96
+ // caret followed by trailing whitespace must count as suspect too.
97
+ const required = TRAILING_CARET_SUSPECT.test(answer.trimEnd()) ? 8 : 2;
98
+ return confirmations >= required;
99
+ };
100
+ }
101
+ /**
102
+ * Resolve the Chrome DevTools port: explicit flag > PRODEX_CDP_PORT env >
103
+ * default 9333. The env var exists so a non-default port picked at login does
104
+ * not have to be repeated as --port on every later command.
105
+ */
106
+ export function resolveCdpPort(explicit) {
107
+ if (explicit !== undefined)
108
+ return explicit;
109
+ const fromEnv = process.env.PRODEX_CDP_PORT;
110
+ if (fromEnv !== undefined && fromEnv !== "") {
111
+ const parsed = Number(fromEnv);
112
+ if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 65_535) {
113
+ throw new Error(`PRODEX_CDP_PORT must be an integer between 1 and 65535, got: ${fromEnv}`);
114
+ }
115
+ return parsed;
116
+ }
117
+ return DEFAULT_CDP_PORT;
118
+ }
63
119
  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
120
  export const CHATGPT_COMPOSER_CANDIDATE_EXCLUDED_ANCESTORS = '[data-message-author-role],script,style,noscript,[aria-hidden="true"]';
65
121
  export const PRODEX_ACTIVE_COMPOSER_ATTRIBUTE = "data-prodex-active-composer";
@@ -398,16 +454,36 @@ export function chatGptVisibilityBlocker(visibilityState, url) {
398
454
  next_step: `Select ${url ?? "the ChatGPT tab"} in the dedicated browser, then retry.`
399
455
  };
400
456
  }
457
+ /**
458
+ * Environment for the browser spawn. On Linux shells without DISPLAY or
459
+ * WAYLAND_DISPLAY (common in WSL where a non-login shell does not export
460
+ * them), a present WSLg X socket means a desktop is actually available -
461
+ * inject DISPLAY=:0 so Chrome does not exit immediately with code 1.
462
+ */
463
+ export function browserLaunchEnv(platform = process.platform, env = process.env, hasX0Socket = () => {
464
+ try {
465
+ statSync("/tmp/.X11-unix/X0");
466
+ return true;
467
+ }
468
+ catch {
469
+ return false;
470
+ }
471
+ }) {
472
+ if (platform === "linux" && !env.DISPLAY && !env.WAYLAND_DISPLAY && hasX0Socket()) {
473
+ return { ...env, DISPLAY: ":0" };
474
+ }
475
+ return env;
476
+ }
401
477
  export function openChatGptBrowser(options = {}) {
402
478
  const command = resolveChromeCommand();
403
- const port = options.port ?? 9333;
479
+ const port = resolveCdpPort(options.port);
404
480
  const profileDir = options.profileDir ?? defaultChatGptProfileDir();
405
481
  const args = buildChromeLaunchArgs({
406
482
  port,
407
483
  profileDir,
408
484
  url: options.url ?? "https://chatgpt.com/"
409
485
  });
410
- const child = spawn(command, args, { detached: true, stdio: "ignore" });
486
+ const child = spawn(command, args, { detached: true, stdio: "ignore", env: browserLaunchEnv() });
411
487
  let earlyExit;
412
488
  const earlyExitWaiters = new Set();
413
489
  const recordEarlyExit = (exit) => {
@@ -444,7 +520,7 @@ export function openChatGptBrowser(options = {}) {
444
520
  };
445
521
  }
446
522
  export async function getChatGptBrowserStatus(options = {}) {
447
- const port = options.port ?? 9333;
523
+ const port = resolveCdpPort(options.port);
448
524
  const page = await findChatGptPage(port, options.timeoutMs ?? 1500);
449
525
  if (!page.ok) {
450
526
  return {
@@ -846,8 +922,20 @@ async function selectProject(cdp, options) {
846
922
  }
847
923
  }
848
924
  export async function sendChatGptPrompt(options) {
849
- const port = options.port ?? 9333;
925
+ const port = resolveCdpPort(options.port);
850
926
  const timeoutMs = options.timeoutMs ?? 90_000;
927
+ const sendStartedAt = Date.now();
928
+ const emitProgress = (phase, detail) => {
929
+ if (!options.onProgress)
930
+ return;
931
+ try {
932
+ options.onProgress({ phase, elapsedMs: Date.now() - sendStartedAt, ...(detail !== undefined ? { detail } : {}) });
933
+ }
934
+ catch {
935
+ // Progress reporting must never break the send itself.
936
+ }
937
+ };
938
+ emitProgress("connecting", `port ${port}`);
851
939
  const normalizedTargetUrl = options.targetUrl ? normalizeChatGptTargetUrl(options.targetUrl) : undefined;
852
940
  const pageResult = await findChatGptPage(port, computePageDiscoveryTimeout(timeoutMs), normalizedTargetUrl);
853
941
  if (!pageResult.ok) {
@@ -877,6 +965,19 @@ export async function sendChatGptPrompt(options) {
877
965
  if (busyBlocker) {
878
966
  throw new ChatGptBrowserBlockerError(busyBlocker);
879
967
  }
968
+ emitProgress("tab_ready");
969
+ // Progress details deliberately avoid project names (receipts redact them too).
970
+ const selectionSummary = [
971
+ options.model !== undefined ? `model=${options.model}` : undefined,
972
+ options.proMode !== undefined ? `pro_mode=${options.proMode}` : undefined,
973
+ options.effort !== undefined ? `effort=${options.effort}` : undefined,
974
+ options.project !== undefined ? "project=set" : undefined,
975
+ options.projectNew !== undefined ? "project_new=set" : undefined
976
+ ]
977
+ .filter((part) => part !== undefined)
978
+ .join(" ");
979
+ if (selectionSummary)
980
+ emitProgress("selecting", selectionSummary);
880
981
  let beforeSubmit;
881
982
  let submitButtonFound = false;
882
983
  const cdp = await connectCdp(page.webSocketDebuggerUrl);
@@ -905,6 +1006,7 @@ export async function sendChatGptPrompt(options) {
905
1006
  finally {
906
1007
  cdp.close();
907
1008
  }
1009
+ emitProgress("sent", `timeout ${Math.round(timeoutMs / 1000)}s`);
908
1010
  const started = Date.now();
909
1011
  const acceptDeadline = computePromptAcceptanceDeadline(timeoutMs, started);
910
1012
  let accepted = false;
@@ -919,6 +1021,7 @@ export async function sendChatGptPrompt(options) {
919
1021
  accepted = true;
920
1022
  break;
921
1023
  }
1024
+ emitProgress("waiting", "prompt posting");
922
1025
  }
923
1026
  if (!accepted) {
924
1027
  // A successful submit clears the composer, so text still sitting there means
@@ -933,34 +1036,26 @@ export async function sendChatGptPrompt(options) {
933
1036
  }
934
1037
  throw acceptanceTimeoutError({ timeoutMs, composerStillHasText, submitButtonFound });
935
1038
  }
936
- let stableAnswer;
937
- let stableConfirmations = 0;
1039
+ const answerIsStable = createChatGptAnswerStabilityTracker();
938
1040
  while (Date.now() - started < timeoutMs) {
939
1041
  await sleep(1000);
940
1042
  finalState = await evaluateOnPage(page, answerExpression());
941
1043
  const runtimeBlocker = chatGptBlockerFromAnswerState(finalState);
942
1044
  if (runtimeBlocker)
943
1045
  throw new ChatGptBrowserBlockerError(runtimeBlocker);
1046
+ emitProgress("waiting", finalState.generating ? "generating" : "stabilizing");
944
1047
  if (!hasFreshChatGptAnswer(beforeSubmit.assistantMessageCount, finalState))
945
1048
  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
- }
1049
+ // A "fresh" answer must also be stable: ChatGPT can momentarily look done
1050
+ // mid-stream, and the streaming caret renders as a literal trailing
1051
+ // character that can outlive the stop button. The tracker requires extra
1052
+ // confirmations for caret-suspect tails (see its doc comment).
1053
+ if (answerIsStable(finalState.answer, finalState.generating))
1054
+ break;
961
1055
  }
962
1056
  const completed = finalState;
963
1057
  if (completed && hasFreshChatGptAnswer(beforeSubmit.assistantMessageCount, completed)) {
1058
+ emitProgress("answered");
964
1059
  return {
965
1060
  url: completed.url,
966
1061
  title: completed.title,
@@ -973,6 +1068,7 @@ export async function sendChatGptPrompt(options) {
973
1068
  // (a truncated-but-real Pro answer is far more useful than losing it) and
974
1069
  // flag it so the caller records the incompleteness and the retry hint.
975
1070
  if (completed && hasPartialChatGptAnswer(beforeSubmit.assistantMessageCount, completed)) {
1071
+ emitProgress("answered", "partial");
976
1072
  return {
977
1073
  url: completed.url,
978
1074
  title: completed.title,
@@ -1002,7 +1098,7 @@ export function modelMenuOptionsExpression() {
1002
1098
  // and press Escape. Nothing is clicked inside the menu, so the user's model
1003
1099
  // selection is never changed.
1004
1100
  export async function listChatGptModelOptions(input = {}) {
1005
- const port = input.port ?? 9333;
1101
+ const port = resolveCdpPort(input.port);
1006
1102
  const timeoutMs = input.timeoutMs ?? 15_000;
1007
1103
  const pageResult = await findChatGptPage(port, computePageDiscoveryTimeout(timeoutMs), undefined);
1008
1104
  if (!pageResult.ok) {
@@ -1424,13 +1520,74 @@ function answerExpression() {
1424
1520
  function enterKeyEvent(type) {
1425
1521
  return { type, key: "Enter", code: "Enter", windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13 };
1426
1522
  }
1523
+ const CHROME_PATH_BINARY_NAMES = ["google-chrome", "chromium", "chromium-browser", "microsoft-edge", "brave-browser"];
1524
+ const DARWIN_CHROME_PATHS = [
1525
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
1526
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
1527
+ "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
1528
+ "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"
1529
+ ];
1530
+ function win32ChromePaths(env) {
1531
+ return [
1532
+ "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
1533
+ "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
1534
+ ...(env.LOCALAPPDATA ? [`${env.LOCALAPPDATA}\\Google\\Chrome\\Application\\chrome.exe`] : []),
1535
+ "C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe",
1536
+ "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe"
1537
+ ];
1538
+ }
1539
+ const WSL_WINDOWS_CHROME_PATHS = [
1540
+ "/mnt/c/Program Files/Google/Chrome/Application/chrome.exe",
1541
+ "/mnt/c/Program Files (x86)/Google/Chrome/Application/chrome.exe",
1542
+ "/mnt/c/Program Files/Microsoft/Edge/Application/msedge.exe",
1543
+ "/mnt/c/Program Files (x86)/Microsoft/Edge/Application/msedge.exe"
1544
+ ];
1545
+ function kernelLooksLikeWsl() {
1546
+ try {
1547
+ return /microsoft/i.test(readFileSync("/proc/version", "utf8"));
1548
+ }
1549
+ catch {
1550
+ return false;
1551
+ }
1552
+ }
1553
+ /**
1554
+ * Ordered browser candidates for the current platform: PATH binary names
1555
+ * first, then well-known absolute install locations (macOS app bundles,
1556
+ * Windows Program Files/LOCALAPPDATA, and Windows-host browsers under WSL).
1557
+ * WSL detection cannot rely on WSL_DISTRO_NAME alone: non-login shells
1558
+ * (measured live) may not carry it, so WSL_INTEROP and the kernel string are
1559
+ * probed too.
1560
+ */
1561
+ export function chromeCommandCandidates(platform = process.platform, env = process.env, isWsl = kernelLooksLikeWsl) {
1562
+ const candidates = [...CHROME_PATH_BINARY_NAMES];
1563
+ if (platform === "darwin")
1564
+ candidates.push(...DARWIN_CHROME_PATHS);
1565
+ if (platform === "win32")
1566
+ candidates.push(...win32ChromePaths(env));
1567
+ if (platform === "linux" && (env.WSL_DISTRO_NAME || env.WSL_INTEROP || isWsl())) {
1568
+ candidates.push(...WSL_WINDOWS_CHROME_PATHS);
1569
+ }
1570
+ return candidates;
1571
+ }
1427
1572
  function resolveChromeCommand() {
1428
1573
  const fromEnv = process.env.PRODEX_CHROME;
1429
1574
  if (fromEnv) {
1430
1575
  assertChromeCommandAvailable(fromEnv, "PRODEX_CHROME");
1431
1576
  return fromEnv;
1432
1577
  }
1433
- for (const command of ["google-chrome", "chromium", "chromium-browser", "microsoft-edge", "brave-browser"]) {
1578
+ for (const command of chromeCommandCandidates()) {
1579
+ if (isPathLikeCommand(command)) {
1580
+ try {
1581
+ if (!statSync(command).isFile())
1582
+ continue;
1583
+ }
1584
+ catch {
1585
+ continue;
1586
+ }
1587
+ if (hasChromeLikeVersion(command))
1588
+ return command;
1589
+ continue;
1590
+ }
1434
1591
  if (isCommandOnPath(command) && hasChromeLikeVersion(command))
1435
1592
  return command;
1436
1593
  }