fix(desktop): restore managed Node server startup (#348)

## Summary
- revert the Bun standalone desktop packaging path and restore the
server's original `dist/bin.js` bootstrap flow
- add a managed Node runtime for Electron and Tauri that downloads only
the current platform/arch artifact into `~/.config/codenomad`
- update desktop startup and packaging scripts so packaged apps use the
managed runtime consistently, and clean up Electron's expected
navigation-abort log noise

## Testing
- npm run typecheck --workspace @neuralnomads/codenomad-electron-app
- cargo check
- npm run build --workspace @neuralnomads/codenomad
- npm run build:mac --workspace @neuralnomads/codenomad-electron-app
- launch
`packages/electron-app/release/mac-arm64/CodeNomad.app/Contents/MacOS/CodeNomad`
and verify the packaged server reaches ready with the managed Node
runtime
This commit is contained in:
Shantur Rathore
2026-04-26 13:20:47 +01:00
committed by GitHub
parent e708c565ef
commit fd57bd11a6
28 changed files with 1962 additions and 2348 deletions

View File

@@ -116,10 +116,20 @@ function loadLoadingScreen(window: BrowserWindow) {
: window.loadFile(target.source)
loader.catch((error) => {
if (isIgnorableNavigationError(error)) {
return
}
console.error("[cli] failed to load loading screen:", error)
})
}
return loader
function isIgnorableNavigationError(error: unknown): boolean {
if (!error || typeof error !== "object") {
return false
}
const code = "code" in error ? String((error as { code?: unknown }).code ?? "") : ""
return code === "ERR_ABORTED" || code === "ERR_FAILED"
}
function getAllowedRendererOrigins(window?: BrowserWindow | null): string[] {
@@ -294,7 +304,7 @@ function createWindow() {
showingLoadingScreen = true
currentCliUrl = null
clearWindowAllowedOrigin(window)
const loadingReady = loadLoadingScreen(window)
loadLoadingScreen(window)
if (process.env.NODE_ENV === "development") {
window.webContents.openDevTools({ mode: "detach" })
@@ -313,7 +323,11 @@ function createWindow() {
showingLoadingScreen = false
})
return loadingReady
if (pendingCliUrl) {
const url = pendingCliUrl
pendingCliUrl = null
startCliPreload(url)
}
}
function showLoadingScreen(force = false) {
@@ -384,6 +398,9 @@ function startCliPreload(url: string) {
})
view.webContents.loadURL(url).catch((error) => {
if (isIgnorableNavigationError(error)) {
return
}
console.error("[cli] failed to preload CLI view:", error)
if (preloadingView === view) {
destroyPreloadingView(view)
@@ -404,7 +421,12 @@ function finalizeCliSwap(url: string) {
currentCliUrl = url
setWindowAllowedOrigin(window, url)
pendingCliUrl = null
window.loadURL(url).catch((error) => console.error("[cli] failed to load CLI view:", error))
window.loadURL(url).catch((error) => {
if (isIgnorableNavigationError(error)) {
return
}
console.error("[cli] failed to load CLI view:", error)
})
}
function buildRemoteWindowTitle(name: string, baseUrl: string) {
@@ -620,8 +642,7 @@ app.whenReady().then(() => {
// ignore
}
const loadingReady = createWindow()
;(mainWindow as BrowserWindow & { __codenomadOpenRemoteWindow?: typeof openRemoteWindow }).__codenomadOpenRemoteWindow = openRemoteWindow
startCli()
if (isMac) {
session.defaultSession.setSpellCheckerEnabled(false)
@@ -638,11 +659,8 @@ app.whenReady().then(() => {
}
}
void loadingReady.finally(() => {
setTimeout(() => {
void startCli()
}, 0)
})
createWindow()
;(mainWindow as BrowserWindow & { __codenomadOpenRemoteWindow?: typeof openRemoteWindow }).__codenomadOpenRemoteWindow = openRemoteWindow
app.on("certificate-error", (event, _webContents, url, error, _certificate, callback) => {
if (isInsecureOriginAllowed(url)) {

View File

@@ -0,0 +1,283 @@
import { dialog, app } from "electron"
import { createHash } from "node:crypto"
import fs from "node:fs"
import { createWriteStream } from "node:fs"
import { mkdir, mkdtemp, rename, rm, stat } from "node:fs/promises"
import https from "node:https"
import os from "node:os"
import path from "node:path"
import { pipeline } from "node:stream/promises"
import { spawn } from "node:child_process"
const MANAGED_NODE_VERSION = "v22.22.2"
const CONFIG_DIR = path.join(app.getPath("home"), ".config", "codenomad")
interface NodeArtifactSpec {
archiveName: string
archiveRoot: string
binaryRelativePath: string
url: string
}
function getNodeArtifactSpec(): NodeArtifactSpec {
const platform = process.platform
const arch = process.arch
if (platform === "darwin" && arch === "x64") {
return buildTarGzSpec("darwin-x64")
}
if (platform === "darwin" && arch === "arm64") {
return buildTarGzSpec("darwin-arm64")
}
if (platform === "linux" && arch === "x64") {
return buildTarGzSpec("linux-x64")
}
if (platform === "linux" && arch === "arm64") {
return buildTarGzSpec("linux-arm64")
}
if (platform === "win32" && arch === "x64") {
return buildZipSpec("win-x64", "node.exe")
}
if (platform === "win32" && arch === "arm64") {
return buildZipSpec("win-arm64", "node.exe")
}
throw new Error(`Managed Node runtime is not supported on ${platform}-${arch}.`)
}
function buildTarGzSpec(target: string): NodeArtifactSpec {
const archiveName = `node-${MANAGED_NODE_VERSION}-${target}.tar.gz`
return {
archiveName,
archiveRoot: archiveName.replace(/\.tar\.gz$/, ""),
binaryRelativePath: path.join("bin", "node"),
url: `https://nodejs.org/dist/${MANAGED_NODE_VERSION}/${archiveName}`,
}
}
function buildZipSpec(target: string, binaryName: string): NodeArtifactSpec {
const archiveName = `node-${MANAGED_NODE_VERSION}-${target}.zip`
return {
archiveName,
archiveRoot: archiveName.replace(/\.zip$/, ""),
binaryRelativePath: binaryName,
url: `https://nodejs.org/dist/${MANAGED_NODE_VERSION}/${archiveName}`,
}
}
function getRuntimePlatformDir(): string {
return `${process.platform}-${process.arch}`
}
function getManagedNodeRoot(): string {
return path.join(CONFIG_DIR, "node", MANAGED_NODE_VERSION, getRuntimePlatformDir())
}
function getManagedNodeBinaryPath(): string {
return path.join(getManagedNodeRoot(), getNodeArtifactSpec().binaryRelativePath)
}
function fileExists(filePath: string): boolean {
try {
return fs.existsSync(filePath)
} catch {
return false
}
}
async function fetchText(url: string): Promise<string> {
const response = await request(url)
return response.toString("utf-8")
}
function request(url: string): Promise<Buffer> {
return new Promise((resolve, reject) => {
const doRequest = (target: string) => {
https
.get(target, (response) => {
const statusCode = response.statusCode ?? 0
const redirect = response.headers.location
if (statusCode >= 300 && statusCode < 400 && redirect) {
response.resume()
doRequest(new URL(redirect, target).toString())
return
}
if (statusCode < 200 || statusCode >= 300) {
response.resume()
reject(new Error(`Request failed for ${target} with status ${statusCode}`))
return
}
const chunks: Buffer[] = []
response.on("data", (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)))
response.on("end", () => resolve(Buffer.concat(chunks)))
response.on("error", reject)
})
.on("error", reject)
}
doRequest(url)
})
}
function downloadFile(url: string, destination: string): Promise<void> {
return new Promise((resolve, reject) => {
const doDownload = (target: string) => {
https
.get(target, (response) => {
const statusCode = response.statusCode ?? 0
const redirect = response.headers.location
if (statusCode >= 300 && statusCode < 400 && redirect) {
response.resume()
doDownload(new URL(redirect, target).toString())
return
}
if (statusCode < 200 || statusCode >= 300) {
response.resume()
reject(new Error(`Download failed for ${target} with status ${statusCode}`))
return
}
const output = createWriteStream(destination)
pipeline(response, output).then(() => resolve()).catch(reject)
})
.on("error", reject)
}
doDownload(url)
})
}
async function sha256File(filePath: string): Promise<string> {
const hash = createHash("sha256")
await new Promise<void>((resolve, reject) => {
const stream = fs.createReadStream(filePath)
stream.on("data", (chunk) => hash.update(chunk))
stream.on("end", () => resolve())
stream.on("error", reject)
})
return hash.digest("hex")
}
async function fetchExpectedSha256(archiveName: string): Promise<string> {
const checksums = await fetchText(`https://nodejs.org/dist/${MANAGED_NODE_VERSION}/SHASUMS256.txt`)
for (const line of checksums.split(/\r?\n/)) {
const trimmed = line.trim()
if (!trimmed) continue
const [checksum, fileName] = trimmed.split(/\s+/, 2)
if (fileName === archiveName) {
return checksum
}
}
throw new Error(`Unable to find checksum for ${archiveName}.`)
}
function runCommand(command: string, args: string[]): Promise<void> {
return new Promise((resolve, reject) => {
const child = spawn(command, args, { stdio: "ignore", shell: false })
child.on("error", reject)
child.on("exit", (code) => {
if (code === 0) {
resolve()
} else {
reject(new Error(`${command} ${args.join(" ")} exited with code ${code ?? 1}`))
}
})
})
}
async function extractArchive(archivePath: string, destination: string): Promise<void> {
if (archivePath.endsWith(".zip")) {
const command = process.platform === "win32" ? "powershell.exe" : "powershell"
await runCommand(command, [
"-NoProfile",
"-NonInteractive",
"-Command",
"Expand-Archive",
"-LiteralPath",
archivePath,
"-DestinationPath",
destination,
"-Force",
])
return
}
await runCommand("tar", ["-xzf", archivePath, "-C", destination])
}
async function promptForManagedNodeDownload(): Promise<boolean> {
const result = await dialog.showMessageBox({
type: "question",
buttons: ["Download", "Cancel"],
defaultId: 0,
cancelId: 1,
noLink: true,
title: "Download Node Runtime",
message: "CodeNomad needs its managed Node.js runtime to start the server.",
detail: `Download ${MANAGED_NODE_VERSION} for ${process.platform}-${process.arch} into ~/.config/codenomad?`,
})
return result.response === 0
}
async function installManagedNodeRuntime(): Promise<string> {
const spec = getNodeArtifactSpec()
const runtimeRoot = getManagedNodeRoot()
const runtimeParent = path.dirname(runtimeRoot)
await mkdir(runtimeParent, { recursive: true })
const tempRoot = await mkdtemp(path.join(runtimeParent, ".download-"))
const archivePath = path.join(tempRoot, spec.archiveName)
const extractRoot = path.join(tempRoot, "extract")
try {
await mkdir(extractRoot, { recursive: true })
const expectedSha = await fetchExpectedSha256(spec.archiveName)
await downloadFile(spec.url, archivePath)
const actualSha = await sha256File(archivePath)
if (actualSha !== expectedSha) {
throw new Error(`Checksum mismatch for ${spec.archiveName}.`)
}
await extractArchive(archivePath, extractRoot)
const extractedRoot = path.join(extractRoot, spec.archiveRoot)
const extractedBinary = path.join(extractedRoot, spec.binaryRelativePath)
if (!fileExists(extractedBinary)) {
throw new Error(`Managed Node binary missing after extraction: ${extractedBinary}`)
}
await rm(runtimeRoot, { recursive: true, force: true })
await rename(extractedRoot, runtimeRoot)
return path.join(runtimeRoot, spec.binaryRelativePath)
} finally {
await rm(tempRoot, { recursive: true, force: true }).catch(() => undefined)
}
}
export async function ensureManagedNodeBinary(): Promise<string> {
const binaryPath = getManagedNodeBinaryPath()
if (fileExists(binaryPath)) {
return binaryPath
}
const confirmed = await promptForManagedNodeDownload()
if (!confirmed) {
throw new Error("CodeNomad requires the managed Node.js runtime to start. Download was cancelled.")
}
const installedBinary = await installManagedNodeRuntime()
const installedStats = await stat(installedBinary)
if (!installedStats.isFile()) {
throw new Error(`Managed Node binary is invalid: ${installedBinary}`)
}
return installedBinary
}

View File

@@ -7,6 +7,7 @@ import os from "os"
import path from "path"
import { fileURLToPath } from "url"
import { parse as parseYaml } from "yaml"
import { ensureManagedNodeBinary } from "./managed-node"
import { buildUserShellCommand, getUserShellEnv, supportsUserShell } from "./user-shell"
const nodeRequire = createRequire(import.meta.url)
@@ -38,8 +39,10 @@ interface StartOptions {
interface CliEntryResolution {
entry: string
runner: "node" | "tsx" | "standalone"
runner: "node" | "tsx"
runnerPath?: string
nodeBinaryPath: string
nodeArgs?: string[]
}
type ManagedChild = ChildProcess | UtilityProcess
@@ -148,14 +151,16 @@ export class CliProcessManager extends EventEmitter {
const listeningMode = this.resolveListeningMode()
const host = resolveHostForMode(listeningMode)
const args = this.buildCliArgs(options, host)
const cliEntry = this.resolveCliEntry(options)
const cliEntry = await this.resolveCliEntry(options)
let child: ManagedChild
if (this.shouldUsePackagedShellSupervisor(options, cliEntry)) {
if (this.shouldUsePackagedShellSupervisor(options)) {
const runtimePath = this.resolveShellNodeCommand()
const entryPath = this.resolveBundledProdEntry()
const supervisorPath = this.resolveCliSupervisorPath()
const shellEnv = supportsUserShell() ? getUserShellEnv() : { ...process.env }
const shellTarget = cliEntry.runner === "standalone" ? this.buildExecutableCommand(cliEntry.entry, args) : this.buildCommand(cliEntry, args)
const shellTarget = this.buildCommand(cliEntry, args)
const shellCommand = buildUserShellCommand(`exec ${shellTarget}`)
const supervisorPayload = JSON.stringify({
command: shellCommand.command,
@@ -164,13 +169,13 @@ export class CliProcessManager extends EventEmitter {
})
console.info(
`[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) via utility supervisor using ${cliEntry.runner} at ${cliEntry.entry} (host=${host})`,
`[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) via utility supervisor using node at ${runtimePath} (host=${host})`,
)
console.info(`[cli] utility supervisor: ${supervisorPath}`)
console.info(`[cli] shell command: ${shellCommand.command} ${shellCommand.args.join(" ")}`)
child = utilityProcess.fork(supervisorPath, [supervisorPayload], {
env: cliEntry.runner === "standalone" ? shellEnv : { ...shellEnv, ELECTRON_RUN_AS_NODE: "1" },
env: { ...shellEnv, ELECTRON_RUN_AS_NODE: "1" },
stdio: "pipe",
serviceName: "CodeNomad CLI Supervisor",
})
@@ -181,16 +186,10 @@ export class CliProcessManager extends EventEmitter {
)
const env = supportsUserShell() ? getUserShellEnv() : { ...process.env }
if (cliEntry.runner !== "standalone") {
env.ELECTRON_RUN_AS_NODE = "1"
}
env.ELECTRON_RUN_AS_NODE = "1"
const spawnDetails = supportsUserShell()
? buildUserShellCommand(
`${cliEntry.runner === "standalone" ? "" : "ELECTRON_RUN_AS_NODE=1 "}exec ${
cliEntry.runner === "standalone" ? this.buildExecutableCommand(cliEntry.entry, args) : this.buildCommand(cliEntry, args)
}`,
)
? buildUserShellCommand(`ELECTRON_RUN_AS_NODE=1 exec ${this.buildCommand(cliEntry, args)}`)
: this.buildDirectSpawn(cliEntry, args)
const detached = process.platform !== "win32"
@@ -568,11 +567,10 @@ export class CliProcessManager extends EventEmitter {
}
private buildCommand(cliEntry: CliEntryResolution, args: string[]): string {
if (cliEntry.runner === "standalone") {
return this.buildExecutableCommand(cliEntry.entry, args)
const parts = [JSON.stringify(cliEntry.nodeBinaryPath)]
for (const nodeArg of cliEntry.nodeArgs ?? []) {
parts.push(JSON.stringify(nodeArg))
}
const parts = [JSON.stringify(process.execPath)]
if (cliEntry.runner === "tsx" && cliEntry.runnerPath) {
parts.push(JSON.stringify(cliEntry.runnerPath))
}
@@ -581,33 +579,30 @@ export class CliProcessManager extends EventEmitter {
return parts.join(" ")
}
private buildExecutableCommand(command: string, args: string[]): string {
return [JSON.stringify(command), ...args.map((arg) => JSON.stringify(arg))].join(" ")
}
private buildDirectSpawn(cliEntry: CliEntryResolution, args: string[]) {
if (cliEntry.runner === "standalone") {
return { command: cliEntry.entry, args }
}
if (cliEntry.runner === "tsx") {
return { command: process.execPath, args: [cliEntry.runnerPath!, cliEntry.entry, ...args] }
return { command: cliEntry.nodeBinaryPath, args: [...(cliEntry.nodeArgs ?? []), cliEntry.runnerPath!, cliEntry.entry, ...args] }
}
return { command: process.execPath, args: [cliEntry.entry, ...args] }
return { command: cliEntry.nodeBinaryPath, args: [...(cliEntry.nodeArgs ?? []), cliEntry.entry, ...args] }
}
private resolveCliEntry(options: StartOptions): CliEntryResolution {
private async resolveCliEntry(options: StartOptions): Promise<CliEntryResolution> {
if (options.dev) {
const tsxPath = this.resolveTsx()
if (!tsxPath) {
throw new Error("tsx is required to run the CLI in development mode. Please install dependencies.")
}
const devEntry = this.resolveDevEntry()
return { entry: devEntry, runner: "tsx", runnerPath: tsxPath }
return { entry: devEntry, runner: "tsx", runnerPath: tsxPath, nodeBinaryPath: process.execPath }
}
return { entry: this.resolveStandaloneProdEntry(), runner: "standalone" }
return {
entry: this.resolveProdEntry(),
runner: "node",
nodeBinaryPath: await ensureManagedNodeBinary(),
nodeArgs: ["--experimental-specifier-resolution=node"],
}
}
private resolveTsx(): string | null {
@@ -647,12 +642,11 @@ export class CliProcessManager extends EventEmitter {
return entry
}
private resolveStandaloneProdEntry(): string {
const executableName = process.platform === "win32" ? "codenomad-server.exe" : "codenomad-server"
private resolveProdEntry(): string {
const candidates = [
path.join(process.resourcesPath, "server", "dist", executableName),
path.join(mainDirname, "../resources/server/dist", executableName),
path.resolve(process.cwd(), "..", "server", "dist", executableName),
path.join(process.resourcesPath, "server", "dist", "bin.js"),
path.join(mainDirname, "../resources/server/dist/bin.js"),
path.resolve(process.cwd(), "..", "server", "dist", "bin.js"),
]
for (const candidate of candidates) {
@@ -661,11 +655,11 @@ export class CliProcessManager extends EventEmitter {
}
}
throw new Error(`Unable to locate standalone CodeNomad server executable (${executableName}). Run npm run build:standalone --workspace @neuralnomads/codenomad.`)
throw new Error("Unable to locate the packaged CodeNomad server entrypoint (dist/bin.js). Rebuild the desktop bundle.")
}
private shouldUsePackagedShellSupervisor(options: StartOptions, cliEntry: CliEntryResolution): boolean {
return !options.dev && app.isPackaged && process.platform === "darwin" && cliEntry.runner !== "standalone"
private shouldUsePackagedShellSupervisor(options: StartOptions): boolean {
return false
}
private resolveCliSupervisorPath(): string {
@@ -683,6 +677,26 @@ export class CliProcessManager extends EventEmitter {
throw new Error("Unable to locate CodeNomad CLI supervisor script.")
}
private resolveShellNodeCommand(): string {
const configured = process.env.NODE_BINARY?.trim()
return configured && configured.length > 0 ? configured : "node"
}
private resolveBundledProdEntry(): string {
const candidates = [
path.join(process.resourcesPath, "server", "dist", "bin.js"),
path.join(mainDirname, "../resources/server/dist/bin.js"),
]
for (const candidate of candidates) {
if (existsSync(candidate)) {
return candidate
}
}
throw new Error("Unable to locate bundled CodeNomad CLI build in app resources.")
}
private describeUtilityProcessError(error: unknown): string {
if (error instanceof Error && error.message) {
return error.message