Compare commits

...

7 Commits

Author SHA1 Message Date
Shantur Rathore
1c317df6c0 fix(ci): invoke pinned npm cli directly 2026-04-21 11:18:38 +01:00
Shantur Rathore
6381934661 fix(ci): pin npm for publish workflow 2026-04-21 10:43:59 +01:00
Shantur Rathore
67a10d12e0 Don't depend on Node anymore (#346)
## Summary
- package `packages/server` as a standalone desktop executable so
Electron and Tauri no longer depend on a system-installed Node runtime
in production
- align Electron and Tauri startup logic around launching the packaged
server, resolving binaries from the user shell, and bundling the same
server resources into both desktop apps
- replace the workspace instance proxy path that used
`@fastify/reply-from` with a direct streaming proxy so packaged
standalone builds can talk to spawned `opencode` instances correctly

## Why
Desktop production builds were still depending on a user-provided Node
runtime to launch `packages/server`, which made packaging less
self-contained and created different behavior across machines. While
moving to a standalone server executable, we also found that
Bun-compiled standalone builds could start `opencode` successfully but
failed when proxying requests to those instances through `reply-from`.

The goal of this change is to make desktop production startup
self-contained, keep Electron and Tauri behavior aligned, and restore
correct communication with local `opencode` instances in packaged
builds.

## What Changed
- added a standalone build path for `packages/server` and bundle
`codenomad-server` into desktop resources
- updated Electron production startup to resolve and launch the
standalone server executable
- updated Tauri production startup to resolve and launch the standalone
server executable with matching cwd and shell behavior
- added runtime path helpers so the packaged server can reliably find
its bundled UI, auth templates, config template, and package metadata
- improved bare binary resolution so commands like `opencode` can be
resolved from the user's login shell environment
- upgraded the server stack to newer Fastify-compatible packages needed
for the standalone/runtime work
- replaced the workspace instance proxy implementation with a direct
streaming proxy for requests to spawned `opencode` instances
- updated Electron and Tauri build/prebuild scripts to generate and
package the standalone server, while also repairing missing
platform-specific optional binaries during packaging

## Benefits
- desktop production builds no longer require Node to be installed on
the user's system
- Electron and Tauri now use the same packaged server model in
production, reducing platform drift
- packaged desktop apps can successfully create workspaces, launch
`opencode`, and proxy health/session traffic to those instances
- the server bundle is more self-contained and resilient to different
launch environments
- desktop packaging is more predictable because the required server
executable is built and bundled as part of the app build flow
2026-04-21 09:04:34 +01:00
Shantur Rathore
68551f6731 fix(ui): unify apply_patch diagnostics matching 2026-04-20 21:08:33 +01:00
Shantur Rathore
662a6b94b0 fix(ui): remove delete shortcuts from recent lists 2026-04-20 20:51:36 +01:00
Pascal André
77df40169a Fix WSL UNC OpenCode binaries on Windows (#341)
## Summary
- support Windows validation and launch of OpenCode binaries stored
under WSL UNC paths like \\wsl.localhost\...
- harden the existing manual directory browser so absolute, UNC, and WSL
paths can be pasted and navigated reliably
- harden WSL env/path propagation, UNC workspace handling, runtime
shutdown, and add targeted tests

Partially addresses #5.

## Testing
- node --test --import tsx src/workspaces/__tests__/spawn.test.ts
- npm run typecheck --workspace @neuralnomads/codenomad
- npm run typecheck --workspace @codenomad/ui
2026-04-20 20:29:08 +01:00
Shantur Rathore
3b411e2e73 fix(ui): gate desktop privileges by host and window context (#347)
Don't let remote server windows use local features like local file browser etc
2026-04-20 20:28:11 +01:00
48 changed files with 2730 additions and 949 deletions

View File

@@ -53,7 +53,7 @@ on:
# least-privilege (e.g. dev CI uses read-only; releases grant write).
env:
NODE_VERSION: 20
NODE_VERSION: 22
jobs:
build-macos:
@@ -372,7 +372,7 @@ jobs:
if [ "$attempt" -gt 1 ]; then
echo "Retrying Tauri CLI install (attempt $attempt)..."
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
done
echo "Tauri CLI failed to load after retries" >&2
@@ -456,7 +456,7 @@ jobs:
if [ "$attempt" -gt 1 ]; then
echo "Retrying Tauri CLI install (attempt $attempt)..."
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
done
echo "Tauri CLI failed to load after retries" >&2
@@ -542,7 +542,7 @@ jobs:
if [ "$attempt" -gt 1 ]; then
echo "Retrying Tauri CLI install (attempt $attempt)..."
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
done
echo "Tauri CLI failed to load after retries" >&2
@@ -614,6 +614,7 @@ jobs:
sudo apt-get install -y \
build-essential \
pkg-config \
xdg-utils \
libgtk-3-dev \
libglib2.0-dev \
libwebkit2gtk-4.1-dev \
@@ -642,6 +643,7 @@ jobs:
if [ "$attempt" -gt 1 ]; then
echo "Retrying Tauri CLI install (attempt $attempt)..."
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
node -e "require('@tauri-apps/cli'); console.log('Tauri CLI loaded')" && exit 0
done
@@ -741,6 +743,7 @@ jobs:
sudo apt-get install -y \
build-essential \
pkg-config \
xdg-utils \
gcc-aarch64-linux-gnu \
g++-aarch64-linux-gnu \
libgtk-3-dev:arm64 \

View File

@@ -46,7 +46,8 @@ jobs:
publish:
runs-on: ubuntu-latest
env:
NODE_VERSION: 20
NODE_VERSION: 22
PUBLISH_NPM_VERSION: 11.5.1
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -59,17 +60,24 @@ jobs:
node-version: ${{ env.NODE_VERSION }}
registry-url: https://registry.npmjs.org
- name: Ensure npm >=11.5.1
run: npm install -g npm@latest
- name: Prepare pinned npm CLI
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 "PINNED_NPM_CLI=$tool_dir/node_modules/npm/bin/npm-cli.js" >> "$GITHUB_ENV"
node "$tool_dir/node_modules/npm/bin/npm-cli.js" --version
- name: Install dependencies
run: npm ci --workspaces
run: node "$PINNED_NPM_CLI" ci --workspaces
- name: Ensure rollup native binary
run: npm install @rollup/rollup-linux-x64-gnu --no-save
run: node "$PINNED_NPM_CLI" install @rollup/rollup-linux-x64-gnu --no-save
- name: Build server package (includes UI bundling)
run: npm run build --workspace packages/server
run: node "$PINNED_NPM_CLI" run build --workspace packages/server
- name: Set publish metadata
shell: bash
@@ -83,7 +91,7 @@ jobs:
echo "PACKAGE_NAME=${{ inputs.package_name }}" >> "$GITHUB_ENV"
- name: Bump package version for publish
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
run: node "$PINNED_NPM_CLI" version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
- name: Set server package name for publish
shell: bash
@@ -107,4 +115,4 @@ jobs:
else
echo "Using NPM_TOKEN authentication"
fi
npm publish --workspace packages/server --access public --tag ${DIST_TAG} --provenance
node "$PINNED_NPM_CLI" publish --workspace packages/server --access public --tag ${DIST_TAG} --provenance

View File

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

View File

@@ -39,7 +39,7 @@ permissions:
contents: write
env:
NODE_VERSION: 20
NODE_VERSION: 22
jobs:
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) => {
console.error("[cli] failed to load loading screen:", error)
})
return loader
}
function getAllowedRendererOrigins(window?: BrowserWindow | null): string[] {
@@ -277,6 +279,7 @@ function createWindow() {
contextIsolation: true,
nodeIntegration: false,
spellcheck: !isMac,
additionalArguments: ["--codenomad-window-context=local"],
},
})
@@ -291,7 +294,7 @@ function createWindow() {
showingLoadingScreen = true
currentCliUrl = null
clearWindowAllowedOrigin(window)
loadLoadingScreen(window)
const loadingReady = loadLoadingScreen(window)
if (process.env.NODE_ENV === "development") {
window.webContents.openDevTools({ mode: "detach" })
@@ -310,11 +313,7 @@ function createWindow() {
showingLoadingScreen = false
})
if (pendingCliUrl) {
const url = pendingCliUrl
pendingCliUrl = null
startCliPreload(url)
}
return loadingReady
}
function showLoadingScreen(force = false) {
@@ -440,6 +439,7 @@ async function openRemoteWindow(payload: { id: string; name: string; baseUrl: st
contextIsolation: true,
nodeIntegration: false,
spellcheck: !isMac,
additionalArguments: ["--codenomad-window-context=remote"],
},
})
@@ -620,7 +620,8 @@ app.whenReady().then(() => {
// ignore
}
startCli()
const loadingReady = createWindow()
;(mainWindow as BrowserWindow & { __codenomadOpenRemoteWindow?: typeof openRemoteWindow }).__codenomadOpenRemoteWindow = openRemoteWindow
if (isMac) {
session.defaultSession.setSpellCheckerEnabled(false)
@@ -637,8 +638,11 @@ app.whenReady().then(() => {
}
}
createWindow()
;(mainWindow as BrowserWindow & { __codenomadOpenRemoteWindow?: typeof openRemoteWindow }).__codenomadOpenRemoteWindow = openRemoteWindow
void loadingReady.finally(() => {
setTimeout(() => {
void startCli()
}, 0)
})
app.on("certificate-error", (event, _webContents, url, error, _certificate, callback) => {
if (isInsecureOriginAllowed(url)) {

View File

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

View File

@@ -1,6 +1,19 @@
const { contextBridge, ipcRenderer, webUtils } = require("electron")
const electronAPI = {
function resolveWindowContext() {
const prefix = "--codenomad-window-context="
const arg = process.argv.find((value) => typeof value === "string" && value.startsWith(prefix))
const context = arg ? arg.slice(prefix.length) : "local"
return context === "remote" ? "remote" : "local"
}
function resolveRuntimeHost(windowContext) {
return "electron"
}
const windowContext = resolveWindowContext()
const localElectronAPI = {
onCliStatus: (callback) => {
ipcRenderer.on("cli:status", (_, data) => callback(data))
return () => ipcRenderer.removeAllListeners("cli:status")
@@ -26,4 +39,15 @@ const electronAPI = {
openRemoteWindow: (payload) => ipcRenderer.invoke("remote:openWindow", payload),
}
contextBridge.exposeInMainWorld("electronAPI", electronAPI)
const remoteElectronAPI = {
requestMicrophoneAccess: localElectronAPI.requestMicrophoneAccess,
setWakeLock: localElectronAPI.setWakeLock,
showNotification: localElectronAPI.showNotification,
}
contextBridge.exposeInMainWorld(
"electronAPI",
windowContext === "local" ? localElectronAPI : remoteElectronAPI,
)
contextBridge.exposeInMainWorld("__CODENOMAD_WINDOW_CONTEXT__", windowContext)
contextBridge.exposeInMainWorld("__CODENOMAD_RUNTIME_HOST__", resolveRuntimeHost(windowContext))

View File

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

View File

@@ -16,6 +16,7 @@ const npmNodeExecPath = process.env.npm_node_execpath
const serverSources = ["dist", "public", "node_modules", "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) {
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() {
if (fs.existsSync(serverDepsMarker)) {
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() {
fs.rmSync(serverDest, { recursive: true, force: true })
fs.mkdirSync(serverDest, { recursive: true })
@@ -121,7 +195,9 @@ function stripNodeModuleBins() {
async function main() {
ensureServerBuild()
ensureStandaloneServerBuild()
ensureServerDependencies()
ensureEsbuildPlatformBinary()
copyServerArtifacts()
stripNodeModuleBins()
}

View File

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

View File

@@ -18,6 +18,7 @@
},
"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:standalone": "node ./scripts/build-standalone.mjs",
"build:ui": "npm run build --prefix ../ui",
"prepare-ui": "node ./scripts/copy-ui-dist.mjs",
"prepare-config": "node ./scripts/copy-opencode-config.mjs",
@@ -25,16 +26,16 @@
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {
"@fastify/cors": "^8.5.0",
"@fastify/reply-from": "^9.8.0",
"@fastify/static": "^7.0.4",
"@fastify/cors": "^11.2.0",
"@fastify/reply-from": "^12.6.2",
"@fastify/static": "^9.1.1",
"commander": "^12.1.0",
"fastify": "^4.28.1",
"fastify": "^5.8.5",
"fuzzysort": "^2.0.4",
"node-forge": "^1.3.3",
"openai": "^6.27.0",
"pino": "^9.4.0",
"undici": "^6.19.8",
"undici": "^8.1.0",
"yaml": "^2.4.2",
"yauzl": "^2.10.0",
"zod": "^3.23.8"
@@ -42,6 +43,7 @@
"devDependencies": {
"@types/node-forge": "^1.3.14",
"@types/yauzl": "^2.10.0",
"bun": "^1.3.13",
"cross-env": "^7.0.3",
"ts-node": "^10.9.2",
"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
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 { fileURLToPath } from "url"
@@ -14,6 +14,67 @@ const selfLinkDir = path.resolve(nodeModulesDir, "@codenomad", "opencode-config"
const npmExecPath = process.env.npm_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)) {
console.error(`[copy-opencode-config] Missing source directory at ${sourceDir}`)
process.exit(1)
@@ -58,4 +119,14 @@ rmSync(targetDir, { recursive: true, force: true })
mkdirSync(path.dirname(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}`)

View File

@@ -29,13 +29,14 @@ import { SideCarManager } from "./sidecars/manager"
import { ClientConnectionManager } from "./clients/connection-manager"
import { PluginChannelManager } from "./plugins/channel"
import { VoiceModeManager } from "./plugins/voice-mode"
import { readServerPackageVersion, resolveServerPublicDir } from "./runtime-paths"
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 __dirname = path.dirname(__filename)
const DEFAULT_UI_STATIC_DIR = path.resolve(__dirname, "../public")
const DEFAULT_UI_STATIC_DIR = resolveServerPublicDir(import.meta.url)
interface CliOptions {
host: string

View File

@@ -1,22 +1,11 @@
import { existsSync } from "fs"
import path from "path"
import { fileURLToPath } from "url"
import { createLogger } from "./logger"
import { resolveOpencodeTemplateDir } from "./runtime-paths"
const log = createLogger({ component: "opencode-config" })
const __filename = fileURLToPath(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 templateDir = resolveOpencodeTemplateDir(import.meta.url)
const isDevBuild = Boolean(process.env.CODENOMAD_DEV ?? process.env.CLI_UI_DEV_SERVER) || existsSync(devTemplateDir)
const templateDir = isDevBuild
? devTemplateDir
: prodTemplateDirs.find((dir) => existsSync(dir)) ?? prodTemplateDirs[0]
const isDevBuild = Boolean(process.env.CODENOMAD_DEV ?? process.env.CLI_UI_DEV_SERVER)
export function getOpencodeConfigDir(): string {
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 { connect as connectTcp, type Socket } from "net"
import path from "path"
import { Readable } from "stream"
import { pipeline } from "stream/promises"
import { connect as connectTls, type TLSSocket } from "tls"
import { fetch } from "undici"
import type { Logger } from "../logger"
@@ -626,57 +628,57 @@ async function proxyWorkspaceRequest(args: {
logger.trace({ workspaceId, targetUrl, body: request.body }, "Instance proxy payload")
}
return reply.from(targetUrl, {
rewriteRequestHeaders: (_originalRequest, headers) => {
if (instanceAuthHeader) {
headers.authorization = instanceAuthHeader
}
const headers = buildWorkspaceInstanceProxyHeaders(request.headers, instanceAuthHeader, directory)
// OpenCode expects the *full* path; we send it via header to avoid query tampering.
const isNonASCII = /[^\x00-\x7F]/.test(directory)
const encodedDirectory = isNonASCII ? encodeURIComponent(directory) : directory
if (logger.isLevelEnabled("trace")) {
logger.trace(
{
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).
;(headers as Record<string, unknown>)["x-opencode-directory"] = encodedDirectory
const init: any = {
method: request.method,
headers,
redirect: "manual",
}
if (logger.isLevelEnabled("trace")) {
const outgoing: Record<string, unknown> = {}
for (const [key, value] of Object.entries(headers as Record<string, unknown>)) {
outgoing[key] = value
}
if (request.method !== "GET" && request.method !== "HEAD") {
const body = toProxyRequestBody(request.body)
if (body !== undefined) {
init.body = body
init.duplex = "half"
}
}
// Redact sensitive headers.
for (const key of Object.keys(outgoing)) {
const lower = key.toLowerCase()
if (lower === "authorization" || lower === "cookie" || lower === "set-cookie") {
outgoing[key] = "<redacted>"
}
}
try {
const response = await fetch(targetUrl, init)
reply.code(response.status)
applyInstanceProxyResponseHeaders(reply, response)
logger.trace(
{
workspaceId,
method: request.method,
targetUrl,
worktreeSlug,
directory,
contentType: request.headers["content-type"],
body: bodyToJson(request.body),
headers: outgoing,
},
"Proxy -> OpenCode request",
)
}
if (!response.body || request.method === "HEAD") {
reply.send()
return
}
return headers
},
onError: (proxyReply, { error }) => {
logger.error({ err: error, workspaceId, targetUrl }, "Failed to proxy workspace request")
if (!proxyReply.sent) {
proxyReply.code(502).send({ error: "Workspace instance proxy failed" })
}
},
})
reply.hijack()
reply.raw.writeHead(reply.statusCode, toOutgoingHeaders(reply.getHeaders()))
await pipeline(Readable.fromWeb(response.body as any), reply.raw)
} catch (error) {
logger.error({ err: error, workspaceId, targetUrl }, "Failed to proxy workspace request")
if (!reply.sent) {
reply.code(502).send({ error: "Workspace instance proxy failed" })
}
}
}
function extractOpencodeDirectoryOverride(pathSuffix: string | undefined): {
@@ -867,12 +869,90 @@ function isApiRequest(rawUrl: string | null | undefined) {
function buildProxyHeaders(headers: FastifyRequest["headers"]): Record<string, string> {
const result: Record<string, string> = {}
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
}
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: {
request: FastifyRequest
reply: FastifyReply

View File

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

View File

@@ -1,6 +1,6 @@
import { FastifyInstance } from "fastify"
import { z } from "zod"
import { probeBinaryVersion } from "../../workspaces/runtime"
import { probeBinaryVersion } from "../../workspaces/spawn"
import type { SettingsService } from "../../settings/service"
import type { Logger } from "../../logger"
import { sanitizeConfigDoc, sanitizeConfigOwner } from "../../settings/public-config"

View File

@@ -0,0 +1,193 @@
import assert from "node:assert/strict"
import { describe, it } from "node:test"
import { buildWindowsSpawnSpec, buildWslSignalSpec, parseWslUncPath, resolveWslWorkingDirectory } from "../spawn"
describe("parseWslUncPath", () => {
it("parses WSL UNC paths into distro and linux path", () => {
assert.deepEqual(parseWslUncPath(String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`), {
distro: "Ubuntu",
linuxPath: "/home/dev/.opencode/bin/opencode",
})
})
it("supports the legacy wsl$ UNC prefix", () => {
assert.deepEqual(parseWslUncPath(String.raw`\\wsl$\Ubuntu\home\dev`), {
distro: "Ubuntu",
linuxPath: "/home/dev",
})
})
})
describe("resolveWslWorkingDirectory", () => {
it("keeps WSL workspace folders in the same distro", () => {
assert.equal(
JSON.stringify(resolveWslWorkingDirectory(String.raw`\\wsl.localhost\Ubuntu\home\dev\workspace`, "Ubuntu")),
JSON.stringify({ kind: "linux", path: "/home/dev/workspace" }),
)
})
it("keeps Windows drive paths so WSL can resolve them with wslpath", () => {
assert.equal(
JSON.stringify(resolveWslWorkingDirectory(String.raw`C:\Users\dev\workspace`, "Ubuntu")),
JSON.stringify({ kind: "windows", path: String.raw`C:\Users\dev\workspace` }),
)
})
it("keeps UNC network paths so WSL can resolve them with wslpath", () => {
assert.equal(
JSON.stringify(resolveWslWorkingDirectory(String.raw`\\server\share\workspace`, "Ubuntu")),
JSON.stringify({ kind: "windows", path: String.raw`\\server\share\workspace` }),
)
})
it("rejects WSL workspace folders from a different distro", () => {
assert.equal(resolveWslWorkingDirectory(String.raw`\\wsl.localhost\Debian\home\dev\workspace`, "Ubuntu"), null)
})
})
describe("buildWindowsSpawnSpec", () => {
it("wraps WSL binaries with wsl.exe and propagates required env vars", () => {
const spec = buildWindowsSpawnSpec(
String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`,
["serve", "--port", "0"],
{
cwd: String.raw`\\wsl.localhost\Ubuntu\home\dev\workspace`,
env: {
OPENCODE_CONFIG_DIR: String.raw`C:\Users\dev\AppData\Roaming\CodeNomad\opencode-config`,
CODENOMAD_INSTANCE_ID: "workspace-123",
OPENCODE_SERVER_PASSWORD: "secret",
},
propagateEnvKeys: ["OPENCODE_CONFIG_DIR", "CODENOMAD_INSTANCE_ID", "OPENCODE_SERVER_PASSWORD"],
},
)
assert.equal(spec.command, "wsl.exe")
assert.deepEqual(spec.args, [
"--distribution",
"Ubuntu",
"--cd",
"/home/dev/workspace",
"--exec",
"/home/dev/.opencode/bin/opencode",
"serve",
"--port",
"0",
])
assert.equal(spec.cwd, undefined)
assert.equal(spec.env?.WSLENV, "OPENCODE_CONFIG_DIR/p:CODENOMAD_INSTANCE_ID:OPENCODE_SERVER_PASSWORD")
})
it("upgrades existing WSLENV path entries to include /p", () => {
const spec = buildWindowsSpawnSpec(
String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`,
["serve"],
{
env: {
OPENCODE_CONFIG_DIR: String.raw`C:\Users\dev\AppData\Roaming\CodeNomad\opencode-config`,
WSLENV: "OPENCODE_CONFIG_DIR:CODENOMAD_INSTANCE_ID/u",
},
propagateEnvKeys: ["OPENCODE_CONFIG_DIR", "CODENOMAD_INSTANCE_ID"],
},
)
assert.equal(spec.env?.WSLENV, "OPENCODE_CONFIG_DIR/p:CODENOMAD_INSTANCE_ID/u")
})
it("propagates inherited known path variables even when they are not explicitly requested", () => {
const spec = buildWindowsSpawnSpec(
String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`,
["serve"],
{
env: {
NODE_EXTRA_CA_CERTS: String.raw`C:\certs\root.pem`,
},
},
)
assert.equal(spec.env?.WSLENV, "NODE_EXTRA_CA_CERTS/p")
})
it("uses wslpath for Windows workspace folders instead of assuming /mnt", () => {
const spec = buildWindowsSpawnSpec(
String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`,
["serve", "--port", "0"],
{
cwd: String.raw`C:\Users\dev\workspace`,
},
)
assert.equal(spec.command, "wsl.exe")
assert.deepEqual(spec.args, [
"--distribution",
"Ubuntu",
"--exec",
"sh",
"-lc",
'cd "$(wslpath -au "$1")" && shift && exec "$@"',
"codenomad-wsl-launch",
String.raw`C:\Users\dev\workspace`,
"/home/dev/.opencode/bin/opencode",
"serve",
"--port",
"0",
])
})
it("uses wslpath for UNC network workspace folders", () => {
const spec = buildWindowsSpawnSpec(
String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`,
["serve"],
{
cwd: String.raw`\\server\share\workspace`,
},
)
assert.equal(spec.command, "wsl.exe")
assert.deepEqual(spec.args, [
"--distribution",
"Ubuntu",
"--exec",
"sh",
"-lc",
'cd "$(wslpath -au "$1")" && shift && exec "$@"',
"codenomad-wsl-launch",
String.raw`\\server\share\workspace`,
"/home/dev/.opencode/bin/opencode",
"serve",
])
})
it("can wrap WSL launches to emit the Linux PID marker", () => {
const spec = buildWindowsSpawnSpec(
String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`,
["serve"],
{
cwd: String.raw`\\wsl.localhost\Ubuntu\home\dev\workspace`,
wslPidMarker: "__CODENOMAD_WSL_PID__:",
},
)
assert.equal(spec.command, "wsl.exe")
assert.deepEqual(spec.args, [
"--distribution",
"Ubuntu",
"--exec",
"sh",
"-lc",
`printf '%s%s\\n' '__CODENOMAD_WSL_PID__:' "$$" && cd "$1" && shift && exec "$@"`,
"codenomad-wsl-launch",
"/home/dev/workspace",
"/home/dev/.opencode/bin/opencode",
"serve",
])
assert.equal(spec.wsl?.pidMarker, "__CODENOMAD_WSL_PID__:")
})
it("builds the WSL kill command for tracked Linux PIDs", () => {
const spec = buildWslSignalSpec("Ubuntu", 4321, "SIGTERM")
assert.equal(spec.command, "wsl.exe")
assert.deepEqual(spec.args, ["--distribution", "Ubuntu", "--exec", "kill", "-TERM", "4321"])
})
})

View File

@@ -21,6 +21,70 @@ import {
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 {
rootDir: string
settings: SettingsService
@@ -266,6 +330,12 @@ export class WorkspaceManager {
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
}

View File

@@ -4,100 +4,10 @@ import path from "path"
import { EventBus } from "../events/bus"
import { LogLevel, WorkspaceLogEntry } from "../api-types"
import { Logger } from "../logger"
export const WINDOWS_CMD_EXTENSIONS = new Set([".cmd", ".bat"])
export const WINDOWS_POWERSHELL_EXTENSIONS = new Set([".ps1"])
const VERSION_REGEX = /([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/
export function buildSpawnSpec(binaryPath: string, args: string[]) {
if (process.platform !== "win32") {
return { command: binaryPath, args, options: {} as const }
}
const extension = path.extname(binaryPath).toLowerCase()
if (WINDOWS_CMD_EXTENSIONS.has(extension)) {
const comspec = process.env.ComSpec || "cmd.exe"
// cmd.exe requires the full command as a single string.
// Using the ""<script> <args>"" pattern ensures paths with spaces are handled.
const commandLine = `""${binaryPath}" ${args.join(" ")}"`
return {
command: comspec,
args: ["/d", "/s", "/c", commandLine],
options: { windowsVerbatimArguments: true } as const,
}
}
if (WINDOWS_POWERSHELL_EXTENSIONS.has(extension)) {
// powershell.exe ships with Windows. (pwsh may not.)
return {
command: "powershell.exe",
args: ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", binaryPath, ...args],
options: {} as const,
}
}
return { command: binaryPath, args, options: {} as const }
}
export function probeBinaryVersion(binaryPath: string): {
valid: boolean
version?: string
reported?: string
error?: string
} {
if (!binaryPath) {
return { valid: false, error: "Missing binary path" }
}
const spec = buildSpawnSpec(binaryPath, ["--version"])
try {
const result = spawnSync(spec.command, spec.args, {
encoding: "utf8",
windowsVerbatimArguments: Boolean(
(spec.options as { windowsVerbatimArguments?: boolean }).windowsVerbatimArguments,
),
})
if (result.error) {
return { valid: false, error: result.error.message }
}
if (result.status !== 0) {
const stderr = result.stderr?.trim()
const stdout = result.stdout?.trim()
const combined = stderr || stdout
const error = combined ? `Exited with code ${result.status}: ${combined}` : `Exited with code ${result.status}`
return { valid: false, error }
}
const stdoutLines = String(result.stdout ?? "")
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0)
const stderrLines = String(result.stderr ?? "")
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0)
// Prefer stdout; fall back to stderr (some tools report version there).
const reported = stdoutLines[0] ?? stderrLines[0]
if (!reported) {
return { valid: true }
}
const versionMatch = reported.match(VERSION_REGEX)
const version = versionMatch?.[1]
return { valid: true, version, reported }
} catch (error) {
return { valid: false, error: error instanceof Error ? error.message : String(error) }
}
}
import { buildSpawnSpec, buildWslSignalSpec } from "./spawn"
const SENSITIVE_ENV_KEY = /(PASSWORD|TOKEN|SECRET)/i
const WSL_PID_MARKER = "__CODENOMAD_WSL_PID__:"
function redactEnvironment(env: Record<string, string | undefined>): Record<string, string | undefined> {
const redacted: Record<string, string | undefined> = {}
@@ -130,6 +40,10 @@ export interface ProcessExitInfo {
interface ManagedProcess {
child: ChildProcess
requestedStop: boolean
wsl?: {
distro: string
linuxPid: number | null
}
}
export class WorkspaceRuntime {
@@ -167,7 +81,13 @@ export class WorkspaceRuntime {
}
return new Promise((resolve, reject) => {
const spec = buildSpawnSpec(options.binaryPath, args)
const propagatedEnvKeys = Object.keys(options.environment ?? {})
const spec = buildSpawnSpec(options.binaryPath, args, {
cwd: options.folder,
env,
propagateEnvKeys: propagatedEnvKeys,
wslPidMarker: WSL_PID_MARKER,
})
const commandLine = [spec.command, ...spec.args].join(" ")
this.logger.info(
{
@@ -197,14 +117,18 @@ export class WorkspaceRuntime {
)
const detached = process.platform !== "win32"
const child = spawn(spec.command, spec.args, {
cwd: options.folder,
env,
cwd: spec.cwd,
env: spec.env,
stdio: ["ignore", "pipe", "pipe"],
detached,
...spec.options,
})
const managed: ManagedProcess = { child, requestedStop: false }
const managed: ManagedProcess = {
child,
requestedStop: false,
...(spec.wsl ? { wsl: { distro: spec.wsl.distro, linuxPid: null } } : {}),
}
this.processes.set(options.workspaceId, managed)
let stdoutBuffer = ""
@@ -284,6 +208,15 @@ export class WorkspaceRuntime {
const trimmed = line.trim()
if (!trimmed) continue
if (managed.wsl && trimmed.startsWith(WSL_PID_MARKER)) {
const linuxPid = Number.parseInt(trimmed.slice(WSL_PID_MARKER.length), 10)
if (Number.isFinite(linuxPid) && linuxPid > 0) {
managed.wsl.linuxPid = linuxPid
this.logger.debug({ workspaceId: options.workspaceId, linuxPid }, "Captured WSL OpenCode PID")
}
continue
}
recentStdout.push(trimmed)
if (recentStdout.length > MAX_OUTPUT_LINES) {
recentStdout.shift()
@@ -398,11 +331,44 @@ export class WorkspaceRuntime {
}
}
const trySignalWslProcess = (signal: NodeJS.Signals) => {
if (process.platform !== "win32" || !managed.wsl?.linuxPid) {
return false
}
try {
const spec = buildWslSignalSpec(managed.wsl.distro, managed.wsl.linuxPid, signal)
const result = spawnSync(spec.command, spec.args, { encoding: "utf8" })
const exitCode = result.status
if (exitCode === 0) {
return true
}
const stderr = (result.stderr ?? "").toString().toLowerCase()
const stdout = (result.stdout ?? "").toString().toLowerCase()
const combined = `${stdout}\n${stderr}`
if (combined.includes("no such process") || combined.includes("not found")) {
return true
}
this.logger.debug(
{ workspaceId, pid, linuxPid: managed.wsl.linuxPid, distro: managed.wsl.distro, exitCode, stderr: result.stderr, stdout: result.stdout },
"WSL kill failed",
)
return false
} catch (error) {
this.logger.debug({ workspaceId, pid, linuxPid: managed.wsl.linuxPid, distro: managed.wsl.distro, err: error }, "WSL kill failed to execute")
return false
}
}
const sendStopSignal = (signal: NodeJS.Signals) => {
if (process.platform === "win32") {
// Best-effort: terminate the whole process tree rooted at pid.
// Use /F only for escalation.
tryTaskkill(signal === "SIGKILL")
// WSL-backed launches need a Linux signal first because the tracked Windows PID belongs to wsl.exe.
if (!trySignalWslProcess(signal)) {
// Fallback to the Windows process tree rooted at pid. Use /F only for escalation.
tryTaskkill(signal === "SIGKILL")
}
return
}

View File

@@ -0,0 +1,307 @@
import { spawnSync } from "child_process"
import path from "path"
export const WINDOWS_CMD_EXTENSIONS = new Set([".cmd", ".bat"])
export const WINDOWS_POWERSHELL_EXTENSIONS = new Set([".ps1"])
const VERSION_REGEX = /([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/
const WSL_UNC_PATH_REGEX = /^\\\\wsl(?:\.localhost|\$)\\([^\\/]+)(?:[\\/](.*))?$/i
const WSL_PATH_ENV_KEYS = new Set(["OPENCODE_CONFIG_DIR", "NODE_EXTRA_CA_CERTS"])
export interface SpawnSpec {
command: string
args: string[]
options: {
windowsVerbatimArguments?: boolean
}
cwd?: string
env?: NodeJS.ProcessEnv
wsl?: {
distro: string
pidMarker?: string
}
}
interface BuildSpawnSpecOptions {
cwd?: string
env?: NodeJS.ProcessEnv
propagateEnvKeys?: string[]
wslPidMarker?: string
}
interface WslPath {
distro: string
linuxPath: string
}
export type WslWorkingDirectory =
| { kind: "linux"; path: string }
| { kind: "windows"; path: string }
export function parseWslUncPath(input: string): WslPath | null {
const normalized = input.trim().replace(/\//g, "\\")
const match = normalized.match(WSL_UNC_PATH_REGEX)
if (!match) {
return null
}
const distro = match[1] ?? ""
const remainder = match[2] ?? ""
const segments = remainder.split(/\\+/).filter((segment) => segment.length > 0)
return {
distro,
linuxPath: segments.length > 0 ? `/${segments.join("/")}` : "/",
}
}
export function resolveWslWorkingDirectory(folder: string, distro: string): WslWorkingDirectory | null {
const wslFolder = parseWslUncPath(folder)
if (wslFolder) {
return wslFolder.distro.toLowerCase() === distro.toLowerCase() ? { kind: "linux", path: wslFolder.linuxPath } : null
}
const windowsFolder = normalizeWindowsPath(folder)
return windowsFolder ? { kind: "windows", path: windowsFolder } : null
}
export function buildWindowsSpawnSpec(binaryPath: string, args: string[], options: BuildSpawnSpecOptions = {}): SpawnSpec {
const wslPath = parseWslUncPath(binaryPath)
if (wslPath) {
return buildWslSpawnSpec(wslPath, args, options)
}
const extension = path.extname(binaryPath).toLowerCase()
if (WINDOWS_CMD_EXTENSIONS.has(extension)) {
const comspec = process.env.ComSpec || "cmd.exe"
// cmd.exe requires the full command as a single string.
// Using the ""<script> <args>"" pattern ensures paths with spaces are handled.
const commandLine = `""${binaryPath}" ${args.join(" ")}"`
return {
command: comspec,
args: ["/d", "/s", "/c", commandLine],
options: { windowsVerbatimArguments: true },
cwd: options.cwd,
env: options.env,
}
}
if (WINDOWS_POWERSHELL_EXTENSIONS.has(extension)) {
// powershell.exe ships with Windows. (pwsh may not.)
return {
command: "powershell.exe",
args: ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", binaryPath, ...args],
options: {},
cwd: options.cwd,
env: options.env,
}
}
return {
command: binaryPath,
args,
options: {},
cwd: options.cwd,
env: options.env,
}
}
export function buildSpawnSpec(binaryPath: string, args: string[], options: BuildSpawnSpecOptions = {}): SpawnSpec {
if (process.platform !== "win32") {
return {
command: binaryPath,
args,
options: {},
cwd: options.cwd,
env: options.env,
}
}
return buildWindowsSpawnSpec(binaryPath, args, options)
}
export function buildWslSignalSpec(distro: string, linuxPid: number, signal: NodeJS.Signals): SpawnSpec {
return {
command: "wsl.exe",
args: ["--distribution", distro, "--exec", "kill", signal === "SIGKILL" ? "-KILL" : "-TERM", String(linuxPid)],
options: {},
wsl: { distro },
}
}
export function probeBinaryVersion(binaryPath: string): {
valid: boolean
version?: string
reported?: string
error?: string
} {
if (!binaryPath) {
return { valid: false, error: "Missing binary path" }
}
try {
const spec = buildSpawnSpec(binaryPath, ["--version"])
const result = spawnSync(spec.command, spec.args, {
encoding: "utf8",
cwd: spec.cwd,
env: spec.env,
windowsVerbatimArguments: Boolean(spec.options.windowsVerbatimArguments),
})
if (result.error) {
return { valid: false, error: result.error.message }
}
if (result.status !== 0) {
const stderr = result.stderr?.trim()
const stdout = result.stdout?.trim()
const combined = stderr || stdout
const error = combined ? `Exited with code ${result.status}: ${combined}` : `Exited with code ${result.status}`
return { valid: false, error }
}
const stdoutLines = String(result.stdout ?? "")
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0)
const stderrLines = String(result.stderr ?? "")
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0)
// Prefer stdout; fall back to stderr (some tools report version there).
const reported = stdoutLines[0] ?? stderrLines[0]
if (!reported) {
return { valid: true }
}
const versionMatch = reported.match(VERSION_REGEX)
const version = versionMatch?.[1]
return { valid: true, version, reported }
} catch (error) {
return { valid: false, error: error instanceof Error ? error.message : String(error) }
}
}
function buildWslSpawnSpec(wslPath: WslPath, args: string[], options: BuildSpawnSpecOptions): SpawnSpec {
const workingDirectory = options.cwd ? resolveWslWorkingDirectory(options.cwd, wslPath.distro) : undefined
if (options.cwd && !workingDirectory) {
throw new Error(
`Unable to translate workspace folder for WSL binary in distro "${wslPath.distro}": ${options.cwd}`,
)
}
const wslArgs = ["--distribution", wslPath.distro]
const shouldWrapWithShell = Boolean(options.wslPidMarker) || workingDirectory?.kind === "windows"
if (!shouldWrapWithShell && workingDirectory?.kind === "linux") {
wslArgs.push("--cd", workingDirectory.path)
}
if (shouldWrapWithShell) {
const launchScript = buildWslLaunchScript(workingDirectory ?? undefined, options.wslPidMarker)
wslArgs.push(
"--exec",
"sh",
"-lc",
launchScript,
"codenomad-wsl-launch",
)
if (workingDirectory) {
wslArgs.push(workingDirectory.path)
}
wslArgs.push(
wslPath.linuxPath,
...args,
)
} else {
wslArgs.push("--exec", wslPath.linuxPath, ...args)
}
return {
command: "wsl.exe",
args: wslArgs,
options: {},
env: buildWslEnvironment(options.env, options.propagateEnvKeys),
wsl: { distro: wslPath.distro, pidMarker: options.wslPidMarker },
}
}
function buildWslLaunchScript(workingDirectory: WslWorkingDirectory | undefined, pidMarker: string | undefined): string {
const steps: string[] = []
if (pidMarker) {
steps.push(`printf '%s%s\\n' '${pidMarker}' "$$"`)
}
if (workingDirectory?.kind === "linux") {
steps.push('cd "$1"')
steps.push("shift")
} else if (workingDirectory?.kind === "windows") {
steps.push('cd "$(wslpath -au "$1")"')
steps.push("shift")
}
steps.push('exec "$@"')
return steps.join(" && ")
}
function normalizeWindowsPath(input: string): string | null {
const normalized = path.win32.normalize(input.trim().replace(/\//g, "\\"))
if (!normalized) {
return null
}
if (/^[A-Za-z]:/.test(normalized) || normalized.startsWith("\\\\")) {
return normalized
}
return null
}
function buildWslEnvironment(env: NodeJS.ProcessEnv | undefined, propagateEnvKeys: string[] | undefined): NodeJS.ProcessEnv | undefined {
if (!env) {
return env
}
const keysToPropagate = Array.from(
new Set([
...(propagateEnvKeys ?? []).filter((key) => env[key] !== undefined),
...Array.from(WSL_PATH_ENV_KEYS).filter((key) => env[key] !== undefined),
]),
)
if (keysToPropagate.length === 0) {
return env
}
const next = { ...env }
const entries = (next.WSLENV ?? "").split(":").filter((entry) => entry.length > 0)
const byName = new Map(entries.map((entry) => [entry.split("/")[0] ?? entry, entry]))
for (const key of keysToPropagate) {
const existingEntry = byName.get(key)
if (existingEntry) {
byName.set(key, ensureWslenvEntry(existingEntry, WSL_PATH_ENV_KEYS.has(key)))
continue
}
byName.set(key, WSL_PATH_ENV_KEYS.has(key) ? `${key}/p` : key)
}
next.WSLENV = Array.from(byName.values()).join(":")
return next
}
function ensureWslenvEntry(entry: string, requiresPathTranslation: boolean): string {
if (!requiresPathTranslation) {
return entry
}
const [name, rawFlags = ""] = entry.split("/")
if (rawFlags.includes("p")) {
return entry
}
return rawFlags.length > 0 ? `${name}/${rawFlags}p` : `${name}/p`
}

View File

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

View File

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

View File

@@ -5,10 +5,10 @@ edition = "2021"
license = "MIT"
[build-dependencies]
tauri-build = { version = "2.5.2", features = [] }
tauri-build = { version = "2.5.6", features = [] }
[dependencies]
tauri = { version = "2.5.2", features = [ "devtools"] }
tauri = { version = "2.10.1", features = [ "devtools"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
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 CLI_STOP_GRACE_SECS: u64 = 30;
@@ -624,16 +628,19 @@ impl CliProcessManager {
log_line("development mode: will prefer tsx + source if present");
}
let cwd = workspace_root();
let cwd = launch_cwd();
if let Some(ref c) = cwd {
log_line(&format!("using cwd={}", c.display()));
}
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!(
"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
));
}
@@ -642,9 +649,17 @@ impl CliProcessManager {
log_line("spawning via user shell");
ShellCommandType::UserShell(build_shell_command_string(&resolution, &args)?)
} 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 {
program: resolution.node_binary.clone(),
program: if resolution.runner == Runner::Standalone {
resolution.entry.clone()
} else {
resolution.node_binary.clone()
},
args: resolution.runner_args(&args),
})
};
@@ -654,11 +669,13 @@ impl CliProcessManager {
log_line(&format!("spawn command: {} {:?}", cmd.shell, cmd.args));
let mut c = Command::new(&cmd.shell);
c.args(&cmd.args)
.env("ELECTRON_RUN_AS_NODE", "1")
.env_remove("npm_config_prefix")
.env_remove("NPM_CONFIG_PREFIX")
.stdout(Stdio::piped())
.stderr(Stdio::piped());
if resolution.runner != Runner::Standalone {
c.env("ELECTRON_RUN_AS_NODE", "1");
}
configure_spawn(&mut c);
if let Some(ref cwd) = cwd {
c.current_dir(cwd);
@@ -671,9 +688,11 @@ impl CliProcessManager {
log_line(&format!("spawn command: {} {:?}", cmd.program, cmd.args));
let mut c = Command::new(&cmd.program);
c.args(&cmd.args)
.env("ELECTRON_RUN_AS_NODE", "1")
.stdout(Stdio::piped())
.stderr(Stdio::piped());
if resolution.runner != Runner::Standalone {
c.env("ELECTRON_RUN_AS_NODE", "1");
}
configure_spawn(&mut c);
if let Some(ref cwd) = cwd {
c.current_dir(cwd);
@@ -924,7 +943,7 @@ impl CliProcessManager {
let mut locked = status.lock();
if locked.error.is_none() {
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()
));
}
@@ -1047,7 +1066,7 @@ struct CliEntry {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Runner {
Node,
Standalone,
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 {
entry,
runner: Runner::Node,
runner: Runner::Standalone,
runner_path: None,
node_binary,
node_binary: String::new(),
});
}
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> {
if self.runner == Runner::Standalone {
return cli_args.to_vec();
}
let mut args = VecDeque::new();
if self.runner == Runner::Tsx {
if let Some(path) = &self.runner_path {
@@ -1204,45 +1227,37 @@ fn resolve_dev_entry(_app: &AppHandle) -> Option<String> {
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 mut candidates: Vec<Option<PathBuf>> = vec![
base.as_ref().map(|p| p.join("packages/server/dist/bin.js")),
base.as_ref()
.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")),
];
let mut candidates = vec![base
.as_ref()
.map(|p| p.join("packages/server/dist").join(executable_name))];
if let Ok(exe) = std::env::current_exe() {
if let Some(dir) = exe.parent() {
candidates.push(Some(dir.join("resources/server/dist/bin.js")));
candidates.push(Some(dir.join("resources/server/dist/index.js")));
candidates.push(Some(dir.join("resources/server/dist/server/bin.js")));
candidates.push(Some(dir.join("resources/server/dist/server/index.js")));
candidates.push(Some(
dir.join("resources/server/dist").join(executable_name),
));
let resources = dir.join("../Resources");
candidates.push(Some(resources.join("server/dist/bin.js")));
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(resources.join("server/dist").join(executable_name)));
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")];
for root in linux_resource_roots {
candidates.push(Some(root.join("server/dist/bin.js")));
candidates.push(Some(root.join("server/dist/index.js")));
candidates.push(Some(root.join("server/dist/server/bin.js")));
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")));
candidates.push(Some(root.join("server/dist").join(executable_name)));
candidates.push(Some(
root.join("resources/server/dist").join(executable_name),
));
}
}
}
@@ -1256,22 +1271,55 @@ fn build_shell_command_string(
) -> anyhow::Result<ShellCommand> {
let shell = default_shell();
let mut quoted: Vec<String> = Vec::new();
quoted.push(shell_escape(&entry.node_binary));
for arg in entry.runner_args(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",
shell_escape(&entry.node_binary),
quoted.join(" "),
MISSING_NODE_PREFIX,
shell_escape(&entry.node_binary),
);
let args = build_shell_args(&shell, &command);
let command = if entry.runner == Runner::Standalone {
quoted.push(shell_escape(&entry.entry));
for arg in cli_args {
quoted.push(shell_escape(arg));
}
format!("exec {}", quoted.join(" "))
} else {
quoted.push(shell_escape(&entry.node_binary));
for arg in entry.runner_args(cli_args) {
quoted.push(shell_escape(&arg));
}
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));
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 {
if let Ok(shell) = std::env::var("SHELL") {
if !shell.trim().is_empty() {
@@ -1306,8 +1354,8 @@ fn build_shell_args(shell: &str, command: &str) -> Vec<String> {
.unwrap_or("")
.to_lowercase();
if shell_name.contains("zsh") || shell_name.contains("bash") {
vec!["-i".into(), "-l".into(), "-c".into(), command.into()]
if shell_name.contains("zsh") {
vec!["-l".into(), "-i".into(), "-c".into(), command.into()]
} else {
vec!["-l".into(), "-c".into(), command.into()]
}

View File

@@ -40,6 +40,8 @@ const DEFAULT_ZOOM_LEVEL: f64 = 1.0;
const ZOOM_STEP: f64 = 0.1;
const MIN_ZOOM_LEVEL: f64 = 0.2;
const MAX_ZOOM_LEVEL: f64 = 5.0;
const LOCAL_WINDOW_CONTEXT_SCRIPT: &str = "window.__CODENOMAD_WINDOW_CONTEXT__ = 'local';";
const REMOTE_WINDOW_CONTEXT_SCRIPT: &str = "window.__CODENOMAD_WINDOW_CONTEXT__ = 'remote';";
#[cfg(windows)]
const WINDOWS_APP_USER_MODEL_ID: &str = "ai.neuralnomads.codenomad.client";
@@ -300,6 +302,7 @@ async fn open_remote_window_impl(
let initial_url = window_url.clone();
let window = WebviewWindowBuilder::new(&app, label.clone(), WebviewUrl::External(initial_url.clone()))
.initialization_script(REMOTE_WINDOW_CONTEXT_SCRIPT)
.title(title)
.inner_size(1400.0, 900.0)
.min_inner_size(800.0, 600.0)
@@ -542,6 +545,9 @@ fn main() {
.setup(|app| {
set_windows_app_user_model_id();
build_menu(&app.handle())?;
if let Some(window) = app.get_webview_window("main") {
let _ = window.eval(LOCAL_WINDOW_CONTEXT_SCRIPT);
}
if let Some(shortcut) = fullscreen_shortcut() {
let shortcut_manager = app.handle().global_shortcut();
let _ = shortcut_manager.register(shortcut.clone());

View File

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

View File

@@ -22,7 +22,7 @@ import { getLogger } from "./lib/logger"
import { launchError, showLaunchError, clearLaunchError } from "./stores/launch-errors"
import { formatLaunchErrorMessage, isMissingBinaryMessage } from "./lib/launch-errors"
import { initReleaseNotifications } from "./stores/releases"
import { runtimeEnv } from "./lib/runtime-env"
import { isTauriHost, isWebHost, runtimeEnv } from "./lib/runtime-env"
import { useI18n } from "./lib/i18n"
import { setWakeLockDesired } from "./lib/native/wake-lock"
import {
@@ -137,7 +137,7 @@ const App: Component = () => {
createEffect(() => {
if (typeof document === "undefined") return
const shouldShow =
runtimeEnv.host !== "web" && runtimeEnv.platform !== "mobile" && (preferences().showKeyboardShortcutHints ?? true)
!isWebHost() && runtimeEnv.platform !== "mobile" && (preferences().showKeyboardShortcutHints ?? true)
document.documentElement.dataset.keyboardHints = shouldShow ? "show" : "hide"
})
@@ -444,7 +444,7 @@ const App: Component = () => {
// Listen for Tauri menu events
onMount(() => {
if (runtimeEnv.host === "tauri") {
if (isTauriHost()) {
const tauriBridge = (window as { __TAURI__?: { event?: { listen: (event: string, handler: (event: { payload: unknown }) => void) => Promise<() => void> } } }).__TAURI__
if (tauriBridge?.event) {
let unlistenMenu: (() => void) | null = null

View File

@@ -58,6 +58,16 @@ function resolveAbsolutePath(root: string, relativePath: string) {
return `${trimmedRoot}${normalized}`
}
function getAbsolutePathFromMetadata(metadata: FileSystemListingMetadata | null) {
if (!metadata || metadata.pathKind === "drives") {
return ""
}
if (metadata.pathKind === "relative") {
return resolveAbsolutePath(metadata.rootPath, metadata.currentPath)
}
return metadata.displayPath
}
type FolderRow =
| { type: "up"; path: string }
| { type: "folder"; entry: FileSystemEntry }
@@ -67,6 +77,8 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
const [rootPath, setRootPath] = createSignal("")
const [loading, setLoading] = createSignal(false)
const [error, setError] = createSignal<string | null>(null)
const [pathInput, setPathInput] = createSignal("")
const [pathInputDirty, setPathInputDirty] = createSignal(false)
const [creatingFolder, setCreatingFolder] = createSignal(false)
const [directoryChildren, setDirectoryChildren] = createSignal<Map<string, FileSystemEntry[]>>(new Map())
const [loadingPaths, setLoadingPaths] = createSignal<Set<string>>(new Set())
@@ -75,12 +87,16 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
const metadataCache = new Map<string, FileSystemListingMetadata>()
const inFlightRequests = new Map<string, Promise<FileSystemListingMetadata>>()
let latestNavigationId = 0
function resetState() {
setRootPath("")
setDirectoryChildren(new Map<string, FileSystemEntry[]>())
setLoadingPaths(new Set<string>())
setCurrentPathKey(null)
setCurrentMetadata(null)
setPathInput("")
setPathInputDirty(false)
metadataCache.clear()
inFlightRequests.clear()
setError(null)
@@ -109,11 +125,7 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
async function initialize() {
setLoading(true)
try {
const metadata = await loadDirectory()
applyMetadata(metadata)
} catch (err) {
const message = err instanceof Error ? err.message : t("directoryBrowser.load.errorFallback")
setError(message)
await navigateTo()
} finally {
setLoading(false)
}
@@ -197,13 +209,22 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
}
async function navigateTo(path?: string) {
const navigationId = ++latestNavigationId
setError(null)
try {
const metadata = await loadDirectory(path)
if (navigationId !== latestNavigationId) {
return null
}
applyMetadata(metadata)
return metadata
} catch (err) {
if (navigationId !== latestNavigationId) {
return null
}
const message = err instanceof Error ? err.message : t("directoryBrowser.load.errorFallback")
setError(message)
return null
}
}
@@ -225,31 +246,58 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
})
function handleNavigateTo(path: string) {
setPathInputDirty(false)
void navigateTo(path)
}
function handleNavigateUp() {
const parent = currentMetadata()?.parentPath
if (parent) {
setPathInputDirty(false)
void navigateTo(parent)
}
}
const currentAbsolutePath = createMemo(() => {
const metadata = currentMetadata()
if (!metadata) {
return ""
return getAbsolutePathFromMetadata(currentMetadata())
})
createEffect(() => {
const absolutePath = currentAbsolutePath()
if (!pathInputDirty()) {
setPathInput(absolutePath)
}
if (metadata.pathKind === "drives") {
return ""
}
if (metadata.pathKind === "relative") {
return resolveAbsolutePath(metadata.rootPath, metadata.currentPath)
}
return metadata.displayPath
})
const canSelectCurrent = createMemo(() => Boolean(currentAbsolutePath()))
const canSubmitPath = createMemo(() => pathInput().trim().length > 0)
async function handlePathSubmit() {
const target = pathInput().trim()
if (!target) {
return
}
const metadata = await navigateTo(target)
if (!metadata) {
return
}
setPathInputDirty(false)
setPathInput(getAbsolutePathFromMetadata(metadata))
}
async function handleSelectCurrent() {
const target = pathInput().trim()
const metadata = target && target !== currentAbsolutePath() ? await navigateTo(target) : currentMetadata()
if (!metadata) {
return
}
setPathInputDirty(false)
const absolute = getAbsolutePathFromMetadata(metadata)
if (absolute) {
setPathInput(absolute)
props.onSelect(absolute)
}
}
function handleEntrySelect(entry: FileSystemEntry) {
const absolutePath = entry.absolutePath
@@ -262,10 +310,13 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
async function handleCreateFolder() {
if (creatingFolder()) return
const metadata = currentMetadata()
const target = pathInput().trim()
const metadata = target && target !== currentAbsolutePath() ? await navigateTo(target) : currentMetadata()
if (!metadata || metadata.pathKind === "drives") {
return
}
setPathInputDirty(false)
setPathInput(getAbsolutePathFromMetadata(metadata))
const name =
(await showPromptDialog(t("directoryBrowser.createFolder.promptMessage"), {
@@ -338,19 +389,29 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
<div class="directory-browser-current">
<div class="directory-browser-current-meta">
<span class="directory-browser-current-label">{t("directoryBrowser.currentFolder")}</span>
<span class="directory-browser-current-path">{currentAbsolutePath()}</span>
<input
type="text"
value={pathInput()}
onInput={(event) => {
setPathInput(event.currentTarget.value)
setPathInputDirty(true)
}}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault()
void handlePathSubmit()
}
}}
spellcheck={false}
class="selector-input directory-browser-current-path"
/>
</div>
<div class="directory-browser-current-actions">
<button
type="button"
class="selector-button selector-button-secondary directory-browser-select directory-browser-current-select"
disabled={!canSelectCurrent() || creatingFolder()}
onClick={() => {
const absolute = currentAbsolutePath()
if (absolute) {
props.onSelect(absolute)
}
}}
disabled={(!canSelectCurrent() && !canSubmitPath()) || creatingFolder()}
onClick={() => void handleSelectCurrent()}
>
{t("directoryBrowser.selectCurrent")}
</button>

View File

@@ -5,7 +5,7 @@ import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp, S
import { useConfig } from "../stores/preferences"
import DirectoryBrowserDialog from "./directory-browser-dialog"
import Kbd from "./kbd"
import { openNativeFolderDialog, supportsNativeDialogs } from "../lib/native/native-functions"
import { openNativeFolderDialog, supportsNativeDialogsInCurrentWindow } from "../lib/native/native-functions"
import { useFolderDrop } from "../lib/hooks/use-folder-drop"
import VersionPill from "./version-pill"
import { DiscordSymbolIcon, GitHubMarkIcon } from "./brand-icons"
@@ -16,7 +16,7 @@ import { showAlertDialog } from "../stores/alerts"
import { openSettings, settingsOpen } from "../stores/settings-screen"
import { openExternalUrl } from "../lib/external-url"
import { serverApi } from "../lib/api-client"
import { runtimeEnv } from "../lib/runtime-env"
import { canOpenRemoteWindows, isTauriHost } from "../lib/runtime-env"
import { openRemoteServerWindow } from "../lib/native/remote-window"
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
@@ -58,7 +58,6 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
const [serverDialogError, setServerDialogError] = createSignal<string | null>(null)
const [isSavingServer, setIsSavingServer] = createSignal(false)
const [connectingServerId, setConnectingServerId] = createSignal<string | null>(null)
const nativeDialogsAvailable = supportsNativeDialogs()
let recentListRef: HTMLDivElement | undefined
type LanguageOption = { value: Locale; label: string }
@@ -78,6 +77,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
const folders = () => recentFolders()
const serverList = () => remoteServers()
const isLoading = () => Boolean(props.isLoading)
const canUseRemoteServerWindows = () => canOpenRemoteWindows()
function getActiveListLength() {
return activeTab() === "local" ? folders().length : serverList().length
@@ -124,17 +124,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
const normalizedKey = e.key.toLowerCase()
const isBrowseShortcut = (e.metaKey || e.ctrlKey) && !e.shiftKey && normalizedKey === "n"
const blockedKeys = [
"ArrowDown",
"ArrowUp",
"PageDown",
"PageUp",
"Home",
"End",
"Enter",
"Backspace",
"Delete",
]
const blockedKeys = ["ArrowDown", "ArrowUp", "PageDown", "PageUp", "Home", "End", "Enter"]
if (isLoading()) {
if (isBrowseShortcut || blockedKeys.includes(e.key)) {
@@ -192,21 +182,6 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
} else if (e.key === "Enter") {
e.preventDefault()
handleEnterKey()
} else if (e.key === "Backspace" || e.key === "Delete") {
e.preventDefault()
if (listLength > 0 && focusMode() === "recent") {
if (activeTab() === "local") {
const folder = folders()[selectedIndex()]
if (folder) {
handleRemove(folder.path)
}
} else {
const server = serverList()[selectedIndex()]
if (server) {
removeRemoteServerProfile(server.id)
}
}
}
}
}
@@ -231,6 +206,10 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
createEffect(() => {
activeTab()
if (!canUseRemoteServerWindows() && activeTab() !== "local") {
setActiveTab("local")
return
}
setSelectedIndex(0)
setFocusMode("recent")
})
@@ -305,11 +284,16 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
}
function openServerDialog() {
if (!canUseRemoteServerWindows()) return
resetServerDialog()
setIsServerDialogOpen(true)
}
async function probeAndOpenServer(input: { id?: string; name: string; baseUrl: string; skipTlsVerify: boolean }, openWindow: boolean) {
if (openWindow && !canUseRemoteServerWindows()) {
throw new Error("Remote server windows can only be opened from a local desktop window")
}
const trimmedName = input.name.trim()
const trimmedUrl = input.baseUrl.trim()
if (!trimmedName || !trimmedUrl) {
@@ -334,7 +318,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
if (openWindow) {
const remoteProxySession =
runtimeEnv.host === "tauri" && profile.skipTlsVerify && profile.baseUrl.startsWith("https://")
isTauriHost() && profile.skipTlsVerify && profile.baseUrl.startsWith("https://")
? await serverApi.createRemoteProxySession({
baseUrl: profile.baseUrl,
skipTlsVerify: profile.skipTlsVerify,
@@ -379,6 +363,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
}
async function handleConnectSavedServer(id: string) {
if (!canUseRemoteServerWindows()) return
const target = remoteServers().find((entry) => entry.id === id)
if (!target || connectingServerId()) return
setConnectingServerId(id)
@@ -397,7 +382,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
async function handleBrowse() {
if (isLoading()) return
setFocusMode("new")
if (nativeDialogsAvailable) {
if (supportsNativeDialogsInCurrentWindow()) {
const fallbackPath = folders()[0]?.path
const selected = await openNativeFolderDialog({
title: t("folderSelection.dialog.title"),
@@ -554,15 +539,17 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
>
<Settings class="w-4 h-4" />
</button>
<button
type="button"
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
onClick={() => openSettings("remote")}
aria-label={t("instanceTabs.remote.ariaLabel")}
title={t("instanceTabs.remote.title")}
>
<MonitorUp class="w-4 h-4" />
</button>
<Show when={canUseRemoteServerWindows()}>
<button
type="button"
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
onClick={() => openSettings("remote")}
aria-label={t("instanceTabs.remote.ariaLabel")}
title={t("instanceTabs.remote.title")}
>
<MonitorUp class="w-4 h-4" />
</button>
</Show>
<Show when={props.onClose}>
<button
type="button"
@@ -636,7 +623,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
<div class="order-1 lg:order-2 flex flex-col gap-4 flex-1 min-h-0 overflow-hidden">
<div class="panel flex flex-col flex-1 min-h-0">
<div class="panel-header !gap-0 !p-0">
<div class="grid grid-cols-2 gap-0 overflow-hidden border border-base rounded-t-lg rounded-b-none">
<div class={`grid ${canUseRemoteServerWindows() ? "grid-cols-2" : "grid-cols-1"} gap-0 overflow-hidden border border-base rounded-t-lg rounded-b-none`}>
<button
type="button"
class="border-r border-base px-4 py-3 text-left transition-colors"
@@ -671,35 +658,37 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
)}
</p>
</button>
<button
type="button"
class="px-4 py-3 text-left transition-colors"
classList={{
"text-primary": activeTab() === "servers",
"text-muted hover:text-secondary": activeTab() !== "servers",
}}
style={{
"background-color": "var(--surface-secondary)",
}}
onClick={() => setActiveTab("servers")}
>
<div
class="panel-title text-base"
style={{
color: activeTab() === "servers" ? "var(--text-primary)" : "var(--text-secondary)",
<Show when={canUseRemoteServerWindows()}>
<button
type="button"
class="px-4 py-3 text-left transition-colors"
classList={{
"text-primary": activeTab() === "servers",
"text-muted hover:text-secondary": activeTab() !== "servers",
}}
>
{t("folderSelection.tabs.servers")}
</div>
<p
class="panel-subtitle mt-1"
style={{
color: activeTab() === "servers" ? "var(--text-muted)" : "var(--text-secondary)",
"background-color": "var(--surface-secondary)",
}}
onClick={() => setActiveTab("servers")}
>
{t("folderSelection.servers.count", { count: remoteServers().length })}
</p>
</button>
<div
class="panel-title text-base"
style={{
color: activeTab() === "servers" ? "var(--text-primary)" : "var(--text-secondary)",
}}
>
{t("folderSelection.tabs.servers")}
</div>
<p
class="panel-subtitle mt-1"
style={{
color: activeTab() === "servers" ? "var(--text-muted)" : "var(--text-secondary)",
}}
>
{t("folderSelection.servers.count", { count: remoteServers().length })}
</p>
</button>
</Show>
</div>
</div>
@@ -707,23 +696,25 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
when={activeTab() === "local"}
fallback={
<Show
when={remoteServers().length > 0}
when={canUseRemoteServerWindows() && remoteServers().length > 0}
fallback={
<div class="panel-empty-state flex-1">
<div class="panel-empty-state-icon">
<Globe class="w-12 h-12 mx-auto" />
<Show when={canUseRemoteServerWindows()}>
<div class="panel-empty-state flex-1">
<div class="panel-empty-state-icon">
<Globe class="w-12 h-12 mx-auto" />
</div>
<p class="panel-empty-state-title">{t("folderSelection.servers.empty.title")}</p>
<p class="panel-empty-state-description">{t("folderSelection.servers.empty.description")}</p>
<button
type="button"
class="button-primary mt-4 w-auto self-center inline-flex items-center justify-center gap-2 px-4"
onClick={openServerDialog}
>
<Globe class="w-4 h-4" />
<span>{t("folderSelection.actions.connectButton")}</span>
</button>
</div>
<p class="panel-empty-state-title">{t("folderSelection.servers.empty.title")}</p>
<p class="panel-empty-state-description">{t("folderSelection.servers.empty.description")}</p>
<button
type="button"
class="button-primary mt-4 w-auto self-center inline-flex items-center justify-center gap-2 px-4"
onClick={openServerDialog}
>
<Globe class="w-4 h-4" />
<span>{t("folderSelection.actions.connectButton")}</span>
</button>
</div>
</Show>
}
>
<div
@@ -891,15 +882,17 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
</div>
</button>
<button
onClick={openServerDialog}
class="button-primary w-full flex items-center justify-center text-sm"
>
<div class="flex items-center gap-2">
<Globe class="w-4 h-4" />
<span>{t("folderSelection.actions.connectButton")}</span>
</div>
</button>
<Show when={canUseRemoteServerWindows()}>
<button
onClick={openServerDialog}
class="button-primary w-full flex items-center justify-center text-sm"
>
<div class="flex items-center gap-2">
<Globe class="w-4 h-4" />
<span>{t("folderSelection.actions.connectButton")}</span>
</div>
</button>
</Show>
</div>
{/* OpenCode settings section */}
@@ -935,10 +928,6 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
<kbd class="kbd">Enter</kbd>
<span>{t("folderSelection.hints.select")}</span>
</div>
<div class="flex items-center gap-1.5">
<kbd class="kbd">Del</kbd>
<span>{t("folderSelection.hints.remove")}</span>
</div>
</Show>
<div class="flex items-center gap-1.5">
<Kbd shortcut="cmd+n" class="kbd-hint" />

View File

@@ -6,6 +6,7 @@ import { Plus, MonitorUp, Bell, BellOff, Settings } from "lucide-solid"
import { keyboardRegistry } from "../lib/keyboard-registry"
import { useI18n } from "../lib/i18n"
import { isOsNotificationSupportedSync } from "../lib/os-notifications"
import { canOpenRemoteWindows } from "../lib/runtime-env"
import { useConfig } from "../stores/preferences"
import { openSettings } from "../stores/settings-screen"
import type { AppTabRecord } from "../stores/app-tabs"
@@ -99,14 +100,16 @@ const InstanceTabs: Component<InstanceTabsProps> = (props) => {
<Dynamic component={notificationIcon()} class="w-4 h-4" />
</button>
<button
class="new-tab-button tab-remote-button"
onClick={() => openSettings("remote")}
title={t("instanceTabs.remote.title")}
aria-label={t("instanceTabs.remote.ariaLabel")}
>
<MonitorUp class="w-4 h-4" />
</button>
<Show when={canOpenRemoteWindows()}>
<button
class="new-tab-button tab-remote-button"
onClick={() => openSettings("remote")}
title={t("instanceTabs.remote.title")}
aria-label={t("instanceTabs.remote.ariaLabel")}
>
<MonitorUp class="w-4 h-4" />
</button>
</Show>
</div>
</div>
</div>

View File

@@ -171,9 +171,6 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
} else if (e.key === "Enter") {
e.preventDefault()
void handleEnterKey()
} else if (e.key === "Delete" || e.key === "Backspace") {
e.preventDefault()
void handleDeleteKey()
}
}
@@ -187,29 +184,6 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
}
}
async function handleDeleteKey() {
const sessions = parentSessions()
const index = selectedIndex()
if (index >= sessions.length) {
return
}
await handleSessionDelete(sessions[index].id)
const updatedSessions = parentSessions()
if (updatedSessions.length === 0) {
setFocusMode("new-session")
setSelectedIndex(0)
return
}
const nextIndex = Math.min(index, updatedSessions.length - 1)
setSelectedIndex(nextIndex)
setFocusMode("sessions")
scrollToIndex(nextIndex)
}
onMount(() => {
window.addEventListener("keydown", handleKeyDown)
@@ -562,10 +536,6 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
<kbd class="kbd">Enter</kbd>
<span>{t("instanceWelcome.hints.resume")}</span>
</div>
<div class="flex items-center gap-1.5">
<kbd class="kbd">Del</kbd>
<span>{t("instanceWelcome.hints.delete")}</span>
</div>
</div>
</div>

View File

@@ -3,7 +3,7 @@ import { FolderOpen, Trash2, Check, AlertCircle, Loader2, Plus } from "lucide-so
import { useConfig } from "../stores/preferences"
import { serverApi } from "../lib/api-client"
import FileSystemBrowserDialog from "./filesystem-browser-dialog"
import { openNativeFileDialog, supportsNativeDialogs } from "../lib/native/native-functions"
import { openNativeFileDialog, supportsNativeDialogsInCurrentWindow } from "../lib/native/native-functions"
import { useI18n } from "../lib/i18n"
import { getLogger } from "../lib/logger"
const log = getLogger("actions")
@@ -38,7 +38,6 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
const [versionInfo, setVersionInfo] = createSignal<Map<string, string>>(new Map<string, string>())
const [validatingPaths, setValidatingPaths] = createSignal<Set<string>>(new Set<string>())
const [isBinaryBrowserOpen, setIsBinaryBrowserOpen] = createSignal(false)
const nativeDialogsAvailable = supportsNativeDialogs()
const binaries = () => opencodeBinaries()
@@ -139,7 +138,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
async function handleBrowseBinary() {
if (props.disabled) return
setValidationError(null)
if (nativeDialogsAvailable) {
if (supportsNativeDialogsInCurrentWindow()) {
const selected = await openNativeFileDialog({
title: t("opencodeBinarySelector.dialog.title"),
})

View File

@@ -15,25 +15,33 @@ import { OpenCodeSettingsSection } from "./settings/opencode-settings-section"
import { RemoteAccessSettingsSection } from "./settings/remote-access-settings-section"
import { SpeechSettingsSection } from "./settings/speech-settings-section"
import { SideCarsSettingsSection } from "./settings/sidecars-settings-section"
import { canOpenRemoteWindows } from "../lib/runtime-env"
export const SettingsScreen: Component = () => {
const { t } = useI18n()
const sections = createMemo(() => [
{ id: "appearance" as SettingsSectionId, icon: Paintbrush, label: t("settings.nav.appearance") },
{ id: "notifications" as SettingsSectionId, icon: Bell, label: t("settings.nav.notifications") },
{ id: "remote" as SettingsSectionId, icon: MonitorUp, label: t("settings.nav.remote") },
{ id: "speech" as SettingsSectionId, icon: Volume2, label: t("settings.nav.speech") },
{ id: "sidecars" as SettingsSectionId, icon: Globe, label: t("settings.nav.sidecars") },
{ id: "opencode" as SettingsSectionId, icon: Terminal, label: t("settings.nav.opencode") },
])
const sections = createMemo(() => {
const items = [
{ id: "appearance" as SettingsSectionId, icon: Paintbrush, label: t("settings.nav.appearance") },
{ id: "notifications" as SettingsSectionId, icon: Bell, label: t("settings.nav.notifications") },
{ id: "speech" as SettingsSectionId, icon: Volume2, label: t("settings.nav.speech") },
{ id: "sidecars" as SettingsSectionId, icon: Globe, label: t("settings.nav.sidecars") },
{ id: "opencode" as SettingsSectionId, icon: Terminal, label: t("settings.nav.opencode") },
]
if (canOpenRemoteWindows()) {
items.splice(2, 0, { id: "remote" as SettingsSectionId, icon: MonitorUp, label: t("settings.nav.remote") })
}
return items
})
const renderSection = () => {
switch (activeSettingsSection()) {
case "notifications":
return <NotificationsSettingsSection />
case "remote":
return <RemoteAccessSettingsSection />
return canOpenRemoteWindows() ? <RemoteAccessSettingsSection /> : <AppearanceSettingsSection />
case "speech":
return <SpeechSettingsSection />
case "sidecars":

View File

@@ -17,6 +17,8 @@ interface LspDiagnostic {
range?: LspRange
}
export type DiagnosticsMap = Record<string, LspDiagnostic[] | undefined>
export interface DiagnosticEntry {
id: string
severity: number
@@ -30,7 +32,7 @@ export interface DiagnosticEntry {
column: number
}
function normalizeDiagnosticPath(path: string) {
export function normalizeDiagnosticPath(path: string) {
return path.replace(/\\/g, "/")
}
@@ -53,49 +55,71 @@ export function extractDiagnostics(state: ToolState | undefined): DiagnosticEntr
const metadata = (state.metadata || {}) as Record<string, unknown>
const input = (state.input || {}) as Record<string, unknown>
const diagnosticsMap = metadata?.diagnostics as Record<string, LspDiagnostic[] | undefined> | undefined
const diagnosticsMap = metadata?.diagnostics as DiagnosticsMap | undefined
if (!diagnosticsMap) return []
const preferredPath = [input.filePath, metadata.filePath, metadata.filepath, input.path].find(
(value) => typeof value === "string" && value.length > 0,
) as string | undefined
return buildDiagnosticEntries(diagnosticsMap, [input.filePath, metadata.filePath, metadata.filepath, input.path])
}
const normalizedPreferred = preferredPath ? normalizeDiagnosticPath(preferredPath) : undefined
if (!normalizedPreferred) return []
const candidateEntries = Object.entries(diagnosticsMap).filter(([, items]) => Array.isArray(items) && items.length > 0)
if (candidateEntries.length === 0) return []
export function resolveDiagnosticsKey(diagnostics: DiagnosticsMap, preferredPaths: Array<string | undefined>): string | undefined {
if (Object.keys(diagnostics).length === 0) return undefined
const prioritizedEntries = candidateEntries.filter(([path]) => {
const normalized = normalizeDiagnosticPath(path)
return normalized === normalizedPreferred
})
const normalizedPreferred = preferredPaths
.filter((value): value is string => typeof value === "string" && value.length > 0)
.map((value) => normalizeDiagnosticPath(value))
if (prioritizedEntries.length === 0) return []
if (normalizedPreferred.length === 0) return undefined
for (const preferred of normalizedPreferred) {
if (diagnostics[preferred]) return preferred
}
const keys = Object.keys(diagnostics)
for (const preferred of normalizedPreferred) {
const direct = keys.find((key) => normalizeDiagnosticPath(key) === preferred)
if (direct) return direct
}
for (const preferred of normalizedPreferred) {
const suffixMatch = keys.find((key) => {
const normalized = normalizeDiagnosticPath(key)
return normalized === preferred || normalized.endsWith("/" + preferred)
})
if (suffixMatch) return suffixMatch
}
return undefined
}
export function buildDiagnosticEntries(diagnostics: DiagnosticsMap, preferredPaths: Array<string | undefined>): DiagnosticEntry[] {
const key = resolveDiagnosticsKey(diagnostics, preferredPaths)
if (!key) return []
const list = diagnostics[key]
if (!Array.isArray(list) || list.length === 0) return []
const entries: DiagnosticEntry[] = []
for (const [pathKey, list] of prioritizedEntries) {
if (!Array.isArray(list)) continue
const normalizedPath = normalizeDiagnosticPath(pathKey)
for (let index = 0; index < list.length; index++) {
const diagnostic = list[index]
if (!diagnostic || typeof diagnostic.message !== "string") continue
const tone = determineSeverityTone(typeof diagnostic.severity === "number" ? diagnostic.severity : undefined)
const severityMeta = getSeverityMeta(tone)
const line = typeof diagnostic.range?.start?.line === "number" ? diagnostic.range.start.line + 1 : 0
const column = typeof diagnostic.range?.start?.character === "number" ? diagnostic.range.start.character + 1 : 0
entries.push({
id: `${normalizedPath}-${index}-${diagnostic.message}`,
severity: severityMeta.rank,
tone,
label: severityMeta.label,
icon: severityMeta.icon,
message: diagnostic.message,
filePath: normalizedPath,
displayPath: getRelativePath(normalizedPath),
line,
column,
})
}
const normalizedPath = normalizeDiagnosticPath(key)
for (let index = 0; index < list.length; index++) {
const diagnostic = list[index]
if (!diagnostic || typeof diagnostic.message !== "string") continue
const tone = determineSeverityTone(typeof diagnostic.severity === "number" ? diagnostic.severity : undefined)
const severityMeta = getSeverityMeta(tone)
const line = typeof diagnostic.range?.start?.line === "number" ? diagnostic.range.start.line + 1 : 0
const column = typeof diagnostic.range?.start?.character === "number" ? diagnostic.range.start.character + 1 : 0
entries.push({
id: `${normalizedPath}-${index}-${diagnostic.message}`,
severity: severityMeta.rank,
tone,
label: severityMeta.label,
icon: severityMeta.icon,
message: diagnostic.message,
filePath: normalizedPath,
displayPath: getRelativePath(normalizedPath),
line,
column,
})
}
return entries.sort((a, b) => a.severity - b.severity)

View File

@@ -1,107 +1,14 @@
import { For, Show, createMemo } from "solid-js"
import type { ToolRenderer } from "../types"
import { getRelativePath, getToolName, isToolStateCompleted, readToolStatePayload } from "../utils"
import type { DiagnosticEntry } from "../diagnostics"
type LspRangePosition = {
line?: number
character?: number
}
type LspRange = {
start?: LspRangePosition
}
type LspDiagnostic = {
message?: string
severity?: number
range?: LspRange
}
import { buildDiagnosticEntries, type DiagnosticEntry, type DiagnosticsMap } from "../diagnostics"
type ApplyPatchFile = {
filePath?: string
relativePath?: string
type?: string
diff?: string
}
function normalizePath(value: string): string {
return value.replace(/\\/g, "/")
}
function determineSeverityTone(severity?: number): DiagnosticEntry["tone"] {
if (severity === 1) return "error"
if (severity === 2) return "warning"
return "info"
}
function getSeverityMeta(tone: DiagnosticEntry["tone"], t: (key: string, params?: Record<string, unknown>) => string) {
if (tone === "error") return { label: t("toolCall.diagnostics.severity.error.short"), icon: "!", rank: 0 }
if (tone === "warning") return { label: t("toolCall.diagnostics.severity.warning.short"), icon: "!", rank: 1 }
return { label: t("toolCall.diagnostics.severity.info.short"), icon: "i", rank: 2 }
}
function resolveDiagnosticsKey(
diagnostics: Record<string, LspDiagnostic[] | undefined>,
file: ApplyPatchFile,
): string | undefined {
const absolute = typeof file.filePath === "string" ? normalizePath(file.filePath) : ""
const relative = typeof file.relativePath === "string" ? normalizePath(file.relativePath) : ""
if (absolute && diagnostics[absolute]) return absolute
if (relative && diagnostics[relative]) return relative
if (absolute) {
const direct = Object.keys(diagnostics).find((key) => normalizePath(key) === absolute)
if (direct) return direct
}
if (relative) {
const suffixMatch = Object.keys(diagnostics).find((key) => {
const normalized = normalizePath(key)
return normalized === relative || normalized.endsWith("/" + relative)
})
if (suffixMatch) return suffixMatch
}
return undefined
}
function buildDiagnostics(
diagnostics: Record<string, LspDiagnostic[] | undefined>,
file: ApplyPatchFile,
t: (key: string, params?: Record<string, unknown>) => string,
): DiagnosticEntry[] {
const key = resolveDiagnosticsKey(diagnostics, file)
if (!key) return []
const list = diagnostics[key]
if (!Array.isArray(list) || list.length === 0) return []
const normalizedKey = normalizePath(key)
const entries: DiagnosticEntry[] = []
for (let index = 0; index < list.length; index++) {
const diagnostic = list[index]
if (!diagnostic || typeof diagnostic.message !== "string") continue
const tone = determineSeverityTone(typeof diagnostic.severity === "number" ? diagnostic.severity : undefined)
const severityMeta = getSeverityMeta(tone, t)
const line = typeof diagnostic.range?.start?.line === "number" ? diagnostic.range.start.line + 1 : 0
const column = typeof diagnostic.range?.start?.character === "number" ? diagnostic.range.start.character + 1 : 0
entries.push({
id: `${normalizedKey}-${index}-${diagnostic.message}`,
severity: severityMeta.rank,
tone,
label: severityMeta.label,
icon: severityMeta.icon,
message: diagnostic.message,
filePath: normalizedKey,
displayPath: getRelativePath(normalizedKey),
line,
column,
})
}
return entries.sort((a, b) => a.severity - b.severity)
patch?: string
}
function DiagnosticsInline(props: { entries: DiagnosticEntry[]; label: string; t: (key: string, params?: Record<string, unknown>) => string }) {
@@ -164,7 +71,7 @@ export const applyPatchRenderer: ToolRenderer = {
})
const diagnosticsMap = createMemo(() => {
const value = (payload.metadata as any).diagnostics
return value && typeof value === "object" ? (value as Record<string, LspDiagnostic[] | undefined>) : {}
return value && typeof value === "object" ? (value as DiagnosticsMap) : {}
})
if (files().length === 0) {
@@ -178,9 +85,9 @@ export const applyPatchRenderer: ToolRenderer = {
<For each={files()}>
{(file, index) => {
const labelBase = file.relativePath || file.filePath || t("toolCall.applyPatch.fileFallback", { number: index() + 1 })
const diffText = typeof file.diff === "string" ? file.diff : ""
const diffText = typeof file.diff === "string" ? file.diff : typeof file.patch === "string" ? file.patch : ""
const filePath = typeof file.filePath === "string" ? file.filePath : file.relativePath
const entries = createMemo(() => buildDiagnostics(diagnosticsMap(), file, t))
const entries = createMemo(() => buildDiagnosticEntries(diagnosticsMap(), [file.filePath, file.relativePath]))
return (
<div class="tool-call-apply-patch-file">

View File

@@ -7,7 +7,7 @@ import {
normalizeDroppedDirectoryPaths,
supportsDesktopFolderDrop,
} from "../native/desktop-file-drop"
import { runtimeEnv } from "../runtime-env"
import { isTauriHost } from "../runtime-env"
interface UseFolderDropOptions {
enabled: Accessor<boolean>
@@ -94,7 +94,7 @@ export function useFolderDrop(options: UseFolderDropOptions): {
const bind: FolderDropBindings = {
onDragEnter(event) {
if (!isSupported || runtimeEnv.host === "tauri" || !options.enabled() || !containsFileDrop(event)) {
if (!isSupported || isTauriHost() || !options.enabled() || !containsFileDrop(event)) {
return
}
event.preventDefault()
@@ -102,7 +102,7 @@ export function useFolderDrop(options: UseFolderDropOptions): {
setIsActive(true)
},
onDragOver(event) {
if (!isSupported || runtimeEnv.host === "tauri" || !options.enabled() || !containsFileDrop(event)) {
if (!isSupported || isTauriHost() || !options.enabled() || !containsFileDrop(event)) {
return
}
event.preventDefault()
@@ -112,7 +112,7 @@ export function useFolderDrop(options: UseFolderDropOptions): {
setIsActive(true)
},
onDragLeave(event) {
if (!isSupported || runtimeEnv.host === "tauri" || !containsFileDrop(event)) {
if (!isSupported || isTauriHost() || !containsFileDrop(event)) {
return
}
event.preventDefault()
@@ -134,7 +134,7 @@ export function useFolderDrop(options: UseFolderDropOptions): {
return
}
if (runtimeEnv.host === "tauri") {
if (isTauriHost()) {
reset()
return
}

View File

@@ -1,12 +1,16 @@
import { invoke } from "@tauri-apps/api/core"
import { runtimeEnv } from "../runtime-env"
import { canRestartCli, isElectronHost, isTauriHost } from "../runtime-env"
import { getLogger } from "../logger"
const log = getLogger("actions")
export async function restartCli(): Promise<boolean> {
if (!canRestartCli()) {
return false
}
try {
if (runtimeEnv.host === "electron") {
if (isElectronHost()) {
const api = (window as typeof window & { electronAPI?: { restartCli?: () => Promise<unknown> } }).electronAPI
if (api?.restartCli) {
await api.restartCli()
@@ -15,7 +19,7 @@ export async function restartCli(): Promise<boolean> {
return false
}
if (runtimeEnv.host === "tauri") {
if (isTauriHost()) {
if (typeof window.__TAURI__?.core?.invoke === "function") {
await invoke("cli_restart")
return true

View File

@@ -1,6 +1,6 @@
import { listen } from "@tauri-apps/api/event"
import { getLogger } from "../logger"
import { runtimeEnv } from "../runtime-env"
import { canUseDesktopFolderDrop, isElectronHost, isTauriHost, runtimeEnv } from "../runtime-env"
const log = getLogger("actions")
@@ -21,7 +21,7 @@ function getFilePath(file: File): string | null {
if (typeof file.path === "string" && file.path.trim().length > 0) {
return file.path
}
if (runtimeEnv.host === "electron") {
if (isElectronHost()) {
const electronPath = (window as Window & { electronAPI?: ElectronAPI }).electronAPI?.getPathForFile?.(file)
if (typeof electronPath === "string" && electronPath.trim().length > 0) {
return electronPath
@@ -44,7 +44,7 @@ async function resolveElectronDirectoryPaths(paths: string[]): Promise<string[]>
}
export function supportsDesktopFolderDrop(): boolean {
return runtimeEnv.platform === "desktop" && runtimeEnv.host !== "web"
return runtimeEnv.platform === "desktop" && canUseDesktopFolderDrop()
}
export function containsFileDrop(event: DragEvent): boolean {
@@ -97,14 +97,14 @@ export async function normalizeDroppedDirectoryPaths(paths: string[]): Promise<s
if (uniquePaths.length === 0) {
return []
}
if (runtimeEnv.host === "electron") {
if (isElectronHost()) {
return resolveElectronDirectoryPaths(uniquePaths)
}
return uniquePaths
}
export async function listenForNativeFolderDrops(onDrop: (paths: string[]) => void): Promise<() => void> {
if (runtimeEnv.host !== "tauri") {
if (!isTauriHost()) {
return () => {}
}
@@ -126,7 +126,7 @@ export async function listenForNativeFolderDrops(onDrop: (paths: string[]) => vo
}
export async function listenForNativeFolderDropState(onState: (state: NativeFolderDropState) => void): Promise<() => void> {
if (runtimeEnv.host !== "tauri") {
if (!isTauriHost()) {
return () => {}
}

View File

@@ -1,4 +1,4 @@
import { runtimeEnv } from "../runtime-env"
import { canUseNativeDialogs, isElectronHost, isTauriHost } from "../runtime-env"
import type { NativeDialogOptions } from "./types"
import { openElectronNativeDialog } from "./electron/functions"
import { openTauriNativeDialog } from "./tauri/functions"
@@ -6,20 +6,23 @@ import { openTauriNativeDialog } from "./tauri/functions"
export type { NativeDialogOptions, NativeDialogFilter, NativeDialogMode } from "./types"
function resolveNativeHandler(): ((options: NativeDialogOptions) => Promise<string | null>) | null {
switch (runtimeEnv.host) {
case "electron":
return openElectronNativeDialog
case "tauri":
return openTauriNativeDialog
default:
return null
if (isElectronHost()) {
return openElectronNativeDialog
}
if (isTauriHost()) {
return openTauriNativeDialog
}
return null
}
export function supportsNativeDialogs(): boolean {
return resolveNativeHandler() !== null
}
export function supportsNativeDialogsInCurrentWindow(): boolean {
return canUseNativeDialogs()
}
async function openNativeDialog(options: NativeDialogOptions): Promise<string | null> {
const handler = resolveNativeHandler()
if (!handler) {

View File

@@ -2,7 +2,7 @@ import { invoke } from "@tauri-apps/api/core"
import type { RemoteServerProfile } from "../../../../server/src/api-types"
import { showConfirmDialog } from "../../stores/alerts"
import { tGlobal } from "../i18n"
import { runtimeEnv } from "../runtime-env"
import { canOpenRemoteWindows, isElectronHost, isTauriHost } from "../runtime-env"
export interface RemoteWindowOpenPayload {
id: string
@@ -18,6 +18,10 @@ export async function openRemoteServerWindow(
entryUrl?: string,
proxySessionId?: string,
): Promise<void> {
if (!canOpenRemoteWindows()) {
throw new Error("Remote server windows can only be opened from a local desktop window")
}
const payload: RemoteWindowOpenPayload = {
id: profile.id,
name: profile.name,
@@ -27,7 +31,7 @@ export async function openRemoteServerWindow(
skipTlsVerify: profile.skipTlsVerify,
}
if (runtimeEnv.host === "electron") {
if (isElectronHost()) {
const api = (window as Window & { electronAPI?: ElectronAPI }).electronAPI
if (typeof api?.openRemoteWindow === "function") {
await api.openRemoteWindow(payload)
@@ -35,7 +39,7 @@ export async function openRemoteServerWindow(
}
}
if (runtimeEnv.host === "tauri") {
if (isTauriHost()) {
const requiresLocalCertificate =
proxySessionId !== undefined && (entryUrl ?? profile.baseUrl).startsWith("https://")

View File

@@ -1,5 +1,5 @@
import { invoke } from "@tauri-apps/api/core"
import { runtimeEnv } from "../runtime-env"
import { isElectronHost, isTauriHost } from "../runtime-env"
import { getLogger } from "../logger"
const log = getLogger("actions")
@@ -56,11 +56,11 @@ async function setWebWakeLock(enabled: boolean): Promise<boolean> {
function hasAnyWakeLockSupport(): boolean {
if (typeof window === "undefined") return false
if (runtimeEnv.host === "electron") {
if (isElectronHost()) {
const api = (window as any).electronAPI
if (api?.setWakeLock) return true
}
if (runtimeEnv.host === "tauri") {
if (isTauriHost()) {
return typeof window.__TAURI__?.core?.invoke === "function"
}
return Boolean((navigator as any)?.wakeLock?.request)
@@ -106,13 +106,13 @@ async function setTauriWakeLock(enabled: boolean): Promise<boolean> {
async function applyWakeLock(enabled: boolean): Promise<boolean> {
if (typeof window === "undefined") return false
if (runtimeEnv.host === "electron") {
if (isElectronHost()) {
const ok = await setElectronWakeLock(enabled)
if (ok || !enabled) return ok
// fallback to web API if electron preload didn't expose it
}
if (runtimeEnv.host === "tauri") {
if (isTauriHost()) {
const ok = await setTauriWakeLock(enabled)
if (ok || !enabled) return ok
// fallback to web API if tauri command isn't available

View File

@@ -2,10 +2,12 @@ import { getLogger } from "./logger"
export type HostRuntime = "electron" | "tauri" | "web"
export type PlatformKind = "desktop" | "mobile"
export type WindowContextKind = "local" | "remote"
export interface RuntimeEnvironment {
host: HostRuntime
platform: PlatformKind
windowContext: WindowContextKind
}
declare global {
@@ -14,6 +16,7 @@ declare global {
}
interface Window {
__CODENOMAD_WINDOW_CONTEXT__?: WindowContextKind
electronAPI?: unknown
__TAURI__?: {
core?: TauriCoreModule
@@ -21,11 +24,41 @@ declare global {
}
}
function detectWindowContext(): WindowContextKind {
if (typeof window === "undefined") {
return "remote"
}
if (window.__CODENOMAD_WINDOW_CONTEXT__ === "remote") {
return "remote"
}
if (window.__CODENOMAD_WINDOW_CONTEXT__ === "local") {
return "local"
}
const win = window as Window & { electronAPI?: unknown }
if (typeof win.electronAPI !== "undefined" || typeof win.__TAURI__ !== "undefined") {
return "local"
}
if (typeof navigator !== "undefined" && /tauri/i.test(navigator.userAgent)) {
return "local"
}
return "remote"
}
function detectHost(): HostRuntime {
if (typeof window === "undefined") {
return "web"
}
const explicitHost = window.__CODENOMAD_RUNTIME_HOST__
if (explicitHost) {
return explicitHost
}
const win = window as Window & { electronAPI?: unknown }
if (typeof win.electronAPI !== "undefined") {
return "electron"
@@ -71,16 +104,24 @@ export function detectRuntimeEnvironment(): RuntimeEnvironment {
cachedEnv = {
host: detectHost(),
platform: detectPlatform(),
windowContext: detectWindowContext(),
}
if (typeof window !== "undefined") {
log.info(`[runtime] host=${cachedEnv.host} platform=${cachedEnv.platform}`)
log.info(`[runtime] host=${cachedEnv.host} platform=${cachedEnv.platform} context=${cachedEnv.windowContext}`)
}
return cachedEnv
}
export const runtimeEnv = detectRuntimeEnvironment()
export const isElectronHost = () => runtimeEnv.host === "electron"
export const isTauriHost = () => runtimeEnv.host === "tauri"
export const isWebHost = () => runtimeEnv.host === "web"
export const isMobilePlatform = () => runtimeEnv.platform === "mobile"
export const isElectronHost = () => detectHost() === "electron"
export const isTauriHost = () => detectHost() === "tauri"
export const isWebHost = () => detectHost() === "web"
export const isDesktopHost = () => isElectronHost() || isTauriHost()
export const isMobilePlatform = () => detectPlatform() === "mobile"
export const isLocalWindow = () => detectWindowContext() === "local"
export const isRemoteWindow = () => detectWindowContext() === "remote"
export const canUseNativeDialogs = () => isDesktopHost() && isLocalWindow()
export const canOpenRemoteWindows = () => isDesktopHost() && isLocalWindow()
export const canRestartCli = () => isDesktopHost() && isLocalWindow()
export const canUseDesktopFolderDrop = () => isDesktopHost() && isLocalWindow()

View File

@@ -6,7 +6,7 @@ import type {
} from "../../stores/preferences"
import type { Command } from "../commands"
import { tGlobal } from "../i18n"
import { runtimeEnv } from "../runtime-env"
import { isWebHost } from "../runtime-env"
export type BehaviorSettingKind = "toggle" | "enum"
@@ -84,7 +84,7 @@ export function getBehaviorSettings(actions: BehaviorRegistryActions): BehaviorS
next,
)
},
disabled: () => runtimeEnv.host === "web",
disabled: () => isWebHost(),
},
{
kind: "toggle",
@@ -337,13 +337,13 @@ export function getBehaviorCommands(actions: BehaviorRegistryActions): Command[]
),
description: () =>
tGlobal(
runtimeEnv.host === "web"
isWebHost()
? "commands.keyboardShortcutHints.description.disabledWeb"
: "commands.keyboardShortcutHints.description",
),
category: "System",
keywords: () => splitKeywords("commands.keyboardShortcutHints.keywords"),
disabled: () => runtimeEnv.host === "web",
disabled: () => isWebHost(),
action: actions.toggleKeyboardShortcutHints,
},
{

View File

@@ -63,10 +63,12 @@ declare global {
}
interface Window {
__CODENOMAD_API_BASE__?: string
__CODENOMAD_EVENTS_URL__?: string
electronAPI?: ElectronAPI
__TAURI__?: TauriBridge
codenomadLogger?: LoggerControls
__CODENOMAD_API_BASE__?: string
__CODENOMAD_EVENTS_URL__?: string
__CODENOMAD_RUNTIME_HOST__?: "electron" | "tauri" | "web"
__CODENOMAD_WINDOW_CONTEXT__?: "local" | "remote"
electronAPI?: ElectronAPI
__TAURI__?: TauriBridge
codenomadLogger?: LoggerControls
}
}