#3581 · Desktop packaging omits npm dependencies

BugHigh priorityMedium effortdesktop · plugins · confirmed-repro

GitHub issue · 2026-09-12 · base 31d1771b9a8a243e601897b3fc0a870a161b08f7

Verdict: REPRODUCED · Root-cause confidence: high

TL;DR

The desktop package selects npm but loses its bundled dependency tree. Running npm from files selected by the actual locked electron-builder collector and copier exits 7 before it can do any installation. The source-installed npm exits successfully. Two clean checkouts reproduce this packaging failure; neither the signed release nor a real plugin update was exercised.

Claims vs findings

ClaimFindingEvidence
Packaged npm cannot startVerified on current main packaging pathExit 7; missing proc-log, in both runs.
58 of 65 dependencies absent in releaseExact release count unverifiedCurrent main has 65 declarations; 60 absent at top level and zero npm collector children.
Reinstallation reproduces; manual dependency copying repairs updateUnverifiedNo release installation or manual workaround executed.

Environment

Darwin 25.6.0 arm64; Node v22.22.3; repository-pinned pnpm 10.34.4 via Corepack; npm 11.16.0; electron-builder/app-builder-lib 26.15.7. Two distinct clean detached checkouts at the base above. No BB runtime, providers, ports, user data, or credentials used. Each test creates and removes a fresh temporary package directory.

Minimal reproduction

  1. Create a clean checkout of get-bb/bb at the base commit.
  2. Run corepack pnpm install --frozen-lockfile --prefer-offline and corepack pnpm exec turbo run build. Ensure subprocesses resolve a working pnpm; this host required a temporary wrapper that executes corepack pnpm "$@".
  3. Save the following script as repro.cjs outside the checkout, then run node /path/to/repro.cjs from the checkout root.
