Spawn the package manager the way Windows needs (AP-1) - #114
Merged
Merged
Conversation
On Windows `pnpm` is `pnpm.cmd`, and Node refuses to exec a `.cmd` directly (CVE-2024-27980, `EINVAL`). Every child `lt dev` starts went through a bare `spawn(pm.bin, …)` with no shell, so **nothing started there at all** — which is why none of the other Windows findings could even be measured. `platform.ts` has solved this since 1.47.0 with cross-spawn, and no `dev-*` module imported any of it. This wires it up. - `platform.ts#spawnCmd` — the asynchronous counterpart of the existing `spawnCmdSync`. `detached` and `stdio` pass through, so callers keep their process group and their log-file descriptors. - `dev-process.ts` — `runChildInherit`, `runChildToFile` and `spawnDetached` now go through it. `lsof` and `curl` deliberately stay as they are; they belong to AP-2 and AP-3. - `dev-test-session.ts#ensurePlaywrightBrowsers` and `dev-ticket.ts#installWorktreeDeps` — the two `execFileSync(pm.bin, …)` sites. - `caddy.ts#runCaddy` — one shape for every tool spawn. - `dev-service.ts#resolveCaddyBin` — `findExecutable` instead of spawning `which`, which does not exist on Windows (and `where.exe` prints every match, not the first). The async signature is kept; callers await it. - `doctor.ts` — spawns `process.execPath` rather than a bare `node`. The CLI can run from a Node that is not on PATH at all, and a different `node` on PATH would answer for a different runtime. ## The part that is not mechanical **`execFileSync` throws on a non-zero exit; `spawnSync` does not.** Both migrated sites depended on the throw, so both now check `status` explicitly: - `installWorktreeDeps` must keep throwing — `lt ticket start` catches and warns, and swallowing the failure would hand the developer a half-installed worktree with no hint that anything went wrong. - `ensurePlaywrightBrowsers` must keep logging — otherwise the suite dies later on a missing browser, which reads as a broken spec rather than a missing install. Pinned by tests: `spawnCmdSync` returning `status: 3` without throwing, and `installWorktreeDeps` throwing on a non-zero exit. Reverting the status check turns the latter red. (`result.error` is `null`, not `undefined`, when nothing went wrong at the spawn level — the assertion says so, because a call site that tests `=== undefined` would be wrong.) ## Also fixed here because the message is read at exactly the wrong moment `installService` said "Install with `brew install caddy` (macOS) or your package manager (Linux)" on every platform. It now names something that exists per platform — `winget`/`scoop` on Windows. A user reads that line when nothing works yet; advice for someone else's operating system is worse than none. The test that covered "caddy not on PATH" drove the old `which` spawn through the injected `ShellRunner`. That seam is gone, so `installService` takes an optional `lookup: FindExecutableOptions` and the test injects the file probe instead — `platform.ts`'s own injection model, the same one that keeps the Windows branches assertable from macOS. Tests: 77 suites / 1175 tests (`git-commands` and `dev-service-e2e` excluded: real network, real launchctl — see CLAUDE.md). tsc and eslint clean; the `perfectionist/sort-modules --fix` diff was read rather than trusted. Verified against the compiled artifact, not just ts-node: `tsconfig.json` sets `allowSyntheticDefaultImports` but not `esModuleInterop`, and `cross-spawn` has no `.default` — so a default import could have emitted `undefined` at runtime while tsc stayed green. It emits `__importStar`, which sets `default` to the module itself; `build/lib/platform.js` spawns a child correctly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N8cvaEziSrKGHv3Jcp59JH
DKoenig9
marked this pull request as ready for review
September 22, 2026 08:10
This was referenced Sep 22, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
AP-1 of the
lt devWindows port (inventory:windows-support/cli-lt-dev-windows.md). It comes first because nothing else can be measured until it lands.The defect
On Windows
pnpmispnpm.cmd, and Node refuses to exec a.cmddirectly (CVE-2024-27980,EINVAL). Every childlt devstarts went through a barespawn(pm.bin, …)with no shell — eleven call sites acrossup,test,dev-test-sessionanddev-api-launch.So on Windows nothing started at all, which is why none of the other findings in the inventory could even be reproduced there.
src/lib/platform.tshas solved exactly this since 1.47.0 (cross-spawn,findExecutable), and nodev-*module imported any of it. CLAUDE.md already says so: "Thelt devspawn sites … anddev-service.ts#resolveCaddyBinare not migrated yet." This is that migration.What changed
platform.tsspawnCmd— the async counterpart ofspawnCmdSync.detachedandstdiopass through, so callers keep their process group and log-file descriptorsdev-process.tsrunChildInherit,runChildToFile,spawnDetachedroute through itdev-test-session.ts,dev-ticket.tsexecFileSync(pm.bin, …)sitescaddy.tsrunCaddy— one shape for every tool spawndev-service.tsresolveCaddyBinusesfindExecutableinstead of spawningwhich(absent on Windows;where.exeprints every match, not the first)doctor.tsprocess.execPath, not a barenodelsofandcurlare deliberately untouched — they are AP-2 and AP-3.The part that is not mechanical
execFileSyncthrows on a non-zero exit.spawnSyncdoes not. Both migrated sites depended on the throw, so both now checkstatusexplicitly:installWorktreeDepsmust keep throwing. Its only caller,lt ticket start, catches and warns. Swallowing the failure would hand the developer a half-installed worktree with no hint anything went wrong.ensurePlaywrightBrowsersmust keep logging. Otherwise the suite dies later on a missing browser — which reads as a broken spec rather than a missing install.Both pinned by tests. Reverting the status check in
installWorktreeDepsturns its test red. One detail the assertion states explicitly:result.errorisnull, notundefined, when nothing went wrong at the spawn level — a call site testing=== undefinedwould be wrong.Verified against the compiled artifact, not just ts-node
tsconfig.jsonsetsallowSyntheticDefaultImportsbut notesModuleInterop, andcross-spawnhas no.defaultproperty. A default import could therefore have emittedcross_spawn_1.default—undefinedat runtime — while tsc stayed green. It does not: tsc emits__importStar, which setsdefaultto the module itself. Checked by compiling and runningbuild/lib/platform.js, which spawns a child correctly.This is the same class as the
help-export trap already in CLAUDE.md, and tsc cannot catch either.Also fixed, because of when the message is read
installServicesaid "Install withbrew install caddy(macOS) or your package manager (Linux)" on every platform. It now names something that exists per platform (winget/scoopon Windows). A user reads that line at the moment nothing works yet; advice for someone else's operating system is worse than none.The test covering "caddy not on PATH" drove the old
whichspawn through the injectedShellRunner. That seam is gone, soinstallServicetakes an optionallookup: FindExecutableOptionsand the test injects the file probe instead —platform.ts's own injection model, the one that keeps Windows branches assertable from macOS.Checks
tsc --noEmitandnpm run lintclean. Theperfectionist/sort-modules --fixdiff was read, not trusted — the moved function kept its JSDoc.git-commandsanddev-service-e2eexcluded — real network, real launchctl; see CLAUDE.md).statuscheck ininstallWorktreeDepsturns its test red. (A first attempt at that mutation producedTests: 0 total— an unused-variable compile error, the trap CLAUDE.md documents. Redone by mutating the body only.)Draft until the block is reviewed as a whole.
🤖 Generated with Claude Code
https://claude.ai/code/session_01N8cvaEziSrKGHv3Jcp59JH