Compare commits

...

18 Commits

Author SHA1 Message Date
Shantur Rathore
105714778b fix(ci): pin npm for publish workflow 2026-04-21 10:06:29 +01:00
Shantur Rathore
c9eea8c003 fix(tauri): require standalone server in desktop bundles 2026-04-21 08:33:53 +01:00
Shantur Rathore
25512e8dc1 fix(ci): install xdg-utils for Linux Tauri bundling 2026-04-21 07:55:32 +01:00
Shantur Rathore
f56d63d166 fix(tauri): strip native config addons from bundles 2026-04-21 07:51:53 +01:00
Shantur Rathore
8173030b1a fix(ci): log Linux Tauri bundle diagnostics 2026-04-21 07:35:22 +01:00
Shantur Rathore
73a97e64ba fix(tauri): let CI control platform CLI binaries 2026-04-20 23:35:13 +01:00
Shantur Rathore
a5f38ee625 fix(tauri): align packaged CLI with Linux bundler 2026-04-20 23:25:39 +01:00
Shantur Rathore
ca880451e7 fix(tauri): prune Bun from Linux app bundle 2026-04-20 23:14:16 +01:00
Shantur Rathore
4af8cc08b9 fix(ci): restore dev Linux Tauri bundling env 2026-04-20 23:04:32 +01:00
Shantur Rathore
b60d86116a fix(tauri): fall back to Node server on Linux 2026-04-20 22:54:21 +01:00
Shantur Rathore
76f14e2189 fix(ci): pin Linux Tauri CLI to known-good version 2026-04-20 21:12:56 +01:00
Shantur Rathore
9ecd5131a6 fix(ci): stabilize Linux Tauri AppImage bundling 2026-04-20 21:00:19 +01:00
Shantur Rathore
95f47ebbe4 fix(tauri): avoid AppImage linuxdeploy desktop alias conflict 2026-04-20 20:35:26 +01:00
Shantur Rathore
6c50564df6 fix(ci): align Tauri CLI with packaged desktop builds 2026-04-20 14:45:32 +01:00
Shantur Rathore
166edd2e30 fix(ci): align Node and Tauri versions for desktop builds 2026-04-20 14:30:29 +01:00
Shantur Rathore
79dbbd4cb4 fix(server): preserve streamed proxy bodies and strip hop headers 2026-04-20 14:13:05 +01:00
Shantur Rathore
1c2ec1558e fix(build): use bundled Bun for standalone server builds 2026-04-20 13:16:23 +01:00
Shantur Rathore
3b08bc3262 fix(desktop): align standalone server startup and proxy workspace instances 2026-04-20 12:58:56 +01:00
24 changed files with 1760 additions and 492 deletions

View File

@@ -53,7 +53,7 @@ on:
# least-privilege (e.g. dev CI uses read-only; releases grant write). # least-privilege (e.g. dev CI uses read-only; releases grant write).
env: env:
NODE_VERSION: 20 NODE_VERSION: 22
jobs: jobs:
build-macos: build-macos:
@@ -372,7 +372,7 @@ jobs:
if [ "$attempt" -gt 1 ]; then if [ "$attempt" -gt 1 ]; then
echo "Retrying Tauri CLI install (attempt $attempt)..." echo "Retrying Tauri CLI install (attempt $attempt)..."
fi fi
npm install @tauri-apps/cli@2.9.4 @tauri-apps/cli-darwin-x64@2.9.4 --no-save --no-audit --no-fund --workspaces=false npm install @tauri-apps/cli@2.10.1 @tauri-apps/cli-darwin-x64@2.10.1 --no-save --no-audit --no-fund --workspaces=false
node -e "require('@tauri-apps/cli'); console.log('Tauri CLI loaded')" && exit 0 node -e "require('@tauri-apps/cli'); console.log('Tauri CLI loaded')" && exit 0
done done
echo "Tauri CLI failed to load after retries" >&2 echo "Tauri CLI failed to load after retries" >&2
@@ -456,7 +456,7 @@ jobs:
if [ "$attempt" -gt 1 ]; then if [ "$attempt" -gt 1 ]; then
echo "Retrying Tauri CLI install (attempt $attempt)..." echo "Retrying Tauri CLI install (attempt $attempt)..."
fi fi
npm install @tauri-apps/cli@2.9.4 @tauri-apps/cli-darwin-arm64@2.9.4 --no-save --no-audit --no-fund --workspaces=false npm install @tauri-apps/cli@2.10.1 @tauri-apps/cli-darwin-arm64@2.10.1 --no-save --no-audit --no-fund --workspaces=false
node -e "require('@tauri-apps/cli'); console.log('Tauri CLI loaded')" && exit 0 node -e "require('@tauri-apps/cli'); console.log('Tauri CLI loaded')" && exit 0
done done
echo "Tauri CLI failed to load after retries" >&2 echo "Tauri CLI failed to load after retries" >&2
@@ -542,7 +542,7 @@ jobs:
if [ "$attempt" -gt 1 ]; then if [ "$attempt" -gt 1 ]; then
echo "Retrying Tauri CLI install (attempt $attempt)..." echo "Retrying Tauri CLI install (attempt $attempt)..."
fi fi
npm install @tauri-apps/cli@2.9.4 @tauri-apps/cli-win32-x64-msvc@2.9.4 --no-save --no-audit --no-fund --workspaces=false npm install @tauri-apps/cli@2.10.1 @tauri-apps/cli-win32-x64-msvc@2.10.1 --no-save --no-audit --no-fund --workspaces=false
node -e "require('@tauri-apps/cli'); console.log('Tauri CLI loaded')" && exit 0 node -e "require('@tauri-apps/cli'); console.log('Tauri CLI loaded')" && exit 0
done done
echo "Tauri CLI failed to load after retries" >&2 echo "Tauri CLI failed to load after retries" >&2
@@ -614,6 +614,7 @@ jobs:
sudo apt-get install -y \ sudo apt-get install -y \
build-essential \ build-essential \
pkg-config \ pkg-config \
xdg-utils \
libgtk-3-dev \ libgtk-3-dev \
libglib2.0-dev \ libglib2.0-dev \
libwebkit2gtk-4.1-dev \ libwebkit2gtk-4.1-dev \
@@ -642,6 +643,7 @@ jobs:
if [ "$attempt" -gt 1 ]; then if [ "$attempt" -gt 1 ]; then
echo "Retrying Tauri CLI install (attempt $attempt)..." echo "Retrying Tauri CLI install (attempt $attempt)..."
fi fi
# Tauri CLI 2.10.1 regresses Linux AppImage bundling in CI; keep Linux on the last known-good CLI.
npm install @tauri-apps/cli@2.9.4 @tauri-apps/cli-linux-x64-gnu@2.9.4 --no-save --no-audit --no-fund --workspaces=false npm install @tauri-apps/cli@2.9.4 @tauri-apps/cli-linux-x64-gnu@2.9.4 --no-save --no-audit --no-fund --workspaces=false
node -e "require('@tauri-apps/cli'); console.log('Tauri CLI loaded')" && exit 0 node -e "require('@tauri-apps/cli'); console.log('Tauri CLI loaded')" && exit 0
done done
@@ -741,6 +743,7 @@ jobs:
sudo apt-get install -y \ sudo apt-get install -y \
build-essential \ build-essential \
pkg-config \ pkg-config \
xdg-utils \
gcc-aarch64-linux-gnu \ gcc-aarch64-linux-gnu \
g++-aarch64-linux-gnu \ g++-aarch64-linux-gnu \
libgtk-3-dev:arm64 \ libgtk-3-dev:arm64 \

View File

@@ -46,7 +46,8 @@ jobs:
publish: publish:
runs-on: ubuntu-latest runs-on: ubuntu-latest
env: env:
NODE_VERSION: 20 NODE_VERSION: 22
PUBLISH_NPM_VERSION: 11.5.1
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -59,8 +60,15 @@ jobs:
node-version: ${{ env.NODE_VERSION }} node-version: ${{ env.NODE_VERSION }}
registry-url: https://registry.npmjs.org registry-url: https://registry.npmjs.org
- name: Ensure npm >=11.5.1 - name: Prepare pinned npm CLI
run: npm install -g npm@latest shell: bash
run: |
set -euo pipefail
tool_dir="$RUNNER_TEMP/publish-npm"
mkdir -p "$tool_dir"
npm install --prefix "$tool_dir" "npm@${PUBLISH_NPM_VERSION}" --no-audit --no-fund
echo "$tool_dir/node_modules/npm/bin" >> "$GITHUB_PATH"
"$tool_dir/node_modules/npm/bin/npm-cli.js" --version
- name: Install dependencies - name: Install dependencies
run: npm ci --workspaces run: npm ci --workspaces

View File

@@ -14,7 +14,7 @@ permissions:
contents: read contents: read
env: env:
NODE_VERSION: 20 NODE_VERSION: 22
jobs: jobs:
release-ui: release-ui:

View File

@@ -39,7 +39,7 @@ permissions:
contents: write contents: write
env: env:
NODE_VERSION: 20 NODE_VERSION: 22
jobs: jobs:
prepare-release: prepare-release:

1216
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -118,6 +118,8 @@ function loadLoadingScreen(window: BrowserWindow) {
loader.catch((error) => { loader.catch((error) => {
console.error("[cli] failed to load loading screen:", error) console.error("[cli] failed to load loading screen:", error)
}) })
return loader
} }
function getAllowedRendererOrigins(window?: BrowserWindow | null): string[] { function getAllowedRendererOrigins(window?: BrowserWindow | null): string[] {
@@ -291,7 +293,7 @@ function createWindow() {
showingLoadingScreen = true showingLoadingScreen = true
currentCliUrl = null currentCliUrl = null
clearWindowAllowedOrigin(window) clearWindowAllowedOrigin(window)
loadLoadingScreen(window) const loadingReady = loadLoadingScreen(window)
if (process.env.NODE_ENV === "development") { if (process.env.NODE_ENV === "development") {
window.webContents.openDevTools({ mode: "detach" }) window.webContents.openDevTools({ mode: "detach" })
@@ -310,11 +312,7 @@ function createWindow() {
showingLoadingScreen = false showingLoadingScreen = false
}) })
if (pendingCliUrl) { return loadingReady
const url = pendingCliUrl
pendingCliUrl = null
startCliPreload(url)
}
} }
function showLoadingScreen(force = false) { function showLoadingScreen(force = false) {
@@ -620,7 +618,8 @@ app.whenReady().then(() => {
// ignore // ignore
} }
startCli() const loadingReady = createWindow()
;(mainWindow as BrowserWindow & { __codenomadOpenRemoteWindow?: typeof openRemoteWindow }).__codenomadOpenRemoteWindow = openRemoteWindow
if (isMac) { if (isMac) {
session.defaultSession.setSpellCheckerEnabled(false) session.defaultSession.setSpellCheckerEnabled(false)
@@ -637,8 +636,11 @@ app.whenReady().then(() => {
} }
} }
createWindow() void loadingReady.finally(() => {
;(mainWindow as BrowserWindow & { __codenomadOpenRemoteWindow?: typeof openRemoteWindow }).__codenomadOpenRemoteWindow = openRemoteWindow setTimeout(() => {
void startCli()
}, 0)
})
app.on("certificate-error", (event, _webContents, url, error, _certificate, callback) => { app.on("certificate-error", (event, _webContents, url, error, _certificate, callback) => {
if (isInsecureOriginAllowed(url)) { if (isInsecureOriginAllowed(url)) {

View File

@@ -38,7 +38,7 @@ interface StartOptions {
interface CliEntryResolution { interface CliEntryResolution {
entry: string entry: string
runner: "node" | "tsx" runner: "node" | "tsx" | "standalone"
runnerPath?: string runnerPath?: string
} }
@@ -148,15 +148,15 @@ export class CliProcessManager extends EventEmitter {
const listeningMode = this.resolveListeningMode() const listeningMode = this.resolveListeningMode()
const host = resolveHostForMode(listeningMode) const host = resolveHostForMode(listeningMode)
const args = this.buildCliArgs(options, host) const args = this.buildCliArgs(options, host)
const cliEntry = this.resolveCliEntry(options)
let child: ManagedChild let child: ManagedChild
if (this.shouldUsePackagedShellSupervisor(options)) { if (this.shouldUsePackagedShellSupervisor(options, cliEntry)) {
const runtimePath = this.resolveShellNodeCommand()
const entryPath = this.resolveBundledProdEntry()
const supervisorPath = this.resolveCliSupervisorPath() const supervisorPath = this.resolveCliSupervisorPath()
const shellEnv = supportsUserShell() ? getUserShellEnv() : { ...process.env } 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({ const supervisorPayload = JSON.stringify({
command: shellCommand.command, command: shellCommand.command,
args: shellCommand.args, args: shellCommand.args,
@@ -164,28 +164,33 @@ export class CliProcessManager extends EventEmitter {
}) })
console.info( 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] utility supervisor: ${supervisorPath}`)
console.info(`[cli] shell command: ${shellCommand.command} ${shellCommand.args.join(" ")}`) console.info(`[cli] shell command: ${shellCommand.command} ${shellCommand.args.join(" ")}`)
child = utilityProcess.fork(supervisorPath, [supervisorPayload], { child = utilityProcess.fork(supervisorPath, [supervisorPayload], {
env: shellEnv, env: cliEntry.runner === "standalone" ? shellEnv : { ...shellEnv, ELECTRON_RUN_AS_NODE: "1" },
stdio: "pipe", stdio: "pipe",
serviceName: "CodeNomad CLI Supervisor", serviceName: "CodeNomad CLI Supervisor",
}) })
this.childLaunchMode = "utility" this.childLaunchMode = "utility"
} else { } else {
const cliEntry = this.resolveCliEntry(options)
console.info( console.info(
`[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) using ${cliEntry.runner} at ${cliEntry.entry} (host=${host})`, `[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) using ${cliEntry.runner} at ${cliEntry.entry} (host=${host})`,
) )
const env = supportsUserShell() ? getUserShellEnv() : { ...process.env } 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() 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) : this.buildDirectSpawn(cliEntry, args)
const detached = process.platform !== "win32" const detached = process.platform !== "win32"
@@ -563,6 +568,10 @@ export class CliProcessManager extends EventEmitter {
} }
private buildCommand(cliEntry: CliEntryResolution, args: string[]): string { private buildCommand(cliEntry: CliEntryResolution, args: string[]): string {
if (cliEntry.runner === "standalone") {
return this.buildExecutableCommand(cliEntry.entry, args)
}
const parts = [JSON.stringify(process.execPath)] const parts = [JSON.stringify(process.execPath)]
if (cliEntry.runner === "tsx" && cliEntry.runnerPath) { if (cliEntry.runner === "tsx" && cliEntry.runnerPath) {
parts.push(JSON.stringify(cliEntry.runnerPath)) parts.push(JSON.stringify(cliEntry.runnerPath))
@@ -577,6 +586,10 @@ export class CliProcessManager extends EventEmitter {
} }
private buildDirectSpawn(cliEntry: CliEntryResolution, args: string[]) { private buildDirectSpawn(cliEntry: CliEntryResolution, args: string[]) {
if (cliEntry.runner === "standalone") {
return { command: cliEntry.entry, args }
}
if (cliEntry.runner === "tsx") { if (cliEntry.runner === "tsx") {
return { command: process.execPath, args: [cliEntry.runnerPath!, cliEntry.entry, ...args] } return { command: process.execPath, args: [cliEntry.runnerPath!, cliEntry.entry, ...args] }
} }
@@ -594,8 +607,7 @@ export class CliProcessManager extends EventEmitter {
return { entry: devEntry, runner: "tsx", runnerPath: tsxPath } return { entry: devEntry, runner: "tsx", runnerPath: tsxPath }
} }
const distEntry = this.resolveProdEntry() return { entry: this.resolveStandaloneProdEntry(), runner: "standalone" }
return { entry: distEntry, runner: "node" }
} }
private resolveTsx(): string | null { private resolveTsx(): string | null {
@@ -635,20 +647,25 @@ export class CliProcessManager extends EventEmitter {
return entry return entry
} }
private resolveProdEntry(): string { private resolveStandaloneProdEntry(): string {
try { const executableName = process.platform === "win32" ? "codenomad-server.exe" : "codenomad-server"
const entry = nodeRequire.resolve("@neuralnomads/codenomad/dist/bin.js") const candidates = [
if (existsSync(entry)) { path.join(process.resourcesPath, "server", "dist", executableName),
return entry 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 { private shouldUsePackagedShellSupervisor(options: StartOptions, cliEntry: CliEntryResolution): boolean {
return !options.dev && app.isPackaged && process.platform === "darwin" return !options.dev && app.isPackaged && process.platform === "darwin" && cliEntry.runner !== "standalone"
} }
private resolveCliSupervisorPath(): string { private resolveCliSupervisorPath(): string {
@@ -666,26 +683,6 @@ export class CliProcessManager extends EventEmitter {
throw new Error("Unable to locate CodeNomad CLI supervisor script.") 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 { private describeUtilityProcessError(error: unknown): string {
if (error instanceof Error && error.message) { if (error instanceof Error && error.message) {
return error.message return error.message

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env node #!/usr/bin/env node
import { spawn } from "child_process" import { spawn } from "child_process"
import { existsSync } from "fs" import { existsSync, readFileSync } from "fs"
import path, { join } from "path" import path, { join } from "path"
import { fileURLToPath } from "url" import { fileURLToPath } from "url"
@@ -14,6 +14,46 @@ const npxCmd = process.platform === "win32" ? "npx.cmd" : "npx"
const nodeModulesPath = join(appDir, "node_modules") const nodeModulesPath = join(appDir, "node_modules")
const workspaceNodeModulesPath = join(workspaceRoot, "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 = { const platforms = {
mac: { mac: {
args: ["--mac", "--x64", "--arm64"], args: ["--mac", "--x64", "--arm64"],
@@ -105,6 +145,8 @@ async function build(platform) {
console.log(`\n🔨 Building for: ${config.description}\n`) console.log(`\n🔨 Building for: ${config.description}\n`)
try { try {
await ensureEsbuildPlatformBinary()
console.log("📦 Step 1/3: Building CLI dependency...\n") console.log("📦 Step 1/3: Building CLI dependency...\n")
await run(npmCmd, ["run", "build", "--workspace", "@neuralnomads/codenomad"], { await run(npmCmd, ["run", "build", "--workspace", "@neuralnomads/codenomad"], {
cwd: workspaceRoot, cwd: workspaceRoot,

View File

@@ -16,6 +16,7 @@ const npmNodeExecPath = process.env.npm_node_execpath
const serverSources = ["dist", "public", "node_modules", "package.json"] const serverSources = ["dist", "public", "node_modules", "package.json"]
const serverDepsMarker = join(serverRoot, "node_modules", "fastify", "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) { function log(message) {
console.log(`[prepare-resources] ${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() { function ensureServerDependencies() {
if (fs.existsSync(serverDepsMarker)) { if (fs.existsSync(serverDepsMarker)) {
return 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() { function copyServerArtifacts() {
fs.rmSync(serverDest, { recursive: true, force: true }) fs.rmSync(serverDest, { recursive: true, force: true })
fs.mkdirSync(serverDest, { recursive: true }) fs.mkdirSync(serverDest, { recursive: true })
@@ -121,7 +195,9 @@ function stripNodeModuleBins() {
async function main() { async function main() {
ensureServerBuild() ensureServerBuild()
ensureStandaloneServerBuild()
ensureServerDependencies() ensureServerDependencies()
ensureEsbuildPlatformBinary()
copyServerArtifacts() copyServerArtifacts()
stripNodeModuleBins() stripNodeModuleBins()
} }

View File

@@ -4,6 +4,6 @@
"private": true, "private": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@opencode-ai/plugin": "1.3.7" "@opencode-ai/plugin": "1.14.19"
} }
} }

View File

@@ -18,6 +18,7 @@
}, },
"scripts": { "scripts": {
"build": "npm run build:ui && npm run prepare-ui && tsc -p tsconfig.json && node ./scripts/copy-auth-pages.mjs && npm run prepare-config", "build": "npm run build:ui && npm run prepare-ui && tsc -p tsconfig.json && node ./scripts/copy-auth-pages.mjs && npm run prepare-config",
"build:standalone": "node ./scripts/build-standalone.mjs",
"build:ui": "npm run build --prefix ../ui", "build:ui": "npm run build --prefix ../ui",
"prepare-ui": "node ./scripts/copy-ui-dist.mjs", "prepare-ui": "node ./scripts/copy-ui-dist.mjs",
"prepare-config": "node ./scripts/copy-opencode-config.mjs", "prepare-config": "node ./scripts/copy-opencode-config.mjs",
@@ -25,16 +26,16 @@
"typecheck": "tsc --noEmit -p tsconfig.json" "typecheck": "tsc --noEmit -p tsconfig.json"
}, },
"dependencies": { "dependencies": {
"@fastify/cors": "^8.5.0", "@fastify/cors": "^11.2.0",
"@fastify/reply-from": "^9.8.0", "@fastify/reply-from": "^12.6.2",
"@fastify/static": "^7.0.4", "@fastify/static": "^9.1.1",
"commander": "^12.1.0", "commander": "^12.1.0",
"fastify": "^4.28.1", "fastify": "^5.8.5",
"fuzzysort": "^2.0.4", "fuzzysort": "^2.0.4",
"node-forge": "^1.3.3", "node-forge": "^1.3.3",
"openai": "^6.27.0", "openai": "^6.27.0",
"pino": "^9.4.0", "pino": "^9.4.0",
"undici": "^6.19.8", "undici": "^8.1.0",
"yaml": "^2.4.2", "yaml": "^2.4.2",
"yauzl": "^2.10.0", "yauzl": "^2.10.0",
"zod": "^3.23.8" "zod": "^3.23.8"
@@ -42,6 +43,7 @@
"devDependencies": { "devDependencies": {
"@types/node-forge": "^1.3.14", "@types/node-forge": "^1.3.14",
"@types/yauzl": "^2.10.0", "@types/yauzl": "^2.10.0",
"bun": "^1.3.13",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"tsx": "^4.20.6", "tsx": "^4.20.6",

View File

@@ -0,0 +1,99 @@
#!/usr/bin/env node
import fs from "fs"
import path from "path"
import { spawnSync } from "child_process"
import { fileURLToPath } from "url"
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const cliRoot = path.resolve(__dirname, "..")
const distDir = path.join(cliRoot, "dist")
const publicDir = path.join(cliRoot, "public")
const authPagesSourceDir = path.join(distDir, "server", "routes", "auth-pages")
const authPagesTargetDir = path.join(distDir, "auth-pages")
const explicitTarget = process.env.CODENOMAD_STANDALONE_TARGET?.trim()
const outputName = (explicitTarget?.includes("windows") || process.platform === "win32") ? "codenomad-server.exe" : "codenomad-server"
const outputPath = path.join(distDir, outputName)
const packageJsonPath = path.join(cliRoot, "package.json")
function resolveBunCommand() {
const executableName = process.platform === "win32" ? "bun.exe" : "bun"
const localBinName = process.platform === "win32" ? "bun.cmd" : "bun"
const candidates = [
path.join(cliRoot, "node_modules", ".bin", localBinName),
path.join(cliRoot, "..", "..", "node_modules", ".bin", localBinName),
path.join(cliRoot, "node_modules", "bun", "bin", executableName),
path.join(cliRoot, "..", "..", "node_modules", "bun", "bin", executableName),
]
for (const candidate of candidates) {
if (fs.existsSync(candidate)) {
return candidate
}
}
return "bun"
}
function fail(message) {
console.error(`[build-standalone] ${message}`)
process.exit(1)
}
function ensureArtifacts() {
const requiredPaths = [distDir, publicDir, authPagesSourceDir, packageJsonPath]
const missing = requiredPaths.filter((filePath) => !fs.existsSync(filePath))
if (missing.length > 0) {
fail(`Missing required build artifacts: ${missing.join(", ")}. Run npm run build first.`)
}
const bunResult = spawnSync(resolveBunCommand(), ["-v"], { cwd: cliRoot, encoding: "utf-8", shell: process.platform === "win32" })
if (bunResult.status !== 0) {
fail("Bun is required to build the standalone server executable. Install dependencies so the local Bun binary is available.")
}
}
function syncStandaloneAuthPages() {
fs.rmSync(authPagesTargetDir, { recursive: true, force: true })
fs.mkdirSync(path.dirname(authPagesTargetDir), { recursive: true })
fs.cpSync(authPagesSourceDir, authPagesTargetDir, { recursive: true })
}
function buildStandaloneExecutable() {
fs.rmSync(outputPath, { force: true })
const bunCommand = resolveBunCommand()
const args = ["build", "--compile"]
if (explicitTarget) {
args.push(`--target=${explicitTarget}`)
}
args.push(path.join(cliRoot, "src", "index.ts"), "--outfile", outputPath)
const result = spawnSync(bunCommand, args, {
cwd: cliRoot,
stdio: "inherit",
shell: process.platform === "win32",
})
if (result.status !== 0) {
if (result.error) {
throw result.error
}
throw new Error(`bun build --compile exited with code ${result.status ?? 1}`)
}
}
function main() {
ensureArtifacts()
syncStandaloneAuthPages()
buildStandaloneExecutable()
console.log(`[build-standalone] built ${outputPath}`)
}
try {
main()
} catch (error) {
console.error("[build-standalone] failed:", error)
process.exit(1)
}

View File

@@ -1,6 +1,6 @@
#!/usr/bin/env node #!/usr/bin/env node
import { spawnSync } from "child_process" import { spawnSync } from "child_process"
import { cpSync, existsSync, mkdirSync, rmSync } from "fs" import { cpSync, existsSync, mkdirSync, readdirSync, rmSync } from "fs"
import path from "path" import path from "path"
import { fileURLToPath } from "url" import { fileURLToPath } from "url"
@@ -14,6 +14,67 @@ const selfLinkDir = path.resolve(nodeModulesDir, "@codenomad", "opencode-config"
const npmExecPath = process.env.npm_execpath const npmExecPath = process.env.npm_execpath
const npmNodeExecPath = process.env.npm_node_execpath const npmNodeExecPath = process.env.npm_node_execpath
function stripNodeModuleBins(rootDir) {
const root = path.join(rootDir, "node_modules")
if (!existsSync(root)) {
return 0
}
const stack = [root]
let removed = 0
while (stack.length > 0) {
const current = stack.pop()
if (!current) break
let entries
try {
entries = readdirSync(current, { withFileTypes: true })
} catch {
continue
}
for (const entry of entries) {
const full = path.join(current, entry.name)
if (entry.name === ".bin") {
rmSync(full, { recursive: true, force: true })
removed += 1
continue
}
if (entry.isDirectory()) {
stack.push(full)
}
}
}
return removed
}
function stripOptionalNativeAddons(rootDir) {
const nodeModulesRoot = path.join(rootDir, "node_modules")
if (!existsSync(nodeModulesRoot)) {
return 0
}
const removablePaths = [
path.join(nodeModulesRoot, "@msgpackr-extract"),
path.join(nodeModulesRoot, "msgpackr-extract"),
]
let removed = 0
for (const targetPath of removablePaths) {
if (!existsSync(targetPath)) {
continue
}
rmSync(targetPath, { recursive: true, force: true })
removed += 1
}
return removed
}
if (!existsSync(sourceDir)) { if (!existsSync(sourceDir)) {
console.error(`[copy-opencode-config] Missing source directory at ${sourceDir}`) console.error(`[copy-opencode-config] Missing source directory at ${sourceDir}`)
process.exit(1) process.exit(1)
@@ -58,4 +119,14 @@ rmSync(targetDir, { recursive: true, force: true })
mkdirSync(path.dirname(targetDir), { recursive: true }) mkdirSync(path.dirname(targetDir), { recursive: true })
cpSync(sourceDir, targetDir, { recursive: true }) cpSync(sourceDir, targetDir, { recursive: true })
const removedBins = stripNodeModuleBins(targetDir)
if (removedBins > 0) {
console.log(`[copy-opencode-config] Removed ${removedBins} node_modules/.bin directories`)
}
const removedNativeAddons = stripOptionalNativeAddons(targetDir)
if (removedNativeAddons > 0) {
console.log(`[copy-opencode-config] Removed ${removedNativeAddons} optional native addon package paths`)
}
console.log(`[copy-opencode-config] Copied ${sourceDir} -> ${targetDir}`) console.log(`[copy-opencode-config] Copied ${sourceDir} -> ${targetDir}`)

View File

@@ -29,13 +29,14 @@ import { SideCarManager } from "./sidecars/manager"
import { ClientConnectionManager } from "./clients/connection-manager" import { ClientConnectionManager } from "./clients/connection-manager"
import { PluginChannelManager } from "./plugins/channel" import { PluginChannelManager } from "./plugins/channel"
import { VoiceModeManager } from "./plugins/voice-mode" import { VoiceModeManager } from "./plugins/voice-mode"
import { readServerPackageVersion, resolveServerPublicDir } from "./runtime-paths"
const require = createRequire(import.meta.url) const require = createRequire(import.meta.url)
const packageJson = require("../package.json") as { version: string } const packageJson = { version: readServerPackageVersion(import.meta.url) }
const __filename = fileURLToPath(import.meta.url) const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename) const __dirname = path.dirname(__filename)
const DEFAULT_UI_STATIC_DIR = path.resolve(__dirname, "../public") const DEFAULT_UI_STATIC_DIR = resolveServerPublicDir(import.meta.url)
interface CliOptions { interface CliOptions {
host: string host: string

View File

@@ -1,22 +1,11 @@
import { existsSync } from "fs" import { existsSync } from "fs"
import path from "path"
import { fileURLToPath } from "url"
import { createLogger } from "./logger" import { createLogger } from "./logger"
import { resolveOpencodeTemplateDir } from "./runtime-paths"
const log = createLogger({ component: "opencode-config" }) const log = createLogger({ component: "opencode-config" })
const __filename = fileURLToPath(import.meta.url) const templateDir = resolveOpencodeTemplateDir(import.meta.url)
const __dirname = path.dirname(__filename)
const devTemplateDir = path.resolve(__dirname, "../../opencode-config")
const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath
const prodTemplateDirs = [
resourcesPath ? path.resolve(resourcesPath, "opencode-config") : undefined,
path.resolve(__dirname, "opencode-config"),
].filter((dir): dir is string => Boolean(dir))
const isDevBuild = Boolean(process.env.CODENOMAD_DEV ?? process.env.CLI_UI_DEV_SERVER) || existsSync(devTemplateDir) const isDevBuild = Boolean(process.env.CODENOMAD_DEV ?? process.env.CLI_UI_DEV_SERVER)
const templateDir = isDevBuild
? devTemplateDir
: prodTemplateDirs.find((dir) => existsSync(dir)) ?? prodTemplateDirs[0]
export function getOpencodeConfigDir(): string { export function getOpencodeConfigDir(): string {
if (!existsSync(templateDir)) { if (!existsSync(templateDir)) {

View File

@@ -0,0 +1,79 @@
import fs from "fs"
import path from "path"
import { fileURLToPath } from "url"
function safeModuleDir(importMetaUrl: string): string | null {
try {
return path.dirname(fileURLToPath(importMetaUrl))
} catch {
return null
}
}
function firstExistingPath(candidates: Array<string | null | undefined>, predicate: (value: string) => boolean): string | null {
for (const candidate of candidates) {
if (!candidate) continue
if (predicate(candidate)) {
return candidate
}
}
return null
}
export function getPackagedDistDir(): string {
return path.dirname(process.execPath)
}
export function resolveServerPackageRoot(importMetaUrl: string): string {
const moduleDir = safeModuleDir(importMetaUrl)
const configuredRoot = process.env.CODENOMAD_SERVER_ROOT?.trim()
const candidates = [
configuredRoot ? path.resolve(configuredRoot) : null,
moduleDir ? path.resolve(moduleDir, "..") : null,
path.resolve(getPackagedDistDir(), ".."),
]
return (
firstExistingPath(candidates, (value) => fs.existsSync(path.join(value, "package.json"))) ??
candidates.find((value): value is string => Boolean(value)) ??
process.cwd()
)
}
export function resolveServerPublicDir(importMetaUrl: string): string {
const moduleDir = safeModuleDir(importMetaUrl)
const candidates = [moduleDir ? path.resolve(moduleDir, "../public") : null, path.join(resolveServerPackageRoot(importMetaUrl), "public")]
return firstExistingPath(candidates, (value) => fs.existsSync(value)) ?? candidates[candidates.length - 1]!
}
export function resolveAuthTemplatePath(importMetaUrl: string, fileName: string): string {
const moduleDir = safeModuleDir(importMetaUrl)
const distDir = getPackagedDistDir()
const candidates = [
moduleDir ? path.join(moduleDir, "auth-pages", fileName) : null,
path.join(distDir, "auth-pages", fileName),
path.join(distDir, "server", "routes", "auth-pages", fileName),
]
return firstExistingPath(candidates, (value) => fs.existsSync(value)) ?? candidates[0]!
}
export function resolveOpencodeTemplateDir(importMetaUrl: string): string {
const moduleDir = safeModuleDir(importMetaUrl)
const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath
const candidates = [
moduleDir ? path.resolve(moduleDir, "../../opencode-config") : null,
resourcesPath ? path.resolve(resourcesPath, "opencode-config") : null,
moduleDir ? path.resolve(moduleDir, "opencode-config") : null,
path.join(getPackagedDistDir(), "opencode-config"),
]
return firstExistingPath(candidates, (value) => fs.existsSync(value)) ?? candidates[candidates.length - 1]!
}
export function readServerPackageVersion(importMetaUrl: string): string {
const packageJsonPath = path.join(resolveServerPackageRoot(importMetaUrl), "package.json")
const parsed = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8")) as { version?: unknown }
return typeof parsed.version === "string" && parsed.version.trim().length > 0 ? parsed.version : "0.0.0"
}

View File

@@ -5,6 +5,8 @@ import replyFrom from "@fastify/reply-from"
import fs from "fs" import fs from "fs"
import { connect as connectTcp, type Socket } from "net" import { connect as connectTcp, type Socket } from "net"
import path from "path" import path from "path"
import { Readable } from "stream"
import { pipeline } from "stream/promises"
import { connect as connectTls, type TLSSocket } from "tls" import { connect as connectTls, type TLSSocket } from "tls"
import { fetch } from "undici" import { fetch } from "undici"
import type { Logger } from "../logger" import type { Logger } from "../logger"
@@ -626,57 +628,57 @@ async function proxyWorkspaceRequest(args: {
logger.trace({ workspaceId, targetUrl, body: request.body }, "Instance proxy payload") logger.trace({ workspaceId, targetUrl, body: request.body }, "Instance proxy payload")
} }
return reply.from(targetUrl, { const headers = buildWorkspaceInstanceProxyHeaders(request.headers, instanceAuthHeader, directory)
rewriteRequestHeaders: (_originalRequest, headers) => {
if (instanceAuthHeader) {
headers.authorization = instanceAuthHeader
}
// OpenCode expects the *full* path; we send it via header to avoid query tampering. if (logger.isLevelEnabled("trace")) {
const isNonASCII = /[^\x00-\x7F]/.test(directory) logger.trace(
const encodedDirectory = isNonASCII ? encodeURIComponent(directory) : directory {
workspaceId,
method: request.method,
targetUrl,
worktreeSlug,
directory,
contentType: request.headers["content-type"],
body: bodyToJson(request.body),
headers: redactProxyHeadersForLogs(headers),
},
"Proxy -> OpenCode request",
)
}
// Overwrite any client-provided value (case-insensitive headers are normalized by Node). const init: any = {
;(headers as Record<string, unknown>)["x-opencode-directory"] = encodedDirectory method: request.method,
headers,
redirect: "manual",
}
if (logger.isLevelEnabled("trace")) { if (request.method !== "GET" && request.method !== "HEAD") {
const outgoing: Record<string, unknown> = {} const body = toProxyRequestBody(request.body)
for (const [key, value] of Object.entries(headers as Record<string, unknown>)) { if (body !== undefined) {
outgoing[key] = value init.body = body
} init.duplex = "half"
}
}
// Redact sensitive headers. try {
for (const key of Object.keys(outgoing)) { const response = await fetch(targetUrl, init)
const lower = key.toLowerCase() reply.code(response.status)
if (lower === "authorization" || lower === "cookie" || lower === "set-cookie") { applyInstanceProxyResponseHeaders(reply, response)
outgoing[key] = "<redacted>"
}
}
logger.trace( if (!response.body || request.method === "HEAD") {
{ reply.send()
workspaceId, return
method: request.method, }
targetUrl,
worktreeSlug,
directory,
contentType: request.headers["content-type"],
body: bodyToJson(request.body),
headers: outgoing,
},
"Proxy -> OpenCode request",
)
}
return headers reply.hijack()
}, reply.raw.writeHead(reply.statusCode, toOutgoingHeaders(reply.getHeaders()))
onError: (proxyReply, { error }) => { await pipeline(Readable.fromWeb(response.body as any), reply.raw)
logger.error({ err: error, workspaceId, targetUrl }, "Failed to proxy workspace request") } catch (error) {
if (!proxyReply.sent) { logger.error({ err: error, workspaceId, targetUrl }, "Failed to proxy workspace request")
proxyReply.code(502).send({ error: "Workspace instance proxy failed" }) if (!reply.sent) {
} reply.code(502).send({ error: "Workspace instance proxy failed" })
}, }
}) }
} }
function extractOpencodeDirectoryOverride(pathSuffix: string | undefined): { function extractOpencodeDirectoryOverride(pathSuffix: string | undefined): {
@@ -867,12 +869,90 @@ function isApiRequest(rawUrl: string | null | undefined) {
function buildProxyHeaders(headers: FastifyRequest["headers"]): Record<string, string> { function buildProxyHeaders(headers: FastifyRequest["headers"]): Record<string, string> {
const result: Record<string, string> = {} const result: Record<string, string> = {}
for (const [key, value] of Object.entries(headers ?? {})) { for (const [key, value] of Object.entries(headers ?? {})) {
if (!value || key.toLowerCase() === "host") continue const lower = key.toLowerCase()
if (!value || lower === "host" || isHopByHopHeader(lower)) continue
result[key] = Array.isArray(value) ? value.join(",") : value result[key] = Array.isArray(value) ? value.join(",") : value
} }
return result return result
} }
function toProxyRequestBody(body: unknown): any {
if (body == null) {
return undefined
}
if (typeof (body as { pipe?: unknown }).pipe === "function") {
return body
}
if (typeof (body as { [Symbol.asyncIterator]?: unknown })[Symbol.asyncIterator] === "function") {
return body
}
if (Buffer.isBuffer(body) || typeof body === "string" || body instanceof Uint8Array) {
return body
}
return JSON.stringify(body)
}
function buildWorkspaceInstanceProxyHeaders(
headers: FastifyRequest["headers"],
instanceAuthHeader: string | undefined,
directory: string,
): Record<string, string> {
const next = buildProxyHeaders(headers)
if (instanceAuthHeader) {
next.authorization = instanceAuthHeader
}
const isNonASCII = /[^\x00-\x7F]/.test(directory)
next["x-opencode-directory"] = isNonASCII ? encodeURIComponent(directory) : directory
return next
}
function redactProxyHeadersForLogs(headers: Record<string, string>): Record<string, string> {
const outgoing = { ...headers }
for (const key of Object.keys(outgoing)) {
const lower = key.toLowerCase()
if (lower === "authorization" || lower === "cookie" || lower === "set-cookie") {
outgoing[key] = "<redacted>"
}
}
return outgoing
}
function applyInstanceProxyResponseHeaders(reply: FastifyReply, response: any) {
response.headers.forEach((value: string, key: string) => {
const lower = key.toLowerCase()
if (isHopByHopHeader(lower) || lower === "content-length" || lower === "content-encoding") {
return
}
reply.header(key, value)
})
}
function toOutgoingHeaders(headers: ReturnType<FastifyReply["getHeaders"]>): Record<string, string | string[]> {
const next: Record<string, string | string[]> = {}
for (const [key, value] of Object.entries(headers)) {
if (value === undefined) {
continue
}
next[key] = Array.isArray(value) ? value.map(String) : String(value)
}
return next
}
function isHopByHopHeader(name: string): boolean {
return new Set([
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"transfer-encoding",
"upgrade",
]).has(name)
}
async function proxySideCarRequest(args: { async function proxySideCarRequest(args: {
request: FastifyRequest request: FastifyRequest
reply: FastifyReply reply: FastifyReply

View File

@@ -3,6 +3,7 @@ import fs from "fs"
import { z } from "zod" import { z } from "zod"
import type { AuthManager } from "../../auth/manager" import type { AuthManager } from "../../auth/manager"
import { isLoopbackAddress } from "../../auth/http-auth" import { isLoopbackAddress } from "../../auth/http-auth"
import { resolveAuthTemplatePath } from "../../runtime-paths"
interface RouteDeps { interface RouteDeps {
authManager: AuthManager authManager: AuthManager
@@ -21,21 +22,21 @@ const PasswordSchema = z.object({
password: z.string().min(8), password: z.string().min(8),
}) })
const LOGIN_TEMPLATE_URL = new URL("./auth-pages/login.html", import.meta.url) const LOGIN_TEMPLATE_PATH = resolveAuthTemplatePath(import.meta.url, "login.html")
const TOKEN_TEMPLATE_URL = new URL("./auth-pages/token.html", import.meta.url) const TOKEN_TEMPLATE_PATH = resolveAuthTemplatePath(import.meta.url, "token.html")
let cachedLoginTemplate: string | null = null let cachedLoginTemplate: string | null = null
let cachedTokenTemplate: string | null = null let cachedTokenTemplate: string | null = null
function readTemplate(url: URL, cache: string | null): string { function readTemplate(filePath: string, cache: string | null): string {
if (cache) return cache if (cache) return cache
const content = fs.readFileSync(url, "utf-8") const content = fs.readFileSync(filePath, "utf-8")
return content return content
} }
function getLoginHtml(defaultUsername: string): string { function getLoginHtml(defaultUsername: string): string {
if (!cachedLoginTemplate) { if (!cachedLoginTemplate) {
cachedLoginTemplate = readTemplate(LOGIN_TEMPLATE_URL, null) cachedLoginTemplate = readTemplate(LOGIN_TEMPLATE_PATH, null)
} }
const escapedUsername = escapeHtml(defaultUsername) const escapedUsername = escapeHtml(defaultUsername)
@@ -44,7 +45,7 @@ function getLoginHtml(defaultUsername: string): string {
function getTokenHtml(): string { function getTokenHtml(): string {
if (!cachedTokenTemplate) { if (!cachedTokenTemplate) {
cachedTokenTemplate = readTemplate(TOKEN_TEMPLATE_URL, null) cachedTokenTemplate = readTemplate(TOKEN_TEMPLATE_PATH, null)
} }
return cachedTokenTemplate return cachedTokenTemplate

View File

@@ -21,6 +21,70 @@ import {
const STARTUP_STABILITY_DELAY_MS = 1500 const STARTUP_STABILITY_DELAY_MS = 1500
function defaultShellPath(): string {
const configured = process.env.SHELL?.trim()
if (configured) {
return configured
}
return process.platform === "darwin" ? "/bin/zsh" : "/bin/bash"
}
function shellEscape(input: string): string {
if (!input) return "''"
return `'${input.replace(/'/g, `'\\''`)}'`
}
function wrapCommandForShell(command: string, shellPath: string): string {
const shellName = path.basename(shellPath).toLowerCase()
if (shellName.includes("bash")) {
return `if [ -f ~/.bashrc ]; then source ~/.bashrc >/dev/null 2>&1; fi; ${command}`
}
if (shellName.includes("zsh")) {
return `if [ -f ~/.zshrc ]; then source ~/.zshrc >/dev/null 2>&1; fi; ${command}`
}
return command
}
function buildShellArgs(shellPath: string, command: string): string[] {
const shellName = path.basename(shellPath).toLowerCase()
if (shellName.includes("zsh")) {
return ["-l", "-i", "-c", command]
}
return ["-l", "-c", command]
}
function resolveBinaryPathFromUserShell(identifier: string): string | null {
if (process.platform === "win32") {
return null
}
const shellPath = defaultShellPath()
const lookupCommand = wrapCommandForShell(`command -v ${shellEscape(identifier)}`, shellPath)
const result = spawnSync(shellPath, buildShellArgs(shellPath, lookupCommand), {
encoding: "utf8",
env: {
...process.env,
npm_config_prefix: undefined,
NPM_CONFIG_PREFIX: undefined,
},
})
if (result.status !== 0) {
return null
}
const resolved = String(result.stdout ?? "")
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => line.length > 0)
return resolved ?? null
}
interface WorkspaceManagerOptions { interface WorkspaceManagerOptions {
rootDir: string rootDir: string
settings: SettingsService settings: SettingsService
@@ -266,6 +330,12 @@ export class WorkspaceManager {
this.options.logger.warn({ identifier, err: error }, "Failed to resolve binary path from system PATH") this.options.logger.warn({ identifier, err: error }, "Failed to resolve binary path from system PATH")
} }
const shellResolved = resolveBinaryPathFromUserShell(identifier)
if (shellResolved) {
this.options.logger.debug({ identifier, resolved: shellResolved }, "Resolved binary path from user shell")
return shellResolved
}
return identifier return identifier
} }

View File

@@ -14,6 +14,6 @@
"build": "tauri build" "build": "tauri build"
}, },
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "^2.9.4" "@tauri-apps/cli": "^2.10.1"
} }
} }

View File

@@ -21,6 +21,7 @@ const serverDevInstallCommand =
const uiDevInstallCommand = const uiDevInstallCommand =
"npm install --workspace @codenomad/ui --include-workspace-root=false --install-strategy=nested --fund=false --audit=false" "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 serverPrepareUiCommand = "npm run prepare-ui --workspace @neuralnomads/codenomad"
const serverStandaloneBuildCommand = "npm run build:standalone --workspace @neuralnomads/codenomad"
const envWithRootBin = { const envWithRootBin = {
...process.env, ...process.env,
@@ -77,6 +78,15 @@ function ensureServerBuild() {
} }
} }
function ensureStandaloneServerBuild() {
console.log("[prebuild] building standalone server executable...")
execSync(serverStandaloneBuildCommand, {
cwd: workspaceRoot,
stdio: "inherit",
env: envWithRootBin,
})
}
function ensureUiBuild() { function ensureUiBuild() {
const loadingHtml = path.join(uiDist, "loading.html") const loadingHtml = path.join(uiDist, "loading.html")
if (fs.existsSync(loadingHtml)) { if (fs.existsSync(loadingHtml)) {
@@ -117,15 +127,19 @@ function ensureServerDevDependencies() {
} }
function ensureServerDependencies() { function ensureServerDependencies() {
if (fs.existsSync(braceExpansionPath)) { console.log("[prebuild] pruning server to production dependencies...")
return execSync("npm prune --omit=dev --ignore-scripts --workspaces=false --fund=false --audit=false", {
}
console.log("[prebuild] ensuring server production dependencies...")
execSync(serverInstallCommand, {
cwd: serverRoot, cwd: serverRoot,
stdio: "inherit", stdio: "inherit",
}) })
if (!fs.existsSync(braceExpansionPath)) {
console.log("[prebuild] restoring missing server production dependencies...")
execSync(serverInstallCommand, {
cwd: serverRoot,
stdio: "inherit",
})
}
} }
function ensureUiDevDependencies() { function ensureUiDevDependencies() {
@@ -178,6 +192,47 @@ function ensureRollupPlatformBinary() {
}) })
} }
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() { function copyServerArtifacts() {
fs.rmSync(serverDest, { recursive: true, force: true }) fs.rmSync(serverDest, { recursive: true, force: true })
fs.mkdirSync(serverDest, { recursive: true }) fs.mkdirSync(serverDest, { recursive: true })
@@ -256,8 +311,10 @@ function copyUiLoadingAssets() {
ensureUiDevDependencies() ensureUiDevDependencies()
await ensureMonacoAssets() await ensureMonacoAssets()
ensureRollupPlatformBinary() ensureRollupPlatformBinary()
ensureServerDependencies() ensureEsbuildPlatformBinary()
ensureServerBuild() ensureServerBuild()
ensureStandaloneServerBuild()
ensureServerDependencies()
ensureUiBuild() ensureUiBuild()
syncServerUiBundle() syncServerUiBundle()
copyServerArtifacts() copyServerArtifacts()

View File

@@ -5,10 +5,10 @@ edition = "2021"
license = "MIT" license = "MIT"
[build-dependencies] [build-dependencies]
tauri-build = { version = "2.5.2", features = [] } tauri-build = { version = "2.5.6", features = [] }
[dependencies] [dependencies]
tauri = { version = "2.5.2", features = [ "devtools"] } tauri = { version = "2.10.1", features = [ "devtools"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
serde_yaml = "0.9" serde_yaml = "0.9"

View File

@@ -136,6 +136,10 @@ fn workspace_root() -> Option<PathBuf> {
}) })
} }
fn launch_cwd() -> Option<PathBuf> {
std::env::current_dir().ok()
}
const SESSION_COOKIE_NAME_PREFIX: &str = "codenomad_session"; const SESSION_COOKIE_NAME_PREFIX: &str = "codenomad_session";
const CLI_STOP_GRACE_SECS: u64 = 30; const CLI_STOP_GRACE_SECS: u64 = 30;
@@ -624,16 +628,19 @@ impl CliProcessManager {
log_line("development mode: will prefer tsx + source if present"); log_line("development mode: will prefer tsx + source if present");
} }
let cwd = workspace_root(); let cwd = launch_cwd();
if let Some(ref c) = cwd { if let Some(ref c) = cwd {
log_line(&format!("using cwd={}", c.display())); log_line(&format!("using cwd={}", c.display()));
} }
let use_user_shell = supports_user_shell(); let use_user_shell = supports_user_shell();
if !use_user_shell && which::which(&resolution.node_binary).is_err() { if resolution.runner == Runner::Tsx
&& !use_user_shell
&& which::which(&resolution.node_binary).is_err()
{
return Err(anyhow::anyhow!( return Err(anyhow::anyhow!(
"Node binary '{}' not found. CodeNomad desktop currently requires Node.js installed on the system, or set NODE_BINARY to a valid runtime path.", "Node binary '{}' not found. CodeNomad development mode requires Node.js installed on the system, or set NODE_BINARY to a valid runtime path.",
resolution.node_binary resolution.node_binary
)); ));
} }
@@ -642,9 +649,17 @@ impl CliProcessManager {
log_line("spawning via user shell"); log_line("spawning via user shell");
ShellCommandType::UserShell(build_shell_command_string(&resolution, &args)?) ShellCommandType::UserShell(build_shell_command_string(&resolution, &args)?)
} else { } else {
log_line("spawning directly with node"); log_line(if resolution.runner == Runner::Standalone {
"spawning directly with standalone executable"
} else {
"spawning directly with node"
});
ShellCommandType::Direct(DirectCommand { ShellCommandType::Direct(DirectCommand {
program: resolution.node_binary.clone(), program: if resolution.runner == Runner::Standalone {
resolution.entry.clone()
} else {
resolution.node_binary.clone()
},
args: resolution.runner_args(&args), args: resolution.runner_args(&args),
}) })
}; };
@@ -654,11 +669,13 @@ impl CliProcessManager {
log_line(&format!("spawn command: {} {:?}", cmd.shell, cmd.args)); log_line(&format!("spawn command: {} {:?}", cmd.shell, cmd.args));
let mut c = Command::new(&cmd.shell); let mut c = Command::new(&cmd.shell);
c.args(&cmd.args) c.args(&cmd.args)
.env("ELECTRON_RUN_AS_NODE", "1")
.env_remove("npm_config_prefix") .env_remove("npm_config_prefix")
.env_remove("NPM_CONFIG_PREFIX") .env_remove("NPM_CONFIG_PREFIX")
.stdout(Stdio::piped()) .stdout(Stdio::piped())
.stderr(Stdio::piped()); .stderr(Stdio::piped());
if resolution.runner != Runner::Standalone {
c.env("ELECTRON_RUN_AS_NODE", "1");
}
configure_spawn(&mut c); configure_spawn(&mut c);
if let Some(ref cwd) = cwd { if let Some(ref cwd) = cwd {
c.current_dir(cwd); c.current_dir(cwd);
@@ -671,9 +688,11 @@ impl CliProcessManager {
log_line(&format!("spawn command: {} {:?}", cmd.program, cmd.args)); log_line(&format!("spawn command: {} {:?}", cmd.program, cmd.args));
let mut c = Command::new(&cmd.program); let mut c = Command::new(&cmd.program);
c.args(&cmd.args) c.args(&cmd.args)
.env("ELECTRON_RUN_AS_NODE", "1")
.stdout(Stdio::piped()) .stdout(Stdio::piped())
.stderr(Stdio::piped()); .stderr(Stdio::piped());
if resolution.runner != Runner::Standalone {
c.env("ELECTRON_RUN_AS_NODE", "1");
}
configure_spawn(&mut c); configure_spawn(&mut c);
if let Some(ref cwd) = cwd { if let Some(ref cwd) = cwd {
c.current_dir(cwd); c.current_dir(cwd);
@@ -924,7 +943,7 @@ impl CliProcessManager {
let mut locked = status.lock(); let mut locked = status.lock();
if locked.error.is_none() { if locked.error.is_none() {
locked.error = Some(format!( locked.error = Some(format!(
"Node binary '{}' not found in the desktop shell environment. CodeNomad desktop currently requires Node.js installed on the system, or set NODE_BINARY to a valid runtime path.", "Node binary '{}' not found in the desktop shell environment. CodeNomad development mode requires Node.js installed on the system, or set NODE_BINARY to a valid runtime path.",
node_binary.trim() node_binary.trim()
)); ));
} }
@@ -1047,7 +1066,7 @@ struct CliEntry {
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Runner { enum Runner {
Node, Standalone,
Tsx, Tsx,
} }
@@ -1068,17 +1087,17 @@ impl CliEntry {
} }
} }
if let Some(entry) = resolve_dist_entry(app) { if let Some(entry) = resolve_standalone_entry(app) {
return Ok(Self { return Ok(Self {
entry, entry,
runner: Runner::Node, runner: Runner::Standalone,
runner_path: None, runner_path: None,
node_binary, node_binary: String::new(),
}); });
} }
Err(anyhow::anyhow!( Err(anyhow::anyhow!(
"Unable to locate CodeNomad CLI build (dist/bin.js). Please build @neuralnomads/codenomad." "Unable to locate the packaged CodeNomad standalone server. Please rebuild the desktop bundle."
)) ))
} }
@@ -1132,6 +1151,10 @@ impl CliEntry {
} }
fn runner_args(&self, cli_args: &[String]) -> Vec<String> { fn runner_args(&self, cli_args: &[String]) -> Vec<String> {
if self.runner == Runner::Standalone {
return cli_args.to_vec();
}
let mut args = VecDeque::new(); let mut args = VecDeque::new();
if self.runner == Runner::Tsx { if self.runner == Runner::Tsx {
if let Some(path) = &self.runner_path { if let Some(path) = &self.runner_path {
@@ -1204,45 +1227,37 @@ fn resolve_dev_entry(_app: &AppHandle) -> Option<String> {
first_existing(candidates) first_existing(candidates)
} }
fn resolve_dist_entry(_app: &AppHandle) -> Option<String> { fn resolve_standalone_entry(_app: &AppHandle) -> Option<String> {
let executable_name = if cfg!(windows) {
"codenomad-server.exe"
} else {
"codenomad-server"
};
let base = workspace_root(); let base = workspace_root();
let mut candidates: Vec<Option<PathBuf>> = vec![ let mut candidates = vec![base
base.as_ref().map(|p| p.join("packages/server/dist/bin.js")), .as_ref()
base.as_ref() .map(|p| p.join("packages/server/dist").join(executable_name))];
.map(|p| p.join("packages/server/dist/index.js")),
base.as_ref().map(|p| p.join("server/dist/bin.js")),
base.as_ref().map(|p| p.join("server/dist/index.js")),
];
if let Ok(exe) = std::env::current_exe() { if let Ok(exe) = std::env::current_exe() {
if let Some(dir) = exe.parent() { if let Some(dir) = exe.parent() {
candidates.push(Some(dir.join("resources/server/dist/bin.js"))); candidates.push(Some(
candidates.push(Some(dir.join("resources/server/dist/index.js"))); dir.join("resources/server/dist").join(executable_name),
candidates.push(Some(dir.join("resources/server/dist/server/bin.js"))); ));
candidates.push(Some(dir.join("resources/server/dist/server/index.js")));
let resources = dir.join("../Resources"); let resources = dir.join("../Resources");
candidates.push(Some(resources.join("server/dist/bin.js"))); candidates.push(Some(resources.join("server/dist").join(executable_name)));
candidates.push(Some(resources.join("server/dist/index.js")));
candidates.push(Some(resources.join("server/dist/server/bin.js")));
candidates.push(Some(resources.join("server/dist/server/index.js")));
candidates.push(Some(resources.join("resources/server/dist/bin.js")));
candidates.push(Some(resources.join("resources/server/dist/index.js")));
candidates.push(Some(resources.join("resources/server/dist/server/bin.js")));
candidates.push(Some( candidates.push(Some(
resources.join("resources/server/dist/server/index.js"), resources
.join("resources/server/dist")
.join(executable_name),
)); ));
let linux_resource_roots = [dir.join("../lib/CodeNomad"), dir.join("../lib/codenomad")]; let linux_resource_roots = [dir.join("../lib/CodeNomad"), dir.join("../lib/codenomad")];
for root in linux_resource_roots { for root in linux_resource_roots {
candidates.push(Some(root.join("server/dist/bin.js"))); candidates.push(Some(root.join("server/dist").join(executable_name)));
candidates.push(Some(root.join("server/dist/index.js"))); candidates.push(Some(
candidates.push(Some(root.join("server/dist/server/bin.js"))); root.join("resources/server/dist").join(executable_name),
candidates.push(Some(root.join("server/dist/server/index.js"))); ));
candidates.push(Some(root.join("resources/server/dist/bin.js")));
candidates.push(Some(root.join("resources/server/dist/index.js")));
candidates.push(Some(root.join("resources/server/dist/server/bin.js")));
candidates.push(Some(root.join("resources/server/dist/server/index.js")));
} }
} }
} }
@@ -1256,22 +1271,55 @@ fn build_shell_command_string(
) -> anyhow::Result<ShellCommand> { ) -> anyhow::Result<ShellCommand> {
let shell = default_shell(); let shell = default_shell();
let mut quoted: Vec<String> = Vec::new(); let mut quoted: Vec<String> = Vec::new();
quoted.push(shell_escape(&entry.node_binary)); let command = if entry.runner == Runner::Standalone {
for arg in entry.runner_args(cli_args) { quoted.push(shell_escape(&entry.entry));
quoted.push(shell_escape(&arg)); for arg in cli_args {
} quoted.push(shell_escape(arg));
let command = format!( }
"if command -v {} >/dev/null 2>&1; then ELECTRON_RUN_AS_NODE=1 exec {}; else printf '%s%s\\n' '{}' {} >&2; exit 127; fi", format!("exec {}", quoted.join(" "))
shell_escape(&entry.node_binary), } else {
quoted.join(" "), quoted.push(shell_escape(&entry.node_binary));
MISSING_NODE_PREFIX, for arg in entry.runner_args(cli_args) {
shell_escape(&entry.node_binary), quoted.push(shell_escape(&arg));
); }
let args = build_shell_args(&shell, &command); format!(
"if command -v {} >/dev/null 2>&1; then ELECTRON_RUN_AS_NODE=1 exec {}; else printf '%s%s\\n' '{}' {} >&2; exit 127; fi",
shell_escape(&entry.node_binary),
quoted.join(" "),
MISSING_NODE_PREFIX,
shell_escape(&entry.node_binary),
)
};
let wrapped_command = wrap_command_for_shell(&command, &shell);
let args = build_shell_args(&shell, &wrapped_command);
log_line(&format!("user shell command: {} {:?}", shell, args)); log_line(&format!("user shell command: {} {:?}", shell, args));
Ok(ShellCommand { shell, args }) Ok(ShellCommand { shell, args })
} }
fn wrap_command_for_shell(command: &str, shell: &str) -> String {
let shell_name = std::path::Path::new(shell)
.file_name()
.and_then(OsStr::to_str)
.unwrap_or("")
.to_lowercase();
if shell_name.contains("bash") {
return format!(
"if [ -f ~/.bashrc ]; then source ~/.bashrc >/dev/null 2>&1; fi; {}",
command
);
}
if shell_name.contains("zsh") {
return format!(
"if [ -f ~/.zshrc ]; then source ~/.zshrc >/dev/null 2>&1; fi; {}",
command
);
}
command.to_string()
}
fn default_shell() -> String { fn default_shell() -> String {
if let Ok(shell) = std::env::var("SHELL") { if let Ok(shell) = std::env::var("SHELL") {
if !shell.trim().is_empty() { if !shell.trim().is_empty() {
@@ -1306,8 +1354,8 @@ fn build_shell_args(shell: &str, command: &str) -> Vec<String> {
.unwrap_or("") .unwrap_or("")
.to_lowercase(); .to_lowercase();
if shell_name.contains("zsh") || shell_name.contains("bash") { if shell_name.contains("zsh") {
vec!["-i".into(), "-l".into(), "-c".into(), command.into()] vec!["-l".into(), "-i".into(), "-c".into(), command.into()]
} else { } else {
vec!["-l".into(), "-c".into(), command.into()] vec!["-l".into(), "-c".into(), command.into()]
} }

View File

@@ -43,11 +43,6 @@
"bundle": { "bundle": {
"active": true, "active": true,
"linux": { "linux": {
"appimage": {
"files": {
"/usr/share/applications/ai.neuralnomads.codenomad.client.desktop": "icons/linux/ai.neuralnomads.codenomad.client.desktop"
}
},
"deb": { "deb": {
"files": { "files": {
"/usr/share/applications/ai.neuralnomads.codenomad.client.desktop": "icons/linux/ai.neuralnomads.codenomad.client.desktop", "/usr/share/applications/ai.neuralnomads.codenomad.client.desktop": "icons/linux/ai.neuralnomads.codenomad.client.desktop",