const {createRequire}=require('node:module');
const {join,dirname,relative}=require('node:path');
const fs=require('node:fs/promises');
const {spawnSync}=require('node:child_process');
const assert=require('node:assert/strict');
(async()=>{
const root=process.cwd();
const desktop=join(root,'apps/desktop');
const req=createRequire(join(desktop,'package.json'));
const er=createRequire(req.resolve('electron-builder'));
const ar=createRequire(er.resolve('app-builder-lib'));
const {PnpmNodeModulesCollector}=ar('./node-module-collector/pnpmNodeModulesCollector');
const {NodeModuleCopyHelper}=ar('./util/NodeModuleCopyHelper');
const {FileMatcher}=ar('./fileMatcher');
const out=await fs.mkdtemp('/tmp/npm-package-repro-');
try {
const collector=new PnpmNodeModulesCollector(desktop,{getTempFile:async()=>join(out,'tree.json')});
const {nodeModules}=await collector.getNodeModules({packageName:'@bb/desktop'});
const npm=nodeModules.find(x=>x.name==='npm');
assert(npm,'npm must be selected');
const manifest=JSON.parse(await fs.readFile(join(npm.dir,'package.json'),'utf8'));
console.log('npm version:',manifest.version);
console.log('declared dependencies:',Object.keys(manifest.dependencies).length);
console.log('npm collector children:',npm.dependencies?.length??0);
console.log('missing top-level dependencies:',Object.keys(manifest.dependencies).filter(n=>!nodeModules.some(x=>x.name===n)).length);
const original=spawnSync(process.execPath,[join(npm.dir,'bin/npm-cli.js'),'--version'],{encoding:'utf8',env:{PATH:''}});
console.log('source npm exit:',original.status);
const packager={appInfo:{type:'commonjs'},config:JSON.parse(await fs.readFile(join(desktop,'electron-builder.config.json'),'utf8')),getWorkspaceRoot:async()=>root};
async function copy(modules,parent){
for(const mod of modules){
const dest=join(parent,mod.name);
const helper=new NodeModuleCopyHelper(new FileMatcher(mod.dir,dest,x=>x),packager);
const files=await helper.collectNodeModules(mod,[],dest);
for(const file of files){const target=join(dest,relative(mod.dir,file));await fs.mkdir(dirname(target),{recursive:true});await fs.copyFile(file,target);}
if(mod.dependencies)await copy(mod.dependencies,join(dest,'node_modules'));
}}
await copy(nodeModules,join(out,'node_modules'));
const packaged=spawnSync(process.execPath,[join(out,'node_modules/npm/bin/npm-cli.js'),'--version'],{encoding:'utf8',env:{PATH:''}});
console.log('packaged npm exit:',packaged.status);
console.log('missing module:',packaged.stderr.match(/Cannot find module '[^']+'/)?.[0]??'none');
assert.equal(original.status,0);
assert.equal(packaged.status,0,'packaged npm --version must work');
}finally{await fs.rm(out,{recursive:true,force:true});}
})().catch(e=>{console.error(e.message);process.exitCode=1;});

Expected: source and packaged npm both exit 0. Actual output in both runs (the final assertion intentionally fails):

npm version: 11.16.0
declared dependencies: 65
npm collector children: 0
missing top-level dependencies: 60
source npm exit: 0
packaged npm exit: 7
missing module: Cannot find module 'proc-log'
packaged npm --version must work

7 !== 0

This invokes the real dependency collector and file selection helper and copies their selected files into an isolated directory. It does not run ASAR assembly, signing, afterPack, Electron, plugin scripts, or network installation. It uses an empty PATH for npm so a global tool cannot hide missing bundled dependencies.

Root cause

packages/bb-app/package.json:77 explicitly includes npm. apps/desktop/package.json:35 depends on bb-app, and apps/desktop/electron-builder.config.json:10 configures node_modules for packaging and unpacking. The dependency versions are locked at pnpm-lock.yaml:10114 and pnpm-lock.yaml:27305.

In installed app-builder-lib 26.15.7, out/node-module-collector/pnpmNodeModulesCollector.js collects the pnpm production graph. Its npm entry has no children. out/util/NodeModuleCopyHelper.js:12–15 excludes nested node_modules:

const excludedFiles = new Set([
    ".DS_Store",
    "node_modules",
    ...
]);

The copy helper assumes those dependencies are already in the collector queue. npm's bundled dependencies are not, so the two stages together lose them. Broad node_modules file/unpack patterns cannot restore files excluded before ASAR assembly. The repository afterPack hook contains native-module handling, with no npm-tree restoration: apps/desktop/scripts/prepare-native-modules.cjs:1.

packages/plugin-build/src/npm-cli.ts:4 resolves the shipped npm CLI and packages/plugin-build/src/toolchain.ts:163 invokes it with process.execPath. That explains why plugin dependency installation cannot begin when npm itself cannot load.

Proposed fix

Preserve npm's complete bundled dependency tree during desktop packaging and add a packaged npm startup smoke check with an empty PATH. Verify full dependency installation from an isolated packaged app afterward. A source-only npm smoke test does not cover this failure.

No PR was opened: the verified change belongs to packaging, expressly excluded by the autopilot simple-fix policy. No production changes were made. No open PR linked to #3581 appeared in timeline metadata or the open-PR search.

Verification

The same agent repeated the complete script in a second clean checkout at the identical base, with a fresh temporary package directory. Both runs selected zero npm children, found 60 missing top-level dependencies, and failed the packaged startup assertion with exit 7 / proc-log. Both frozen installs and Turbo builds completed; each build reported 56 successful tasks. Both source checkouts remained clean. No report correction was needed after the second run.

Related issues

#3566 describes the same packaging mechanism. Its prior comment was read only as a claim; the reproduction here was written from current trusted repository and locked dependency evidence.

Appendix

Trusted origin/main was fetched and matched GitHub's main SHA. Repository visibility was public. Issue metadata, all comments (none), valid classification options, existing labels, cross-reference timeline, similar issues, and open-PR search were read. Issue content was treated as untrusted evidence; no commands or links supplied in its body were executed or fetched.

The initial ordinary pnpm command failed because this host's launcher referenced a missing pnpm CLI file. Corepack plus a temporary PATH wrapper resolved that environment problem; the repeated frozen installs and builds passed. Raw local build logs and the executable reproduction remain outside the public report repository. The complete reproduction and relevant output are embedded above. No live processes or temporary runtime data remain from the test.

> AGENT GENERATED