Symptom
A non-technical user (recruiter trying pupila for the first time) clicked Fetch jobs now and watched every source fail immediately. The progress dock shows:
X Run failed 0/13
remoteok ·
remotive ·
weworkremotely ·
cryptojobslist ·
web3career ·
aijobsnet ·
hn-hiring ·
hn-jobs ·
greenhouse ·
ashby ·
lever ·
aave ·
ashby-private ·
spawn pnpm ENOENT
Every source stays at the pending · marker — none ever transition to running/done/fail, so the spawn itself died before a single fetcher started. Screenshot attached (Telegram).
What the reporter thought was happening
Paraphrased from the feedback:
I think that's a bug when the user has the CLI installed but is not maybe authenticated OR doesn't have the pro plan to use it.
A reasonable guess from a non-technical reporter, but this one isn't about the LLM CLI (claude/codex/gemini) — those aren't invoked during the fetch step. Documenting the hypothesis here so we can also improve the on-screen error message to disambiguate (see fix #4 below).
Actual root cause
spawn pnpm ENOENT from child_process.spawn means the pnpm binary isn't on the PATH inherited by the spawned child. The Fetch jobs now endpoint at ui/plugins/fetchJobs.ts:128 shells out to:
const proc = spawn('pnpm', ['exec', 'tsx', 'src/index.ts'], {
cwd: REPO_ROOT,
env: process.env,
stdio: 'pipe',
});
spawn(...) resolves 'pnpm' against process.env.PATH. Common ways this fails for non-developers:
- Node was installed but
pnpm came in via corepack / Volta / fnm shims that aren't on the PATH of the process that launched pnpm run ui (e.g. launched via a desktop shortcut, IDE task, or a shell that didn't source the rc adding the shim).
- Global pnpm install prefix isn't on PATH (
~/.local/share/pnpm, etc.).
- Windows:
pnpm resolves to pnpm.cmd, which spawn(...) without shell: true won't find.
The dock currently surfaces err.message verbatim, which is opaque to a non-technical user.
Reproduce
- Install pupila as a non-developer would (Node + corepack-shim, or pnpm via Volta/fnm without a stable shim on PATH).
- Launch
pnpm run ui from an environment where which pnpm succeeds, but where the child-process PATH doesn't include the pnpm location (e.g. via launchd, a desktop shortcut, or some IDE task runners — anything that bypasses the interactive shell rc).
- Click Fetch jobs now.
- Observe
Run failed, 0/13 sources started, spawn pnpm ENOENT.
Suggested fix directions
Pick one (or stack):
-
Drop the package-manager wrapper entirely (preferred). tsx is already a direct dep at node_modules/.bin/tsx. Spawn it via process.execPath + the tsx CLI module (or just the .bin/tsx shim) so the fetch is independent of any package manager being on PATH. Standalone, deterministic, and the most robust against ENOENT surprises.
// sketch — resolve once at startup, reuse for every fetch run.
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const tsxCli = require.resolve('tsx/cli'); // direct dep, always present
const proc = spawn(process.execPath, [tsxCli, 'src/index.ts'], {
cwd: REPO_ROOT, env: process.env, stdio: 'pipe',
});
-
If we keep a package-manager wrapper, detect the available one at startup and prefer in this order: pnpm → bun → npm. npm last and only as a last resort — its default postinstall/preinstall execution model is the weakest link in the supply chain (pnpm 11 already runs with minimumReleaseAge: 1d + strictDepBuilds: true for that exact reason, see root CLAUDE.md; bun has tighter defaults too). Fail fast at server boot with a clear message if none of the three resolve, instead of letting the first Fetch jobs now click die opaquely. Sketch:
// Resolve once at boot. Cache the absolute path so PATH drift mid-session can't break us.
const RUNNERS = [
{ cmd: 'pnpm', args: ['exec', 'tsx', 'src/index.ts'] },
{ cmd: 'bun', args: ['x', 'tsx', 'src/index.ts'] }, // bun x resolves local bins
{ cmd: 'npm', args: ['exec', '--', 'tsx', 'src/index.ts'] }, // last-resort, security caveat above
] as const;
const runner = await pickFirstResolvable(RUNNERS); // uses `which`/`where`, caches the absolute path
if (!runner) throw new Error('No package manager found on PATH — install pnpm (preferred), bun, or npm.');
-
Spawn with shell: true — cheapest, also fixes the Windows pnpm.cmd case. Slower and less explicit; only meaningful if we also keep the package-manager wrapper.
-
Improve the dock error message. When proc.on('error') fires with an ENOENT, surface a remediation hint instead of the raw err.message. Something like "<cmd> wasn't found on the server's PATH — restart the UI from a shell where which <cmd> succeeds, or see [troubleshooting]." This also stops confused non-technical users mis-attributing the failure to their LLM CLI subscription.
File pointers
ui/plugins/fetchJobs.ts:128 — the failing spawn('pnpm', ...) call.
ui/plugins/clean.ts:56 — same pattern, same exposure to this bug.
src/lib/fetch-runner.ts:156 — options.command ?? 'pnpm' default; revisit alongside.
Source
Telegram feedback from a recruiter trying pupila on a candidate (a Solidity engineer ex-AAVE — was testing the web3 reverse-engineering tags across Ashby and other sources).
Symptom
A non-technical user (recruiter trying pupila for the first time) clicked Fetch jobs now and watched every source fail immediately. The progress dock shows:
Every source stays at the pending
·marker — none ever transition torunning/done/fail, so the spawn itself died before a single fetcher started. Screenshot attached (Telegram).What the reporter thought was happening
Paraphrased from the feedback:
A reasonable guess from a non-technical reporter, but this one isn't about the LLM CLI (claude/codex/gemini) — those aren't invoked during the fetch step. Documenting the hypothesis here so we can also improve the on-screen error message to disambiguate (see fix #4 below).
Actual root cause
spawn pnpm ENOENTfromchild_process.spawnmeans thepnpmbinary isn't on the PATH inherited by the spawned child. TheFetch jobs nowendpoint atui/plugins/fetchJobs.ts:128shells out to:spawn(...)resolves'pnpm'againstprocess.env.PATH. Common ways this fails for non-developers:pnpmcame in via corepack / Volta / fnm shims that aren't on the PATH of the process that launchedpnpm run ui(e.g. launched via a desktop shortcut, IDE task, or a shell that didn't source the rc adding the shim).~/.local/share/pnpm, etc.).pnpmresolves topnpm.cmd, whichspawn(...)withoutshell: truewon't find.The dock currently surfaces
err.messageverbatim, which is opaque to a non-technical user.Reproduce
pnpm run uifrom an environment wherewhich pnpmsucceeds, but where the child-process PATH doesn't include the pnpm location (e.g. via launchd, a desktop shortcut, or some IDE task runners — anything that bypasses the interactive shell rc).Run failed, 0/13 sources started,spawn pnpm ENOENT.Suggested fix directions
Pick one (or stack):
Drop the package-manager wrapper entirely (preferred).
tsxis already a direct dep atnode_modules/.bin/tsx. Spawn it viaprocess.execPath+ the tsx CLI module (or just the.bin/tsxshim) so the fetch is independent of any package manager being on PATH. Standalone, deterministic, and the most robust againstENOENTsurprises.If we keep a package-manager wrapper, detect the available one at startup and prefer in this order:
pnpm→bun→npm. npm last and only as a last resort — its defaultpostinstall/preinstallexecution model is the weakest link in the supply chain (pnpm 11 already runs withminimumReleaseAge: 1d+strictDepBuilds: truefor that exact reason, see rootCLAUDE.md; bun has tighter defaults too). Fail fast at server boot with a clear message if none of the three resolve, instead of letting the first Fetch jobs now click die opaquely. Sketch:Spawn with
shell: true— cheapest, also fixes the Windowspnpm.cmdcase. Slower and less explicit; only meaningful if we also keep the package-manager wrapper.Improve the dock error message. When
proc.on('error')fires with anENOENT, surface a remediation hint instead of the rawerr.message. Something like "<cmd>wasn't found on the server's PATH — restart the UI from a shell wherewhich <cmd>succeeds, or see [troubleshooting]." This also stops confused non-technical users mis-attributing the failure to their LLM CLI subscription.File pointers
ui/plugins/fetchJobs.ts:128— the failingspawn('pnpm', ...)call.ui/plugins/clean.ts:56— same pattern, same exposure to this bug.src/lib/fetch-runner.ts:156—options.command ?? 'pnpm'default; revisit alongside.Source
Telegram feedback from a recruiter trying pupila on a candidate (a Solidity engineer ex-AAVE — was testing the web3 reverse-engineering tags across Ashby and other sources).