Skip to content

fix(cli): propagate NODE_PATH to the generated service, not just PATH - #13

Draft
andromarces wants to merge 1 commit into
rjx18:mainfrom
andromarces:fix/service-node-path-propagation
Draft

fix(cli): propagate NODE_PATH to the generated service, not just PATH#13
andromarces wants to merge 1 commit into
rjx18:mainfrom
andromarces:fix/service-node-path-propagation

Conversation

@andromarces

@andromarces andromarces commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Problem

pnpm's .bin shim sets NODE_PATH to include the hidden hoist directory (node_modules/.pnpm/node_modules) before invoking Node -- that's what lets the CLI resolve modules like bogon's undeclared b4a dependency (a separate upstream issue, not fixed here). The generated per-platform service bypasses the shim entirely and invokes Node directly, propagating only PATH. Module resolution then has no fallback, which is fatal specifically when normal ancestor-directory resolution cannot reach the hidden hoist dir -- the pnpm global-virtual-store layout, where the resolved module's realpath sits inside the store rather than under the project tree. There the service dies immediately with Cannot find module 'b4a', while the CLI itself, launched through the shim, works fine. This is what installer step 4/5 surfaces as Codor did not become ready at http://127.0.0.1:8137. (The fix is a harmless no-op for other pnpm layouts, where module resolution already succeeds without it.)

Fix

All three platform generators (Windows service script, launchd plist, systemd environment file) now emit NODE_PATH alongside PATH, derived the same way the entrypoint already is:

  • Probe the source, emit the destination. installDurableRuntime may copy the invoking runtime's node_modules wholesale to a durable location (~/.codor/runtime) before the service ever runs, so the hoist directory is checked for existence at the source install root (which exists at render time) but the value emitted is rooted at the destination (serviceLocation) -- the same split setup.ts already makes for the entrypoint path at setup.ts:667-668.
  • When no hoist directory is found (npm/yarn installs, or any layout without one), no NODE_PATH line is emitted at all -- a no-op for non-pnpm layouts.
  • Escaping follows each generator's existing convention: PowerShell single-quote doubling, XML entity escaping, and the systemd env file's existing bare-value convention (matching how PATH= is already written there).

