Skip to content

bug(ui): Fetch jobs now fails with "spawn pnpm ENOENT" — 0/13 sources start #50

@ogarciarevett

Description

@ogarciarevett

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

  1. Install pupila as a non-developer would (Node + corepack-shim, or pnpm via Volta/fnm without a stable shim on PATH).
  2. 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).
  3. Click Fetch jobs now.
  4. Observe Run failed, 0/13 sources started, spawn pnpm ENOENT.

Suggested fix directions

Pick one (or stack):

  1. 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',
    });
  2. If we keep a package-manager wrapper, detect the available one at startup and prefer in this order: pnpmbunnpm. 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.');
  3. 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.

  4. 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:156options.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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions