Skip to content

Commit 22ae871

Browse files
committed
Fixed packaged desktop app backend crash by inlining JS deps and symlinking _modules
- Changed server tsdown config to bundle all dependencies except native addons (node-pty) and packages needing runtime require.resolve (@github/copilot-sdk) into a self-contained bin.mjs - Updated desktop artifact build to install only external deps via npm, then rename node_modules to _modules (electron-builder strips node_modules) - Added ensureBackendModulesSymlink() in desktop main process that creates a node_modules -> _modules symlink at runtime before spawning the backend (NODE_PATH does not work for ESM module resolution)
1 parent 703d43b commit 22ae871

4 files changed

Lines changed: 118 additions & 5 deletions

File tree

apps/desktop/src/backend/backendManager.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@ import {
66
captureBackendOutput,
77
writeBackendSessionBoundary,
88
} from "../logging/logging";
9-
import { resolveBackendCwd, resolveBackendEntry } from "../env/pathResolver";
9+
import {
10+
ensureBackendModulesSymlink,
11+
resolveBackendCwd,
12+
resolveBackendEntry,
13+
} from "../env/pathResolver";
1014
import type { RotatingFileSink } from "@bigcode/shared/logging";
1115
import { readPersistedBackendObservabilitySettings } from "../logging/logging";
1216

