Compare commits
19 Commits
v0.14.0-de
...
no-more-no
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
105714778b | ||
|
|
c9eea8c003 | ||
|
|
25512e8dc1 | ||
|
|
f56d63d166 | ||
|
|
8173030b1a | ||
|
|
73a97e64ba | ||
|
|
a5f38ee625 | ||
|
|
ca880451e7 | ||
|
|
4af8cc08b9 | ||
|
|
b60d86116a | ||
|
|
76f14e2189 | ||
|
|
9ecd5131a6 | ||
|
|
95f47ebbe4 | ||
|
|
6c50564df6 | ||
|
|
166edd2e30 | ||
|
|
79dbbd4cb4 | ||
|
|
1c2ec1558e | ||
|
|
3b08bc3262 | ||
|
|
016c7bda4a |
11
.github/workflows/build-and-upload.yml
vendored
11
.github/workflows/build-and-upload.yml
vendored
@@ -53,7 +53,7 @@ on:
|
|||||||
# least-privilege (e.g. dev CI uses read-only; releases grant write).
|
# least-privilege (e.g. dev CI uses read-only; releases grant write).
|
||||||
|
|
||||||
env:
|
env:
|
||||||
NODE_VERSION: 20
|
NODE_VERSION: 22
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-macos:
|
build-macos:
|
||||||
@@ -372,7 +372,7 @@ jobs:
|
|||||||
if [ "$attempt" -gt 1 ]; then
|
if [ "$attempt" -gt 1 ]; then
|
||||||
echo "Retrying Tauri CLI install (attempt $attempt)..."
|
echo "Retrying Tauri CLI install (attempt $attempt)..."
|
||||||
fi
|
fi
|
||||||
npm install @tauri-apps/cli@2.9.4 @tauri-apps/cli-darwin-x64@2.9.4 --no-save --no-audit --no-fund --workspaces=false
|
npm install @tauri-apps/cli@2.10.1 @tauri-apps/cli-darwin-x64@2.10.1 --no-save --no-audit --no-fund --workspaces=false
|
||||||
node -e "require('@tauri-apps/cli'); console.log('Tauri CLI loaded')" && exit 0
|
node -e "require('@tauri-apps/cli'); console.log('Tauri CLI loaded')" && exit 0
|
||||||
done
|
done
|
||||||
echo "Tauri CLI failed to load after retries" >&2
|
echo "Tauri CLI failed to load after retries" >&2
|
||||||
@@ -456,7 +456,7 @@ jobs:
|
|||||||
if [ "$attempt" -gt 1 ]; then
|
if [ "$attempt" -gt 1 ]; then
|
||||||
echo "Retrying Tauri CLI install (attempt $attempt)..."
|
echo "Retrying Tauri CLI install (attempt $attempt)..."
|
||||||
fi
|
fi
|
||||||
npm install @tauri-apps/cli@2.9.4 @tauri-apps/cli-darwin-arm64@2.9.4 --no-save --no-audit --no-fund --workspaces=false
|
npm install @tauri-apps/cli@2.10.1 @tauri-apps/cli-darwin-arm64@2.10.1 --no-save --no-audit --no-fund --workspaces=false
|
||||||
node -e "require('@tauri-apps/cli'); console.log('Tauri CLI loaded')" && exit 0
|
node -e "require('@tauri-apps/cli'); console.log('Tauri CLI loaded')" && exit 0
|
||||||
done
|
done
|
||||||
echo "Tauri CLI failed to load after retries" >&2
|
echo "Tauri CLI failed to load after retries" >&2
|
||||||
@@ -542,7 +542,7 @@ jobs:
|
|||||||
if [ "$attempt" -gt 1 ]; then
|
if [ "$attempt" -gt 1 ]; then
|
||||||
echo "Retrying Tauri CLI install (attempt $attempt)..."
|
echo "Retrying Tauri CLI install (attempt $attempt)..."
|
||||||
fi
|
fi
|
||||||
npm install @tauri-apps/cli@2.9.4 @tauri-apps/cli-win32-x64-msvc@2.9.4 --no-save --no-audit --no-fund --workspaces=false
|
npm install @tauri-apps/cli@2.10.1 @tauri-apps/cli-win32-x64-msvc@2.10.1 --no-save --no-audit --no-fund --workspaces=false
|
||||||
node -e "require('@tauri-apps/cli'); console.log('Tauri CLI loaded')" && exit 0
|
node -e "require('@tauri-apps/cli'); console.log('Tauri CLI loaded')" && exit 0
|
||||||
done
|
done
|
||||||
echo "Tauri CLI failed to load after retries" >&2
|
echo "Tauri CLI failed to load after retries" >&2
|
||||||
@@ -614,6 +614,7 @@ jobs:
|
|||||||
sudo apt-get install -y \
|
sudo apt-get install -y \
|
||||||
build-essential \
|
build-essential \
|
||||||
pkg-config \
|
pkg-config \
|
||||||
|
xdg-utils \
|
||||||
libgtk-3-dev \
|
libgtk-3-dev \
|
||||||
libglib2.0-dev \
|
libglib2.0-dev \
|
||||||
libwebkit2gtk-4.1-dev \
|
libwebkit2gtk-4.1-dev \
|
||||||
@@ -642,6 +643,7 @@ jobs:
|
|||||||
if [ "$attempt" -gt 1 ]; then
|
if [ "$attempt" -gt 1 ]; then
|
||||||
echo "Retrying Tauri CLI install (attempt $attempt)..."
|
echo "Retrying Tauri CLI install (attempt $attempt)..."
|
||||||
fi
|
fi
|
||||||
|
# Tauri CLI 2.10.1 regresses Linux AppImage bundling in CI; keep Linux on the last known-good CLI.
|
||||||
npm install @tauri-apps/cli@2.9.4 @tauri-apps/cli-linux-x64-gnu@2.9.4 --no-save --no-audit --no-fund --workspaces=false
|
npm install @tauri-apps/cli@2.9.4 @tauri-apps/cli-linux-x64-gnu@2.9.4 --no-save --no-audit --no-fund --workspaces=false
|
||||||
node -e "require('@tauri-apps/cli'); console.log('Tauri CLI loaded')" && exit 0
|
node -e "require('@tauri-apps/cli'); console.log('Tauri CLI loaded')" && exit 0
|
||||||
done
|
done
|
||||||
@@ -741,6 +743,7 @@ jobs:
|
|||||||
sudo apt-get install -y \
|
sudo apt-get install -y \
|
||||||
build-essential \
|
build-essential \
|
||||||
pkg-config \
|
pkg-config \
|
||||||
|
xdg-utils \
|
||||||
gcc-aarch64-linux-gnu \
|
gcc-aarch64-linux-gnu \
|
||||||
g++-aarch64-linux-gnu \
|
g++-aarch64-linux-gnu \
|
||||||
libgtk-3-dev:arm64 \
|
libgtk-3-dev:arm64 \
|
||||||
|
|||||||
14
.github/workflows/manual-npm-publish.yml
vendored
14
.github/workflows/manual-npm-publish.yml
vendored
@@ -46,7 +46,8 @@ jobs:
|
|||||||
publish:
|
publish:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
NODE_VERSION: 20
|
NODE_VERSION: 22
|
||||||
|
PUBLISH_NPM_VERSION: 11.5.1
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -59,8 +60,15 @@ jobs:
|
|||||||
node-version: ${{ env.NODE_VERSION }}
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
registry-url: https://registry.npmjs.org
|
registry-url: https://registry.npmjs.org
|
||||||
|
|
||||||
- name: Ensure npm >=11.5.1
|
- name: Prepare pinned npm CLI
|
||||||
run: npm install -g npm@latest
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
tool_dir="$RUNNER_TEMP/publish-npm"
|
||||||
|
mkdir -p "$tool_dir"
|
||||||
|
npm install --prefix "$tool_dir" "npm@${PUBLISH_NPM_VERSION}" --no-audit --no-fund
|
||||||
|
echo "$tool_dir/node_modules/npm/bin" >> "$GITHUB_PATH"
|
||||||
|
"$tool_dir/node_modules/npm/bin/npm-cli.js" --version
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci --workspaces
|
run: npm ci --workspaces
|
||||||
|
|||||||
2
.github/workflows/release-ui.yml
vendored
2
.github/workflows/release-ui.yml
vendored
@@ -14,7 +14,7 @@ permissions:
|
|||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
env:
|
env:
|
||||||
NODE_VERSION: 20
|
NODE_VERSION: 22
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release-ui:
|
release-ui:
|
||||||
|
|||||||
2
.github/workflows/reusable-release.yml
vendored
2
.github/workflows/reusable-release.yml
vendored
@@ -39,7 +39,7 @@ permissions:
|
|||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
env:
|
env:
|
||||||
NODE_VERSION: 20
|
NODE_VERSION: 22
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
prepare-release:
|
prepare-release:
|
||||||
|
|||||||
1216
package-lock.json
generated
1216
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -118,6 +118,8 @@ function loadLoadingScreen(window: BrowserWindow) {
|
|||||||
loader.catch((error) => {
|
loader.catch((error) => {
|
||||||
console.error("[cli] failed to load loading screen:", error)
|
console.error("[cli] failed to load loading screen:", error)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
return loader
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAllowedRendererOrigins(window?: BrowserWindow | null): string[] {
|
function getAllowedRendererOrigins(window?: BrowserWindow | null): string[] {
|
||||||
@@ -291,7 +293,7 @@ function createWindow() {
|
|||||||
showingLoadingScreen = true
|
showingLoadingScreen = true
|
||||||
currentCliUrl = null
|
currentCliUrl = null
|
||||||
clearWindowAllowedOrigin(window)
|
clearWindowAllowedOrigin(window)
|
||||||
loadLoadingScreen(window)
|
const loadingReady = loadLoadingScreen(window)
|
||||||
|
|
||||||
if (process.env.NODE_ENV === "development") {
|
if (process.env.NODE_ENV === "development") {
|
||||||
window.webContents.openDevTools({ mode: "detach" })
|
window.webContents.openDevTools({ mode: "detach" })
|
||||||
@@ -310,11 +312,7 @@ function createWindow() {
|
|||||||
showingLoadingScreen = false
|
showingLoadingScreen = false
|
||||||
})
|
})
|
||||||
|
|
||||||
if (pendingCliUrl) {
|
return loadingReady
|
||||||
const url = pendingCliUrl
|
|
||||||
pendingCliUrl = null
|
|
||||||
startCliPreload(url)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function showLoadingScreen(force = false) {
|
function showLoadingScreen(force = false) {
|
||||||
@@ -620,7 +618,8 @@ app.whenReady().then(() => {
|
|||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
|
|
||||||
startCli()
|
const loadingReady = createWindow()
|
||||||
|
;(mainWindow as BrowserWindow & { __codenomadOpenRemoteWindow?: typeof openRemoteWindow }).__codenomadOpenRemoteWindow = openRemoteWindow
|
||||||
|
|
||||||
if (isMac) {
|
if (isMac) {
|
||||||
session.defaultSession.setSpellCheckerEnabled(false)
|
session.defaultSession.setSpellCheckerEnabled(false)
|
||||||
@@ -637,8 +636,11 @@ app.whenReady().then(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
createWindow()
|
void loadingReady.finally(() => {
|
||||||
;(mainWindow as BrowserWindow & { __codenomadOpenRemoteWindow?: typeof openRemoteWindow }).__codenomadOpenRemoteWindow = openRemoteWindow
|
setTimeout(() => {
|
||||||
|
void startCli()
|
||||||
|
}, 0)
|
||||||
|
})
|
||||||
|
|
||||||
app.on("certificate-error", (event, _webContents, url, error, _certificate, callback) => {
|
app.on("certificate-error", (event, _webContents, url, error, _certificate, callback) => {
|
||||||
if (isInsecureOriginAllowed(url)) {
|
if (isInsecureOriginAllowed(url)) {
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ interface StartOptions {
|
|||||||
|
|
||||||
interface CliEntryResolution {
|
interface CliEntryResolution {
|
||||||
entry: string
|
entry: string
|
||||||
runner: "node" | "tsx"
|
runner: "node" | "tsx" | "standalone"
|
||||||
runnerPath?: string
|
runnerPath?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,15 +148,15 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
const listeningMode = this.resolveListeningMode()
|
const listeningMode = this.resolveListeningMode()
|
||||||
const host = resolveHostForMode(listeningMode)
|
const host = resolveHostForMode(listeningMode)
|
||||||
const args = this.buildCliArgs(options, host)
|
const args = this.buildCliArgs(options, host)
|
||||||
|
const cliEntry = this.resolveCliEntry(options)
|
||||||
|
|
||||||
let child: ManagedChild
|
let child: ManagedChild
|
||||||
|
|
||||||
if (this.shouldUsePackagedShellSupervisor(options)) {
|
if (this.shouldUsePackagedShellSupervisor(options, cliEntry)) {
|
||||||
const runtimePath = this.resolveShellNodeCommand()
|
|
||||||
const entryPath = this.resolveBundledProdEntry()
|
|
||||||
const supervisorPath = this.resolveCliSupervisorPath()
|
const supervisorPath = this.resolveCliSupervisorPath()
|
||||||
const shellEnv = supportsUserShell() ? getUserShellEnv() : { ...process.env }
|
const shellEnv = supportsUserShell() ? getUserShellEnv() : { ...process.env }
|
||||||
const shellCommand = buildUserShellCommand(`exec ${this.buildExecutableCommand(runtimePath, [entryPath, ...args])}`)
|
const shellTarget = cliEntry.runner === "standalone" ? this.buildExecutableCommand(cliEntry.entry, args) : this.buildCommand(cliEntry, args)
|
||||||
|
const shellCommand = buildUserShellCommand(`exec ${shellTarget}`)
|
||||||
const supervisorPayload = JSON.stringify({
|
const supervisorPayload = JSON.stringify({
|
||||||
command: shellCommand.command,
|
command: shellCommand.command,
|
||||||
args: shellCommand.args,
|
args: shellCommand.args,
|
||||||
@@ -164,28 +164,33 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
})
|
})
|
||||||
|
|
||||||
console.info(
|
console.info(
|
||||||
`[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) via utility supervisor using node at ${runtimePath} (host=${host})`,
|
`[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) via utility supervisor using ${cliEntry.runner} at ${cliEntry.entry} (host=${host})`,
|
||||||
)
|
)
|
||||||
console.info(`[cli] utility supervisor: ${supervisorPath}`)
|
console.info(`[cli] utility supervisor: ${supervisorPath}`)
|
||||||
console.info(`[cli] shell command: ${shellCommand.command} ${shellCommand.args.join(" ")}`)
|
console.info(`[cli] shell command: ${shellCommand.command} ${shellCommand.args.join(" ")}`)
|
||||||
|
|
||||||
child = utilityProcess.fork(supervisorPath, [supervisorPayload], {
|
child = utilityProcess.fork(supervisorPath, [supervisorPayload], {
|
||||||
env: shellEnv,
|
env: cliEntry.runner === "standalone" ? shellEnv : { ...shellEnv, ELECTRON_RUN_AS_NODE: "1" },
|
||||||
stdio: "pipe",
|
stdio: "pipe",
|
||||||
serviceName: "CodeNomad CLI Supervisor",
|
serviceName: "CodeNomad CLI Supervisor",
|
||||||
})
|
})
|
||||||
this.childLaunchMode = "utility"
|
this.childLaunchMode = "utility"
|
||||||
} else {
|
} else {
|
||||||
const cliEntry = this.resolveCliEntry(options)
|
|
||||||
console.info(
|
console.info(
|
||||||
`[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) using ${cliEntry.runner} at ${cliEntry.entry} (host=${host})`,
|
`[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) using ${cliEntry.runner} at ${cliEntry.entry} (host=${host})`,
|
||||||
)
|
)
|
||||||
|
|
||||||
const env = supportsUserShell() ? getUserShellEnv() : { ...process.env }
|
const env = supportsUserShell() ? getUserShellEnv() : { ...process.env }
|
||||||
env.ELECTRON_RUN_AS_NODE = "1"
|
if (cliEntry.runner !== "standalone") {
|
||||||
|
env.ELECTRON_RUN_AS_NODE = "1"
|
||||||
|
}
|
||||||
|
|
||||||
const spawnDetails = supportsUserShell()
|
const spawnDetails = supportsUserShell()
|
||||||
? buildUserShellCommand(`ELECTRON_RUN_AS_NODE=1 exec ${this.buildCommand(cliEntry, args)}`)
|
? buildUserShellCommand(
|
||||||
|
`${cliEntry.runner === "standalone" ? "" : "ELECTRON_RUN_AS_NODE=1 "}exec ${
|
||||||
|
cliEntry.runner === "standalone" ? this.buildExecutableCommand(cliEntry.entry, args) : this.buildCommand(cliEntry, args)
|
||||||
|
}`,
|
||||||
|
)
|
||||||
: this.buildDirectSpawn(cliEntry, args)
|
: this.buildDirectSpawn(cliEntry, args)
|
||||||
|
|
||||||
const detached = process.platform !== "win32"
|
const detached = process.platform !== "win32"
|
||||||
@@ -563,6 +568,10 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private buildCommand(cliEntry: CliEntryResolution, args: string[]): string {
|
private buildCommand(cliEntry: CliEntryResolution, args: string[]): string {
|
||||||
|
if (cliEntry.runner === "standalone") {
|
||||||
|
return this.buildExecutableCommand(cliEntry.entry, args)
|
||||||
|
}
|
||||||
|
|
||||||
const parts = [JSON.stringify(process.execPath)]
|
const parts = [JSON.stringify(process.execPath)]
|
||||||
if (cliEntry.runner === "tsx" && cliEntry.runnerPath) {
|
if (cliEntry.runner === "tsx" && cliEntry.runnerPath) {
|
||||||
parts.push(JSON.stringify(cliEntry.runnerPath))
|
parts.push(JSON.stringify(cliEntry.runnerPath))
|
||||||
@@ -577,6 +586,10 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private buildDirectSpawn(cliEntry: CliEntryResolution, args: string[]) {
|
private buildDirectSpawn(cliEntry: CliEntryResolution, args: string[]) {
|
||||||
|
if (cliEntry.runner === "standalone") {
|
||||||
|
return { command: cliEntry.entry, args }
|
||||||
|
}
|
||||||
|
|
||||||
if (cliEntry.runner === "tsx") {
|
if (cliEntry.runner === "tsx") {
|
||||||
return { command: process.execPath, args: [cliEntry.runnerPath!, cliEntry.entry, ...args] }
|
return { command: process.execPath, args: [cliEntry.runnerPath!, cliEntry.entry, ...args] }
|
||||||
}
|
}
|
||||||
@@ -593,9 +606,8 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
const devEntry = this.resolveDevEntry()
|
const devEntry = this.resolveDevEntry()
|
||||||
return { entry: devEntry, runner: "tsx", runnerPath: tsxPath }
|
return { entry: devEntry, runner: "tsx", runnerPath: tsxPath }
|
||||||
}
|
}
|
||||||
|
|
||||||
const distEntry = this.resolveProdEntry()
|
return { entry: this.resolveStandaloneProdEntry(), runner: "standalone" }
|
||||||
return { entry: distEntry, runner: "node" }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveTsx(): string | null {
|
private resolveTsx(): string | null {
|
||||||
@@ -635,20 +647,25 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
return entry
|
return entry
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveProdEntry(): string {
|
private resolveStandaloneProdEntry(): string {
|
||||||
try {
|
const executableName = process.platform === "win32" ? "codenomad-server.exe" : "codenomad-server"
|
||||||
const entry = nodeRequire.resolve("@neuralnomads/codenomad/dist/bin.js")
|
const candidates = [
|
||||||
if (existsSync(entry)) {
|
path.join(process.resourcesPath, "server", "dist", executableName),
|
||||||
return entry
|
path.join(mainDirname, "../resources/server/dist", executableName),
|
||||||
|
path.resolve(process.cwd(), "..", "server", "dist", executableName),
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (existsSync(candidate)) {
|
||||||
|
return candidate
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
// fall through to error below
|
|
||||||
}
|
}
|
||||||
throw new Error("Unable to locate CodeNomad CLI build (dist/bin.js). Run npm run build --workspace @neuralnomads/codenomad.")
|
|
||||||
|
throw new Error(`Unable to locate standalone CodeNomad server executable (${executableName}). Run npm run build:standalone --workspace @neuralnomads/codenomad.`)
|
||||||
}
|
}
|
||||||
|
|
||||||
private shouldUsePackagedShellSupervisor(options: StartOptions): boolean {
|
private shouldUsePackagedShellSupervisor(options: StartOptions, cliEntry: CliEntryResolution): boolean {
|
||||||
return !options.dev && app.isPackaged && process.platform === "darwin"
|
return !options.dev && app.isPackaged && process.platform === "darwin" && cliEntry.runner !== "standalone"
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveCliSupervisorPath(): string {
|
private resolveCliSupervisorPath(): string {
|
||||||
@@ -666,26 +683,6 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
throw new Error("Unable to locate CodeNomad CLI supervisor script.")
|
throw new Error("Unable to locate CodeNomad CLI supervisor script.")
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveShellNodeCommand(): string {
|
|
||||||
const configured = process.env.NODE_BINARY?.trim()
|
|
||||||
return configured && configured.length > 0 ? configured : "node"
|
|
||||||
}
|
|
||||||
|
|
||||||
private resolveBundledProdEntry(): string {
|
|
||||||
const candidates = [
|
|
||||||
path.join(process.resourcesPath, "server", "dist", "bin.js"),
|
|
||||||
path.join(mainDirname, "../resources/server/dist/bin.js"),
|
|
||||||
]
|
|
||||||
|
|
||||||
for (const candidate of candidates) {
|
|
||||||
if (existsSync(candidate)) {
|
|
||||||
return candidate
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error("Unable to locate bundled CodeNomad CLI build in app resources.")
|
|
||||||
}
|
|
||||||
|
|
||||||
private describeUtilityProcessError(error: unknown): string {
|
private describeUtilityProcessError(error: unknown): string {
|
||||||
if (error instanceof Error && error.message) {
|
if (error instanceof Error && error.message) {
|
||||||
return error.message
|
return error.message
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
|
||||||
import { spawn } from "child_process"
|
import { spawn } from "child_process"
|
||||||
import { existsSync } from "fs"
|
import { existsSync, readFileSync } from "fs"
|
||||||
import path, { join } from "path"
|
import path, { join } from "path"
|
||||||
import { fileURLToPath } from "url"
|
import { fileURLToPath } from "url"
|
||||||
|
|
||||||
@@ -14,6 +14,46 @@ const npxCmd = process.platform === "win32" ? "npx.cmd" : "npx"
|
|||||||
const nodeModulesPath = join(appDir, "node_modules")
|
const nodeModulesPath = join(appDir, "node_modules")
|
||||||
const workspaceNodeModulesPath = join(workspaceRoot, "node_modules")
|
const workspaceNodeModulesPath = join(workspaceRoot, "node_modules")
|
||||||
|
|
||||||
|
function getPlatformEsbuildPackage() {
|
||||||
|
const platformKey = `${process.platform}-${process.arch}`
|
||||||
|
const platformPackages = {
|
||||||
|
"linux-x64": "@esbuild/linux-x64",
|
||||||
|
"linux-arm64": "@esbuild/linux-arm64",
|
||||||
|
"darwin-arm64": "@esbuild/darwin-arm64",
|
||||||
|
"darwin-x64": "@esbuild/darwin-x64",
|
||||||
|
"win32-arm64": "@esbuild/win32-arm64",
|
||||||
|
"win32-x64": "@esbuild/win32-x64",
|
||||||
|
}
|
||||||
|
|
||||||
|
return platformPackages[platformKey] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureEsbuildPlatformBinary() {
|
||||||
|
const pkgName = getPlatformEsbuildPackage()
|
||||||
|
if (!pkgName) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const platformPackagePath = join(workspaceNodeModulesPath, ...pkgName.split("/"))
|
||||||
|
if (existsSync(platformPackagePath)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let esbuildVersion = ""
|
||||||
|
try {
|
||||||
|
esbuildVersion = JSON.parse(readFileSync(join(workspaceNodeModulesPath, "esbuild", "package.json"), "utf-8")).version ?? ""
|
||||||
|
} catch {
|
||||||
|
// leave version empty; fallback install will use latest compatible
|
||||||
|
}
|
||||||
|
|
||||||
|
const packageSpec = esbuildVersion ? `${pkgName}@${esbuildVersion}` : pkgName
|
||||||
|
console.log("📦 Step 0/3: Restoring esbuild platform binary...\n")
|
||||||
|
await run(npmCmd, ["install", packageSpec, "--no-save", "--ignore-scripts", "--fund=false", "--audit=false"], {
|
||||||
|
cwd: workspaceRoot,
|
||||||
|
env: { NODE_PATH: workspaceNodeModulesPath },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const platforms = {
|
const platforms = {
|
||||||
mac: {
|
mac: {
|
||||||
args: ["--mac", "--x64", "--arm64"],
|
args: ["--mac", "--x64", "--arm64"],
|
||||||
@@ -105,6 +145,8 @@ async function build(platform) {
|
|||||||
console.log(`\n🔨 Building for: ${config.description}\n`)
|
console.log(`\n🔨 Building for: ${config.description}\n`)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
await ensureEsbuildPlatformBinary()
|
||||||
|
|
||||||
console.log("📦 Step 1/3: Building CLI dependency...\n")
|
console.log("📦 Step 1/3: Building CLI dependency...\n")
|
||||||
await run(npmCmd, ["run", "build", "--workspace", "@neuralnomads/codenomad"], {
|
await run(npmCmd, ["run", "build", "--workspace", "@neuralnomads/codenomad"], {
|
||||||
cwd: workspaceRoot,
|
cwd: workspaceRoot,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const npmNodeExecPath = process.env.npm_node_execpath
|
|||||||
|
|
||||||
const serverSources = ["dist", "public", "node_modules", "package.json"]
|
const serverSources = ["dist", "public", "node_modules", "package.json"]
|
||||||
const serverDepsMarker = join(serverRoot, "node_modules", "fastify", "package.json")
|
const serverDepsMarker = join(serverRoot, "node_modules", "fastify", "package.json")
|
||||||
|
const standaloneMarker = join(serverRoot, "dist", process.platform === "win32" ? "codenomad-server.exe" : "codenomad-server")
|
||||||
|
|
||||||
function log(message) {
|
function log(message) {
|
||||||
console.log(`[prepare-resources] ${message}`)
|
console.log(`[prepare-resources] ${message}`)
|
||||||
@@ -29,6 +30,34 @@ function ensureServerBuild() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ensureStandaloneServerBuild() {
|
||||||
|
log("building standalone server executable")
|
||||||
|
const result = spawnSync(
|
||||||
|
"npm",
|
||||||
|
["run", "build:standalone", "--workspace", "@neuralnomads/codenomad"],
|
||||||
|
{
|
||||||
|
cwd: workspaceRoot,
|
||||||
|
stdio: "inherit",
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
PATH: `${join(workspaceRoot, "node_modules", ".bin")}${path.delimiter}${process.env.PATH ?? ""}`,
|
||||||
|
},
|
||||||
|
shell: process.platform === "win32",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if (result.status !== 0) {
|
||||||
|
if (result.error) {
|
||||||
|
throw result.error
|
||||||
|
}
|
||||||
|
throw new Error(`standalone server build exited with code ${result.status ?? 1}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(standaloneMarker)) {
|
||||||
|
throw new Error(`Standalone server executable missing after build: ${standaloneMarker}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function ensureServerDependencies() {
|
function ensureServerDependencies() {
|
||||||
if (fs.existsSync(serverDepsMarker)) {
|
if (fs.existsSync(serverDepsMarker)) {
|
||||||
return
|
return
|
||||||
@@ -65,6 +94,51 @@ function ensureServerDependencies() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ensureEsbuildPlatformBinary() {
|
||||||
|
const platformKey = `${process.platform}-${process.arch}`
|
||||||
|
const platformPackages = {
|
||||||
|
"linux-x64": "@esbuild/linux-x64",
|
||||||
|
"linux-arm64": "@esbuild/linux-arm64",
|
||||||
|
"darwin-arm64": "@esbuild/darwin-arm64",
|
||||||
|
"darwin-x64": "@esbuild/darwin-x64",
|
||||||
|
"win32-arm64": "@esbuild/win32-arm64",
|
||||||
|
"win32-x64": "@esbuild/win32-x64",
|
||||||
|
}
|
||||||
|
|
||||||
|
const pkgName = platformPackages[platformKey]
|
||||||
|
if (!pkgName) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const platformPackagePath = join(workspaceRoot, "node_modules", ...pkgName.split("/"))
|
||||||
|
if (fs.existsSync(platformPackagePath)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let esbuildVersion = ""
|
||||||
|
try {
|
||||||
|
esbuildVersion = JSON.parse(fs.readFileSync(join(workspaceRoot, "node_modules", "esbuild", "package.json"), "utf-8")).version ?? ""
|
||||||
|
} catch {
|
||||||
|
// leave version empty; fallback install will use latest compatible
|
||||||
|
}
|
||||||
|
|
||||||
|
const packageSpec = esbuildVersion ? `${pkgName}@${esbuildVersion}` : pkgName
|
||||||
|
log("installing esbuild platform binary (optional dep workaround)")
|
||||||
|
|
||||||
|
const result = spawnSync("npm", ["install", packageSpec, "--no-save", "--ignore-scripts", "--fund=false", "--audit=false"], {
|
||||||
|
cwd: workspaceRoot,
|
||||||
|
stdio: "inherit",
|
||||||
|
shell: process.platform === "win32",
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result.status !== 0) {
|
||||||
|
if (result.error) {
|
||||||
|
throw result.error
|
||||||
|
}
|
||||||
|
throw new Error(`esbuild platform install exited with code ${result.status ?? 1}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function copyServerArtifacts() {
|
function copyServerArtifacts() {
|
||||||
fs.rmSync(serverDest, { recursive: true, force: true })
|
fs.rmSync(serverDest, { recursive: true, force: true })
|
||||||
fs.mkdirSync(serverDest, { recursive: true })
|
fs.mkdirSync(serverDest, { recursive: true })
|
||||||
@@ -121,7 +195,9 @@ function stripNodeModuleBins() {
|
|||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
ensureServerBuild()
|
ensureServerBuild()
|
||||||
|
ensureStandaloneServerBuild()
|
||||||
ensureServerDependencies()
|
ensureServerDependencies()
|
||||||
|
ensureEsbuildPlatformBinary()
|
||||||
copyServerArtifacts()
|
copyServerArtifacts()
|
||||||
stripNodeModuleBins()
|
stripNodeModuleBins()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,6 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/plugin": "1.3.7"
|
"@opencode-ai/plugin": "1.14.19"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "npm run build:ui && npm run prepare-ui && tsc -p tsconfig.json && node ./scripts/copy-auth-pages.mjs && npm run prepare-config",
|
"build": "npm run build:ui && npm run prepare-ui && tsc -p tsconfig.json && node ./scripts/copy-auth-pages.mjs && npm run prepare-config",
|
||||||
|
"build:standalone": "node ./scripts/build-standalone.mjs",
|
||||||
"build:ui": "npm run build --prefix ../ui",
|
"build:ui": "npm run build --prefix ../ui",
|
||||||
"prepare-ui": "node ./scripts/copy-ui-dist.mjs",
|
"prepare-ui": "node ./scripts/copy-ui-dist.mjs",
|
||||||
"prepare-config": "node ./scripts/copy-opencode-config.mjs",
|
"prepare-config": "node ./scripts/copy-opencode-config.mjs",
|
||||||
@@ -25,16 +26,16 @@
|
|||||||
"typecheck": "tsc --noEmit -p tsconfig.json"
|
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^8.5.0",
|
"@fastify/cors": "^11.2.0",
|
||||||
"@fastify/reply-from": "^9.8.0",
|
"@fastify/reply-from": "^12.6.2",
|
||||||
"@fastify/static": "^7.0.4",
|
"@fastify/static": "^9.1.1",
|
||||||
"commander": "^12.1.0",
|
"commander": "^12.1.0",
|
||||||
"fastify": "^4.28.1",
|
"fastify": "^5.8.5",
|
||||||
"fuzzysort": "^2.0.4",
|
"fuzzysort": "^2.0.4",
|
||||||
"node-forge": "^1.3.3",
|
"node-forge": "^1.3.3",
|
||||||
"openai": "^6.27.0",
|
"openai": "^6.27.0",
|
||||||
"pino": "^9.4.0",
|
"pino": "^9.4.0",
|
||||||
"undici": "^6.19.8",
|
"undici": "^8.1.0",
|
||||||
"yaml": "^2.4.2",
|
"yaml": "^2.4.2",
|
||||||
"yauzl": "^2.10.0",
|
"yauzl": "^2.10.0",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
@@ -42,6 +43,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node-forge": "^1.3.14",
|
"@types/node-forge": "^1.3.14",
|
||||||
"@types/yauzl": "^2.10.0",
|
"@types/yauzl": "^2.10.0",
|
||||||
|
"bun": "^1.3.13",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"tsx": "^4.20.6",
|
"tsx": "^4.20.6",
|
||||||
|
|||||||
99
packages/server/scripts/build-standalone.mjs
Normal file
99
packages/server/scripts/build-standalone.mjs
Normal 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)
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
import { spawnSync } from "child_process"
|
import { spawnSync } from "child_process"
|
||||||
import { cpSync, existsSync, mkdirSync, rmSync } from "fs"
|
import { cpSync, existsSync, mkdirSync, readdirSync, rmSync } from "fs"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { fileURLToPath } from "url"
|
import { fileURLToPath } from "url"
|
||||||
|
|
||||||
@@ -14,6 +14,67 @@ const selfLinkDir = path.resolve(nodeModulesDir, "@codenomad", "opencode-config"
|
|||||||
const npmExecPath = process.env.npm_execpath
|
const npmExecPath = process.env.npm_execpath
|
||||||
const npmNodeExecPath = process.env.npm_node_execpath
|
const npmNodeExecPath = process.env.npm_node_execpath
|
||||||
|
|
||||||
|
function stripNodeModuleBins(rootDir) {
|
||||||
|
const root = path.join(rootDir, "node_modules")
|
||||||
|
if (!existsSync(root)) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const stack = [root]
|
||||||
|
let removed = 0
|
||||||
|
|
||||||
|
while (stack.length > 0) {
|
||||||
|
const current = stack.pop()
|
||||||
|
if (!current) break
|
||||||
|
|
||||||
|
let entries
|
||||||
|
try {
|
||||||
|
entries = readdirSync(current, { withFileTypes: true })
|
||||||
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const full = path.join(current, entry.name)
|
||||||
|
if (entry.name === ".bin") {
|
||||||
|
rmSync(full, { recursive: true, force: true })
|
||||||
|
removed += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
stack.push(full)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return removed
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripOptionalNativeAddons(rootDir) {
|
||||||
|
const nodeModulesRoot = path.join(rootDir, "node_modules")
|
||||||
|
if (!existsSync(nodeModulesRoot)) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const removablePaths = [
|
||||||
|
path.join(nodeModulesRoot, "@msgpackr-extract"),
|
||||||
|
path.join(nodeModulesRoot, "msgpackr-extract"),
|
||||||
|
]
|
||||||
|
|
||||||
|
let removed = 0
|
||||||
|
for (const targetPath of removablePaths) {
|
||||||
|
if (!existsSync(targetPath)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
rmSync(targetPath, { recursive: true, force: true })
|
||||||
|
removed += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return removed
|
||||||
|
}
|
||||||
|
|
||||||
if (!existsSync(sourceDir)) {
|
if (!existsSync(sourceDir)) {
|
||||||
console.error(`[copy-opencode-config] Missing source directory at ${sourceDir}`)
|
console.error(`[copy-opencode-config] Missing source directory at ${sourceDir}`)
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
@@ -58,4 +119,14 @@ rmSync(targetDir, { recursive: true, force: true })
|
|||||||
mkdirSync(path.dirname(targetDir), { recursive: true })
|
mkdirSync(path.dirname(targetDir), { recursive: true })
|
||||||
cpSync(sourceDir, targetDir, { recursive: true })
|
cpSync(sourceDir, targetDir, { recursive: true })
|
||||||
|
|
||||||
|
const removedBins = stripNodeModuleBins(targetDir)
|
||||||
|
if (removedBins > 0) {
|
||||||
|
console.log(`[copy-opencode-config] Removed ${removedBins} node_modules/.bin directories`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const removedNativeAddons = stripOptionalNativeAddons(targetDir)
|
||||||
|
if (removedNativeAddons > 0) {
|
||||||
|
console.log(`[copy-opencode-config] Removed ${removedNativeAddons} optional native addon package paths`)
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`[copy-opencode-config] Copied ${sourceDir} -> ${targetDir}`)
|
console.log(`[copy-opencode-config] Copied ${sourceDir} -> ${targetDir}`)
|
||||||
|
|||||||
@@ -29,13 +29,14 @@ import { SideCarManager } from "./sidecars/manager"
|
|||||||
import { ClientConnectionManager } from "./clients/connection-manager"
|
import { ClientConnectionManager } from "./clients/connection-manager"
|
||||||
import { PluginChannelManager } from "./plugins/channel"
|
import { PluginChannelManager } from "./plugins/channel"
|
||||||
import { VoiceModeManager } from "./plugins/voice-mode"
|
import { VoiceModeManager } from "./plugins/voice-mode"
|
||||||
|
import { readServerPackageVersion, resolveServerPublicDir } from "./runtime-paths"
|
||||||
|
|
||||||
const require = createRequire(import.meta.url)
|
const require = createRequire(import.meta.url)
|
||||||
|
|
||||||
const packageJson = require("../package.json") as { version: string }
|
const packageJson = { version: readServerPackageVersion(import.meta.url) }
|
||||||
const __filename = fileURLToPath(import.meta.url)
|
const __filename = fileURLToPath(import.meta.url)
|
||||||
const __dirname = path.dirname(__filename)
|
const __dirname = path.dirname(__filename)
|
||||||
const DEFAULT_UI_STATIC_DIR = path.resolve(__dirname, "../public")
|
const DEFAULT_UI_STATIC_DIR = resolveServerPublicDir(import.meta.url)
|
||||||
|
|
||||||
interface CliOptions {
|
interface CliOptions {
|
||||||
host: string
|
host: string
|
||||||
|
|||||||
@@ -1,22 +1,11 @@
|
|||||||
import { existsSync } from "fs"
|
import { existsSync } from "fs"
|
||||||
import path from "path"
|
|
||||||
import { fileURLToPath } from "url"
|
|
||||||
import { createLogger } from "./logger"
|
import { createLogger } from "./logger"
|
||||||
|
import { resolveOpencodeTemplateDir } from "./runtime-paths"
|
||||||
|
|
||||||
const log = createLogger({ component: "opencode-config" })
|
const log = createLogger({ component: "opencode-config" })
|
||||||
const __filename = fileURLToPath(import.meta.url)
|
const templateDir = resolveOpencodeTemplateDir(import.meta.url)
|
||||||
const __dirname = path.dirname(__filename)
|
|
||||||
const devTemplateDir = path.resolve(__dirname, "../../opencode-config")
|
|
||||||
const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath
|
|
||||||
const prodTemplateDirs = [
|
|
||||||
resourcesPath ? path.resolve(resourcesPath, "opencode-config") : undefined,
|
|
||||||
path.resolve(__dirname, "opencode-config"),
|
|
||||||
].filter((dir): dir is string => Boolean(dir))
|
|
||||||
|
|
||||||
const isDevBuild = Boolean(process.env.CODENOMAD_DEV ?? process.env.CLI_UI_DEV_SERVER) || existsSync(devTemplateDir)
|
const isDevBuild = Boolean(process.env.CODENOMAD_DEV ?? process.env.CLI_UI_DEV_SERVER)
|
||||||
const templateDir = isDevBuild
|
|
||||||
? devTemplateDir
|
|
||||||
: prodTemplateDirs.find((dir) => existsSync(dir)) ?? prodTemplateDirs[0]
|
|
||||||
|
|
||||||
export function getOpencodeConfigDir(): string {
|
export function getOpencodeConfigDir(): string {
|
||||||
if (!existsSync(templateDir)) {
|
if (!existsSync(templateDir)) {
|
||||||
|
|||||||
79
packages/server/src/runtime-paths.ts
Normal file
79
packages/server/src/runtime-paths.ts
Normal 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"
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ import replyFrom from "@fastify/reply-from"
|
|||||||
import fs from "fs"
|
import fs from "fs"
|
||||||
import { connect as connectTcp, type Socket } from "net"
|
import { connect as connectTcp, type Socket } from "net"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
|
import { Readable } from "stream"
|
||||||
|
import { pipeline } from "stream/promises"
|
||||||
import { connect as connectTls, type TLSSocket } from "tls"
|
import { connect as connectTls, type TLSSocket } from "tls"
|
||||||
import { fetch } from "undici"
|
import { fetch } from "undici"
|
||||||
import type { Logger } from "../logger"
|
import type { Logger } from "../logger"
|
||||||
@@ -626,57 +628,57 @@ async function proxyWorkspaceRequest(args: {
|
|||||||
logger.trace({ workspaceId, targetUrl, body: request.body }, "Instance proxy payload")
|
logger.trace({ workspaceId, targetUrl, body: request.body }, "Instance proxy payload")
|
||||||
}
|
}
|
||||||
|
|
||||||
return reply.from(targetUrl, {
|
const headers = buildWorkspaceInstanceProxyHeaders(request.headers, instanceAuthHeader, directory)
|
||||||
rewriteRequestHeaders: (_originalRequest, headers) => {
|
|
||||||
if (instanceAuthHeader) {
|
|
||||||
headers.authorization = instanceAuthHeader
|
|
||||||
}
|
|
||||||
|
|
||||||
// OpenCode expects the *full* path; we send it via header to avoid query tampering.
|
if (logger.isLevelEnabled("trace")) {
|
||||||
const isNonASCII = /[^\x00-\x7F]/.test(directory)
|
logger.trace(
|
||||||
const encodedDirectory = isNonASCII ? encodeURIComponent(directory) : directory
|
{
|
||||||
|
workspaceId,
|
||||||
|
method: request.method,
|
||||||
|
targetUrl,
|
||||||
|
worktreeSlug,
|
||||||
|
directory,
|
||||||
|
contentType: request.headers["content-type"],
|
||||||
|
body: bodyToJson(request.body),
|
||||||
|
headers: redactProxyHeadersForLogs(headers),
|
||||||
|
},
|
||||||
|
"Proxy -> OpenCode request",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Overwrite any client-provided value (case-insensitive headers are normalized by Node).
|
const init: any = {
|
||||||
;(headers as Record<string, unknown>)["x-opencode-directory"] = encodedDirectory
|
method: request.method,
|
||||||
|
headers,
|
||||||
|
redirect: "manual",
|
||||||
|
}
|
||||||
|
|
||||||
if (logger.isLevelEnabled("trace")) {
|
if (request.method !== "GET" && request.method !== "HEAD") {
|
||||||
const outgoing: Record<string, unknown> = {}
|
const body = toProxyRequestBody(request.body)
|
||||||
for (const [key, value] of Object.entries(headers as Record<string, unknown>)) {
|
if (body !== undefined) {
|
||||||
outgoing[key] = value
|
init.body = body
|
||||||
}
|
init.duplex = "half"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Redact sensitive headers.
|
try {
|
||||||
for (const key of Object.keys(outgoing)) {
|
const response = await fetch(targetUrl, init)
|
||||||
const lower = key.toLowerCase()
|
reply.code(response.status)
|
||||||
if (lower === "authorization" || lower === "cookie" || lower === "set-cookie") {
|
applyInstanceProxyResponseHeaders(reply, response)
|
||||||
outgoing[key] = "<redacted>"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.trace(
|
if (!response.body || request.method === "HEAD") {
|
||||||
{
|
reply.send()
|
||||||
workspaceId,
|
return
|
||||||
method: request.method,
|
}
|
||||||
targetUrl,
|
|
||||||
worktreeSlug,
|
|
||||||
directory,
|
|
||||||
contentType: request.headers["content-type"],
|
|
||||||
body: bodyToJson(request.body),
|
|
||||||
headers: outgoing,
|
|
||||||
},
|
|
||||||
"Proxy -> OpenCode request",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return headers
|
reply.hijack()
|
||||||
},
|
reply.raw.writeHead(reply.statusCode, toOutgoingHeaders(reply.getHeaders()))
|
||||||
onError: (proxyReply, { error }) => {
|
await pipeline(Readable.fromWeb(response.body as any), reply.raw)
|
||||||
logger.error({ err: error, workspaceId, targetUrl }, "Failed to proxy workspace request")
|
} catch (error) {
|
||||||
if (!proxyReply.sent) {
|
logger.error({ err: error, workspaceId, targetUrl }, "Failed to proxy workspace request")
|
||||||
proxyReply.code(502).send({ error: "Workspace instance proxy failed" })
|
if (!reply.sent) {
|
||||||
}
|
reply.code(502).send({ error: "Workspace instance proxy failed" })
|
||||||
},
|
}
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractOpencodeDirectoryOverride(pathSuffix: string | undefined): {
|
function extractOpencodeDirectoryOverride(pathSuffix: string | undefined): {
|
||||||
@@ -867,12 +869,90 @@ function isApiRequest(rawUrl: string | null | undefined) {
|
|||||||
function buildProxyHeaders(headers: FastifyRequest["headers"]): Record<string, string> {
|
function buildProxyHeaders(headers: FastifyRequest["headers"]): Record<string, string> {
|
||||||
const result: Record<string, string> = {}
|
const result: Record<string, string> = {}
|
||||||
for (const [key, value] of Object.entries(headers ?? {})) {
|
for (const [key, value] of Object.entries(headers ?? {})) {
|
||||||
if (!value || key.toLowerCase() === "host") continue
|
const lower = key.toLowerCase()
|
||||||
|
if (!value || lower === "host" || isHopByHopHeader(lower)) continue
|
||||||
result[key] = Array.isArray(value) ? value.join(",") : value
|
result[key] = Array.isArray(value) ? value.join(",") : value
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toProxyRequestBody(body: unknown): any {
|
||||||
|
if (body == null) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
if (typeof (body as { pipe?: unknown }).pipe === "function") {
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
if (typeof (body as { [Symbol.asyncIterator]?: unknown })[Symbol.asyncIterator] === "function") {
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
if (Buffer.isBuffer(body) || typeof body === "string" || body instanceof Uint8Array) {
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
return JSON.stringify(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWorkspaceInstanceProxyHeaders(
|
||||||
|
headers: FastifyRequest["headers"],
|
||||||
|
instanceAuthHeader: string | undefined,
|
||||||
|
directory: string,
|
||||||
|
): Record<string, string> {
|
||||||
|
const next = buildProxyHeaders(headers)
|
||||||
|
if (instanceAuthHeader) {
|
||||||
|
next.authorization = instanceAuthHeader
|
||||||
|
}
|
||||||
|
|
||||||
|
const isNonASCII = /[^\x00-\x7F]/.test(directory)
|
||||||
|
next["x-opencode-directory"] = isNonASCII ? encodeURIComponent(directory) : directory
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
function redactProxyHeadersForLogs(headers: Record<string, string>): Record<string, string> {
|
||||||
|
const outgoing = { ...headers }
|
||||||
|
for (const key of Object.keys(outgoing)) {
|
||||||
|
const lower = key.toLowerCase()
|
||||||
|
if (lower === "authorization" || lower === "cookie" || lower === "set-cookie") {
|
||||||
|
outgoing[key] = "<redacted>"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return outgoing
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyInstanceProxyResponseHeaders(reply: FastifyReply, response: any) {
|
||||||
|
response.headers.forEach((value: string, key: string) => {
|
||||||
|
const lower = key.toLowerCase()
|
||||||
|
if (isHopByHopHeader(lower) || lower === "content-length" || lower === "content-encoding") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
reply.header(key, value)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function toOutgoingHeaders(headers: ReturnType<FastifyReply["getHeaders"]>): Record<string, string | string[]> {
|
||||||
|
const next: Record<string, string | string[]> = {}
|
||||||
|
for (const [key, value] of Object.entries(headers)) {
|
||||||
|
if (value === undefined) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
next[key] = Array.isArray(value) ? value.map(String) : String(value)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHopByHopHeader(name: string): boolean {
|
||||||
|
return new Set([
|
||||||
|
"connection",
|
||||||
|
"keep-alive",
|
||||||
|
"proxy-authenticate",
|
||||||
|
"proxy-authorization",
|
||||||
|
"te",
|
||||||
|
"trailer",
|
||||||
|
"transfer-encoding",
|
||||||
|
"upgrade",
|
||||||
|
]).has(name)
|
||||||
|
}
|
||||||
|
|
||||||
async function proxySideCarRequest(args: {
|
async function proxySideCarRequest(args: {
|
||||||
request: FastifyRequest
|
request: FastifyRequest
|
||||||
reply: FastifyReply
|
reply: FastifyReply
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import fs from "fs"
|
|||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import type { AuthManager } from "../../auth/manager"
|
import type { AuthManager } from "../../auth/manager"
|
||||||
import { isLoopbackAddress } from "../../auth/http-auth"
|
import { isLoopbackAddress } from "../../auth/http-auth"
|
||||||
|
import { resolveAuthTemplatePath } from "../../runtime-paths"
|
||||||
|
|
||||||
interface RouteDeps {
|
interface RouteDeps {
|
||||||
authManager: AuthManager
|
authManager: AuthManager
|
||||||
@@ -21,21 +22,21 @@ const PasswordSchema = z.object({
|
|||||||
password: z.string().min(8),
|
password: z.string().min(8),
|
||||||
})
|
})
|
||||||
|
|
||||||
const LOGIN_TEMPLATE_URL = new URL("./auth-pages/login.html", import.meta.url)
|
const LOGIN_TEMPLATE_PATH = resolveAuthTemplatePath(import.meta.url, "login.html")
|
||||||
const TOKEN_TEMPLATE_URL = new URL("./auth-pages/token.html", import.meta.url)
|
const TOKEN_TEMPLATE_PATH = resolveAuthTemplatePath(import.meta.url, "token.html")
|
||||||
|
|
||||||
let cachedLoginTemplate: string | null = null
|
let cachedLoginTemplate: string | null = null
|
||||||
let cachedTokenTemplate: string | null = null
|
let cachedTokenTemplate: string | null = null
|
||||||
|
|
||||||
function readTemplate(url: URL, cache: string | null): string {
|
function readTemplate(filePath: string, cache: string | null): string {
|
||||||
if (cache) return cache
|
if (cache) return cache
|
||||||
const content = fs.readFileSync(url, "utf-8")
|
const content = fs.readFileSync(filePath, "utf-8")
|
||||||
return content
|
return content
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLoginHtml(defaultUsername: string): string {
|
function getLoginHtml(defaultUsername: string): string {
|
||||||
if (!cachedLoginTemplate) {
|
if (!cachedLoginTemplate) {
|
||||||
cachedLoginTemplate = readTemplate(LOGIN_TEMPLATE_URL, null)
|
cachedLoginTemplate = readTemplate(LOGIN_TEMPLATE_PATH, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const escapedUsername = escapeHtml(defaultUsername)
|
const escapedUsername = escapeHtml(defaultUsername)
|
||||||
@@ -44,7 +45,7 @@ function getLoginHtml(defaultUsername: string): string {
|
|||||||
|
|
||||||
function getTokenHtml(): string {
|
function getTokenHtml(): string {
|
||||||
if (!cachedTokenTemplate) {
|
if (!cachedTokenTemplate) {
|
||||||
cachedTokenTemplate = readTemplate(TOKEN_TEMPLATE_URL, null)
|
cachedTokenTemplate = readTemplate(TOKEN_TEMPLATE_PATH, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
return cachedTokenTemplate
|
return cachedTokenTemplate
|
||||||
|
|||||||
@@ -21,6 +21,70 @@ import {
|
|||||||
|
|
||||||
const STARTUP_STABILITY_DELAY_MS = 1500
|
const STARTUP_STABILITY_DELAY_MS = 1500
|
||||||
|
|
||||||
|
function defaultShellPath(): string {
|
||||||
|
const configured = process.env.SHELL?.trim()
|
||||||
|
if (configured) {
|
||||||
|
return configured
|
||||||
|
}
|
||||||
|
|
||||||
|
return process.platform === "darwin" ? "/bin/zsh" : "/bin/bash"
|
||||||
|
}
|
||||||
|
|
||||||
|
function shellEscape(input: string): string {
|
||||||
|
if (!input) return "''"
|
||||||
|
return `'${input.replace(/'/g, `'\\''`)}'`
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrapCommandForShell(command: string, shellPath: string): string {
|
||||||
|
const shellName = path.basename(shellPath).toLowerCase()
|
||||||
|
|
||||||
|
if (shellName.includes("bash")) {
|
||||||
|
return `if [ -f ~/.bashrc ]; then source ~/.bashrc >/dev/null 2>&1; fi; ${command}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shellName.includes("zsh")) {
|
||||||
|
return `if [ -f ~/.zshrc ]; then source ~/.zshrc >/dev/null 2>&1; fi; ${command}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return command
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildShellArgs(shellPath: string, command: string): string[] {
|
||||||
|
const shellName = path.basename(shellPath).toLowerCase()
|
||||||
|
if (shellName.includes("zsh")) {
|
||||||
|
return ["-l", "-i", "-c", command]
|
||||||
|
}
|
||||||
|
return ["-l", "-c", command]
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveBinaryPathFromUserShell(identifier: string): string | null {
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const shellPath = defaultShellPath()
|
||||||
|
const lookupCommand = wrapCommandForShell(`command -v ${shellEscape(identifier)}`, shellPath)
|
||||||
|
const result = spawnSync(shellPath, buildShellArgs(shellPath, lookupCommand), {
|
||||||
|
encoding: "utf8",
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
npm_config_prefix: undefined,
|
||||||
|
NPM_CONFIG_PREFIX: undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result.status !== 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolved = String(result.stdout ?? "")
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.find((line) => line.length > 0)
|
||||||
|
|
||||||
|
return resolved ?? null
|
||||||
|
}
|
||||||
|
|
||||||
interface WorkspaceManagerOptions {
|
interface WorkspaceManagerOptions {
|
||||||
rootDir: string
|
rootDir: string
|
||||||
settings: SettingsService
|
settings: SettingsService
|
||||||
@@ -266,6 +330,12 @@ export class WorkspaceManager {
|
|||||||
this.options.logger.warn({ identifier, err: error }, "Failed to resolve binary path from system PATH")
|
this.options.logger.warn({ identifier, err: error }, "Failed to resolve binary path from system PATH")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const shellResolved = resolveBinaryPathFromUserShell(identifier)
|
||||||
|
if (shellResolved) {
|
||||||
|
this.options.logger.debug({ identifier, resolved: shellResolved }, "Resolved binary path from user shell")
|
||||||
|
return shellResolved
|
||||||
|
}
|
||||||
|
|
||||||
return identifier
|
return identifier
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,6 @@
|
|||||||
"build": "tauri build"
|
"build": "tauri build"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2.9.4"
|
"@tauri-apps/cli": "^2.10.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const serverDevInstallCommand =
|
|||||||
const uiDevInstallCommand =
|
const uiDevInstallCommand =
|
||||||
"npm install --workspace @codenomad/ui --include-workspace-root=false --install-strategy=nested --fund=false --audit=false"
|
"npm install --workspace @codenomad/ui --include-workspace-root=false --install-strategy=nested --fund=false --audit=false"
|
||||||
const serverPrepareUiCommand = "npm run prepare-ui --workspace @neuralnomads/codenomad"
|
const serverPrepareUiCommand = "npm run prepare-ui --workspace @neuralnomads/codenomad"
|
||||||
|
const serverStandaloneBuildCommand = "npm run build:standalone --workspace @neuralnomads/codenomad"
|
||||||
|
|
||||||
const envWithRootBin = {
|
const envWithRootBin = {
|
||||||
...process.env,
|
...process.env,
|
||||||
@@ -77,6 +78,15 @@ function ensureServerBuild() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ensureStandaloneServerBuild() {
|
||||||
|
console.log("[prebuild] building standalone server executable...")
|
||||||
|
execSync(serverStandaloneBuildCommand, {
|
||||||
|
cwd: workspaceRoot,
|
||||||
|
stdio: "inherit",
|
||||||
|
env: envWithRootBin,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function ensureUiBuild() {
|
function ensureUiBuild() {
|
||||||
const loadingHtml = path.join(uiDist, "loading.html")
|
const loadingHtml = path.join(uiDist, "loading.html")
|
||||||
if (fs.existsSync(loadingHtml)) {
|
if (fs.existsSync(loadingHtml)) {
|
||||||
@@ -117,15 +127,19 @@ function ensureServerDevDependencies() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ensureServerDependencies() {
|
function ensureServerDependencies() {
|
||||||
if (fs.existsSync(braceExpansionPath)) {
|
console.log("[prebuild] pruning server to production dependencies...")
|
||||||
return
|
execSync("npm prune --omit=dev --ignore-scripts --workspaces=false --fund=false --audit=false", {
|
||||||
}
|
|
||||||
|
|
||||||
console.log("[prebuild] ensuring server production dependencies...")
|
|
||||||
execSync(serverInstallCommand, {
|
|
||||||
cwd: serverRoot,
|
cwd: serverRoot,
|
||||||
stdio: "inherit",
|
stdio: "inherit",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (!fs.existsSync(braceExpansionPath)) {
|
||||||
|
console.log("[prebuild] restoring missing server production dependencies...")
|
||||||
|
execSync(serverInstallCommand, {
|
||||||
|
cwd: serverRoot,
|
||||||
|
stdio: "inherit",
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureUiDevDependencies() {
|
function ensureUiDevDependencies() {
|
||||||
@@ -178,6 +192,47 @@ function ensureRollupPlatformBinary() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ensureEsbuildPlatformBinary() {
|
||||||
|
const platformKey = `${process.platform}-${process.arch}`
|
||||||
|
const platformPackages = {
|
||||||
|
"linux-x64": "@esbuild/linux-x64",
|
||||||
|
"linux-arm64": "@esbuild/linux-arm64",
|
||||||
|
"darwin-arm64": "@esbuild/darwin-arm64",
|
||||||
|
"darwin-x64": "@esbuild/darwin-x64",
|
||||||
|
"win32-arm64": "@esbuild/win32-arm64",
|
||||||
|
"win32-x64": "@esbuild/win32-x64",
|
||||||
|
}
|
||||||
|
|
||||||
|
const pkgName = platformPackages[platformKey]
|
||||||
|
if (!pkgName) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const platformPackagePath = path.join(workspaceRoot, "node_modules", ...pkgName.split("/"))
|
||||||
|
if (fs.existsSync(platformPackagePath)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let esbuildVersion = ""
|
||||||
|
try {
|
||||||
|
esbuildVersion = require(path.join(workspaceRoot, "node_modules", "esbuild", "package.json")).version
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
esbuildVersion = require(path.join(workspaceRoot, "node_modules", "vite", "node_modules", "esbuild", "package.json")).version
|
||||||
|
} catch {
|
||||||
|
// leave version empty; fallback install will use latest compatible
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const packageSpec = esbuildVersion ? `${pkgName}@${esbuildVersion}` : pkgName
|
||||||
|
|
||||||
|
console.log("[prebuild] installing esbuild platform binary (optional dep workaround)...")
|
||||||
|
execSync(`npm install ${packageSpec} --no-save --ignore-scripts --fund=false --audit=false`, {
|
||||||
|
cwd: workspaceRoot,
|
||||||
|
stdio: "inherit",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function copyServerArtifacts() {
|
function copyServerArtifacts() {
|
||||||
fs.rmSync(serverDest, { recursive: true, force: true })
|
fs.rmSync(serverDest, { recursive: true, force: true })
|
||||||
fs.mkdirSync(serverDest, { recursive: true })
|
fs.mkdirSync(serverDest, { recursive: true })
|
||||||
@@ -256,8 +311,10 @@ function copyUiLoadingAssets() {
|
|||||||
ensureUiDevDependencies()
|
ensureUiDevDependencies()
|
||||||
await ensureMonacoAssets()
|
await ensureMonacoAssets()
|
||||||
ensureRollupPlatformBinary()
|
ensureRollupPlatformBinary()
|
||||||
ensureServerDependencies()
|
ensureEsbuildPlatformBinary()
|
||||||
ensureServerBuild()
|
ensureServerBuild()
|
||||||
|
ensureStandaloneServerBuild()
|
||||||
|
ensureServerDependencies()
|
||||||
ensureUiBuild()
|
ensureUiBuild()
|
||||||
syncServerUiBundle()
|
syncServerUiBundle()
|
||||||
copyServerArtifacts()
|
copyServerArtifacts()
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ edition = "2021"
|
|||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
tauri-build = { version = "2.5.2", features = [] }
|
tauri-build = { version = "2.5.6", features = [] }
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tauri = { version = "2.5.2", features = [ "devtools"] }
|
tauri = { version = "2.10.1", features = [ "devtools"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
serde_yaml = "0.9"
|
serde_yaml = "0.9"
|
||||||
|
|||||||
@@ -136,6 +136,10 @@ fn workspace_root() -> Option<PathBuf> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn launch_cwd() -> Option<PathBuf> {
|
||||||
|
std::env::current_dir().ok()
|
||||||
|
}
|
||||||
|
|
||||||
const SESSION_COOKIE_NAME_PREFIX: &str = "codenomad_session";
|
const SESSION_COOKIE_NAME_PREFIX: &str = "codenomad_session";
|
||||||
|
|
||||||
const CLI_STOP_GRACE_SECS: u64 = 30;
|
const CLI_STOP_GRACE_SECS: u64 = 30;
|
||||||
@@ -624,16 +628,19 @@ impl CliProcessManager {
|
|||||||
log_line("development mode: will prefer tsx + source if present");
|
log_line("development mode: will prefer tsx + source if present");
|
||||||
}
|
}
|
||||||
|
|
||||||
let cwd = workspace_root();
|
let cwd = launch_cwd();
|
||||||
if let Some(ref c) = cwd {
|
if let Some(ref c) = cwd {
|
||||||
log_line(&format!("using cwd={}", c.display()));
|
log_line(&format!("using cwd={}", c.display()));
|
||||||
}
|
}
|
||||||
|
|
||||||
let use_user_shell = supports_user_shell();
|
let use_user_shell = supports_user_shell();
|
||||||
|
|
||||||
if !use_user_shell && which::which(&resolution.node_binary).is_err() {
|
if resolution.runner == Runner::Tsx
|
||||||
|
&& !use_user_shell
|
||||||
|
&& which::which(&resolution.node_binary).is_err()
|
||||||
|
{
|
||||||
return Err(anyhow::anyhow!(
|
return Err(anyhow::anyhow!(
|
||||||
"Node binary '{}' not found. CodeNomad desktop currently requires Node.js installed on the system, or set NODE_BINARY to a valid runtime path.",
|
"Node binary '{}' not found. CodeNomad development mode requires Node.js installed on the system, or set NODE_BINARY to a valid runtime path.",
|
||||||
resolution.node_binary
|
resolution.node_binary
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -642,9 +649,17 @@ impl CliProcessManager {
|
|||||||
log_line("spawning via user shell");
|
log_line("spawning via user shell");
|
||||||
ShellCommandType::UserShell(build_shell_command_string(&resolution, &args)?)
|
ShellCommandType::UserShell(build_shell_command_string(&resolution, &args)?)
|
||||||
} else {
|
} else {
|
||||||
log_line("spawning directly with node");
|
log_line(if resolution.runner == Runner::Standalone {
|
||||||
|
"spawning directly with standalone executable"
|
||||||
|
} else {
|
||||||
|
"spawning directly with node"
|
||||||
|
});
|
||||||
ShellCommandType::Direct(DirectCommand {
|
ShellCommandType::Direct(DirectCommand {
|
||||||
program: resolution.node_binary.clone(),
|
program: if resolution.runner == Runner::Standalone {
|
||||||
|
resolution.entry.clone()
|
||||||
|
} else {
|
||||||
|
resolution.node_binary.clone()
|
||||||
|
},
|
||||||
args: resolution.runner_args(&args),
|
args: resolution.runner_args(&args),
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
@@ -654,11 +669,13 @@ impl CliProcessManager {
|
|||||||
log_line(&format!("spawn command: {} {:?}", cmd.shell, cmd.args));
|
log_line(&format!("spawn command: {} {:?}", cmd.shell, cmd.args));
|
||||||
let mut c = Command::new(&cmd.shell);
|
let mut c = Command::new(&cmd.shell);
|
||||||
c.args(&cmd.args)
|
c.args(&cmd.args)
|
||||||
.env("ELECTRON_RUN_AS_NODE", "1")
|
|
||||||
.env_remove("npm_config_prefix")
|
.env_remove("npm_config_prefix")
|
||||||
.env_remove("NPM_CONFIG_PREFIX")
|
.env_remove("NPM_CONFIG_PREFIX")
|
||||||
.stdout(Stdio::piped())
|
.stdout(Stdio::piped())
|
||||||
.stderr(Stdio::piped());
|
.stderr(Stdio::piped());
|
||||||
|
if resolution.runner != Runner::Standalone {
|
||||||
|
c.env("ELECTRON_RUN_AS_NODE", "1");
|
||||||
|
}
|
||||||
configure_spawn(&mut c);
|
configure_spawn(&mut c);
|
||||||
if let Some(ref cwd) = cwd {
|
if let Some(ref cwd) = cwd {
|
||||||
c.current_dir(cwd);
|
c.current_dir(cwd);
|
||||||
@@ -671,9 +688,11 @@ impl CliProcessManager {
|
|||||||
log_line(&format!("spawn command: {} {:?}", cmd.program, cmd.args));
|
log_line(&format!("spawn command: {} {:?}", cmd.program, cmd.args));
|
||||||
let mut c = Command::new(&cmd.program);
|
let mut c = Command::new(&cmd.program);
|
||||||
c.args(&cmd.args)
|
c.args(&cmd.args)
|
||||||
.env("ELECTRON_RUN_AS_NODE", "1")
|
|
||||||
.stdout(Stdio::piped())
|
.stdout(Stdio::piped())
|
||||||
.stderr(Stdio::piped());
|
.stderr(Stdio::piped());
|
||||||
|
if resolution.runner != Runner::Standalone {
|
||||||
|
c.env("ELECTRON_RUN_AS_NODE", "1");
|
||||||
|
}
|
||||||
configure_spawn(&mut c);
|
configure_spawn(&mut c);
|
||||||
if let Some(ref cwd) = cwd {
|
if let Some(ref cwd) = cwd {
|
||||||
c.current_dir(cwd);
|
c.current_dir(cwd);
|
||||||
@@ -924,7 +943,7 @@ impl CliProcessManager {
|
|||||||
let mut locked = status.lock();
|
let mut locked = status.lock();
|
||||||
if locked.error.is_none() {
|
if locked.error.is_none() {
|
||||||
locked.error = Some(format!(
|
locked.error = Some(format!(
|
||||||
"Node binary '{}' not found in the desktop shell environment. CodeNomad desktop currently requires Node.js installed on the system, or set NODE_BINARY to a valid runtime path.",
|
"Node binary '{}' not found in the desktop shell environment. CodeNomad development mode requires Node.js installed on the system, or set NODE_BINARY to a valid runtime path.",
|
||||||
node_binary.trim()
|
node_binary.trim()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -1047,7 +1066,7 @@ struct CliEntry {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
enum Runner {
|
enum Runner {
|
||||||
Node,
|
Standalone,
|
||||||
Tsx,
|
Tsx,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1068,17 +1087,17 @@ impl CliEntry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(entry) = resolve_dist_entry(app) {
|
if let Some(entry) = resolve_standalone_entry(app) {
|
||||||
return Ok(Self {
|
return Ok(Self {
|
||||||
entry,
|
entry,
|
||||||
runner: Runner::Node,
|
runner: Runner::Standalone,
|
||||||
runner_path: None,
|
runner_path: None,
|
||||||
node_binary,
|
node_binary: String::new(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Err(anyhow::anyhow!(
|
Err(anyhow::anyhow!(
|
||||||
"Unable to locate CodeNomad CLI build (dist/bin.js). Please build @neuralnomads/codenomad."
|
"Unable to locate the packaged CodeNomad standalone server. Please rebuild the desktop bundle."
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1132,6 +1151,10 @@ impl CliEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn runner_args(&self, cli_args: &[String]) -> Vec<String> {
|
fn runner_args(&self, cli_args: &[String]) -> Vec<String> {
|
||||||
|
if self.runner == Runner::Standalone {
|
||||||
|
return cli_args.to_vec();
|
||||||
|
}
|
||||||
|
|
||||||
let mut args = VecDeque::new();
|
let mut args = VecDeque::new();
|
||||||
if self.runner == Runner::Tsx {
|
if self.runner == Runner::Tsx {
|
||||||
if let Some(path) = &self.runner_path {
|
if let Some(path) = &self.runner_path {
|
||||||
@@ -1204,45 +1227,37 @@ fn resolve_dev_entry(_app: &AppHandle) -> Option<String> {
|
|||||||
first_existing(candidates)
|
first_existing(candidates)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_dist_entry(_app: &AppHandle) -> Option<String> {
|
fn resolve_standalone_entry(_app: &AppHandle) -> Option<String> {
|
||||||
|
let executable_name = if cfg!(windows) {
|
||||||
|
"codenomad-server.exe"
|
||||||
|
} else {
|
||||||
|
"codenomad-server"
|
||||||
|
};
|
||||||
let base = workspace_root();
|
let base = workspace_root();
|
||||||
let mut candidates: Vec<Option<PathBuf>> = vec![
|
let mut candidates = vec![base
|
||||||
base.as_ref().map(|p| p.join("packages/server/dist/bin.js")),
|
.as_ref()
|
||||||
base.as_ref()
|
.map(|p| p.join("packages/server/dist").join(executable_name))];
|
||||||
.map(|p| p.join("packages/server/dist/index.js")),
|
|
||||||
base.as_ref().map(|p| p.join("server/dist/bin.js")),
|
|
||||||
base.as_ref().map(|p| p.join("server/dist/index.js")),
|
|
||||||
];
|
|
||||||
|
|
||||||
if let Ok(exe) = std::env::current_exe() {
|
if let Ok(exe) = std::env::current_exe() {
|
||||||
if let Some(dir) = exe.parent() {
|
if let Some(dir) = exe.parent() {
|
||||||
candidates.push(Some(dir.join("resources/server/dist/bin.js")));
|
candidates.push(Some(
|
||||||
candidates.push(Some(dir.join("resources/server/dist/index.js")));
|
dir.join("resources/server/dist").join(executable_name),
|
||||||
candidates.push(Some(dir.join("resources/server/dist/server/bin.js")));
|
));
|
||||||
candidates.push(Some(dir.join("resources/server/dist/server/index.js")));
|
|
||||||
|
|
||||||
let resources = dir.join("../Resources");
|
let resources = dir.join("../Resources");
|
||||||
candidates.push(Some(resources.join("server/dist/bin.js")));
|
candidates.push(Some(resources.join("server/dist").join(executable_name)));
|
||||||
candidates.push(Some(resources.join("server/dist/index.js")));
|
|
||||||
candidates.push(Some(resources.join("server/dist/server/bin.js")));
|
|
||||||
candidates.push(Some(resources.join("server/dist/server/index.js")));
|
|
||||||
candidates.push(Some(resources.join("resources/server/dist/bin.js")));
|
|
||||||
candidates.push(Some(resources.join("resources/server/dist/index.js")));
|
|
||||||
candidates.push(Some(resources.join("resources/server/dist/server/bin.js")));
|
|
||||||
candidates.push(Some(
|
candidates.push(Some(
|
||||||
resources.join("resources/server/dist/server/index.js"),
|
resources
|
||||||
|
.join("resources/server/dist")
|
||||||
|
.join(executable_name),
|
||||||
));
|
));
|
||||||
|
|
||||||
let linux_resource_roots = [dir.join("../lib/CodeNomad"), dir.join("../lib/codenomad")];
|
let linux_resource_roots = [dir.join("../lib/CodeNomad"), dir.join("../lib/codenomad")];
|
||||||
for root in linux_resource_roots {
|
for root in linux_resource_roots {
|
||||||
candidates.push(Some(root.join("server/dist/bin.js")));
|
candidates.push(Some(root.join("server/dist").join(executable_name)));
|
||||||
candidates.push(Some(root.join("server/dist/index.js")));
|
candidates.push(Some(
|
||||||
candidates.push(Some(root.join("server/dist/server/bin.js")));
|
root.join("resources/server/dist").join(executable_name),
|
||||||
candidates.push(Some(root.join("server/dist/server/index.js")));
|
));
|
||||||
candidates.push(Some(root.join("resources/server/dist/bin.js")));
|
|
||||||
candidates.push(Some(root.join("resources/server/dist/index.js")));
|
|
||||||
candidates.push(Some(root.join("resources/server/dist/server/bin.js")));
|
|
||||||
candidates.push(Some(root.join("resources/server/dist/server/index.js")));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1256,22 +1271,55 @@ fn build_shell_command_string(
|
|||||||
) -> anyhow::Result<ShellCommand> {
|
) -> anyhow::Result<ShellCommand> {
|
||||||
let shell = default_shell();
|
let shell = default_shell();
|
||||||
let mut quoted: Vec<String> = Vec::new();
|
let mut quoted: Vec<String> = Vec::new();
|
||||||
quoted.push(shell_escape(&entry.node_binary));
|
let command = if entry.runner == Runner::Standalone {
|
||||||
for arg in entry.runner_args(cli_args) {
|
quoted.push(shell_escape(&entry.entry));
|
||||||
quoted.push(shell_escape(&arg));
|
for arg in cli_args {
|
||||||
}
|
quoted.push(shell_escape(arg));
|
||||||
let command = format!(
|
}
|
||||||
"if command -v {} >/dev/null 2>&1; then ELECTRON_RUN_AS_NODE=1 exec {}; else printf '%s%s\\n' '{}' {} >&2; exit 127; fi",
|
format!("exec {}", quoted.join(" "))
|
||||||
shell_escape(&entry.node_binary),
|
} else {
|
||||||
quoted.join(" "),
|
quoted.push(shell_escape(&entry.node_binary));
|
||||||
MISSING_NODE_PREFIX,
|
for arg in entry.runner_args(cli_args) {
|
||||||
shell_escape(&entry.node_binary),
|
quoted.push(shell_escape(&arg));
|
||||||
);
|
}
|
||||||
let args = build_shell_args(&shell, &command);
|
format!(
|
||||||
|
"if command -v {} >/dev/null 2>&1; then ELECTRON_RUN_AS_NODE=1 exec {}; else printf '%s%s\\n' '{}' {} >&2; exit 127; fi",
|
||||||
|
shell_escape(&entry.node_binary),
|
||||||
|
quoted.join(" "),
|
||||||
|
MISSING_NODE_PREFIX,
|
||||||
|
shell_escape(&entry.node_binary),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let wrapped_command = wrap_command_for_shell(&command, &shell);
|
||||||
|
let args = build_shell_args(&shell, &wrapped_command);
|
||||||
log_line(&format!("user shell command: {} {:?}", shell, args));
|
log_line(&format!("user shell command: {} {:?}", shell, args));
|
||||||
Ok(ShellCommand { shell, args })
|
Ok(ShellCommand { shell, args })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn wrap_command_for_shell(command: &str, shell: &str) -> String {
|
||||||
|
let shell_name = std::path::Path::new(shell)
|
||||||
|
.file_name()
|
||||||
|
.and_then(OsStr::to_str)
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_lowercase();
|
||||||
|
|
||||||
|
if shell_name.contains("bash") {
|
||||||
|
return format!(
|
||||||
|
"if [ -f ~/.bashrc ]; then source ~/.bashrc >/dev/null 2>&1; fi; {}",
|
||||||
|
command
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if shell_name.contains("zsh") {
|
||||||
|
return format!(
|
||||||
|
"if [ -f ~/.zshrc ]; then source ~/.zshrc >/dev/null 2>&1; fi; {}",
|
||||||
|
command
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
command.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
fn default_shell() -> String {
|
fn default_shell() -> String {
|
||||||
if let Ok(shell) = std::env::var("SHELL") {
|
if let Ok(shell) = std::env::var("SHELL") {
|
||||||
if !shell.trim().is_empty() {
|
if !shell.trim().is_empty() {
|
||||||
@@ -1306,8 +1354,8 @@ fn build_shell_args(shell: &str, command: &str) -> Vec<String> {
|
|||||||
.unwrap_or("")
|
.unwrap_or("")
|
||||||
.to_lowercase();
|
.to_lowercase();
|
||||||
|
|
||||||
if shell_name.contains("zsh") || shell_name.contains("bash") {
|
if shell_name.contains("zsh") {
|
||||||
vec!["-i".into(), "-l".into(), "-c".into(), command.into()]
|
vec!["-l".into(), "-i".into(), "-c".into(), command.into()]
|
||||||
} else {
|
} else {
|
||||||
vec!["-l".into(), "-c".into(), command.into()]
|
vec!["-l".into(), "-c".into(), command.into()]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ use tauri::webview::Webview;
|
|||||||
use tauri::{
|
use tauri::{
|
||||||
AppHandle, Emitter, Manager, Runtime, WebviewUrl, WebviewWindowBuilder, WindowEvent, Wry,
|
AppHandle, Emitter, Manager, Runtime, WebviewUrl, WebviewWindowBuilder, WindowEvent, Wry,
|
||||||
};
|
};
|
||||||
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind};
|
|
||||||
use tauri_plugin_global_shortcut::{
|
use tauri_plugin_global_shortcut::{
|
||||||
Code as ShortcutCode, GlobalShortcutExt, Shortcut, ShortcutState,
|
Code as ShortcutCode, GlobalShortcutExt, Shortcut, ShortcutState,
|
||||||
};
|
};
|
||||||
@@ -78,34 +77,6 @@ fn schedule_remote_proxy_session_cleanup(app: AppHandle, session_id: String) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn confirm_local_certificate_install(app: &AppHandle) -> Result<bool, String> {
|
|
||||||
let (sender, receiver) = std::sync::mpsc::sync_channel(1);
|
|
||||||
|
|
||||||
let mut dialog = app
|
|
||||||
.dialog()
|
|
||||||
.message(
|
|
||||||
"CodeNomad needs to install a local certificate to open self-signed HTTPS remote windows. This certificate is only used for local desktop proxy traffic on your machine. Your operating system may show a second certificate prompt after this.",
|
|
||||||
)
|
|
||||||
.title("Install Local Certificate")
|
|
||||||
.kind(MessageDialogKind::Warning)
|
|
||||||
.buttons(MessageDialogButtons::OkCancelCustom(
|
|
||||||
"Continue".into(),
|
|
||||||
"Cancel".into(),
|
|
||||||
));
|
|
||||||
|
|
||||||
if let Some(window) = app.get_webview_window("main") {
|
|
||||||
dialog = dialog.parent(&window);
|
|
||||||
}
|
|
||||||
|
|
||||||
dialog.show(move |accepted| {
|
|
||||||
let _ = sender.send(accepted);
|
|
||||||
});
|
|
||||||
|
|
||||||
tauri::async_runtime::spawn_blocking(move || receiver.recv().unwrap_or(false))
|
|
||||||
.await
|
|
||||||
.map_err(|err| err.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn cleanup_remote_proxy_session(app: &AppHandle, session_id: &str) -> Result<(), String> {
|
async fn cleanup_remote_proxy_session(app: &AppHandle, session_id: &str) -> Result<(), String> {
|
||||||
let status = app.state::<AppState>().manager.status();
|
let status = app.state::<AppState>().manager.status();
|
||||||
let Some(base_url) = status.url else {
|
let Some(base_url) = status.url else {
|
||||||
@@ -367,6 +338,24 @@ async fn open_remote_window_impl(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn needs_local_certificate_install() -> Result<bool, String> {
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
{
|
||||||
|
let local_cert = cert_manager::ensure_local_cert().map_err(|err| {
|
||||||
|
format!("Failed to load the local HTTPS certificate for the remote proxy window: {err}")
|
||||||
|
})?;
|
||||||
|
return cert_manager::needs_trust_in_store(&local_cert.ca_cert_der).map_err(|err| {
|
||||||
|
format!("Failed to inspect the local CodeNomad certificate trust state: {err}")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn open_remote_window(app: AppHandle, payload: RemoteWindowPayload) -> Result<(), String> {
|
async fn open_remote_window(app: AppHandle, payload: RemoteWindowPayload) -> Result<(), String> {
|
||||||
#[cfg(not(target_os = "linux"))]
|
#[cfg(not(target_os = "linux"))]
|
||||||
@@ -379,17 +368,6 @@ async fn open_remote_window(app: AppHandle, payload: RemoteWindowPayload) -> Res
|
|||||||
"Failed to load the local HTTPS certificate for the remote proxy window: {err}"
|
"Failed to load the local HTTPS certificate for the remote proxy window: {err}"
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
if cert_manager::needs_trust_in_store(&local_cert.ca_cert_der).map_err(|err| {
|
|
||||||
format!("Failed to inspect the local CodeNomad certificate trust state: {err}")
|
|
||||||
})? {
|
|
||||||
let accepted = confirm_local_certificate_install(&app).await?;
|
|
||||||
if !accepted {
|
|
||||||
return Err(
|
|
||||||
"CodeNomad needs the local certificate to be trusted before it can open self-signed HTTPS remote windows."
|
|
||||||
.to_string(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Err(err) = cert_manager::trust_cert_in_store(&local_cert.ca_cert_der) {
|
if let Err(err) = cert_manager::trust_cert_in_store(&local_cert.ca_cert_der) {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"Failed to trust the local CodeNomad CA certificate. Accept the certificate installation prompt and try again: {err}"
|
"Failed to trust the local CodeNomad CA certificate. Accept the certificate installation prompt and try again: {err}"
|
||||||
@@ -598,6 +576,7 @@ fn main() {
|
|||||||
cli_restart,
|
cli_restart,
|
||||||
wake_lock_start,
|
wake_lock_start,
|
||||||
wake_lock_stop,
|
wake_lock_stop,
|
||||||
|
needs_local_certificate_install,
|
||||||
open_remote_window
|
open_remote_window
|
||||||
])
|
])
|
||||||
.on_menu_event(|app_handle, event| {
|
.on_menu_event(|app_handle, event| {
|
||||||
|
|||||||
@@ -43,11 +43,6 @@
|
|||||||
"bundle": {
|
"bundle": {
|
||||||
"active": true,
|
"active": true,
|
||||||
"linux": {
|
"linux": {
|
||||||
"appimage": {
|
|
||||||
"files": {
|
|
||||||
"/usr/share/applications/ai.neuralnomads.codenomad.client.desktop": "icons/linux/ai.neuralnomads.codenomad.client.desktop"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"deb": {
|
"deb": {
|
||||||
"files": {
|
"files": {
|
||||||
"/usr/share/applications/ai.neuralnomads.codenomad.client.desktop": "icons/linux/ai.neuralnomads.codenomad.client.desktop",
|
"/usr/share/applications/ai.neuralnomads.codenomad.client.desktop": "icons/linux/ai.neuralnomads.codenomad.client.desktop",
|
||||||
|
|||||||
@@ -69,5 +69,10 @@ export const folderSelectionMessages = {
|
|||||||
"folderSelection.servers.dialog.connecting": "Connecting...",
|
"folderSelection.servers.dialog.connecting": "Connecting...",
|
||||||
"folderSelection.servers.dialog.errorRequired": "Server name and URL are required.",
|
"folderSelection.servers.dialog.errorRequired": "Server name and URL are required.",
|
||||||
"folderSelection.servers.dialog.errorConnect": "Could not connect to the remote server.",
|
"folderSelection.servers.dialog.errorConnect": "Could not connect to the remote server.",
|
||||||
|
"folderSelection.servers.certificateInstall.title": "Install Local Certificate",
|
||||||
|
"folderSelection.servers.certificateInstall.confirmMessage": "CodeNomad needs to install a local certificate to open self-signed HTTPS remote windows. This certificate is only used for local desktop proxy traffic on your machine. Your operating system may show a second certificate prompt after this.",
|
||||||
|
"folderSelection.servers.certificateInstall.confirmLabel": "Continue",
|
||||||
|
"folderSelection.servers.certificateInstall.cancelLabel": "Cancel",
|
||||||
|
"folderSelection.servers.certificateInstall.cancelled": "CodeNomad needs the local certificate to be trusted before it can open self-signed HTTPS remote windows.",
|
||||||
"folderSelection.sidecars.button": "Open SideCar",
|
"folderSelection.sidecars.button": "Open SideCar",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -69,5 +69,10 @@ export const folderSelectionMessages = {
|
|||||||
"folderSelection.servers.dialog.connecting": "Conectando...",
|
"folderSelection.servers.dialog.connecting": "Conectando...",
|
||||||
"folderSelection.servers.dialog.errorRequired": "El nombre y la URL del servidor son obligatorios.",
|
"folderSelection.servers.dialog.errorRequired": "El nombre y la URL del servidor son obligatorios.",
|
||||||
"folderSelection.servers.dialog.errorConnect": "No se pudo conectar al servidor remoto.",
|
"folderSelection.servers.dialog.errorConnect": "No se pudo conectar al servidor remoto.",
|
||||||
|
"folderSelection.servers.certificateInstall.title": "Instalar certificado local",
|
||||||
|
"folderSelection.servers.certificateInstall.confirmMessage": "CodeNomad necesita instalar un certificado local para abrir ventanas remotas HTTPS autofirmadas. Este certificado solo se usa para el trafico del proxy local de escritorio en tu equipo. Es posible que tu sistema operativo muestre un segundo aviso de certificado despues de esto.",
|
||||||
|
"folderSelection.servers.certificateInstall.confirmLabel": "Continuar",
|
||||||
|
"folderSelection.servers.certificateInstall.cancelLabel": "Cancelar",
|
||||||
|
"folderSelection.servers.certificateInstall.cancelled": "CodeNomad necesita que el certificado local sea de confianza antes de poder abrir ventanas remotas HTTPS autofirmadas.",
|
||||||
"folderSelection.sidecars.button": "Open SideCar",
|
"folderSelection.sidecars.button": "Open SideCar",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -69,5 +69,10 @@ export const folderSelectionMessages = {
|
|||||||
"folderSelection.servers.dialog.connecting": "Connexion...",
|
"folderSelection.servers.dialog.connecting": "Connexion...",
|
||||||
"folderSelection.servers.dialog.errorRequired": "Le nom du serveur et l'URL sont requis.",
|
"folderSelection.servers.dialog.errorRequired": "Le nom du serveur et l'URL sont requis.",
|
||||||
"folderSelection.servers.dialog.errorConnect": "Impossible de se connecter au serveur distant.",
|
"folderSelection.servers.dialog.errorConnect": "Impossible de se connecter au serveur distant.",
|
||||||
|
"folderSelection.servers.certificateInstall.title": "Installer le certificat local",
|
||||||
|
"folderSelection.servers.certificateInstall.confirmMessage": "CodeNomad doit installer un certificat local pour ouvrir des fenetres distantes HTTPS auto-signees. Ce certificat est utilise uniquement pour le trafic du proxy local de bureau sur votre machine. Votre systeme d'exploitation peut afficher une seconde invite de certificat apres cela.",
|
||||||
|
"folderSelection.servers.certificateInstall.confirmLabel": "Continuer",
|
||||||
|
"folderSelection.servers.certificateInstall.cancelLabel": "Annuler",
|
||||||
|
"folderSelection.servers.certificateInstall.cancelled": "CodeNomad a besoin que le certificat local soit approuve avant de pouvoir ouvrir des fenetres distantes HTTPS auto-signees.",
|
||||||
"folderSelection.sidecars.button": "Open SideCar",
|
"folderSelection.sidecars.button": "Open SideCar",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -69,5 +69,10 @@ export const folderSelectionMessages = {
|
|||||||
"folderSelection.servers.dialog.connecting": "מתחבר...",
|
"folderSelection.servers.dialog.connecting": "מתחבר...",
|
||||||
"folderSelection.servers.dialog.errorRequired": "שם השרת והכתובת הם שדות חובה.",
|
"folderSelection.servers.dialog.errorRequired": "שם השרת והכתובת הם שדות חובה.",
|
||||||
"folderSelection.servers.dialog.errorConnect": "לא ניתן היה להתחבר לשרת המרוחק.",
|
"folderSelection.servers.dialog.errorConnect": "לא ניתן היה להתחבר לשרת המרוחק.",
|
||||||
|
"folderSelection.servers.certificateInstall.title": "התקנת אישור מקומי",
|
||||||
|
"folderSelection.servers.certificateInstall.confirmMessage": "CodeNomad צריך להתקין אישור מקומי כדי לפתוח חלונות HTTPS מרוחקים עם אישור בחתימה עצמית. האישור הזה משמש רק לתעבורת ה-proxy המקומי של האפליקציה במחשב שלך. ייתכן שמערכת ההפעלה תציג לאחר מכן בקשת אישור נוספת.",
|
||||||
|
"folderSelection.servers.certificateInstall.confirmLabel": "המשך",
|
||||||
|
"folderSelection.servers.certificateInstall.cancelLabel": "ביטול",
|
||||||
|
"folderSelection.servers.certificateInstall.cancelled": "CodeNomad צריך שהאישור המקומי יהיה מהימן לפני שיוכל לפתוח חלונות HTTPS מרוחקים עם אישור בחתימה עצמית.",
|
||||||
"folderSelection.sidecars.button": "Open SideCar",
|
"folderSelection.sidecars.button": "Open SideCar",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -69,5 +69,10 @@ export const folderSelectionMessages = {
|
|||||||
"folderSelection.servers.dialog.connecting": "接続中...",
|
"folderSelection.servers.dialog.connecting": "接続中...",
|
||||||
"folderSelection.servers.dialog.errorRequired": "サーバー名と URL は必須です。",
|
"folderSelection.servers.dialog.errorRequired": "サーバー名と URL は必須です。",
|
||||||
"folderSelection.servers.dialog.errorConnect": "リモートサーバーに接続できませんでした。",
|
"folderSelection.servers.dialog.errorConnect": "リモートサーバーに接続できませんでした。",
|
||||||
|
"folderSelection.servers.certificateInstall.title": "ローカル証明書をインストール",
|
||||||
|
"folderSelection.servers.certificateInstall.confirmMessage": "CodeNomad は自己署名 HTTPS のリモートウィンドウを開くために、ローカル証明書をインストールする必要があります。この証明書は、このマシン上のローカルデスクトッププロキシ通信にのみ使用されます。この後、OS が追加の証明書プロンプトを表示する場合があります。",
|
||||||
|
"folderSelection.servers.certificateInstall.confirmLabel": "続行",
|
||||||
|
"folderSelection.servers.certificateInstall.cancelLabel": "キャンセル",
|
||||||
|
"folderSelection.servers.certificateInstall.cancelled": "自己署名 HTTPS のリモートウィンドウを開くには、CodeNomad のローカル証明書を信頼する必要があります。",
|
||||||
"folderSelection.sidecars.button": "Open SideCar",
|
"folderSelection.sidecars.button": "Open SideCar",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -69,5 +69,10 @@ export const folderSelectionMessages = {
|
|||||||
"folderSelection.servers.dialog.connecting": "Подключение...",
|
"folderSelection.servers.dialog.connecting": "Подключение...",
|
||||||
"folderSelection.servers.dialog.errorRequired": "Имя сервера и URL обязательны.",
|
"folderSelection.servers.dialog.errorRequired": "Имя сервера и URL обязательны.",
|
||||||
"folderSelection.servers.dialog.errorConnect": "Не удалось подключиться к удаленному серверу.",
|
"folderSelection.servers.dialog.errorConnect": "Не удалось подключиться к удаленному серверу.",
|
||||||
|
"folderSelection.servers.certificateInstall.title": "Установить локальный сертификат",
|
||||||
|
"folderSelection.servers.certificateInstall.confirmMessage": "CodeNomad должен установить локальный сертификат, чтобы открывать удаленные HTTPS-окна с самоподписанным сертификатом. Этот сертификат используется только для трафика локального настольного прокси на вашем устройстве. После этого ваша операционная система может показать второе предупреждение о сертификате.",
|
||||||
|
"folderSelection.servers.certificateInstall.confirmLabel": "Продолжить",
|
||||||
|
"folderSelection.servers.certificateInstall.cancelLabel": "Отмена",
|
||||||
|
"folderSelection.servers.certificateInstall.cancelled": "CodeNomad должен доверять локальному сертификату, прежде чем сможет открывать удаленные HTTPS-окна с самоподписанным сертификатом.",
|
||||||
"folderSelection.sidecars.button": "Open SideCar",
|
"folderSelection.sidecars.button": "Open SideCar",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -69,5 +69,10 @@ export const folderSelectionMessages = {
|
|||||||
"folderSelection.servers.dialog.connecting": "连接中...",
|
"folderSelection.servers.dialog.connecting": "连接中...",
|
||||||
"folderSelection.servers.dialog.errorRequired": "服务器名称和 URL 为必填项。",
|
"folderSelection.servers.dialog.errorRequired": "服务器名称和 URL 为必填项。",
|
||||||
"folderSelection.servers.dialog.errorConnect": "无法连接到远程服务器。",
|
"folderSelection.servers.dialog.errorConnect": "无法连接到远程服务器。",
|
||||||
|
"folderSelection.servers.certificateInstall.title": "安装本地证书",
|
||||||
|
"folderSelection.servers.certificateInstall.confirmMessage": "CodeNomad 需要安装本地证书,才能打开使用自签名 HTTPS 的远程窗口。此证书仅用于你这台设备上的本地桌面代理流量。之后你的操作系统可能还会显示第二个证书提示。",
|
||||||
|
"folderSelection.servers.certificateInstall.confirmLabel": "继续",
|
||||||
|
"folderSelection.servers.certificateInstall.cancelLabel": "取消",
|
||||||
|
"folderSelection.servers.certificateInstall.cancelled": "CodeNomad 需要先信任本地证书,才能打开使用自签名 HTTPS 的远程窗口。",
|
||||||
"folderSelection.sidecars.button": "Open SideCar",
|
"folderSelection.sidecars.button": "Open SideCar",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core"
|
import { invoke } from "@tauri-apps/api/core"
|
||||||
import type { RemoteServerProfile } from "../../../../server/src/api-types"
|
import type { RemoteServerProfile } from "../../../../server/src/api-types"
|
||||||
|
import { showConfirmDialog } from "../../stores/alerts"
|
||||||
|
import { tGlobal } from "../i18n"
|
||||||
import { runtimeEnv } from "../runtime-env"
|
import { runtimeEnv } from "../runtime-env"
|
||||||
|
|
||||||
export interface RemoteWindowOpenPayload {
|
export interface RemoteWindowOpenPayload {
|
||||||
@@ -34,6 +36,28 @@ export async function openRemoteServerWindow(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (runtimeEnv.host === "tauri") {
|
if (runtimeEnv.host === "tauri") {
|
||||||
|
const requiresLocalCertificate =
|
||||||
|
proxySessionId !== undefined && (entryUrl ?? profile.baseUrl).startsWith("https://")
|
||||||
|
|
||||||
|
if (requiresLocalCertificate) {
|
||||||
|
const needsInstall = await invoke<boolean>("needs_local_certificate_install")
|
||||||
|
if (needsInstall) {
|
||||||
|
const accepted = await showConfirmDialog(
|
||||||
|
tGlobal("folderSelection.servers.certificateInstall.confirmMessage"),
|
||||||
|
{
|
||||||
|
title: tGlobal("folderSelection.servers.certificateInstall.title"),
|
||||||
|
variant: "warning",
|
||||||
|
confirmLabel: tGlobal("folderSelection.servers.certificateInstall.confirmLabel"),
|
||||||
|
cancelLabel: tGlobal("folderSelection.servers.certificateInstall.cancelLabel"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!accepted) {
|
||||||
|
throw new Error(tGlobal("folderSelection.servers.certificateInstall.cancelled"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await invoke("open_remote_window", { payload })
|
await invoke("open_remote_window", { payload })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user