## 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
327 lines
9.8 KiB
JavaScript
327 lines
9.8 KiB
JavaScript
#!/usr/bin/env node
|
|
const fs = require("fs")
|
|
const path = require("path")
|
|
const { execSync } = require("child_process")
|
|
const { pathToFileURL } = require("url")
|
|
|
|
const root = path.resolve(__dirname, "..")
|
|
const workspaceRoot = path.resolve(root, "..", "..")
|
|
const serverRoot = path.resolve(root, "..", "server")
|
|
const uiRoot = path.resolve(root, "..", "ui")
|
|
const uiDist = path.resolve(uiRoot, "src", "renderer", "dist")
|
|
const serverDest = path.resolve(root, "src-tauri", "resources", "server")
|
|
const uiLoadingDest = path.resolve(root, "src-tauri", "resources", "ui-loading")
|
|
|
|
const sources = ["dist", "public", "node_modules", "package.json"]
|
|
|
|
const serverInstallCommand =
|
|
"npm install --omit=dev --ignore-scripts --workspaces=false --package-lock=false --install-strategy=shallow --fund=false --audit=false"
|
|
const serverDevInstallCommand =
|
|
"npm install --workspace @neuralnomads/codenomad --include-workspace-root=false --install-strategy=nested --fund=false --audit=false"
|
|
const uiDevInstallCommand =
|
|
"npm install --workspace @codenomad/ui --include-workspace-root=false --install-strategy=nested --fund=false --audit=false"
|
|
const serverPrepareUiCommand = "npm run prepare-ui --workspace @neuralnomads/codenomad"
|
|
const serverStandaloneBuildCommand = "npm run build:standalone --workspace @neuralnomads/codenomad"
|
|
|
|
const envWithRootBin = {
|
|
...process.env,
|
|
PATH: `${path.join(workspaceRoot, "node_modules/.bin")}:${process.env.PATH}`,
|
|
}
|
|
|
|
const braceExpansionPath = path.join(
|
|
serverRoot,
|
|
"node_modules",
|
|
"@fastify",
|
|
"static",
|
|
"node_modules",
|
|
"brace-expansion",
|
|
"package.json",
|
|
)
|
|
|
|
const serverBuildDependencyPaths = [
|
|
path.join(serverRoot, "node_modules", "typescript", "package.json"),
|
|
path.join(serverRoot, "node_modules", "@types", "node-forge", "package.json"),
|
|
path.join(serverRoot, "node_modules", "@types", "yauzl", "package.json"),
|
|
]
|
|
|
|
const viteBinPath = path.join(uiRoot, "node_modules", ".bin", "vite")
|
|
|
|
async function ensureMonacoAssets() {
|
|
const helperPath = path.join(uiRoot, "scripts", "monaco-public-assets.js")
|
|
const helperUrl = pathToFileURL(helperPath).href
|
|
const { copyMonacoPublicAssets } = await import(helperUrl)
|
|
copyMonacoPublicAssets({
|
|
uiRendererRoot: path.join(uiRoot, "src", "renderer"),
|
|
warn: (msg) => console.warn(`[prebuild] ${msg}`),
|
|
sourceRoots: [
|
|
path.resolve(workspaceRoot, "node_modules", "monaco-editor", "min", "vs"),
|
|
path.resolve(uiRoot, "node_modules", "monaco-editor", "min", "vs"),
|
|
],
|
|
})
|
|
}
|
|
|
|
function ensureServerBuild() {
|
|
const distPath = path.join(serverRoot, "dist")
|
|
const publicPath = path.join(serverRoot, "public")
|
|
console.log("[prebuild] rebuilding server workspace for desktop packaging...")
|
|
execSync("npm --workspace @neuralnomads/codenomad run build", {
|
|
cwd: workspaceRoot,
|
|
stdio: "inherit",
|
|
env: {
|
|
...process.env,
|
|
PATH: `${path.join(workspaceRoot, "node_modules/.bin")}:${process.env.PATH}`,
|
|
},
|
|
})
|
|
|
|
if (!fs.existsSync(distPath) || !fs.existsSync(publicPath)) {
|
|
throw new Error("[prebuild] server artifacts still missing after build")
|
|
}
|
|
}
|
|
|
|
function ensureStandaloneServerBuild() {
|
|
console.log("[prebuild] building standalone server executable...")
|
|
execSync(serverStandaloneBuildCommand, {
|
|
cwd: workspaceRoot,
|
|
stdio: "inherit",
|
|
env: envWithRootBin,
|
|
})
|
|
}
|
|
|
|
function ensureUiBuild() {
|
|
const loadingHtml = path.join(uiDist, "loading.html")
|
|
if (fs.existsSync(loadingHtml)) {
|
|
return
|
|
}
|
|
|
|
console.log("[prebuild] ui build missing; running workspace build...")
|
|
execSync("npm --workspace @codenomad/ui run build", {
|
|
cwd: workspaceRoot,
|
|
stdio: "inherit",
|
|
})
|
|
|
|
if (!fs.existsSync(loadingHtml)) {
|
|
throw new Error("[prebuild] ui loading assets missing after build")
|
|
}
|
|
}
|
|
|
|
function syncServerUiBundle() {
|
|
console.log("[prebuild] syncing server public UI bundle...")
|
|
execSync(serverPrepareUiCommand, {
|
|
cwd: workspaceRoot,
|
|
stdio: "inherit",
|
|
env: envWithRootBin,
|
|
})
|
|
}
|
|
|
|
function ensureServerDevDependencies() {
|
|
if (serverBuildDependencyPaths.every((filePath) => fs.existsSync(filePath))) {
|
|
return
|
|
}
|
|
|
|
console.log("[prebuild] ensuring server build dependencies (with dev)...")
|
|
execSync(serverDevInstallCommand, {
|
|
cwd: workspaceRoot,
|
|
stdio: "inherit",
|
|
env: envWithRootBin,
|
|
})
|
|
}
|
|
|
|
function ensureServerDependencies() {
|
|
console.log("[prebuild] pruning server to production dependencies...")
|
|
execSync("npm prune --omit=dev --ignore-scripts --workspaces=false --fund=false --audit=false", {
|
|
cwd: serverRoot,
|
|
stdio: "inherit",
|
|
})
|
|
|
|
if (!fs.existsSync(braceExpansionPath)) {
|
|
console.log("[prebuild] restoring missing server production dependencies...")
|
|
execSync(serverInstallCommand, {
|
|
cwd: serverRoot,
|
|
stdio: "inherit",
|
|
})
|
|
}
|
|
}
|
|
|
|
function ensureUiDevDependencies() {
|
|
if (fs.existsSync(viteBinPath)) {
|
|
return
|
|
}
|
|
|
|
console.log("[prebuild] ensuring ui build dependencies...")
|
|
execSync(uiDevInstallCommand, {
|
|
cwd: workspaceRoot,
|
|
stdio: "inherit",
|
|
env: envWithRootBin,
|
|
})
|
|
}
|
|
|
|
function ensureRollupPlatformBinary() {
|
|
const platformKey = `${process.platform}-${process.arch}`
|
|
const platformPackages = {
|
|
"linux-x64": "@rollup/rollup-linux-x64-gnu",
|
|
"linux-arm64": "@rollup/rollup-linux-arm64-gnu",
|
|
"darwin-arm64": "@rollup/rollup-darwin-arm64",
|
|
"darwin-x64": "@rollup/rollup-darwin-x64",
|
|
"win32-arm64": "@rollup/rollup-win32-arm64-msvc",
|
|
"win32-x64": "@rollup/rollup-win32-x64-msvc",
|
|
}
|
|
|
|
const pkgName = platformPackages[platformKey]
|
|
if (!pkgName) {
|
|
return
|
|
}
|
|
|
|
const platformPackagePath = path.join(workspaceRoot, "node_modules", "@rollup", pkgName.split("/").pop())
|
|
if (fs.existsSync(platformPackagePath)) {
|
|
return
|
|
}
|
|
|
|
let rollupVersion = ""
|
|
try {
|
|
rollupVersion = require(path.join(workspaceRoot, "node_modules", "rollup", "package.json")).version
|
|
} catch (error) {
|
|
// leave version empty; fallback install will use latest compatible
|
|
}
|
|
|
|
const packageSpec = rollupVersion ? `${pkgName}@${rollupVersion}` : pkgName
|
|
|
|
console.log("[prebuild] installing rollup platform binary (optional dep workaround)...")
|
|
execSync(`npm install ${packageSpec} --no-save --ignore-scripts --fund=false --audit=false`, {
|
|
cwd: workspaceRoot,
|
|
stdio: "inherit",
|
|
})
|
|
}
|
|
|
|
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 = path.join(workspaceRoot, "node_modules", ...pkgName.split("/"))
|
|
if (fs.existsSync(platformPackagePath)) {
|
|
return
|
|
}
|
|
|
|
let esbuildVersion = ""
|
|
try {
|
|
esbuildVersion = require(path.join(workspaceRoot, "node_modules", "esbuild", "package.json")).version
|
|
} catch {
|
|
try {
|
|
esbuildVersion = require(path.join(workspaceRoot, "node_modules", "vite", "node_modules", "esbuild", "package.json")).version
|
|
} catch {
|
|
// leave version empty; fallback install will use latest compatible
|
|
}
|
|
}
|
|
|
|
const packageSpec = esbuildVersion ? `${pkgName}@${esbuildVersion}` : pkgName
|
|
|
|
console.log("[prebuild] installing esbuild platform binary (optional dep workaround)...")
|
|
execSync(`npm install ${packageSpec} --no-save --ignore-scripts --fund=false --audit=false`, {
|
|
cwd: workspaceRoot,
|
|
stdio: "inherit",
|
|
})
|
|
}
|
|
|
|
function copyServerArtifacts() {
|
|
fs.rmSync(serverDest, { recursive: true, force: true })
|
|
fs.mkdirSync(serverDest, { recursive: true })
|
|
|
|
for (const name of sources) {
|
|
const from = path.join(serverRoot, name)
|
|
const to = path.join(serverDest, name)
|
|
if (!fs.existsSync(from)) {
|
|
console.warn(`[prebuild] skipped missing ${from}`)
|
|
continue
|
|
}
|
|
fs.cpSync(from, to, { recursive: true, dereference: true })
|
|
console.log(`[prebuild] copied ${from} -> ${to}`)
|
|
}
|
|
}
|
|
|
|
function stripNodeModuleBins() {
|
|
const root = path.join(serverDest, "node_modules")
|
|
if (!fs.existsSync(root)) {
|
|
return
|
|
}
|
|
|
|
const stack = [root]
|
|
let removed = 0
|
|
|
|
while (stack.length > 0) {
|
|
const current = stack.pop()
|
|
if (!current) break
|
|
|
|
let entries
|
|
try {
|
|
entries = fs.readdirSync(current, { withFileTypes: true })
|
|
} catch {
|
|
continue
|
|
}
|
|
|
|
for (const entry of entries) {
|
|
const full = path.join(current, entry.name)
|
|
if (entry.name === ".bin") {
|
|
fs.rmSync(full, { recursive: true, force: true })
|
|
removed += 1
|
|
continue
|
|
}
|
|
if (entry.isDirectory()) {
|
|
stack.push(full)
|
|
}
|
|
}
|
|
}
|
|
|
|
if (removed > 0) {
|
|
console.log(`[prebuild] removed ${removed} node_modules/.bin directories`)
|
|
}
|
|
}
|
|
|
|
function copyUiLoadingAssets() {
|
|
const loadingSource = path.join(uiDist, "loading.html")
|
|
const assetsSource = path.join(uiDist, "assets")
|
|
|
|
if (!fs.existsSync(loadingSource)) {
|
|
throw new Error("[prebuild] cannot find built loading.html")
|
|
}
|
|
|
|
fs.rmSync(uiLoadingDest, { recursive: true, force: true })
|
|
fs.mkdirSync(uiLoadingDest, { recursive: true })
|
|
|
|
fs.copyFileSync(loadingSource, path.join(uiLoadingDest, "loading.html"))
|
|
if (fs.existsSync(assetsSource)) {
|
|
fs.cpSync(assetsSource, path.join(uiLoadingDest, "assets"), { recursive: true })
|
|
}
|
|
|
|
console.log(`[prebuild] prepared UI loading assets from ${uiDist}`)
|
|
}
|
|
|
|
;(async () => {
|
|
ensureServerDevDependencies()
|
|
ensureUiDevDependencies()
|
|
await ensureMonacoAssets()
|
|
ensureRollupPlatformBinary()
|
|
ensureEsbuildPlatformBinary()
|
|
ensureServerBuild()
|
|
ensureStandaloneServerBuild()
|
|
ensureServerDependencies()
|
|
ensureUiBuild()
|
|
syncServerUiBundle()
|
|
copyServerArtifacts()
|
|
stripNodeModuleBins()
|
|
copyUiLoadingAssets()
|
|
})().catch((err) => {
|
|
console.error("[prebuild] failed:", err)
|
|
process.exit(1)
|
|
})
|