mirror of
https://github.com/NeuralNomadsAI/CodeNomad.git
synced 2026-08-24 07:43:28 +00:00
## Summary - Follow up #578 by consolidating desktop persistence, restore reconciliation, lifecycle coordination, and regression coverage. - Preserve active drafts and attachments, request-scoped workspace ownership, deletion tombstones, renderer authority, and bounded shutdown behavior. - Fix the reported macOS cleanup failure with targeted BSD process queries and random-token-guarded process-group cleanup, without an unverified PID fallback. ## Platform hardening - Ignore development renderer origins in packaged Electron builds. - Preserve staged Tauri navigation authority and handle confirmed Windows session-end shutdown on the UI thread. - Bound workspace launch preflight, runtime startup, and health readiness. - Retain cleanup ownership after unexpected leaders exit and verify portable POSIX descendants by immutable identity or inherited launch token. - Add real Darwin-only process-group integration tests for macOS CI. ## Scope - 96 files changed. - 6,295 additions and 12,167 deletions, a net reduction of 5,872 lines from the merged implementation. - Consolidated duplicated tests while retaining focused race, durability, cleanup, and platform contracts. ## Validation - pm run typecheck - pm run typecheck --workspace @neuralnomads/codenomad - Electron native suite: 60 passed - Tauri suite: 49 passed - Focused server lifecycle/identity suite: 31 passed, 2 Darwin-only skipped on Windows - Focused UI restore/codec/reconciliation suite: 36 passed - Broader server suite: 59 passed, 3 platform skips - Broader UI suite: 97 passed, 1 skip; 2 Node 25 solid-toast loader failures reproduced on the merged baseline - git diff --check - Final limited gatekeeper: PASS for server/macOS, UI restore, and Electron/Tauri
576 lines
20 KiB
TypeScript
576 lines
20 KiB
TypeScript
import { spawn, type ChildProcess } from "child_process"
|
|
import { app } from "electron"
|
|
import { createRequire } from "module"
|
|
import { EventEmitter } from "events"
|
|
import { existsSync, readFileSync } from "fs"
|
|
import os from "os"
|
|
import path from "path"
|
|
import { fileURLToPath } from "url"
|
|
import { parse as parseYaml } from "yaml"
|
|
import { ensureManagedNodeBinary } from "./managed-node"
|
|
import { getProcessStartIdentityAsync } from "./client-state-process-identity"
|
|
import {
|
|
CLI_STOP_DEADLINE_MS,
|
|
captureInitialProcessTree,
|
|
captureProcessTree,
|
|
forceCapturedProcessTree,
|
|
mergeCapturedProcessTrees,
|
|
stopManagedChild,
|
|
} from "./process-stop"
|
|
import { SerializedLifecycle } from "./serialized-lifecycle"
|
|
import { buildUserShellCommand, getUserShellEnv, supportsUserShell } from "./user-shell"
|
|
|
|
const nodeRequire = createRequire(import.meta.url)
|
|
const mainFilename = fileURLToPath(import.meta.url)
|
|
const mainDirname = path.dirname(mainFilename)
|
|
|
|
const BOOTSTRAP_TOKEN_PREFIX = "CODENOMAD_BOOTSTRAP_TOKEN:"
|
|
const SERVER_SHUTDOWN_COMPLETE = "CODENOMAD_SHUTDOWN_STATUS:complete"
|
|
const SERVER_SHUTDOWN_INCOMPLETE = "CODENOMAD_SHUTDOWN_STATUS:incomplete"
|
|
const SESSION_COOKIE_NAME_PREFIX = "codenomad_session"
|
|
type CliState = "starting" | "ready" | "error" | "stopped"
|
|
type ListeningMode = "local" | "all"
|
|
|
|
export interface CliStatus {
|
|
state: CliState
|
|
pid?: number
|
|
port?: number
|
|
url?: string
|
|
error?: string
|
|
}
|
|
|
|
export interface CliLogEntry {
|
|
stream: "stdout" | "stderr"
|
|
message: string
|
|
}
|
|
|
|
interface StartOptions {
|
|
dev: boolean
|
|
}
|
|
|
|
interface CliEntryResolution {
|
|
entry: string
|
|
runner: "node" | "tsx"
|
|
runnerPath?: string
|
|
nodeBinaryPath: string
|
|
nodeArgs?: string[]
|
|
}
|
|
|
|
const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json"
|
|
|
|
function isYamlPath(filePath: string): boolean {
|
|
const lower = filePath.toLowerCase()
|
|
return lower.endsWith(".yaml") || lower.endsWith(".yml")
|
|
}
|
|
|
|
function isJsonPath(filePath: string): boolean {
|
|
return filePath.toLowerCase().endsWith(".json")
|
|
}
|
|
|
|
function resolveConfigPaths(raw?: string): { configYamlPath: string; legacyJsonPath: string } {
|
|
const target = raw && raw.trim().length > 0 ? raw.trim() : DEFAULT_CONFIG_PATH
|
|
const resolved = resolveConfigPath(target)
|
|
|
|
if (isYamlPath(resolved)) {
|
|
const baseDir = path.dirname(resolved)
|
|
return { configYamlPath: resolved, legacyJsonPath: path.join(baseDir, "config.json") }
|
|
}
|
|
|
|
if (isJsonPath(resolved)) {
|
|
const baseDir = path.dirname(resolved)
|
|
return { configYamlPath: path.join(baseDir, "config.yaml"), legacyJsonPath: resolved }
|
|
}
|
|
|
|
// Treat as directory.
|
|
return {
|
|
configYamlPath: path.join(resolved, "config.yaml"),
|
|
legacyJsonPath: path.join(resolved, "config.json"),
|
|
}
|
|
}
|
|
|
|
function resolveConfigPath(configPath?: string): string {
|
|
const target = configPath && configPath.trim().length > 0 ? configPath : DEFAULT_CONFIG_PATH
|
|
if (target.startsWith("~/")) {
|
|
return path.join(os.homedir(), target.slice(2))
|
|
}
|
|
return path.resolve(target)
|
|
}
|
|
|
|
function resolveHostForMode(mode: ListeningMode): string {
|
|
return mode === "local" ? "127.0.0.1" : "0.0.0.0"
|
|
}
|
|
|
|
function readListeningModeFromConfig(): ListeningMode {
|
|
try {
|
|
const { configYamlPath, legacyJsonPath } = resolveConfigPaths(process.env.CLI_CONFIG)
|
|
|
|
let parsed: any = null
|
|
if (existsSync(configYamlPath)) {
|
|
const content = readFileSync(configYamlPath, "utf-8")
|
|
parsed = parseYaml(content)
|
|
} else if (existsSync(legacyJsonPath)) {
|
|
const content = readFileSync(legacyJsonPath, "utf-8")
|
|
parsed = JSON.parse(content)
|
|
} else {
|
|
return "local"
|
|
}
|
|
|
|
const mode = parsed?.server?.listeningMode ?? parsed?.preferences?.listeningMode
|
|
if (mode === "local" || mode === "all") {
|
|
return mode
|
|
}
|
|
} catch (error) {
|
|
console.warn("[cli] failed to read listening mode from config", error)
|
|
}
|
|
return "local"
|
|
}
|
|
|
|
export declare interface CliProcessManager {
|
|
on(event: "status", listener: (status: CliStatus) => void): this
|
|
on(event: "ready", listener: (status: CliStatus) => void): this
|
|
on(event: "bootstrapToken", listener: (token: string) => void): this
|
|
on(event: "log", listener: (entry: CliLogEntry) => void): this
|
|
on(event: "exit", listener: (status: CliStatus) => void): this
|
|
on(event: "error", listener: (error: Error) => void): this
|
|
}
|
|
|
|
export class CliProcessManager extends EventEmitter {
|
|
private child?: ChildProcess
|
|
private childStartIdentity?: Promise<string | undefined>
|
|
private status: CliStatus = { state: "stopped" }
|
|
private stdoutBuffer = ""
|
|
private stderrBuffer = ""
|
|
private bootstrapToken: string | null = null
|
|
private authCookieName = `${SESSION_COOKIE_NAME_PREFIX}_${process.pid}_${Date.now()}`
|
|
private requestedStop = false
|
|
private shutdownStatus: "complete" | "incomplete" | null = null
|
|
private lifecycle = new SerializedLifecycle()
|
|
|
|
start(options: StartOptions): Promise<CliStatus> {
|
|
return this.lifecycle.enqueue(() => this.startNow(options))
|
|
}
|
|
|
|
restart(options: StartOptions): Promise<CliStatus> {
|
|
return this.lifecycle.enqueue(async () => {
|
|
await this.stopNow()
|
|
if (this.lifecycle.stopped) throw new Error("CLI process manager is shutting down")
|
|
return this.startNow(options)
|
|
})
|
|
}
|
|
|
|
stop(): Promise<void> {
|
|
return this.lifecycle.enqueue(() => this.stopNow())
|
|
}
|
|
|
|
shutdown(): Promise<void> {
|
|
return this.lifecycle.stop(() => this.stopNow())
|
|
}
|
|
|
|
private async startNow(options: StartOptions): Promise<CliStatus> {
|
|
if (this.lifecycle.stopped) throw new Error("CLI process manager is shutting down")
|
|
if (this.child) {
|
|
await this.stopNow()
|
|
if (this.child) throw new Error("CLI process did not exit before restart")
|
|
}
|
|
|
|
this.stdoutBuffer = ""
|
|
this.stderrBuffer = ""
|
|
this.bootstrapToken = null
|
|
this.authCookieName = `${SESSION_COOKIE_NAME_PREFIX}_${process.pid}_${Date.now()}`
|
|
this.requestedStop = false
|
|
this.shutdownStatus = null
|
|
this.childStartIdentity = undefined
|
|
this.updateStatus({ state: "starting", port: undefined, pid: undefined, url: undefined, error: undefined })
|
|
|
|
const listeningMode = this.resolveListeningMode()
|
|
const host = resolveHostForMode(listeningMode)
|
|
const args = this.buildCliArgs(options, host)
|
|
const cliEntry = await 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"
|
|
|
|
const spawnDetails = supportsUserShell()
|
|
? buildUserShellCommand(`ELECTRON_RUN_AS_NODE=1 exec ${this.buildCommand(cliEntry, args)}`)
|
|
: this.buildDirectSpawn(cliEntry, args)
|
|
|
|
const child = spawn(spawnDetails.command, spawnDetails.args, {
|
|
cwd: process.cwd(),
|
|
stdio: ["pipe", "pipe", "pipe"],
|
|
env,
|
|
shell: false,
|
|
detached: process.platform !== "win32",
|
|
})
|
|
|
|
console.info(`[cli] spawn command: ${spawnDetails.command} ${spawnDetails.args.join(" ")}`)
|
|
if (!child.pid) {
|
|
console.error("[cli] spawn failed: no pid")
|
|
}
|
|
|
|
this.child = child
|
|
this.childStartIdentity = child.pid ? getProcessStartIdentityAsync(child.pid, 1_500) : Promise.resolve(undefined)
|
|
this.updateStatus({ pid: child.pid ?? undefined })
|
|
|
|
const stdout = child.stdout as NodeJS.ReadableStream | undefined
|
|
const stderr = child.stderr as NodeJS.ReadableStream | undefined
|
|
|
|
stdout?.on("data", (data: Buffer) => {
|
|
this.handleStream(data.toString(), "stdout")
|
|
})
|
|
|
|
stderr?.on("data", (data: Buffer) => {
|
|
this.handleStream(data.toString(), "stderr")
|
|
})
|
|
|
|
child.on("error", (error) => {
|
|
console.error("[cli] failed to start CLI:", error)
|
|
this.updateStatus({ state: "error", error: error.message })
|
|
this.emit("error", error)
|
|
})
|
|
|
|
child.on("exit", (code, signal) => {
|
|
if (this.child !== child) return
|
|
const failed = this.status.state !== "ready"
|
|
const error = failed ? this.status.error ?? `CLI exited with code ${code ?? 0}${signal ? ` (${signal})` : ""}` : undefined
|
|
console.info(`[cli] exit (code=${code}, signal=${signal || ""})${error ? ` error=${error}` : ""}`)
|
|
this.updateStatus({ state: failed ? "error" : "stopped", error })
|
|
if (failed && error) {
|
|
this.emit("error", new Error(error))
|
|
}
|
|
this.emit("exit", this.status)
|
|
this.child = undefined
|
|
this.childStartIdentity = undefined
|
|
})
|
|
|
|
return new Promise<CliStatus>((resolve, reject) => {
|
|
const timeout = setTimeout(() => {
|
|
this.handleTimeout()
|
|
reject(new Error("CLI startup timeout"))
|
|
}, 60000)
|
|
|
|
this.once("ready", (status) => {
|
|
clearTimeout(timeout)
|
|
resolve(status)
|
|
})
|
|
|
|
this.once("error", (error) => {
|
|
clearTimeout(timeout)
|
|
reject(error)
|
|
})
|
|
})
|
|
}
|
|
|
|
private async stopNow(): Promise<void> {
|
|
const child = this.child
|
|
if (!child) {
|
|
this.updateStatus({ state: "stopped" })
|
|
return
|
|
}
|
|
|
|
const spawnedChild = child
|
|
|
|
this.requestedStop = true
|
|
|
|
const pid = spawnedChild.pid
|
|
if (!pid) {
|
|
this.child = undefined
|
|
this.updateStatus({ state: "stopped" })
|
|
return
|
|
}
|
|
|
|
const isAlreadyExited = () => spawnedChild.exitCode !== null || spawnedChild.signalCode !== null
|
|
|
|
const deadlineAt = Date.now() + CLI_STOP_DEADLINE_MS
|
|
const spawnedIdentity = this.childStartIdentity ?? Promise.resolve(undefined)
|
|
const { tree: initialCapture, rootStartIdentity } = await captureInitialProcessTree(
|
|
pid,
|
|
process.platform,
|
|
undefined,
|
|
() => spawnedIdentity,
|
|
deadlineAt,
|
|
)
|
|
let processTree = rootStartIdentity
|
|
? mergeCapturedProcessTrees(undefined, initialCapture, pid, rootStartIdentity)
|
|
: initialCapture
|
|
let enforcement: Promise<boolean> | null = null
|
|
const forceProcessTree = (enforcementDeadline = deadlineAt) => {
|
|
if (enforcement) return enforcement
|
|
enforcement = (async () => {
|
|
const latest = await captureProcessTree(pid, process.platform, undefined, Math.min(1_500, enforcementDeadline - Date.now()))
|
|
processTree = mergeCapturedProcessTrees(processTree, latest, pid, rootStartIdentity)
|
|
return processTree ? forceCapturedProcessTree(processTree, undefined, undefined, process.kill, { deadlineAt: enforcementDeadline }) : false
|
|
})().finally(() => { enforcement = null })
|
|
return enforcement
|
|
}
|
|
|
|
let forceConfirmed = false
|
|
const enforceIncompleteCleanup = () => {
|
|
void forceProcessTree().then((confirmed) => {
|
|
forceConfirmed = confirmed
|
|
if (!confirmed) console.warn(`[cli] immediate enforcement after incomplete cleanup was not confirmed (pid=${pid})`)
|
|
}, (error) => {
|
|
console.warn(`[cli] immediate enforcement after incomplete cleanup failed (pid=${pid})`, error)
|
|
forceConfirmed = false
|
|
})
|
|
}
|
|
this.once("shutdownIncomplete", enforceIncompleteCleanup)
|
|
|
|
try {
|
|
await stopManagedChild({
|
|
child: spawnedChild,
|
|
isExited: isAlreadyExited,
|
|
force: async (forceDeadline) => forceConfirmed || await forceProcessTree(forceDeadline),
|
|
isCleanupComplete: () => this.shutdownStatus === "complete",
|
|
deadlineMs: CLI_STOP_DEADLINE_MS,
|
|
deadlineAt,
|
|
forceReserveMs: 5_000,
|
|
warn: (message, error) => console.warn(`[cli] ${message} (pid=${pid})`, error ?? ""),
|
|
})
|
|
} finally {
|
|
this.off("shutdownIncomplete", enforceIncompleteCleanup)
|
|
}
|
|
if (this.shutdownStatus !== "complete") {
|
|
console.warn(`[cli] CLI exited without a complete graceful-shutdown handshake (status=${this.shutdownStatus ?? "missing"})`)
|
|
}
|
|
console.info("[cli] CLI process exited")
|
|
if (this.child === spawnedChild) {
|
|
this.child = undefined
|
|
this.childStartIdentity = undefined
|
|
this.updateStatus({ state: "stopped" })
|
|
}
|
|
}
|
|
|
|
getStatus(): CliStatus {
|
|
return { ...this.status }
|
|
}
|
|
|
|
getAuthCookieName(): string {
|
|
return this.authCookieName
|
|
}
|
|
|
|
private resolveListeningMode(): ListeningMode {
|
|
return readListeningModeFromConfig()
|
|
}
|
|
|
|
private handleTimeout() {
|
|
const timedOutChild = this.child
|
|
if (timedOutChild) {
|
|
const pid = timedOutChild.pid
|
|
if (pid) {
|
|
const deadlineAt = Date.now() + 5_000
|
|
const spawnedIdentity = this.childStartIdentity ?? Promise.resolve(undefined)
|
|
void captureInitialProcessTree(pid, process.platform, undefined, () => spawnedIdentity, deadlineAt).then(async ({ tree, rootStartIdentity }) => {
|
|
const latest = await captureProcessTree(pid, process.platform, undefined, Math.min(1_500, deadlineAt - Date.now()))
|
|
const processTree = mergeCapturedProcessTrees(tree, latest, pid, rootStartIdentity)
|
|
const forced = processTree ? await forceCapturedProcessTree(processTree, undefined, undefined, process.kill, { deadlineAt }) : false
|
|
if (!forced) console.warn(`[cli] startup-timeout process tree cleanup was not confirmed (pid=${pid})`)
|
|
else if (this.child === timedOutChild) {
|
|
this.child = undefined
|
|
this.childStartIdentity = undefined
|
|
}
|
|
}).catch((error) => console.warn(`[cli] startup-timeout process tree cleanup failed (pid=${pid})`, error))
|
|
}
|
|
}
|
|
this.updateStatus({ state: "error", error: "CLI did not start in time" })
|
|
this.emit("error", new Error("CLI did not start in time"))
|
|
}
|
|
|
|
private handleStream(chunk: string, stream: "stdout" | "stderr") {
|
|
if (stream === "stdout") {
|
|
this.stdoutBuffer += chunk
|
|
this.processBuffer("stdout")
|
|
} else {
|
|
this.stderrBuffer += chunk
|
|
this.processBuffer("stderr")
|
|
}
|
|
}
|
|
|
|
private processBuffer(stream: "stdout" | "stderr") {
|
|
const buffer = stream === "stdout" ? this.stdoutBuffer : this.stderrBuffer
|
|
const lines = buffer.split("\n")
|
|
const trailing = lines.pop() ?? ""
|
|
|
|
if (stream === "stdout") {
|
|
this.stdoutBuffer = trailing
|
|
} else {
|
|
this.stderrBuffer = trailing
|
|
}
|
|
|
|
for (const line of lines) {
|
|
const trimmed = line.trim()
|
|
if (!trimmed) continue
|
|
|
|
if (trimmed === SERVER_SHUTDOWN_COMPLETE) {
|
|
if (this.shutdownStatus === "incomplete") continue
|
|
this.shutdownStatus = "complete"
|
|
console.info("[cli] server confirmed graceful shutdown")
|
|
continue
|
|
}
|
|
if (trimmed === SERVER_SHUTDOWN_INCOMPLETE) {
|
|
if (this.shutdownStatus === "incomplete") continue
|
|
this.shutdownStatus = "incomplete"
|
|
console.warn("[cli] server reported incomplete cleanup; requesting final process-tree enforcement")
|
|
this.emit("shutdownIncomplete")
|
|
continue
|
|
}
|
|
if (trimmed.startsWith(BOOTSTRAP_TOKEN_PREFIX)) {
|
|
const token = trimmed.slice(BOOTSTRAP_TOKEN_PREFIX.length).trim()
|
|
if (token && !this.bootstrapToken) {
|
|
this.bootstrapToken = token
|
|
this.emit("bootstrapToken", token)
|
|
}
|
|
continue
|
|
}
|
|
|
|
console.info(`[cli][${stream}] ${trimmed}`)
|
|
this.emit("log", { stream, message: trimmed })
|
|
|
|
const localUrl = this.extractLocalUrl(trimmed)
|
|
if (localUrl && this.status.state === "starting") {
|
|
let port: number | undefined
|
|
try {
|
|
port = Number(new URL(localUrl).port) || undefined
|
|
} catch {
|
|
port = undefined
|
|
}
|
|
console.info(`[cli] ready on ${localUrl}`)
|
|
this.updateStatus({ state: "ready", port, url: localUrl })
|
|
this.emit("ready", this.status)
|
|
}
|
|
}
|
|
}
|
|
|
|
private extractLocalUrl(line: string): string | null {
|
|
const match = line.match(/^Local\s+Connection\s+URL\s*:\s*(https?:\/\/\S+)\s*$/i)
|
|
if (!match) {
|
|
return null
|
|
}
|
|
return match[1] ?? null
|
|
}
|
|
|
|
private updateStatus(patch: Partial<CliStatus>) {
|
|
this.status = { ...this.status, ...patch }
|
|
this.emit("status", this.status)
|
|
}
|
|
|
|
private buildCliArgs(options: StartOptions, host: string): string[] {
|
|
const args = ["serve", "--host", host, "--generate-token", "--auth-cookie-name", this.authCookieName, "--unrestricted-root"]
|
|
|
|
if (options.dev) {
|
|
// Dev: run plain HTTP + Vite dev server proxy.
|
|
args.push("--https", "false", "--http", "true")
|
|
// Avoid collisions with an already-running server (and dual-stack ::/0.0.0.0 quirks)
|
|
// by forcing an ephemeral port in dev.
|
|
args.push("--http-port", "0")
|
|
} else {
|
|
// Prod desktop: always keep loopback HTTP enabled.
|
|
args.push("--https", "true", "--http", "true")
|
|
}
|
|
|
|
if (options.dev) {
|
|
const devServer = process.env.VITE_DEV_SERVER_URL || process.env.ELECTRON_RENDERER_URL || "http://localhost:3000"
|
|
const rawLogLevel = (process.env.CLI_LOG_LEVEL ?? "info").trim()
|
|
const logLevel = rawLogLevel.length > 0 ? rawLogLevel.toLowerCase() : "info"
|
|
args.push("--ui-dev-server", devServer, "--log-level", logLevel)
|
|
}
|
|
|
|
return args
|
|
}
|
|
|
|
private buildCommand(cliEntry: CliEntryResolution, args: string[]): string {
|
|
const parts = [JSON.stringify(cliEntry.nodeBinaryPath)]
|
|
for (const nodeArg of cliEntry.nodeArgs ?? []) {
|
|
parts.push(JSON.stringify(nodeArg))
|
|
}
|
|
if (cliEntry.runner === "tsx" && cliEntry.runnerPath) {
|
|
parts.push(JSON.stringify(cliEntry.runnerPath))
|
|
}
|
|
parts.push(JSON.stringify(cliEntry.entry))
|
|
args.forEach((arg) => parts.push(JSON.stringify(arg)))
|
|
return parts.join(" ")
|
|
}
|
|
|
|
private buildDirectSpawn(cliEntry: CliEntryResolution, args: string[]) {
|
|
if (cliEntry.runner === "tsx") {
|
|
return { command: cliEntry.nodeBinaryPath, args: [...(cliEntry.nodeArgs ?? []), cliEntry.runnerPath!, cliEntry.entry, ...args] }
|
|
}
|
|
|
|
return { command: cliEntry.nodeBinaryPath, args: [...(cliEntry.nodeArgs ?? []), cliEntry.entry, ...args] }
|
|
}
|
|
|
|
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, nodeBinaryPath: process.execPath }
|
|
}
|
|
|
|
return {
|
|
entry: this.resolveProdEntry(),
|
|
runner: "node",
|
|
nodeBinaryPath: await ensureManagedNodeBinary(),
|
|
nodeArgs: ["--experimental-specifier-resolution=node"],
|
|
}
|
|
}
|
|
|
|
private resolveTsx(): string | null {
|
|
const candidates: Array<string | (() => string)> = [
|
|
() => nodeRequire.resolve("tsx/cli"),
|
|
() => nodeRequire.resolve("tsx/dist/cli.mjs"),
|
|
() => nodeRequire.resolve("tsx/dist/cli.cjs"),
|
|
path.resolve(process.cwd(), "node_modules", "tsx", "dist", "cli.mjs"),
|
|
path.resolve(process.cwd(), "node_modules", "tsx", "dist", "cli.cjs"),
|
|
path.resolve(process.cwd(), "..", "node_modules", "tsx", "dist", "cli.mjs"),
|
|
path.resolve(process.cwd(), "..", "node_modules", "tsx", "dist", "cli.cjs"),
|
|
path.resolve(process.cwd(), "..", "..", "node_modules", "tsx", "dist", "cli.mjs"),
|
|
path.resolve(process.cwd(), "..", "..", "node_modules", "tsx", "dist", "cli.cjs"),
|
|
path.resolve(app.getAppPath(), "..", "node_modules", "tsx", "dist", "cli.mjs"),
|
|
path.resolve(app.getAppPath(), "..", "node_modules", "tsx", "dist", "cli.cjs"),
|
|
]
|
|
|
|
for (const candidate of candidates) {
|
|
try {
|
|
const resolved = typeof candidate === "function" ? candidate() : candidate
|
|
if (resolved && existsSync(resolved)) {
|
|
return resolved
|
|
}
|
|
} catch {
|
|
continue
|
|
}
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
private resolveDevEntry(): string {
|
|
const entry = path.resolve(process.cwd(), "..", "server", "src", "index.ts")
|
|
if (!existsSync(entry)) {
|
|
throw new Error(`Dev CLI entry not found at ${entry}. Run npm run dev:electron from the repository root after installing dependencies.`)
|
|
}
|
|
return entry
|
|
}
|
|
|
|
private resolveProdEntry(): string {
|
|
const candidates = [
|
|
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) {
|
|
if (existsSync(candidate)) {
|
|
return candidate
|
|
}
|
|
}
|
|
|
|
throw new Error("Unable to locate the packaged CodeNomad server entrypoint (dist/bin.js). Rebuild the desktop bundle.")
|
|
}
|
|
|
|
}
|