@@ -103,6 +107,10 @@ export function startBackend(): void {
103107
const backendLogSink = _deps.getBackendLogSink();
104108
const captureBackendLogs = backendLogSink !== null;
105109

110+
// Ensure _modules → node_modules symlink exists for ESM resolution of
111+
// external native packages (e.g. @github/copilot-sdk, node-pty).
112+
ensureBackendModulesSymlink();
113+
106114
// Always pipe stderr so we can capture crash output for diagnostics,
107115
// regardless of whether a log sink is configured.
108116
const child = ChildProcess.spawn(process.execPath, [backendEntry, "--bootstrap-fd", "3"], {

apps/desktop/src/env/pathResolver.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,48 @@ export function resolveBackendCwd(rootDir: string): string {
168168
return OS.homedir();
169169
}
170170

171+
/**
172+
* Ensures native/external modules are resolvable in packaged builds.
173+
*
174+
* electron-builder silently strips directories named `node_modules` from
175+
* extraResources copies. The build script works around this by renaming the
176+
* server's `node_modules` to `_modules`. At runtime we create a
177+
* `node_modules` → `_modules` **symlink** so that Node.js ESM resolution
178+
* (which walks up the directory tree looking for `node_modules/`) can find the
179+
* external packages normally.
180+
*
181+
* `NODE_PATH` is intentionally NOT used because Node.js ESM resolution ignores
182+
* it — only CJS honours `NODE_PATH`.
183+
*
184+
* No-ops in dev (modules resolve normally from the monorepo).
185+
*/
186+
export function ensureBackendModulesSymlink(): void {
187+
if (!app.isPackaged) return;
188+
189+
const serverDir = Path.join(process.resourcesPath, "server");
190+
const modulesDir = Path.join(serverDir, "_modules");
191+
const symlinkPath = Path.join(serverDir, "node_modules");
192+
193+
if (!FS.existsSync(modulesDir)) return;
194+
195+
// If the symlink already exists (e.g. from a previous launch) skip creation.
196+
try {
197+
const stat = FS.lstatSync(symlinkPath);
198+
if (stat.isSymbolicLink()) return;
199+
// Not a symlink — something unexpected. Remove and recreate.
200+
FS.rmSync(symlinkPath, { recursive: true, force: true });
201+
} catch {
202+
// Does not exist yet — good, we'll create it below.
203+
}
204+
205+
try {
206+
// Relative symlink so the .app bundle stays relocatable.
207+
FS.symlinkSync("_modules", symlinkPath, "dir");
208+
} catch (err) {
209+
console.error("[desktop] failed to create node_modules symlink:", err);
210+
}
211+
}
212+
171213
// ---------------------------------------------------------------------------
172214
// Desktop static asset resolution
173215
// ---------------------------------------------------------------------------

apps/server/tsdown.config.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,31 @@
11
import { defineConfig } from "tsdown";
22

3+
// Packages that MUST remain external and cannot be inlined into the bundle:
4+
// - node-pty: native C++ addon (.node binary + spawn-helper executable); also
5+
// resolved at runtime via createRequire/require.resolve in NodePTY.ts.
6+
// - @github/copilot-sdk, @github/copilot: CopilotAdapter.types.ts uses
7+
// require.resolve("@github/copilot-sdk") at runtime to locate the sibling
8+
// @github/copilot CLI entry point on disk.
9+
// - @effect/sql-sqlite-bun, @effect/platform-bun: Bun-only; wrap bun:sqlite
10+
// and Bun built-in APIs. Never loaded in an Electron/Node context.
11+
//
12+
// Everything else (effect, @effect/platform-node, @anthropic-ai/claude-agent-sdk,
13+
// @opencode-ai/sdk, @pierre/diffs, open, @bigcode/*) is inlined to produce a
14+
// self-contained bundle that does not require a node_modules tree at runtime.
15+
// This is critical for packaged desktop builds where the server runs under
16+
// Electron's Node.js via ELECTRON_RUN_AS_NODE=1.
17+
const EXTERNAL_PACKAGES = [
18+
"node-pty",
19+
"@github/copilot-sdk",
20+
"@github/copilot",
21+
"@effect/sql-sqlite-bun",
22+
"@effect/platform-bun",
23+
];
24+
25+
function isExternal(id: string): boolean {
26+
return EXTERNAL_PACKAGES.some((pkg) => id === pkg || id.startsWith(`${pkg}/`));
27+
}
28+
329
export default defineConfig({
430
entry: ["src/bin.ts"],
531
format: ["esm", "cjs"],
@@ -9,7 +35,9 @@ export default defineConfig({
935
outDir: "dist",
1036
sourcemap: true,
1137
clean: true,
12-
noExternal: (id) => id.startsWith("@bigcode/"),
38+
// Bundle ALL dependencies into the output except the explicitly external ones.
39+
// noExternal: true tells the bundler to inline everything by default.
40+
noExternal: (id) => !isExternal(id),
1341
inlineOnly: false,
1442
banner: {
1543
js: "#!/usr/bin/env node\n",

scripts/lib/desktop-artifact/build.ts

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,23 @@ import {
2525
} from "./resources.ts";
2626
import { resolveCatalogDependencies } from "../resolve-catalog.ts";
2727

28+
// Packages that are NOT inlined by tsdown and must be installed at runtime.
29+
// Must be kept in sync with EXTERNAL_PACKAGES in apps/server/tsdown.config.ts.
30+
// Bun-only externals (@effect/sql-sqlite-bun, @effect/platform-bun) are excluded
31+
// because they are never loaded in the Electron/Node.js desktop runtime.
32+
const SERVER_RUNTIME_EXTERNAL_PACKAGES = new Set(["node-pty", "@github/copilot-sdk"]);
33+
34+
/** Filter a dependency map to only include packages that are external at runtime. */
35+
function pickExternalDependencies(dependencies: Record<string, unknown>): Record<string, unknown> {
36+
const result: Record<string, unknown> = {};
37+
for (const [name, version] of Object.entries(dependencies)) {
38+
if (SERVER_RUNTIME_EXTERNAL_PACKAGES.has(name)) {
39+
result[name] = version;
40+
}
41+
}
42+
return result;
43+
}
44+
2845
export const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
2946
options: ResolvedBuildOptions,
3047
) {
@@ -123,6 +140,11 @@ export const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function*
123140
yield* assertPlatformBuildResources(options.platform, stageResourcesDir, options.verbose);
124141
yield* fs.copy(stageResourcesDir, path.join(stageAppDir, "apps/desktop/prod-resources"));
125142

143+
// The server bundle is self-contained (all JS dependencies inlined by tsdown).
144+
// Only packages that cannot be bundled (native addons, runtime require.resolve)
145+
// need to be installed in the staged server directory.
146+
const serverExternalDependencies = pickExternalDependencies(resolvedServerDependencies);
147+
126148
const stagePackageJson: StagePackageJson = {
127149
name: "bigcode-desktop",
128150
version: appVersion,
@@ -141,7 +163,7 @@ export const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function*
141163
options.mockUpdateServerPort,
142164
),
143165
dependencies: {
144-
...resolvedServerDependencies,
166+
...serverExternalDependencies,
145167
...resolvedDesktopRuntimeDependencies,
146168
},
147169
devDependencies: {
@@ -159,7 +181,7 @@ export const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function*
159181
type: serverPackageJson.type,
160182
bin: serverPackageJson.bin,
161183
files: serverPackageJson.files,
162-
dependencies: resolvedServerDependencies,
184+
dependencies: serverExternalDependencies,
163185
};
164186
const stageServerPackageJsonString = yield* encodeJsonString(stageServerPackageJson);
165187
yield* fs.writeFileString(
@@ -175,14 +197,27 @@ export const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function*
175197
shell: process.platform === "win32",
176198
})`bun install --production`,
177199
);
200+
// Use npm (not bun) for the server directory so node_modules follows the
201+
// standard flat layout that Node.js expects. Bun's symlink-based hoisting
202+
// does not survive electron-builder's file copy to extraResources.
178203
yield* runCommand(
179204
ChildProcess.make({
180205
cwd: stageServerDir,
181206
...commandOutputOptions(options.verbose),
182207
shell: process.platform === "win32",
183-
})`bun install --production`,
208+
})`npm install --production --no-optional`,
184209
);
185210

211+
// electron-builder silently strips node_modules from extraResources copies.
212+
// Rename to _modules so the directory survives into the packaged app.
213+
// The desktop main process sets NODE_PATH to _modules when spawning the
214+
// backend child process so Node.js can still resolve these external packages.
215+
const serverNodeModules = path.join(stageServerDir, "node_modules");
216+
const serverModulesRenamed = path.join(stageServerDir, "_modules");
217+
if (yield* fs.exists(serverNodeModules)) {
218+
yield* fs.rename(serverNodeModules, serverModulesRenamed);
219+
}
220+
186221
const buildEnv: NodeJS.ProcessEnv = { ...process.env };
187222
for (const [key, value] of Object.entries(buildEnv)) {
188223
if (value === "") {

0 commit comments

Comments
 (0)