Testing

  • packages/cli/src/setup-win32.spec.ts: added a case creating a .pnpm/node_modules hoist dir under the fixture's checkout and asserting the rendered service script emits $env:NODE_PATH, plus negative assertions on the two existing cases (no hoist dir present -> no NODE_PATH line).
  • packages/cli/src/index.spec.ts: updated the systemd dry-run inline snapshot to include the new NODE_PATH= line (this repo's own checkout is pnpm-linked, so the real hoist dir is detected); added positive assertions to the real systemd-write and macOS dry-run/install tests (both of which also run against this real checkout); added an explicit negative assertion to the existing npx-cache case, whose stubbed installIo reports no hoist dir at the source.
  • The case that actually proves the source/destination distinction: every assertion above uses a durable source checkout, where installSource.installRoot and serviceLocation happen to be the same path -- so none of them alone would catch a source/destination swap in the derivation. Added a dedicated case (plain it, runs on every platform) sibling to the npx-cache test, with its own installIo stub reporting the source hoist dir present while an ephemeral runtime is mid-copy to ~/.codor/runtime, asserting NODE_PATH lands on the durable copy and contains no trace of the ephemeral source path. Verified this case is load-bearing by temporarily inverting the probe/emit paths locally (probe the destination, emit the source) and confirming it fails against this exact committed test -- the destination doesn't exist yet for a fresh install, so no NODE_PATH is emitted at all -- then reverting and confirming it passes again.
  • Gate run on Windows 11 (branched from unpatched origin/main at bc47942): pnpm install --frozen-lockfile and pnpm -r build both succeed. cd packages/cli && pnpm exec vitest run (direct invocation -- pnpm test cannot spawn Vitest on Windows before the separate Windows-portability PR lands) shows 177 passed, 18 skipped, 5 failed -- the same 5 pre-existing baseline failures tracked separately, no others.

What could not be executed here

The remaining index.spec.ts assertions this PR touches (the real systemd-write test and the macOS dry-run/install test) are inside posixHostIt-guarded tests, skipped on Windows because they assert exact POSIX permission-bit values (statSync(...).mode) that NTFS does not track -- unrelated to this change. To verify the derivation logic itself on Windows without that unrelated skip, I exercised the same two code paths (real systemd write, real darwin dry-run render) against throwaway scratch copies of those tests using plain it, confirmed each behaves as expected, then discarded the scratch files -- they are not part of this PR, and those two specific assertions were not executed by CI in this session. A Linux or macOS run is needed to confirm them directly. (The ephemeral-runtime case above, and the win32 case, are not in this category -- both run as plain tests and were verified directly, no scratch copy involved.)

No real end-to-end service run was performed. I did not remove a source runtime, let a service start against the durable copy, and confirm it actually resolves b4a in practice. Everything above verifies the derivation logic (the right path is computed and emitted); it does not verify the running service. That end-to-end check is a manual one a reviewer would need to perform.

The launchd and systemd paths are otherwise unverified on macOS/Linux beyond the above. NODE_PATH is consulted by CommonJS require() resolution only, not ESM import -- this repairs the present CommonJS-based failure and any layout that fails the same way, not a general linker-independent guarantee.

Notes for review

  • serviceLocation's durability currently depends on isEphemeralRuntime correctly classifying the invoking runtime (a separate, already-open PR extends that classifier to pnpm's dlx cache). Neither fix subsumes the other: that PR decides whether serviceLocation resolves to ~/.codor/runtime or a transient cache; this PR is what makes NODE_PATH follow serviceLocation either way.
  • This is a process-lifecycle change to the generated service environment, so I've opened it as a draft pending review of the two touched assumptions below.
  • This branch's single commit is the result of a squash (three review-round commits combined, with corrected wording) followed by a force-push; the resulting tree is unchanged from before the squash.

Touches windows-setup-installs-private-task-service (setup.ts:556) and setup-preserves-private-platform-service (setup.ts:636), both active. No .harn/assumptions/ files were edited directly.

pnpm's .bin shim sets NODE_PATH to include the hidden hoist directory
before invoking Node -- that's what lets the CLI resolve modules like
bogon's undeclared b4a dependency. The generated service bypasses the
shim and calls Node directly, so it inherited none of that. Module
resolution then has no fallback specifically when normal ancestor
resolution cannot reach the hidden hoist dir -- the pnpm global-virtual-
store layout, where the resolved module's realpath sits inside the
store rather than under the project tree -- and the service died with
Cannot find module 'b4a'. The fix is a harmless no-op on other pnpm
layouts, where resolution already succeeds without it.

Probe the source install root (which exists at render time) for a
node_modules/.pnpm/node_modules hoist dir, and if present emit NODE_PATH
rooted at the service's eventual location (the durable copy when the
invoking runtime is ephemeral, the source root otherwise) into all
three platform generators: the Windows service script, the launchd
plist, and the systemd environment file.

Every prior committed assertion used a durable source checkout, where
the source and destination roots are the same path, so a source/
destination swap in the derivation would have passed unnoticed. Added a
dedicated case with its own installIo stub reporting the source hoist
dir present while an ephemeral runtime is mid-copy, asserting NODE_PATH
lands on the durable copy and contains no trace of the ephemeral
source. Verified this case is load-bearing by temporarily inverting the
probe/emit paths and confirming it fails, then reverting and confirming
it passes again.

Claude-Session: https://claude.ai/code/session_01RjMaDN6VbJP9pdbCY6u3Hk
@andromarces
andromarces force-pushed the fix/service-node-path-propagation branch from e83f7ae to d0648b3 Compare July 26, 2026 04:35
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