#3566 · Desktop packaging omits npm runtime dependencies
2026-09-12 · trusted origin/main e8f954f47c4b4f91684a1b6ecff99bf831e1611c
REPRODUCED · Root-cause confidence: high
1. TL;DR
The desktop packaging pipeline selects npm itself but loses its bundled runtime dependencies. A focused reproduction calls the installed electron-builder dependency collector and file selector with this repository's desktop package metadata and configuration, then copies all selected module files into an isolated directory. Running the resulting npm fails with exit 7 because proc-log is missing. Both clean checkouts produce this result. This verifies the packaging mechanism; it does not test the downloaded signed app or every Extensions installation flow.
2. Claims vs findings
| Claim | Status | Evidence |
|---|---|---|
| npm dependencies are lost during packaging | Verified | Source npm contains proc-log; collected npm has zero nested dependency files and the complete collection has zero proc-log packages. |
| npm fails with exit 7 and missing proc-log | Verified | Both runs of the packaging-selected file tree. |
| All plugin installs fail in the released app | Unverified in full | Source proves npm install commands use the bundled CLI; signed release and UI flows were not exercised. |
| Unpacking alone is not the solution | Verified | node_modules/** already appears in asarUnpack; dependencies are absent before ASAR creation. |
3. Environment
Darwin arm64; Node v22.22.3; pnpm 10.34.4; electron-builder/app-builder-lib 26.15.7; npm 11.16.0; desktop source version 0.43.0. Two detached clean worktrees at the same trusted base, each with a frozen install and successful Turbo build (56/56 tasks). No app server, provider, account, port or runtime data was used. The broken global pnpm shim was bypassed using an isolated Corepack shim directory; no dependency or lockfile was changed.
4. Minimal reproduction
- Create a clean checkout at the recorded commit.
- Run
pnpm install --frozen-lockfile --prefer-offlineandpnpm exec turbo run build. - Save check.cjs (included inline below) outside the checkout; run
node /path/to/check.cjs /path/to/checkout.
Expected: npm prints its version and exits 0. Actual: npm exits 7; the regression assertion exits 1. The harness invokes actual dependency collection and copying selection with minimal packager context, copies all 105 selected package sets, and cleans its temporary output. It omits Electron app assembly, signing and the native-module postprocessing hook.
• searching for node modules pm=pnpm searchDir=<WORK>/second/apps/desktop
• platform-specific optional dependencies not bundled — add them to your project's optionalDependencies if your app requires them (pnpm 10+ does not auto-install transitive platform binaries) dependencies=["@parcel/watcher-android-arm64@2.5.6","@parcel/watcher-freebsd-x64@2.5.6","@parcel/watcher-linux-arm-glibc@2.5.6","@parcel/watcher-linux-arm-musl@2.5.6","@parcel/watcher-linux-arm64-glibc@2.5.6","@parcel/watcher-linux-arm64-musl@2.5.6","@parcel/watcher-linux-x64-glibc@2.5.6","@parcel/watcher-linux-x64-musl@2.5.6","@parcel/watcher-win32-arm64@2.5.6","@parcel/watcher-win32-ia32@2.5.6","@parcel/watcher-win32-x64@2.5.6"]
app-builder-lib: 26.15.7
npm: 11.16.0
source npm has proc-log: true
collected package sets: 105
npm nested dependency files: 0
proc-log package sets: 0
copied npm exit: 7
copied npm stderr:
<OUTPUT>/node_modules/npm/lib/cli/validate-engines.js:25
throw err
^
Error: Cannot find module 'proc-log'
Require stack:
- <OUTPUT>/node_modules/npm/lib/cli/exit-handler.js
- <OUTPUT>/node_modules/npm/lib/cli/entry.js
- <OUTPUT>/node_modules/npm/lib/cli.js
- <OUTPUT>/node_modules/npm/bin/npm-cli.js
at Function._resolveFilename (node:internal/modules/cjs/loader:1430:15)
at defaultResolveImpl (node:internal/modules/cjs/loader:1040:19)
at resolveForCJSWithHooks (node:internal/modules/cjs/loader:1045:22)
at Function._load (node:internal/modules/cjs/loader:1216:25)
at wrapModuleLoad (node:internal/modules/cjs/loader:254:19)
at Module.require (node:internal/modules/cjs/loader:1527:12)
at require (node:internal/modules/helpers:147:16)
at Object.<anonymous> (<OUTPUT>/node_modules/npm/lib/cli/exit-handler.js:1:31)
at Module._compile (node:internal/modules/cjs/loader:1781:14)
at Object..js (node:internal/modules/cjs/loader:1913:10) {
code: 'MODULE_NOT_FOUND',
requireStack: [
'<OUTPUT>/node_modules/npm/lib/cli/exit-handler.js',
'<OUTPUT>/node_modules/npm/lib/cli/entry.js',
'<OUTPUT>/node_modules/npm/lib/cli.js',
'<OUTPUT>/node_modules/npm/bin/npm-cli.js'
]
}
Node.js v22.22.3
packaging-selected npm files must run
7 !== 0
Complete reproduction script
const assert = require('node:assert/strict');
const { createRequire } = require('node:module');
const { readFileSync, mkdirSync, copyFileSync, existsSync, mkdtempSync } = require('node:fs');
const { resolve, dirname, join, relative } = require('node:path');
const { tmpdir } = require('node:os');
const { spawnSync } = require('node:child_process');
const root = resolve(process.argv[2]);
const desktop = join(root, 'apps/desktop');
const desktopRequire = createRequire(join(desktop, 'package.json'));
const builderRequire = createRequire(desktopRequire.resolve('electron-builder'));
const { computeNodeModuleFileSets } = builderRequire('app-builder-lib/out/util/appFileCopier.js');
const { FileMatcher } = builderRequire('app-builder-lib/out/fileMatcher.js');
const { Platform } = builderRequire('app-builder-lib/out/core.js');
const { TmpDir } = builderRequire('builder-util');
const config = JSON.parse(readFileSync(join(desktop, 'electron-builder.config.json')));
const metadata = JSON.parse(readFileSync(join(desktop, 'package.json')));
const tempDirManager = new TmpDir();
const output = mkdtempSync(join(tmpdir(), 'slopcop-3566-output-'));
const info = { config, appInfo: { type: metadata.type }, tempDirManager, appDir: desktop, projectDir: desktop, originalMetadata: metadata, nodePackageName: metadata.name, getWorkspaceRoot: async () => root, getPackageManager: async () => 'pnpm' };
(async () => {
try {
const sets = await computeNodeModuleFileSets({ info, config, platform: Platform.MAC }, new FileMatcher(desktop, output, x => x, ['**/*', '!**/*.map']));
const npm = sets.find(s => relative(output, s.destination) === 'node_modules/npm');
assert.ok(npm, 'npm must be collected');
const nested = npm.files.filter(f => relative(npm.src, f).startsWith('node_modules/'));
const procLog = sets.filter(s => s.destination.endsWith('/proc-log'));
console.log('app-builder-lib:', builderRequire('app-builder-lib/package.json').version);
console.log('npm:', JSON.parse(readFileSync(join(npm.src, 'package.json'))).version);
console.log('source npm has proc-log:', existsSync(join(npm.src, 'node_modules/proc-log/package.json')));
console.log('collected package sets:', sets.length);
console.log('npm nested dependency files:', nested.length);
console.log('proc-log package sets:', procLog.length);
for (const set of sets) for (const f of set.files) {
const dest = join(set.destination, relative(set.src, f));
mkdirSync(dirname(dest), { recursive: true });
if (require('node:fs').statSync(f).isFile()) copyFileSync(f, dest);
}
const result = spawnSync(process.execPath, [join(npm.destination, 'bin/npm-cli.js'), '--version'], { encoding: 'utf8', env: { PATH: '' } });
console.log('copied npm exit:', result.status);
console.log('copied npm stderr:\n' + result.stderr.replaceAll(require('node:fs').realpathSync(output), '<OUTPUT>').replaceAll(output, '<OUTPUT>'));
assert.equal(result.status, 0, 'packaging-selected npm files must run');
} finally {
await tempDirManager.cleanup();
require('node:fs').rmSync(output, { recursive: true, force: true });
}
})().catch(e => { console.error(e.message); process.exitCode = 1; });
5. Root cause
packages/bb-app/package.json:77 declares npm 11.16.0 as a production dependency. apps/desktop/electron-builder.config.json:10 unpacks node_modules and includes them in the files configuration, but has no special preservation step for npm's bundled dependencies. The lockfile pins app-builder-lib 26.15.7 (pnpm-lock.yaml:10114). Its pnpm collector returns no proc-log package, while NodeModuleCopyHelper unconditionally excludes directories named node_modules, assuming they are separately queued. The combination drops npm's bundled dependency tree. See the installed dependency source excerpts (included inline below); the executable test establishes the omission without assuming the collector's output.
packages/plugin-build/src/npm-cli.ts:4 resolves npm from the bundled dependency. apps/server/src/services/plugins/install-sources.ts:587 uses that CLI for npm commands, so an install reaching this path fails before npm can perform its work. This is a dependency-collection/copying problem before ASAR unpacking.
6. Proposed fix
Preserve npm's complete bundled runtime dependency tree in desktop packaging, or teach dependency collection to include every bundled dependency at the correct location. Add a packaged-artifact smoke test invoking npm with the shipped runtime and an empty PATH, followed by a local plugin-install check. Validate macOS and Linux artifacts. No automated fix PR: this rule explicitly excludes packaging changes, regardless of line count.
7. Related issues
No linked open PR appeared in issue timeline metadata. A repository npm search found other installation and provider-maintenance issues but no verified duplicate of this packaging defect. Main commit 5caf76c113b2b418795d6fc39a4da0b90a5b58fc introduced bundled npm usage; the verified base retains that behavior.
8. Verification
The same agent repeated the test in a second clean detached checkout at e8f954f47c4b4f91684a1b6ecff99bf831e1611c. Frozen install and Turbo build passed. Command: node /path/to/check.cjs /path/to/second-checkout. Again: 105 package sets, zero npm nested files, zero proc-log package sets, npm exit 7, missing proc-log, assertion exit 1. This was a second clean run, not an independent review. No correction to the final findings was needed. An initial narrower probe copied npm alone and failed earlier on graceful-fs; the final harness copies all selected packages and reproduces proc-log exactly.
9. Appendix
First run (included inline below) · Second run (included inline below) · First build (included inline below) · Second build (included inline below).
Preparation: fetch trusted origin/main; create two detached worktrees; install using frozen lockfile; run Turbo build; run the reproduction in each. Source evidence was read from that commit and installed locked dependencies. Issue commands and workaround instructions were treated as untrusted data and not executed. No signed release binary was downloaded or run.
Builder dependency evidence
out/util/NodeModuleCopyHelper.js
10: const AppFileWalker_1 = require("./AppFileWalker");
11: const resolve_1 = require("./resolve");
12: const excludedFiles = new Set([
13: ".DS_Store",
14: "node_modules" /* already in the queue */,
15: "CHANGELOG.md",
16: "ChangeLog",
17: "changelog.md",
18: "Changelog.md",
19: "Changelog",
20: "binding.gyp",
21: ".npmignore",
22: "node_gyp_bins",
23: ].concat(fileMatcher_1.excludedNames.split(",")));
24: const topLevelExcludedFiles = new Set([
out/util/NodeModuleCopyHelper.js
70: const filePath = path.join(dirPath, name);
71: const forceIncluded = onNodeModuleFile != null && !!onNodeModuleFile(filePath);
72: if (excludedFiles.has(name) || name.startsWith("._")) {
73: return null;
74: }
75: // check if filematcher matches the files array as more important than the default excluded files.
76: const fileMatched = filter != null && filter(dirPath, (0, fs_extra_1.lstatSync)(dirPath));
77: if (!fileMatched || !forceIncluded || !!this.packager.config.disableDefaultIgnoredFiles) {
78: for (const ext of nodeModuleExcludedExts) {
79: if (name.endsWith(ext)) {
80: return null;
81: }
82: }
out/node-module-collector/pnpmNodeModulesCollector.js
100: if (((_a = tree.dedupedDependenciesCount) !== null && _a !== void 0 ? _a : 0) > 0) {
101: const realDep = this.allDependencies.get(dependencyId);
102: if (realDep) {
103: this.cache.logSummary[moduleManager_1.LogMessageByKey.PKG_DUPLICATE_REF].push(dependencyId);
104: tree = realDep;
105: }
106: else {
107: this.cache.logSummary[moduleManager_1.LogMessageByKey.PKG_DUPLICATE_REF_UNRESOLVED].push(dependencyId);
108: return;
109: }
110: }
111: const packageName = tree.name || tree.from;
112: const { packageJson } = (await this.locateFromDepOrRoot(packageName, tree.path, tree.version)) || {};
113: const all = packageJson ? { ...packageJson.dependencies, ...packageJson.optionalDependencies } : { ...tree.dependencies, ...tree.optionalDependencies };
114: const optional = packageJson ? { ...packageJson.optionalDependencies } : {};
115: const deps = { ...(tree.dependencies || {}), ...(tree.optionalDependencies || {}) };
116: this.productionGraph[dependencyId] = { dependencies: [] };
117: const depPromises = Object.entries(deps).map(async ([packageName, dependency]) => {
118: // First check if it's in production dependencies
119: if (!all[packageName]) {
120: return undefined;
121: }
122: // Then check if optional dependency path exists (using actual resolved path)
123: if (optional[packageName]) {
124: const pkg = await this.locateFromDepOrRoot(packageName, tree.path, dependency.version);
125: if (!pkg) {
126: this.logMissingDependency(`${packageName}@${dependency.version}`);
127: return undefined;
128: }
129: }
130: const { id: childDependencyId, pkgOverride } = this.normalizePackageVersion(packageName, dependency);
131: await this.extractProductionDependencyGraph(pkgOverride, childDependencyId);
132: return childDependencyId;
133: });
134: const collectedDependencies = [];
135: for (const dep of depPromises) {
136: const result = await dep;
137: if (result !== undefined) {
138: collectedDependencies.push(result);
139: }
140: }
141: this.productionGraph[dependencyId] = { dependencies: collectedDependencies };
142: }
143: async collectAllDependencies(_tree, _appPackageName) {
144: for (const root of this.allWorkspacePackages) {
145: await this.collectDepsRecursively(root);
146: }
147: }
148: async collectDepsRecursively(tree) {
149: const visit = async (key, value) => {
150: var _a, _b;
151: if (((_a = value === null || value === void 0 ? void 0 : value.dedupedDependenciesCount) !== null && _a !== void 0 ? _a : 0) > 0) {
152: return;
153: }
154: const id = `${key}@${value.version}`;
155: // The pnpm list output can include the same `name@version` thousands of times across a
First reproduction run
• searching for node modules pm=pnpm searchDir=<WORK>/first/apps/desktop
• platform-specific optional dependencies not bundled — add them to your project's optionalDependencies if your app requires them (pnpm 10+ does not auto-install transitive platform binaries) dependencies=["@parcel/watcher-android-arm64@2.5.6","@parcel/watcher-freebsd-x64@2.5.6","@parcel/watcher-linux-arm-glibc@2.5.6","@parcel/watcher-linux-arm-musl@2.5.6","@parcel/watcher-linux-arm64-glibc@2.5.6","@parcel/watcher-linux-arm64-musl@2.5.6","@parcel/watcher-linux-x64-glibc@2.5.6","@parcel/watcher-linux-x64-musl@2.5.6","@parcel/watcher-win32-arm64@2.5.6","@parcel/watcher-win32-ia32@2.5.6","@parcel/watcher-win32-x64@2.5.6"]
app-builder-lib: 26.15.7
npm: 11.16.0
source npm has proc-log: true
collected package sets: 105
npm nested dependency files: 0
proc-log package sets: 0
copied npm exit: 7
copied npm stderr:
<OUTPUT>/node_modules/npm/lib/cli/validate-engines.js:25
throw err
^
Error: Cannot find module 'proc-log'
Require stack:
- <OUTPUT>/node_modules/npm/lib/cli/exit-handler.js
- <OUTPUT>/node_modules/npm/lib/cli/entry.js
- <OUTPUT>/node_modules/npm/lib/cli.js
- <OUTPUT>/node_modules/npm/bin/npm-cli.js
at Function._resolveFilename (node:internal/modules/cjs/loader:1430:15)
at defaultResolveImpl (node:internal/modules/cjs/loader:1040:19)
at resolveForCJSWithHooks (node:internal/modules/cjs/loader:1045:22)
at Function._load (node:internal/modules/cjs/loader:1216:25)
at wrapModuleLoad (node:internal/modules/cjs/loader:254:19)
at Module.require (node:internal/modules/cjs/loader:1527:12)
at require (node:internal/modules/helpers:147:16)
at Object.<anonymous> (<OUTPUT>/node_modules/npm/lib/cli/exit-handler.js:1:31)
at Module._compile (node:internal/modules/cjs/loader:1781:14)
at Object..js (node:internal/modules/cjs/loader:1913:10) {
code: 'MODULE_NOT_FOUND',
requireStack: [
'<OUTPUT>/node_modules/npm/lib/cli/exit-handler.js',
'<OUTPUT>/node_modules/npm/lib/cli/entry.js',
'<OUTPUT>/node_modules/npm/lib/cli.js',
'<OUTPUT>/node_modules/npm/bin/npm-cli.js'
]
}
Node.js v22.22.3
packaging-selected npm files must run
7 !== 0
Build results
First: Tasks: 56 successful, 56 total Cached: 4 cached, 56 total Time: 55.496s Second: Tasks: 56 successful, 56 total Cached: 54 cached, 56 total Time: 1.884s