Don't depend on Node anymore (#346)
## Summary - package `packages/server` as a standalone desktop executable so Electron and Tauri no longer depend on a system-installed Node runtime in production - align Electron and Tauri startup logic around launching the packaged server, resolving binaries from the user shell, and bundling the same server resources into both desktop apps - replace the workspace instance proxy path that used `@fastify/reply-from` with a direct streaming proxy so packaged standalone builds can talk to spawned `opencode` instances correctly ## Why Desktop production builds were still depending on a user-provided Node runtime to launch `packages/server`, which made packaging less self-contained and created different behavior across machines. While moving to a standalone server executable, we also found that Bun-compiled standalone builds could start `opencode` successfully but failed when proxying requests to those instances through `reply-from`. The goal of this change is to make desktop production startup self-contained, keep Electron and Tauri behavior aligned, and restore correct communication with local `opencode` instances in packaged builds. ## What Changed - added a standalone build path for `packages/server` and bundle `codenomad-server` into desktop resources - updated Electron production startup to resolve and launch the standalone server executable - updated Tauri production startup to resolve and launch the standalone server executable with matching cwd and shell behavior - added runtime path helpers so the packaged server can reliably find its bundled UI, auth templates, config template, and package metadata - improved bare binary resolution so commands like `opencode` can be resolved from the user's login shell environment - upgraded the server stack to newer Fastify-compatible packages needed for the standalone/runtime work - replaced the workspace instance proxy implementation with a direct streaming proxy for requests to spawned `opencode` instances - updated Electron and Tauri build/prebuild scripts to generate and package the standalone server, while also repairing missing platform-specific optional binaries during packaging ## Benefits - desktop production builds no longer require Node to be installed on the user's system - Electron and Tauri now use the same packaged server model in production, reducing platform drift - packaged desktop apps can successfully create workspaces, launch `opencode`, and proxy health/session traffic to those instances - the server bundle is more self-contained and resilient to different launch environments - desktop packaging is more predictable because the required server executable is built and bundled as part of the app build flow
This commit is contained in:
@@ -118,6 +118,8 @@ function loadLoadingScreen(window: BrowserWindow) {
|
||||
loader.catch((error) => {
|
||||
console.error("[cli] failed to load loading screen:", error)
|
||||
})
|
||||
|
||||
return loader
|
||||
}
|
||||
|
||||
function getAllowedRendererOrigins(window?: BrowserWindow | null): string[] {
|
||||
@@ -292,7 +294,7 @@ function createWindow() {
|
||||
showingLoadingScreen = true
|
||||
currentCliUrl = null
|
||||
clearWindowAllowedOrigin(window)
|
||||
loadLoadingScreen(window)
|
||||
const loadingReady = loadLoadingScreen(window)
|
||||
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
window.webContents.openDevTools({ mode: "detach" })
|
||||
@@ -311,11 +313,7 @@ function createWindow() {
|
||||
showingLoadingScreen = false
|
||||
})
|
||||
|
||||
if (pendingCliUrl) {
|
||||
const url = pendingCliUrl
|
||||
pendingCliUrl = null
|
||||
startCliPreload(url)
|
||||
}
|
||||
return loadingReady
|
||||
}
|
||||
|
||||
function showLoadingScreen(force = false) {
|
||||
@@ -622,7 +620,8 @@ app.whenReady().then(() => {
|
||||
// ignore
|
||||
}
|
||||
|
||||
startCli()
|
||||
const loadingReady = createWindow()
|
||||
;(mainWindow as BrowserWindow & { __codenomadOpenRemoteWindow?: typeof openRemoteWindow }).__codenomadOpenRemoteWindow = openRemoteWindow
|
||||
|
||||
if (isMac) {
|
||||
session.defaultSession.setSpellCheckerEnabled(false)
|
||||
@@ -639,8 +638,11 @@ app.whenReady().then(() => {
|
||||
}
|
||||
}
|
||||
|
||||
createWindow()
|
||||
;(mainWindow as BrowserWindow & { __codenomadOpenRemoteWindow?: typeof openRemoteWindow }).__codenomadOpenRemoteWindow = openRemoteWindow
|
||||
void loadingReady.finally(() => {
|
||||
setTimeout(() => {
|
||||
void startCli()
|
||||
}, 0)
|
||||
})
|
||||
|
||||
app.on("certificate-error", (event, _webContents, url, error, _certificate, callback) => {
|
||||
if (isInsecureOriginAllowed(url)) {
|
||||
|
||||
@@ -38,7 +38,7 @@ interface StartOptions {
|
||||
|
||||
interface CliEntryResolution {
|
||||
entry: string
|
||||
runner: "node" | "tsx"
|
||||
runner: "node" | "tsx" | "standalone"
|
||||
runnerPath?: string
|
||||
}
|
||||
|
||||
@@ -148,15 +148,15 @@ export class CliProcessManager extends EventEmitter {
|
||||
const listeningMode = this.resolveListeningMode()
|
||||
const host = resolveHostForMode(listeningMode)
|
||||
const args = this.buildCliArgs(options, host)
|
||||
const cliEntry = this.resolveCliEntry(options)
|
||||
|
||||
let child: ManagedChild
|
||||
|
||||
if (this.shouldUsePackagedShellSupervisor(options)) {
|
||||
const runtimePath = this.resolveShellNodeCommand()
|
||||
const entryPath = this.resolveBundledProdEntry()
|
||||
if (this.shouldUsePackagedShellSupervisor(options, cliEntry)) {
|
||||
const supervisorPath = this.resolveCliSupervisorPath()
|
||||
const shellEnv = supportsUserShell() ? getUserShellEnv() : { ...process.env }
|
||||
const shellCommand = buildUserShellCommand(`exec ${this.buildExecutableCommand(runtimePath, [entryPath, ...args])}`)
|
||||
const shellTarget = cliEntry.runner === "standalone" ? this.buildExecutableCommand(cliEntry.entry, args) : this.buildCommand(cliEntry, args)
|
||||
const shellCommand = buildUserShellCommand(`exec ${shellTarget}`)
|
||||
const supervisorPayload = JSON.stringify({
|
||||
command: shellCommand.command,
|
||||
args: shellCommand.args,
|
||||
@@ -164,28 +164,33 @@ export class CliProcessManager extends EventEmitter {
|
||||
})
|
||||
|
||||
console.info(
|
||||
`[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) via utility supervisor using node at ${runtimePath} (host=${host})`,
|
||||
`[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) via utility supervisor using ${cliEntry.runner} at ${cliEntry.entry} (host=${host})`,
|
||||
)
|
||||
console.info(`[cli] utility supervisor: ${supervisorPath}`)
|
||||
console.info(`[cli] shell command: ${shellCommand.command} ${shellCommand.args.join(" ")}`)
|
||||
|
||||
child = utilityProcess.fork(supervisorPath, [supervisorPayload], {
|
||||
env: shellEnv,
|
||||
env: cliEntry.runner === "standalone" ? shellEnv : { ...shellEnv, ELECTRON_RUN_AS_NODE: "1" },
|
||||
stdio: "pipe",
|
||||
serviceName: "CodeNomad CLI Supervisor",
|
||||
})
|
||||
this.childLaunchMode = "utility"
|
||||
} else {
|
||||
const cliEntry = this.resolveCliEntry(options)
|
||||
console.info(
|
||||
`[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) using ${cliEntry.runner} at ${cliEntry.entry} (host=${host})`,
|
||||
)
|
||||
|
||||
const env = supportsUserShell() ? getUserShellEnv() : { ...process.env }
|
||||
env.ELECTRON_RUN_AS_NODE = "1"
|
||||
if (cliEntry.runner !== "standalone") {
|
||||
env.ELECTRON_RUN_AS_NODE = "1"
|
||||
}
|
||||
|
||||
const spawnDetails = supportsUserShell()
|
||||
? buildUserShellCommand(`ELECTRON_RUN_AS_NODE=1 exec ${this.buildCommand(cliEntry, args)}`)
|
||||
? buildUserShellCommand(
|
||||
`${cliEntry.runner === "standalone" ? "" : "ELECTRON_RUN_AS_NODE=1 "}exec ${
|
||||
cliEntry.runner === "standalone" ? this.buildExecutableCommand(cliEntry.entry, args) : this.buildCommand(cliEntry, args)
|
||||
}`,
|
||||
)
|
||||
: this.buildDirectSpawn(cliEntry, args)
|
||||
|
||||
const detached = process.platform !== "win32"
|
||||
@@ -563,6 +568,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(process.execPath)]
|
||||
if (cliEntry.runner === "tsx" && cliEntry.runnerPath) {
|
||||
parts.push(JSON.stringify(cliEntry.runnerPath))
|
||||
@@ -577,6 +586,10 @@ export class CliProcessManager extends EventEmitter {
|
||||
}
|
||||
|
||||
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] }
|
||||
}
|
||||
@@ -593,9 +606,8 @@ export class CliProcessManager extends EventEmitter {
|
||||
const devEntry = this.resolveDevEntry()
|
||||
return { entry: devEntry, runner: "tsx", runnerPath: tsxPath }
|
||||
}
|
||||
|
||||
const distEntry = this.resolveProdEntry()
|
||||
return { entry: distEntry, runner: "node" }
|
||||
|
||||
return { entry: this.resolveStandaloneProdEntry(), runner: "standalone" }
|
||||
}
|
||||
|
||||
private resolveTsx(): string | null {
|
||||
@@ -635,20 +647,25 @@ export class CliProcessManager extends EventEmitter {
|
||||
return entry
|
||||
}
|
||||
|
||||
private resolveProdEntry(): string {
|
||||
try {
|
||||
const entry = nodeRequire.resolve("@neuralnomads/codenomad/dist/bin.js")
|
||||
if (existsSync(entry)) {
|
||||
return entry
|
||||
private resolveStandaloneProdEntry(): string {
|
||||
const executableName = process.platform === "win32" ? "codenomad-server.exe" : "codenomad-server"
|
||||
const candidates = [
|
||||
path.join(process.resourcesPath, "server", "dist", executableName),
|
||||
path.join(mainDirname, "../resources/server/dist", executableName),
|
||||
path.resolve(process.cwd(), "..", "server", "dist", executableName),
|
||||
]
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate)) {
|
||||
return candidate
|
||||
}
|
||||
} catch {
|
||||
// fall through to error below
|
||||
}
|
||||
throw new Error("Unable to locate CodeNomad CLI build (dist/bin.js). Run npm run build --workspace @neuralnomads/codenomad.")
|
||||
|
||||
throw new Error(`Unable to locate standalone CodeNomad server executable (${executableName}). Run npm run build:standalone --workspace @neuralnomads/codenomad.`)
|
||||
}
|
||||
|
||||
private shouldUsePackagedShellSupervisor(options: StartOptions): boolean {
|
||||
return !options.dev && app.isPackaged && process.platform === "darwin"
|
||||
private shouldUsePackagedShellSupervisor(options: StartOptions, cliEntry: CliEntryResolution): boolean {
|
||||
return !options.dev && app.isPackaged && process.platform === "darwin" && cliEntry.runner !== "standalone"
|
||||
}
|
||||
|
||||
private resolveCliSupervisorPath(): string {
|
||||
@@ -666,26 +683,6 @@ 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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawn } from "child_process"
|
||||
import { existsSync } from "fs"
|
||||
import { existsSync, readFileSync } from "fs"
|
||||
import path, { join } from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
@@ -14,6 +14,46 @@ const npxCmd = process.platform === "win32" ? "npx.cmd" : "npx"
|
||||
const nodeModulesPath = join(appDir, "node_modules")
|
||||
const workspaceNodeModulesPath = join(workspaceRoot, "node_modules")
|
||||
|
||||
function getPlatformEsbuildPackage() {
|
||||
const platformKey = `${process.platform}-${process.arch}`
|
||||
const platformPackages = {
|
||||
"linux-x64": "@esbuild/linux-x64",
|
||||
"linux-arm64": "@esbuild/linux-arm64",
|
||||
"darwin-arm64": "@esbuild/darwin-arm64",
|
||||
"darwin-x64": "@esbuild/darwin-x64",
|
||||
"win32-arm64": "@esbuild/win32-arm64",
|
||||
"win32-x64": "@esbuild/win32-x64",
|
||||
}
|
||||
|
||||
return platformPackages[platformKey] ?? null
|
||||
}
|
||||
|
||||
async function ensureEsbuildPlatformBinary() {
|
||||
const pkgName = getPlatformEsbuildPackage()
|
||||
if (!pkgName) {
|
||||
return
|
||||
}
|
||||
|
||||
const platformPackagePath = join(workspaceNodeModulesPath, ...pkgName.split("/"))
|
||||
if (existsSync(platformPackagePath)) {
|
||||
return
|
||||
}
|
||||
|
||||
let esbuildVersion = ""
|
||||
try {
|
||||
esbuildVersion = JSON.parse(readFileSync(join(workspaceNodeModulesPath, "esbuild", "package.json"), "utf-8")).version ?? ""
|
||||
} catch {
|
||||
// leave version empty; fallback install will use latest compatible
|
||||
}
|
||||
|
||||
const packageSpec = esbuildVersion ? `${pkgName}@${esbuildVersion}` : pkgName
|
||||
console.log("📦 Step 0/3: Restoring esbuild platform binary...\n")
|
||||
await run(npmCmd, ["install", packageSpec, "--no-save", "--ignore-scripts", "--fund=false", "--audit=false"], {
|
||||
cwd: workspaceRoot,
|
||||
env: { NODE_PATH: workspaceNodeModulesPath },
|
||||
})
|
||||
}
|
||||
|
||||
const platforms = {
|
||||
mac: {
|
||||
args: ["--mac", "--x64", "--arm64"],
|
||||
@@ -105,6 +145,8 @@ async function build(platform) {
|
||||
console.log(`\n🔨 Building for: ${config.description}\n`)
|
||||
|
||||
try {
|
||||
await ensureEsbuildPlatformBinary()
|
||||
|
||||
console.log("📦 Step 1/3: Building CLI dependency...\n")
|
||||
await run(npmCmd, ["run", "build", "--workspace", "@neuralnomads/codenomad"], {
|
||||
cwd: workspaceRoot,
|
||||
|
||||
@@ -16,6 +16,7 @@ const npmNodeExecPath = process.env.npm_node_execpath
|
||||
|
||||
const serverSources = ["dist", "public", "node_modules", "package.json"]
|
||||
const serverDepsMarker = join(serverRoot, "node_modules", "fastify", "package.json")
|
||||
const standaloneMarker = join(serverRoot, "dist", process.platform === "win32" ? "codenomad-server.exe" : "codenomad-server")
|
||||
|
||||
function log(message) {
|
||||
console.log(`[prepare-resources] ${message}`)
|
||||
@@ -29,6 +30,34 @@ function ensureServerBuild() {
|
||||
}
|
||||
}
|
||||
|
||||
function ensureStandaloneServerBuild() {
|
||||
log("building standalone server executable")
|
||||
const result = spawnSync(
|
||||
"npm",
|
||||
["run", "build:standalone", "--workspace", "@neuralnomads/codenomad"],
|
||||
{
|
||||
cwd: workspaceRoot,
|
||||
stdio: "inherit",
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${join(workspaceRoot, "node_modules", ".bin")}${path.delimiter}${process.env.PATH ?? ""}`,
|
||||
},
|
||||
shell: process.platform === "win32",
|
||||
},
|
||||
)
|
||||
|
||||
if (result.status !== 0) {
|
||||
if (result.error) {
|
||||
throw result.error
|
||||
}
|
||||
throw new Error(`standalone server build exited with code ${result.status ?? 1}`)
|
||||
}
|
||||
|
||||
if (!fs.existsSync(standaloneMarker)) {
|
||||
throw new Error(`Standalone server executable missing after build: ${standaloneMarker}`)
|
||||
}
|
||||
}
|
||||
|
||||
function ensureServerDependencies() {
|
||||
if (fs.existsSync(serverDepsMarker)) {
|
||||
return
|
||||
@@ -65,6 +94,51 @@ function ensureServerDependencies() {
|
||||
}
|
||||
}
|
||||
|
||||
function ensureEsbuildPlatformBinary() {
|
||||
const platformKey = `${process.platform}-${process.arch}`
|
||||
const platformPackages = {
|
||||
"linux-x64": "@esbuild/linux-x64",
|
||||
"linux-arm64": "@esbuild/linux-arm64",
|
||||
"darwin-arm64": "@esbuild/darwin-arm64",
|
||||
"darwin-x64": "@esbuild/darwin-x64",
|
||||
"win32-arm64": "@esbuild/win32-arm64",
|
||||
"win32-x64": "@esbuild/win32-x64",
|
||||
}
|
||||
|
||||
const pkgName = platformPackages[platformKey]
|
||||
if (!pkgName) {
|
||||
return
|
||||
}
|
||||
|
||||
const platformPackagePath = join(workspaceRoot, "node_modules", ...pkgName.split("/"))
|
||||
if (fs.existsSync(platformPackagePath)) {
|
||||
return
|
||||
}
|
||||
|
||||
let esbuildVersion = ""
|
||||
try {
|
||||
esbuildVersion = JSON.parse(fs.readFileSync(join(workspaceRoot, "node_modules", "esbuild", "package.json"), "utf-8")).version ?? ""
|
||||
} catch {
|
||||
// leave version empty; fallback install will use latest compatible
|
||||
}
|
||||
|
||||
const packageSpec = esbuildVersion ? `${pkgName}@${esbuildVersion}` : pkgName
|
||||
log("installing esbuild platform binary (optional dep workaround)")
|
||||
|
||||
const result = spawnSync("npm", ["install", packageSpec, "--no-save", "--ignore-scripts", "--fund=false", "--audit=false"], {
|
||||
cwd: workspaceRoot,
|
||||
stdio: "inherit",
|
||||
shell: process.platform === "win32",
|
||||
})
|
||||
|
||||
if (result.status !== 0) {
|
||||
if (result.error) {
|
||||
throw result.error
|
||||
}
|
||||
throw new Error(`esbuild platform install exited with code ${result.status ?? 1}`)
|
||||
}
|
||||
}
|
||||
|
||||
function copyServerArtifacts() {
|
||||
fs.rmSync(serverDest, { recursive: true, force: true })
|
||||
fs.mkdirSync(serverDest, { recursive: true })
|
||||
@@ -121,7 +195,9 @@ function stripNodeModuleBins() {
|
||||
|
||||
async function main() {
|
||||
ensureServerBuild()
|
||||
ensureStandaloneServerBuild()
|
||||
ensureServerDependencies()
|
||||
ensureEsbuildPlatformBinary()
|
||||
copyServerArtifacts()
|
||||
stripNodeModuleBins()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user