diff --git a/AGENTS.md b/AGENTS.md index 16fd64a..2435d28 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,7 +34,7 @@ The CLI is layered. Each layer has one job and only depends on the layer below i - `src/index.tsx` — argv dispatcher. Maps each command to its app component or logic function and exits. - `src/App.tsx` — command-root Ink components, one per command (`InstallApp`, `UpdateApp`, `UploadApp`). Each is a state machine that wires together components from `src/components/` and orchestrates the command's flow. `index.tsx` mounts these via `render()`. - `src/components/*.tsx` — reusable Ink components (Banner, Frame, Step, TaskList) and command-phase components (Install, Configure, Login, FetchApiKey, Update). Apps and other components import these; they never import apps. -- `src/lib/*.ts` — non-UI logic modules (`auth`, `configure`, `npm`, `paths`, `markdown`, `statistics`, `export`, `upload`, `run`, `restore`, `backend`, `help`, `const`, `reexec`, `supabase`). Components and apps import logic; logic never imports UI. +- `src/lib/*.ts` — non-UI logic modules (`auth`, `configure`, `npm`, `paths`, `markdown`, `statistics`, `export`, `upload`, `run`, `restore`, `backend`, `help`, `const`, `reexec`, `supabase`, `proxy`, `doctor`). Components and apps import logic; logic never imports UI. - `src/providers/*.ts` — agent-specific reader implementations used by `src/lib/export.ts` (one file per agent). When adding a new command: @@ -154,12 +154,62 @@ Node verifies TLS against its **own bundled Mozilla CA snapshot and never consul - **At most one retry per process.** `applySystemCaCertsOnce` returns null once it has run, so a chain that stays untrusted surfaces its error instead of looping. Replay is safe: a TLS handshake fails before any body is sent, and every call site passes a replayable body (string/URLSearchParams/FormData/Buffer), never a stream — keep it that way. - **Merge `default` + `system`, never `bundled` + `system`.** `"default"` already folds in `NODE_EXTRA_CA_CERTS`; narrowing it would silently drop the certs of users who fixed this the documented way. - **Every failure mode is a no-op**, including an empty system store (`setDefaultCACertificates` is then never called, keeping Node's behavior byte-identical). Trust config isn't ours to have opinions about, and a bad merge would break users whose certs already work. -- The APIs need Node ≥22.19/24.5 (our floor is 22.5), hence `tlsApi.supported()`. They're accessed off the default import, never destructured — a named ESM import of a builtin export that doesn't exist is a link-time error on older Node. +- The APIs need Node ≥22.19/24.5, hence `tlsApi.supported()`. Our floor is 22.21, so every supported 22.x is fine — but **24.0–24.4 is not**, which is what keeps the guard load-bearing. They're accessed off the default import, never destructured — a named ESM import of a builtin export that doesn't exist is a link-time error on older Node. `describeNetworkError` unwraps Node's bare `fetch failed` (the real reason hides on `err.cause`) and appends a remedy for cert codes. It exists because **Node's own `--use-system-ca` hint pointedly excludes `SELF_SIGNED_CERT_IN_CHAIN`** — `crypto_common.cc` gates it on `DEPTH_ZERO_SELF_SIGNED_CERT` / `UNABLE_TO_VERIFY_LEAF_SIGNATURE` / `UNABLE_TO_GET_ISSUER_CERT` only, so the corporate-proxy case, the one the hint most exists for, is the one that prints nothing. If a cert error survives the merge, the root isn't in the OS store either, so the hint names `NODE_EXTRA_CA_CERTS` rather than `--use-system-ca`, which would be a dead end. Keep `certHint` **pure** — reading the OS store to word a sentence would put that same Windows stall on the error path, and the retry has already read it. Note the blast radius: this only covers **our own** process. `npm i -g` during install and the agents themselves are separate processes behind the same proxy and need `NODE_EXTRA_CA_CERTS` / npm's `cafile` of their own. +## HTTP proxies + +TLS trust (above) and proxying are **separate problems** with separate fixes; a machine behind a corporate gateway usually has both. + +Node does not honor `HTTP_PROXY`/`HTTPS_PROXY` on its own. Support is gated behind `NODE_USE_ENV_PROXY=1` and read at **bootstrap**, so assigning `process.env` mid-run is too late for the already-initialized global dispatcher. Users who follow the install guide and export only the proxy variables therefore get no proxy at all — silently, with no warning from Node — and every `fetch` dies in the firewall. + +`src/lib/proxy.ts#applyEnvProxy` closes that gap once, from `index.tsx` right after `initLogging` and before dispatch, so `install`, `login`, `upload`, `model` and the launch-time key refresh all benefit rather than just the command being debugged. It no-ops unless a proxy is set and `NODE_USE_ENV_PROXY` is unset, then takes one of two paths: + +1. **`http.setGlobalProxyFromEnv()`** when the running Node has it. Verified empirically to route global `fetch`, not just `node:http`/`https` Agent traffic — which is what makes the fast path viable at all. Probed behind the `httpApi` indirection, same shape as `tlsApi` (off the default import, never destructured). +2. **Re-exec** with `NODE_USE_ENV_PROXY=1` otherwise, via `reexec.ts#spawner`, guarded by a `CODEV_PROXY_APPLIED=1` sentinel so it can never loop. + +`readEither` normalizes each spelling **independently** (`nonEmpty(env.HTTP_PROXY) ?? nonEmpty(env.http_proxy)`). A plain `??` only falls through on `undefined`, so an exported-but-empty `HTTP_PROXY` would mask a perfectly good lowercase one — `tests/lib/proxy.test.ts` pins this. + +`NO_PROXY` is the other half. An entry covering our own backend (internal images ship a blanket `*.viettel.vn`) routes sign-in traffic *around* the proxy and straight into the firewall — the documented cause of `Login failed`. `matchingNoProxyEntry` detects it. The split in responsibility is deliberate: **`index.tsx` only warns** (rewriting the user's environment mid-command is overreach), while **`doctor` strips it in its retry child**, where the user has explicitly asked us to try a proxy. + +Blast radius again: **npm keeps its own proxy and TLS configuration** (`npm config set proxy` / `https-proxy` / `cafile` / `registry`), entirely separate from these variables. A working `codevhub` proves nothing about `npm i -g`, which is why `doctor` checks them separately. + +## `codevhub doctor` + +Pre-flight for everything `install` depends on, so users on a locked-down network find out *before* `npm i -g` mutates their machine. Read-only apart from the diagnostic log — it never installs or configures anything. + +`src/lib/doctor.ts` holds a `Check[]` registry in five groups (`environment`, `network`, `account`, `llm`, `state`), each `run` returning `pass`/`warn`/`fail`/`skip` and never throwing. `DoctorApp` walks the groups; `components/CheckList.tsx` renders them. `Login` is mounted for the sign-in check so `doctor` exercises the real flow, including the paste-back fallback. + +**The diagnosis layer is the point of the command**, not a detail. `lib/tls.ts#describeNetworkError` unwraps one level of `err.cause` and only special-cases cert codes; everything else still reaches the user as `fetch failed (connect ECONNREFUSED 10.0.0.1:8080)`, which tells a non-engineer nothing. `diagnoseError` walks the **whole** chain (including `AggregateError`, which Node emits one entry per address family into) and returns four parts — what happened in plain language, the likely cause *given this machine's proxy state*, the fix, and the raw chain verbatim. Load-bearing details: + +- **A configured-but-ignored proxy supersedes every other explanation** and takes the `fix` slot, because nothing else can be diagnosed until it is fixed. +- **`rootLink` recovers a code from the message text** when `.code` was lost to a re-throw (`"getaddrinfo ENOTFOUND host"`), which is the difference between a real diagnosis and the generic fallback. +- **`AbortSignal.timeout` is special-cased** — its own message names neither host nor duration, so both are rebuilt from the `Attempt`. +- **HTTP responses are diagnosed too** (`diagnoseResponse`). A non-2xx is not an exception, so `describeNetworkError` never sees it — yet 407 and a proxy-issued 403 are exactly what internal users hit. Interceptor headers (`via`, `proxy-authenticate`, …) distinguish "the network blocked this" from "your account lacks access". +- **npm's stderr is reproduced in full** (`diagnoseExec`) — it names the registry, proxy and `.npmrc` in play, and truncating it destroys the diagnosis. +- **Nothing bypasses redaction.** Raw chains and response bodies go through `extra`, never `unsafeUnredacted`, and terminal output is scrubbed via `log.ts#redactSecrets` (the same `SCRUB_PATTERNS`) because users paste this into chats. + +`Login`/`FetchApiKey` render `describeFailure`, which upgrades **transport** errors (those with an `err.cause`) to the compact diagnosis while leaving a backend's own precise HTTP message alone — proxy-oriented reasoning about `Backend /config failed (403)` would be actively misleading. Single-line reasons stay inline (`Login failed: `); only a multi-line diagnosis breaks onto its own lines. `Login` also takes an optional `onError`, which hands failure to the parent and drops its retry prompt — `doctor` records login as one check among many and must reach its summary. + +**The re-exec handoff.** When the network group fails, `DoctorApp` offers a proxy prompt and, on submit, records `doctorOutcome.retryWithProxy` and exits — `index.tsx` calls `rerunDoctorWithProxy` after `waitUntilExit()` resolves. It **cannot** happen inside the component: `spawnSync` with inherited stdio while Ink still owns the TTY corrupts the terminal. `CODEV_DOCTOR_PROXY=1` on the child stops the prompt being offered twice, and is the *only* guard on it. + +**The report file.** Every run writes `~/.codev-hub/doctor-report.json` (`paths.ts#doctorReportPath`) and **replaces** the previous one — it is a snapshot of "how is this machine right now", and a stale one is worse than none when it is attached to a ticket. It carries the timestamp, CoDev/Node/platform versions, the full proxy environment, per-check outcomes including diagnoses, the summary counts and the next steps. Writing is best-effort in the same sense as `lib/log.ts`: a diagnostic that breaks the command it is diagnosing is worse than no diagnostic, so a failure returns null and the run continues. The serialized JSON goes through `redactSecrets` before hitting disk — this is the file most likely to be emailed around, so it is the last place a token should survive. Note this is *distinct* from the NDJSON diagnostic log, which also receives every check as a `doctor.check` document; the report is one self-contained artifact, the log is the append-only trail. + +**The environment is reported as it actually is.** `proxy-env` renders `proxy.ts#proxyEnvSummary`: the seven core variables **always, set or not** — `unset` is an answer, since most of the failures this command exists for are a *missing* variable and a reader scanning for `HTTP_PROXY` should find it stated rather than infer its absence — plus anything else the user has set, verbatim and in their own spelling, including variables `readProxyEnv` does not model (`NODE_EXTRA_CA_CERTS`, `NODE_OPTIONS`, npm's `npm_config_*`). The lowercase spellings and `npm_config_*` are in the when-set tier only: listing eleven more `unset` entries would bury the seven that matter, and `http_proxy: unset` beside `HTTP_PROXY: unset` reads as a duplicate. `readProxyEnv` normalizes to fixed fields, which is what the logic needs but hides everything else, and on a misbehaving machine the variable nobody thought to look at is usually the culprit. Each request's activity line also names the proxy it went through (`proxyForUrl`), which accounts for scheme and NO_PROXY — the only place a NO_PROXY exemption becomes visible as one request quietly going direct while the rest are proxied. **Credentials are masked at every display boundary** (`maskProxyCredentials`): proxy URLs routinely carry `user:pass`, and the check row, the activity lines and the report file all end up pasted into tickets. `readProxyEnv` keeps the real value — the retry child has to authenticate. + +**Everything doctor does is shown under the check that did it.** Subprocesses go through an opt-in recorder in `execAsync` (`npm.ts#commandLog`); HTTP requests go through its sibling in `loggedFetch` (`log.ts#requestLog`). `doctor` switches both on, and `runChecks` slices whatever the recorders gained while each check ran onto that check's `activity` — exact because checks run strictly in sequence, and it still works for a check that fans out internally (npm-registry runs five `npm config get` concurrently; all five land on its row). Sign-in is the exception, since `` owns it rather than `runChecks`, so `DoctorApp` marks it by hand with `activityMark`/`collectActivity`. Activity lines carry **no status icon**: the row's icon is the verdict, and an earlier revision that listed requests separately with their own icons scored an expected 401 red directly under a check that correctly called it a pass. They render **after** the fix — status, what, what to do, then evidence — because putting them above pushed the one actionable line down the screen. Recorded URLs drop the query string (OAuth codes, signed-URL signatures). Each recorder lives *inside* its seam rather than wrapping it, because callers reach those functions through module-local bindings no external wrapper can intercept, and both stay off by default so `update` and the upload daemon don't accumulate unbounded buffers. Subprocesses go through an opt-in recorder in `execAsync` (`npm.ts#commandLog`); HTTP requests go through its sibling in `loggedFetch` (`log.ts#requestLog`). `doctor` switches both on for its run and renders **Commands run** and **Endpoints contacted**, with the same two lists in the report file under `commands` and `requests`. The npm list alone was only half the answer: the connection tests to the backend, Supabase and the gateway never touch `execAsync`, and on a corporate network the endpoints are the *more* useful half — they are what IT allow-lists. Recorded URLs drop the query string (OAuth codes, signed-URL signatures). The endpoints section scores on **reachability, not 2xx**: a 401 means the endpoint answered, and scoring it on `ok` painted expected 401s red while the check rows above correctly called them a pass. Each recorder lives *inside* its seam rather than wrapping it, because callers reach those functions through module-local bindings no external wrapper can intercept — and both stay off by default so `update` and the upload daemon don't accumulate unbounded buffers. `execAsync` carries an opt-in recorder (`npm.ts#commandLog`), which `doctor` switches on for its run. The terminal prints a **Commands run** section — each command with its duration and outcome — and the report file carries the same list under `commands`. The recorder lives *inside* `execAsync` rather than wrapping it from doctor.ts because helpers in npm.ts (`npmGlobalRoot`, `verifyInstall`, …) call it through their module-local binding, which no external wrapper can intercept. It stays off by default so `update` and the upload daemon, which share the same seam, don't accumulate an unbounded buffer. + +**Every child process is inventoried.** `tests/lib/doctor-commands.test.ts` asserts the complete, ordered list of commands each group spawns — 10 for a full run, all `npm`, none mutating — so a new subprocess shows up in review rather than quietly appearing on users' machines. It spies at the **`execFile` boundary**, not on `npm.ts#execAsync`: helpers inside npm.ts call `execAsync` through their module-local binding, which a spy on the export cannot intercept. An earlier draft used that spy and reported the `state` group as spawning nothing while it was in fact shelling out four times. That same measurement is what caught `installedAgentsCheck` running `npm root -g` once per agent; it now resolves the root once. + +The spawn itself lives in `lib/doctor.ts`, not the dispatcher, so the exact command is assertable — `tests/lib/doctor.test.ts` spells it out rather than describing it. It runs `node [...process.execArgv] doctor [...args]` with `stdio: "inherit"`: node directly on the script, never a shell and never the `codevhub` bin. Forwarding `execArgv` is load-bearing — a `pnpm dev` run carries tsx's loader flags, and a child without them cannot load TypeScript and dies immediately. + +The prompt is offered **whether or not a proxy is already configured**. An earlier revision suppressed it when one was active, reasoning that "it's set up, so something else is wrong" — that was backwards: a wrong proxy address is among the likeliest reasons the checks failed, and suppressing the prompt left exactly that user with no way to try another. When a proxy is present the copy shifts from "do you need a proxy?" to "is this one wrong?", naming the current value, and Enter keeps it rather than skipping. + +**`PREFLIGHT_CHECKS` vs `ENVIRONMENT_CHECKS`.** `SetupApp` runs the former at the head of install/config. It is strictly pure — `process.versions` and `process.env` only. The npm checks are excluded because each spawns `npm config get` (~300ms) and install runs npm for real moments later anyway; `system-ca` is excluded because the OS-store read blocks the event loop for 300ms+ on Windows, which is precisely the stall documented in the TLS section above. The pre-flight is **advisory and never blocks** — the one condition that genuinely cannot proceed (Node below the floor) is already refused at startup in `index.tsx`. + ## Diagnostic logging `~/.codev-hub` has two log homes — don't mix them up: diff --git a/README.md b/README.md index bca1848..b5dc62f 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ CoDev — AI Coding Agent Hub. Install, configure, and manage multiple AI coding agents. -Requires Node.js ≥ 22.5 (Node 24+ recommended). +Requires Node.js ≥ 22.21 (Node 24+ recommended). ## Install @@ -13,11 +13,73 @@ npm install -g codev-ai Then run: ```bash +codevhub doctor # check your environment and network first codevhub install ``` After install, go to your project and type `codev`, `claude`, `codex`, or `opencode` to launch. +## Check your setup first: `codevhub doctor` + +On a corporate network — behind a proxy, a TLS-inspecting gateway, or an +internal npm mirror — run this before `codevhub install`: + +```bash +codevhub doctor +codevhub doctor --force # also force a real sign-in instead of reusing the cached session +``` + +It is read-only (it installs and configures nothing) and checks, in order: + +- **Environment** — Node version, npm, the global npm prefix (on `PATH` and + writable), the npm registry/proxy configuration, your proxy and TLS + environment variables, and whether the OS certificate store is readable. +- **Network** — the CoDev backend and the npm registry are actually reachable. +- **Account** — sign-in, gateway API key, CoDev configuration, and Supabase + (used by `codevhub upload`). +- **LLM access** — the key is valid, models are listable, and a real one-token + completion succeeds. Only the last of these proves inference is permitted; + `/key/info` and `/v1/models` both pass for a key that is then 403'd on every + completion. +- **This machine** — what is already installed, configured, and backed up. + +Failures expand in place into what happened, the most likely cause given your +proxy and TLS settings, the fix, and the raw error chain — never a bare +`fetch failed`. If the network checks fail it offers to re-run everything +through a proxy you type in (nothing is written to disk), so you see the fix +work before it prints the exact `export` / `setx` commands to make it permanent. + +Exit code is 0 when nothing failed — warnings do not fail it — and 1 otherwise. + +Every run also writes a machine-readable report to +**`~/.codev-hub/doctor-report.json`**, replacing the previous one. It holds the +full results, diagnoses, your Node and proxy environment, and the suggested next +steps — attach it to a support ticket rather than pasting screenshots. Secrets +are scrubbed before it is written. The same results additionally land in the +diagnostic log, so `codevhub logs` can replay a whole run. + +### Working behind a proxy + +Node does **not** honor `HTTP_PROXY`/`HTTPS_PROXY` on its own — it needs +`NODE_USE_ENV_PROXY=1`, read at startup. CoDev applies that for you when it sees +a proxy configured, but it is worth setting permanently: + +```bash +export HTTP_PROXY=http://: +export HTTPS_PROXY=http://: +export NODE_USE_ENV_PROXY=1 +export NODE_USE_SYSTEM_CA=1 +``` + +Two traps `codevhub doctor` will catch for you: + +- **`NO_PROXY` covering the CoDev backend** (for example a blanket + `*.viettel.vn`) routes sign-in traffic *around* the proxy and straight into + the firewall. This is the usual cause of `Login failed`. +- **npm keeps its own proxy and TLS configuration**, separate from these + variables — `npm config set proxy`, `https-proxy`, `cafile`, `registry`. A + working `codevhub` says nothing about whether `npm i -g` will work. + ## CodeGraph integration When you run `codevhub install` or `codevhub config`, CoDev also installs [CodeGraph](https://github.com/colbymchenry/codegraph) — a local, MCP-based, pre-indexed code-knowledge-graph tool — and wires it into each agent you select (Claude Code, Codex, OpenCode, etc.). Because your codebase is pre-indexed, agents can look up symbols, references, and structure straight from the graph instead of repeatedly grepping and reading files to find their way around. That means **fewer tool calls and fewer tokens** per task — the agent spends its context on the work rather than on rediscovering the codebase. diff --git a/README.npm.md b/README.npm.md index de7c20d..0143467 100644 --- a/README.npm.md +++ b/README.npm.md @@ -2,7 +2,7 @@ CoDev — AI Coding Agent Hub. Install, configure, and manage multiple AI coding agents. -Requires Node.js ≥ 22.5 (Node 24+ recommended). +Requires Node.js ≥ 22.21 (Node 24+ recommended). ## Install @@ -13,6 +13,7 @@ npm install -g codev-ai Then run: ```bash +codevhub doctor # check your environment and network first codevhub install ``` diff --git a/package.json b/package.json index 63acc40..b90b5a9 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,9 @@ "dist" ], "packageManager": "pnpm@10.26.0", + "engines": { + "node": ">=22.21.0" + }, "scripts": { "dev": "tsx src/index.tsx", "build": "tsx build.ts", diff --git a/src/DoctorApp.tsx b/src/DoctorApp.tsx new file mode 100644 index 0000000..979361c --- /dev/null +++ b/src/DoctorApp.tsx @@ -0,0 +1,470 @@ +import { homedir } from "node:os"; +import { Box, Text, useApp } from "ink"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { Banner } from "@/components/Banner.js"; +import { CheckList } from "@/components/CheckList.js"; +import { Frame } from "@/components/Frame.js"; +import { Login, loginTitle } from "@/components/Login.js"; +import { ProxyPrompt, proxyPromptTitle } from "@/components/ProxyPrompt.js"; +import { Step } from "@/components/Step.js"; +import { type AuthData, logout } from "@/lib/auth.js"; +import { SSO_URL } from "@/lib/const.js"; +import { + ACCOUNT_CHECKS, + activityMark, + alreadyRetriedWithProxy, + buildDoctorReport, + buildNextSteps, + type Check, + type CheckGroup, + type CheckOutcome, + collectActivity, + type DoctorContext, + diagnoseError, + doctorOutcome, + ENVIRONMENT_CHECKS, + hasFailure, + LLM_CHECKS, + NETWORK_CHECKS, + recordedCommands, + recordedRequests, + runChecks, + STATE_CHECKS, + startCommandRecording, + writeDoctorReport, +} from "@/lib/doctor.js"; +import { logDebug, type RequestRecord } from "@/lib/log.js"; +import type { CommandRecord } from "@/lib/npm.js"; +import { readProxyEnv } from "@/lib/proxy.js"; + +type Phase = + | "preparing" + | "environment" + | "network" + | "proxy-prompt" + | "login" + | "account" + | "llm" + | "state" + | "done"; + +const GROUP_TITLES: Record = { + environment: "Environment", + network: "Network", + account: "Account & credentials", + llm: "LLM access", + state: "This machine", +}; + +const GROUP_CHECKS: Record = { + environment: ENVIRONMENT_CHECKS, + network: NETWORK_CHECKS, + account: ACCOUNT_CHECKS, + llm: LLM_CHECKS, + state: STATE_CHECKS, +}; + +// Phases at or after which a group's Step should stay mounted (read-only +// history), mirroring SetupApp's POST_* arrays. +const AFTER: Record = { + environment: [ + "environment", + "network", + "proxy-prompt", + "login", + "account", + "llm", + "state", + "done", + ], + network: [ + "network", + "proxy-prompt", + "login", + "account", + "llm", + "state", + "done", + ], + account: ["account", "llm", "state", "done"], + llm: ["llm", "state", "done"], + state: ["state", "done"], +}; + +const EMPTY: Record = { + environment: [], + network: [], + account: [], + llm: [], + state: [], +}; + +interface DoctorAppProps { + /** `--force`: wipe the cached SSO session so sign-in is genuinely exercised. */ + force?: boolean; +} + +export function DoctorApp({ force = false }: DoctorAppProps) { + const { exit } = useApp(); + const [phase, setPhase] = useState( + force ? "preparing" : "environment", + ); + const [outcomes, setOutcomes] = + useState>(EMPTY); + // Login is rendered by , but its pass/fail still belongs in the + // summary and the exit code, so it gets an outcome of its own. + const [loginOutcome, setLoginOutcome] = useState(null); + const [proxyUrl, setProxyUrl] = useState(null); + // Where the machine-readable report landed, so the summary can point at it. + // null when the write failed — best-effort, never fatal. + const [reportPath, setReportPath] = useState(null); + // Snapshotted at the terminal phase — the recorder is a mutable buffer, so + // reading it during render would tear as checks are still finishing. + const [commands, setCommands] = useState([]); + const [requests, setRequests] = useState([]); + const didPrepRef = useRef(false); + // Checks mutate the context as they resolve (token → key → gateway URL → + // models), so it must be a ref: a state read would see a stale snapshot + // within the same run. + const ctxRef = useRef({}); + const startedRef = useRef>(new Set()); + + useEffect(() => { + logDebug(`doctor step: ${phase}`, { + action: "step.transition", + extra: { step: phase, command: "doctor" }, + }); + }, [phase]); + + // Start recording before any check runs, so each row can show exactly what + // it executed on the user's machine. + useEffect(() => { + startCommandRecording(); + }, []); + + // Sign-in is the one step not run through runChecks — owns it — so + // its requests need marking by hand or they would be the only work in the + // run with no row to sit under. + const loginMarkRef = useRef<{ commands: number; requests: number } | null>( + null, + ); + useEffect(() => { + if (phase === "login" && !loginMarkRef.current) { + loginMarkRef.current = activityMark(); + } + }, [phase]); + + const loginActivity = useCallback(() => { + const mark = loginMarkRef.current; + return mark ? collectActivity(mark.commands, mark.requests) : undefined; + }, []); + + // --force wipes the cached session first, so `sso-login` measures a real + // round trip rather than reporting the cache. Same approach as LoginApp. + useEffect(() => { + if (!force || didPrepRef.current) return; + didPrepRef.current = true; + logout().finally(() => setPhase("environment")); + }, [force]); + + const runGroup = useCallback((group: CheckGroup, next: Phase) => { + if (startedRef.current.has(group)) return; + startedRef.current.add(group); + runChecks(GROUP_CHECKS[group], ctxRef.current, (outcome) => { + setOutcomes((prev) => ({ + ...prev, + [group]: [...prev[group], outcome], + })); + }).then((results) => { + // The network group is the proxy gate: a hard failure there is the + // signal that this machine may need a different proxy than it has. + // Offered whether or not one is already configured — an earlier + // revision suppressed it when a proxy was active, on the reasoning + // that "it's set up, so something else is wrong". That was backwards: + // a wrong proxy address is among the likeliest reasons the checks + // failed, and suppressing the prompt left exactly that user with no + // way to try another one. The only guard is the retry sentinel, so a + // child that still fails prints its summary instead of asking again. + if ( + group === "network" && + hasFailure(results) && + !alreadyRetriedWithProxy() + ) { + setPhase("proxy-prompt"); + return; + } + setPhase(next); + }); + }, []); + + useEffect(() => { + if (phase === "environment") runGroup("environment", "network"); + else if (phase === "network") runGroup("network", "login"); + else if (phase === "account") runGroup("account", "llm"); + else if (phase === "llm") runGroup("llm", "state"); + else if (phase === "state") runGroup("state", "done"); + }, [phase, runGroup]); + + const handleProxySubmit = useCallback( + (url: string | null) => { + if (!url) { + // Skipped — keep going so the user still gets the account/LLM + // results (they may be on a network that needs no proxy) and the + // full setup instructions at the end. + setPhase("login"); + return; + } + setProxyUrl(url); + // The re-exec cannot happen here: spawnSync with inherited stdio while + // Ink owns the TTY corrupts the terminal. Record the intent and let + // index.tsx act on it once waitUntilExit() has resolved. + doctorOutcome.retryWithProxy = { http: url, https: url }; + setTimeout(() => exit(), 300); + }, + [exit], + ); + + const handleLoginDone = useCallback( + (auth: AuthData) => { + ctxRef.current.accessToken = auth.access_token; + setLoginOutcome({ + key: "sso-login", + label: "Sign in to SSO", + group: "account", + status: "pass", + detail: `Signed in as ${auth.user.email}.`, + activity: loginActivity(), + }); + setPhase("account"); + }, + [loginActivity], + ); + + const handleLoginError = useCallback( + (err: unknown) => { + const diagnosis = diagnoseError(err, { url: SSO_URL, method: "GET" }); + setLoginOutcome({ + key: "sso-login", + label: "Sign in to SSO", + group: "account", + status: "fail", + detail: diagnosis.what, + fix: diagnosis.fix, + diagnosis, + activity: loginActivity(), + }); + // Keep going. The account/LLM checks report themselves as skipped without + // a token, which tells the reader exactly how far the flow got. + setPhase("account"); + }, + [loginActivity], + ); + + // Terminal phase: write the report, compute the exit code, then hold the + // frame briefly so Ink flushes the summary before unmounting. + useEffect(() => { + if (phase !== "done") return; + const all = [ + ...outcomes.environment, + ...outcomes.network, + ...(loginOutcome ? [loginOutcome] : []), + ...outcomes.account, + ...outcomes.llm, + ]; + doctorOutcome.exitCode = hasFailure(all) ? 1 : 0; + // The `state` group is informational, but it belongs in the file — it is + // what tells support what was already on the machine. + setCommands(recordedCommands()); + setRequests(recordedRequests()); + setReportPath( + writeDoctorReport( + buildDoctorReport( + [...all, ...outcomes.state], + new Date().toISOString(), + proxyUrl ? { http: proxyUrl, https: proxyUrl } : undefined, + ), + ), + ); + const timer = setTimeout(() => exit(), 1000); + return () => clearTimeout(timer); + }, [phase, outcomes, loginOutcome, proxyUrl, exit]); + + const groupProps = (group: CheckGroup) => { + const done = outcomes[group]; + const pending = GROUP_CHECKS[group] + .filter((c) => !done.some((o) => o.key === c.key)) + .map((c) => c.label); + const active = phase === group; + return { + outcomes: done, + // The head of the pending list is what's executing right now. + running: active ? (pending[0] ?? null) : null, + pending: active ? pending.slice(1) : [], + }; + }; + + // What the environment already points at, so the prompt can ask "is this one + // wrong?" rather than "do you need a proxy?". + const proxyEnv = readProxyEnv(); + const currentProxy = proxyEnv.httpsProxy ?? proxyEnv.httpProxy; + + const allOutcomes = [ + ...outcomes.environment, + ...outcomes.network, + ...(loginOutcome ? [loginOutcome] : []), + ...outcomes.account, + ...outcomes.llm, + ...outcomes.state, + ]; + const nextSteps = + phase === "done" + ? buildNextSteps( + allOutcomes, + proxyUrl ? { http: proxyUrl, https: proxyUrl } : undefined, + ) + : []; + + return ( + + + + {phase === "preparing" && ( + Signing out previous session}> + Revoking tokens... + + )} + + {(["environment", "network"] as const).map( + (group) => + AFTER[group].includes(phase) && ( + {GROUP_TITLES[group]}} + > + + + ), + )} + + {phase === "proxy-prompt" && ( + + + + )} + + {phase !== "preparing" && + phase !== "environment" && + phase !== "network" && + phase !== "proxy-prompt" && ( + + + {loginOutcome?.status === "fail" && ( + + )} + + )} + + {(["account", "llm", "state"] as const).map( + (group) => + AFTER[group].includes(phase) && ( + {GROUP_TITLES[group]}} + > + + + ), + )} + + {phase === "done" && ( + Result}> + + + )} + + + ); +} + +/** + * `/Users/minh/.codev-hub/x` → `~/.codev-hub/x`. Shorter (so it does not wrap) + * and directly usable in a shell — both zsh/bash and PowerShell expand `~`. + * Returns the path untouched when it is not under the home directory. + */ +function abbreviateHome(path: string): string { + const home = homedir(); + return home && path.startsWith(home) ? `~${path.slice(home.length)}` : path; +} + +function Summary({ + outcomes, + nextSteps, + reportPath, +}: { + outcomes: CheckOutcome[]; + nextSteps: string[]; + reportPath: string | null; +}) { + const failed = outcomes.filter((o) => o.status === "fail").length; + const warned = outcomes.filter((o) => o.status === "warn").length; + + return ( + + {failed === 0 && warned === 0 && ( + + {"✓ Everything checks out. You're ready to run `codevhub install`."} + + )} + {failed === 0 && warned > 0 && ( + + {`▲ ${warned} warning(s). \`codevhub install\` should work, but read the notes below first.`} + + )} + {failed > 0 && ( + // Name the warnings too: the Next steps list below numbers failures + // and warnings together, so a bare "5 failed" above 7 numbered + // items reads as a contradiction. + + {`✗ ${failed} check(s) failed${ + warned > 0 ? `, ${warned} warning(s)` : "" + }. Fix these before running \`codevhub install\`.`} + + )} + {nextSteps.length > 0 && ( + + {"Next steps"} + {nextSteps.map((line, i) => ( + + {line} + + ))} + + )} + {/* Naming the file is the point of writing it — an artifact nobody + knows about cannot help a support conversation. Abbreviated to `~` + so it stays on one line: a path that wraps picks up the Step's + `│ ` gutter on continuation lines, which corrupts it when copied + (the same trap Login.tsx documents for the sign-in URL). */} + {reportPath && ( + + + {`Full report saved to ${abbreviateHome(reportPath)} — attach it to a support ticket.`} + + + )} + + ); +} diff --git a/src/SetupApp.tsx b/src/SetupApp.tsx index 17a51d0..c2498ed 100644 --- a/src/SetupApp.tsx +++ b/src/SetupApp.tsx @@ -1,12 +1,13 @@ import { Box, Text, useApp } from "ink"; import Spinner from "ink-spinner"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import type { AuthMethodChoice } from "@/components/AuthMethod.js"; import { AuthMethod, configurationMethodTitle, } from "@/components/AuthMethod.js"; import { Banner } from "@/components/Banner.js"; +import { CheckList } from "@/components/CheckList.js"; import { Configure, configureTitle } from "@/components/Configure.js"; import { Confirm, confirmTitle } from "@/components/Confirm.js"; import { @@ -56,12 +57,20 @@ import { type Tool, } from "@/lib/configure.js"; import { FALLBACK_MODEL } from "@/lib/const.js"; +import { + type CheckOutcome, + PREFLIGHT_CHECKS, + runChecks, +} from "@/lib/doctor.js"; import { logApiKeyConfigured, logDebug, logError, logWarn } from "@/lib/log.js"; import { providerFromName } from "@/lib/provider.js"; import { installShims, toolToShimAgent } from "@/lib/shims.js"; import { disableClaudeCodeLoginPrompt } from "@/lib/vscode-settings.js"; type Phase = + // Pure environment checks (Node version, proxy/TLS env), before the user is + // asked for anything. Advisory only — it always advances to "select". + | "preflight" | "select" | "editor-select" | "confirm" @@ -158,7 +167,7 @@ interface SetupAppProps { export function SetupApp({ mode }: SetupAppProps) { const { exit } = useApp(); - const [step, setStep] = useState("select"); + const [step, setStep] = useState("preflight"); const [tools, setTools] = useState([]); // Survivors of the install step — populated from TaskList's succeededKeys. // Drives shim install + the Configure step, so failed tools are quietly @@ -201,6 +210,23 @@ export function SetupApp({ mode }: SetupAppProps) { // (and the row stays unmounted) on the success path, so refresh remains // invisible when it works. const [refreshWarning, setRefreshWarning] = useState(null); + // Pure environment checks, shared verbatim with `codevhub doctor`. + const [preflight, setPreflight] = useState([]); + const preflightStartedRef = useRef(false); + + // Surface a misconfigured proxy BEFORE the user invests a few minutes in the + // wizard — on a corporate network that misconfiguration is what kills the + // run at sign-in, and the message is the same either way. Advisory by + // design: it never blocks, because the one condition that genuinely cannot + // proceed (Node below the floor) is already refused at startup in + // index.tsx, and a proxy warning must not stop a user who has no proxy. + useEffect(() => { + if (step !== "preflight" || preflightStartedRef.current) return; + preflightStartedRef.current = true; + runChecks(PREFLIGHT_CHECKS, {}, (outcome) => { + setPreflight((prev) => [...prev, outcome]); + }).then(() => setStep("select")); + }, [step]); // Diagnostic trail of the wizard's progress — one document per phase // transition, so a stuck or aborted run shows exactly where it stopped. @@ -591,244 +617,276 @@ export function SetupApp({ mode }: SetupAppProps) { [creds, runFinalizeSideEffects], ); + // Nothing downstream of the pre-flight should mount while it runs — several + // of the Steps below key off `step !== "select"` and would otherwise render + // their read-only history before the user has chosen anything. + const inPreflight = step === "preflight"; + return ( - - - - {(claudeCodeExtSelected || continueSelected) && step !== "select" && ( + {preflight.length > 0 && ( Checking your environment} > - - - )} - {step !== "select" && step !== "editor-select" && ( - - - - )} - {step !== "select" && - step !== "editor-select" && - step !== "confirm" && ( - - - - )} - {(mode === "install" || - (mode === "config" && codegraphEligible(tools))) && - POST_LOGIN.includes(step) && ( - - {mode === "install" - ? "Installing packages" - : "Installing CodeGraph"} - - } - > - - - )} - {POST_REFRESH.includes(step) && refreshWarning && ( - Refresh CoDev config}> - - - {` ${refreshWarning}`} - - - )} - {POST_REFRESH.includes(step) && savedCreds && ( - Checking saved API key} - > - {step === "validating-existing" ? ( - - - - - Verifying with gateway... - - ) : ( + {preflight.some((o) => o.status !== "pass") && ( - {existingValid - ? "Saved API key is valid." - : (existingMessage ?? "")} + { + "Run `codevhub doctor` for the full check — npm, network, sign-in and LLM access — plus setup instructions." + } )} )} - {POST_VALIDATE.includes(step) && ( - - - - )} - {POST_KEY_CHOICE.includes(step) && authMethod === "new" && auth && ( - - - - )} - {POST_KEY_CHOICE.includes(step) && - (authMethod === "manual" || - (authMethod === "new" && step === "manual-creds")) && ( - - - - )} - {POST_KEY_CHOICE.includes(step) && - authMethod !== "skip" && - modelWarning && ( - Model list}> - - - {` ${modelWarning}`} - - - )} - {POST_KEY_CHOICE.includes(step) && - authMethod !== "skip" && - creds && - step !== "fetching-key" && - step !== "manual-creds" && ( + {!inPreflight && ( + <> - - )} - {POST_VERIFY_GATEWAY.includes(step) && creds && ( - Verifying gateway access} - > - {step === "verifying-gateway" ? ( - - - - - {` Sending a test request to ${chosenModel ?? "the model"}…`} - - ) : smokeWarning ? ( - + {(claudeCodeExtSelected || continueSelected) && + step !== "select" && ( + + + + )} + {step !== "select" && step !== "editor-select" && ( + + + + )} + {step !== "select" && + step !== "editor-select" && + step !== "confirm" && ( + + + + )} + {(mode === "install" || + (mode === "config" && codegraphEligible(tools))) && + POST_LOGIN.includes(step) && ( + + {mode === "install" + ? "Installing packages" + : "Installing CodeGraph"} + + } + > + + + )} + {POST_REFRESH.includes(step) && refreshWarning && ( + Refresh CoDev config}> - {` ${smokeWarning}`} + {` ${refreshWarning}`} - - { - "Config was still written, but your agents will hit this same error — fix gateway access (model entitlement, budget, or region/IP), then relaunch." - } - - - ) : ( - {"Gateway accepted a test request."} + )} - - )} - {POST_MODEL_CHOICE.includes(step) && - (creds || authMethod === "skip") && - (authMethod === "skip" ? ( - // Skip configuration runs Configure for its backup - // side-effects only — rendered bare (no Step, no title) so - // nothing appears in the TUI. Configure itself emits no rows - // on the creds === null path. - - ) : ( - - - - ))} - {(step === "finalizing" || step === "done") && - codegraphEligible(installedTools) && - codegraphResult?.status !== "skipped" && ( - Set up CodeGraph} - > - {codegraphResult === null ? ( - - - + {POST_REFRESH.includes(step) && savedCreds && ( + Checking saved API key} + > + {step === "validating-existing" ? ( + + + + + Verifying with gateway... + + ) : ( + + {existingValid + ? "Saved API key is valid." + : (existingMessage ?? "")} - Setting up CodeGraph… - - ) : codegraphResult.status === "warning" ? ( - - - - {` ${codegraphResult.message ?? "CodeGraph setup did not complete."}`} - - + )} + + )} + {POST_VALIDATE.includes(step) && ( + + + + )} + {POST_KEY_CHOICE.includes(step) && authMethod === "new" && auth && ( + + + + )} + {POST_KEY_CHOICE.includes(step) && + (authMethod === "manual" || + (authMethod === "new" && step === "manual-creds")) && ( + + + + )} + {POST_KEY_CHOICE.includes(step) && + authMethod !== "skip" && + modelWarning && ( + Model list}> + + + {` ${modelWarning}`} + + + )} + {POST_KEY_CHOICE.includes(step) && + authMethod !== "skip" && + creds && + step !== "fetching-key" && + step !== "manual-creds" && ( + + + + )} + {POST_VERIFY_GATEWAY.includes(step) && creds && ( + Verifying gateway access} + > + {step === "verifying-gateway" ? ( + + + + + {` Sending a test request to ${chosenModel ?? "the model"}…`} + + ) : smokeWarning ? ( + + + + {` ${smokeWarning}`} + + + { + "Config was still written, but your agents will hit this same error — fix gateway access (model entitlement, budget, or region/IP), then relaunch." + } + + + ) : ( + {"Gateway accepted a test request."} + )} + + )} + {POST_MODEL_CHOICE.includes(step) && + (creds || authMethod === "skip") && + (authMethod === "skip" ? ( + // Skip configuration runs Configure for its backup + // side-effects only — rendered bare (no Step, no title) so + // nothing appears in the TUI. Configure itself emits no rows + // on the creds === null path. + ) : ( - - {`Wired CodeGraph into ${formatCodegraphTargets(codegraphResult.targets)}.`} - + + + + ))} + {(step === "finalizing" || step === "done") && + codegraphEligible(installedTools) && + codegraphResult?.status !== "skipped" && ( + Set up CodeGraph} + > + {codegraphResult === null ? ( + + + + + Setting up CodeGraph… + + ) : codegraphResult.status === "warning" ? ( + + + + {` ${codegraphResult.message ?? "CodeGraph setup did not complete."}`} + + + ) : ( + + {`Wired CodeGraph into ${formatCodegraphTargets(codegraphResult.targets)}.`} + + )} + )} - - )} - {step === "done" && ( - + {step === "done" && ( + + )} + )} diff --git a/src/components/CheckList.tsx b/src/components/CheckList.tsx new file mode 100644 index 0000000..40eab79 --- /dev/null +++ b/src/components/CheckList.tsx @@ -0,0 +1,158 @@ +import { Box, Text } from "ink"; +import Spinner from "ink-spinner"; +import type { CheckOutcome, CheckStatus, Diagnosis } from "@/lib/doctor.js"; + +interface CheckListProps { + /** Labels of checks that have not produced an outcome yet, in order. */ + pending: string[]; + outcomes: CheckOutcome[]; + /** The label currently executing, rendered with a spinner. */ + running?: string | null; + /** + * When true, passing rows collapse to a single summary line. Used by the + * install pre-flight, where a clean environment should cost one line — + * `codevhub doctor` itself always shows every row. + */ + collapsePasses?: boolean; +} + +/** + * Doctor's row renderer. + * + * Deliberately a sibling of TaskList rather than an extension of it: TaskList's + * rowText is phrased entirely around installing things ("Installed X", "Failed + * to install X"), and a check row needs a detail line plus, on failure, a full + * multi-part diagnosis. Sharing the icon vocabulary (✓ ▲ ✗ ○) is the part that + * matters for consistency, and that is duplicated intentionally — the ▲/⚠ width + * note in TaskList applies here too. + */ +export function CheckList({ + pending, + outcomes, + running, + collapsePasses = false, +}: CheckListProps) { + const passes = outcomes.filter((o) => o.status === "pass"); + const shown = collapsePasses + ? outcomes.filter((o) => o.status !== "pass") + : outcomes; + + return ( + + {collapsePasses && passes.length > 0 && ( + + + {` ${passes.length} environment checks passed`} + + )} + {shown.map((outcome) => ( + + ))} + {running && ( + + + + + {` ${running}...`} + + )} + {pending.map((label) => ( + + + {` ${label}`} + + ))} + + ); +} + +function StatusIcon({ status }: { status: CheckStatus }) { + if (status === "pass") return ; + // `▲` (U+25B2) renders single-cell in monospace fonts; `⚠` (U+26A0) sits in + // the emoji bucket and renders ~2 cells wide, breaking row alignment. + if (status === "warn") return ; + if (status === "fail") return ; + return ; +} + +function CheckRow({ outcome }: { outcome: CheckOutcome }) { + const color = + outcome.status === "fail" + ? "red" + : outcome.status === "warn" + ? "yellow" + : undefined; + return ( + + + + {` ${outcome.label}`} + + + {outcome.detail} + + {/* Failures expand in place. There is no --verbose flag on purpose: + explaining the failure IS the command, and a user who has to + re-run with a flag to find out why has already been failed once. */} + {outcome.status === "fail" && outcome.diagnosis && ( + + )} + {/* Warnings carry their fix inline; they have no diagnosis block. */} + {outcome.status === "warn" && outcome.fix && ( + + {outcome.fix.split("\n").map((line, i) => ( + + {line} + + ))} + + )} + {/* What this check actually ran, under the check that ran it — + "what is this doing on my machine?" answered in place rather + than in a separate list the reader has to correlate. + Last, deliberately: status, then what, then what to do, then the + evidence. Putting it above the fix pushed the one actionable line + down the screen. No status icon either — the row's own icon is the + verdict, and marking an expected 401 red here would contradict it. */} + {outcome.activity?.map((a, i) => ( + + {`↳ ${a.detail} (${a.durationMs}ms)`} + + ))} + + ); +} + +const LABEL_WIDTH = 15; + +function Field({ name, children }: { name: string; children: string[] }) { + if (children.length === 0) return null; + return ( + + {children.map((line, i) => ( + + + + {i === 0 ? name : ""} + + + + {line} + + + ))} + + ); +} + +function DiagnosisBlock({ diagnosis }: { diagnosis: Diagnosis }) { + return ( + + {[diagnosis.what]} + {[diagnosis.cause]} + {diagnosis.fix.split("\n")} + {diagnosis.context} + {diagnosis.raw} + + ); +} diff --git a/src/components/FetchApiKey.tsx b/src/components/FetchApiKey.tsx index 02d9f3d..2bf8d14 100644 --- a/src/components/FetchApiKey.tsx +++ b/src/components/FetchApiKey.tsx @@ -3,6 +3,8 @@ import Spinner from "ink-spinner"; import { useEffect, useState } from "react"; import type { AuthData } from "@/lib/auth.js"; import { fetchApiKey } from "@/lib/backend.js"; +import { BACKEND_URL } from "@/lib/const.js"; +import { describeFailure } from "@/lib/doctor.js"; interface FetchApiKeyProps { auth: AuthData; @@ -40,7 +42,14 @@ export function FetchApiKey({ auth, onDone, onFallback }: FetchApiKeyProps) { }) .catch((err: Error) => { setPending(false); - setError(err.message); + // A transport failure here (proxy/TLS/DNS) gets the full diagnosis; + // a backend HTTP error keeps its own already-precise message. + setError( + describeFailure(err, { + url: `${BACKEND_URL}/auth/exchange`, + method: "POST", + }), + ); }); }, [auth.access_token, onDone, attempt]); @@ -75,7 +84,17 @@ export function FetchApiKey({ auth, onDone, onFallback }: FetchApiKeyProps) { )} {error && ( <> - {`Failed to fetch API key: ${error}`} + {/* One-line reasons stay inline; a multi-line transport + diagnosis keeps its structure on following lines. */} + {`Failed to fetch API key: ${error.split("\n")[0] ?? ""}`} + {error + .split("\n") + .slice(1) + .map((line, i) => ( + + {line} + + ))} {"Press Enter to retry, Ctrl-C to quit"} )} diff --git a/src/components/Login.tsx b/src/components/Login.tsx index e34e4ec..28177ca 100644 --- a/src/components/Login.tsx +++ b/src/components/Login.tsx @@ -4,7 +4,8 @@ import { useCallback, useEffect, useState } from "react"; import { PasteBackPrompt, usePasteBack } from "@/components/PasteBack.js"; import { type AuthData, login } from "@/lib/auth.js"; import { clipboard } from "@/lib/clipboard.js"; -import { describeNetworkError } from "@/lib/tls.js"; +import { SSO_URL } from "@/lib/const.js"; +import { describeFailure } from "@/lib/doctor.js"; interface LoginProps { onDone: (auth: AuthData) => void; @@ -14,9 +15,15 @@ interface LoginProps { // only surfaces for headless/remote machines or a browser that didn't open. // Overridable so tests can reveal it immediately. fallbackDelayMs?: number; + // When provided, the parent owns the failure: this component reports the + // error upward and drops its own "Press Enter to retry" affordance. Used by + // `codevhub doctor`, which records login as one check among many and must + // keep going to the summary rather than parking on a retry prompt. Absent + // (install / config / login), the retry behaviour is unchanged. + onError?: (err: unknown) => void; } -export function Login({ onDone, fallbackDelayMs = 3000 }: LoginProps) { +export function Login({ onDone, fallbackDelayMs = 3000, onError }: LoginProps) { const [logs, setLogs] = useState([]); const [error, setError] = useState(null); const [attempt, setAttempt] = useState(0); @@ -72,11 +79,15 @@ export function Login({ onDone, fallbackDelayMs = 3000 }: LoginProps) { onDone(auth); }) .catch((err: Error) => { - // Unwraps Node's bare `fetch failed` to the real reason (DNS, TLS, - // proxy interception) and appends a remedy for certificate failures. - setError(describeNetworkError(err)); + // Full diagnosis, not Node's bare `fetch failed`: names the real + // failure (DNS / refused / timeout / TLS interception), says what + // most likely caused it on this machine given the proxy state, and + // gives the fix. This is where users on a corporate network most + // often meet a network error, so it gets the good message. + setError(describeFailure(err, { url: SSO_URL })); + onError?.(err); }); - }, [addLog, onDone, attempt]); + }, [addLog, onDone, onError, attempt]); // Reveal the manual fallback a few seconds after the URL is ready, so the // happy path stays a clean one-line spinner. @@ -102,14 +113,28 @@ export function Login({ onDone, fallbackDelayMs = 3000 }: LoginProps) { useInput((_input, key) => { // A fatal failure takes over the screen: Enter restarts the attempt. - if (error && key.return) setAttempt((n) => n + 1); + // When the parent handles errors (doctor), it decides what happens next + // and this key would fight with its own flow. + if (!onError && error && key.return) setAttempt((n) => n + 1); }); if (error) { + // A plain one-line reason stays inline ("Login failed: "); a + // multi-line diagnosis (what happened / likely cause / fix) breaks onto + // its own lines so the structure survives. + const lines = error.split("\n"); + const [first = "", ...rest] = lines; return ( - {`Login failed: ${error}`} - {"Press Enter to retry, Ctrl-C to quit"} + {`Login failed: ${first}`} + {rest.map((line, i) => ( + + {line} + + ))} + {!onError && ( + {"Press Enter to retry, Ctrl-C to quit"} + )} ); } diff --git a/src/components/ProxyPrompt.tsx b/src/components/ProxyPrompt.tsx new file mode 100644 index 0000000..3e7ac05 --- /dev/null +++ b/src/components/ProxyPrompt.tsx @@ -0,0 +1,155 @@ +import { Box, Text, useInput } from "ink"; +import { useState } from "react"; +import { normalizeProxyInput } from "@/lib/doctor.js"; + +interface ProxyPromptProps { + /** Called with a normalized proxy URL, or null when the user skips. */ + onSubmit: (proxyUrl: string | null) => void; + /** + * The proxy already in the environment, if any. Its presence changes the + * question from "do you need a proxy?" to "is this one wrong?", which is the + * more useful question once the network has failed *despite* a proxy. + */ + currentProxy?: string | null; + readOnly?: boolean; +} + +// Concrete forms, because "host:port" alone leaves real questions unanswered: +// does a hostname work, how do I pass a password, do I need http://. Each line +// exists to answer one of those. +const EXAMPLES: [string, string][] = [ + ["10.60.129.1:3128", "IP and port"], + ["proxy.corp.vn:8080", "hostname and port"], + ["user:pass@10.60.129.1:3128", "proxy that needs a login"], + ["http://10.60.129.1:3128", "full URL (http:// is assumed if you omit it)"], +]; + +const EXAMPLE_WIDTH = Math.max(...EXAMPLES.map(([e]) => e.length)); + +/** + * Single `host:port` field, offered whenever the network checks fail. + * + * Deliberately offered even when a proxy is already configured: "the proxy is + * set, so the proxy is fine" was the wrong inference — a *wrong* proxy address + * is one of the most likely reasons the checks failed at all, and suppressing + * the prompt left that user with no way to try another one. + * + * Hand-rolled on `useInput` like ManualCredentials — the repo deliberately + * carries no text-input dependency, and one optional field does not justify + * adding one. + */ +export function ProxyPrompt({ + onSubmit, + currentProxy = null, + readOnly = false, +}: ProxyPromptProps) { + const [value, setValue] = useState(""); + const [error, setError] = useState(null); + const [submitted, setSubmitted] = useState(false); + + useInput( + (input, key) => { + if (submitted) return; + + if (key.return) { + const trimmed = value.trim(); + // Blank means "skip" — the failure stays recorded and the summary + // still prints the setup instructions. + if (!trimmed) { + setSubmitted(true); + onSubmit(null); + return; + } + const url = normalizeProxyInput(trimmed); + if (!url) { + // Name the likely mistake. A bare number is both the most probable + // slip against a "host:port" prompt and the one with the worst + // silent failure, so it gets its own message. + setError( + /^\d+$/.test(trimmed) + ? `"${trimmed}" looks like just the port. Enter the host too, e.g. 10.0.0.1:${trimmed}` + : "That doesn't look like a proxy address. Use host:port, e.g. 10.0.0.1:8080", + ); + return; + } + setError(null); + setSubmitted(true); + onSubmit(url); + return; + } + + if (key.backspace || key.delete) { + setValue((prev) => prev.slice(0, -1)); + return; + } + + // Ignore control keys so escape sequences never leak into the field. + if (key.ctrl || key.meta || key.escape) return; + if (!input) return; + setValue((prev) => prev + input); + }, + { isActive: !readOnly && !submitted }, + ); + + if (submitted) { + return ( + + {value.trim() ? `Retrying via ${value.trim()}…` : "Skipped."} + + ); + } + + return ( + + {currentProxy ? ( + <> + + {`The network checks failed even though a proxy is configured (${currentProxy}).`} + + + { + "If that address is wrong, enter the correct one and CoDev will re-run the checks with it." + } + + + ) : ( + + { + "The network checks failed. If this machine reaches the internet through a proxy, enter it here and CoDev will re-run the checks with it applied." + } + + )} + + {"Nothing is written to disk — this applies to this run only."} + + + + {"Examples:"} + {EXAMPLES.map(([example, note]) => ( + + {" "} + + {example} + + {note} + + ))} + + + + + {currentProxy + ? "Proxy (host:port), or Enter to keep the current one: " + : "Proxy (host:port), or Enter to skip: "} + + {value} + + + {error && {error}} + + ); +} + +export function proxyPromptTitle() { + return {"Configure a proxy"}; +} diff --git a/src/index.tsx b/src/index.tsx index 924a9c5..b086e05 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -3,14 +3,23 @@ import { join } from "node:path"; import { styleText } from "node:util"; import { render } from "ink"; import { ConfigApp } from "@/ConfigApp.js"; +import { DoctorApp } from "@/DoctorApp.js"; import { InstallApp } from "@/InstallApp.js"; import { LoginApp } from "@/LoginApp.js"; import { clearSkillhubCookie, logout } from "@/lib/auth.js"; import { runClearLogs } from "@/lib/clear-logs.js"; import { forwardToCodegraph } from "@/lib/codegraph.js"; +import { + MIN_NODE_STRING, + NODE_DOWNLOAD_URL, + nodeVersionMeets, + RECOMMENDED_NODE, +} from "@/lib/const.js"; +import { doctorOutcome, rerunDoctorWithProxy } from "@/lib/doctor.js"; import { printHelp, printVersion } from "@/lib/help.js"; import { initLogging } from "@/lib/log.js"; import { runLogs } from "@/lib/logs.js"; +import { applyEnvProxy } from "@/lib/proxy.js"; import { ensureNodeSqliteOrReexec } from "@/lib/reexec.js"; import { ensureFreshGatewayKey } from "@/lib/refresh.js"; import { @@ -41,16 +50,18 @@ import { SkillPushApp } from "@/SkillPushApp.js"; import { UpdateApp } from "@/UpdateApp.js"; import { UploadApp } from "@/UploadApp.js"; -// `node:sqlite` (used by the OpenCode provider) was added in Node 22.5 and -// stabilized in Node 23.5. Earlier 22.x patches don't expose the module even -// with --experimental-sqlite. -const MIN_NODE_VERSION = "22.5.0"; -const [nodeMajor = 0, nodeMinor = 0] = process.versions.node - .split(".") - .map((n) => Number.parseInt(n, 10) || 0); -if (nodeMajor < 22 || (nodeMajor === 22 && nodeMinor < 5)) { +// The floor is 22.21.0 — the release that backported HTTP_PROXY/HTTPS_PROXY +// support to the Node 22 line. Below it Node silently ignores proxy environment +// variables, so on a corporate network every command fails at sign-in with no +// way to fix it. (`node:sqlite`, the previous reason for a 22.5 floor, is +// comfortably below this and still handled by the --experimental-sqlite +// re-exec in lib/reexec.ts.) +if (!nodeVersionMeets(process.versions.node)) { console.error( - `CoDev requires Node.js >= ${MIN_NODE_VERSION}. Current version: ${process.versions.node}.`, + `CoDev requires Node.js >= ${MIN_NODE_STRING} (Node ${RECOMMENDED_NODE} recommended). ` + + `Current version: ${process.version}.\n` + + `Below ${MIN_NODE_STRING}, Node ignores HTTP_PROXY/HTTPS_PROXY entirely, so sign-in ` + + `cannot work behind a corporate proxy.\nDownload: ${NODE_DOWNLOAD_URL}`, ); process.exit(1); } @@ -87,6 +98,22 @@ function flagValue(argv: string[], name: string): string | undefined { // never stdout/stderr, which the Ink apps own. initLogging(command ?? "help", args); +// Node does not honor HTTP_PROXY/HTTPS_PROXY unless NODE_USE_ENV_PROXY is set, +// and it reads that only at startup. Users who follow the install guide and +// export just the proxy variables therefore get no proxy at all — silently. +// Enable it here, before dispatch, so every command benefits rather than just +// the one being debugged. Best-effort: a failure never blocks the command, +// because `codevhub doctor` must still be able to run and explain why. +{ + const proxyResult = applyEnvProxy(); + if (proxyResult.action === "reexec") process.exit(proxyResult.exitCode ?? 1); + if (proxyResult.noProxyWarning) { + // Not fixable on the user's behalf — rewriting their NO_PROXY would be + // overreach — but it silently defeats the proxy, so say so once. + process.stderr.write(`Warning: ${proxyResult.noProxyWarning}\n`); + } +} + // Rewrite shims left behind by pre-0.4 hub versions (their bodies re-exec the // old `codev` hub command, which is now the agent). Best-effort: a filesystem // hiccup here must never block the actual command. @@ -143,6 +170,26 @@ switch (command) { } break; } + // Pre-flight for everything `install` depends on: Node version, npm, proxy + // and TLS environment, network reachability, sign-in, API key, and a real + // LLM completion. Read-only — it never installs or configures anything. + case "doctor": { + const { waitUntilExit } = render( + , + ); + try { + await waitUntilExit(); + } catch { + process.exit(1); + } + // The app cannot re-exec itself: spawnSync with inherited stdio while Ink + // still owns the TTY corrupts the terminal. It records the intent instead + // and we act on it here, now that Ink has unmounted. + const retry = doctorOutcome.retryWithProxy; + if (retry) process.exit(rerunDoctorWithProxy(retry, args)); + process.exit(doctorOutcome.exitCode); + break; + } case "update": { const { waitUntilExit } = render(); try { diff --git a/src/lib/const.ts b/src/lib/const.ts index eba7257..c2ba6dc 100644 --- a/src/lib/const.ts +++ b/src/lib/const.ts @@ -30,6 +30,35 @@ export const GATEWAY_MAX_OUTPUT_TOKENS = 65536; export const VERSION: string = pkg.version; +// Node 22.21.0 (2025-10-20) is where HTTP_PROXY/HTTPS_PROXY support was +// backported to the 22 LTS line (nodejs/node#57872). Below it Node's `fetch` +// silently ignores proxy environment variables, so sign-in can never work +// behind a corporate proxy no matter how the user configures their shell — +// which is why the floor is this oddly specific patch and not a rounder number. +// It supersedes the older 22.5 floor, which only existed for `node:sqlite`. +// +// Lives here rather than in lib/doctor.ts so index.tsx can gate on it without +// pulling in doctor's dependency graph before the version check has run. +export const MIN_NODE = { major: 22, minor: 21, patch: 0 } as const; +export const MIN_NODE_STRING = "22.21.0"; +export const RECOMMENDED_NODE = "24 (LTS)"; +export const NODE_DOWNLOAD_URL = "https://nodejs.org/en/download"; + +export function parseNodeVersion(version: string): [number, number, number] { + const [major = 0, minor = 0, patch = 0] = version + .replace(/^v/, "") + .split(".") + .map((n) => Number.parseInt(n, 10) || 0); + return [major, minor, patch]; +} + +export function nodeVersionMeets(version: string): boolean { + const [major, minor, patch] = parseNodeVersion(version); + if (major !== MIN_NODE.major) return major > MIN_NODE.major; + if (minor !== MIN_NODE.minor) return minor > MIN_NODE.minor; + return patch >= MIN_NODE.patch; +} + export const HELP_HINT = "Run `codevhub --help` to see all commands."; export const HAPPY_CODING = "Happy coding! 🎉"; diff --git a/src/lib/doctor.ts b/src/lib/doctor.ts new file mode 100644 index 0000000..06a67ac --- /dev/null +++ b/src/lib/doctor.ts @@ -0,0 +1,1991 @@ +import { + accessSync, + existsSync, + constants as fsConstants, + mkdirSync, + writeFileSync, +} from "node:fs"; +import { delimiter, dirname, join } from "node:path"; +import { loadApiKey } from "@/lib/auth.js"; +import { + fetchApiKey, + fetchCodevConfig, + fetchModels, + fetchSupabaseSession, + smokeTestModel, + validateApiKey, +} from "@/lib/backend.js"; +import { + detectConfiguredTools, + getBackupStatus, + type Tool, +} from "@/lib/configure.js"; +import { + BACKEND_URL, + FALLBACK_MODEL, + MIN_NODE_STRING, + NODE_DOWNLOAD_URL, + nodeVersionMeets, + parseNodeVersion, + RECOMMENDED_NODE, + VERSION, +} from "@/lib/const.js"; +import { + currentTraceId, + logDebug, + logError, + loggedFetch, + logInfo, + logWarn, + type RequestRecord, + recordRequests, + redactSecrets, + requestLog, +} from "@/lib/log.js"; +import { + CLI, + type CommandRecord, + commandLog, + execAsync, + type NpmTool, + npmGlobalRoot, + PKG, + recordCommands, +} from "@/lib/npm.js"; +import { doctorReportPath } from "@/lib/paths.js"; +import { + backendHost, + envVarKeys, + hasProxyConfigured, + maskProxyCredentials, + matchingNoProxyEntry, + overrideEnvVar, + type ProxyEnv, + type ProxyEnvVar, + proxyAutoEnabled, + proxyEnvSummary, + proxyForUrl, + readProxyEnv, + stripNoProxyFor, +} from "@/lib/proxy.js"; +import { spawner } from "@/lib/reexec.js"; +import { agentOnPath } from "@/lib/run.js"; +import { detectInstalledShims } from "@/lib/shims.js"; +import { isCertError, tlsApi } from "@/lib/tls.js"; + +// --------------------------------------------------------------------------- +// Requirements +// --------------------------------------------------------------------------- + +// The Node floor and its rationale live in lib/const.ts so index.tsx can gate +// on them without importing this module's dependency graph. + +// The internal registry mirror from the install guide. Suggested, never set. +export const INTERNAL_NPM_REGISTRY = + "http://10.60.129.132/repository/npm-proxy"; +const PUBLIC_NPM_REGISTRY = /registry\.npmjs\.org/; + +// The package `codevhub` itself ships as — probed against the registry to prove +// an authenticated read works end-to-end through the proxy. +const SELF_PKG = "codev-ai"; + +// Deliberately more generous than backend.ts's 5s/10s/15s, which are tuned for +// the interactive install flow. Behind a slow proxy those produce spurious +// timeouts, and a pre-flight check that cries wolf is worse than none. +const REACH_TIMEOUT_MS = 20_000; + +// --------------------------------------------------------------------------- +// Check model +// --------------------------------------------------------------------------- + +export type CheckStatus = "pass" | "warn" | "fail" | "skip"; +export type CheckGroup = + | "environment" + | "network" + | "account" + | "llm" + | "state"; + +export interface CheckResult { + status: CheckStatus; + /** One-line outcome. Always shown, whatever the status. */ + detail: string; + /** Remediation. Collected into the final "Next steps" block. */ + fix?: string; + /** Full diagnosis, rendered inline under a failing row. */ + diagnosis?: Diagnosis; +} + +export interface DoctorContext { + /** SSO access token, set once the login check passes. */ + accessToken?: string; + /** Gateway API key, set by the api-key check. */ + apiKey?: string; + /** Gateway base URL, set by the codev-config check. */ + gatewayUrl?: string; + /** Model ids, set by the models check. */ + models?: string[]; + /** Supabase project URL, set by the codev-config check. */ + supabaseUrl?: string; +} + +export interface Check { + key: string; + label: string; + group: CheckGroup; + run: (ctx: DoctorContext) => Promise; +} + +/** + * One thing a check actually did — a subprocess it spawned or a request it + * made — shown under that check's row. + * + * Deliberately carries no pass/fail marker of its own. An earlier revision + * listed requests separately with their own icons, and scoring a 401 as a + * failure contradicted the check above it that (correctly) called the same 401 + * a pass. The check's icon is the verdict; these lines are evidence. + */ +export interface CheckActivity { + kind: "command" | "request"; + detail: string; + durationMs: number; +} + +export interface CheckOutcome extends CheckResult { + key: string; + label: string; + group: CheckGroup; + /** What this check ran, in order. */ + activity?: CheckActivity[]; +} + +// --------------------------------------------------------------------------- +// Error diagnosis +// +// This is the reason the command exists. `describeNetworkError` (lib/tls.ts) +// unwraps ONE level of err.cause and only special-cases certificate codes; +// everything else still reaches the user as `fetch failed` or +// `fetch failed (connect ECONNREFUSED 10.0.0.1:8080)`, which tells a +// non-engineer nothing. Here we walk the whole chain, name the failure in plain +// language, say what most likely caused it *on this machine*, give the fix, and +// print the raw chain so nothing is hidden from support. +// --------------------------------------------------------------------------- + +export interface Diagnosis { + /** Plain language, not the errno. */ + what: string; + /** Most likely cause, specific to this environment. */ + cause: string; + /** The concrete fix. */ + fix: string; + /** Connection state that determined the outcome. */ + context: string[]; + /** The verbatim error chain, one line per link. */ + raw: string[]; +} + +/** What we were doing when it broke. Drives the context block. */ +export interface Attempt { + url?: string; + method?: string; + timeoutMs?: number; + /** For child processes: the exact command line. */ + command?: string; +} + +interface ErrorLink { + name: string; + message: string; + code?: string; + errno?: number; + syscall?: string; + hostname?: string; + address?: string; + port?: number; +} + +// Node's fetch throws `TypeError: fetch failed` and hides the real reason on +// .cause — sometimes several levels down, sometimes inside an AggregateError +// (one entry per address family when a host resolves to both A and AAAA). +// Flatten the whole thing; the first link carrying a `code` is the real failure. +function errorChain(err: unknown, depth = 0): ErrorLink[] { + if (depth > 8 || !(err instanceof Error)) return []; + const e = err as NodeJS.ErrnoException & { errors?: unknown[] }; + const link: ErrorLink = { + name: err.name, + message: err.message, + code: typeof e.code === "string" ? e.code : undefined, + errno: typeof e.errno === "number" ? e.errno : undefined, + syscall: typeof e.syscall === "string" ? e.syscall : undefined, + hostname: (e as { hostname?: string }).hostname, + address: (e as { address?: string }).address, + port: (e as { port?: number }).port, + }; + const nested: ErrorLink[] = []; + if (Array.isArray(e.errors)) { + for (const inner of e.errors) nested.push(...errorChain(inner, depth + 1)); + } + if (err.cause !== undefined) nested.push(...errorChain(err.cause, depth + 1)); + return [link, ...nested]; +} + +// Codes we know how to explain. Used both to pick the root link and to recover +// a code that only survives in the message text. +const KNOWN_CODES = [ + "ENOTFOUND", + "EAI_AGAIN", + "ECONNREFUSED", + "ETIMEDOUT", + "UND_ERR_CONNECT_TIMEOUT", + "ECONNRESET", + "EPROTO", + "UND_ERR_SOCKET", + "CERT_HAS_EXPIRED", + "ERR_TLS_CERT_ALTNAME_INVALID", +]; + +// Node normally sets `.code` on the underlying error, but code that catches and +// re-throws routinely loses it while keeping the text ("getaddrinfo ENOTFOUND +// host", "connect ECONNREFUSED 10.0.0.1:8080"). Recovering it from the message +// is the difference between a real diagnosis and the generic fallback. +function inferCodeFromMessage(message: string): string | undefined { + return KNOWN_CODES.find((code) => message.includes(code)); +} + +// A bare `Error("getaddrinfo ENOTFOUND api.example.com")` still names the host; +// pull it out so the explanation can say which host failed. +function inferHostFromMessage(message: string): string | undefined { + const dns = /(?:getaddrinfo|queryA\w*)\s+\w+\s+([\w.-]+)/.exec(message); + if (dns?.[1]) return dns[1]; + const conn = /connect\s+\w+\s+([\d.a-f:]+):(\d+)/i.exec(message); + if (conn?.[1]) return conn[1]; + return undefined; +} + +/** + * The first link that carries an OS/undici error code — the real failure. + * + * Falls back to recovering the code (and host/port) from the message text, so + * a re-thrown error that lost its properties still gets a real diagnosis + * instead of the generic one. + */ +function rootLink(chain: ErrorLink[]): ErrorLink | undefined { + const withCode = chain.find((l) => l.code !== undefined); + if (withCode) return withCode; + + for (const link of chain) { + const code = inferCodeFromMessage(link.message); + if (!code) continue; + const conn = /connect\s+\w+\s+([\d.a-f:]+):(\d+)/i.exec(link.message); + return { + ...link, + code, + hostname: link.hostname ?? inferHostFromMessage(link.message), + address: link.address ?? conn?.[1], + port: link.port ?? (conn?.[2] ? Number(conn[2]) : undefined), + }; + } + return chain[chain.length - 1]; +} + +function renderChain(chain: ErrorLink[]): string[] { + return chain.map((link, i) => { + const indent = i === 0 ? "" : `${" ".repeat(i - 1)}└ `; + const facts = [ + link.code && `code ${link.code}`, + link.syscall && `syscall ${link.syscall}`, + link.hostname && `hostname ${link.hostname}`, + link.address && `address ${link.address}`, + link.port !== undefined && `port ${link.port}`, + ].filter(Boolean); + const suffix = facts.length > 0 ? ` (${facts.join(" · ")})` : ""; + return redactSecrets(`${indent}${link.name}: ${link.message}${suffix}`); + }); +} + +/** Where a proxy applies from, for the context block. */ +function proxyDescription(proxy: ProxyEnv): string { + if (!hasProxyConfigured(proxy)) return "proxy: none"; + const parts: string[] = []; + if (proxy.httpsProxy) { + parts.push(`HTTPS_PROXY=${maskProxyCredentials(proxy.httpsProxy)}`); + } + if (proxy.httpProxy) { + parts.push(`HTTP_PROXY=${maskProxyCredentials(proxy.httpProxy)}`); + } + return `proxy: ${parts.join(" · ")}`; +} + +// The single most common silent failure: a proxy is set, but Node was never +// told to use it, so every request goes direct and dies in the firewall. Node +// gives no hint whatsoever that it ignored the variable. +function proxyIgnoredNote(proxy: ProxyEnv): string | null { + if (!hasProxyConfigured(proxy)) return null; + if (proxy.useEnvProxy) return null; + return "NODE_USE_ENV_PROXY is unset — Node is IGNORING your proxy settings"; +} + +function connectionContext(attempt: Attempt): string[] { + const proxy = readProxyEnv(); + const lines: string[] = []; + if (attempt.command) lines.push(`command: ${attempt.command}`); + if (attempt.url) { + const timeout = attempt.timeoutMs + ? ` (timeout ${Math.round(attempt.timeoutMs / 1000)}s)` + : ""; + lines.push( + redactSecrets(`${attempt.method ?? "GET"} ${attempt.url}${timeout}`), + ); + } + const ignored = proxyIgnoredNote(proxy); + lines.push( + `${proxyDescription(proxy)} · NODE_USE_ENV_PROXY: ${ + proxy.useEnvProxy ? "on" : "unset" + }`, + ); + if (ignored) lines.push(`⚠ ${ignored}`); + + const noProxyEntry = matchingNoProxyEntry(proxy, backendHost()); + lines.push( + `NO_PROXY: ${proxy.noProxy ?? "unset"}${ + noProxyEntry ? ` — "${noProxyEntry}" exempts ${backendHost()}` : "" + }`, + ); + lines.push(`node ${process.version} · ${process.platform} ${process.arch}`); + return lines; +} + +// A proxy is configured but Node was told to ignore it — that supersedes every +// other explanation, because nothing else can be diagnosed until it's fixed. +function proxyMisconfigFix(proxy: ProxyEnv): string | null { + if (!hasProxyConfigured(proxy) || proxy.useEnvProxy) return null; + return ( + "Set NODE_USE_ENV_PROXY=1 alongside HTTP_PROXY/HTTPS_PROXY. Node ignores " + + "proxy environment variables without it, and reads it only at startup." + ); +} + +const NO_PROXY_SET_FIX = + "No proxy is configured. If this network requires one, set HTTP_PROXY, " + + "HTTPS_PROXY and NODE_USE_ENV_PROXY=1, then re-run."; + +/** + * Turn any thrown error into a four-part, human-readable diagnosis. + * + * Never returns a bare "fetch failed": every branch produces a plain-language + * `what`, a `cause` specific to this machine's proxy/TLS state, a concrete + * `fix`, and the raw chain for support. + */ +export function diagnoseError(err: unknown, attempt: Attempt = {}): Diagnosis { + const chain = errorChain(err); + const root = rootLink(chain); + const proxy = readProxyEnv(); + const context = connectionContext(attempt); + const raw = + chain.length > 0 ? renderChain(chain) : [redactSecrets(String(err))]; + const host = root?.hostname ?? hostOf(attempt.url) ?? "the server"; + const proxied = hasProxyConfigured(proxy); + const misconfig = proxyMisconfigFix(proxy); + + const base = (what: string, cause: string, fix: string): Diagnosis => ({ + what, + // A configured-but-ignored proxy explains every network symptom below, + // so it wins the "likely cause" slot when present. + cause: misconfig ? `${cause} ${proxyIgnoredNote(proxy)}.` : cause, + fix: misconfig ?? fix, + context, + raw, + }); + + // AbortSignal.timeout produces a DOMException whose message ("The operation + // was aborted due to a timeout") names neither the host nor the duration — + // useless on its own, so rebuild it from the attempt. + if (root?.name === "TimeoutError" || root?.code === "ABORT_ERR") { + const secs = attempt.timeoutMs + ? `${Math.round(attempt.timeoutMs / 1000)}s` + : "the timeout"; + return base( + `No response from ${host} within ${secs}.`, + proxied + ? "The proxy accepted the connection but never returned a response — it may be slow, overloaded, or blocking this host." + : "Traffic to this host is most likely being dropped by a firewall that expects it to go through a proxy.", + proxied + ? "Confirm the proxy address is correct and that it is allowed to reach this host, then re-run." + : NO_PROXY_SET_FIX, + ); + } + + switch (root?.code) { + case "ENOTFOUND": + case "EAI_AGAIN": + return base( + `Could not resolve ${host} (DNS lookup failed).`, + proxied + ? "A proxy is configured, so DNS should be resolved by the proxy — the request appears to have gone direct instead." + : "No proxy is configured and this network's DNS does not resolve external names.", + proxied + ? "Check that the proxy URL is well-formed (http://host:port) and that this host is not listed in NO_PROXY." + : NO_PROXY_SET_FIX, + ); + + case "ECONNREFUSED": + return base( + `Connection refused by ${root.address ?? host}${ + root.port !== undefined ? `:${root.port}` : "" + }.`, + // Only call it "your proxy" when the error actually named the + // address it tried. Without one we fall back to the request host, + // and asserting that the *destination* is the proxy contradicts the + // line right above it. + proxied && root.address + ? "That address is your proxy — nothing is listening on it, so the proxy host or port is wrong." + : proxied + ? "A proxy is configured, so the refusal most likely came from the proxy rather than the destination." + : "Nothing accepted a connection on that address.", + proxied + ? "Double-check HTTP_PROXY / HTTPS_PROXY. The value must include the scheme and port, e.g. http://10.0.0.1:8080." + : NO_PROXY_SET_FIX, + ); + + case "ETIMEDOUT": + case "UND_ERR_CONNECT_TIMEOUT": + return base( + `Timed out connecting to ${root.address ?? host}.`, + proxied + ? "The proxy address is reachable in principle but never completed the handshake." + : "A firewall is silently dropping this traffic, which usually means it must go through a proxy.", + proxied + ? "Verify the proxy host and port with your IT team, then re-run." + : NO_PROXY_SET_FIX, + ); + + case "ECONNRESET": + case "EPROTO": + case "UND_ERR_SOCKET": + return base( + `The connection to ${host} was reset mid-handshake.`, + "Typically a TLS-intercepting proxy or HTTPS-scanning antivirus rejecting the request.", + misconfig ?? + "Ask IT for your organization's root CA (a PEM file) and set NODE_EXTRA_CA_CERTS to its path, plus NODE_USE_SYSTEM_CA=1, then re-run.", + ); + + case "CERT_HAS_EXPIRED": + return base( + `${host} presented an expired TLS certificate.`, + "Either the server's certificate genuinely expired, or an intercepting proxy is re-signing with an expired root.", + "Check your system clock is correct. If it is, ask IT — their interception certificate has expired.", + ); + + case "ERR_TLS_CERT_ALTNAME_INVALID": + return base( + `The TLS certificate presented for ${host} is issued for a different hostname.`, + "A proxy is substituting its own certificate without matching the requested host.", + "Ask IT to confirm this host is correctly configured for TLS interception, then re-run.", + ); + + default: + break; + } + + // Certificate trust failures — reuse tls.ts's classification so both paths + // agree on what counts as "untrusted chain". + if (isCertError(err) || root?.code?.includes("CERT")) { + return base( + `Node does not trust the TLS certificate presented for ${host}.`, + "A TLS-intercepting proxy or antivirus is re-signing traffic with a corporate root CA that Node's bundled certificate list does not include.", + `Set NODE_USE_SYSTEM_CA=1 so Node reads your OS trust store. If the root is not in the OS store either, ask IT for the root CA (PEM) and point NODE_EXTRA_CA_CERTS at it.${ + tlsApi.supported() + ? "" + : ` Note: Node ${process.version} cannot read the OS store at all — upgrade to Node ${RECOMMENDED_NODE}.` + }`, + ); + } + + // Nothing in the chain names a network failure and there is no `cause`, so + // this is our own code reporting what the server said — a thrown + // `Backend /auth/exchange failed (401): …`, not a connectivity problem. + // Running it through the proxy reasoning below would tell a user whose + // network is fine that they are missing a proxy, which is worse than + // saying nothing. `base()` is deliberately not used here: its whole job is + // to fold in proxy speculation. + if (root?.code === undefined && !isTransportError(err)) { + const message = root?.message ?? String(err); + const status = /\((\d{3})\)/.exec(message)?.[1]; + const isAuth = status === "401" || status === "403"; + return { + what: redactSecrets(message), + cause: isAuth + ? `${host} answered and rejected the credentials — an authorization result, not a connectivity problem.` + : `${host} answered; it rejected the request rather than the network failing.`, + fix: isAuth + ? "Run `codevhub login --force` to re-authenticate, then re-run." + : "See the raw output below — the server's own message is the best guide here.", + context, + raw, + }; + } + + // Unknown code — still never a bare "fetch failed". Name the deepest message + // we found and hand over the raw chain. + return base( + root?.message + ? `Request to ${host} failed: ${redactSecrets(root.message)}` + : `Request to ${host} failed.`, + proxied + ? "A proxy is configured; the failure happened somewhere between this machine, the proxy, and the destination." + : "No proxy is configured. If this network requires one, that is the most likely cause.", + misconfig ?? NO_PROXY_SET_FIX, + ); +} + +function hostOf(url: string | undefined): string | null { + if (!url) return null; + try { + return new URL(url).hostname; + } catch { + return null; + } +} + +// Headers that betray an interceptor sitting between us and the destination. +const INTERCEPTOR_HEADERS = [ + "via", + "server", + "proxy-authenticate", + "www-authenticate", + "x-cache", + "cf-ray", + "x-squid-error", +]; + +/** + * Diagnose a non-2xx HTTP response. + * + * A response is not an exception, so `describeNetworkError` never sees these at + * all — yet a 407 or a proxy-issued 403 is exactly what internal users hit. + */ +export function diagnoseResponse( + status: number, + statusText: string, + headers: Headers, + body: string, + attempt: Attempt = {}, +): Diagnosis { + const context = connectionContext(attempt); + const signature = INTERCEPTOR_HEADERS.map((h) => { + const v = headers.get(h); + return v ? `${h}: ${v}` : null; + }).filter((v): v is string => v !== null); + const snippet = redactSecrets(body.trim()).slice(0, 400); + const raw = [ + `HTTP ${status} ${statusText}`, + ...signature.map(redactSecrets), + ...(snippet ? [snippet + (body.trim().length > 400 ? "…" : "")] : []), + ]; + const host = hostOf(attempt.url) ?? "the server"; + + if (status === 407) { + return { + what: "The proxy requires authentication (HTTP 407).", + cause: `Your proxy is challenging this request: ${ + headers.get("proxy-authenticate") ?? "no scheme advertised" + }.`, + fix: "Include your credentials in the proxy URL, e.g. HTTPS_PROXY=http://user:password@host:port, then re-run.", + context, + raw, + }; + } + + if (status === 403) { + const intercepted = + signature.length > 0 || /proxy|blocked|denied|firewall/i.test(body); + return { + what: `${host} returned HTTP 403 (forbidden).`, + cause: intercepted + ? "The response carries proxy/gateway signatures, so this looks like the network blocking the request rather than a CoDev permissions problem." + : "Either your account lacks access, or a proxy is blocking the request without identifying itself.", + fix: "If you are behind a proxy, this is the documented npm/registry 403: unset HTTP_PROXY and HTTPS_PROXY in the CURRENT shell (do not open a new terminal) and retry.", + context, + raw, + }; + } + + if (status === 502 || status === 503 || status === 504) { + return { + what: `${host} returned HTTP ${status} (${statusText || "upstream error"}).`, + cause: + "Usually the proxy failing to reach the destination, rather than the destination itself being down.", + fix: "Retry shortly. If it persists, confirm with IT that the proxy is allowed to reach this host.", + context, + raw, + }; + } + + if (status === 401) { + return { + what: `${host} rejected the credentials (HTTP 401).`, + cause: "The token or API key sent with this request was not accepted.", + fix: "Run `codevhub login --force` to re-authenticate, then re-run.", + context, + raw, + }; + } + + return { + what: `${host} returned HTTP ${status}${statusText ? ` (${statusText})` : ""}.`, + cause: + signature.length > 0 + ? "The response carries proxy signatures, so a network appliance may have rewritten it." + : "The server rejected the request.", + fix: "See the raw response below; if it does not look like it came from CoDev, a proxy is intercepting the request.", + context, + raw, + }; +} + +/** Diagnose a failed child process (npm, agent CLIs). */ +export function diagnoseExec( + command: string, + exitCode: string | number | null, + stderr: string, + isEnoent: boolean, +): Diagnosis { + // npm's own error text names the registry, proxy and .npmrc in play, so it + // is reproduced in FULL — truncating it destroys the diagnosis. + const raw = redactSecrets(stderr.trim() || "(no stderr)") + .split("\n") + .map((l) => l.trimEnd()); + + if (isEnoent) { + return { + what: `\`${command.split(" ")[0]}\` was not found on your PATH.`, + cause: + "The program is not installed, or its install directory is not on PATH.", + fix: "Install Node.js from nodejs.org (npm ships with it) and open a new terminal so PATH is refreshed.", + context: connectionContext({ command }), + raw, + }; + } + + const proxy = readProxyEnv(); + const lower = stderr.toLowerCase(); + let cause = + "The command ran but exited with an error. The full output is below."; + let fix = "Read the output below — npm names the registry and proxy it used."; + + if (lower.includes("e403") || lower.includes("403 forbidden")) { + cause = + "A 403 from the registry. Behind a corporate proxy this is usually the proxy rejecting the request, not a package permissions problem."; + fix = + "Unset the proxy in the CURRENT shell and retry without opening a new terminal: `unset HTTPS_PROXY HTTP_PROXY` (Linux/macOS) or `$env:HTTPS_PROXY = $null; $env:HTTP_PROXY = $null` (PowerShell)."; + } else if (lower.includes("eacces") || lower.includes("permission denied")) { + cause = "npm could not write to its global install directory."; + fix = + "Point npm at a writable prefix (`npm config set prefix ~/.npm-global`) and add its `bin` directory to PATH, or reinstall Node with a user-owned prefix. Avoid `sudo npm i -g`."; + } else if ( + lower.includes("self-signed") || + lower.includes("self signed") || + lower.includes("unable_to_get_issuer") || + lower.includes("cert") + ) { + cause = + "npm could not verify the registry's TLS certificate — a TLS-intercepting proxy is re-signing the connection."; + fix = + "Point npm at your organization's root CA: `npm config set cafile `. npm keeps its own TLS config, separate from Node's."; + } else if ( + lower.includes("etimedout") || + lower.includes("econnrefused") || + lower.includes("enotfound") || + lower.includes("network") + ) { + cause = hasProxyConfigured(proxy) + ? "npm could not reach the registry. npm has its OWN proxy configuration, separate from Node's environment variables." + : "npm could not reach the registry and no proxy is configured."; + fix = `Set npm's proxy explicitly: \`npm config set proxy \` and \`npm config set https-proxy \`. On the internal network also set the registry mirror: \`npm config set registry ${INTERNAL_NPM_REGISTRY}\`.`; + } + + return { + what: `\`${command}\` failed (exit code ${exitCode ?? "unknown"}).`, + cause, + fix, + context: connectionContext({ command }), + raw, + }; +} + +/** + * Compact single-string rendering, for callers with one line of screen space + * (Login / FetchApiKey's error frames). Keeps the plain-language explanation + * and the fix — the two things `fetch failed` never gave anyone. + */ +export function renderDiagnosisCompact(d: Diagnosis): string { + const lines = [d.what, d.cause, `Fix: ${d.fix}`]; + return lines.join("\n"); +} + +/** + * True when an error came from the transport layer (DNS/TCP/TLS) rather than + * from an HTTP response our own code turned into an Error. + * + * Node's fetch always nests the real reason on `.cause`; a + * `new Error("Backend /config failed (403): forbidden")` thrown by backend.ts + * has none. The distinction matters: the proxy-oriented reasoning in + * `diagnoseError` is exactly right for the former and actively misleading for + * the latter, whose own message is already precise. + */ +export function isTransportError(err: unknown): boolean { + return err instanceof Error && err.cause !== undefined; +} + +/** + * Compact, always-useful failure text for callers with a single error frame: + * a full diagnosis for transport failures, the server's own message otherwise. + */ +export function describeFailure(err: unknown, attempt: Attempt = {}): string { + if (isTransportError(err)) { + return renderDiagnosisCompact(diagnoseError(err, attempt)); + } + return err instanceof Error ? redactSecrets(err.message) : String(err); +} + +// --------------------------------------------------------------------------- +// Probing helpers +// --------------------------------------------------------------------------- + +/** Run a thunk, converting any throw into a `fail` with a full diagnosis. */ +async function guard( + attempt: Attempt, + fn: () => Promise, +): Promise { + try { + return await fn(); + } catch (err) { + const diagnosis = diagnoseError(err, attempt); + return { + status: "fail", + detail: diagnosis.what, + fix: diagnosis.fix, + diagnosis, + }; + } +} + +/** + * Transport-only reachability probe. ANY HTTP response — including 3xx and + * 401/403/404 — proves the network path works, which is all this is asking. + * Only DNS/TCP/TLS failures fail it. + * + * `redirect: "manual"` is load-bearing, not tidiness. `fetch` follows redirects + * by default, and the gateway 301s bare directory-ish paths to its *internal* + * origin (`GET /codev-backend` → `http://netmind.viettel.vn:9096/codev-backend/`, + * note the port and the downgrade to http). Following that from outside the + * corporate network times out, so the probe reported the backend unreachable + * on machines where every real API call worked. A redirect response is already + * proof the transport works; chasing it measures a different host and port. + */ +async function reach( + url: string, + label: string, + method = "GET", +): Promise { + const attempt = { url, method, timeoutMs: REACH_TIMEOUT_MS }; + return guard(attempt, async () => { + const res = await loggedFetch("doctor.reach", url, { + method, + redirect: "manual", + signal: AbortSignal.timeout(REACH_TIMEOUT_MS), + }); + // 407 is the one status that means the transport is NOT usable. + if (res.status === 407) { + const diagnosis = diagnoseResponse( + res.status, + res.statusText, + res.headers, + await res.text().catch(() => ""), + attempt, + ); + return { + status: "fail", + detail: diagnosis.what, + fix: diagnosis.fix, + diagnosis, + }; + } + // An unauthenticated probe is *supposed* to be rejected; saying so keeps + // a 401 from reading like a failure in a report full of real ones. + const note = + res.status === 401 || res.status === 403 + ? " — expected without credentials" + : ""; + return { + status: "pass", + detail: `${label} reachable (HTTP ${res.status}${note}).`, + }; + }); +} + +/** `npm config get `, or null when npm can't answer. */ +async function npmConfig(key: string): Promise { + const r = await execAsync("npm", ["config", "get", key]); + if (r.error) return null; + const value = r.stdout.trim(); + // npm prints the literal string "undefined"/"null" for unset keys. + if (!value || value === "undefined" || value === "null") return null; + return value; +} + +// --------------------------------------------------------------------------- +// Group 1 — environment (synchronous, no network) +// --------------------------------------------------------------------------- + +const nodeVersionCheck: Check = { + key: "node-version", + label: "Node.js version", + group: "environment", + run: async () => { + const version = process.versions.node; + if (!nodeVersionMeets(version)) { + return { + status: "fail", + detail: `Node ${version} is too old — CoDev requires >= ${MIN_NODE_STRING}.`, + fix: `Install Node ${RECOMMENDED_NODE} from ${NODE_DOWNLOAD_URL} and re-run.`, + diagnosis: { + what: `Node ${version} is below the required ${MIN_NODE_STRING}.`, + cause: + "Support for HTTP_PROXY/HTTPS_PROXY was added to the Node 22 line in 22.21.0. Below that, Node silently ignores proxy environment variables, so sign-in can never work behind a corporate proxy no matter how you configure it.", + fix: `Install Node ${RECOMMENDED_NODE} from ${NODE_DOWNLOAD_URL}, open a new terminal, and re-run \`codevhub doctor\`.`, + context: [ + `node ${process.version} · ${process.platform} ${process.arch}`, + `required >= ${MIN_NODE_STRING} · recommended ${RECOMMENDED_NODE}`, + ], + raw: [`process.versions.node = ${version}`], + }, + }; + } + const [major] = parseNodeVersion(version); + if (major < 24) { + return { + status: "pass", + detail: `Node ${version} meets the minimum (Node ${RECOMMENDED_NODE} recommended).`, + }; + } + return { status: "pass", detail: `Node ${version}.` }; + }, +}; + +const npmAvailableCheck: Check = { + key: "npm-available", + label: "npm available", + group: "environment", + run: async () => { + const r = await execAsync("npm", ["-v"]); + if (r.error) { + const diagnosis = diagnoseExec( + "npm -v", + r.error.code ?? null, + r.stderr, + r.error.code === "ENOENT", + ); + return { + status: "fail", + detail: diagnosis.what, + fix: diagnosis.fix, + diagnosis, + }; + } + return { status: "pass", detail: `npm ${r.stdout.trim()}.` }; + }, +}; + +// The two most common global-install failures, both documented: the prefix bin +// directory missing from PATH (Windows "PowerShell can't find the package") and +// EACCES writing into it. +const npmPrefixCheck: Check = { + key: "npm-prefix", + label: "npm global prefix", + group: "environment", + run: async () => { + const prefix = await npmConfig("prefix"); + if (!prefix) { + return { + status: "warn", + detail: "Could not read `npm config get prefix`.", + fix: "Confirm npm is installed and working (`npm -v`).", + }; + } + // npm puts global bins in /bin on Unix and directly in + // on Windows. + const binDir = process.platform === "win32" ? prefix : join(prefix, "bin"); + const onPath = (process.env.PATH ?? "") + .split(delimiter) + .some((dir) => dir && normalizePath(dir) === normalizePath(binDir)); + + let writable = true; + try { + accessSync(existsSync(binDir) ? binDir : prefix, fsConstants.W_OK); + } catch { + writable = false; + } + + if (!writable) { + return { + status: "fail", + detail: `npm's global directory is not writable: ${binDir}`, + fix: "Point npm at a writable prefix (`npm config set prefix ~/.npm-global`) and add its bin directory to PATH. Avoid `sudo npm i -g`, which creates root-owned files.", + diagnosis: { + what: `\`npm i -g\` cannot write to ${binDir}.`, + cause: + "The global prefix is owned by another user (commonly root, after an earlier `sudo npm i -g`), so installing agents will fail with EACCES.", + fix: "Run `npm config set prefix ~/.npm-global`, add `~/.npm-global/bin` to PATH, open a new terminal, and re-run.", + context: [`prefix: ${prefix}`, `bin: ${binDir}`], + raw: [`accessSync(${binDir}, W_OK) threw`], + }, + }; + } + + if (!onPath) { + return { + status: "warn", + detail: `npm's global bin directory is not on PATH: ${binDir}`, + fix: + process.platform === "win32" + ? 'Add it in PowerShell: $npmPath = npm config get prefix; [Environment]::SetEnvironmentVariable("PATH", "$([Environment]::GetEnvironmentVariable(\'PATH\',\'User\'));$npmPath", "User") — then open a new terminal.' + : `Add it to your shell profile: export PATH="${binDir}:$PATH" — then open a new terminal.`, + }; + } + + return { status: "pass", detail: `${binDir} (on PATH, writable).` }; + }, +}; + +function normalizePath(p: string): string { + const trimmed = p.replace(/[\\/]+$/, ""); + return process.platform === "win32" ? trimmed.toLowerCase() : trimmed; +} + +const npmRegistryCheck: Check = { + key: "npm-registry", + label: "npm registry configuration", + group: "environment", + run: async () => { + const [registry, proxy, httpsProxy, strictSsl, cafile] = await Promise.all([ + npmConfig("registry"), + npmConfig("proxy"), + npmConfig("https-proxy"), + npmConfig("strict-ssl"), + npmConfig("cafile"), + ]); + const parts = [`registry: ${registry ?? "unset"}`]; + if (proxy) parts.push(`proxy: ${proxy}`); + if (httpsProxy) parts.push(`https-proxy: ${httpsProxy}`); + if (cafile) parts.push(`cafile: ${cafile}`); + if (strictSsl === "false") parts.push("strict-ssl: false"); + const detail = parts.join(" · "); + + // npm keeps its own proxy/TLS configuration entirely separate from Node's + // environment variables — a working `codevhub` says nothing about whether + // `npm i -g` will work. + const env = readProxyEnv(); + if (hasProxyConfigured(env) && !proxy && !httpsProxy) { + return { + status: "warn", + detail, + fix: `Your shell has a proxy but npm does not — npm keeps its own config. Run: npm config set proxy ${env.httpsProxy ?? env.httpProxy} && npm config set https-proxy ${env.httpsProxy ?? env.httpProxy}`, + }; + } + if (registry && PUBLIC_NPM_REGISTRY.test(registry)) { + return { + status: "warn", + detail, + fix: `On the internal network the public registry is usually unreachable. Set the mirror: npm config set registry ${INTERNAL_NPM_REGISTRY}`, + }; + } + return { status: "pass", detail }; + }, +}; + +const proxyEnvCheck: Check = { + key: "proxy-env", + label: "Proxy & TLS environment", + group: "environment", + run: async () => { + const env = readProxyEnv(); + const host = backendHost(); + // The core variables are always listed, set or not: most of the failures + // this command exists for are a *missing* variable, so "unset" is an + // answer rather than an omission. Anything else the user has set is + // appended — including variables we do not model (NODE_OPTIONS, npm's + // npm_config_*), because on a misbehaving machine the one nobody thought + // to look at is usually the culprit. + const detail = proxyEnvSummary() + .map((v) => `${v.name}=${v.value ?? "unset"}`) + .join(" · "); + + const problems: string[] = []; + if (hasProxyConfigured(env) && proxyAutoEnabled()) { + // We turned it on for this process, so proxying genuinely works right + // now — but npm and the agents are separate processes that inherit the + // user's shell, not ours. + problems.push( + "NODE_USE_ENV_PROXY was not set, so CoDev enabled proxy support for this run. Set NODE_USE_ENV_PROXY=1 in your shell so npm and the agents get it too.", + ); + } else if (hasProxyConfigured(env) && !env.useEnvProxy) { + problems.push( + "A proxy is set but NODE_USE_ENV_PROXY is not — Node ignores proxy environment variables without it. Set NODE_USE_ENV_PROXY=1.", + ); + } + const noProxyEntry = matchingNoProxyEntry(env, host); + if (noProxyEntry) { + problems.push( + `NO_PROXY contains "${noProxyEntry}", which sends ${host} traffic direct instead of through the proxy. This is the documented cause of "Login failed" — remove that entry.`, + ); + } + if (env.tlsRejectUnauthorized === "0") { + problems.push( + "NODE_TLS_REJECT_UNAUTHORIZED=0 disables TLS verification for every connection. It works, but it is a troubleshooting setting — prefer NODE_USE_SYSTEM_CA=1 or NODE_EXTRA_CA_CERTS once your root CA is available.", + ); + } + if (hasProxyConfigured(env) && !env.useSystemCa) { + problems.push( + "A proxy is set but NODE_USE_SYSTEM_CA is not. Intercepting proxies re-sign TLS with a corporate root that Node does not trust by default; set NODE_USE_SYSTEM_CA=1.", + ); + } + + if (problems.length === 0) { + return { status: "pass", detail }; + } + return { status: "warn", detail, fix: problems.join("\n") }; + }, +}; + +const systemCaCheck: Check = { + key: "system-ca", + label: "System certificate store", + group: "environment", + run: async () => { + if (!tlsApi.supported()) { + return { + status: "warn", + detail: `Node ${process.version} cannot read the OS certificate store.`, + fix: `Upgrade to Node ${RECOMMENDED_NODE}, or set NODE_EXTRA_CA_CERTS to your organization's root CA (a PEM file).`, + }; + } + let count = 0; + try { + count = tlsApi.getCACertificates("system").length; + } catch { + count = 0; + } + if (count === 0) { + return { + status: "warn", + detail: "The OS certificate store is empty or unreadable.", + fix: "If you are behind a TLS-intercepting proxy, ask IT for the root CA (PEM) and set NODE_EXTRA_CA_CERTS to its path.", + }; + } + return { + status: "pass", + detail: `${count} certificates readable from the OS trust store.`, + }; + }, +}; + +/** + * The subset cheap enough to run at the head of `codevhub install`. + * + * Strictly pure: reads `process.versions` and `process.env` and nothing else. + * Everything excluded here is excluded for a concrete reason, not for tidiness: + * + * - the npm checks each spawn `npm config get`, ~300ms apiece, and `install` + * runs npm for real moments later anyway — a broken npm surfaces there with + * the same diagnosis; + * - `system-ca` reads the OS trust store, which is ~20ms on macOS but 300ms+ + * on Windows where it BLOCKS THE EVENT LOOP. lib/tls.ts documents exactly + * this: an earlier revision paid that cost eagerly and stalled Ink's render + * timers badly enough to fail timing-sensitive tests that never fetch. + * + * `codevhub doctor` runs the full set, where a second of latency is the point. + */ +export const PREFLIGHT_CHECKS: Check[] = [nodeVersionCheck, proxyEnvCheck]; + +export const ENVIRONMENT_CHECKS: Check[] = [ + nodeVersionCheck, + npmAvailableCheck, + npmPrefixCheck, + npmRegistryCheck, + proxyEnvCheck, + systemCaCheck, +]; + +// --------------------------------------------------------------------------- +// Group 2 — network (transport only, no auth) +// --------------------------------------------------------------------------- + +// Probe a route the CLI actually calls, not the API base path. `/codev-backend` +// on its own is not an endpoint — the gateway 301s it to an internal-only +// origin, so it measured something no CoDev command ever does. `POST /config` +// answers 401 without a token, which proves DNS, TCP, TLS and the real API +// route in one round trip. +const backendReachCheck: Check = { + key: "backend-reach", + label: "Reach the CoDev backend", + group: "network", + run: () => reach(`${BACKEND_URL}/config`, backendHost(), "POST"), +}; + +const npmReachCheck: Check = { + key: "npm-reach", + label: "Reach the npm registry", + group: "network", + run: async () => { + const ping = await execAsync("npm", ["ping"]); + if (ping.error) { + const diagnosis = diagnoseExec( + "npm ping", + ping.error.code ?? null, + ping.stderr, + ping.error.code === "ENOENT", + ); + return { + status: "fail", + detail: diagnosis.what, + fix: diagnosis.fix, + diagnosis, + }; + } + // A ping proves the registry answers; a real metadata read proves the + // proxy will let a package through. + const view = await execAsync("npm", ["view", SELF_PKG, "version"]); + if (view.error) { + const diagnosis = diagnoseExec( + `npm view ${SELF_PKG} version`, + view.error.code ?? null, + view.stderr, + view.error.code === "ENOENT", + ); + return { + status: "fail", + detail: diagnosis.what, + fix: diagnosis.fix, + diagnosis, + }; + } + return { + status: "pass", + detail: `Registry reachable; ${SELF_PKG}@${view.stdout.trim()} is installable.`, + }; + }, +}; + +export const NETWORK_CHECKS: Check[] = [backendReachCheck, npmReachCheck]; + +// --------------------------------------------------------------------------- +// Group 3 — account (needs SSO). The login step itself is the +// component, mounted by DoctorApp; these run once it resolves. +// --------------------------------------------------------------------------- + +const apiKeyCheck: Check = { + key: "api-key", + label: "Fetch a gateway API key", + group: "account", + run: async (ctx) => { + if (!ctx.accessToken) { + return { status: "skip", detail: "Skipped — sign-in did not complete." }; + } + const attempt = { url: `${BACKEND_URL}/auth/exchange`, method: "POST" }; + return guard(attempt, async () => { + const key = await fetchApiKey(ctx.accessToken as string); + if (!key) { + return { + status: "fail", + detail: "The backend returned an empty API key.", + fix: "Your account may not be provisioned for the gateway yet — contact the CoDev team.", + }; + } + ctx.apiKey = key; + return { status: "pass", detail: "API key issued." }; + }); + }, +}; + +const configCheck: Check = { + key: "codev-config", + label: "Fetch CoDev configuration", + group: "account", + run: async (ctx) => { + if (!ctx.accessToken) { + return { status: "skip", detail: "Skipped — sign-in did not complete." }; + } + const attempt = { url: `${BACKEND_URL}/config`, method: "POST" }; + return guard(attempt, async () => { + const config = await fetchCodevConfig(ctx.accessToken as string); + ctx.gatewayUrl = config.gatewayUrl; + ctx.supabaseUrl = config.supabaseUrl; + return { + status: "pass", + detail: `Gateway ${config.gatewayUrl}.`, + }; + }); + }, +}; + +const supabaseCheck: Check = { + key: "supabase-reach", + label: "Reach Supabase (log upload)", + group: "account", + run: async (ctx) => { + if (!ctx.accessToken) { + return { status: "skip", detail: "Skipped — sign-in did not complete." }; + } + const attempt = { + url: `${BACKEND_URL}/supabase/exchange`, + method: "POST", + }; + return guard(attempt, async () => { + await fetchSupabaseSession(ctx.accessToken as string); + // Supabase is a different host from the backend and the gateway, so it + // can be blocked independently. `codevhub upload` depends on it. + if (ctx.supabaseUrl) { + const r = await reach(ctx.supabaseUrl, "Supabase"); + if (r.status !== "pass") return r; + } + return { + status: "pass", + detail: "Supabase session issued and reachable.", + }; + }); + }, +}; + +export const ACCOUNT_CHECKS: Check[] = [ + apiKeyCheck, + configCheck, + supabaseCheck, +]; + +// --------------------------------------------------------------------------- +// Group 4 — LLM +// --------------------------------------------------------------------------- + +// Every call below passes ctx.gatewayUrl explicitly. Omitting it makes +// backend.ts fall back to AI_GATEWAY_URL(), which reads `gateway_url` out of +// ~/.codev-hub/auth.json and throws when it is absent — so on a machine that +// has never run `codevhub install`, exactly the audience this command exists +// for, all three checks failed with "Run `codevhub install`". Circular advice +// from a pre-flight tool, and wrong: the config check fetched the URL one step +// earlier. `doctor` never writes that cache, so the value has to be threaded. +const gatewayKeyCheck: Check = { + key: "gateway-key", + label: "Validate the gateway key", + group: "llm", + run: async (ctx) => { + if (!ctx.apiKey) { + return { status: "skip", detail: "Skipped — no API key to validate." }; + } + if (!ctx.gatewayUrl) { + return { + status: "skip", + detail: "Skipped — the gateway URL could not be fetched.", + }; + } + const attempt = { url: `${ctx.gatewayUrl}/key/info`, method: "GET" }; + return guard(attempt, async () => { + const ok = await validateApiKey(ctx.apiKey as string, ctx.gatewayUrl); + return ok + ? { status: "pass", detail: "Key accepted by the gateway." } + : { + status: "fail", + detail: "The gateway rejected the key (401/403).", + fix: "Run `codevhub login --force` to mint a fresh key, then re-run.", + }; + }); + }, +}; + +const modelsCheck: Check = { + key: "gateway-models", + label: "List available models", + group: "llm", + run: async (ctx) => { + if (!ctx.apiKey) { + return { status: "skip", detail: "Skipped — no API key." }; + } + if (!ctx.gatewayUrl) { + return { + status: "skip", + detail: "Skipped — the gateway URL could not be fetched.", + }; + } + const attempt = { url: `${ctx.gatewayUrl}/v1/models`, method: "GET" }; + return guard(attempt, async () => { + const models = await fetchModels(ctx.apiKey as string, ctx.gatewayUrl); + ctx.models = models; + const preview = models.slice(0, 3).join(", "); + return { + status: "pass", + detail: `${models.length} models available (${preview}${ + models.length > 3 ? ", …" : "" + }).`, + }; + }); + }, +}; + +// The only check that proves inference is actually permitted: /key/info and +// /v1/models both succeed for a key that is then 403'd on every completion. +const completionCheck: Check = { + key: "llm-completion", + label: "Send a test request to the LLM", + group: "llm", + run: async (ctx) => { + if (!ctx.apiKey) { + return { status: "skip", detail: "Skipped — no API key." }; + } + if (!ctx.gatewayUrl) { + return { + status: "skip", + detail: "Skipped — the gateway URL could not be fetched.", + }; + } + const model = ctx.models?.[0] ?? FALLBACK_MODEL; + // smokeTestModel never throws — it returns a reason string. + const reason = await smokeTestModel(ctx.apiKey, model, ctx.gatewayUrl); + if (!reason) { + return { status: "pass", detail: `${model} answered a test prompt.` }; + } + // It stringifies its own errors, so a transport failure arrives as + // "Couldn't reach the gateway to test X: fetch failed" — the exact bare + // message this command exists to eliminate. Diagnose that case properly + // rather than echoing it; the underlying error object is gone by now, but + // the model-list check immediately above hit the same host and carries the + // full chain. + if (/^Couldn't reach the gateway/.test(reason)) { + const host = hostOf(ctx.gatewayUrl) ?? "the gateway"; + return { + status: "fail", + detail: `Could not reach the gateway at ${host} to run a test completion.`, + fix: "Fix gateway connectivity first — see the model list check above for the underlying network error.", + diagnosis: { + what: `Could not reach the gateway at ${host} to run a test completion.`, + cause: + "The request never got a response, so this is a connectivity problem rather than a model-permissions one.", + fix: "The model list check above hit the same host and reports the underlying network error — fix that first, then re-run.", + context: connectionContext({ + url: `${ctx.gatewayUrl}/v1/chat/completions`, + method: "POST", + }), + raw: [redactSecrets(reason)], + }, + }; + } + return { + status: "fail", + detail: reason, + fix: "The key exists but cannot run this model. Check model entitlement, budget, and any region/IP restrictions with the CoDev team.", + diagnosis: { + what: `The gateway refused a real completion for ${model}.`, + cause: + "Listing models only proves the key exists. This is the error your agents would hit on their first message — model entitlement, an exhausted budget, or an edge/WAF block.", + fix: "Contact the CoDev team with the response below; it distinguishes a permissions problem from a budget one.", + context: connectionContext({ + url: `${ctx.gatewayUrl ?? ""}/v1/chat/completions`, + method: "POST", + }), + raw: [redactSecrets(reason)], + }, + }; + }, +}; + +export const LLM_CHECKS: Check[] = [ + gatewayKeyCheck, + modelsCheck, + completionCheck, +]; + +// --------------------------------------------------------------------------- +// Group 5 — state (informational; never fails, never blocks) +// --------------------------------------------------------------------------- + +const NPM_TOOLS: NpmTool[] = ["codev-code", "claude-code", "codex", "opencode"]; + +const installedAgentsCheck: Check = { + key: "installed-agents", + label: "Agents installed", + group: "state", + run: async () => { + // Resolve the global root ONCE. `detectInstalledViaNpm` looks it up per + // call, so a loop over four agents spawned `npm root -g` four times, in + // series, for an answer that cannot differ between them — about a second + // of pure waste in a command people run while already frustrated. + const root = await npmGlobalRoot(); + if (!root) { + return { + status: "skip", + detail: "Could not resolve npm's global directory.", + }; + } + const found = NPM_TOOLS.filter((tool) => + existsSync(join(root, ...PKG[tool].split("/"))), + ).map((tool) => `${PKG[tool]} (${CLI[tool]})`); + return found.length > 0 + ? { status: "pass", detail: found.join(", ") } + : { + status: "skip", + detail: "None — `codevhub install` has not run yet.", + }; + }, +}; + +const configuredAgentsCheck: Check = { + key: "configured-agents", + label: "CoDev-managed configs", + group: "state", + run: async () => { + const tools = detectConfiguredTools(); + if (tools.length === 0) { + return { status: "skip", detail: "None." }; + } + const withBackups = tools.filter((tool: Tool) => + getBackupStatus(tool).some((s) => s.hasBackup), + ); + return { + status: "pass", + detail: `${tools.join(", ")}${ + withBackups.length > 0 + ? ` · backups exist for ${withBackups.join(", ")}` + : " · no backups" + }`, + }; + }, +}; + +const shimsCheck: Check = { + key: "shims", + label: "PATH shims", + group: "state", + run: async () => { + const shims = detectInstalledShims(); + return shims.length > 0 + ? { status: "pass", detail: shims.join(", ") } + : { status: "skip", detail: "None installed." }; + }, +}; + +const editorCliCheck: Check = { + key: "editor-clis", + label: "Editor CLIs", + group: "state", + run: async () => { + const found = ["code", "idea", "pycharm", "webstorm"].filter((cmd) => + agentOnPath(cmd), + ); + return found.length > 0 + ? { status: "pass", detail: `${found.join(", ")} on PATH.` } + : { + status: "skip", + detail: + "None on PATH — editor extension installs will be skipped with a warning.", + }; + }, +}; + +const savedKeyCheck: Check = { + key: "saved-key", + label: "Saved credentials", + group: "state", + run: async () => { + const saved = loadApiKey(); + if (!saved?.apiKey) { + return { status: "skip", detail: "No API key saved yet." }; + } + return { + status: "pass", + detail: `Key saved${saved.model ? ` · model ${saved.model}` : ""}${ + saved.providerName ? ` · provider ${saved.providerName}` : "" + }.`, + }; + }, +}; + +export const STATE_CHECKS: Check[] = [ + installedAgentsCheck, + configuredAgentsCheck, + shimsCheck, + editorCliCheck, + savedKeyCheck, +]; + +// --------------------------------------------------------------------------- +// Runner +// --------------------------------------------------------------------------- + +/** + * Run a group of checks in order, mirroring each result into the diagnostic + * log. A check must never throw — but if one somehow does, it becomes a `fail` + * rather than taking the whole command down. + */ +export async function runChecks( + checks: Check[], + ctx: DoctorContext, + onResult?: (outcome: CheckOutcome) => void, +): Promise { + const outcomes: CheckOutcome[] = []; + for (const check of checks) { + // Checks run strictly in sequence, so anything the recorders gain while + // one is running belongs to it. That makes attribution exact without + // threading a context through every call site — and it still works for a + // check that fans out internally (npm-registry runs five `npm config get` + // concurrently; all five land on its row). + const commandsBefore = commandLog.entries.length; + const requestsBefore = requestLog.entries.length; + + let result: CheckResult; + try { + result = await check.run(ctx); + } catch (err) { + const diagnosis = diagnoseError(err); + result = { + status: "fail", + detail: diagnosis.what, + fix: diagnosis.fix, + diagnosis, + }; + } + const outcome: CheckOutcome = { + ...result, + key: check.key, + label: check.label, + group: check.group, + activity: collectActivity(commandsBefore, requestsBefore), + }; + logCheck(outcome); + outcomes.push(outcome); + onResult?.(outcome); + } + return outcomes; +} + +function logCheck(outcome: CheckOutcome): void { + // Everything goes through `extra`, never `unsafeUnredacted` — raw error + // bodies can carry bearer tokens and must stay scrubbed on disk. + const fields = { + action: "doctor.check", + outcome: + outcome.status === "pass" + ? ("success" as const) + : outcome.status === "fail" + ? ("failure" as const) + : undefined, + extra: { + key: outcome.key, + group: outcome.group, + status: outcome.status, + detail: outcome.detail, + fix: outcome.fix, + diagnosis_raw: outcome.diagnosis?.raw, + diagnosis_context: outcome.diagnosis?.context, + }, + }; + const msg = `doctor ${outcome.key}: ${outcome.status}`; + if (outcome.status === "fail") logError(msg, fields); + else if (outcome.status === "warn") logWarn(msg, fields); + else if (outcome.status === "skip") logDebug(msg, fields); + else logInfo(msg, fields); +} + +export function hasFailure(outcomes: CheckOutcome[]): boolean { + return outcomes.some((o) => o.status === "fail"); +} + +// --------------------------------------------------------------------------- +// Remediation summary +// --------------------------------------------------------------------------- + +/** + * Per-platform instructions for making proxy settings permanent, so the user + * can set them up BEFORE running `codevhub install`. Mirrors the install guide. + */ +export function persistProxyInstructions(proxy?: { + http?: string; + https?: string; +}): string[] { + const value = proxy?.https ?? proxy?.http ?? ":"; + const url = /^https?:\/\//.test(value) ? value : `http://${value}`; + if (process.platform === "win32") { + return [ + "Windows (PowerShell) — set these for your user, then open a new terminal:", + ` [Environment]::SetEnvironmentVariable("HTTP_PROXY", "${url}", "User")`, + ` [Environment]::SetEnvironmentVariable("HTTPS_PROXY", "${url}", "User")`, + ' [Environment]::SetEnvironmentVariable("NODE_USE_ENV_PROXY", "1", "User")', + ' [Environment]::SetEnvironmentVariable("NODE_USE_SYSTEM_CA", "1", "User")', + "", + "npm keeps its own proxy configuration, separate from the above:", + ` npm config set proxy ${url}`, + ` npm config set https-proxy ${url}`, + ` npm config set registry ${INTERNAL_NPM_REGISTRY}`, + ]; + } + return [ + "Linux / macOS — add these to ~/.bashrc or ~/.zshrc, then `source` it:", + ` export HTTP_PROXY=${url}`, + ` export HTTPS_PROXY=${url}`, + " export NODE_USE_ENV_PROXY=1", + " export NODE_USE_SYSTEM_CA=1", + "", + "npm keeps its own proxy configuration, separate from the above:", + ` npm config set proxy ${url}`, + ` npm config set https-proxy ${url}`, + ` npm config set registry ${INTERNAL_NPM_REGISTRY}`, + ]; +} + +/** + * The "Next steps" block: every unresolved issue in order, then the persistent + * setup instructions. Printed whenever anything failed or warned, because the + * point of the command is to leave the user able to fix things before + * `codevhub install`. + */ +export function buildNextSteps( + outcomes: CheckOutcome[], + proxy?: { http?: string; https?: string }, +): string[] { + const actionable = outcomes.filter( + (o) => (o.status === "fail" || o.status === "warn") && o.fix, + ); + if (actionable.length === 0) return []; + + const lines: string[] = []; + for (const [i, o] of actionable.entries()) { + lines.push( + `${i + 1}. ${o.label} — ${o.status === "fail" ? "FAILED" : "warning"}`, + ); + for (const fixLine of (o.fix ?? "").split("\n")) { + lines.push(` ${fixLine}`); + } + } + + // Only append the proxy setup block when something actually points at the + // network. Keying off the group alone treated a plain expired-token 401 as + // a proxy problem and buried the real instruction ("log in again") under a + // wall of export lines. A transport failure anywhere still qualifies — + // diagnoseError puts the proxy variables in its `fix`, so that is the + // signal rather than the group. + // Match the environment variable names, not the word "proxy" — the npm + // mirror's own URL ends in `/npm-proxy`, so a loose /proxy/i match pulled + // the whole block in on a run whose only issue was the registry setting. + const NAMES_PROXY_ENV = /\b(?:HTTPS?_PROXY|NODE_USE_ENV_PROXY)\b/; + const proxyRelated = outcomes.some( + (o) => + o.status !== "pass" && + (o.key === "proxy-env" || + o.group === "network" || + NAMES_PROXY_ENV.test(o.fix ?? "")), + ); + if (proxyRelated) { + lines.push(""); + lines.push(...persistProxyInstructions(proxy)); + } + + lines.push(""); + lines.push( + "Re-run `codevhub doctor` to confirm, then run `codevhub install`.", + ); + return lines; +} + +// --------------------------------------------------------------------------- +// Report file +// --------------------------------------------------------------------------- + +export interface DoctorReport { + generatedAt: string; + codevVersion: string; + node: { version: string; platform: string; arch: string }; + proxy: ProxyEnv & { + autoEnabledByCodev: boolean; + /** + * The proxy/TLS environment as reported to the user: the core variables + * always, with `null` for unset, plus anything else that is set. + */ + environment: ProxyEnvVar[]; + }; + summary: { + ok: boolean; + passed: number; + warned: number; + failed: number; + skipped: number; + }; + checks: CheckOutcome[]; + /** Every child process this run spawned, in order. */ + commands: CommandRecord[]; + /** Every HTTP request this run made, in order. */ + requests: RequestRecord[]; + nextSteps: string[]; +} + +/** + * Start recording the commands this run spawns. + * + * Called once at the top of the run: `doctor` executes things on someone + * else's machine, often a locked-down one, and "what did it just run?" should + * be answerable from the output rather than from the source. + */ +export function startCommandRecording(): void { + recordCommands(); + recordRequests(); +} + +export function recordedCommands(): CommandRecord[] { + return [...commandLog.entries]; +} + +export function recordedRequests(): RequestRecord[] { + return [...requestLog.entries]; +} + +/** + * Everything the recorders gained since the given offsets, as display lines. + * + * Returns undefined rather than an empty array when a check ran nothing, so + * the renderer has one falsy thing to test and the report file stays free of + * empty `activity: []` noise on the many checks that are pure logic. + */ +export function collectActivity( + commandsBefore: number, + requestsBefore: number, +): CheckActivity[] | undefined { + const activity: CheckActivity[] = [ + ...commandLog.entries.slice(commandsBefore).map((c) => ({ + kind: "command" as const, + detail: c.command, + durationMs: c.durationMs, + })), + ...requestLog.entries.slice(requestsBefore).map((r) => { + // Naming the proxy the request actually went through is the difference + // between "this endpoint is unreachable" and "your proxy could not + // reach this endpoint" — and it is the only way to see, per request, + // that a NO_PROXY entry quietly sent one of them direct. + const via = proxyForUrl(r.url); + return { + kind: "request" as const, + // The status belongs on the line: it is what distinguishes "the + // endpoint answered no" from "nothing answered at all". + detail: `${r.method} ${r.url} → ${r.status ?? "no response"}${ + via ? ` via ${via}` : "" + }`, + durationMs: r.durationMs, + }; + }), + ]; + return activity.length > 0 ? activity : undefined; +} + +/** Snapshot of the recorders, for callers attributing work done outside runChecks. */ +export function activityMark(): { commands: number; requests: number } { + return { + commands: commandLog.entries.length, + requests: requestLog.entries.length, + }; +} + +function maskProxyEnv(env: ProxyEnv): ProxyEnv { + return { + ...env, + httpProxy: env.httpProxy ? maskProxyCredentials(env.httpProxy) : null, + httpsProxy: env.httpsProxy ? maskProxyCredentials(env.httpsProxy) : null, + }; +} + +export function buildDoctorReport( + outcomes: CheckOutcome[], + generatedAt: string, + proxyUsed?: { http?: string; https?: string }, +): DoctorReport { + const count = (s: CheckStatus) => + outcomes.filter((o) => o.status === s).length; + return { + generatedAt, + codevVersion: VERSION, + node: { + version: process.version, + platform: process.platform, + arch: process.arch, + }, + // Credentials masked: the report is written to be attached to tickets. + proxy: { + ...maskProxyEnv(readProxyEnv()), + autoEnabledByCodev: proxyAutoEnabled(), + environment: proxyEnvSummary(), + }, + summary: { + ok: !hasFailure(outcomes), + passed: count("pass"), + warned: count("warn"), + failed: count("fail"), + skipped: count("skip"), + }, + checks: outcomes, + commands: recordedCommands(), + requests: recordedRequests(), + nextSteps: buildNextSteps(outcomes, proxyUsed), + }; +} + +/** + * Write the machine-readable report to ~/.codev-hub/doctor-report.json, + * replacing any previous one. + * + * Best-effort by construction, matching the logging discipline in lib/log.ts: + * a diagnostic that breaks the command it is diagnosing is worse than no + * diagnostic. Returns the path on success, null if anything went wrong. + * + * The serialized JSON goes through the same secret scrubbing as the terminal + * output and the NDJSON log — this file exists to be attached to tickets, so + * it is the *last* place a token should survive. Scrubbing after + * stringification is safe: every replacement is plain text and none of the + * patterns can match across a JSON quote or escape. + */ +export function writeDoctorReport(report: DoctorReport): string | null { + try { + const path = doctorReportPath(); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync( + path, + `${redactSecrets(JSON.stringify(report, null, 2))}\n`, + "utf-8", + ); + logInfo("wrote doctor report", { + action: "doctor.report", + outcome: "success", + extra: { checks: report.checks.length, ok: report.summary.ok }, + }); + return path; + } catch (err) { + logWarn("could not write the doctor report", { + action: "doctor.report", + outcome: "failure", + err, + }); + return null; + } +} + +// --------------------------------------------------------------------------- +// Re-exec handoff +// +// DoctorApp cannot spawn the retry itself: spawnSync with inherited stdio while +// Ink still owns the TTY corrupts the terminal. Instead the app records its +// intent here and exits; index.tsx reads it after waitUntilExit() resolves. +// Same shape of indirection as reexec.ts. +// --------------------------------------------------------------------------- + +export interface DoctorOutcome { + exitCode: number; + /** Set when the user supplied a proxy and wants the checks re-run with it. */ + retryWithProxy: { http: string; https: string } | null; +} + +export const doctorOutcome: DoctorOutcome = { + exitCode: 0, + retryWithProxy: null, +}; + +export function resetDoctorOutcome(): void { + doctorOutcome.exitCode = 0; + doctorOutcome.retryWithProxy = null; +} + +/** + * Normalize a user-typed `host:port` into a proxy URL. + * + * Accepts an IPv4 address, a hostname, or a bracketed IPv6 literal, with or + * without a scheme and with optional `user:pass@` credentials. Returns null for + * anything unusable, which the prompt turns into an inline error. + */ +export function normalizeProxyInput(input: string): string | null { + const trimmed = input.trim(); + if (!trimmed) return null; + // A bare number is the likely mistake when the prompt asks for "host:port" — + // and it is the dangerous one, because WHATWG URL parses integers as 32-bit + // IPv4 addresses: `8080` becomes `http://0.0.31.144`. That is accepted + // silently and then fails much later as an unexplained connection timeout to + // an address the user never typed. Reject it here, where we can still say why. + if (/^\d+$/.test(trimmed.replace(/^https?:\/\//i, ""))) return null; + const withScheme = /^https?:\/\//i.test(trimmed) + ? trimmed + : `http://${trimmed}`; + try { + const url = new URL(withScheme); + if (!url.hostname) return null; + return url.toString().replace(/\/$/, ""); + } catch { + return null; + } +} + +/** Set when doctor has already retried, so the prompt is offered only once. */ +export const DOCTOR_PROXY_ENV = "CODEV_DOCTOR_PROXY"; + +export function alreadyRetriedWithProxy(): boolean { + const v = process.env[DOCTOR_PROXY_ENV]; + return v !== undefined && v !== "" && v !== "0"; +} + +/** + * Re-run `codevhub doctor` with the proxy the user just typed, so they see the + * fix actually work before being told to make it permanent. Nothing is written + * to disk — the settings live only in the child's environment. + * + * Lives here rather than in index.tsx so the exact command and environment are + * assertable; the dispatcher only decides *when* to call it. It must still be + * invoked from index.tsx after Ink has unmounted: `spawnSync` with inherited + * stdio while Ink owns the TTY corrupts the terminal. + */ +export function rerunDoctorWithProxy( + proxy: { http: string; https: string }, + args: string[], +): number { + const selfPath = process.argv[1]; + if (!selfPath) { + process.stderr.write("Could not determine the CLI path to re-run.\n"); + return 1; + } + const traceId = currentTraceId(); + const env: NodeJS.ProcessEnv = { ...process.env }; + // Assigned through overrideEnvVar rather than spread: Windows environment + // variables are case-insensitive, so a user's existing `http_proxy` would + // survive alongside our `HTTP_PROXY` and the child could pick up the stale + // address — testing a proxy the user did not type. + overrideEnvVar(env, "HTTP_PROXY", proxy.http); + overrideEnvVar(env, "HTTPS_PROXY", proxy.https); + overrideEnvVar(env, "NODE_USE_ENV_PROXY", "1"); + // Intercepting proxies re-sign TLS with a corporate root, so reading the + // OS trust store is part of "try it with the proxy", not a separate step. + overrideEnvVar(env, "NODE_USE_SYSTEM_CA", "1"); + // Offer the prompt only once — if the retry still fails, the summary must + // be allowed to print rather than asking again. + overrideEnvVar(env, DOCTOR_PROXY_ENV, "1"); + if (traceId) overrideEnvVar(env, "CODEV_TRACE_PARENT", traceId); + + // A NO_PROXY entry covering our own backend would send that traffic direct + // and defeat the proxy we're testing — the documented cause of "Login + // failed". Drop it for the child only; the user's environment is untouched. + // Every spelling is stripped, since any of them would defeat the retry. + const host = backendHost(); + for (const key of envVarKeys(env, "NO_PROXY")) { + const value = env[key]; + if (value) env[key] = stripNoProxyFor(value, host); + } + + // execArgv is forwarded so the child keeps whatever flags this process was + // started with — without it, a `pnpm dev` run (node + tsx loader flags) + // would spawn a child that cannot load TypeScript and dies immediately. + const result = spawner.spawnSync( + process.execPath, + [...process.execArgv, selfPath, "doctor", ...args], + { stdio: "inherit", env }, + ); + return result.status ?? 1; +} diff --git a/src/lib/help.ts b/src/lib/help.ts index 4f83448..7eb6b40 100644 --- a/src/lib/help.ts +++ b/src/lib/help.ts @@ -15,6 +15,9 @@ passed through to it as well — \`codevhub run "fix the tests"\`, \`codevhub serve\`, \`codevhub models\`, and so on. Hub commands: + doctor Check your environment and network before installing + (Node version, npm, proxy/TLS, sign-in, LLM access; + --force to test a real sign-in instead of the cached one) install Install and configure AI coding agents config Configure existing AI coding agents update Update installed AI coding agents diff --git a/src/lib/log.ts b/src/lib/log.ts index 0f10b8c..fe7b0d2 100644 --- a/src/lib/log.ts +++ b/src/lib/log.ts @@ -294,6 +294,53 @@ async function errorBody(res: Response): Promise { // bearer tokens; error response bodies are captured from a clone, truncated, // and pass through the same line scrubbing as every other document. With // logging disabled this is a plain fetch passthrough. +export interface RequestRecord { + method: string; + /** Scheme, host and path. The query is stripped — it can carry tokens. */ + url: string; + /** null when no response ever arrived (DNS/TCP/TLS failure). */ + status: number | null; + durationMs: number; + ok: boolean; +} + +/** + * Opt-in record of every HTTP request made through `loggedFetch`. + * + * The sibling of `npm.ts#commandLog`, for the same reason: `codevhub doctor` + * shows the user everything it did on their machine, and on a corporate + * network the endpoints it contacted are the *more* useful half — they are + * what IT needs in order to allow-list them. + * + * Off by default; only `doctor` turns it on. + */ +export const requestLog: { enabled: boolean; entries: RequestRecord[] } = { + enabled: false, + entries: [], +}; + +export function recordRequests(): void { + requestLog.enabled = true; + requestLog.entries = []; +} + +// Query strings are dropped rather than redacted: OAuth codes, signed-URL +// signatures and access tokens all live there, and none of it helps a user +// understand which endpoint was contacted. +function requestUrl(raw: string): string { + try { + const u = new URL(raw); + return `${u.origin}${u.pathname}`; + } catch { + const q = raw.indexOf("?"); + return q === -1 ? raw : raw.slice(0, q); + } +} + +function recordRequest(record: RequestRecord): void { + if (requestLog.enabled) requestLog.entries.push(record); +} + export async function loggedFetch( endpoint: string, input: string | URL, @@ -312,6 +359,13 @@ export async function loggedFetch( try { const res = await fetchTrustingSystemCa(input, init, endpoint); const durationMs = Date.now() - startedAt; + recordRequest({ + method, + url: requestUrl(url), + status: res.status, + durationMs, + ok: res.ok, + }); if (res.ok) { logDebug(`http ${method} ${endpoint} → ${res.status}`, { action: "http.request", @@ -339,13 +393,23 @@ export async function loggedFetch( } return res; } catch (err) { + const durationMs = Date.now() - startedAt; + // No response at all — DNS, TCP or TLS. Recorded with a null status so + // the user can see the request was attempted and got nowhere. + recordRequest({ + method, + url: requestUrl(url), + status: null, + durationMs, + ok: false, + }); logError(`http ${method} ${endpoint} failed`, { action: "http.request", eventType: "end", outcome: "failure", url, method, - durationMs: Date.now() - startedAt, + durationMs, err, extra: { endpoint }, }); @@ -491,6 +555,14 @@ function scrubLine(line: string): string { return out; } +// The same value-shape scrubbing, exposed for text that is about to be printed +// to the TERMINAL rather than written to disk — `codevhub doctor` echoes raw +// error bodies and response headers, and users paste that output into chat. +// Sharing SCRUB_PATTERNS keeps one definition of "this must never be shown". +export function redactSecrets(text: string): string { + return scrubLine(text); +} + export interface PruneLimits { maxAgeDays?: number; maxTotalBytes?: number; diff --git a/src/lib/npm.ts b/src/lib/npm.ts index 2ff91a5..d87814a 100644 --- a/src/lib/npm.ts +++ b/src/lib/npm.ts @@ -61,6 +61,34 @@ export interface ExecResult { error: NodeJS.ErrnoException | null; } +export interface CommandRecord { + command: string; + durationMs: number; + ok: boolean; +} + +/** + * Opt-in record of every child process this module spawns. + * + * `codevhub doctor` turns it on so it can show the user exactly what it ran on + * their machine — a fair question for a diagnostic tool, and one that should + * not require reading the source. Off by default: no other command needs it, + * and an always-on buffer would grow unbounded in the upload daemon. + * + * It lives inside `execAsync` rather than wrapping it from doctor.ts because + * helpers in this module (`npmGlobalRoot`, `verifyInstall`, …) call `execAsync` + * through their module-local binding, which no external wrapper can intercept. + */ +export const commandLog: { enabled: boolean; entries: CommandRecord[] } = { + enabled: false, + entries: [], +}; + +export function recordCommands(): void { + commandLog.enabled = true; + commandLog.entries = []; +} + export function execAsync(file: string, args: string[]): Promise { // Every child process codev shells out to funnels through here (npm, the // agent --version probes, `code --install-extension`, JetBrains CLIs, @@ -80,6 +108,13 @@ export function execAsync(file: string, args: string[]): Promise { stderr: string, ) => { const durationMs = Date.now() - startedAt; + if (commandLog.enabled) { + commandLog.entries.push({ + command: `${file} ${args.join(" ")}`.trim(), + durationMs, + ok: !error, + }); + } if (error) { logWarn(`exec failed: ${file} ${args.join(" ")}`, { action: "process.exit", diff --git a/src/lib/paths.ts b/src/lib/paths.ts index c494b5f..bbde373 100644 --- a/src/lib/paths.ts +++ b/src/lib/paths.ts @@ -17,6 +17,14 @@ export function cliLogsDir(): string { return join(homedir(), ".codev-hub", "logs"); } +// The machine-readable result of the last `codevhub doctor` run. Deliberately a +// single file rather than a dated series: it is a snapshot of "how is this +// machine right now", and a stale one is worse than none when someone attaches +// it to a support ticket. Every run replaces it. +export function doctorReportPath(): string { + return join(homedir(), ".codev-hub", "doctor-report.json"); +} + // Maps a working directory to a per-project subfolder name. Strips the user's // home prefix so the folder is shorter, replaces non-alphanumeric chars with // dashes, then collapses runs of dashes and trims them. Falls back to "home" diff --git a/src/lib/proxy.ts b/src/lib/proxy.ts new file mode 100644 index 0000000..6b7f9bf --- /dev/null +++ b/src/lib/proxy.ts @@ -0,0 +1,411 @@ +import http from "node:http"; +import { BACKEND_URL } from "@/lib/const.js"; +import { currentTraceId, logInfo, logWarn } from "@/lib/log.js"; +import { spawner } from "@/lib/reexec.js"; + +// Node does NOT honor HTTP_PROXY/HTTPS_PROXY by default. Support is gated behind +// NODE_USE_ENV_PROXY=1 (or --use-env-proxy) and it is read at *bootstrap*, so +// assigning process.env mid-run is too late for the already-initialized global +// dispatcher. Users who follow our install docs and export only +// HTTP_PROXY/HTTPS_PROXY therefore get no proxy at all — silently — and every +// fetch fails with a bare `fetch failed`. +// +// This module closes that gap once, at the CLI entry point, so every command +// (install, login, upload, model, the launch-time key refresh) benefits rather +// than just the one being debugged. +// +// Both the env-var support and http.setGlobalProxyFromEnv() landed together in +// Node 22.21.0 / 24.x — which is exactly why MIN_NODE_VERSION is 22.21.0. + +// Indirection so tests can simulate a Node that predates the API (mirrors +// `tlsApi` in tls.ts / `spawner` in reexec.ts). Accessed off the default import +// and never destructured: on older Node this export doesn't exist, and a named +// ESM import of a missing builtin export is a link-time error, not a runtime +// `undefined`. +export const httpApi = { + setGlobalProxyFromEnv: (): void => { + ( + http as unknown as { setGlobalProxyFromEnv?: () => void } + ).setGlobalProxyFromEnv?.(); + }, + supported: (): boolean => + typeof (http as unknown as Record) + .setGlobalProxyFromEnv === "function", +}; + +// Set on the re-exec child so a Node that somehow still ignores the env var +// can't send us round the loop forever (same guard shape as reexec.ts's +// `process.execArgv.includes("--experimental-sqlite")` check). +export const PROXY_APPLIED_ENV = "CODEV_PROXY_APPLIED"; + +export interface ProxyEnv { + /** Raw HTTP_PROXY / http_proxy value, whichever is set. */ + httpProxy: string | null; + httpsProxy: string | null; + noProxy: string | null; + /** NODE_USE_ENV_PROXY is set to something truthy. */ + useEnvProxy: boolean; + /** NODE_USE_SYSTEM_CA is set to something truthy. */ + useSystemCa: boolean; + /** Raw NODE_TLS_REJECT_UNAUTHORIZED, so "0" can be reported verbatim. */ + tlsRejectUnauthorized: string | null; +} + +// Node accepts either case for the proxy vars (and lowercase is the long- +// standing Unix convention), so read both rather than trusting the docs' casing. +// Each spelling is normalized independently: an empty HTTP_PROXY means "unset" +// and must fall through to http_proxy. A plain `env[upper] ?? env[lower]` only +// falls through on `undefined`, so an exported-but-empty variable would mask a +// perfectly good lowercase one. +function readEither( + env: NodeJS.ProcessEnv, + upper: string, + lower: string, +): string | null { + return nonEmpty(env[upper]) ?? nonEmpty(env[lower]); +} + +function nonEmpty(value: string | undefined): string | null { + const trimmed = value?.trim(); + return trimmed ? trimmed : null; +} + +// "1"/"true"/"yes" style. Node itself treats any non-empty value as on, so an +// explicit "0" is deliberately still "set" here — we report what the user did +// rather than second-guessing it. +function isEnabled(value: string | undefined): boolean { + if (value === undefined) return false; + const v = value.trim().toLowerCase(); + return v !== "" && v !== "0" && v !== "false" && v !== "no"; +} + +export function readProxyEnv(env: NodeJS.ProcessEnv = process.env): ProxyEnv { + return { + httpProxy: readEither(env, "HTTP_PROXY", "http_proxy"), + httpsProxy: readEither(env, "HTTPS_PROXY", "https_proxy"), + noProxy: readEither(env, "NO_PROXY", "no_proxy"), + useEnvProxy: isEnabled(env.NODE_USE_ENV_PROXY), + useSystemCa: isEnabled(env.NODE_USE_SYSTEM_CA), + tlsRejectUnauthorized: env.NODE_TLS_REJECT_UNAUTHORIZED ?? null, + }; +} + +export function hasProxyConfigured(proxy: ProxyEnv): boolean { + return proxy.httpProxy !== null || proxy.httpsProxy !== null; +} + +/** + * `http://user:hunter2@10.0.0.1:8080` → `http://user:***@10.0.0.1:8080`. + * + * Proxy URLs routinely carry credentials, and every place we display one — the + * check row, the per-request activity lines, the report file — is somewhere a + * user pastes into a ticket or a chat. Masking happens at the display boundary + * only: `readProxyEnv` keeps the real value, because the child process of the + * proxy retry needs to actually authenticate. + */ +export function maskProxyCredentials(url: string): string { + return url.replace(/(:\/\/[^:/@\s]+):[^@\s]*@/, "$1:***@"); +} + +// Always reported, set or not. "unset" is an answer in its own right — most of +// the failures this command exists for are a *missing* variable, and a reader +// scanning for HTTP_PROXY should find it stated rather than have to infer its +// absence from a list that only shows what happens to be present. +const ALWAYS_REPORTED_ENV_VARS = [ + "HTTP_PROXY", + "HTTPS_PROXY", + "NO_PROXY", + "NODE_USE_ENV_PROXY", + "NODE_USE_SYSTEM_CA", + "NODE_EXTRA_CA_CERTS", + "NODE_TLS_REJECT_UNAUTHORIZED", +] as const; + +// Reported only when set. Listing these as "unset" too would put eleven more +// entries on the row for the overwhelming majority of users, and the lowercase +// spellings would read as duplicates of the uppercase ones above. +const WHEN_SET_ENV_VARS = [ + "http_proxy", + "https_proxy", + "ALL_PROXY", + "all_proxy", + "no_proxy", + "NODE_OPTIONS", + "npm_config_proxy", + "npm_config_https_proxy", + "npm_config_registry", + "npm_config_strict_ssl", + "npm_config_cafile", +] as const; + +export interface ProxyEnvVar { + name: string; + /** null when the variable is unset (or set to whitespace). */ + value: string | null; +} + +/** + * The proxy/TLS environment as shown to the user. + * + * `readProxyEnv` normalizes to a fixed set of fields, which is what the logic + * needs — but it hides everything else, including `NODE_EXTRA_CA_CERTS` (the + * remedy our own TLS guidance hands out) and npm's `npm_config_*` overrides. + * This reports the core variables always (with null for unset) plus anything + * else the user has set, in their own spelling. + */ +export function proxyEnvSummary( + env: NodeJS.ProcessEnv = process.env, +): ProxyEnvVar[] { + const mask = (v: string | null) => + v === null ? null : maskProxyCredentials(v); + const out: ProxyEnvVar[] = ALWAYS_REPORTED_ENV_VARS.map((name) => ({ + name, + value: mask(nonEmpty(env[name])), + })); + for (const name of WHEN_SET_ENV_VARS) { + const value = nonEmpty(env[name]); + if (value !== null) out.push({ name, value: maskProxyCredentials(value) }); + } + return out; +} + +/** + * The proxy that applies to a given URL, or null when none does — accounting + * for the scheme, for NO_PROXY, and for whether Node was actually told to use + * a proxy at all. + */ +export function proxyForUrl( + url: string, + proxy: ProxyEnv = readProxyEnv(), +): string | null { + if (!proxy.useEnvProxy) return null; + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return null; + } + if (matchingNoProxyEntry(proxy, parsed.hostname)) return null; + const chosen = + parsed.protocol === "https:" + ? (proxy.httpsProxy ?? proxy.httpProxy) + : (proxy.httpProxy ?? proxy.httpsProxy); + return chosen ? maskProxyCredentials(chosen) : null; +} + +/** The host CoDev's backend, SSO wrapper and skill hub all live on. */ +export function backendHost(): string { + try { + return new URL(BACKEND_URL).hostname; + } catch { + return ""; + } +} + +// Standard NO_PROXY matching: comma-separated entries, `*` means everything, a +// leading dot or `*.` is a suffix match, an optional `:port` suffix is ignored +// for our purposes (we only ever talk https). +export function noProxyEntryMatches(entry: string, host: string): boolean { + const clean = entry.trim().toLowerCase().replace(/:\d+$/, ""); + if (!clean) return false; + if (clean === "*") return true; + const target = host.toLowerCase(); + if (clean.startsWith("*.")) return target.endsWith(clean.slice(1)); + if (clean.startsWith(".")) return target.endsWith(clean); + return target === clean || target.endsWith(`.${clean}`); +} + +/** + * The NO_PROXY entry that would exempt `host` from the proxy, or null. + * + * This is the documented cause of the "Login failed" symptom in the install + * guide: internal images ship with `*.viettel.vn` in NO_PROXY, which sends our + * backend traffic direct — straight into the firewall — while every other + * request correctly goes through the proxy. + */ +export function matchingNoProxyEntry( + proxy: ProxyEnv, + host: string, +): string | null { + if (!proxy.noProxy || !host) return null; + for (const entry of proxy.noProxy.split(",")) { + if (noProxyEntryMatches(entry, host)) return entry.trim(); + } + return null; +} + +/** + * Set a variable in a child environment, removing any other spelling of it. + * + * Windows environment variables are **case-insensitive**, so `http_proxy` and + * `HTTP_PROXY` are one variable — but a plain `{...process.env, HTTP_PROXY: x}` + * produces an object holding both the user's original key (with the old value) + * and ours. Handing that to `spawnSync` leaves which one wins to chance, and if + * the stale one wins the proxy retry silently tests the wrong address. + * + * Removing the variants is right on POSIX too: there both spellings are real + * and both are consulted, so leaving the old one behind is the same ambiguity + * by a different mechanism. + */ +export function overrideEnvVar( + env: NodeJS.ProcessEnv, + name: string, + value: string, +): void { + const lower = name.toLowerCase(); + for (const key of Object.keys(env)) { + if (key !== name && key.toLowerCase() === lower) delete env[key]; + } + env[name] = value; +} + +/** Every key in `env` that spells `name`, in any case. */ +export function envVarKeys(env: NodeJS.ProcessEnv, name: string): string[] { + const lower = name.toLowerCase(); + return Object.keys(env).filter((key) => key.toLowerCase() === lower); +} + +/** NO_PROXY with every entry that would exempt `host` removed. */ +export function stripNoProxyFor(noProxy: string, host: string): string { + return noProxy + .split(",") + .filter((entry) => entry.trim() && !noProxyEntryMatches(entry, host)) + .map((entry) => entry.trim()) + .join(","); +} + +export type ProxyAction = + // No HTTP_PROXY/HTTPS_PROXY set — nothing to do. + | "none" + // Already active: the user set NODE_USE_ENV_PROXY themselves, or we are the + // re-exec child. + | "already-active" + // Enabled in-process via http.setGlobalProxyFromEnv(). + | "applied" + // Re-executed with NODE_USE_ENV_PROXY=1; the caller must exit. + | "reexec" + // We tried and could not enable it. Non-fatal: the command proceeds and + // fails with a diagnosable network error rather than a mystery. + | "failed"; + +// True once we enabled proxying ourselves, as opposed to the user having set +// NODE_USE_ENV_PROXY. The distinction only matters for advice: proxying works +// either way for *this* process, but npm and the agents are separate processes +// and still need the variable set in the user's shell. +let autoEnabled = false; + +export function proxyAutoEnabled(): boolean { + return autoEnabled; +} + +/** Test-only: forget that we enabled the proxy so each case starts clean. */ +export function resetProxyState(): void { + autoEnabled = false; +} + +export interface ProxyResult { + action: ProxyAction; + exitCode?: number; + /** Set when NO_PROXY would send backend traffic direct. */ + noProxyWarning?: string; + error?: string; +} + +/** + * Make `fetch` honor HTTP_PROXY/HTTPS_PROXY for this process. + * + * Called from index.tsx right after initLogging and before command dispatch. + * Strictly best-effort: any failure returns "failed" and the command runs + * anyway — a proxy we couldn't enable must not stop `codevhub doctor` from + * running and explaining why the network is broken. + */ +export function applyEnvProxy(): ProxyResult { + const proxy = readProxyEnv(); + + // A NO_PROXY exemption for our own backend defeats the proxy no matter how + // it is enabled, so surface it on every path below (including "none", where + // the user has no proxy but may still have inherited the entry). + const noProxyEntry = matchingNoProxyEntry(proxy, backendHost()); + const noProxyWarning = noProxyEntry + ? `NO_PROXY contains "${noProxyEntry}", which sends ${backendHost()} traffic ` + + "direct instead of through your proxy. This is the usual cause of " + + "`Login failed`. Remove that entry and re-run." + : undefined; + + if (!hasProxyConfigured(proxy)) return { action: "none", noProxyWarning }; + if (proxy.useEnvProxy || isEnabled(process.env[PROXY_APPLIED_ENV])) { + return { action: "already-active", noProxyWarning }; + } + + // Fast path: enable it in-process. Verified to route global `fetch`, not + // just node:http/https Agent traffic. + if (httpApi.supported()) { + try { + httpApi.setGlobalProxyFromEnv(); + // Record the truth in the environment. Two reasons this matters: + // 1. Everything downstream reads NODE_USE_ENV_PROXY to decide whether + // the proxy is live. Leaving it unset made diagnoseError claim + // "Node is IGNORING your proxy settings" on a request that had + // demonstrably just gone through the proxy — a message that + // contradicts its own evidence is worse than no message. + // 2. Children we spawn (npm, the agents) inherit it, so they pick up + // the proxy too instead of each needing their own fix. + process.env.NODE_USE_ENV_PROXY = "1"; + autoEnabled = true; + logInfo("enabled proxy from environment", { + action: "proxy.configure", + extra: { + via: "setGlobalProxyFromEnv", + no_proxy_conflict: !!noProxyEntry, + }, + }); + return { action: "applied", noProxyWarning }; + } catch (err) { + logWarn("setGlobalProxyFromEnv failed; falling back to re-exec", { + action: "proxy.configure", + err, + }); + } + } + + return { ...reexecWithEnvProxy(), noProxyWarning }; +} + +// Slow path for Node builds without setGlobalProxyFromEnv: relaunch ourselves +// with NODE_USE_ENV_PROXY=1 so the flag is present at bootstrap, where Node +// actually reads it. stdio is inherited, so the user sees one continuous +// session; we just exit with the child's code. +function reexecWithEnvProxy(): ProxyResult { + const selfPath = process.argv[1]; + if (!selfPath) { + const error = "cannot determine CLI entry path for proxy re-exec"; + logWarn(error, { action: "proxy.configure", outcome: "failure" }); + return { action: "failed", error }; + } + + logInfo("re-executing with NODE_USE_ENV_PROXY=1", { + action: "proxy.configure", + extra: { via: "reexec" }, + }); + // Ink owns the TTY once a command renders, but this runs before dispatch, so + // plain stderr is safe here and the extra process isn't a mystery. + process.stderr.write( + "Detected HTTP_PROXY; restarting with NODE_USE_ENV_PROXY=1 so it takes effect.\n", + ); + + const traceId = currentTraceId(); + const result = spawner.spawnSync( + process.execPath, + [...process.execArgv, selfPath, ...process.argv.slice(2)], + { + stdio: "inherit", + env: { + ...process.env, + NODE_USE_ENV_PROXY: "1", + [PROXY_APPLIED_ENV]: "1", + ...(traceId ? { CODEV_TRACE_PARENT: traceId } : {}), + }, + }, + ); + return { action: "reexec", exitCode: result.status ?? 1 }; +} diff --git a/tests/DoctorApp.test.tsx b/tests/DoctorApp.test.tsx new file mode 100644 index 0000000..a08a31b --- /dev/null +++ b/tests/DoctorApp.test.tsx @@ -0,0 +1,384 @@ +import * as child_process from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { cleanup, render } from "ink-testing-library"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { DoctorApp } from "@/DoctorApp.js"; +import * as auth from "@/lib/auth.js"; +import * as backend from "@/lib/backend.js"; +import { + DOCTOR_PROXY_ENV, + doctorOutcome, + resetDoctorOutcome, +} from "@/lib/doctor.js"; +import * as log from "@/lib/log.js"; +import { PROXY_APPLIED_ENV } from "@/lib/proxy.js"; +import * as reexec from "@/lib/reexec.js"; +import * as tls from "@/lib/tls.js"; + +vi.mock("node:child_process", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, execFile: vi.fn() }; +}); + +const PROXY_VARS = [ + "HTTP_PROXY", + "http_proxy", + "HTTPS_PROXY", + "https_proxy", + "NO_PROXY", + "no_proxy", + "NODE_USE_ENV_PROXY", + "NODE_USE_SYSTEM_CA", + "NODE_TLS_REJECT_UNAUTHORIZED", + DOCTOR_PROXY_ENV, + PROXY_APPLIED_ENV, +]; + +type ExecCb = (error: Error | null, stdout: string, stderr: string) => void; + +// Mirrors the shape normalization in InstallApp.test.tsx: production uses +// (file, args, opts, cb) on POSIX and a single command string on Windows. +function stubExecFile( + handler: ( + file: string, + args: string[], + ) => { error?: Error | null; stdout?: string; stderr?: string }, +) { + vi.mocked(child_process.execFile).mockImplementation((( + ...callArgs: unknown[] + ) => { + const cb = callArgs[callArgs.length - 1] as ExecCb; + const first = callArgs[0] as string; + const second = callArgs[1]; + let file: string; + let args: string[]; + if (Array.isArray(second)) { + file = first; + args = second as string[]; + } else { + const tokens = first.split(/\s+/).filter(Boolean); + file = tokens[0] ?? ""; + args = tokens.slice(1); + } + const r = handler(file, args); + setImmediate(() => cb(r.error ?? null, r.stdout ?? "", r.stderr ?? "")); + return {} as unknown as child_process.ChildProcess; + }) as unknown as typeof child_process.execFile); +} + +let tempHome: string; + +beforeEach(() => { + tempHome = mkdtempSync(join(tmpdir(), "codev-doctorapp-test-")); + vi.stubEnv("HOME", tempHome); + vi.stubEnv("USERPROFILE", tempHome); + for (const name of PROXY_VARS) vi.stubEnv(name, ""); + resetDoctorOutcome(); + + // Every npm probe answers plausibly by default; individual tests override. + stubExecFile((file, args) => { + if (file !== "npm") return { stdout: "" }; + if (args[0] === "-v") return { stdout: "10.9.0" }; + if (args[0] === "config") return { stdout: "undefined" }; + if (args[0] === "ping") return { stdout: "ok" }; + if (args[0] === "view") return { stdout: "0.4.5" }; + return { stdout: "" }; + }); + // The OS trust store read is slow on Windows and machine-dependent + // everywhere; pin it so the row is deterministic. + vi.spyOn(tls.tlsApi, "supported").mockReturnValue(true); + vi.spyOn(tls.tlsApi, "getCACertificates").mockReturnValue(["cert"]); + // Never let a test reach the real network or re-exec the CLI. + vi.spyOn(log, "loggedFetch").mockResolvedValue( + new Response("", { status: 200 }), + ); + vi.spyOn(reexec.spawner, "spawnSync").mockReturnValue( + // biome-ignore lint/suspicious/noExplicitAny: minimal SpawnSyncReturns stub + { status: 0 } as any, + ); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + rmSync(tempHome, { recursive: true, force: true }); +}); + +function fakeAuth(): auth.AuthData { + return { + access_token: "access-xyz", + id_token: "id-xyz", + expires_at: Date.now() + 3_600_000, + user: { sub: "u", email: "test@example.com", displayName: "Test" }, + }; +} + +/** Stub the whole happy path: sign-in, key, config, supabase, gateway, LLM. */ +function stubHappyPath() { + vi.spyOn(auth, "login").mockResolvedValue(fakeAuth()); + vi.spyOn(backend, "fetchApiKey").mockResolvedValue("sk-doctor-123"); + vi.spyOn(backend, "fetchCodevConfig").mockResolvedValue({ + supabaseUrl: "https://supabase.example.com", + supabaseAnonKey: "anon", + gatewayUrl: "https://gateway.example.com", + }); + vi.spyOn(backend, "fetchSupabaseSession").mockResolvedValue({ + access_token: "sb", + user: { id: "u", email: "test@example.com" }, + }); + vi.spyOn(backend, "validateApiKey").mockResolvedValue(true); + vi.spyOn(backend, "fetchModels").mockResolvedValue(["m-alpha", "m-beta"]); + vi.spyOn(backend, "smokeTestModel").mockResolvedValue(null); +} + +// Poll rather than sleep — the flow is a chain of async phases and a fixed +// sleep that passes locally is a Heisenbug on slower CI. +async function waitForFrame( + frames: string[], + needle: string, + maxMs = 5_000, +): Promise { + const start = Date.now(); + while (Date.now() - start < maxMs) { + if (frames.join("\n").includes(needle)) { + await new Promise((r) => setTimeout(r, 30)); + return; + } + await new Promise((r) => setTimeout(r, 20)); + } +} + +describe("DoctorApp", () => { + test("a clean machine reaches the summary and exits 0", async () => { + stubHappyPath(); + const { frames } = render(); + + await waitForFrame(frames, "Everything checks out"); + const output = frames.join("\n"); + + // Every group ran, in order. + expect(output).toContain("Environment"); + expect(output).toContain("Network"); + expect(output).toContain("Signed in as test@example.com"); + expect(output).toContain("LLM access"); + // The LLM completion is the check that proves inference is permitted. + expect(output).toContain("m-alpha answered a test prompt"); + expect(output).toContain("codevhub install"); + expect(doctorOutcome.exitCode).toBe(0); + }); + + test("a failing LLM completion fails the run and explains itself", async () => { + stubHappyPath(); + vi.spyOn(backend, "smokeTestModel").mockResolvedValue( + "Gateway rejected a test request for m-alpha (HTTP 403): over budget", + ); + const { frames } = render(); + + await waitForFrame(frames, "check(s) failed"); + const output = frames.join("\n"); + + expect(output).toContain("What happened"); + expect(output).toContain("Likely cause"); + expect(output).toContain("What to do"); + expect(output).toContain("over budget"); + expect(doctorOutcome.exitCode).toBe(1); + }); + + // The point of the whole command: a network failure must never surface as + // a bare `fetch failed`. + test("a network failure renders the full diagnosis, not `fetch failed`", async () => { + const err = new TypeError("fetch failed"); + (err as Error & { cause?: unknown }).cause = Object.assign( + new Error("getaddrinfo ENOTFOUND netmind.example.com"), + { code: "ENOTFOUND", hostname: "netmind.example.com" }, + ); + vi.spyOn(log, "loggedFetch").mockRejectedValue(err); + stubHappyPath(); + + const { frames } = render(); + await waitForFrame(frames, "Could not resolve"); + const output = frames.join("\n"); + + expect(output).toContain("Could not resolve netmind.example.com"); + expect(output).toContain("What to do"); + // The raw chain is still shown — hiding it would cost support the detail. + expect(output).toContain("code ENOTFOUND"); + }); + + test("a network failure offers the proxy prompt", async () => { + vi.spyOn(log, "loggedFetch").mockRejectedValue( + Object.assign(new TypeError("fetch failed"), { + cause: Object.assign(new Error("connect ECONNREFUSED 1.2.3.4:443"), { + code: "ECONNREFUSED", + }), + }), + ); + stubHappyPath(); + + const { frames } = render(); + await waitForFrame(frames, "Proxy (host:port)"); + expect(frames.join("\n")).toContain("Configure a proxy"); + }); + + test("submitting a proxy records the retry for index.tsx to run", async () => { + vi.spyOn(log, "loggedFetch").mockRejectedValue( + Object.assign(new TypeError("fetch failed"), { + cause: Object.assign(new Error("connect ECONNREFUSED 1.2.3.4:443"), { + code: "ECONNREFUSED", + }), + }), + ); + stubHappyPath(); + + const { frames, stdin } = render(); + await waitForFrame(frames, "Proxy (host:port)"); + // Give ProxyPrompt's useInput handler a render to register on before + // typing — a keystroke that lands first is simply dropped. + await new Promise((r) => setTimeout(r, 80)); + stdin.write("10.0.0.1:8080"); + await new Promise((r) => setTimeout(r, 50)); + stdin.write("\r"); + await new Promise((r) => setTimeout(r, 100)); + + // The app must NOT spawn the retry itself — Ink still owns the TTY. + expect(doctorOutcome.retryWithProxy).toEqual({ + http: "http://10.0.0.1:8080", + https: "http://10.0.0.1:8080", + }); + expect(reexec.spawner.spawnSync).not.toHaveBeenCalled(); + }); + + test("an empty proxy answer skips the retry and continues to the summary", async () => { + vi.spyOn(log, "loggedFetch").mockRejectedValue( + Object.assign(new TypeError("fetch failed"), { + cause: Object.assign(new Error("connect ECONNREFUSED 1.2.3.4:443"), { + code: "ECONNREFUSED", + }), + }), + ); + stubHappyPath(); + + const { frames, stdin } = render(); + await waitForFrame(frames, "Proxy (host:port)"); + stdin.write("\r"); + + await waitForFrame(frames, "check(s) failed"); + expect(doctorOutcome.retryWithProxy).toBeNull(); + // It still gets far enough to hand back setup instructions. + expect(frames.join("\n")).toContain("Next steps"); + }); + + // Offering the prompt again in the retry child would loop the user forever. + test("the retry child is not offered the proxy prompt again", async () => { + vi.stubEnv(DOCTOR_PROXY_ENV, "1"); + vi.spyOn(log, "loggedFetch").mockRejectedValue( + Object.assign(new TypeError("fetch failed"), { + cause: Object.assign(new Error("connect ECONNREFUSED 1.2.3.4:443"), { + code: "ECONNREFUSED", + }), + }), + ); + stubHappyPath(); + + const { frames } = render(); + await waitForFrame(frames, "check(s) failed"); + expect(frames.join("\n")).not.toContain("Proxy (host:port)"); + }); + + // A wrong proxy address is one of the likeliest reasons the checks failed, + // so the prompt must still be offered — an earlier revision suppressed it + // here and left exactly that user with no way to try a different one. + test("still prompts when a proxy is already configured, naming the current one", async () => { + vi.stubEnv("HTTPS_PROXY", "http://10.0.0.1:8080"); + vi.stubEnv("NODE_USE_ENV_PROXY", "1"); + vi.spyOn(log, "loggedFetch").mockRejectedValue( + Object.assign(new TypeError("fetch failed"), { + cause: Object.assign(new Error("connect ECONNREFUSED 1.2.3.4:443"), { + code: "ECONNREFUSED", + }), + }), + ); + stubHappyPath(); + + const { frames } = render(); + await waitForFrame(frames, "Proxy (host:port)"); + const output = frames.join("\n"); + // The question shifts from "do you need a proxy?" to "is this one wrong?". + expect(output).toContain("even though a proxy is configured"); + expect(output).toContain("http://10.0.0.1:8080"); + expect(output).toContain("Enter to keep the current one"); + // The examples belong on BOTH variants — someone correcting a wrong proxy + // needs the syntax just as much as someone entering their first one. + expect(output).toContain("Examples:"); + expect(output).toContain("user:pass@"); + }); + + test("offers concrete examples of what to type", async () => { + vi.spyOn(log, "loggedFetch").mockRejectedValue( + Object.assign(new TypeError("fetch failed"), { + cause: Object.assign(new Error("connect ECONNREFUSED 1.2.3.4:443"), { + code: "ECONNREFUSED", + }), + }), + ); + stubHappyPath(); + + const { frames } = render(); + await waitForFrame(frames, "Proxy (host:port)"); + const output = frames.join("\n"); + expect(output).toContain("Examples:"); + // Each example answers a question "host:port" alone leaves open. + expect(output).toContain("10.60.129.1:3128"); + expect(output).toContain("proxy.corp.vn:8080"); + expect(output).toContain("user:pass@"); + expect(output).toContain("http:// is assumed"); + }); + + // Login must not park the run on a retry prompt — the summary is the value. + test("a sign-in failure is recorded and the run still reaches the summary", async () => { + vi.spyOn(auth, "login").mockRejectedValue( + Object.assign(new TypeError("fetch failed"), { + cause: Object.assign( + new Error("getaddrinfo ENOTFOUND sso.example.com"), + { + code: "ENOTFOUND", + hostname: "sso.example.com", + }, + ), + }), + ); + + const { frames } = render(); + await waitForFrame(frames, "check(s) failed"); + const output = frames.join("\n"); + + expect(output).toContain("Could not resolve sso.example.com"); + // Downstream checks report themselves as skipped, which shows how far the + // flow got rather than silently vanishing. + expect(output).toContain("Skipped"); + expect(output).not.toContain("Press Enter to retry"); + expect(doctorOutcome.exitCode).toBe(1); + }); + + test("environment warnings do not fail the run", async () => { + vi.stubEnv("HTTPS_PROXY", "http://10.0.0.1:8080"); + vi.stubEnv("NODE_USE_ENV_PROXY", "1"); + vi.stubEnv("NODE_TLS_REJECT_UNAUTHORIZED", "0"); + stubHappyPath(); + + const { frames } = render(); + await waitForFrame(frames, "warning(s)"); + expect(frames.join("\n")).toContain("NODE_TLS_REJECT_UNAUTHORIZED"); + expect(doctorOutcome.exitCode).toBe(0); + }); + + test("reports what is already installed on this machine", async () => { + stubHappyPath(); + const { frames } = render(); + await waitForFrame(frames, "This machine"); + expect(frames.join("\n")).toContain("This machine"); + }); +}); diff --git a/tests/components/Login.test.tsx b/tests/components/Login.test.tsx index e452266..a895224 100644 --- a/tests/components/Login.test.tsx +++ b/tests/components/Login.test.tsx @@ -128,7 +128,11 @@ describe("Login", () => { expect(onDone).not.toHaveBeenCalled(); }); - test("surfaces err.cause when present (real-world: fetch failed → DNS detail)", async () => { + // Node's fetch throws a bare `TypeError: fetch failed` and hides the reason + // on err.cause. Rather than echoing that, Login now renders the full + // diagnosis: what actually failed, why it most likely happened on this + // machine, and the fix. `fetch failed` must never reach the user alone. + test("renders a full diagnosis for a DNS failure, not `fetch failed`", async () => { vi.spyOn(auth, "login").mockImplementation(() => { const err = new TypeError("fetch failed"); (err as Error & { cause?: unknown }).cause = new Error( @@ -143,9 +147,12 @@ describe("Login", () => { await new Promise((r) => setTimeout(r, 50)); const output = lastFrame() ?? ""; - expect(output).toContain( - "Login failed: fetch failed (getaddrinfo ENOTFOUND sso.example.com)", - ); + // Names the real failure and the host, in plain language. + expect(output).toContain("Could not resolve sso.example.com"); + // And tells the user what to do about it. + expect(output).toContain("NODE_USE_ENV_PROXY"); + // The bare Node message is not what's shown on its own. + expect(output).not.toContain("Login failed: fetch failed"); }); test("keeps the happy path to a one-line spinner until the fallback delay elapses", async () => { diff --git a/tests/lib/const.test.ts b/tests/lib/const.test.ts index d6b48a4..420635d 100644 --- a/tests/lib/const.test.ts +++ b/tests/lib/const.test.ts @@ -10,6 +10,9 @@ import { GATEWAY_COMPACT_TRIGGER, GATEWAY_CONTEXT_WINDOW, GATEWAY_MAX_OUTPUT_TOKENS, + MIN_NODE_STRING, + nodeVersionMeets, + parseNodeVersion, SUPABASE_ANON_KEY, SUPABASE_URL, } from "@/lib/const.js"; @@ -123,3 +126,42 @@ describe("gateway compaction constants", () => { expect(GATEWAY_COMPACT_RESERVED).toBe(29491); }); }); + +// This gate decides whether the CLI runs at all, and the boundary is an +// oddly specific patch release: 22.21.0 is where HTTP_PROXY support was +// backported to the Node 22 line. A naive `major < 22 || minor < 21` compare +// would wrongly reject every 23.x and 24.x, so pin both directions. +describe("Node version gate", () => { + test("parses a v-prefixed version", () => { + expect(parseNodeVersion("v24.15.0")).toEqual([24, 15, 0]); + expect(parseNodeVersion("22.21.0")).toEqual([22, 21, 0]); + }); + + test.each([ + ["22.21.0", true], + ["22.21.1", true], + ["22.22.0", true], + ["v24.15.0", true], + // Newer majors must pass even though their minor is below 21. + ["23.0.0", true], + ["24.0.0", true], + ["24.5.0", true], + ["22.20.9", false], + ["22.5.0", false], + ["22.0.0", false], + ["21.99.99", false], + ["20.11.0", false], + ])("%s meets the floor: %s", (version, expected) => { + expect(nodeVersionMeets(version)).toBe(expected); + }); + + test("the floor string matches what the comparison enforces", () => { + expect(nodeVersionMeets(MIN_NODE_STRING)).toBe(true); + const [major, minor, patch] = parseNodeVersion(MIN_NODE_STRING); + expect(nodeVersionMeets(`${major}.${minor}.${patch - 1}`)).toBe(false); + }); + + test("the suite's own Node satisfies the gate it ships", () => { + expect(nodeVersionMeets(process.versions.node)).toBe(true); + }); +}); diff --git a/tests/lib/doctor-commands.test.ts b/tests/lib/doctor-commands.test.ts new file mode 100644 index 0000000..b8597b2 --- /dev/null +++ b/tests/lib/doctor-commands.test.ts @@ -0,0 +1,423 @@ +import * as child_process from "node:child_process"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { + ACCOUNT_CHECKS, + buildDoctorReport, + type Check, + ENVIRONMENT_CHECKS, + LLM_CHECKS, + NETWORK_CHECKS, + PREFLIGHT_CHECKS, + recordedCommands, + recordedRequests, + rerunDoctorWithProxy, + runChecks, + STATE_CHECKS, + startCommandRecording, +} from "@/lib/doctor.js"; +import * as log from "@/lib/log.js"; +import { loggedFetch, recordRequests, requestLog } from "@/lib/log.js"; +import { commandLog } from "@/lib/npm.js"; +import * as proxy from "@/lib/proxy.js"; +import * as reexec from "@/lib/reexec.js"; +import * as tls from "@/lib/tls.js"; + +/** + * The complete inventory of every child process `codevhub doctor` spawns. + * + * `doctor` is a diagnostic tool people run on locked-down machines, often while + * on a call with IT — "what is this thing actually running?" is a fair question + * and needs an answer that isn't "read the source". These tests are that + * answer, and they fail the moment a command is added, removed or reworded, so + * the list cannot quietly drift. + * + * Spying at the `execFile` boundary rather than on `lib/npm.ts#execAsync` is + * deliberate: helpers inside npm.ts call `execAsync` through their module-local + * binding, which a spy on the export cannot intercept. An earlier draft of this + * file used that spy and reported the `state` group as spawning nothing, when + * it was in fact shelling out four times. + */ + +vi.mock("node:child_process", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, execFile: vi.fn() }; +}); + +const PROXY_VARS = [ + "HTTP_PROXY", + "http_proxy", + "HTTPS_PROXY", + "https_proxy", + "NO_PROXY", + "no_proxy", + "NODE_USE_ENV_PROXY", + "NODE_USE_SYSTEM_CA", +]; + +let spawned: string[]; + +beforeEach(() => { + spawned = []; + commandLog.enabled = false; + commandLog.entries = []; + for (const name of PROXY_VARS) vi.stubEnv(name, ""); + proxy.resetProxyState(); + + vi.mocked(child_process.execFile).mockImplementation((( + ...callArgs: unknown[] + ) => { + const cb = callArgs[callArgs.length - 1] as ( + e: Error | null, + stdout: string, + stderr: string, + ) => void; + const first = callArgs[0] as string; + const second = callArgs[1]; + // POSIX passes (file, args, opts, cb); win32 passes one command string. + spawned.push( + Array.isArray(second) + ? `${first} ${(second as string[]).join(" ")}`.trim() + : first, + ); + setImmediate(() => cb(null, "/tmp/npm-prefix", "")); + return {} as unknown as child_process.ChildProcess; + }) as unknown as typeof child_process.execFile); + + // Nothing below should reach the network or the real OS trust store. + vi.spyOn(log, "loggedFetch").mockResolvedValue( + new Response("", { status: 200 }), + ); + vi.spyOn(tls.tlsApi, "supported").mockReturnValue(true); + vi.spyOn(tls.tlsApi, "getCACertificates").mockReturnValue(["cert"]); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); +}); + +async function commandsFor(checks: Check[]): Promise { + // Reset so each call reports only its own group — several tests below run + // more than one group and would otherwise see the running total. + spawned = []; + await runChecks(checks, { + accessToken: "token", + apiKey: "key", + gatewayUrl: "https://gateway.example.com", + models: ["m-alpha"], + }); + return spawned; +} + +describe("every command `codevhub doctor` runs", () => { + // The pre-flight is also embedded at the head of `codevhub install`, where a + // single `npm config get` (~300ms) would be felt on every run. Zero is the + // contract, not an accident — see PREFLIGHT_CHECKS in lib/doctor.ts. + test("pre-flight (also used by install): runs nothing", async () => { + expect(await commandsFor(PREFLIGHT_CHECKS)).toEqual([]); + }); + + test("environment group: 7 npm probes, all read-only", async () => { + expect(await commandsFor(ENVIRONMENT_CHECKS)).toEqual([ + "npm -v", + "npm config get prefix", + "npm config get registry", + "npm config get proxy", + "npm config get https-proxy", + "npm config get strict-ssl", + "npm config get cafile", + ]); + }); + + test("network group: a registry ping and a metadata read", async () => { + expect(await commandsFor(NETWORK_CHECKS)).toEqual([ + "npm ping", + "npm view codev-ai version", + ]); + }); + + test("account group: pure HTTP, no child processes", async () => { + expect(await commandsFor(ACCOUNT_CHECKS)).toEqual([]); + }); + + test("llm group: pure HTTP, no child processes", async () => { + expect(await commandsFor(LLM_CHECKS)).toEqual([]); + }); + + // Regression: this ran `npm root -g` once per agent — four serial spawns for + // an answer that cannot differ between them. + test("state group: resolves npm's global root exactly once", async () => { + expect(await commandsFor(STATE_CHECKS)).toEqual(["npm root -g"]); + }); + + test("a whole run spawns 10 processes, and every one is read-only", async () => { + const all = [ + ...(await commandsFor(ENVIRONMENT_CHECKS)), + ...(await commandsFor(NETWORK_CHECKS)), + ...(await commandsFor(ACCOUNT_CHECKS)), + ...(await commandsFor(LLM_CHECKS)), + ...(await commandsFor(STATE_CHECKS)), + ]; + expect(all).toHaveLength(10); + // Everything is `npm`, and nothing mutates: no install, uninstall, publish, + // cache write or config *set*. `doctor` promises to be read-only and this + // is what enforces it. + for (const command of all) { + expect(command).toMatch(/^npm /); + expect(command).not.toMatch( + /\b(install|i|add|uninstall|rm|remove|publish|link|update|dedupe)\b/, + ); + expect(command).not.toMatch(/config set/); + } + }); +}); + +// Knowing the list is only useful if the user is shown it. The inventory above +// is the contract; this is the part that gets it in front of them. +describe("the commands are recorded and reported back", () => { + test("recording is off until doctor asks for it", async () => { + commandLog.enabled = false; + commandLog.entries = []; + await commandsFor(ENVIRONMENT_CHECKS); + // Other commands (`update`, the upload daemon) share execAsync and must + // not accumulate an unbounded buffer. + expect(commandLog.entries).toEqual([]); + }); + + test("captures every command, in order, with timing and outcome", async () => { + startCommandRecording(); + await commandsFor(NETWORK_CHECKS); + const recorded = recordedCommands(); + expect(recorded.map((c) => c.command)).toEqual([ + "npm ping", + "npm view codev-ai version", + ]); + for (const c of recorded) { + expect(c.ok).toBe(true); + expect(typeof c.durationMs).toBe("number"); + } + }); + + // It catches `npm root -g`, which is spawned by a helper *inside* npm.ts — + // the case an external wrapper around execAsync cannot see. + test("captures commands spawned from inside npm.ts itself", async () => { + startCommandRecording(); + await commandsFor(STATE_CHECKS); + expect(recordedCommands().map((c) => c.command)).toEqual(["npm root -g"]); + }); + + test("a failing command is recorded as failed, not dropped", async () => { + vi.mocked(child_process.execFile).mockImplementation((( + ...callArgs: unknown[] + ) => { + const cb = callArgs[callArgs.length - 1] as ( + e: Error | null, + o: string, + s: string, + ) => void; + setImmediate(() => cb(new Error("boom"), "", "npm ERR!")); + return {} as unknown as child_process.ChildProcess; + }) as unknown as typeof child_process.execFile); + + startCommandRecording(); + await commandsFor(NETWORK_CHECKS); + expect(recordedCommands()[0]?.ok).toBe(false); + }); + + test("a new run starts from an empty list", async () => { + startCommandRecording(); + await commandsFor(NETWORK_CHECKS); + expect(recordedCommands()).toHaveLength(2); + startCommandRecording(); + expect(recordedCommands()).toEqual([]); + }); + + test("the report file carries them too", async () => { + startCommandRecording(); + await commandsFor(ENVIRONMENT_CHECKS); + const report = buildDoctorReport([], "2026-07-29T00:00:00.000Z"); + expect(report.commands.map((c) => c.command)).toEqual([ + "npm -v", + "npm config get prefix", + "npm config get registry", + "npm config get proxy", + "npm config get https-proxy", + "npm config get strict-ssl", + "npm config get cafile", + ]); + }); +}); + +// The one command doctor runs that is NOT npm, and the only one that starts a +// new CoDev process. Its argv and environment are asserted in doctor.test.ts; +// this states the shape alongside the rest of the inventory. +describe("the one non-npm command: the proxy retry", () => { + test("runs node on this CLI again with `doctor`", () => { + const spawn = vi + .spyOn(reexec.spawner, "spawnSync") + // biome-ignore lint/suspicious/noExplicitAny: minimal SpawnSyncReturns stub + .mockReturnValue({ status: 0 } as any); + + rerunDoctorWithProxy( + { http: "http://10.0.0.1:8080", https: "http://10.0.0.1:8080" }, + [], + ); + + const [file, argv] = spawn.mock.calls[0] ?? []; + expect([file, ...(argv as string[])]).toEqual([ + process.execPath, + ...process.execArgv, + process.argv[1], + "doctor", + ]); + }); + + test("is not spawned unless the user supplies a proxy", async () => { + const spawn = vi.spyOn(reexec.spawner, "spawnSync"); + await commandsFor(NETWORK_CHECKS); + expect(spawn).not.toHaveBeenCalled(); + }); +}); + +/** + * The npm list alone was only half the answer. `doctor`'s connection tests — + * backend, Supabase, the gateway — are HTTP, never touch `execAsync`, and so + * appeared nowhere. On a corporate network they are the *more* useful half: + * the endpoints are what IT needs in order to allow-list them. + * + * These stub global `fetch`, not `loggedFetch`: the recorder lives inside + * `loggedFetch`, so stubbing that export bypasses the very code under test. + */ +describe("the endpoints doctor contacted", () => { + beforeEach(() => { + // Undo the module-level loggedFetch stub so the real one — and its + // recorder — actually runs. + vi.restoreAllMocks(); + recordRequests(); + }); + + function stubFetch(status = 200) { + return vi + .spyOn(globalThis, "fetch") + .mockResolvedValue(new Response("", { status })); + } + + test("records method, URL, status and duration", async () => { + stubFetch(200); + await loggedFetch("probe", "https://api.example.com/v1/models", { + method: "GET", + }); + const [r] = recordedRequests(); + expect(r?.method).toBe("GET"); + expect(r?.url).toBe("https://api.example.com/v1/models"); + expect(r?.status).toBe(200); + expect(typeof r?.durationMs).toBe("number"); + }); + + // OAuth codes, signed-URL signatures and access tokens all live in query + // strings, and none of it helps a user see which endpoint was contacted. + test("drops the query string", async () => { + stubFetch(200); + await loggedFetch( + "probe", + "https://api.example.com/callback?code=SUPERSECRET&state=abc", + ); + const [r] = recordedRequests(); + expect(r?.url).toBe("https://api.example.com/callback"); + expect(r?.url).not.toContain("SUPERSECRET"); + }); + + // A 401 means the endpoint answered — reachability succeeded even though + // the request did not. It is recorded, so the UI can score it on + // "did anything come back" rather than on 2xx. + test("records a non-2xx response with its status", async () => { + stubFetch(401); + await loggedFetch("probe", "https://api.example.com/config", { + method: "POST", + }); + const [r] = recordedRequests(); + expect(r?.status).toBe(401); + expect(r?.ok).toBe(false); + }); + + test("a request that never got a response has a null status", async () => { + vi.spyOn(globalThis, "fetch").mockRejectedValue( + Object.assign(new TypeError("fetch failed"), { + cause: Object.assign(new Error("getaddrinfo ENOTFOUND x"), { + code: "ENOTFOUND", + }), + }), + ); + await expect( + loggedFetch("probe", "https://api.example.com/x"), + ).rejects.toThrow(); + const [r] = recordedRequests(); + expect(r?.status).toBeNull(); + expect(r?.ok).toBe(false); + }); + + test("recording is off until doctor asks for it", async () => { + requestLog.enabled = false; + requestLog.entries = []; + stubFetch(200); + await loggedFetch("probe", "https://api.example.com/x"); + expect(requestLog.entries).toEqual([]); + }); + + test("the report file carries the endpoints too", async () => { + stubFetch(200); + await loggedFetch("probe", "https://api.example.com/x"); + const report = buildDoctorReport([], "2026-07-29T00:00:00.000Z"); + expect(report.requests.map((r) => r.url)).toEqual([ + "https://api.example.com/x", + ]); + }); +}); + +/** + * Attribution: each check shows what it ran, under its own row, rather than in + * a separate list the reader has to correlate back to a step. + */ +describe("each check reports what it ran", () => { + test("a check that shells out lists its commands", async () => { + startCommandRecording(); + const check = ENVIRONMENT_CHECKS.find((c) => c.key === "npm-available"); + const [o] = await runChecks([check as Check], {}); + expect(o?.activity?.map((a) => a.detail)).toEqual(["npm -v"]); + expect(o?.activity?.[0]?.kind).toBe("command"); + }); + + // npm-registry fans out five `npm config get` concurrently; all five belong + // to it, which is what the before/after slice around a sequential run buys. + test("a check that fans out keeps all of its work on one row", async () => { + startCommandRecording(); + const check = ENVIRONMENT_CHECKS.find((c) => c.key === "npm-registry"); + const [o] = await runChecks([check as Check], {}); + expect(o?.activity).toHaveLength(5); + for (const a of o?.activity ?? []) { + expect(a.detail).toMatch(/^npm config get /); + } + }); + + test("work is never attributed to the wrong check", async () => { + startCommandRecording(); + const outcomes = await runChecks( + [ + ENVIRONMENT_CHECKS.find((c) => c.key === "node-version") as Check, + ENVIRONMENT_CHECKS.find((c) => c.key === "npm-available") as Check, + ], + {}, + ); + // node-version is pure logic and must claim nothing. + expect(outcomes[0]?.activity).toBeUndefined(); + expect(outcomes[1]?.activity?.map((a) => a.detail)).toEqual(["npm -v"]); + }); + + test("a pure-logic check carries no activity at all", async () => { + startCommandRecording(); + const outcomes = await runChecks(PREFLIGHT_CHECKS, {}); + // undefined rather than [], so the renderer has one falsy thing to test + // and the report file stays free of empty arrays. + for (const o of outcomes) expect(o.activity).toBeUndefined(); + }); +}); diff --git a/tests/lib/doctor.test.ts b/tests/lib/doctor.test.ts new file mode 100644 index 0000000..1b36909 --- /dev/null +++ b/tests/lib/doctor.test.ts @@ -0,0 +1,1284 @@ +import { + existsSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import * as backend from "@/lib/backend.js"; +import { VERSION } from "@/lib/const.js"; +import { + buildDoctorReport, + buildNextSteps, + type CheckOutcome, + DOCTOR_PROXY_ENV, + type DoctorReport, + describeFailure, + diagnoseError, + diagnoseExec, + diagnoseResponse, + ENVIRONMENT_CHECKS, + hasFailure, + INTERNAL_NPM_REGISTRY, + isTransportError, + LLM_CHECKS, + NETWORK_CHECKS, + normalizeProxyInput, + PREFLIGHT_CHECKS, + persistProxyInstructions, + renderDiagnosisCompact, + rerunDoctorWithProxy, + runChecks, + writeDoctorReport, +} from "@/lib/doctor.js"; +import * as log from "@/lib/log.js"; +import * as npm from "@/lib/npm.js"; +import * as proxy from "@/lib/proxy.js"; +import { envVarKeys, PROXY_APPLIED_ENV } from "@/lib/proxy.js"; +import * as reexec from "@/lib/reexec.js"; +import * as tls from "@/lib/tls.js"; + +const PROXY_VARS = [ + "HTTP_PROXY", + "http_proxy", + "HTTPS_PROXY", + "https_proxy", + "NO_PROXY", + "no_proxy", + "NODE_USE_ENV_PROXY", + "NODE_USE_SYSTEM_CA", + "NODE_TLS_REJECT_UNAUTHORIZED", + PROXY_APPLIED_ENV, +]; + +beforeEach(() => { + // A maintainer's own proxy settings would otherwise change which branch of + // every diagnosis is taken. + for (const name of PROXY_VARS) vi.stubEnv(name, ""); + proxy.resetProxyState(); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); +}); + +/** + * Build the shape Node's `fetch` actually throws: a bare `TypeError: fetch + * failed` wrapping the real error, which carries the code and syscall details. + * Hand-rolling `new Error("ECONNREFUSED")` would test a shape that never occurs. + */ +function fetchError( + code: string, + message: string, + extra: Record = {}, +): TypeError { + const outer = new TypeError("fetch failed"); + const inner = Object.assign(new Error(message), { code, ...extra }); + (outer as Error & { cause?: unknown }).cause = inner; + return outer; +} + +function rendered(d: ReturnType): string { + return [d.what, d.cause, d.fix, ...d.context, ...d.raw].join("\n"); +} + +describe("diagnoseError", () => { + // Table-driven: every code we claim to explain must produce all four parts. + const CASES: [string, TypeError, RegExp][] = [ + [ + "ENOTFOUND", + fetchError("ENOTFOUND", "getaddrinfo ENOTFOUND api.example.com", { + syscall: "getaddrinfo", + hostname: "api.example.com", + }), + /could not resolve/i, + ], + [ + "EAI_AGAIN", + fetchError("EAI_AGAIN", "getaddrinfo EAI_AGAIN api.example.com", { + hostname: "api.example.com", + }), + /could not resolve/i, + ], + [ + "ECONNREFUSED", + fetchError("ECONNREFUSED", "connect ECONNREFUSED 10.0.0.1:8080", { + address: "10.0.0.1", + port: 8080, + }), + /connection refused/i, + ], + [ + "ETIMEDOUT", + fetchError("ETIMEDOUT", "connect ETIMEDOUT 10.0.0.1:8080", { + address: "10.0.0.1", + }), + /timed out/i, + ], + [ + "UND_ERR_CONNECT_TIMEOUT", + fetchError("UND_ERR_CONNECT_TIMEOUT", "Connect Timeout Error"), + /timed out/i, + ], + [ + "ECONNRESET", + fetchError("ECONNRESET", "read ECONNRESET"), + /reset mid-handshake/i, + ], + [ + "EPROTO", + fetchError("EPROTO", "write EPROTO ... ssl3_read_bytes"), + /reset mid-handshake/i, + ], + [ + "CERT_HAS_EXPIRED", + fetchError("CERT_HAS_EXPIRED", "certificate has expired"), + /expired TLS certificate/i, + ], + [ + "ERR_TLS_CERT_ALTNAME_INVALID", + fetchError("ERR_TLS_CERT_ALTNAME_INVALID", "Hostname/IP does not match"), + /different hostname/i, + ], + [ + "SELF_SIGNED_CERT_IN_CHAIN", + fetchError( + "SELF_SIGNED_CERT_IN_CHAIN", + "self-signed certificate in certificate chain", + ), + /does not trust the TLS certificate/i, + ], + ]; + + test.each( + CASES, + )("%s produces a complete diagnosis", (_name, err, matcher) => { + const d = diagnoseError(err, { + url: "https://api.example.com/x", + method: "GET", + timeoutMs: 20_000, + }); + expect(d.what).toMatch(matcher); + // All four parts must be populated — a blank field is a silent regression + // back towards "fetch failed". + expect(d.what.length).toBeGreaterThan(10); + expect(d.cause.length).toBeGreaterThan(10); + expect(d.fix.length).toBeGreaterThan(10); + expect(d.context.length).toBeGreaterThan(0); + expect(d.raw.length).toBeGreaterThan(0); + }); + + // The guard that gives the whole feature its point. + test.each(CASES)("%s never renders a bare `fetch failed`", (_n, err) => { + const d = diagnoseError(err, { url: "https://api.example.com/x" }); + expect(d.what).not.toBe("fetch failed"); + expect(d.what).not.toMatch(/^fetch failed/); + // The raw chain may (and should) still contain it verbatim — that's the + // point of the raw section — but never the explanation. + expect(d.raw.join("\n")).toContain("fetch failed"); + }); + + test("preserves the full cause chain verbatim, with codes and syscalls", () => { + const d = diagnoseError( + fetchError("ENOTFOUND", "getaddrinfo ENOTFOUND api.example.com", { + syscall: "getaddrinfo", + hostname: "api.example.com", + }), + ); + const raw = d.raw.join("\n"); + expect(raw).toContain("TypeError: fetch failed"); + expect(raw).toContain("getaddrinfo ENOTFOUND api.example.com"); + expect(raw).toContain("code ENOTFOUND"); + expect(raw).toContain("syscall getaddrinfo"); + }); + + // Node emits one entry per address family when a host has both A and AAAA + // records, and the real reason lives inside the AggregateError. + test("unwraps an AggregateError from a dual-stack connect", () => { + const outer = new TypeError("fetch failed"); + const agg = new AggregateError( + [ + Object.assign(new Error("connect ECONNREFUSED 127.0.0.1:9"), { + code: "ECONNREFUSED", + address: "127.0.0.1", + port: 9, + }), + Object.assign(new Error("connect ECONNREFUSED ::1:9"), { + code: "ECONNREFUSED", + }), + ], + "", + ); + (outer as Error & { cause?: unknown }).cause = agg; + + const d = diagnoseError(outer); + expect(d.what).toMatch(/connection refused/i); + expect(d.what).toContain("127.0.0.1:9"); + }); + + // AbortSignal.timeout's own message names neither host nor duration. + test("a real AbortSignal.timeout names the URL and the elapsed budget", async () => { + const signal = AbortSignal.timeout(1); + const err = await new Promise((resolve) => { + signal.addEventListener("abort", () => resolve(signal.reason)); + }); + const d = diagnoseError(err, { + url: "https://api.example.com/x", + timeoutMs: 20_000, + }); + expect(d.what).toMatch(/no response/i); + expect(d.what).toContain("api.example.com"); + expect(d.what).toContain("20s"); + }); + + // Code that catches and re-throws routinely drops .code but keeps the text. + test("recovers the code and host from the message when properties are lost", () => { + const outer = new TypeError("fetch failed"); + (outer as Error & { cause?: unknown }).cause = new Error( + "getaddrinfo ENOTFOUND sso.example.com", + ); + const d = diagnoseError(outer); + expect(d.what).toMatch(/could not resolve/i); + expect(d.what).toContain("sso.example.com"); + }); + + // Regression: an HTTP error our own code threw has no `.cause` and no + // errno, so it fell through to the generic branch and was reported as + // "No proxy is configured. If this network requires one, that is the most + // likely cause." — telling a user whose network is demonstrably fine (the + // server just answered them) to configure a proxy. + describe("application-level errors are not blamed on the network", () => { + test("a backend 401 is reported as an auth result, not a proxy problem", () => { + const d = diagnoseError( + new Error( + "Backend /auth/exchange failed (401): Invalid or expired SSO token", + ), + { url: "https://api.example.com/auth/exchange", method: "POST" }, + ); + expect(d.what).toContain("Invalid or expired SSO token"); + expect(d.cause).toMatch(/rejected the credentials/i); + expect(d.fix).toContain("codevhub login --force"); + // The proxy must not appear anywhere in the explanation. + expect(d.cause).not.toMatch(/proxy/i); + expect(d.fix).not.toMatch(/proxy/i); + }); + + test("a non-auth backend error points at the server's own message", () => { + const d = diagnoseError( + new Error("Backend /config returned incomplete payload: {}"), + ); + expect(d.cause).toMatch(/answered/i); + expect(d.fix).not.toMatch(/HTTP_PROXY/); + }); + + // A proxy IS the likely cause when nothing answered, so that reasoning + // must survive for errors that really did come from the transport. + test("a transport error keeps its proxy reasoning", () => { + const d = diagnoseError( + fetchError("ENOTFOUND", "getaddrinfo ENOTFOUND api.example.com"), + ); + expect(d.fix).toContain("HTTP_PROXY"); + }); + + // An errno in the message with no `cause` is still a network failure. + test("a causeless error naming an errno is still diagnosed as network", () => { + const d = diagnoseError(new Error("connect ECONNREFUSED 10.0.0.1:8080")); + expect(d.what).toMatch(/connection refused/i); + }); + }); + + test("an unknown code still explains itself rather than echoing the error", () => { + const d = diagnoseError(fetchError("E_WEIRD", "something odd happened"), { + url: "https://api.example.com/x", + }); + expect(d.what).toContain("something odd happened"); + expect(d.cause.length).toBeGreaterThan(10); + expect(d.fix.length).toBeGreaterThan(10); + }); + + describe("proxy state shapes the explanation", () => { + test("no proxy set → tells the user to configure one", () => { + const d = diagnoseError( + fetchError("ENOTFOUND", "getaddrinfo ENOTFOUND api.example.com"), + ); + expect(d.fix).toContain("HTTP_PROXY"); + expect(rendered(d)).toContain("proxy: none"); + }); + + // The single most common silent failure: a proxy is set and Node ignores + // it. That must dominate the explanation, because nothing else can be + // diagnosed until it is fixed. + test("proxy set but NODE_USE_ENV_PROXY unset → that is the fix", () => { + vi.stubEnv("HTTPS_PROXY", "http://10.0.0.1:8080"); + const d = diagnoseError( + fetchError("ENOTFOUND", "getaddrinfo ENOTFOUND api.example.com"), + ); + expect(d.fix).toContain("NODE_USE_ENV_PROXY=1"); + expect(d.cause).toContain("IGNORING your proxy settings"); + expect(rendered(d)).toContain("NODE_USE_ENV_PROXY: unset"); + }); + + test("proxy active → ECONNREFUSED is read as a wrong proxy address", () => { + vi.stubEnv("HTTPS_PROXY", "http://10.0.0.1:8080"); + vi.stubEnv("NODE_USE_ENV_PROXY", "1"); + const d = diagnoseError( + fetchError("ECONNREFUSED", "connect ECONNREFUSED 10.0.0.1:8080", { + address: "10.0.0.1", + port: 8080, + }), + ); + expect(d.cause).toMatch(/that address is your proxy/i); + expect(d.fix).toContain("HTTP_PROXY"); + }); + + // Without an address in the error we fall back to the request host, and + // claiming the *destination* is the proxy contradicts the line above it. + test("an addressless refusal does not call the destination the proxy", () => { + vi.stubEnv("HTTPS_PROXY", "http://10.0.0.1:8080"); + vi.stubEnv("NODE_USE_ENV_PROXY", "1"); + const d = diagnoseError( + fetchError("ECONNREFUSED", "connect ECONNREFUSED"), + { url: "https://api.example.com/x" }, + ); + expect(d.what).toContain("api.example.com"); + expect(d.cause).not.toContain("That address is your proxy"); + expect(d.cause).toMatch(/most likely came from the proxy/i); + }); + + test("a NO_PROXY exemption for the backend is reported in the context", () => { + vi.stubEnv("HTTPS_PROXY", "http://10.0.0.1:8080"); + vi.stubEnv("NODE_USE_ENV_PROXY", "1"); + vi.stubEnv("NO_PROXY", "*.viettel.vn"); + const d = diagnoseError(fetchError("ETIMEDOUT", "connect ETIMEDOUT")); + expect(rendered(d)).toContain("*.viettel.vn"); + }); + }); + + test("redacts credentials that appear in an error message", () => { + const d = diagnoseError( + fetchError( + "E_WEIRD", + "rejected request with Authorization: Bearer abc123def456ghi", + ), + ); + const all = rendered(d); + expect(all).not.toContain("abc123def456ghi"); + expect(all).toContain("[REDACTED]"); + }); +}); + +describe("diagnoseResponse", () => { + function headers(init: Record = {}): Headers { + return new Headers(init); + } + + test("407 explains proxy authentication and how to supply credentials", () => { + const d = diagnoseResponse( + 407, + "Proxy Authentication Required", + headers({ "proxy-authenticate": "Basic realm=corp" }), + "", + { url: "https://api.example.com/x" }, + ); + expect(d.what).toContain("407"); + expect(d.fix).toContain("user:password@host:port"); + expect(d.raw.join("\n")).toContain("Basic realm=corp"); + }); + + // The documented npm-403 case: it reads as a permissions problem but is the + // network blocking the request. + test("403 with proxy signatures is attributed to the network, not the account", () => { + const d = diagnoseResponse( + 403, + "Forbidden", + headers({ via: "1.1 corp-proxy" }), + "blocked by policy", + { url: "https://registry.npmjs.org/x" }, + ); + expect(d.cause).toMatch(/network blocking/i); + expect(d.fix).toContain("unset"); + }); + + test("502 is attributed to the proxy rather than a CoDev outage", () => { + const d = diagnoseResponse(502, "Bad Gateway", headers(), "", { + url: "https://api.example.com/x", + }); + expect(d.cause).toMatch(/proxy failing to reach/i); + }); + + test("truncates a huge error body so it can't blow up the frame", () => { + const d = diagnoseResponse( + 500, + "Server Error", + headers(), + "x".repeat(5000), + ); + expect(d.raw.join("\n").length).toBeLessThan(1000); + }); + + test("redacts a bearer token echoed back in an error body", () => { + const d = diagnoseResponse( + 401, + "Unauthorized", + headers(), + '{"error":"bad token: Bearer supersecrettokenvalue"}', + ); + const all = d.raw.join("\n"); + expect(all).not.toContain("supersecrettokenvalue"); + expect(all).toContain("[REDACTED]"); + }); +}); + +describe("diagnoseExec", () => { + test("ENOENT is reported as a missing program, not a failed command", () => { + const d = diagnoseExec("npm -v", "ENOENT", "", true); + expect(d.what).toContain("not found on your PATH"); + expect(d.fix).toContain("nodejs.org"); + }); + + // npm's stderr names the registry, proxy and .npmrc in play — truncating it + // destroys the diagnosis, so it must survive in full. + test("keeps npm's stderr in full", () => { + const stderr = Array.from( + { length: 60 }, + (_, i) => `npm ERR! line ${i}`, + ).join("\n"); + const d = diagnoseExec("npm ping", 1, stderr, false); + expect(d.raw).toHaveLength(60); + expect(d.raw.at(-1)).toContain("line 59"); + }); + + test("E403 gets the documented unset-proxy-in-this-shell fix", () => { + const d = diagnoseExec("npm ping", 1, "npm ERR! code E403", false); + expect(d.fix).toContain("unset HTTPS_PROXY HTTP_PROXY"); + expect(d.fix).toContain("$env:HTTPS_PROXY = $null"); + }); + + test("EACCES points at a writable prefix, not sudo", () => { + const d = diagnoseExec("npm i -g x", 1, "npm ERR! EACCES denied", false); + expect(d.fix).toContain("npm config set prefix"); + expect(d.fix).toMatch(/avoid `sudo npm i -g`/i); + }); + + test("a TLS failure points at npm's own cafile, not Node's env vars", () => { + const d = diagnoseExec( + "npm ping", + 1, + "npm ERR! request to https://registry.npmjs.org failed, reason: self-signed certificate in certificate chain", + false, + ); + expect(d.fix).toContain("npm config set cafile"); + }); + + test("a network failure names the internal registry mirror", () => { + const d = diagnoseExec("npm ping", 1, "npm ERR! ETIMEDOUT", false); + expect(d.fix).toContain(INTERNAL_NPM_REGISTRY); + }); +}); + +describe("describeFailure", () => { + test("upgrades a transport error to the full diagnosis", () => { + const msg = describeFailure( + fetchError("ENOTFOUND", "getaddrinfo ENOTFOUND api.example.com"), + ); + expect(msg).toMatch(/could not resolve/i); + expect(msg).toContain("Fix:"); + }); + + // A backend HTTP error already has a precise message; wrapping it in + // proxy-oriented reasoning would be actively misleading. + test("leaves a plain backend error message alone", () => { + const err = new Error("Backend /config failed (403): forbidden"); + expect(isTransportError(err)).toBe(false); + expect(describeFailure(err)).toBe( + "Backend /config failed (403): forbidden", + ); + }); +}); + +describe("environment checks", () => { + test("the pre-flight subset is pure — no subprocess, no OS trust store", async () => { + const exec = vi.spyOn(npm, "execAsync"); + const ca = vi.spyOn(tls.tlsApi, "getCACertificates"); + await runChecks(PREFLIGHT_CHECKS, {}); + // Both are load-bearing: `npm config get` costs ~300ms apiece, and the + // OS-store read blocks the event loop for 300ms+ on Windows, which is + // exactly what stalls Ink's render timers (see lib/tls.ts). + expect(exec).not.toHaveBeenCalled(); + expect(ca).not.toHaveBeenCalled(); + }); + + test("the pre-flight subset is a subset of the full environment group", () => { + for (const check of PREFLIGHT_CHECKS) { + expect(ENVIRONMENT_CHECKS).toContain(check); + } + }); + + async function runOne(key: string): Promise { + const check = ENVIRONMENT_CHECKS.find((c) => c.key === key); + if (!check) throw new Error(`no such check: ${key}`); + const [outcome] = await runChecks([check], {}); + if (!outcome) throw new Error("no outcome"); + return outcome; + } + + test("node-version passes on the Node running the suite", async () => { + // The suite can only run on a supported Node, so this pins that the + // check agrees with the startup gate rather than drifting from it. + expect((await runOne("node-version")).status).toBe("pass"); + }); + + test("proxy-env warns when a proxy is set without NODE_USE_ENV_PROXY", async () => { + vi.stubEnv("HTTPS_PROXY", "http://10.0.0.1:8080"); + const o = await runOne("proxy-env"); + expect(o.status).toBe("warn"); + expect(o.fix).toContain("NODE_USE_ENV_PROXY"); + }); + + test("proxy-env warns about a NO_PROXY entry covering the backend", async () => { + vi.stubEnv("HTTPS_PROXY", "http://10.0.0.1:8080"); + vi.stubEnv("NODE_USE_ENV_PROXY", "1"); + vi.stubEnv("NODE_USE_SYSTEM_CA", "1"); + vi.stubEnv("NO_PROXY", "*.viettel.vn"); + const o = await runOne("proxy-env"); + expect(o.status).toBe("warn"); + expect(o.fix).toContain("*.viettel.vn"); + expect(o.fix).toContain("Login failed"); + }); + + // Accuracy matters more than brevity here: claiming Node ignores the proxy, + // on a run where CoDev enabled it, contradicts the evidence in the same + // report. The advice must shift to "set it for npm and the agents". + test("proxy-env explains an auto-enabled proxy without claiming it is ignored", async () => { + vi.stubEnv("HTTPS_PROXY", "http://10.0.0.1:8080"); + vi.stubEnv("NODE_USE_ENV_PROXY", "1"); + vi.stubEnv("NODE_USE_SYSTEM_CA", "1"); + vi.spyOn(proxy, "proxyAutoEnabled").mockReturnValue(true); + const o = await runOne("proxy-env"); + expect(o.status).toBe("warn"); + expect(o.fix).toContain("CoDev enabled proxy support for this run"); + expect(o.fix).not.toContain("Node ignores proxy environment variables"); + }); + + test("proxy-env flags disabled TLS verification", async () => { + vi.stubEnv("NODE_TLS_REJECT_UNAUTHORIZED", "0"); + const o = await runOne("proxy-env"); + expect(o.status).toBe("warn"); + expect(o.fix).toContain("NODE_TLS_REJECT_UNAUTHORIZED=0"); + }); + + test("proxy-env passes on a clean environment", async () => { + expect((await runOne("proxy-env")).status).toBe("pass"); + }); + + test("npm-available fails with an install hint when npm is missing", async () => { + vi.spyOn(npm, "execAsync").mockResolvedValue({ + stdout: "", + stderr: "", + error: Object.assign(new Error("spawn npm ENOENT"), { code: "ENOENT" }), + }); + const o = await runOne("npm-available"); + expect(o.status).toBe("fail"); + expect(o.diagnosis?.what).toContain("not found on your PATH"); + }); + + test("npm-registry warns when the shell has a proxy but npm does not", async () => { + vi.stubEnv("HTTPS_PROXY", "http://10.0.0.1:8080"); + vi.spyOn(npm, "execAsync").mockImplementation(async (_f, args) => ({ + stdout: args[2] === "registry" ? INTERNAL_NPM_REGISTRY : "undefined", + stderr: "", + error: null, + })); + const o = await runOne("npm-registry"); + expect(o.status).toBe("warn"); + expect(o.fix).toContain("npm config set proxy"); + }); + + test("npm-registry warns when pointed at the public registry", async () => { + vi.spyOn(npm, "execAsync").mockImplementation(async (_f, args) => ({ + stdout: + args[2] === "registry" ? "https://registry.npmjs.org/" : "undefined", + stderr: "", + error: null, + })); + const o = await runOne("npm-registry"); + expect(o.status).toBe("warn"); + expect(o.fix).toContain(INTERNAL_NPM_REGISTRY); + }); + + test("system-ca warns on a Node that cannot read the OS store", async () => { + vi.spyOn(tls.tlsApi, "supported").mockReturnValue(false); + const o = await runOne("system-ca"); + expect(o.status).toBe("warn"); + expect(o.fix).toContain("NODE_EXTRA_CA_CERTS"); + }); + + test("system-ca warns when the store is empty", async () => { + vi.spyOn(tls.tlsApi, "supported").mockReturnValue(true); + vi.spyOn(tls.tlsApi, "getCACertificates").mockReturnValue([]); + expect((await runOne("system-ca")).status).toBe("warn"); + }); +}); + +// Regression: the probe used to GET the API base path and let `fetch` follow +// redirects. The gateway 301s that path to its internal origin +// (`http://netmind.viettel.vn:9096/codev-backend/`), which is unreachable from +// outside the corporate network — so `doctor` reported the backend down on +// machines where `codevhub install` worked perfectly. +describe("backend reachability probe", () => { + async function runReach(): Promise { + const check = NETWORK_CHECKS.find((c) => c.key === "backend-reach"); + if (!check) throw new Error("no backend-reach check"); + const [outcome] = await runChecks([check], {}); + if (!outcome) throw new Error("no outcome"); + return outcome; + } + + test("never follows redirects, and hits a real route rather than the base path", async () => { + const fetchSpy = vi + .spyOn(log, "loggedFetch") + .mockResolvedValue(new Response("", { status: 401 })); + + await runReach(); + + const [, url, init] = fetchSpy.mock.calls[0] ?? []; + expect(init?.redirect).toBe("manual"); + expect(init?.method).toBe("POST"); + // The bare base path is exactly what 301s to the internal origin. + expect(String(url)).not.toMatch(/\/codev-backend\/?$/); + expect(String(url)).toContain("/codev-backend/config"); + }); + + test("a redirect counts as reachable — the response itself proves transport", async () => { + vi.spyOn(log, "loggedFetch").mockResolvedValue( + new Response("", { + status: 301, + headers: { location: "http://netmind.viettel.vn:9096/codev-backend/" }, + }), + ); + expect((await runReach()).status).toBe("pass"); + }); + + test("401 is a pass, and says so rather than reading like a failure", async () => { + vi.spyOn(log, "loggedFetch").mockResolvedValue( + new Response("", { status: 401 }), + ); + const o = await runReach(); + expect(o.status).toBe("pass"); + expect(o.detail).toContain("expected without credentials"); + }); + + test("407 still fails — a proxy challenge means the transport is unusable", async () => { + vi.spyOn(log, "loggedFetch").mockResolvedValue( + new Response("", { status: 407 }), + ); + const o = await runReach(); + expect(o.status).toBe("fail"); + expect(o.fix).toContain("user:password@host:port"); + }); + + test("a connect timeout still fails, with the full diagnosis", async () => { + vi.spyOn(log, "loggedFetch").mockRejectedValue( + Object.assign(new TypeError("fetch failed"), { + cause: Object.assign(new Error("Connect Timeout Error"), { + code: "UND_ERR_CONNECT_TIMEOUT", + }), + }), + ); + const o = await runReach(); + expect(o.status).toBe("fail"); + expect(o.diagnosis?.what).toMatch(/timed out/i); + }); +}); + +describe("llm checks", () => { + // doctor never writes ~/.codev-hub/auth.json, so every LLM check has to be + // handed the gateway URL the config check fetched. + const GATEWAY = "https://gateway.example.com"; + + async function runLlm(key: string, ctx: object): Promise { + const check = LLM_CHECKS.find((c) => c.key === key); + if (!check) throw new Error(`no such check: ${key}`); + const [outcome] = await runChecks([check], ctx); + if (!outcome) throw new Error("no outcome"); + return outcome; + } + + test("skips cleanly when there is no API key", async () => { + const o = await runLlm("gateway-key", {}); + expect(o.status).toBe("skip"); + }); + + // Regression: these called backend.ts without a base URL, so it fell back to + // AI_GATEWAY_URL(), which reads ~/.codev-hub/auth.json and throws when it is + // absent. On a machine that has never run `codevhub install` — the audience + // this command exists for — all three failed with "Run `codevhub install`": + // circular advice, and wrong, since the config check had just fetched the + // URL. `doctor` never writes that cache, so it must be threaded through. + describe("the gateway URL is threaded, not read from the install cache", () => { + test.each([ + ["gateway-key", () => vi.spyOn(backend, "validateApiKey")], + ["gateway-models", () => vi.spyOn(backend, "fetchModels")], + ["llm-completion", () => vi.spyOn(backend, "smokeTestModel")], + ])("%s passes ctx.gatewayUrl through", async (key, spyFor) => { + const spy = spyFor(); + // biome-ignore lint/suspicious/noExplicitAny: one spy shape per callee + (spy as any).mockResolvedValue( + key === "gateway-models" + ? ["m-alpha"] + : key === "gateway-key" + ? true + : null, + ); + await runLlm(key, { + apiKey: "k", + gatewayUrl: "https://gateway.example.com", + models: ["m-alpha"], + }); + // The URL must reach backend.ts as an argument — the last one for the + // completion (apiKey, model, baseUrl), the second otherwise. + expect(spy.mock.calls[0]).toContain("https://gateway.example.com"); + }); + + test.each([ + "gateway-key", + "gateway-models", + "llm-completion", + ])("%s skips rather than blaming the install cache when the URL is missing", async (key) => { + const o = await runLlm(key, { apiKey: "k" }); + expect(o.status).toBe("skip"); + expect(o.detail).not.toContain("auth.json"); + expect(o.detail).not.toContain("codevhub install"); + }); + }); + + test("a rejected key fails with a re-auth instruction", async () => { + vi.spyOn(backend, "validateApiKey").mockResolvedValue(false); + const o = await runLlm("gateway-key", { + apiKey: "k", + gatewayUrl: GATEWAY, + }); + expect(o.status).toBe("fail"); + expect(o.fix).toContain("codevhub login --force"); + }); + + // The only check that proves inference is actually permitted — /key/info + // and /v1/models both pass for a key that is 403'd on every completion. + test("a gateway completion refusal fails with the reason attached", async () => { + vi.spyOn(backend, "smokeTestModel").mockResolvedValue( + "Gateway rejected a test request for m-alpha (HTTP 403): budget exceeded", + ); + const o = await runLlm("llm-completion", { + apiKey: "k", + gatewayUrl: GATEWAY, + models: ["m-alpha"], + }); + expect(o.status).toBe("fail"); + expect(o.diagnosis?.raw.join("\n")).toContain("budget exceeded"); + expect(o.diagnosis?.cause).toMatch(/only proves the key exists/i); + }); + + // smokeTestModel stringifies its own errors, so an unreachable gateway + // arrives as "Couldn't reach the gateway to test X: fetch failed" — the + // exact bare message this command exists to eliminate. + test("an unreachable gateway is diagnosed, not echoed as `fetch failed`", async () => { + vi.spyOn(backend, "smokeTestModel").mockResolvedValue( + "Couldn't reach the gateway to test m-alpha: fetch failed", + ); + const o = await runLlm("llm-completion", { + apiKey: "k", + gatewayUrl: GATEWAY, + models: ["m-alpha"], + }); + expect(o.status).toBe("fail"); + expect(o.detail).toContain("gateway.example.com"); + expect(o.detail).not.toContain("fetch failed"); + expect(o.diagnosis?.cause).toMatch(/connectivity problem/i); + // The raw string is still preserved for support. + expect(o.diagnosis?.raw.join("\n")).toContain("fetch failed"); + }); + + test("a successful completion passes and names the model", async () => { + vi.spyOn(backend, "smokeTestModel").mockResolvedValue(null); + const o = await runLlm("llm-completion", { + apiKey: "k", + gatewayUrl: GATEWAY, + models: ["m-alpha"], + }); + expect(o.status).toBe("pass"); + expect(o.detail).toContain("m-alpha"); + }); +}); + +describe("runChecks", () => { + test("a check that throws becomes a failure rather than taking the run down", async () => { + const outcomes = await runChecks( + [ + { + key: "boom", + label: "Boom", + group: "environment", + run: async () => { + throw new Error("unexpected"); + }, + }, + ], + {}, + ); + expect(outcomes[0]?.status).toBe("fail"); + expect(hasFailure(outcomes)).toBe(true); + }); + + test("threads context forward between checks", async () => { + const ctx: { apiKey?: string } = {}; + await runChecks( + [ + { + key: "a", + label: "A", + group: "account", + run: async (c) => { + c.apiKey = "from-a"; + return { status: "pass", detail: "" }; + }, + }, + { + key: "b", + label: "B", + group: "llm", + run: async (c) => ({ status: "pass", detail: c.apiKey ?? "missing" }), + }, + ], + ctx, + ); + expect(ctx.apiKey).toBe("from-a"); + }); +}); + +describe("remediation output", () => { + const failing: CheckOutcome[] = [ + { + key: "proxy-env", + label: "Proxy & TLS environment", + group: "environment", + status: "warn", + detail: "…", + fix: "Set NODE_USE_ENV_PROXY=1.", + }, + { + key: "backend-reach", + label: "Reach the CoDev backend", + group: "network", + status: "fail", + detail: "…", + fix: "Check the proxy address.", + }, + ]; + + test("lists every actionable item and ends pointing at install", () => { + const lines = buildNextSteps(failing).join("\n"); + expect(lines).toContain("Set NODE_USE_ENV_PROXY=1."); + expect(lines).toContain("Check the proxy address."); + expect(lines).toContain("codevhub install"); + }); + + // The explicit requirement: users must leave with instructions they can + // apply before running install. + test("includes persistent setup instructions when the failure is proxy-related", () => { + const lines = buildNextSteps(failing, { + http: "http://10.0.0.1:8080", + https: "http://10.0.0.1:8080", + }).join("\n"); + expect(lines).toContain("NODE_USE_ENV_PROXY"); + expect(lines).toContain("10.0.0.1:8080"); + expect(lines).toContain("npm config set proxy"); + }); + + // A wall of export lines under an expired-token 401 buries the one + // instruction that actually helps. + test("omits proxy setup when the only failures are authentication", () => { + const lines = buildNextSteps([ + { + key: "api-key", + label: "Fetch a gateway API key", + group: "account", + status: "fail", + detail: "Backend /auth/exchange failed (401)", + fix: "Run `codevhub login --force` to re-authenticate, then re-run.", + }, + ]).join("\n"); + expect(lines).toContain("codevhub login --force"); + expect(lines).not.toContain("export HTTPS_PROXY"); + expect(lines).not.toContain("SetEnvironmentVariable"); + }); + + // The internal mirror's URL ends in `/npm-proxy`, which a loose /proxy/i + // test matched — dragging the whole export block into a run whose only + // issue was the registry setting. + test("the npm mirror URL does not count as a proxy problem", () => { + const lines = buildNextSteps([ + { + key: "npm-registry", + label: "npm registry configuration", + group: "environment", + status: "warn", + detail: "registry: https://registry.npmjs.org/", + fix: `Set the mirror: npm config set registry ${INTERNAL_NPM_REGISTRY}`, + }, + ]).join("\n"); + expect(lines).toContain(INTERNAL_NPM_REGISTRY); + expect(lines).not.toContain("export HTTPS_PROXY"); + expect(lines).not.toContain("SetEnvironmentVariable"); + }); + + // But a transport failure in the same group still warrants them — the + // signal is the diagnosis naming the proxy vars, not the group. + test("includes proxy setup when an account failure is a transport failure", () => { + const lines = buildNextSteps([ + { + key: "api-key", + label: "Fetch a gateway API key", + group: "account", + status: "fail", + detail: "Could not resolve api.example.com", + fix: "Set HTTP_PROXY, HTTPS_PROXY and NODE_USE_ENV_PROXY=1, then re-run.", + }, + ]).join("\n"); + expect(lines).toContain("NODE_USE_ENV_PROXY=1"); + expect(lines).toContain("npm config set proxy"); + }); + + test("says nothing when everything passed", () => { + expect( + buildNextSteps([ + { + key: "x", + label: "X", + group: "network", + status: "pass", + detail: "ok", + }, + ]), + ).toEqual([]); + }); + + test("persist instructions are platform-appropriate", () => { + const lines = persistProxyInstructions({ + https: "http://10.0.0.1:8080", + }).join("\n"); + if (process.platform === "win32") { + expect(lines).toContain("SetEnvironmentVariable"); + } else { + expect(lines).toContain("export HTTPS_PROXY="); + } + // npm's proxy config is separate from the shell's, and users miss this. + expect(lines).toContain("npm config set https-proxy"); + }); +}); + +describe("the doctor report file", () => { + let home: string; + + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "codev-report-")); + vi.stubEnv("HOME", home); + vi.stubEnv("USERPROFILE", home); + }); + + afterEach(() => { + rmSync(home, { recursive: true, force: true }); + }); + + const outcomes: CheckOutcome[] = [ + { + key: "node-version", + label: "Node.js version", + group: "environment", + status: "pass", + detail: "Node 24.15.0.", + }, + { + key: "backend-reach", + label: "Reach the CoDev backend", + group: "network", + status: "fail", + detail: "Timed out.", + fix: "Set HTTP_PROXY, HTTPS_PROXY and NODE_USE_ENV_PROXY=1.", + }, + ]; + + function write(at = "2026-07-29T00:00:00.000Z") { + return writeDoctorReport(buildDoctorReport(outcomes, at)); + } + + test("lands at ~/.codev-hub/doctor-report.json", () => { + const path = write(); + expect(path).toBe(join(home, ".codev-hub", "doctor-report.json")); + expect(existsSync(path as string)).toBe(true); + }); + + test("creates ~/.codev-hub when the machine has never run CoDev", () => { + // The audience for `doctor` is precisely someone who has not installed + // yet, so the directory usually does not exist. + expect(existsSync(join(home, ".codev-hub"))).toBe(false); + expect(write()).not.toBeNull(); + }); + + test("records the results, the summary and the environment", () => { + const report = JSON.parse( + readFileSync(write() as string, "utf-8"), + ) as DoctorReport; + expect(report.summary).toMatchObject({ + ok: false, + passed: 1, + failed: 1, + warned: 0, + skipped: 0, + }); + expect(report.checks).toHaveLength(2); + expect(report.checks[1]?.fix).toContain("NODE_USE_ENV_PROXY"); + expect(report.nextSteps.join("\n")).toContain("codevhub install"); + expect(report.node.version).toBe(process.version); + expect(report.codevVersion).toBe(VERSION); + expect(report.generatedAt).toBe("2026-07-29T00:00:00.000Z"); + }); + + // A stale report is worse than none when someone attaches it to a ticket. + test("every run replaces the previous report", () => { + write("2026-07-29T00:00:00.000Z"); + const path = write("2026-07-30T11:22:33.000Z") as string; + const raw = readFileSync(path, "utf-8"); + expect(raw).toContain("2026-07-30T11:22:33.000Z"); + expect(raw).not.toContain("2026-07-29T00:00:00.000Z"); + // Replaced, not appended — it must still be a single JSON document. + expect(() => JSON.parse(raw)).not.toThrow(); + }); + + // This file exists to be attached to tickets, so it is the last place a + // credential should survive. + test("scrubs credentials before writing", () => { + const path = writeDoctorReport( + buildDoctorReport( + [ + { + key: "api-key", + label: "Fetch a gateway API key", + group: "account", + status: "fail", + detail: "rejected: Authorization: Bearer abc123def456ghi", + diagnosis: { + what: "w", + cause: "c", + fix: "f", + context: [], + raw: ["token sk-livesecretvalue123 leaked"], + }, + }, + ], + "2026-07-29T00:00:00.000Z", + ), + ) as string; + const raw = readFileSync(path, "utf-8"); + expect(raw).not.toContain("abc123def456ghi"); + expect(raw).not.toContain("sk-livesecretvalue123"); + expect(raw).toContain("[REDACTED"); + // Scrubbing must not corrupt the JSON. + expect(() => JSON.parse(raw)).not.toThrow(); + }); + + // Matches the discipline in lib/log.ts: a diagnostic that breaks the command + // it is diagnosing is worse than no diagnostic. + test("an unwritable location is survivable, not fatal", () => { + // A plain file where ~/.codev-hub must be a directory: mkdirSync cannot + // proceed, which is the realistic shape of "we could not write here". + writeFileSync(join(home, ".codev-hub"), "not a directory"); + expect(write()).toBeNull(); + }); +}); + +// The retry is the one place `doctor` shells out, and what it runs has to be +// exactly reproducible by hand for support. These spell the command out. +describe("the proxy retry command", () => { + const PROXY = { http: "http://10.0.0.1:8080", https: "http://10.0.0.1:8080" }; + + function captureSpawn() { + return ( + vi + .spyOn(reexec.spawner, "spawnSync") + // biome-ignore lint/suspicious/noExplicitAny: minimal SpawnSyncReturns stub + .mockReturnValue({ status: 0 } as any) + ); + } + + test("runs `node doctor`, forwarding argv and node's own flags", () => { + const spawn = captureSpawn(); + rerunDoctorWithProxy(PROXY, ["--force"]); + + const [file, argv, opts] = spawn.mock.calls[0] ?? []; + // The whole command, spelled out: + // [...node flags] doctor --force + expect([file, ...(argv as string[])]).toEqual([ + process.execPath, + ...process.execArgv, + process.argv[1], + "doctor", + "--force", + ]); + // It re-invokes node on the script directly — no shell, no `codevhub` bin. + expect(file).toBe(process.execPath); + expect(opts?.shell).toBeUndefined(); + // Inherited stdio is what makes the retry look like one continuous session. + expect(opts?.stdio).toBe("inherit"); + }); + + // Without execArgv a `pnpm dev` run (node + tsx loader flags) would spawn a + // child that cannot load TypeScript and dies immediately. + test("forwards node's own exec flags so a tsx dev run still works", () => { + const spawn = captureSpawn(); + const original = process.execArgv; + Object.defineProperty(process, "execArgv", { + value: ["--import", "file:///tmp/tsx/loader.mjs"], + configurable: true, + }); + try { + rerunDoctorWithProxy(PROXY, []); + } finally { + Object.defineProperty(process, "execArgv", { + value: original, + configurable: true, + }); + } + const argv = spawn.mock.calls[0]?.[1] as string[]; + expect(argv.slice(0, 2)).toEqual([ + "--import", + "file:///tmp/tsx/loader.mjs", + ]); + expect(argv).toContain("doctor"); + }); + + test("sets exactly the environment the retry needs", () => { + const spawn = captureSpawn(); + rerunDoctorWithProxy(PROXY, []); + + const env = spawn.mock.calls[0]?.[2]?.env as NodeJS.ProcessEnv; + expect(env.HTTP_PROXY).toBe("http://10.0.0.1:8080"); + expect(env.HTTPS_PROXY).toBe("http://10.0.0.1:8080"); + // Node reads this only at bootstrap, which is the entire reason the retry + // is a new process rather than an in-place re-run. + expect(env.NODE_USE_ENV_PROXY).toBe("1"); + // An intercepting proxy re-signs TLS, so trusting the OS store is part of + // "try it with the proxy", not a separate step. + expect(env.NODE_USE_SYSTEM_CA).toBe("1"); + // Stops the child prompting again if it still fails. + expect(env[DOCTOR_PROXY_ENV]).toBe("1"); + }); + + // A NO_PROXY entry covering our backend routes that traffic around the very + // proxy being tested — the documented cause of "Login failed". + test("drops a NO_PROXY entry that would bypass the proxy under test", () => { + vi.stubEnv("NO_PROXY", "localhost,*.viettel.vn,127.0.0.1"); + const spawn = captureSpawn(); + rerunDoctorWithProxy(PROXY, []); + + const env = spawn.mock.calls[0]?.[2]?.env as NodeJS.ProcessEnv; + // Looked up case-insensitively and ignoring empty spellings: Windows + // stores the key in whatever case it was first created with, so + // `env.NO_PROXY` alone is not portable, and an unset variant legitimately + // stays empty because there is nothing in it to strip. + const effective = envVarKeys(env, "NO_PROXY") + .map((key) => env[key]) + .filter((value): value is string => Boolean(value)); + expect(effective).toEqual(["localhost,127.0.0.1"]); + // The user's own environment is untouched — only the child's copy changes. + expect(process.env.NO_PROXY).toBe("localhost,*.viettel.vn,127.0.0.1"); + }); + + // Windows environment variables are case-insensitive, so a user's existing + // `http_proxy` would otherwise survive the spread alongside our `HTTP_PROXY` + // and the child could start up on the stale address. + test("leaves exactly one spelling of each variable it overrides", () => { + vi.stubEnv("http_proxy", "http://stale:1"); + const spawn = captureSpawn(); + rerunDoctorWithProxy(PROXY, []); + + const env = spawn.mock.calls[0]?.[2]?.env as NodeJS.ProcessEnv; + const keys = envVarKeys(env, "HTTP_PROXY"); + expect(keys).toEqual(["HTTP_PROXY"]); + expect(env.HTTP_PROXY).toBe("http://10.0.0.1:8080"); + }); + + test("propagates the child's exit code", () => { + vi.spyOn(reexec.spawner, "spawnSync") + // biome-ignore lint/suspicious/noExplicitAny: minimal SpawnSyncReturns stub + .mockReturnValue({ status: 3 } as any); + expect(rerunDoctorWithProxy(PROXY, [])).toBe(3); + }); + + test("a killed child (null status) is reported as a failure", () => { + vi.spyOn(reexec.spawner, "spawnSync") + // biome-ignore lint/suspicious/noExplicitAny: minimal SpawnSyncReturns stub + .mockReturnValue({ status: null } as any); + expect(rerunDoctorWithProxy(PROXY, [])).toBe(1); + }); +}); + +describe("normalizeProxyInput", () => { + test.each([ + ["10.0.0.1:8080", "http://10.0.0.1:8080"], + ["http://10.0.0.1:8080", "http://10.0.0.1:8080"], + ["https://proxy.corp:3128", "https://proxy.corp:3128"], + [" 10.0.0.1:8080 ", "http://10.0.0.1:8080"], + ])("%s → %s", (input, expected) => { + expect(normalizeProxyInput(input)).toBe(expected); + }); + + test.each([ + ["proxy.corp:3128", "http://proxy.corp:3128"], + // Bracketed IPv6, per normal URL syntax. + ["[::1]:8080", "http://[::1]:8080"], + // Credentials survive, for proxies that require auth. + ["user:pass@10.0.0.1:8080", "http://user:pass@10.0.0.1:8080"], + ])("%s → %s", (input, expected) => { + expect(normalizeProxyInput(input)).toBe(expected); + }); + + test.each([ + "", + " ", + "::::", + "10.0.0.1 8080", + "10.0.0.1:8080:9", + ])("rejects %s", (input) => { + expect(normalizeProxyInput(input)).toBeNull(); + }); + + // WHATWG URL parses bare integers as 32-bit IPv4 addresses, so `8080` + // silently became `http://0.0.31.144` — accepted, then failing much later as + // a timeout to an address the user never typed. Typing only the port is a + // very plausible slip against a prompt that asks for "host:port". + test.each([ + "8080", + "3128", + "0", + "http://8080", + ])("rejects the bare port %s instead of coercing it to an IP", (input) => { + expect(normalizeProxyInput(input)).toBeNull(); + }); +}); + +describe("renderDiagnosisCompact", () => { + test("keeps the explanation and the fix", () => { + const out = renderDiagnosisCompact({ + what: "W", + cause: "C", + fix: "F", + context: ["ctx"], + raw: ["raw"], + }); + expect(out).toBe("W\nC\nFix: F"); + }); +}); diff --git a/tests/lib/proxy.test.ts b/tests/lib/proxy.test.ts new file mode 100644 index 0000000..7deeb35 --- /dev/null +++ b/tests/lib/proxy.test.ts @@ -0,0 +1,398 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { BACKEND_URL } from "@/lib/const.js"; +import { + applyEnvProxy, + backendHost, + envVarKeys, + hasProxyConfigured, + httpApi, + maskProxyCredentials, + matchingNoProxyEntry, + noProxyEntryMatches, + overrideEnvVar, + PROXY_APPLIED_ENV, + proxyAutoEnabled, + proxyEnvSummary, + proxyForUrl, + readProxyEnv, + resetProxyState, + stripNoProxyFor, +} from "@/lib/proxy.js"; +import { spawner } from "@/lib/reexec.js"; + +// Every proxy variable Node or we might read. Cleared before each test so a +// developer's own shell can't leak into the assertions. +const PROXY_VARS = [ + "HTTP_PROXY", + "http_proxy", + "HTTPS_PROXY", + "https_proxy", + "NO_PROXY", + "no_proxy", + "NODE_USE_ENV_PROXY", + "NODE_USE_SYSTEM_CA", + "NODE_TLS_REJECT_UNAUTHORIZED", + PROXY_APPLIED_ENV, +]; + +beforeEach(() => { + for (const name of PROXY_VARS) vi.stubEnv(name, ""); + resetProxyState(); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); +}); + +describe("readProxyEnv", () => { + test("reads both upper and lower case spellings", () => { + vi.stubEnv("http_proxy", "http://lower:1"); + vi.stubEnv("HTTPS_PROXY", "http://upper:2"); + const env = readProxyEnv(); + expect(env.httpProxy).toBe("http://lower:1"); + expect(env.httpsProxy).toBe("http://upper:2"); + expect(hasProxyConfigured(env)).toBe(true); + }); + + test("treats an empty string as unset", () => { + vi.stubEnv("HTTP_PROXY", " "); + expect(hasProxyConfigured(readProxyEnv())).toBe(false); + }); + + test("NODE_USE_ENV_PROXY=0 counts as off", () => { + vi.stubEnv("NODE_USE_ENV_PROXY", "0"); + expect(readProxyEnv().useEnvProxy).toBe(false); + }); + + test("reports NODE_TLS_REJECT_UNAUTHORIZED verbatim so 0 can be flagged", () => { + vi.stubEnv("NODE_TLS_REJECT_UNAUTHORIZED", "0"); + expect(readProxyEnv().tlsRejectUnauthorized).toBe("0"); + }); +}); + +describe("NO_PROXY matching", () => { + test.each([ + ["*.viettel.vn", "netmind.viettel.vn", true], + [".viettel.vn", "netmind.viettel.vn", true], + ["viettel.vn", "netmind.viettel.vn", true], + ["viettel.vn", "viettel.vn", true], + ["*", "anything.example.com", true], + ["netmind.viettel.vn:443", "netmind.viettel.vn", true], + ["other.vn", "netmind.viettel.vn", false], + // A suffix that is not on a label boundary must not match. + ["ettel.vn", "netmind.viettel.vn", false], + ["", "netmind.viettel.vn", false], + ])("%s vs %s → %s", (entry, host, expected) => { + expect(noProxyEntryMatches(entry, host)).toBe(expected); + }); + + // The documented cause of "Login failed" on internal machines: images ship + // with *.viettel.vn in NO_PROXY, which routes our backend traffic direct. + test("finds the entry that exempts the CoDev backend", () => { + vi.stubEnv("NO_PROXY", "localhost,127.0.0.1,*.viettel.vn"); + expect(matchingNoProxyEntry(readProxyEnv(), backendHost())).toBe( + "*.viettel.vn", + ); + }); + + test("returns null when nothing exempts the backend", () => { + vi.stubEnv("NO_PROXY", "localhost,127.0.0.1"); + expect(matchingNoProxyEntry(readProxyEnv(), backendHost())).toBeNull(); + }); + + test("stripNoProxyFor removes only the offending entries", () => { + expect( + stripNoProxyFor("localhost, *.viettel.vn ,127.0.0.1", backendHost()), + ).toBe("localhost,127.0.0.1"); + }); + + test("backendHost matches the configured backend URL", () => { + expect(backendHost()).toBe(new URL(BACKEND_URL).hostname); + }); +}); + +describe("applyEnvProxy", () => { + test("no-ops when no proxy is configured", () => { + const spy = vi.spyOn(httpApi, "setGlobalProxyFromEnv"); + expect(applyEnvProxy().action).toBe("none"); + expect(spy).not.toHaveBeenCalled(); + }); + + test("does nothing when the user already set NODE_USE_ENV_PROXY", () => { + vi.stubEnv("HTTPS_PROXY", "http://p:8080"); + vi.stubEnv("NODE_USE_ENV_PROXY", "1"); + const spy = vi.spyOn(httpApi, "setGlobalProxyFromEnv"); + expect(applyEnvProxy().action).toBe("already-active"); + expect(spy).not.toHaveBeenCalled(); + }); + + test("enables the proxy in-process when Node supports it", () => { + vi.stubEnv("HTTPS_PROXY", "http://p:8080"); + vi.spyOn(httpApi, "supported").mockReturnValue(true); + const apply = vi + .spyOn(httpApi, "setGlobalProxyFromEnv") + .mockImplementation(() => {}); + const spawn = vi.spyOn(spawner, "spawnSync"); + + expect(applyEnvProxy().action).toBe("applied"); + expect(apply).toHaveBeenCalledOnce(); + // The whole point of the fast path: no extra process. + expect(spawn).not.toHaveBeenCalled(); + // The environment must reflect reality afterwards. Without this, + // diagnoseError would report "Node is IGNORING your proxy settings" about + // a request that had just gone through the proxy — and children we spawn + // (npm, the agents) would not inherit it. + expect(process.env.NODE_USE_ENV_PROXY).toBe("1"); + expect(readProxyEnv().useEnvProxy).toBe(true); + expect(proxyAutoEnabled()).toBe(true); + }); + + test("a user-set NODE_USE_ENV_PROXY is not reported as auto-enabled", () => { + vi.stubEnv("HTTPS_PROXY", "http://p:8080"); + vi.stubEnv("NODE_USE_ENV_PROXY", "1"); + expect(applyEnvProxy().action).toBe("already-active"); + // The distinction drives the advice: only the auto path needs to tell the + // user to set it in their shell for npm and the agents. + expect(proxyAutoEnabled()).toBe(false); + }); + + test("falls back to a re-exec on a Node without setGlobalProxyFromEnv", () => { + vi.stubEnv("HTTPS_PROXY", "http://p:8080"); + vi.spyOn(httpApi, "supported").mockReturnValue(false); + vi.spyOn(process.stderr, "write").mockReturnValue(true); + const spawn = vi + .spyOn(spawner, "spawnSync") + // biome-ignore lint/suspicious/noExplicitAny: minimal SpawnSyncReturns stub + .mockReturnValue({ status: 0 } as any); + + const result = applyEnvProxy(); + expect(result.action).toBe("reexec"); + expect(result.exitCode).toBe(0); + + // The child must carry the flag Node only reads at bootstrap, plus the + // sentinel that stops it looping if Node still ignores it. + const env = spawn.mock.calls[0]?.[2]?.env as NodeJS.ProcessEnv; + expect(env.NODE_USE_ENV_PROXY).toBe("1"); + expect(env[PROXY_APPLIED_ENV]).toBe("1"); + }); + + test("the sentinel stops a second attempt in the re-exec child", () => { + vi.stubEnv("HTTPS_PROXY", "http://p:8080"); + vi.stubEnv(PROXY_APPLIED_ENV, "1"); + vi.spyOn(httpApi, "supported").mockReturnValue(false); + const spawn = vi.spyOn(spawner, "spawnSync"); + + expect(applyEnvProxy().action).toBe("already-active"); + expect(spawn).not.toHaveBeenCalled(); + }); + + test("falls back to a re-exec when setGlobalProxyFromEnv throws", () => { + vi.stubEnv("HTTPS_PROXY", "http://p:8080"); + vi.spyOn(httpApi, "supported").mockReturnValue(true); + vi.spyOn(httpApi, "setGlobalProxyFromEnv").mockImplementation(() => { + throw new Error("nope"); + }); + vi.spyOn(process.stderr, "write").mockReturnValue(true); + const spawn = vi + .spyOn(spawner, "spawnSync") + // biome-ignore lint/suspicious/noExplicitAny: minimal SpawnSyncReturns stub + .mockReturnValue({ status: 3 } as any); + + const result = applyEnvProxy(); + expect(result.action).toBe("reexec"); + expect(result.exitCode).toBe(3); + expect(spawn).toHaveBeenCalledOnce(); + }); + + // The warning has to survive every branch: a NO_PROXY exemption defeats the + // proxy no matter how (or whether) it was enabled. + test("warns about a backend NO_PROXY exemption even with no proxy set", () => { + vi.stubEnv("NO_PROXY", "*.viettel.vn"); + const result = applyEnvProxy(); + expect(result.action).toBe("none"); + expect(result.noProxyWarning).toContain("*.viettel.vn"); + expect(result.noProxyWarning).toContain("Login failed"); + }); + + test("warns about a backend NO_PROXY exemption on the applied path", () => { + vi.stubEnv("HTTPS_PROXY", "http://p:8080"); + vi.stubEnv("NO_PROXY", "*.viettel.vn"); + vi.spyOn(httpApi, "supported").mockReturnValue(true); + vi.spyOn(httpApi, "setGlobalProxyFromEnv").mockImplementation(() => {}); + const result = applyEnvProxy(); + expect(result.action).toBe("applied"); + expect(result.noProxyWarning).toContain("*.viettel.vn"); + }); +}); + +// Proxy URLs routinely carry credentials, and every place one is displayed — +// the check row, the per-request activity lines, the report file — is somewhere +// a user pastes into a ticket or a chat. +describe("credential masking", () => { + test.each([ + ["http://user:hunter2@10.0.0.1:8080", "http://user:***@10.0.0.1:8080"], + ["https://svc:p%40ss@proxy.corp:3128", "https://svc:***@proxy.corp:3128"], + // Nothing to mask — left byte-identical. + ["http://10.0.0.1:8080", "http://10.0.0.1:8080"], + ["http://user@10.0.0.1:8080", "http://user@10.0.0.1:8080"], + ])("%s → %s", (input, expected) => { + expect(maskProxyCredentials(input)).toBe(expected); + }); + + test("readProxyEnv keeps the real value — the retry child must authenticate", () => { + vi.stubEnv("HTTPS_PROXY", "http://user:hunter2@10.0.0.1:8080"); + expect(readProxyEnv().httpsProxy).toBe("http://user:hunter2@10.0.0.1:8080"); + }); +}); + +describe("proxyEnvSummary", () => { + // "unset" is an answer: most failures this command exists for are a + // *missing* variable, and a reader scanning for HTTP_PROXY should find it + // stated rather than infer its absence. + test("always lists the core variables, set or not", () => { + // Explicit env, not process.env: the package manager exports + // npm_config_registry for its own scripts, which would leak into an + // assertion about the exact list. + const summary = proxyEnvSummary({}); + expect(summary.map((v) => v.name)).toEqual([ + "HTTP_PROXY", + "HTTPS_PROXY", + "NO_PROXY", + "NODE_USE_ENV_PROXY", + "NODE_USE_SYSTEM_CA", + "NODE_EXTRA_CA_CERTS", + "NODE_TLS_REJECT_UNAUTHORIZED", + ]); + for (const v of summary) expect(v.value).toBeNull(); + }); + + // Explicit env, not process.env: on Windows environment variables are + // case-insensitive, so stubbing `http_proxy` there also answers to + // `HTTP_PROXY` and the two spellings cannot be told apart. + test("reports a set variable in the user's own spelling", () => { + const summary = proxyEnvSummary({ http_proxy: "http://10.0.0.1:8080" }); + // The lowercase spelling is appended; the uppercase stays listed as unset + // rather than silently absorbing the lowercase value. + expect(summary.find((v) => v.name === "http_proxy")?.value).toBe( + "http://10.0.0.1:8080", + ); + expect(summary.find((v) => v.name === "HTTP_PROXY")?.value).toBeNull(); + }); + + // readProxyEnv models a fixed set of fields; anything outside it was + // invisible, including the remedy our own TLS guidance hands out. + test("includes variables readProxyEnv does not model", () => { + const names = proxyEnvSummary({ + NODE_EXTRA_CA_CERTS: "/etc/ssl/corp.pem", + npm_config_registry: "http://mirror.internal/npm", + NODE_OPTIONS: "--max-old-space-size=4096", + }).map((v) => v.name); + expect(names).toEqual( + expect.arrayContaining([ + "NODE_EXTRA_CA_CERTS", + "npm_config_registry", + "NODE_OPTIONS", + ]), + ); + }); + + test("masks credentials in the reported values", () => { + const value = proxyEnvSummary({ + HTTPS_PROXY: "http://user:hunter2@10.0.0.1:8080", + }).find((v) => v.name === "HTTPS_PROXY")?.value; + expect(value).toBe("http://user:***@10.0.0.1:8080"); + }); + + test("an empty variable is reported as unset, not as a blank value", () => { + expect( + proxyEnvSummary({ HTTP_PROXY: " " }).find( + (v) => v.name === "HTTP_PROXY", + )?.value, + ).toBeNull(); + }); +}); + +describe("proxyForUrl", () => { + test("returns null when Node was never told to use the proxy", () => { + vi.stubEnv("HTTPS_PROXY", "http://10.0.0.1:8080"); + // NODE_USE_ENV_PROXY unset — the proxy is configured but ignored, so no + // request actually went through it and claiming otherwise would mislead. + expect(proxyForUrl("https://api.example.com/x")).toBeNull(); + }); + + test("picks the proxy matching the URL's scheme", () => { + vi.stubEnv("HTTP_PROXY", "http://plain:80"); + vi.stubEnv("HTTPS_PROXY", "http://secure:443"); + vi.stubEnv("NODE_USE_ENV_PROXY", "1"); + expect(proxyForUrl("https://api.example.com/x")).toBe("http://secure:443"); + expect(proxyForUrl("http://api.example.com/x")).toBe("http://plain:80"); + }); + + // The per-request view is the only place a NO_PROXY exemption becomes + // visible: one request quietly going direct while the rest are proxied. + test("returns null for a host exempted by NO_PROXY", () => { + vi.stubEnv("HTTPS_PROXY", "http://10.0.0.1:8080"); + vi.stubEnv("NODE_USE_ENV_PROXY", "1"); + vi.stubEnv("NO_PROXY", "*.viettel.vn"); + expect(proxyForUrl("https://netmind.viettel.vn/x")).toBeNull(); + expect(proxyForUrl("https://other.example.com/x")).toBe( + "http://10.0.0.1:8080", + ); + }); + + test("masks credentials", () => { + vi.stubEnv("HTTPS_PROXY", "http://user:hunter2@10.0.0.1:8080"); + vi.stubEnv("NODE_USE_ENV_PROXY", "1"); + expect(proxyForUrl("https://api.example.com/x")).toBe( + "http://user:***@10.0.0.1:8080", + ); + }); + + test("an unparseable URL is not attributed to a proxy", () => { + vi.stubEnv("HTTPS_PROXY", "http://10.0.0.1:8080"); + vi.stubEnv("NODE_USE_ENV_PROXY", "1"); + expect(proxyForUrl("not a url")).toBeNull(); + }); +}); + +/** + * Windows environment variables are case-insensitive, so `http_proxy` and + * `HTTP_PROXY` are one variable — but a plain `{...process.env, HTTP_PROXY: x}` + * yields an object holding both the user's original key (old value) and ours. + * Handing that to spawnSync leaves which one wins to chance, and a stale win + * means the proxy retry silently tests an address the user never typed. + */ +describe("child environment overrides", () => { + test("replaces the value and removes every other spelling", () => { + const env: NodeJS.ProcessEnv = { + http_proxy: "http://stale:1", + HTTP_Proxy: "http://also-stale:2", + PATH: "/usr/bin", + }; + overrideEnvVar(env, "HTTP_PROXY", "http://fresh:8080"); + expect(envVarKeys(env, "HTTP_PROXY")).toEqual(["HTTP_PROXY"]); + expect(env.HTTP_PROXY).toBe("http://fresh:8080"); + // Unrelated variables survive untouched. + expect(env.PATH).toBe("/usr/bin"); + }); + + test("setting a variable that is not present just adds it", () => { + const env: NodeJS.ProcessEnv = {}; + overrideEnvVar(env, "NODE_USE_ENV_PROXY", "1"); + expect(env).toEqual({ NODE_USE_ENV_PROXY: "1" }); + }); + + test("envVarKeys finds every spelling and nothing else", () => { + const env: NodeJS.ProcessEnv = { + NO_PROXY: "a", + no_proxy: "b", + NOT_A_PROXY: "c", + }; + expect(envVarKeys(env, "NO_PROXY").sort()).toEqual([ + "NO_PROXY", + "no_proxy", + ]); + expect(envVarKeys(env, "MISSING")).toEqual([]); + }); +});