Files
CodeNomad/packages/electron-app/electron/main/managed-node.ts
Shantur Rathore fd57bd11a6 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
2026-04-26 13:20:47 +01:00

284 lines
8.7 KiB
TypeScript

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
}