## 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
284 lines
8.7 KiB
TypeScript
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
|
|
}
|