#3584 · Desktop packaging drops npm runtime dependencies

Bug · High priority · Medium effort · desktop · plugins · 2026-09-12

GitHub issue · Base 3b37d2790d084a47c96eb78267da5d159598f203

Verdict: PARTIALLY REPRODUCED · Root-cause confidence: high for the shared packaging mechanism.

1. TL;DR

The desktop dependency collection and file-copy components discard npm's own bundled dependencies. Running npm from the resulting staged package tree exits 7 because proc-log cannot be loaded, before any plugin installation begins. Both clean checkouts produced the same result. This verifies the shared packaging failure on trusted main; the Linux AppImage and plugin UI were not executed on this macOS host.

2. Claims vs findings

ClaimFindingEvidence
Bundled npm exits 7 for missing proc-log.Verified at component levelReal collector and file-copy helper produce a staged tree whose npm exits 7 in both runs.
60 of 65 direct npm runtime dependencies are absent.Verified in collected graph65 declared, 60 absent; proc-log exists in source npm but is absent from the collected graph.
Linux AppImage 0.43.0 and Community install UI fail.Unverified directlyNo release binary or external attachment was fetched or run. Host is Darwin arm64.
System npm does not change the installer command.Verified in sourceInstaller resolves npm from the package and invokes it with process.execPath.
The npm smoke check did not run or block Linux release.Not establishedThe tarball check tests a different packaging path. Main's Linux release job contains desktop smoke steps; their historical run outcome was not inspected.

3. Environment

Public repository get-bb/bb; trusted origin/main matched GitHub's default branch at the full commit above. Darwin arm64, Node v22.22.3, repository-pinned pnpm 9.15.0 via Corepack, electron-builder/app-builder-lib 26.15.7, npm 11.16.0. No provider, server, port, user data directory, or browser was used. Each test created and removed a fresh temporary package staging directory.

Frozen installs completed in both checkouts. Full Turbo builds passed: first run 56/56 tasks (4 cached); second 56/56 (54 cached). The machine's normal pnpm launcher was broken; a temporary wrapper invoked the existing Corepack pnpm and was placed first on PATH. No repository dependency or lockfile was changed.

4. Minimal reproduction

  1. Create a clean checkout at the recorded commit.
  2. Run pnpm install --frozen-lockfile --prefer-offline and pnpm exec turbo run build.
  3. Save the inline test below outside the checkout. Run node /path/to/collector.cjs /path/to/checkout with the working pnpm on PATH.

The test invokes the actual installed PnpmNodeModulesCollector and NodeModuleCopyHelper with the desktop's checked-in configuration, stages their selected files and starts staged npm with an empty PATH and isolated HOME. It does not invoke Electron's final archive builder or execute native binaries.

Expected: npm exits 0 and prints its version. Actual: npm exits 7 and the test exits 1 at its runnable-npm assertion.

npm version: 11.16.0
declared dependencies: 65
dependencies absent from collected graph: 60
proc-log exists in source npm: true
proc-log collected: false
staged npm exit: 7
missing proc-log error: true
<isolated-stage>/node_modules/npm/lib/cli/validate-engines.js:25
    throw err
    ^

Error: Cannot find module 'proc-log'
Require stack:
- <isolated-stage>/node_modules/npm/lib/cli/exit-handler.js
- <isolated-stage>/node_modules/npm/lib/cli/entry.js
- <isolated-stage>/node_modules/npm/lib/cli.js
packaged dependency graph must retain a runnable npm

7 !== 0

Both runs produced the output above. The complete probe is inline below; raw evidence is retained locally.

