Skip to content

Spawn the package manager the way Windows needs (AP-1) - #114

Merged
DKoenig9 merged 1 commit into
mainfrom
feat/windows-dev-spawn
Sep 22, 2026
Merged

DKoenig9 merged 1 commit into
mainfrom
feat/windows-dev-spawn

Conversation

@DKoenig9

Copy link
Copy Markdown
Contributor

AP-1 of the lt dev Windows 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 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 — eleven call sites across up, test, dev-test-session and dev-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.ts has solved exactly this since 1.47.0 (cross-spawn, findExecutable), and no dev-* module imported any of it. CLAUDE.md already says so: "The lt dev spawn sites … and dev-service.ts#resolveCaddyBin are not migrated yet." This is that migration.

What changed

File Change
platform.ts New spawnCmd — the async counterpart of spawnCmdSync. detached and stdio pass through, so callers keep their process group and log-file descriptors
dev-process.ts runChildInherit, runChildToFile, spawnDetached route through it
dev-test-session.ts, dev-ticket.ts the two execFileSync(pm.bin, …) sites
caddy.ts runCaddy — one shape for every tool spawn
dev-service.ts resolveCaddyBin uses findExecutable instead of spawning which (absent on Windows; where.exe prints every match, not the first)
doctor.ts spawns process.execPath, not a bare node

lsof and curl are deliberately untouched — they are AP-2 and AP-3.

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. 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.
  • ensurePlaywrightBrowsers must 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 installWorktreeDeps turns its test red. One detail the assertion states explicitly: result.error is null, not undefined, when nothing went wrong at the spawn level — a call site testing === undefined would be wrong.

Verified against the compiled artifact, not just ts-node

tsconfig.json sets allowSyntheticDefaultImports but not esModuleInterop, and cross-spawn has no .default property. A default import could therefore have emitted cross_spawn_1.defaultundefined at runtime — while tsc stayed green. It does not: tsc emits __importStar, which sets default to the module itself. Checked by compiling and running build/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

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 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 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 one that keeps Windows branches assertable from macOS.

Checks

  • tsc --noEmit and npm run lint clean. The perfectionist/sort-modules --fix diff was read, not trusted — the moved function kept its JSDoc.
  • Jest: 77 suites / 1175 tests (git-commands and dev-service-e2e excluded — real network, real launchctl; see CLAUDE.md).
  • Mutation: dropping the status check in installWorktreeDeps turns its test red. (A first attempt at that mutation produced Tests: 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

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
DKoenig9 marked this pull request as ready for review September 22, 2026 08:10
@DKoenig9
DKoenig9 merged commit d1a5c08 into main Sep 22, 2026
2 checks passed
@DKoenig9
DKoenig9 deleted the feat/windows-dev-spawn branch September 22, 2026 08:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant