fix(make-pdf): Windows .exe resolution for browse + pdftotext#1118
fix(make-pdf): Windows .exe resolution for browse + pdftotext#1118scarson wants to merge 3 commits intogarrytan:mainfrom
Conversation
`resolveBrowseBin()` probed bare paths (`browse`, `$selfDir/../browse/dist/browse`, `~/.claude/skills/gstack/browse/dist/browse`) with `fs.accessSync(path, X_OK)`. On Windows, bun's `--compile --outfile browse/dist/browse` lands the artifact at `browse/dist/browse.exe`, and `fs.constants.X_OK` is degraded to an existence check (Node docs: "On Windows, the file system accessibility checks can behave differently. For example, changing visibility using chmod does not update read and write permissions... Only the presence of the read-only attribute is reflected."). So every probe returned false even though the binary was present, and the tool errored with `browse binary not found` unless the user set `BROWSE_BIN=/c/.../dist/browse.exe` explicitly. Fix: a new exported helper `findExecutable(base)` returns the resolved path, probing `.exe`/`.cmd`/`.bat` suffixes on win32. Every call site in `resolveBrowseBin` now routes through it. The POSIX path is semantically unchanged — `isExecutable(base)` runs first and returns the base as-is when it exists, so Linux/macOS callers see no behavior change. Also: the final PATH fallback ran `which browse`, which exists under Git Bash but not in cmd.exe or PowerShell. On win32 we now shell out to `where` (always in `System32`, so it resolves without Git Bash in PATH). POSIX still uses `which`. Regression tests cover the three moving parts: the `findExecutable` helper directly (baseline, missing path, `.exe` probe, `.cmd`/`.bat` probe), and the `resolveBrowseBin` env-override path on win32 with a base path that needs `.exe`. The pre-existing "honors BROWSE_BIN" test hardcoded `/bin/sh` and always failed on win32 — made cross-platform with `cmd.exe` as the Windows probe target, so Windows CI (currently commented out in .github/workflows/make-pdf-gate.yml) won't trip on this when enabled. Verified end-to-end on win32: with BROWSE_BIN unset, `bun run make-pdf/src/cli.ts generate in.md out.pdf` now resolves `browse.exe` via the global path probe and produces a valid 31KB PDF. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Same root cause as the previous commit, smaller blast radius. The pdftotext wrapper only runs in the copy-paste CI gate and unit tests (never in production renders), so Windows users don't hit it at runtime — but anyone running `bun test make-pdf/` on Windows with poppler installed via Scoop or Chocolatey and `PDFTOTEXT_BIN=C:\tools\poppler\bin\pdftotext` set would have fallen through to `PdftotextUnavailableError` because the on-disk artifact is `pdftotext.exe`. Changes mirror `browseClient.ts`: 1. File-local `findExecutable(base)` helper that probes `.exe`/`.cmd`/`.bat` on win32. Duplicated rather than shared with browseClient to keep the existing module-independence convention (both files already duplicate `isExecutable`). 2. `which pdftotext` PATH lookup becomes `where pdftotext` on win32, so users running tests from cmd.exe or PowerShell (no Git Bash in PATH) still get PATH resolution. 3. `macCandidates` renamed `posixCandidates` — it was always a POSIX-only set (`/opt/homebrew/bin`, `/usr/local/bin`, `/usr/bin`). No Windows candidates added: Poppler on Windows scatters across Scoop, Chocolatey, oschwartz10612/poppler-windows releases, and portable zips — guessing causes false positives. Users with poppler outside PATH set the env var, which now works. 4. Error message picks up a Windows install hint: `scoop install poppler` + the oschwartz10612/poppler-windows link. Regression test: env-override probe with a base path that needs `.exe`, guarded to win32 with early-return on POSIX. Empty `.exe` is sufficient because `describeBinary()` catches the non-zero exit from `bin -v` and records `version: "unknown"` — the test asserts only on the resolved path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adopt the platform-aware error-message UX from @BkashJEE's independent concurrent fix (garrytan#1094). On Windows, `export FOO=...` is the wrong syntax — users want `setx FOO "..."` for a persistent environment variable in cmd.exe/PowerShell, or `$env:FOO = "..."` for session-local PowerShell. `setx` is the closest one-liner equivalent and matches what Bikash proposed. Also adds `Platform: <process.platform>` to both error messages — tiny but useful for bug reports, especially given both fix sites (browse and pdftotext) have subtle platform-specific behavior. Credit: @BkashJEE for the `setx` hint shape in garrytan#1094. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Heads-up for reviewers: @BkashJEE got to this diagnosis first in #1094 (opened 2026-04-20, one day before this PR). Same core root cause — Node's What this PR adds beyond #1094:
Just adopted the Genuinely fine either way on consolidation. If @garrytan prefers to land #1094 as the primary (narrower diff, landed first), happy to rebase the pdftotext + tests + cross-platform test fix as a follow-on PR on top. This matches the #1024 pattern where two concurrent fixes converged and the consolidating PR credited both contributors. |
|
For context: opened #1122 today ("Boiling the Windows CI Lake") proposing a Phase-1 Windows smoke CI that would have caught this class of bug at PR time. This PR is cited in the RFC's coverage table as one of the five recent examples that motivated it — not asking for anything on this PR, it can merge on its own merits. Cross-linking because the "Why this shipped unnoticed" paragraph above reads differently with a systemic answer in flight. Also a companion issue at #1123 for higher-level discussion if the PR's review thread gets crowded. |
Summary
On Windows,
make-pdf generatefails withbrowse binary not foundeven after a successful./setup. The resolution logic inmake-pdf/src/browseClient.tsprobes bare paths (browse,$selfDir/../browse/dist/browse,~/.claude/skills/gstack/browse/dist/browse), butbun build --compile --outfile browse/dist/browseemitsbrowse/dist/browse.exeon Windows, so every probe misses.Repro (Windows, bun 1.3.11, gstack v1.5.1.0, Node v25+):
Workaround the user has today:
export BROWSE_BIN=/c/.../browse/dist/browse.exe. Fix in this PR: the resolver itself probes.exeon win32, so the workaround is no longer needed.Root cause
Two independent issues compound:
1.
fs.constants.X_OKis degraded to existence-checking on Windows. Per the Node.js docs onfs.accessSync:So
fs.accessSync('/path/to/browse', X_OK)on Windows checks whether a file literally namedbrowse(no extension) exists. When bun emitsbrowse.exe, the check returns false. The localisExecutable()helper inherits this semantic and returns false for every candidate.2. bun
--compile --outfileappends.exeon Windows unconditionally. The build command inpackage.jsontargetsbrowse/dist/browse/make-pdf/dist/pdf/design/dist/design:On Windows, bun silently appends
.exe(the PE format requires it forCreateProcessto locate the entrypoint). Confirmed on a fresh Windows checkout:There are no files named
browse,pdf, ordesignin those directories — only their.exevariants.3. The
which browsePATH fallback doesn't help native Windows.whichships with Git Bash (MSYS2) but not with cmd.exe or PowerShell. A user running the compiledpdf.exefrom PowerShell doesn't havewhichin PATH, soexecFileSync("which", ...)throws ENOENT, the catch block swallows it, and we fall through to thebrowse binary not founderror.Why this shipped unnoticed
.github/workflows/make-pdf-gate.yml:25:No gstack workflow runs on Windows — not
make-pdf-gate, notevals, notci-image. This PR doesn't enable Windows CI (the tolerance-mode question the comment flags still needs a separate call), but it is a prerequisite — as-is, binary resolution would ERR out before pdftotext comparisons even started. A pre-existing test inbrowseClient.test.tsthat hardcoded/bin/shwould also have broken Windows CI immediately; this PR makes that test cross-platform.Scope
Three sites in the repo currently probe executable paths with
fs.accessSync(p, X_OK):make-pdf/src/browseClient.tsresolveBrowseBinmake-pdf/src/pdftotext.tsresolvePdftotextbun teston Windows, not runtime renders)browse/src/security.tsfindTelemetryBinary#!/usr/bin/env bash), not a compiled.exe. Windows has a different blocker there (shebang scripts cannot beCreateProcess-ed directly).The fix
A file-local
findExecutable(base)helper in each affected module. On win32, after the bare-path probe fails, it probes.exe,.cmd,.batsuffixes. On POSIX,isExecutable(base)is load-bearing and the Windows branch never runs, so Linux/macOS behavior is bit-identical to the old code.Exported from
browseClient.ts(it is useful beyond internal resolution — any future site that probes an executable should route through it). Duplicated (not shared) inpdftotext.tsto match the existingisExecutableduplication pattern across the two modules.Secondary change: the PATH fallback uses
whereon win32,whichon POSIX.whereships inC:\Windows\System32\on every Windows install, soexecFileSync("where", ...)works from cmd.exe, PowerShell, AND Git Bash.Tertiary change: pdftotext's
macCandidatesis renamedposixCandidates(it was always POSIX-only —/opt/homebrew/bin,/usr/local/bin,/usr/bin). No Windows candidates added — Poppler on Windows scatters across Scoop, Chocolatey, oschwartz10612/poppler-windows releases, and portable zips; guessing causes false positives. Users setPDFTOTEXT_BINinstead, which now works. The not-found error picks up a Windows install hint:Test plan
bun test make-pdf/test/browseClient.test.ts make-pdf/test/pdftotext.test.tson win32 — 21 pass, 0 fail (including 5 new Windows-specific assertions that early-return on POSIX)bun test make-pdf/test/on win32 — 80 pass, 1 skip (e2e test needs built binary), 0 failunset BROWSE_BIN && bun run make-pdf/src/cli.ts generate /tmp/in.md /tmp/out.pdfon win32 produces a valid 31KB PDF using the patchedresolveBrowseBinagainst the pre-existingbrowse.exeresolveBrowseBin > honors BROWSE_BIN when it points at a real executablemade cross-platform (was hardcoded/bin/sh, now usescmd.exeon Windows). This would have been a Windows-CI blocker.isExecutable(base)is the first probe and short-circuits before the Windows-only extension loop./pair-agent/ OAuth flows not exercised — unchanged, no touchpoint with this PR's surface area.Commits
705996b—fix(make-pdf): resolve bun-compiled .exe binaries on Windows(browseClient + its test)f04bbd1—fix(make-pdf): apply .exe extension probe to pdftotext resolver(pdftotext + its test)Atomic: each commit is independently reviewable and leaves the test suite green on POSIX. Commit 1 is the user-visible fix (what the failing render hit); commit 2 is the matching fix for the same root cause in a less-exercised code path.
What this PR does not do
make-pdf-gate.ymlmatrix comment about pdftotext extraction tolerance on Windows is a separate question that still needs a call.findTelemetryBinaryinbrowse/src/security.ts. Different bug (shebang-script invocation on Windows), different fix, separate PR if it ever becomes user-facing.fs.accessSynccall. That one checks a directory for reachability, not executability — unrelated.🤖 Generated with Claude Code