Complete probe source
const { createRequire } = require('node:module');
const { resolve, dirname, relative, join } = require('node:path');
const { readFileSync, existsSync, mkdirSync, copyFileSync, mkdtempSync, rmSync } = require('node:fs');
const { tmpdir } = require('node:os');
const { spawnSync } = require('node:child_process');
const assert = require('node:assert/strict');
const root = resolve(process.argv[2]);
const desktop = join(root, 'apps/desktop');
const req = createRequire(join(desktop, 'package.json'));
const builder = createRequire(req.resolve('electron-builder'));
const lib = createRequire(builder.resolve('app-builder-lib'));
const { PnpmNodeModulesCollector } = lib('./node-module-collector/pnpmNodeModulesCollector');
const { NodeModuleCopyHelper } = lib('./util/NodeModuleCopyHelper');
const { FileMatcher } = lib('./fileMatcher');
const { TmpDir } = lib('temp-file');
const config = JSON.parse(readFileSync(join(desktop, 'electron-builder.config.json')));
const scratch = mkdtempSync(join(tmpdir(), 'issue-3584-collector-'));
const temp = new TmpDir();
(async () => {
  const collector = new PnpmNodeModulesCollector(desktop, temp);
  const { nodeModules } = await collector.getNodeModules({ packageName: '@bb/desktop' });
  const flat = [];
  const walk = (nodes) => { for (const n of nodes) { flat.push(n); walk(n.dependencies || []); } };
  walk(nodeModules);
  const npm = flat.find(n => n.name === 'npm');
  assert.ok(npm, 'collector includes npm');
  const manifest = JSON.parse(readFileSync(join(npm.dir, 'package.json')));
  const declared = Object.keys(manifest.dependencies);
  const missing = declared.filter(name => !flat.some(n => n.name === name));
  console.log('npm version:', manifest.version);
  console.log('declared dependencies:', declared.length);
  console.log('dependencies absent from collected graph:', missing.length);
  console.log('proc-log exists in source npm:', existsSync(join(npm.dir, 'node_modules/proc-log/package.json')));
  console.log('proc-log collected:', flat.some(n => n.name === 'proc-log'));
  const packager = { appInfo: { type: 'commonjs' }, config, getWorkspaceRoot: async () => root };
  async function stage(nodes, destination) {
    for (const n of nodes) {
      const target = join(destination, n.name);
      const helper = new NodeModuleCopyHelper(new FileMatcher(n.dir, target, x => x), packager);
      const files = await helper.collectNodeModules(n, ['.map'], target);
      for (const file of files) {
        const out = join(target, relative(n.dir, file));
        mkdirSync(dirname(out), { recursive: true });
        copyFileSync(file, out);
      }
      await stage(n.dependencies || [], join(target, 'node_modules'));
    }
  }
  await stage(nodeModules, join(scratch, 'node_modules'));
  const result = spawnSync(process.execPath, [join(scratch, 'node_modules/npm/bin/npm-cli.js'), '--version'], {
    cwd: scratch, env: { PATH: '', HOME: scratch }, encoding: 'utf8'
  });
  console.log('staged npm exit:', result.status);
  console.log('missing proc-log error:', result.stderr.includes("Cannot find module 'proc-log'"));
  console.log(result.stderr.split('\n').slice(0, 9).join('\n').replaceAll(scratch, '<isolated-stage>'));
  assert.equal(result.status, 0, 'packaged dependency graph must retain a runnable npm');
})().finally(async () => { await temp.cleanup(); rmSync(scratch, { recursive: true, force: true }); }).catch(error => {
  console.error(error.message);
  process.exitCode = 1;
});

5. Root cause

Desktop declares electron-builder and bb-app declares npm 11.16.0. The desktop configuration delegates dependency copying to electron-builder and unpacks node_modules, but unpacking cannot restore files that were never collected.

In locked app-builder-lib 26.15.7, PnpmNodeModulesCollector.getArgs uses list --prod --json --depth Infinity --silent --loglevel=error. Its graph extractor iterates dependency entries supplied by pnpm. npm's bundled packages are physically present but omitted from that graph. NodeModuleCopyHelper's excludedFiles set includes node_modules, so copying the npm package itself also omits its private dependency directory. The staged package consequently lacks proc-log; npm's exit-handler requires it during startup.

The installed dependency implementation was inspected locally from the frozen install, not taken from issue attachments. The collector and copier are executed by the probe rather than reimplemented. The probe uses a minimal packager context and stages ordinary files; final archive creation, Electron launch and AppImage behavior remain outside this test.

resolvePluginNpmCli selects the shipped npm CLI. runInstallCommand runs that CLI under the current executable, explaining why a working system npm is not selected.

The tarball smoke test checks npm after tarball installation, which preserves its bundled dependencies. The Linux release job runs packaged and lifecycle smoke tests, but the packaged smoke script contains no npm/plugin install assertion. A successful tarball smoke test therefore does not prove the Electron package retained npm's dependency tree.

6. Proposed fix

Preserve npm's complete bundled dependency tree during desktop packaging, and require npm startup plus a small offline dependency-install check against the final packaged artifact on each platform. Keep the package manager's private dependency versions intact. This requires packaging/release changes and therefore fails this rule's simple-fix eligibility boundary. No production fix branch or pull request was created.

7. Related issues

GitHub metadata for #3566 and #3581 classifies related reports as Bug with desktop/plugins areas and confirmed-repro labels. Their artifacts were not used as reproduction evidence. No open PR linked to #3584 appeared in the issue timeline or the open-PR search.

8. Verification

The same agent created a second clean detached worktree at the identical commit, performed a separate frozen install and Turbo build, then repeated the exact probe command with that second checkout as its argument. The second run again found 60/65 missing dependencies and proc-log absent, with npm exit 7 and the intended assertion failure. No finding needed correction. This is repeated verification by one agent, not an independent review. Partial verdict is retained because the Linux AppImage claim remains untested.

9. Appendix

Investigation commands: git fetch origin main; git rev-parse origin/main; GitHub default-branch/type/field/label/comment/timeline reads; git worktree add --detach for each clean checkout; frozen pnpm install; pnpm exec turbo run build; node collector.cjs with each checkout; targeted source reads of desktop configuration, collector/copier, installer and smoke workflows. Reproduction logs above contain only test evidence; machine-specific paths are normalized to <isolated-stage>.

Issue content and attachments were treated as untrusted claims. No issue-supplied command, patch, binary, branch or external link was executed or fetched. Both trusted worktrees remained unchanged in git status. Temporary staging directories were removed by the probe; no app process was started.

> AGENT GENERATED