diff --git a/.opencode/commands/release-notes.md b/.opencode/commands/release-notes.md index e0c7734c6..bd48f771e 100644 --- a/.opencode/commands/release-notes.md +++ b/.opencode/commands/release-notes.md @@ -3,5 +3,5 @@ description: Creates release notes agent: build --- -Check how I do prepare release notes here - https://github.com/NeuralNomadsAI/CodeNomad/releases/tag/v0.7.0 +Check how I do prepare release notes here - https://github.com/NeuralNomadsAI/CodeNomad/releases/tag/v0.15.0 Use the same format to create release notes from users perspective for new release by looking at changes from last tagged release to tip of branch \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 83d6dd284..5341e6294 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codenomad-workspace", - "version": "0.15.0", + "version": "0.16.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codenomad-workspace", - "version": "0.15.0", + "version": "0.16.0", "license": "MIT", "dependencies": { "7zip-bin": "^5.2.0", @@ -29,7 +29,7 @@ "packages/ui", "packages/electron-app", "packages/tauri-app", - "packages/opencode-config" + "packages/opencode-plugin" ] } }, @@ -1575,8 +1575,8 @@ "dev": true, "license": "MIT" }, - "node_modules/@codenomad/opencode-config": { - "resolved": "packages/opencode-config", + "node_modules/@codenomad/codenomad-opencode-plugin": { + "resolved": "packages/opencode-plugin", "link": true }, "node_modules/@codenomad/tauri-app": { @@ -3228,6 +3228,43 @@ "node": ">= 8" } }, + "node_modules/@opencode-ai/plugin": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.3.7.tgz", + "integrity": "sha512-pVBIcYtHiniQ93Gj/KRkhrIz1oIAwGRifb7+dfGWdHRy00gr9DyEHFYmgHcBYgfrBavZrWw2xmqEDJdjdBuC7g==", + "license": "MIT", + "dependencies": { + "@opencode-ai/sdk": "1.3.7", + "zod": "4.1.8" + }, + "peerDependencies": { + "@opentui/core": ">=0.1.92", + "@opentui/solid": ">=0.1.92" + }, + "peerDependenciesMeta": { + "@opentui/core": { + "optional": true + }, + "@opentui/solid": { + "optional": true + } + } + }, + "node_modules/@opencode-ai/plugin/node_modules/@opencode-ai/sdk": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.3.7.tgz", + "integrity": "sha512-ugkta0v0dMZchN15QGmqHb9zf35k+K1VM9wt3x4ZRJ6GxKAs0XlCmQPQJflgV9YSedNxjkgTud0GCCIWUSiUOg==", + "license": "MIT" + }, + "node_modules/@opencode-ai/plugin/node_modules/zod": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.8.tgz", + "integrity": "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/@opencode-ai/sdk": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.2.6.tgz", @@ -4013,6 +4050,142 @@ "node": ">= 10" } }, + "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.9.4.tgz", + "integrity": "sha512-tTWkEPig+2z3Rk0zqZYfjUYcgD+aSm72wdrIhdYobxbQZOBw0zfn50YtWv+av7bm0SHvv75f0l7JuwgZM1HFow==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-gnu": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.9.4.tgz", + "integrity": "sha512-ql6vJ611qoqRYHxkKPnb2vHa27U+YRKRmIpLMMBeZnfFtZ938eao7402AQCH1mO2+/8ioUhbpy9R/ZcLTXVmkg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-musl": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.9.4.tgz", + "integrity": "sha512-vg7yNn7ICTi6hRrcA/6ff2UpZQP7un3xe3SEld5QM0prgridbKAiXGaCKr3BnUBx/rGXegQlD/wiLcWdiiraSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-riscv64-gnu": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.9.4.tgz", + "integrity": "sha512-l8L+3VxNk6yv5T/Z/gv5ysngmIpsai40B9p6NQQyqYqxImqYX37pqREoEBl1YwG7szGnDibpWhidPrWKR59OJA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-gnu": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.9.4.tgz", + "integrity": "sha512-PepPhCXc/xVvE3foykNho46OmCyx47E/aG676vKTVp+mqin5d+IBqDL6wDKiGNT5OTTxKEyNlCQ81Xs2BQhhqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-musl": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.9.4.tgz", + "integrity": "sha512-zcd1QVffh5tZs1u1SCKUV/V7RRynebgYUNWHuV0FsIF1MjnULUChEXhAhug7usCDq4GZReMJOoXa6rukEozWIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-arm64-msvc": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.9.4.tgz", + "integrity": "sha512-/7ZhnP6PY04bEob23q8MH/EoDISdmR1wuNm0k9d5HV7TDMd2GGCDa8dPXA4vJuglJKXIfXqxFmZ4L+J+MO42+w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-ia32-msvc": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.9.4.tgz", + "integrity": "sha512-1LmAfaC4Cq+3O1Ir1ksdhczhdtFSTIV51tbAGtbV/mr348O+M52A/xwCCXQank0OcdBxy5BctqkMtuZnQvA8uQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@tauri-apps/cli-win32-x64-msvc": { "version": "2.9.4", "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.9.4.tgz", @@ -4030,6 +4203,23 @@ "node": ">= 10" } }, + "node_modules/@tauri-apps/cli/node_modules/@tauri-apps/cli-darwin-arm64": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.9.4.tgz", + "integrity": "sha512-9rHkMVtbMhe0AliVbrGpzMahOBg3rwV46JYRELxR9SN6iu1dvPOaMaiC4cP6M/aD1424ziXnnMdYU06RAH8oIw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@tauri-apps/plugin-dialog": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.6.0.tgz", @@ -13216,11 +13406,9 @@ }, "packages/electron-app": { "name": "@neuralnomads/codenomad-electron-app", - "version": "0.15.0", + "version": "0.16.0", "license": "MIT", "dependencies": { - "@codenomad/ui": "file:../ui", - "@neuralnomads/codenomad": "file:../server", "yaml": "^2.4.2" }, "devDependencies": { @@ -13243,54 +13431,21 @@ "dev": true, "license": "MIT" }, - "packages/opencode-config": { - "name": "@codenomad/opencode-config", - "version": "0.15.0", + "packages/opencode-plugin": { + "name": "@codenomad/codenomad-opencode-plugin", + "version": "0.16.0", "license": "MIT", "dependencies": { "@opencode-ai/plugin": "1.3.7" - } - }, - "packages/opencode-config/node_modules/@opencode-ai/plugin": { - "version": "1.3.7", - "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.3.7.tgz", - "integrity": "sha512-pVBIcYtHiniQ93Gj/KRkhrIz1oIAwGRifb7+dfGWdHRy00gr9DyEHFYmgHcBYgfrBavZrWw2xmqEDJdjdBuC7g==", - "license": "MIT", - "dependencies": { - "@opencode-ai/sdk": "1.3.7", - "zod": "4.1.8" - }, - "peerDependencies": { - "@opentui/core": ">=0.1.92", - "@opentui/solid": ">=0.1.92" }, - "peerDependenciesMeta": { - "@opentui/core": { - "optional": true - }, - "@opentui/solid": { - "optional": true - } - } - }, - "packages/opencode-config/node_modules/@opencode-ai/sdk": { - "version": "1.3.7", - "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.3.7.tgz", - "integrity": "sha512-ugkta0v0dMZchN15QGmqHb9zf35k+K1VM9wt3x4ZRJ6GxKAs0XlCmQPQJflgV9YSedNxjkgTud0GCCIWUSiUOg==", - "license": "MIT" - }, - "packages/opencode-config/node_modules/zod": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.8.tgz", - "integrity": "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" + "devDependencies": { + "@types/node": "^22.18.0", + "typescript": "^5.6.3" } }, "packages/server": { "name": "@neuralnomads/codenomad", - "version": "0.15.0", + "version": "0.16.0", "license": "MIT", "dependencies": { "@fastify/cors": "^8.5.0", @@ -13332,7 +13487,7 @@ }, "packages/tauri-app": { "name": "@codenomad/tauri-app", - "version": "0.15.0", + "version": "0.16.0", "license": "MIT", "devDependencies": { "@tauri-apps/cli": "^2.9.4" @@ -13340,7 +13495,7 @@ }, "packages/ui": { "name": "@codenomad/ui", - "version": "0.15.0", + "version": "0.16.0", "license": "MIT", "dependencies": { "@git-diff-view/solid": "^0.0.8", diff --git a/package.json b/package.json index 387e35b1b..b4337c87a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codenomad-workspace", - "version": "0.15.0", + "version": "0.16.0", "private": true, "description": "CodeNomad monorepo workspace", "license": "MIT", @@ -10,7 +10,7 @@ "packages/ui", "packages/electron-app", "packages/tauri-app", - "packages/opencode-config" + "packages/opencode-plugin" ] }, "scripts": { diff --git a/packages/cloudflare/release-config.json b/packages/cloudflare/release-config.json index 479969cc9..a996a65f7 100644 --- a/packages/cloudflare/release-config.json +++ b/packages/cloudflare/release-config.json @@ -1,4 +1,4 @@ { - "minServerVersion": "0.15.0", + "minServerVersion": "0.16.0", "latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest" } diff --git a/packages/electron-app/electron.vite.config.ts b/packages/electron-app/electron.vite.config.ts index 1f3f7ede4..fe8697965 100644 --- a/packages/electron-app/electron.vite.config.ts +++ b/packages/electron-app/electron.vite.config.ts @@ -80,9 +80,9 @@ export default defineConfig({ port: 3000, }, build: { - minify: false, - cssMinify: false, - sourcemap: true, + minify: true, + cssMinify: true, + sourcemap: false, outDir: resolve(__dirname, "dist/renderer"), rollupOptions: { input: { diff --git a/packages/electron-app/electron/main/ipc.ts b/packages/electron-app/electron/main/ipc.ts index d65369459..076ec8ca3 100644 --- a/packages/electron-app/electron/main/ipc.ts +++ b/packages/electron-app/electron/main/ipc.ts @@ -10,6 +10,7 @@ interface DialogOpenRequest { title?: string defaultPath?: string filters?: Array<{ name?: string; extensions: string[] }> + multiple?: boolean } interface DialogOpenResult { @@ -47,6 +48,9 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan ipcMain.handle("dialog:open", async (_, request: DialogOpenRequest): Promise => { const properties: OpenDialogOptions["properties"] = request.mode === "directory" ? ["openDirectory", "createDirectory"] : ["openFile"] + if (request.mode === "file" && request.multiple) { + properties.push("multiSelections") + } const filters = request.filters?.map((filter) => ({ name: filter.name ?? "Files", diff --git a/packages/electron-app/electron/main/main.ts b/packages/electron-app/electron/main/main.ts index 4b31cc9a1..c4e01e295 100644 --- a/packages/electron-app/electron/main/main.ts +++ b/packages/electron-app/electron/main/main.ts @@ -474,12 +474,15 @@ function finalizeCliSwap(url: string) { } function buildRemoteWindowTitle(name: string, baseUrl: string) { - try { - const parsed = new URL(baseUrl) - return `${name} - ${parsed.host}` - } catch { - return `${name} - ${baseUrl}` - } + return `${name} - ${baseUrl}` +} + +function lockWindowTitle(window: BrowserWindow, title: string) { + window.setTitle(title) + window.webContents.on("page-title-updated", (event) => { + event.preventDefault() + window.setTitle(title) + }) } function buildRemoteErrorHtml(name: string, baseUrl: string, message: string) { @@ -508,6 +511,7 @@ async function openRemoteWindow(payload: { id: string; name: string; baseUrl: st additionalArguments: ["--codenomad-window-context=remote"], }, }) + lockWindowTitle(window, title) setWindowAllowedOrigin(window, targetUrl.toString()) if (payload.skipTlsVerify) { diff --git a/packages/electron-app/package.json b/packages/electron-app/package.json index 9fca6f98f..f6a1fe753 100644 --- a/packages/electron-app/package.json +++ b/packages/electron-app/package.json @@ -1,6 +1,6 @@ { "name": "@neuralnomads/codenomad-electron-app", - "version": "0.15.0", + "version": "0.16.0", "description": "CodeNomad - AI coding assistant", "license": "MIT", "author": { @@ -37,11 +37,10 @@ "build:all": "node scripts/build.js all", "package:mac": "node scripts/build.js mac", "package:win": "node scripts/build.js win", - "package:linux": "node scripts/build.js linux" + "package:linux": "node scripts/build.js linux", + "smoke:resources": "node ../../scripts/smoke-packaged-resources.cjs --resources electron/resources --loading dist/renderer" }, "dependencies": { - "@neuralnomads/codenomad": "file:../server", - "@codenomad/ui": "file:../ui", "yaml": "^2.4.2" }, "devDependencies": { @@ -66,7 +65,10 @@ "buildResources": "electron/resources" }, "files": [ - "dist/**/*", + "dist/main/**/*", + "dist/preload/**/*", + "dist/renderer/loading.html", + "dist/renderer/assets/**/*", "package.json" ], "extraResources": [ @@ -79,8 +81,8 @@ ] }, { - "from": "../server/dist/opencode-config", - "to": "opencode-config" + "from": "../server/dist/opencode-plugin", + "to": "opencode-plugin" } ], "mac": { diff --git a/packages/electron-app/scripts/build.js b/packages/electron-app/scripts/build.js index 1621ce1ac..f6dfe7269 100644 --- a/packages/electron-app/scripts/build.js +++ b/packages/electron-app/scripts/build.js @@ -137,9 +137,24 @@ async function build(platform) { console.log(`\nšŸ“¦ Preparing resources for ${job.nodeTarget}...\n`) await run(process.execPath, [join(appDir, "scripts", "prepare-resources.js")], { cwd: workspaceRoot, + shell: false, env: { NODE_PATH: workspaceNodeModulesPath, CODENOMAD_NODE_TARGET: job.nodeTarget }, }) + console.log(`\nšŸ”Ž Validating resources for ${job.nodeTarget}...\n`) + await run(process.execPath, [ + join(workspaceRoot, "scripts", "smoke-packaged-resources.cjs"), + "--resources", + join(appDir, "electron", "resources"), + "--loading", + join(appDir, "dist", "renderer"), + "--target", + job.nodeTarget, + ], { + cwd: workspaceRoot, + shell: false, + }) + console.log(`\nšŸ“¦ Packaging ${job.nodeTarget}...\n`) await run(npxCmd, ["electron-builder", "--publish=never", ...job.args], { env: { CODENOMAD_NODE_TARGET: job.nodeTarget }, diff --git a/packages/electron-app/scripts/prepare-resources.js b/packages/electron-app/scripts/prepare-resources.js index 27cbd58b3..0e0694910 100644 --- a/packages/electron-app/scripts/prepare-resources.js +++ b/packages/electron-app/scripts/prepare-resources.js @@ -16,8 +16,8 @@ const serverDest = join(resourcesRoot, "server") const npmExecPath = process.env.npm_execpath const npmNodeExecPath = process.env.npm_node_execpath const { prepareBundledNodeRuntime } = require(join(workspaceRoot, "scripts", "prepare-node-runtime.cjs")) +const { copyPackagedServerResources } = require(join(workspaceRoot, "scripts", "desktop-server-resources.cjs")) -const serverSources = ["dist", "public", "node_modules", "package.json"] const serverDepsMarker = join(serverRoot, "node_modules", "fastify", "package.json") function log(message) { @@ -68,65 +68,10 @@ function ensureServerDependencies() { } } -function copyServerArtifacts() { - fs.rmSync(serverDest, { recursive: true, force: true }) - fs.mkdirSync(serverDest, { recursive: true }) - - for (const name of serverSources) { - const from = join(serverRoot, name) - const to = join(serverDest, name) - if (!fs.existsSync(from)) { - throw new Error(`Missing required server artifact: ${from}`) - } - fs.cpSync(from, to, { recursive: true, dereference: true }) - log(`copied ${name} to Electron resources`) - } -} - -function stripNodeModuleBins() { - const root = join(serverDest, "node_modules") - if (!fs.existsSync(root)) { - return - } - - const stack = [root] - let removed = 0 - - while (stack.length > 0) { - const current = stack.pop() - if (!current) break - - let entries - try { - entries = fs.readdirSync(current, { withFileTypes: true }) - } catch { - continue - } - - for (const entry of entries) { - const full = join(current, entry.name) - if (entry.name === ".bin") { - fs.rmSync(full, { recursive: true, force: true }) - removed += 1 - continue - } - - if (entry.isDirectory()) { - stack.push(full) - } - } - } - - if (removed > 0) { - log(`removed ${removed} node_modules/.bin directories`) - } -} - async function main() { ensureServerBuild() ensureServerDependencies() - copyServerArtifacts() - stripNodeModuleBins() + copyPackagedServerResources({ serverRoot, serverDest, log }) await prepareBundledNodeRuntime({ resourcesRoot }) } diff --git a/packages/opencode-config/opencode.jsonc b/packages/opencode-config/opencode.jsonc deleted file mode 100644 index c3eb6a56f..000000000 --- a/packages/opencode-config/opencode.jsonc +++ /dev/null @@ -1,3 +0,0 @@ -{ - "$schema": "https://opencode.ai/config.json" -} \ No newline at end of file diff --git a/packages/opencode-config/package-lock.json b/packages/opencode-config/package-lock.json deleted file mode 100644 index 840b67473..000000000 --- a/packages/opencode-config/package-lock.json +++ /dev/null @@ -1,380 +0,0 @@ -{ - "name": "@codenomad/opencode-config", - "version": "0.15.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "@codenomad/opencode-config", - "version": "0.15.0", - "license": "MIT", - "dependencies": { - "@opencode-ai/plugin": "1.14.24" - } - }, - "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", - "integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz", - "integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz", - "integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz", - "integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz", - "integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz", - "integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@opencode-ai/plugin": { - "version": "1.14.24", - "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.14.24.tgz", - "integrity": "sha512-upzw2a9KfzIkIvvjYSPJiyV6o85D3HLmhVvAJIwV8mYWxbvi2wP2NA0hJaMp2+GZVuUl/ra8WV8kacD1CWcb4w==", - "license": "MIT", - "dependencies": { - "@opencode-ai/sdk": "1.14.24", - "effect": "4.0.0-beta.48", - "zod": "4.1.8" - }, - "peerDependencies": { - "@opentui/core": ">=0.1.99", - "@opentui/solid": ">=0.1.99" - }, - "peerDependenciesMeta": { - "@opentui/core": { - "optional": true - }, - "@opentui/solid": { - "optional": true - } - } - }, - "node_modules/@opencode-ai/sdk": { - "version": "1.14.24", - "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.14.24.tgz", - "integrity": "sha512-hZWc1jx+gtZBM6Mff9iOMlXM1at9BbAGg0uNrQk8DuXpd8K19fu942emojdInO2zy0jC5/wWggsi7GJu7HMp/w==", - "license": "MIT", - "dependencies": { - "cross-spawn": "7.0.6" - } - }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "license": "MIT" - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "license": "Apache-2.0", - "optional": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/effect": { - "version": "4.0.0-beta.48", - "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.48.tgz", - "integrity": "sha512-MMAM/ZabuNdNmgXiin+BAanQXK7qM8mlt7nfXDoJ/Gn9V8i89JlCq+2N0AiWmqFLXjGLA0u3FjiOjSOYQk5uMw==", - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.1.0", - "fast-check": "^4.6.0", - "find-my-way-ts": "^0.1.6", - "ini": "^6.0.0", - "kubernetes-types": "^1.30.0", - "msgpackr": "^1.11.9", - "multipasta": "^0.2.7", - "toml": "^4.1.1", - "uuid": "^13.0.0", - "yaml": "^2.8.3" - } - }, - "node_modules/fast-check": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.7.0.tgz", - "integrity": "sha512-NsZRtqvSSoCP0HbNjUD+r1JH8zqZalyp6gLY9e7OYs7NK9b6AHOs2baBFeBG7bVNsuoukh89x2Yg3rPsul8ziQ==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/dubzzz" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fast-check" - } - ], - "license": "MIT", - "dependencies": { - "pure-rand": "^8.0.0" - }, - "engines": { - "node": ">=12.17.0" - } - }, - "node_modules/find-my-way-ts": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/find-my-way-ts/-/find-my-way-ts-0.1.6.tgz", - "integrity": "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==", - "license": "MIT" - }, - "node_modules/ini": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz", - "integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==", - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC" - }, - "node_modules/kubernetes-types": { - "version": "1.30.0", - "resolved": "https://registry.npmjs.org/kubernetes-types/-/kubernetes-types-1.30.0.tgz", - "integrity": "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==", - "license": "Apache-2.0" - }, - "node_modules/msgpackr": { - "version": "1.11.10", - "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.10.tgz", - "integrity": "sha512-iCZNq+HszvF+fC3anCm4nBmWEnbeIAfpDs6IStAEKhQ2YSgkjzVG2FF9XJqwwQh5bH3N9OUTUt4QwVN6MLMLtA==", - "license": "MIT", - "optionalDependencies": { - "msgpackr-extract": "^3.0.2" - } - }, - "node_modules/msgpackr-extract": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz", - "integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "dependencies": { - "node-gyp-build-optional-packages": "5.2.2" - }, - "bin": { - "download-msgpackr-prebuilds": "bin/download-prebuilds.js" - }, - "optionalDependencies": { - "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", - "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" - } - }, - "node_modules/multipasta": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.7.tgz", - "integrity": "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA==", - "license": "MIT" - }, - "node_modules/node-gyp-build-optional-packages": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", - "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", - "license": "MIT", - "optional": true, - "dependencies": { - "detect-libc": "^2.0.1" - }, - "bin": { - "node-gyp-build-optional-packages": "bin.js", - "node-gyp-build-optional-packages-optional": "optional.js", - "node-gyp-build-optional-packages-test": "build-test.js" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/pure-rand": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.0.tgz", - "integrity": "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/dubzzz" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fast-check" - } - ], - "license": "MIT" - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/toml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/toml/-/toml-4.1.1.tgz", - "integrity": "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw==", - "license": "MIT", - "engines": { - "node": ">=20" - } - }, - "node_modules/uuid": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", - "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist-node/bin/uuid" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/yaml": { - "version": "2.8.3", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", - "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", - "license": "ISC", - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, - "node_modules/zod": { - "version": "4.1.8", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - } - } -} diff --git a/packages/opencode-config/package.json b/packages/opencode-config/package.json deleted file mode 100644 index b7d127b9b..000000000 --- a/packages/opencode-config/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "@codenomad/opencode-config", - "version": "0.15.0", - "private": true, - "license": "MIT", - "dependencies": { - "@opencode-ai/plugin": "1.3.7" - } -} diff --git a/packages/opencode-config/README.md b/packages/opencode-plugin/README.md similarity index 55% rename from packages/opencode-config/README.md rename to packages/opencode-plugin/README.md index 28e60bd2b..16b8a8192 100644 --- a/packages/opencode-config/README.md +++ b/packages/opencode-plugin/README.md @@ -1,16 +1,16 @@ -# opencode-config +# CodeNomad OpenCode Plugin ## TLDR -Template config + plugins injected into every OpenCode instance that CodeNomad launches. It provides a CodeNomad bridge plugin for local event exchange between the CLI server and opencode. +Packaged OpenCode plugin injected into every OpenCode instance that CodeNomad launches. It provides the CodeNomad bridge for local event exchange between the CLI server and OpenCode. ## What it is -A packaged config directory that CodeNomad copies into `~/.config/codenomad/opencode-config` for production builds or uses directly in dev. OpenCode autoloads any `plugin/*.ts` or `plugin/*.js` from this directory. +An npm-packable plugin package. Production builds ship a local `.tgz` and inject it through `OPENCODE_CONFIG_CONTENT`; dev runs reference the TypeScript plugin entry directly with a `file://` URL. ## How it works -- CodeNomad sets `OPENCODE_CONFIG_DIR` when spawning each opencode instance (`packages/server/src/workspaces/manager.ts`). -- This template is synced from `packages/opencode-config` (`packages/server/src/opencode-config.ts`, `packages/server/scripts/copy-opencode-config.mjs`). -- OpenCode autoloads plugins from `plugin/` (`packages/opencode-config/plugin/codenomad.ts`). -- The `CodeNomadPlugin` reads `CODENOMAD_INSTANCE_ID` + `CODENOMAD_BASE_URL`, connects to `GET /workspaces/:id/plugin/events`, and posts to `POST /workspaces/:id/plugin/event` (`packages/opencode-config/plugin/lib/client.ts`). +- CodeNomad sets `OPENCODE_CONFIG_CONTENT` when spawning each OpenCode instance (`packages/server/src/workspaces/manager.ts`). +- The server packs this package during build (`packages/server/scripts/package-opencode-plugin.mjs`). +- OpenCode loads the plugin from `plugin` entries injected into the config content. +- The `CodeNomadPlugin` reads `CODENOMAD_INSTANCE_ID` + `CODENOMAD_BASE_URL`, connects to `GET /workspaces/:id/plugin/events`, and posts to `POST /workspaces/:id/plugin/event` (`packages/opencode-plugin/plugin/lib/client.ts`). - The server exposes the plugin routes and maps events into the UI SSE pipeline (`packages/server/src/server/routes/plugin.ts`, `packages/server/src/plugins/handlers.ts`). ## Expectations @@ -25,8 +25,8 @@ A packaged config directory that CodeNomad copies into `~/.config/codenomad/open - Promote stable event shapes and version tags once the protocol settles. ## Pointers -- Plugin entry: `packages/opencode-config/plugin/codenomad.ts` -- Plugin client: `packages/opencode-config/plugin/lib/client.ts` +- Plugin entry: `packages/opencode-plugin/plugin/codenomad.ts` +- Plugin client: `packages/opencode-plugin/plugin/lib/client.ts` - Plugin server routes: `packages/server/src/server/routes/plugin.ts` - Plugin event handling: `packages/server/src/plugins/handlers.ts` - Workspace env injection: `packages/server/src/workspaces/manager.ts` diff --git a/packages/opencode-plugin/package.json b/packages/opencode-plugin/package.json new file mode 100644 index 000000000..a77516bb7 --- /dev/null +++ b/packages/opencode-plugin/package.json @@ -0,0 +1,22 @@ +{ + "name": "@codenomad/codenomad-opencode-plugin", + "version": "0.16.0", + "private": true, + "license": "MIT", + "type": "module", + "main": "dist/codenomad.js", + "files": [ + "dist", + "README.md" + ], + "scripts": { + "build": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json" + }, + "dependencies": { + "@opencode-ai/plugin": "1.3.7" + }, + "devDependencies": { + "@types/node": "^22.18.0", + "typescript": "^5.6.3" + } +} diff --git a/packages/opencode-config/plugin/codenomad.ts b/packages/opencode-plugin/plugin/codenomad.ts similarity index 86% rename from packages/opencode-config/plugin/codenomad.ts rename to packages/opencode-plugin/plugin/codenomad.ts index 08515dd8a..61d1827f0 100644 --- a/packages/opencode-config/plugin/codenomad.ts +++ b/packages/opencode-plugin/plugin/codenomad.ts @@ -1,10 +1,14 @@ import type { PluginInput } from "@opencode-ai/plugin" -import { createCodeNomadClient, getCodeNomadConfig } from "./lib/client" -import { createBackgroundProcessTools } from "./lib/background-process" +import { createCodeNomadClient, getCodeNomadConfig } from "./lib/client.js" +import { createBackgroundProcessTools } from "./lib/background-process.js" let voiceModeEnabled = false -export async function CodeNomadPlugin(input: PluginInput) { +export async function CodeNomadPlugin(input: PluginInput): Promise<{ + tool: ReturnType + "chat.message": CodeNomadChatMessageHook + event: CodeNomadEventHook +}> { const config = getCodeNomadConfig() const client = createCodeNomadClient(config) const backgroundProcessTools = createBackgroundProcessTools(config, { baseDir: input.directory }) @@ -45,6 +49,13 @@ export async function CodeNomadPlugin(input: PluginInput) { } } +type CodeNomadChatMessageHook = ( + _input: { sessionID: string }, + output: { message: { system?: string } }, +) => Promise + +type CodeNomadEventHook = (input: { event: any }) => Promise + function buildVoiceModePrompt(): string { return [ "Voice conversation mode is enabled.", diff --git a/packages/opencode-config/plugin/lib/background-process.ts b/packages/opencode-plugin/plugin/lib/background-process.ts similarity index 99% rename from packages/opencode-config/plugin/lib/background-process.ts rename to packages/opencode-plugin/plugin/lib/background-process.ts index 15198288a..6840737d6 100644 --- a/packages/opencode-config/plugin/lib/background-process.ts +++ b/packages/opencode-plugin/plugin/lib/background-process.ts @@ -1,6 +1,6 @@ import path from "path" import { tool } from "@opencode-ai/plugin/tool" -import { createCodeNomadRequester, type CodeNomadConfig } from "./request" +import { createCodeNomadRequester, type CodeNomadConfig } from "./request.js" type BackgroundProcess = { id: string diff --git a/packages/opencode-config/plugin/lib/client.ts b/packages/opencode-plugin/plugin/lib/client.ts similarity index 98% rename from packages/opencode-config/plugin/lib/client.ts rename to packages/opencode-plugin/plugin/lib/client.ts index fddc49a4f..aee7a15dc 100644 --- a/packages/opencode-config/plugin/lib/client.ts +++ b/packages/opencode-plugin/plugin/lib/client.ts @@ -1,6 +1,6 @@ -import { createCodeNomadRequester, type CodeNomadConfig, type PluginEvent } from "./request" +import { createCodeNomadRequester, type CodeNomadConfig, type PluginEvent } from "./request.js" -export { getCodeNomadConfig, type CodeNomadConfig, type PluginEvent } from "./request" +export { getCodeNomadConfig, type CodeNomadConfig, type PluginEvent } from "./request.js" export function createCodeNomadClient(config: CodeNomadConfig) { const requester = createCodeNomadRequester(config) diff --git a/packages/opencode-config/plugin/lib/request.ts b/packages/opencode-plugin/plugin/lib/request.ts similarity index 100% rename from packages/opencode-config/plugin/lib/request.ts rename to packages/opencode-plugin/plugin/lib/request.ts diff --git a/packages/opencode-plugin/tsconfig.json b/packages/opencode-plugin/tsconfig.json new file mode 100644 index 000000000..09a866276 --- /dev/null +++ b/packages/opencode-plugin/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": false, + "outDir": "dist", + "rootDir": "plugin", + "types": ["node"] + }, + "include": ["plugin/**/*.ts"], + "exclude": ["dist", "node_modules"] +} diff --git a/packages/server/package-lock.json b/packages/server/package-lock.json index 5f97fe0c1..df79cd15f 100644 --- a/packages/server/package-lock.json +++ b/packages/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "@neuralnomads/codenomad", - "version": "0.15.0", + "version": "0.16.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@neuralnomads/codenomad", - "version": "0.15.0", + "version": "0.16.0", "license": "MIT", "dependencies": { "@fastify/cors": "^11.2.0", diff --git a/packages/server/package.json b/packages/server/package.json index b47bdf292..6639b3607 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@neuralnomads/codenomad", - "version": "0.15.0", + "version": "0.16.0", "description": "CodeNomad Server", "license": "MIT", "author": { @@ -17,10 +17,10 @@ "codenomad": "dist/bin.js" }, "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-plugin", "build:ui": "npm run build --prefix ../ui", "prepare-ui": "node ./scripts/copy-ui-dist.mjs", - "prepare-config": "node ./scripts/copy-opencode-config.mjs", + "prepare-plugin": "node ./scripts/package-opencode-plugin.mjs", "dev": "cross-env CODENOMAD_DEV=1 CODENOMAD_SERVER_PASSWORD=codenomad-dev CLI_UI_DEV_SERVER=http://localhost:3000 CLI_HTTPS=false CLI_HTTP=true tsx src/index.ts", "typecheck": "tsc --noEmit -p tsconfig.json" }, diff --git a/packages/server/scripts/copy-opencode-config.mjs b/packages/server/scripts/copy-opencode-config.mjs deleted file mode 100644 index 5e7143956..000000000 --- a/packages/server/scripts/copy-opencode-config.mjs +++ /dev/null @@ -1,61 +0,0 @@ -#!/usr/bin/env node -import { spawnSync } from "child_process" -import { cpSync, existsSync, mkdirSync, rmSync } from "fs" -import path from "path" -import { fileURLToPath } from "url" - -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) -const cliRoot = path.resolve(__dirname, "..") -const sourceDir = path.resolve(cliRoot, "../opencode-config") -const targetDir = path.resolve(cliRoot, "dist/opencode-config") -const nodeModulesDir = path.resolve(sourceDir, "node_modules") -const selfLinkDir = path.resolve(nodeModulesDir, "@codenomad", "opencode-config") -const npmExecPath = process.env.npm_execpath -const npmNodeExecPath = process.env.npm_node_execpath - -if (!existsSync(sourceDir)) { - console.error(`[copy-opencode-config] Missing source directory at ${sourceDir}`) - process.exit(1) -} - -if (!existsSync(nodeModulesDir)) { - console.log(`[copy-opencode-config] Installing opencode-config dependencies in ${sourceDir}`) - - const npmArgs = [ - "install", - "--prefix", - sourceDir, - "--omit=dev", - "--ignore-scripts", - "--fund=false", - "--audit=false", - "--package-lock=false", - "--workspaces=false", - ] - - const env = { ...process.env, npm_config_workspaces: "false" } - - const npmCli = npmExecPath && npmNodeExecPath ? [npmNodeExecPath, [npmExecPath, ...npmArgs]] : null - const result = npmCli - ? spawnSync(npmCli[0], npmCli[1], { cwd: sourceDir, stdio: "inherit", env }) - : spawnSync("npm", npmArgs, { cwd: sourceDir, stdio: "inherit", env, shell: process.platform === "win32" }) - - if (result.status !== 0) { - if (result.error) { - console.error("[copy-opencode-config] npm install failed to start", result.error) - } - console.error("[copy-opencode-config] Failed to install opencode-config dependencies") - process.exit(result.status ?? 1) - } -} - -// npm can create a self-referential link for scoped packages on Windows. -// That link causes recursive copies (ELOOP) during bundling. -rmSync(selfLinkDir, { recursive: true, force: true }) - -rmSync(targetDir, { recursive: true, force: true }) -mkdirSync(path.dirname(targetDir), { recursive: true }) -cpSync(sourceDir, targetDir, { recursive: true }) - -console.log(`[copy-opencode-config] Copied ${sourceDir} -> ${targetDir}`) diff --git a/packages/server/scripts/package-opencode-plugin.mjs b/packages/server/scripts/package-opencode-plugin.mjs new file mode 100644 index 000000000..319905476 --- /dev/null +++ b/packages/server/scripts/package-opencode-plugin.mjs @@ -0,0 +1,59 @@ +#!/usr/bin/env node +import { readdirSync, renameSync, rmSync, mkdirSync } 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 serverRoot = path.resolve(__dirname, "..") +const workspaceRoot = path.resolve(serverRoot, "../..") +const pluginRoot = path.resolve(serverRoot, "../opencode-plugin") +const targetDir = path.resolve(serverRoot, "dist/opencode-plugin") +const targetTarballName = "codenomad-opencode-plugin.tgz" +const pluginWorkspace = "@codenomad/codenomad-opencode-plugin" +const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm" + +function run(command, args, options) { + const result = spawnSync(command, args, { + stdio: options?.capture ? ["ignore", "pipe", "inherit"] : "inherit", + shell: process.platform === "win32", + encoding: "utf8", + ...options, + }) + + if (result.error) { + console.error(`[package-opencode-plugin] ${command} failed to start`, result.error) + process.exit(1) + } + + if (result.status !== 0) { + console.error(`[package-opencode-plugin] ${command} exited with code ${result.status ?? 1}`) + process.exit(result.status ?? 1) + } + + return result.stdout ?? "" +} + +rmSync(targetDir, { recursive: true, force: true }) +mkdirSync(targetDir, { recursive: true }) + +console.log(`[package-opencode-plugin] Building ${pluginWorkspace}`) +run(npmCommand, ["run", "build", "--workspace", pluginWorkspace], { cwd: workspaceRoot }) + +console.log(`[package-opencode-plugin] Packing ${pluginWorkspace}`) +run(npmCommand, ["pack", "--pack-destination", targetDir], { cwd: pluginRoot, capture: true }) + +const tarballs = readdirSync(targetDir).filter((name) => name.endsWith(".tgz")) +if (tarballs.length !== 1) { + console.error(`[package-opencode-plugin] Expected exactly one packed plugin tarball in ${targetDir}, found ${tarballs.length}`) + process.exit(1) +} + +const packedTarball = path.join(targetDir, tarballs[0]) +const targetTarball = path.join(targetDir, targetTarballName) +if (packedTarball !== targetTarball) { + renameSync(packedTarball, targetTarball) +} + +console.log(`[package-opencode-plugin] Packed ${targetTarball}`) diff --git a/packages/server/src/api-types.ts b/packages/server/src/api-types.ts index 9e5fb92ad..d591dba44 100644 --- a/packages/server/src/api-types.ts +++ b/packages/server/src/api-types.ts @@ -214,6 +214,12 @@ export interface FileSystemCreateFolderResponse { absolutePath: string } +export interface FileSystemFileContentResponse { + path: string + contents: string + encoding: "utf-8" | "base64" +} + export const WINDOWS_DRIVES_ROOT = "__drives__" export interface WorkspaceFileResponse { @@ -221,6 +227,7 @@ export interface WorkspaceFileResponse { relativePath: string /** UTF-8 file contents; binary files should be base64 encoded by the caller. */ contents: string + encoding?: "utf-8" | "base64" } export type WorkspaceFileSearchResponse = FileSystemEntry[] @@ -256,6 +263,14 @@ export interface SideCar { updatedAt: string } +export interface PreviewSession { + token: string + sessionId: string + targetUrl: string + proxyUrl: string + createdAt: string +} + export interface BinaryRecord { id: string path: string diff --git a/packages/server/src/filesystem/browser.ts b/packages/server/src/filesystem/browser.ts index cd6c2a9e8..933637e93 100644 --- a/packages/server/src/filesystem/browser.ts +++ b/packages/server/src/filesystem/browser.ts @@ -4,6 +4,7 @@ import path from "path" import { FileSystemCreateFolderResponse, FileSystemEntry, + FileSystemFileContentResponse, FileSystemListResponse, FileSystemListingMetadata, WINDOWS_DRIVES_ROOT, @@ -22,6 +23,7 @@ interface DirectoryReadOptions { } const WINDOWS_DRIVE_LETTERS = Array.from({ length: 26 }, (_, i) => String.fromCharCode(65 + i)) +const MAX_READABLE_FILE_BYTES = 5 * 1024 * 1024 export class FileSystemBrowser { private readonly root: string @@ -98,6 +100,28 @@ export class FileSystemBrowser { return fs.readFileSync(resolved, "utf-8") } + readFileBase64(relativePath: string): string { + if (this.unrestricted) { + throw new Error("readFileBase64 is not available in unrestricted mode") + } + const resolved = this.toRestrictedAbsolute(relativePath) + return fs.readFileSync(resolved).toString("base64") + } + + readFileContent(targetPath: string, options?: { encoding?: "utf-8" | "base64" }): FileSystemFileContentResponse { + const encoding = options?.encoding ?? "utf-8" + const resolved = this.unrestricted ? this.resolveUnrestrictedPath(targetPath) : this.toRestrictedAbsolute(targetPath) + const stats = fs.statSync(resolved) + if (!stats.isFile()) { + throw new Error("Selected path is not a file") + } + if (stats.size > MAX_READABLE_FILE_BYTES) { + throw new Error("Selected file is too large to attach") + } + const contents = encoding === "base64" ? fs.readFileSync(resolved).toString("base64") : fs.readFileSync(resolved, "utf-8") + return { path: targetPath, contents, encoding } + } + private listRestrictedWithMetadata(relativePath: string | undefined, includeFiles: boolean): FileSystemListResponse { const normalizedPath = this.normalizeRelativePath(relativePath) const absolutePath = this.toRestrictedAbsolute(normalizedPath) diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 17de82f7f..f4dc70fe4 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -26,6 +26,7 @@ import { resolveNetworkAddresses, resolveRemoteAddresses } from "./server/networ import { startDevReleaseMonitor } from "./releases/dev-release-monitor" import { SpeechService } from "./speech/service" import { SideCarManager } from "./sidecars/manager" +import { PreviewManager } from "./previews/manager" import { ClientConnectionManager } from "./clients/connection-manager" import { PluginChannelManager } from "./plugins/channel" import { VoiceModeManager } from "./plugins/voice-mode" @@ -340,6 +341,7 @@ async function main() { eventBus, logger: logger.child({ component: "sidecars" }), }) + const previewManager = new PreviewManager() const instanceEventBridge = new InstanceEventBridge({ workspaceManager, eventBus, @@ -435,6 +437,7 @@ async function main() { instanceStore, speechService, sidecarManager, + previewManager, authManager, clientConnectionManager, pluginChannel, @@ -461,6 +464,7 @@ async function main() { instanceStore, speechService, sidecarManager, + previewManager, authManager, clientConnectionManager, pluginChannel, diff --git a/packages/server/src/opencode-config.ts b/packages/server/src/opencode-config.ts deleted file mode 100644 index f61cb9ca6..000000000 --- a/packages/server/src/opencode-config.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { existsSync } from "fs" -import path from "path" -import { fileURLToPath } from "url" -import { createLogger } from "./logger" - -const log = createLogger({ component: "opencode-config" }) -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) -const devTemplateDir = path.resolve(__dirname, "../../opencode-config") -const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath -const prodTemplateDirs = [ - resourcesPath ? path.resolve(resourcesPath, "opencode-config") : undefined, - path.resolve(__dirname, "opencode-config"), -].filter((dir): dir is string => Boolean(dir)) - -const isDevBuild = Boolean(process.env.CODENOMAD_DEV ?? process.env.CLI_UI_DEV_SERVER) || existsSync(devTemplateDir) -const templateDir = isDevBuild - ? devTemplateDir - : prodTemplateDirs.find((dir) => existsSync(dir)) ?? prodTemplateDirs[0] - -export function getOpencodeConfigDir(): string { - if (!existsSync(templateDir)) { - throw new Error(`CodeNomad Opencode config template missing at ${templateDir}`) - } - - if (isDevBuild) { - log.debug({ templateDir }, "Using Opencode config template directly (dev mode)") - } - - return templateDir -} diff --git a/packages/server/src/opencode-plugin.test.ts b/packages/server/src/opencode-plugin.test.ts new file mode 100644 index 000000000..dda5f9881 --- /dev/null +++ b/packages/server/src/opencode-plugin.test.ts @@ -0,0 +1,38 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" + +import { buildOpencodeConfigContent } from "./opencode-plugin" + +describe("buildOpencodeConfigContent", () => { + it("creates config content with the CodeNomad plugin", () => { + const content = buildOpencodeConfigContent(undefined, "file:///plugin.tgz") + + assert.deepEqual(JSON.parse(content), { + "$schema": "https://opencode.ai/config.json", + plugin: ["file:///plugin.tgz"], + }) + }) + + it("merges with existing JSONC content", () => { + const content = buildOpencodeConfigContent( + `{ + // user plugin + "plugin": ["npm:user-plugin",], + "model": "test-model", + }`, + "file:///plugin.tgz", + ) + + assert.deepEqual(JSON.parse(content), { + "$schema": "https://opencode.ai/config.json", + plugin: ["npm:user-plugin", "file:///plugin.tgz"], + model: "test-model", + }) + }) + + it("does not duplicate the CodeNomad plugin", () => { + const content = buildOpencodeConfigContent('{"plugin":["file:///plugin.tgz"]}', "file:///plugin.tgz") + + assert.deepEqual(JSON.parse(content).plugin, ["file:///plugin.tgz"]) + }) +}) diff --git a/packages/server/src/opencode-plugin.ts b/packages/server/src/opencode-plugin.ts new file mode 100644 index 000000000..761292da5 --- /dev/null +++ b/packages/server/src/opencode-plugin.ts @@ -0,0 +1,175 @@ +import { existsSync, readdirSync } from "fs" +import path from "path" +import { fileURLToPath, pathToFileURL } from "url" +import { createLogger } from "./logger" + +const log = createLogger({ component: "opencode-plugin" }) +const pluginPackageName = "@codenomad/codenomad-opencode-plugin" +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) +const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath +const devPluginEntry = path.resolve(__dirname, "../../opencode-plugin/plugin/codenomad.ts") +const prodPluginDirs = [ + resourcesPath ? path.resolve(resourcesPath, "opencode-plugin") : undefined, + resourcesPath ? path.resolve(resourcesPath, "server/dist/opencode-plugin") : undefined, + path.resolve(__dirname, "opencode-plugin"), +].filter((dir): dir is string => Boolean(dir)) + +const isDevBuild = Boolean( + process.env.CODENOMAD_DEV ?? + process.env.CLI_UI_DEV_SERVER ?? + process.env.VITE_DEV_SERVER_URL ?? + process.env.ELECTRON_RENDERER_URL, +) +const isSourceRun = path.basename(__dirname) === "src" && existsSync(devPluginEntry) + +export function getCodeNomadPluginUrl(): string { + if (isDevBuild || isSourceRun) { + if (!existsSync(devPluginEntry)) { + throw new Error(`CodeNomad OpenCode plugin entry missing at ${devPluginEntry}`) + } + + log.debug({ pluginEntry: devPluginEntry }, "Using OpenCode plugin source directly (dev mode)") + return pathToFileURL(devPluginEntry).href + } + + for (const dir of prodPluginDirs) { + const tarball = findPluginTarball(dir) + if (tarball) { + return toNpmFileSpecifier(tarball) + } + } + + throw new Error(`CodeNomad OpenCode plugin package missing in ${prodPluginDirs.join(", ")}`) +} + +export function buildOpencodeConfigContent(existingContent: string | undefined, pluginUrl: string): string { + const config = existingContent?.trim() ? parseJsoncObject(existingContent) : {} + const existingPlugins = normalizePluginEntries(config.plugin) + if (!existingPlugins.includes(pluginUrl)) { + existingPlugins.push(pluginUrl) + } + return JSON.stringify( + { + "$schema": typeof config["$schema"] === "string" ? config["$schema"] : "https://opencode.ai/config.json", + ...config, + plugin: existingPlugins, + }, + null, + 2, + ) +} + +export function resolveExistingOpencodeConfigContent(userEnvironment: Record): string | undefined { + const userValue = normalizeConfigContentValue(userEnvironment.OPENCODE_CONFIG_CONTENT) + if (userValue) { + return userValue + } + return normalizeConfigContentValue(process.env.OPENCODE_CONFIG_CONTENT) +} + +function toNpmFileSpecifier(filePath: string): string { + return `${pluginPackageName}@file:${filePath.replace(/\\/g, "/")}` +} + +function findPluginTarball(dir: string): string | null { + if (!existsSync(dir)) { + return null + } + + const tarballs = readdirSync(dir) + .filter((name) => name.endsWith(".tgz")) + .sort() + return tarballs.length > 0 ? path.resolve(dir, tarballs[tarballs.length - 1]) : null +} + +function normalizeConfigContentValue(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value : undefined +} + +function parseJsoncObject(content: string): Record { + try { + const parsed = JSON.parse(stripJsonc(content)) + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("OPENCODE_CONFIG_CONTENT must be a JSON object") + } + return parsed as Record + } catch (error) { + const reason = error instanceof Error ? error.message : String(error) + throw new Error(`Failed to parse OPENCODE_CONFIG_CONTENT: ${reason}`) + } +} + +function normalizePluginEntries(value: unknown): string[] { + if (value === undefined) { + return [] + } + if (typeof value === "string") { + return [value] + } + if (Array.isArray(value) && value.every((item) => typeof item === "string")) { + return [...value] + } + throw new Error("OPENCODE_CONFIG_CONTENT plugin field must be a string or string array") +} + +function stripJsonc(input: string): string { + let output = "" + let inString = false + let escape = false + + for (let index = 0; index < input.length; index += 1) { + const char = input[index] + const next = input[index + 1] + + if (escape) { + output += char + escape = false + continue + } + + if (char === "\\" && inString) { + output += char + escape = true + continue + } + + if (char === '"') { + output += char + inString = !inString + continue + } + + if (!inString && char === "/" && next === "/") { + while (index < input.length && input[index] !== "\n") { + index += 1 + } + output += "\n" + continue + } + + if (!inString && char === "/" && next === "*") { + index += 2 + while (index < input.length && !(input[index] === "*" && input[index + 1] === "/")) { + output += input[index] === "\n" ? "\n" : "" + index += 1 + } + index += 1 + continue + } + + if (!inString && char === ",") { + let lookahead = index + 1 + while (lookahead < input.length && /\s/.test(input[lookahead])) { + lookahead += 1 + } + if (input[lookahead] === "}" || input[lookahead] === "]") { + continue + } + } + + output += char + } + + return output +} diff --git a/packages/server/src/previews/manager.ts b/packages/server/src/previews/manager.ts new file mode 100644 index 000000000..7bf2396af --- /dev/null +++ b/packages/server/src/previews/manager.ts @@ -0,0 +1,77 @@ +import { randomUUID } from "crypto" +import type { PreviewSession } from "../api-types" + +interface PreviewRecord { + token: string + sessionId: string + target: URL + createdAt: string +} + +export class PreviewManager { + private readonly previews = new Map() + + create(sessionId: string, rawUrl: string): PreviewSession { + const target = this.normalizeTargetUrl(rawUrl) + const token = randomUUID() + const record: PreviewRecord = { + token, + sessionId, + target, + createdAt: new Date().toISOString(), + } + this.previews.set(token, record) + return this.toPreviewSession(record) + } + + get(token: string): PreviewSession | undefined { + const record = this.previews.get(token) + return record ? this.toPreviewSession(record) : undefined + } + + delete(token: string): boolean { + return this.previews.delete(token) + } + + buildTargetUrl(token: string, incomingPath: string, search = ""): URL | undefined { + const record = this.previews.get(token) + if (!record) return undefined + + const publicBase = this.buildProxyBasePath(token) + let targetPath = incomingPath.startsWith(publicBase) ? incomingPath.slice(publicBase.length) : incomingPath + if (!targetPath || targetPath === "/") { + targetPath = record.target.pathname || "/" + } else if (!targetPath.startsWith("/")) { + targetPath = `/${targetPath}` + } + + return new URL(`${targetPath}${search}`, record.target.origin) + } + + buildProxyBasePath(token: string): string { + return `/previews/${encodeURIComponent(token)}` + } + + private normalizeTargetUrl(rawUrl: string): URL { + const trimmed = rawUrl.trim() + const withProtocol = /^[a-z][a-z0-9+.-]*:/i.test(trimmed) ? trimmed : `https://${trimmed}` + const target = new URL(withProtocol) + if (target.protocol !== "http:" && target.protocol !== "https:") { + throw new Error("Preview URL must use HTTP or HTTPS") + } + if (target.username || target.password) { + throw new Error("Preview URL cannot include credentials") + } + return target + } + + private toPreviewSession(record: PreviewRecord): PreviewSession { + return { + token: record.token, + sessionId: record.sessionId, + targetUrl: record.target.toString(), + proxyUrl: `${this.buildProxyBasePath(record.token)}${record.target.pathname}${record.target.search}${record.target.hash}`, + createdAt: record.createdAt, + } + } +} diff --git a/packages/server/src/server/http-server.ts b/packages/server/src/server/http-server.ts index cf7dae364..faa53d3fd 100644 --- a/packages/server/src/server/http-server.ts +++ b/packages/server/src/server/http-server.ts @@ -6,7 +6,7 @@ import fs from "fs" import { connect as connectTcp, type Socket } from "net" import path from "path" import { connect as connectTls, type TLSSocket } from "tls" -import { fetch } from "undici" +import { fetch, type Headers } from "undici" import type { Logger } from "../logger" import { WorkspaceManager } from "../workspaces/manager" import { isValidWorktreeSlug, listWorktrees, resolveRepoRoot } from "../workspaces/git-worktrees" @@ -28,6 +28,7 @@ import { registerSpeechRoutes } from "./routes/speech" import { registerRemoteServerRoutes } from "./routes/remote-servers" import { registerRemoteProxyRoutes } from "./routes/remote-proxy" import { registerSideCarRoutes } from "./routes/sidecars" +import { registerPreviewRoutes } from "./routes/previews" import { ServerMeta } from "../api-types" import { InstanceStore } from "../storage/instance-store" import { BackgroundProcessManager } from "../background-processes/manager" @@ -39,6 +40,7 @@ import { ClientConnectionManager } from "../clients/connection-manager" import { PluginChannelManager } from "../plugins/channel" import { VoiceModeManager } from "../plugins/voice-mode" import type { SideCarManager } from "../sidecars/manager" +import type { PreviewManager } from "../previews/manager" import type { RemoteProxySessionManager } from "./remote-proxy" interface HttpServerDeps { @@ -56,6 +58,7 @@ interface HttpServerDeps { instanceStore: InstanceStore speechService: SpeechService sidecarManager: SideCarManager + previewManager: PreviewManager authManager: AuthManager clientConnectionManager: ClientConnectionManager pluginChannel: PluginChannelManager @@ -214,7 +217,7 @@ export function createHttpServer(deps: HttpServerDeps) { const session = deps.authManager.getSessionFromRequest(request) - const requiresAuthForApi = pathname.startsWith("/api/") || pathname.startsWith("/workspaces/") || pathname.startsWith("/sidecars/") + const requiresAuthForApi = pathname.startsWith("/api/") || pathname.startsWith("/workspaces/") || pathname.startsWith("/sidecars/") || pathname.startsWith("/previews/") if (requiresAuthForApi && !session) { // Allow OpenCode plugin -> CodeNomad calls with per-instance basic auth. const pluginMatch = pathname.match(/^\/workspaces\/([^/]+)\/plugin(?:\/|$)/) @@ -285,12 +288,19 @@ export function createHttpServer(deps: HttpServerDeps) { registerRemoteProxyRoutes(app, { logger: proxyLogger, sessionManager: deps.remoteProxySessionManager }) registerSpeechRoutes(app, { speechService: deps.speechService }) registerSideCarRoutes(app, { sidecarManager: deps.sidecarManager }) + registerPreviewRoutes(app, { previewManager: deps.previewManager }) registerSideCarProxyRoutes(app, { sidecarManager: deps.sidecarManager, logger: proxyLogger }) + registerPreviewProxyRoutes(app, { previewManager: deps.previewManager, logger: proxyLogger }) setupSideCarWebSocketProxy(app, { sidecarManager: deps.sidecarManager, authManager: deps.authManager, logger: proxyLogger, }) + setupPreviewWebSocketProxy(app, { + previewManager: deps.previewManager, + authManager: deps.authManager, + logger: proxyLogger, + }) registerPluginRoutes(app, { workspaceManager: deps.workspaceManager, eventBus: deps.eventBus, @@ -303,9 +313,9 @@ export function createHttpServer(deps: HttpServerDeps) { if (deps.uiDevServerUrl) { - setupDevProxy(app, deps.uiDevServerUrl, deps.authManager) + setupDevProxy(app, deps.uiDevServerUrl, deps.authManager, deps.previewManager, proxyLogger) } else { - setupStaticUi(app, deps.uiStaticDir, deps.authManager) + setupStaticUi(app, deps.uiStaticDir, deps.authManager, deps.previewManager, proxyLogger) } return { @@ -381,6 +391,15 @@ interface SideCarWebSocketProxyDeps extends SideCarProxyDeps { authManager: AuthManager } +interface PreviewProxyDeps { + previewManager: PreviewManager + logger: Logger +} + +interface PreviewWebSocketProxyDeps extends PreviewProxyDeps { + authManager: AuthManager +} + function registerSideCarProxyRoutes(app: FastifyInstance, deps: SideCarProxyDeps) { const proxyBaseHandler = async ( request: FastifyRequest<{ Params: { id: string } }>, @@ -412,6 +431,37 @@ function registerSideCarProxyRoutes(app: FastifyInstance, deps: SideCarProxyDeps app.all("/sidecars/:id/*", proxyWildcardHandler) } +function registerPreviewProxyRoutes(app: FastifyInstance, deps: PreviewProxyDeps) { + const proxyBaseHandler = async ( + request: FastifyRequest<{ Params: { token: string } }>, + reply: FastifyReply, + ) => { + await proxyPreviewRequest({ + request, + reply, + previewManager: deps.previewManager, + logger: deps.logger, + pathSuffix: "", + }) + } + + const proxyWildcardHandler = async ( + request: FastifyRequest<{ Params: { token: string; "*": string } }>, + reply: FastifyReply, + ) => { + await proxyPreviewRequest({ + request, + reply, + previewManager: deps.previewManager, + logger: deps.logger, + pathSuffix: request.params["*"] ?? "", + }) + } + + app.all("/previews/:token", proxyBaseHandler) + app.all("/previews/:token/*", proxyWildcardHandler) +} + function setupSideCarWebSocketProxy(app: FastifyInstance, deps: SideCarWebSocketProxyDeps) { app.server.on("upgrade", (request, socket, head) => { const rawUrl = request.url ?? "/" @@ -434,6 +484,28 @@ function setupSideCarWebSocketProxy(app: FastifyInstance, deps: SideCarWebSocket }) } +function setupPreviewWebSocketProxy(app: FastifyInstance, deps: PreviewWebSocketProxyDeps) { + app.server.on("upgrade", (request, socket, head) => { + const rawUrl = request.url ?? "/" + const parsed = parsePreviewUpgradePath(rawUrl) + if (!parsed) { + return + } + + void proxyPreviewWebSocketUpgrade({ + request, + socket: socket as Socket, + head, + token: parsed.token, + incomingPath: parsed.pathname, + search: parsed.search, + previewManager: deps.previewManager, + authManager: deps.authManager, + logger: deps.logger, + }) + }) +} + function registerInstanceProxyRoutes(app: FastifyInstance, deps: InstanceProxyDeps) { app.register(async (instance) => { instance.removeAllContentTypeParsers() @@ -770,7 +842,13 @@ function normalizeInstanceSuffix(pathSuffix: string | undefined) { return trimmed.length === 0 ? "/" : `/${trimmed}` } -function setupStaticUi(app: FastifyInstance, uiDir: string, authManager: AuthManager) { +function setupStaticUi( + app: FastifyInstance, + uiDir: string, + authManager: AuthManager, + previewManager: PreviewManager, + logger: Logger, +) { if (!uiDir) { app.log.warn("UI static directory not provided; API endpoints only") return @@ -781,6 +859,14 @@ function setupStaticUi(app: FastifyInstance, uiDir: string, authManager: AuthMan return } + app.addHook("preHandler", (request, reply, done) => { + const session = authManager.getSessionFromRequest(request) + if (session && proxyPreviewFallbackFromReferer(request, reply, previewManager, logger)) { + return + } + done() + }) + app.register(fastifyStatic, { root: uiDir, prefix: "/", @@ -797,6 +883,10 @@ function setupStaticUi(app: FastifyInstance, uiDir: string, authManager: AuthMan } const session = authManager.getSessionFromRequest(request) + if (session && proxyPreviewFallbackFromReferer(request, reply, previewManager, logger)) { + return + } + if (!session && wantsHtml(request)) { reply.redirect("/login") return @@ -810,7 +900,13 @@ function setupStaticUi(app: FastifyInstance, uiDir: string, authManager: AuthMan }) } -function setupDevProxy(app: FastifyInstance, upstreamBase: string, authManager: AuthManager) { +function setupDevProxy( + app: FastifyInstance, + upstreamBase: string, + authManager: AuthManager, + previewManager: PreviewManager, + logger: Logger, +) { app.log.info({ upstreamBase }, "Proxying UI requests to development server") app.setNotFoundHandler((request: FastifyRequest, reply: FastifyReply) => { const url = request.raw.url ?? "" @@ -820,6 +916,10 @@ function setupDevProxy(app: FastifyInstance, upstreamBase: string, authManager: } const session = authManager.getSessionFromRequest(request) + if (session && proxyPreviewFallbackFromReferer(request, reply, previewManager, logger)) { + return + } + if (!session && wantsHtml(request)) { reply.redirect("/login") return @@ -829,6 +929,49 @@ function setupDevProxy(app: FastifyInstance, upstreamBase: string, authManager: }) } +function proxyPreviewFallbackFromReferer( + request: FastifyRequest, + reply: FastifyReply, + previewManager: PreviewManager, + logger: Logger, +): boolean { + const rawUrl = request.raw.url ?? request.url ?? "" + const pathname = rawUrl.split("?")[0] ?? "" + if (!isPreviewFallbackPath(pathname)) { + return false + } + + const refererHeader = request.headers.referer ?? request.headers.referrer + const referer = Array.isArray(refererHeader) ? refererHeader[0] : refererHeader + if (!referer) { + return false + } + + const parsed = parsePreviewUpgradePath(referer) + if (!parsed) { + return false + } + + void proxyPreviewAssetRequest({ + request, + reply, + previewManager, + logger, + token: parsed.token, + }) + return true +} + +function isPreviewFallbackPath(pathname: string): boolean { + if (!pathname || pathname === "/") return false + if (pathname.startsWith("/api/") || pathname === "/api") return false + if (pathname.startsWith("/workspaces/")) return false + if (pathname.startsWith("/sidecars/")) return false + if (pathname.startsWith("/previews/")) return false + if (pathname.startsWith("/auth/") || pathname === "/login") return false + return true +} + async function proxyToDevServer(request: FastifyRequest, reply: FastifyReply, upstreamBase: string) { try { const targetUrl = new URL(request.raw.url ?? "/", upstreamBase) @@ -873,6 +1016,82 @@ function buildProxyHeaders(headers: FastifyRequest["headers"]): Record { + const sanitized = sanitizeSideCarProxyRequestHeaders( + headers as Record, + targetOrigin, + ) + const result: Record = {} + for (const [key, value] of Object.entries(sanitized)) { + if (!value) continue + if (key.toLowerCase() === "cookie") continue + result[key] = Array.isArray(value) ? value.join(",") : value + } + return result +} + +function headersToRecord(headers: Headers): Record { + const result: Record = {} + headers.forEach((value, key) => { + result[key.toLowerCase()] = value + }) + return result +} + +function getHeaderValue(headers: Record, key: string): string | undefined { + const value = headers[key.toLowerCase()] + return Array.isArray(value) ? value[0] : value +} + +function shouldForwardRequestBody(method: string): boolean { + const normalized = method.toUpperCase() + return normalized !== "GET" && normalized !== "HEAD" +} + +function isHtmlContentType(contentType: string): boolean { + const normalized = contentType.toLowerCase() + return normalized.includes("text/html") || normalized.includes("application/xhtml+xml") +} + +function isCssContentType(contentType: string): boolean { + return contentType.toLowerCase().includes("text/css") +} + +function rewritePreviewBodyUrls(body: string, publicBase: string, kind: "html" | "css"): string { + if (kind === "css") { + return rewriteCssPreviewUrls(body, publicBase) + } + + return rewriteCssPreviewUrls( + body + .replace(/\b(src|href|action|poster|data)=(["'])\/(?!\/)([^"']*)\2/gi, (_match, attr: string, quote: string, pathValue: string) => { + return `${attr}=${quote}${publicBase}/${pathValue}${quote}` + }) + .replace(/\bsrcset=(["'])([^"']*)\1/gi, (_match, quote: string, value: string) => { + return `srcset=${quote}${rewriteSrcsetPreviewUrls(value, publicBase)}${quote}` + }), + publicBase, + ) +} + +function rewriteCssPreviewUrls(body: string, publicBase: string): string { + return body.replace(/url\((\s*)(["']?)\/(?!\/)([^"')]+)\2(\s*)\)/gi, (_match, before: string, quote: string, pathValue: string, after: string) => { + return `url(${before}${quote}${publicBase}/${pathValue}${quote}${after})` + }) +} + +function rewriteSrcsetPreviewUrls(value: string, publicBase: string): string { + return value + .split(",") + .map((entry) => { + const trimmed = entry.trimStart() + if (!trimmed.startsWith("/") || trimmed.startsWith("//")) return entry + const leading = entry.slice(0, entry.length - trimmed.length) + return `${leading}${publicBase}${trimmed}` + }) + .join(",") +} + async function proxySideCarRequest(args: { request: FastifyRequest reply: FastifyReply @@ -897,14 +1116,154 @@ async function proxySideCarRequest(args: { const targetUrl = `${targetOrigin}${targetPath}` args.logger.debug({ sidecarId: sidecar.id, targetUrl, pathname, prefixMode: sidecar.prefixMode }, "Proxying request to SideCar") - await args.reply.from(targetUrl, { - rewriteRequestHeaders: (_originalRequest, headers) => - sanitizeSideCarProxyRequestHeaders(headers as Record, targetOrigin), + await proxyTargetRequest({ + reply: args.reply, + logger: args.logger, + targetUrl, + targetOrigin, + logContext: { sidecarId: sidecar.id }, + errorMessage: "SideCar proxy failed", rewriteHeaders: (headers) => rewriteSideCarResponseHeaders(headers, sidecarId, targetOrigin, sidecar.prefixMode), + }) +} + +async function proxyPreviewRequest(args: { + request: FastifyRequest + reply: FastifyReply + previewManager: PreviewManager + logger: Logger + pathSuffix?: string +}) { + const token = (args.request.params as { token?: string }).token ?? "" + const preview = args.previewManager.get(token) + if (!preview) { + args.reply.code(404).send({ error: "Preview not found" }) + return + } + + const rawUrl = args.request.raw.url ?? args.request.url ?? "" + const queryIndex = rawUrl.indexOf("?") + const search = queryIndex >= 0 ? rawUrl.slice(queryIndex) : "" + const pathSuffix = args.pathSuffix ?? "" + const requestPath = pathSuffix ? `${args.previewManager.buildProxyBasePath(token)}/${pathSuffix.replace(/^\/+/, "")}` : args.previewManager.buildProxyBasePath(token) + const targetUrl = args.previewManager.buildTargetUrl(token, requestPath, search) + if (!targetUrl) { + args.reply.code(404).send({ error: "Preview not found" }) + return + } + + args.logger.debug({ previewToken: token, targetUrl: targetUrl.toString() }, "Proxying request to preview") + await proxyPreviewTargetRequest({ + request: args.request, + reply: args.reply, + logger: args.logger, + targetUrl: targetUrl.toString(), + targetOrigin: targetUrl.origin, + publicBase: args.previewManager.buildProxyBasePath(token), + logContext: { previewToken: token }, + errorMessage: "Preview proxy failed", + rewriteHeaders: (headers) => rewritePreviewResponseHeaders(headers, token, targetUrl.origin), + }) +} + +async function proxyPreviewAssetRequest(args: { + request: FastifyRequest + reply: FastifyReply + previewManager: PreviewManager + logger: Logger + token: string +}) { + const rawUrl = args.request.raw.url ?? args.request.url ?? "" + const queryIndex = rawUrl.indexOf("?") + const search = queryIndex >= 0 ? rawUrl.slice(queryIndex) : "" + const pathname = rawUrl.split("?")[0] ?? "/" + const targetUrl = args.previewManager.buildTargetUrl(args.token, pathname, search) + if (!targetUrl) { + args.reply.code(404).send({ error: "Preview not found" }) + return + } + + args.logger.debug({ previewToken: args.token, targetUrl: targetUrl.toString() }, "Proxying preview fallback asset") + await proxyPreviewTargetRequest({ + request: args.request, + reply: args.reply, + logger: args.logger, + targetUrl: targetUrl.toString(), + targetOrigin: targetUrl.origin, + publicBase: args.previewManager.buildProxyBasePath(args.token), + logContext: { previewToken: args.token, previewFallback: true }, + errorMessage: "Preview proxy failed", + rewriteHeaders: (headers) => rewritePreviewResponseHeaders(headers, args.token, targetUrl.origin), + }) +} + +async function proxyPreviewTargetRequest(args: { + request: FastifyRequest + reply: FastifyReply + logger: Logger + targetUrl: string + targetOrigin: string + publicBase: string + logContext: Record + errorMessage: string + rewriteHeaders: (headers: Record) => Record +}) { + try { + const response = await fetch(args.targetUrl, { + method: args.request.method, + headers: buildFetchProxyHeaders(args.request.headers, args.targetOrigin), + body: shouldForwardRequestBody(args.request.method) ? (args.request.raw as any) : undefined, + duplex: shouldForwardRequestBody(args.request.method) ? "half" : undefined, + redirect: "manual", + } as any) + + const headers = args.rewriteHeaders(headersToRecord(response.headers)) + const contentType = getHeaderValue(headers, "content-type") ?? response.headers.get("content-type") ?? "" + delete headers["content-length"] + delete headers["content-encoding"] + + for (const [key, value] of Object.entries(headers)) { + if (value !== undefined) args.reply.header(key, value) + } + args.reply.code(response.status) + + if (!response.body || args.request.method === "HEAD") { + args.reply.send() + return + } + + if (isHtmlContentType(contentType) || isCssContentType(contentType)) { + const text = await response.text() + args.reply.send(rewritePreviewBodyUrls(text, args.publicBase, isCssContentType(contentType) ? "css" : "html")) + return + } + + args.reply.send(Buffer.from(await response.arrayBuffer())) + } catch (error) { + args.logger.error({ ...args.logContext, err: error, targetUrl: args.targetUrl }, args.errorMessage) + if (!args.reply.sent) { + args.reply.code(502).send({ error: args.errorMessage }) + } + } +} + +async function proxyTargetRequest(args: { + reply: FastifyReply + logger: Logger + targetUrl: string + targetOrigin: string + logContext: Record + errorMessage: string + rewriteHeaders: (headers: Record) => Record +}) { + await args.reply.from(args.targetUrl, { + rewriteRequestHeaders: (_originalRequest, headers) => + sanitizeSideCarProxyRequestHeaders(headers as Record, args.targetOrigin), + rewriteHeaders: args.rewriteHeaders, onError: (reply, { error }) => { - args.logger.error({ sidecarId: sidecar.id, err: error, targetUrl }, "Failed to proxy SideCar request") + args.logger.error({ ...args.logContext, err: error, targetUrl: args.targetUrl }, args.errorMessage) if (!reply.sent) { - reply.code(502).send({ error: "SideCar proxy failed" }) + reply.code(502).send({ error: args.errorMessage }) } }, }) @@ -934,6 +1293,30 @@ function parseSideCarUpgradePath(rawUrl: string): { sidecarId: string; pathname: } } +function parsePreviewUpgradePath(rawUrl: string): { token: string; pathname: string; search: string } | null { + let parsed: URL + try { + parsed = new URL(rawUrl, "http://localhost") + } catch { + return null + } + + const match = parsed.pathname.match(/^\/previews\/([^/]+)(?:\/.*)?$/) + if (!match) { + return null + } + + try { + return { + token: decodeURIComponent(match[1] ?? ""), + pathname: parsed.pathname, + search: parsed.search, + } + } catch { + return null + } +} + async function proxySideCarWebSocketUpgrade(args: { request: import("http").IncomingMessage socket: Socket @@ -969,6 +1352,71 @@ async function proxySideCarWebSocketUpgrade(args: { const targetUrl = new URL(`${targetOrigin}${targetPath}`) logger.debug({ sidecarId, targetUrl: targetUrl.toString(), prefixMode: sidecar.prefixMode }, "Proxying websocket to SideCar") + proxyTargetWebSocketUpgrade({ + request, + socket, + head, + targetUrl, + logger, + logContext: { sidecarId }, + proxyLabel: "SideCar", + }) +} + +async function proxyPreviewWebSocketUpgrade(args: { + request: import("http").IncomingMessage + socket: Socket + head: Buffer + token: string + incomingPath: string + search: string + previewManager: PreviewManager + authManager: AuthManager + logger: Logger +}) { + const { request, socket, head, token, incomingPath, search, previewManager, authManager, logger } = args + + if (!isWebSocketUpgradeRequest(request)) { + rejectUpgrade(socket, 400, "Bad Request") + return + } + + const session = authManager.getSessionFromHeaders(request.headers) + if (!session) { + rejectUpgrade(socket, 401, "Unauthorized") + return + } + + const targetUrl = previewManager.buildTargetUrl(token, incomingPath, search) + if (!targetUrl) { + rejectUpgrade(socket, 404, "Not Found") + return + } + + logger.debug({ previewToken: token, targetUrl: targetUrl.toString() }, "Proxying websocket to preview") + proxyTargetWebSocketUpgrade({ + request, + socket, + head, + targetUrl, + logger, + logContext: { previewToken: token }, + proxyLabel: "preview", + stripCookies: true, + }) +} + +function proxyTargetWebSocketUpgrade(args: { + request: import("http").IncomingMessage + socket: Socket + head: Buffer + targetUrl: URL + logger: Logger + logContext: Record + proxyLabel: string + stripCookies?: boolean +}) { + const { request, socket, head, targetUrl, logger, logContext, proxyLabel, stripCookies } = args const { socket: upstream, readyEvent } = createSideCarUpstreamSocket(targetUrl) const closeBoth = () => { @@ -981,7 +1429,7 @@ async function proxySideCarWebSocketUpgrade(args: { } upstream.once("error", (error) => { - logger.error({ sidecarId, err: error, targetUrl: targetUrl.toString() }, "Failed to proxy SideCar websocket") + logger.error({ ...logContext, err: error, targetUrl: targetUrl.toString() }, `Failed to proxy ${proxyLabel} websocket`) rejectUpgrade(socket, 502, "Bad Gateway") if (!upstream.destroyed) { upstream.destroy() @@ -989,7 +1437,7 @@ async function proxySideCarWebSocketUpgrade(args: { }) socket.once("error", (error) => { - logger.debug({ sidecarId, err: error }, "SideCar websocket client socket errored") + logger.debug({ ...logContext, err: error }, `${proxyLabel} websocket client socket errored`) if (!upstream.destroyed) { upstream.destroy() } @@ -997,14 +1445,14 @@ async function proxySideCarWebSocketUpgrade(args: { upstream.once(readyEvent, () => { try { - upstream.write(buildSideCarWebSocketRequest(request, targetUrl)) + upstream.write(buildSideCarWebSocketRequest(request, targetUrl, { stripCookies })) if (head.length > 0) { upstream.write(head) } upstream.pipe(socket) socket.pipe(upstream) } catch (error) { - logger.error({ sidecarId, err: error, targetUrl: targetUrl.toString() }, "Failed to forward SideCar websocket upgrade") + logger.error({ ...logContext, err: error, targetUrl: targetUrl.toString() }, `Failed to forward ${proxyLabel} websocket upgrade`) closeBoth() } }) @@ -1040,7 +1488,11 @@ function createSideCarUpstreamSocket(targetUrl: URL): { socket: Socket | TLSSock } } -function buildSideCarWebSocketRequest(request: import("http").IncomingMessage, targetUrl: URL): string { +function buildSideCarWebSocketRequest( + request: import("http").IncomingMessage, + targetUrl: URL, + options?: { stripCookies?: boolean }, +): string { const pathWithQuery = `${targetUrl.pathname}${targetUrl.search}` const requestLine = `${request.method ?? "GET"} ${pathWithQuery} HTTP/${request.httpVersion}\r\n` const headerLines: string[] = [] @@ -1053,6 +1505,7 @@ function buildSideCarWebSocketRequest(request: import("http").IncomingMessage, t if (!key || value === undefined) continue const lower = key.toLowerCase() if (blockedHeaders.has(lower)) continue + if (options?.stripCookies && lower === "cookie") continue if (lower === "origin") { headerLines.push(`Origin: ${targetUrl.origin}\r\n`) continue @@ -1121,6 +1574,42 @@ function rewriteSideCarResponseHeaders( return next } +function rewritePreviewResponseHeaders( + headers: Record, + token: string, + targetOrigin: string, +) { + const next = { ...headers } + delete next["x-frame-options"] + delete next["content-security-policy"] + delete next["content-security-policy-report-only"] + delete next["set-cookie"] + delete next["set-cookie2"] + + const locationHeader = next.location + const location = Array.isArray(locationHeader) ? locationHeader[0] : locationHeader + if (!location) { + return next + } + + const publicBase = `/previews/${encodeURIComponent(token)}` + if (location.startsWith("/")) { + next.location = `${publicBase}${location}` + return next + } + + try { + const parsed = new URL(location) + if (parsed.origin === targetOrigin) { + next.location = `${publicBase}${parsed.pathname}${parsed.search}${parsed.hash}` + } + } catch { + // Relative redirects should continue to resolve against the current preview path. + } + + return next +} + function sanitizeSideCarProxyRequestHeaders( headers: Record, targetOrigin: string, diff --git a/packages/server/src/server/routes/filesystem.ts b/packages/server/src/server/routes/filesystem.ts index 4f5895f49..418157208 100644 --- a/packages/server/src/server/routes/filesystem.ts +++ b/packages/server/src/server/routes/filesystem.ts @@ -16,6 +16,11 @@ const FilesystemCreateFolderSchema = z.object({ name: z.string(), }) +const FilesystemFileContentQuerySchema = z.object({ + path: z.string(), + encoding: z.enum(["utf-8", "base64"]).optional(), +}) + export function registerFilesystemRoutes(app: FastifyInstance, deps: RouteDeps) { app.get("/api/filesystem", async (request, reply) => { const query = FilesystemQuerySchema.parse(request.query ?? {}) @@ -51,4 +56,14 @@ export function registerFilesystemRoutes(app: FastifyInstance, deps: RouteDeps) reply.code(400).type("text/plain").send((error as Error).message) } }) + + app.get("/api/filesystem/files/content", async (request, reply) => { + const query = FilesystemFileContentQuerySchema.parse(request.query ?? {}) + + try { + return deps.fileSystemBrowser.readFileContent(query.path, { encoding: query.encoding }) + } catch (error) { + reply.code(400).type("text/plain").send((error as Error).message) + } + }) } diff --git a/packages/server/src/server/routes/previews.ts b/packages/server/src/server/routes/previews.ts new file mode 100644 index 000000000..fa42a5ac5 --- /dev/null +++ b/packages/server/src/server/routes/previews.ts @@ -0,0 +1,34 @@ +import type { FastifyInstance } from "fastify" +import { z } from "zod" +import type { PreviewSession } from "../../api-types" +import type { PreviewManager } from "../../previews/manager" + +interface RouteDeps { + previewManager: PreviewManager +} + +const PreviewCreateSchema = z.object({ + sessionId: z.string().trim().min(1), + url: z.string().trim().min(1), +}) + +export function registerPreviewRoutes(app: FastifyInstance, deps: RouteDeps) { + app.post("/api/previews", async (request, reply): Promise => { + try { + const body = PreviewCreateSchema.parse(request.body ?? {}) + return deps.previewManager.create(body.sessionId, body.url) + } catch (error) { + reply.code(400) + return { error: error instanceof Error ? error.message : "Failed to create preview" } + } + }) + + app.delete<{ Params: { token: string } }>("/api/previews/:token", async (request, reply) => { + const removed = deps.previewManager.delete(request.params.token) + if (!removed) { + reply.code(404) + return { error: "Preview not found" } + } + reply.code(204) + }) +} diff --git a/packages/server/src/server/routes/workspaces.ts b/packages/server/src/server/routes/workspaces.ts index a115bf0d5..517367f56 100644 --- a/packages/server/src/server/routes/workspaces.ts +++ b/packages/server/src/server/routes/workspaces.ts @@ -28,6 +28,7 @@ const WorkspaceFilesQuerySchema = z.object({ const WorkspaceFileContentQuerySchema = z.object({ path: z.string(), + encoding: z.enum(["utf-8", "base64"]).optional(), }) const WorkspaceFileContentBodySchema = z.object({ @@ -135,7 +136,7 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) { }>("/api/workspaces/:id/files/content", async (request, reply) => { try { const query = WorkspaceFileContentQuerySchema.parse(request.query ?? {}) - return deps.workspaceManager.readFile(request.params.id, query.path) + return deps.workspaceManager.readFile(request.params.id, query.path, { encoding: query.encoding }) } catch (error) { return handleWorkspaceError(error, reply) } diff --git a/packages/server/src/workspaces/__tests__/spawn.test.ts b/packages/server/src/workspaces/__tests__/spawn.test.ts index b33aec6a8..7b829ac75 100644 --- a/packages/server/src/workspaces/__tests__/spawn.test.ts +++ b/packages/server/src/workspaces/__tests__/spawn.test.ts @@ -54,12 +54,12 @@ describe("buildWindowsSpawnSpec", () => { { cwd: String.raw`\\wsl.localhost\Ubuntu\home\dev\workspace`, env: { - OPENCODE_CONFIG_DIR: String.raw`C:\Users\dev\AppData\Roaming\CodeNomad\opencode-config`, + OPENCODE_CONFIG_CONTENT: JSON.stringify({ plugin: ["file:///C:/Users/dev/AppData/Roaming/CodeNomad/plugin.tgz"] }), CODENOMAD_INSTANCE_ID: "workspace-123", OPENCODE_SERVER_BASE_URL: "https://127.0.0.1:4321/workspaces/workspace-123/worktrees/root/instance", OPENCODE_SERVER_PASSWORD: "secret", }, - propagateEnvKeys: ["OPENCODE_CONFIG_DIR", "CODENOMAD_INSTANCE_ID", "OPENCODE_SERVER_BASE_URL", "OPENCODE_SERVER_PASSWORD"], + propagateEnvKeys: ["OPENCODE_CONFIG_CONTENT", "CODENOMAD_INSTANCE_ID", "OPENCODE_SERVER_BASE_URL", "OPENCODE_SERVER_PASSWORD"], }, ) @@ -76,23 +76,47 @@ describe("buildWindowsSpawnSpec", () => { "0", ]) assert.equal(spec.cwd, undefined) - assert.equal(spec.env?.WSLENV, "OPENCODE_CONFIG_DIR/p:CODENOMAD_INSTANCE_ID:OPENCODE_SERVER_BASE_URL:OPENCODE_SERVER_PASSWORD") + assert.equal(spec.env?.WSLENV, "OPENCODE_CONFIG_CONTENT:CODENOMAD_INSTANCE_ID:OPENCODE_SERVER_BASE_URL:OPENCODE_SERVER_PASSWORD") }) - it("upgrades existing WSLENV path entries to include /p", () => { + it("preserves non-path OPENCODE_CONFIG_CONTENT WSLENV entries", () => { const spec = buildWindowsSpawnSpec( String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`, ["serve"], { env: { - OPENCODE_CONFIG_DIR: String.raw`C:\Users\dev\AppData\Roaming\CodeNomad\opencode-config`, - WSLENV: "OPENCODE_CONFIG_DIR:CODENOMAD_INSTANCE_ID/u", + OPENCODE_CONFIG_CONTENT: JSON.stringify({ plugin: ["file:///C:/Users/dev/AppData/Roaming/CodeNomad/plugin.tgz"] }), + WSLENV: "OPENCODE_CONFIG_CONTENT:CODENOMAD_INSTANCE_ID/u", }, - propagateEnvKeys: ["OPENCODE_CONFIG_DIR", "CODENOMAD_INSTANCE_ID"], + propagateEnvKeys: ["OPENCODE_CONFIG_CONTENT", "CODENOMAD_INSTANCE_ID"], }, ) - assert.equal(spec.env?.WSLENV, "OPENCODE_CONFIG_DIR/p:CODENOMAD_INSTANCE_ID/u") + assert.equal(spec.env?.WSLENV, "OPENCODE_CONFIG_CONTENT:CODENOMAD_INSTANCE_ID/u") + }) + + it("rewrites packaged plugin paths for WSL before launching", () => { + const spec = buildWindowsSpawnSpec( + String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`, + ["serve"], + { + env: { + OPENCODE_CONFIG_CONTENT: JSON.stringify({ + plugin: [ + "@codenomad/codenomad-opencode-plugin@file:C:/Users/dev/AppData/Roaming/CodeNomad/codenomad-opencode-plugin.tgz", + ], + }), + }, + propagateEnvKeys: ["OPENCODE_CONFIG_CONTENT"], + }, + ) + + assert.equal(spec.command, "wsl.exe") + assert.equal(spec.env?.CODENOMAD_OPENCODE_PLUGIN_WSL_PATH, String.raw`C:\Users\dev\AppData\Roaming\CodeNomad\codenomad-opencode-plugin.tgz`) + assert.match(spec.env?.OPENCODE_CONFIG_CONTENT ?? "", /__CODENOMAD_OPENCODE_PLUGIN_WSL_PATH__/) + assert.equal(spec.env?.WSLENV, "OPENCODE_CONFIG_CONTENT:CODENOMAD_OPENCODE_PLUGIN_WSL_PATH/p") + assert.deepEqual(spec.args.slice(0, 4), ["--distribution", "Ubuntu", "--exec", "sh"]) + assert.match(spec.args[5] ?? "", /CODENOMAD_OPENCODE_PLUGIN_WSL_PATH/) }) it("propagates inherited known path variables even when they are not explicitly requested", () => { diff --git a/packages/server/src/workspaces/manager.ts b/packages/server/src/workspaces/manager.ts index 063c2cbb9..cd1933b2f 100644 --- a/packages/server/src/workspaces/manager.ts +++ b/packages/server/src/workspaces/manager.ts @@ -10,7 +10,11 @@ import { clearWorkspaceSearchCache } from "../filesystem/search-cache" import { WorkspaceDescriptor, WorkspaceFileResponse, FileSystemEntry } from "../api-types" import { WorkspaceRuntime, ProcessExitInfo } from "./runtime" import { Logger } from "../logger" -import { getOpencodeConfigDir } from "../opencode-config.js" +import { + buildOpencodeConfigContent, + getCodeNomadPluginUrl, + resolveExistingOpencodeConfigContent, +} from "../opencode-plugin.js" import { OPENCODE_SERVER_BASE_URL_ENV, buildOpencodeBasicAuthHeader, @@ -37,12 +41,12 @@ interface WorkspaceRecord extends WorkspaceDescriptor {} export class WorkspaceManager { private readonly workspaces = new Map() private readonly runtime: WorkspaceRuntime - private readonly opencodeConfigDir: string + private readonly codeNomadPluginUrl: string private readonly opencodeAuth = new Map() constructor(private readonly options: WorkspaceManagerOptions) { this.runtime = new WorkspaceRuntime(this.options.eventBus, this.options.logger) - this.opencodeConfigDir = getOpencodeConfigDir() + this.codeNomadPluginUrl = getCodeNomadPluginUrl() } list(): WorkspaceDescriptor[] { @@ -72,14 +76,16 @@ export class WorkspaceManager { return searchWorkspaceFiles(workspace.path, query, options) } - readFile(workspaceId: string, relativePath: string): WorkspaceFileResponse { + readFile(workspaceId: string, relativePath: string, options?: { encoding?: "utf-8" | "base64" }): WorkspaceFileResponse { const workspace = this.requireWorkspace(workspaceId) const browser = new FileSystemBrowser({ rootDir: workspace.path }) - const contents = browser.readFile(relativePath) + const encoding = options?.encoding ?? "utf-8" + const contents = encoding === "base64" ? browser.readFileBase64(relativePath) : browser.readFile(relativePath) return { workspaceId, relativePath, contents, + encoding, } } @@ -123,6 +129,10 @@ export class WorkspaceManager { const serverConfig = this.options.settings.getOwner("config", "server") const envVars = (serverConfig as any)?.environmentVariables const userEnvironment = envVars && typeof envVars === "object" && !Array.isArray(envVars) ? (envVars as any) : {} + const opencodeConfigContent = buildOpencodeConfigContent( + resolveExistingOpencodeConfigContent(userEnvironment), + this.codeNomadPluginUrl, + ) const serverBaseUrl = this.options.getServerBaseUrl() const normalizedServerBaseUrl = serverBaseUrl.replace(/\/+$/, "") @@ -138,7 +148,7 @@ export class WorkspaceManager { const environment = { ...userEnvironment, - OPENCODE_CONFIG_DIR: this.opencodeConfigDir, + OPENCODE_CONFIG_CONTENT: opencodeConfigContent, CODENOMAD_INSTANCE_ID: id, CODENOMAD_BASE_URL: serverBaseUrl, ...(this.options.nodeExtraCaCertsPath ? { NODE_EXTRA_CA_CERTS: this.options.nodeExtraCaCertsPath } : {}), diff --git a/packages/server/src/workspaces/spawn.ts b/packages/server/src/workspaces/spawn.ts index 8add4aa48..f40dcdb02 100644 --- a/packages/server/src/workspaces/spawn.ts +++ b/packages/server/src/workspaces/spawn.ts @@ -6,7 +6,13 @@ export const WINDOWS_POWERSHELL_EXTENSIONS = new Set([".ps1"]) const VERSION_REGEX = /([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/ const WSL_UNC_PATH_REGEX = /^\\\\wsl(?:\.localhost|\$)\\([^\\/]+)(?:[\\/](.*))?$/i -const WSL_PATH_ENV_KEYS = new Set(["OPENCODE_CONFIG_DIR", "NODE_EXTRA_CA_CERTS"]) +const CODENOMAD_PLUGIN_PACKAGE_NAME = "@codenomad/codenomad-opencode-plugin" +const WSL_PLUGIN_PATH_ENV = "CODENOMAD_OPENCODE_PLUGIN_WSL_PATH" +const WSL_PLUGIN_PATH_PLACEHOLDER = "__CODENOMAD_OPENCODE_PLUGIN_WSL_PATH__" +const CODENOMAD_PLUGIN_FILE_SPEC_REGEX = new RegExp( + `(${escapeRegex(CODENOMAD_PLUGIN_PACKAGE_NAME)}@file:)([A-Za-z]:[^"\\r\\n]+?\\.tgz)`, +) +const WSL_PATH_ENV_KEYS = new Set(["NODE_EXTRA_CA_CERTS", WSL_PLUGIN_PATH_ENV]) export interface SpawnSpec { command: string @@ -187,6 +193,8 @@ export function probeBinaryVersion(binaryPath: string): { function buildWslSpawnSpec(wslPath: WslPath, args: string[], options: BuildSpawnSpecOptions): SpawnSpec { const workingDirectory = options.cwd ? resolveWslWorkingDirectory(options.cwd, wslPath.distro) : undefined + const env = buildWslEnvironment(options.env, options.propagateEnvKeys) + const shouldTranslatePluginPath = Boolean(env?.[WSL_PLUGIN_PATH_ENV]) if (options.cwd && !workingDirectory) { throw new Error( `Unable to translate workspace folder for WSL binary in distro "${wslPath.distro}": ${options.cwd}`, @@ -194,14 +202,14 @@ function buildWslSpawnSpec(wslPath: WslPath, args: string[], options: BuildSpawn } const wslArgs = ["--distribution", wslPath.distro] - const shouldWrapWithShell = Boolean(options.wslPidMarker) || workingDirectory?.kind === "windows" + const shouldWrapWithShell = Boolean(options.wslPidMarker) || workingDirectory?.kind === "windows" || shouldTranslatePluginPath if (!shouldWrapWithShell && workingDirectory?.kind === "linux") { wslArgs.push("--cd", workingDirectory.path) } if (shouldWrapWithShell) { - const launchScript = buildWslLaunchScript(workingDirectory ?? undefined, options.wslPidMarker) + const launchScript = buildWslLaunchScript(workingDirectory ?? undefined, options.wslPidMarker, shouldTranslatePluginPath) wslArgs.push( "--exec", "sh", @@ -224,12 +232,16 @@ function buildWslSpawnSpec(wslPath: WslPath, args: string[], options: BuildSpawn command: "wsl.exe", args: wslArgs, options: {}, - env: buildWslEnvironment(options.env, options.propagateEnvKeys), + env, wsl: { distro: wslPath.distro, pidMarker: options.wslPidMarker }, } } -function buildWslLaunchScript(workingDirectory: WslWorkingDirectory | undefined, pidMarker: string | undefined): string { +function buildWslLaunchScript( + workingDirectory: WslWorkingDirectory | undefined, + pidMarker: string | undefined, + translatePluginPath: boolean, +): string { const steps: string[] = [] if (pidMarker) { @@ -244,6 +256,12 @@ function buildWslLaunchScript(workingDirectory: WslWorkingDirectory | undefined, steps.push("shift") } + if (translatePluginPath) { + steps.push( + `if [ -n "$${WSL_PLUGIN_PATH_ENV}" ] && [ -n "$OPENCODE_CONFIG_CONTENT" ]; then escaped_plugin_path=$(printf '%s' "$${WSL_PLUGIN_PATH_ENV}" | sed 's/[\\&|]/\\\\&/g'); OPENCODE_CONFIG_CONTENT=$(printf '%s' "$OPENCODE_CONFIG_CONTENT" | sed "s|${WSL_PLUGIN_PATH_PLACEHOLDER}|$escaped_plugin_path|g"); export OPENCODE_CONFIG_CONTENT; unset ${WSL_PLUGIN_PATH_ENV}; fi`, + ) + } + steps.push('exec "$@"') return steps.join(" && ") } @@ -266,17 +284,19 @@ function buildWslEnvironment(env: NodeJS.ProcessEnv | undefined, propagateEnvKey return env } + const next = { ...env } + rewriteOpencodePluginPathForWsl(next) + const keysToPropagate = Array.from( new Set([ - ...(propagateEnvKeys ?? []).filter((key) => env[key] !== undefined), - ...Array.from(WSL_PATH_ENV_KEYS).filter((key) => env[key] !== undefined), + ...(propagateEnvKeys ?? []).filter((key) => next[key] !== undefined), + ...Array.from(WSL_PATH_ENV_KEYS).filter((key) => next[key] !== undefined), ]), ) if (keysToPropagate.length === 0) { - return env + return next } - const next = { ...env } const entries = (next.WSLENV ?? "").split(":").filter((entry) => entry.length > 0) const byName = new Map(entries.map((entry) => [entry.split("/")[0] ?? entry, entry])) @@ -293,6 +313,22 @@ function buildWslEnvironment(env: NodeJS.ProcessEnv | undefined, propagateEnvKey return next } +function rewriteOpencodePluginPathForWsl(env: NodeJS.ProcessEnv) { + const content = env.OPENCODE_CONFIG_CONTENT + if (!content) { + return + } + + const match = content.match(CODENOMAD_PLUGIN_FILE_SPEC_REGEX) + const hostPath = match?.[2] + if (!hostPath) { + return + } + + env.OPENCODE_CONFIG_CONTENT = content.replace(hostPath, WSL_PLUGIN_PATH_PLACEHOLDER) + env[WSL_PLUGIN_PATH_ENV] = path.win32.normalize(hostPath) +} + function ensureWslenvEntry(entry: string, requiresPathTranslation: boolean): string { if (!requiresPathTranslation) { return entry @@ -305,3 +341,7 @@ function ensureWslenvEntry(entry: string, requiresPathTranslation: boolean): str return rawFlags.length > 0 ? `${name}/${rawFlags}p` : `${name}/p` } + +function escapeRegex(input: string): string { + return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") +} diff --git a/packages/tauri-app/Cargo.lock b/packages/tauri-app/Cargo.lock index 4024304ea..828f003dc 100644 --- a/packages/tauri-app/Cargo.lock +++ b/packages/tauri-app/Cargo.lock @@ -497,7 +497,7 @@ dependencies = [ [[package]] name = "codenomad-tauri" -version = "0.15.0" +version = "0.16.0" dependencies = [ "anyhow", "base64 0.22.1", diff --git a/packages/tauri-app/package.json b/packages/tauri-app/package.json index 31c0192ed..590a3a4cb 100644 --- a/packages/tauri-app/package.json +++ b/packages/tauri-app/package.json @@ -1,6 +1,6 @@ { "name": "@codenomad/tauri-app", - "version": "0.15.0", + "version": "0.16.0", "private": true, "license": "MIT", "scripts": { @@ -11,7 +11,8 @@ "sync:version": "node ./scripts/sync-tauri-version.js", "prebuild": "node ./scripts/prebuild.js", "bundle:server": "npm run prebuild", - "build": "tauri build" + "build": "tauri build", + "smoke:resources": "node ../../scripts/smoke-packaged-resources.cjs --resources src-tauri/resources --loading src-tauri/resources/ui-loading" }, "devDependencies": { "@tauri-apps/cli": "^2.9.4" diff --git a/packages/tauri-app/scripts/prebuild.js b/packages/tauri-app/scripts/prebuild.js index ba2a119c0..daccde793 100644 --- a/packages/tauri-app/scripts/prebuild.js +++ b/packages/tauri-app/scripts/prebuild.js @@ -13,13 +13,14 @@ const serverDest = path.resolve(root, "src-tauri", "resources", "server") const uiLoadingDest = path.resolve(root, "src-tauri", "resources", "ui-loading") const resourcesRoot = path.resolve(root, "src-tauri", "resources") const { prepareBundledNodeRuntime } = require(path.join(workspaceRoot, "scripts", "prepare-node-runtime.cjs")) - -const sources = ["dist", "public", "node_modules", "package.json"] +const { copyPackagedServerResources } = require(path.join(workspaceRoot, "scripts", "desktop-server-resources.cjs")) const serverInstallCommand = "npm install --omit=dev --ignore-scripts --workspaces=false --package-lock=false --install-strategy=shallow --fund=false --audit=false" const serverDevInstallCommand = "npm install --workspace @neuralnomads/codenomad --include-workspace-root=false --install-strategy=nested --fund=false --audit=false" +const pluginDevInstallCommand = + "npm install --workspace @codenomad/codenomad-opencode-plugin --include-workspace-root=false --install-strategy=nested --fund=false --audit=false" const uiDevInstallCommand = "npm install --workspace @codenomad/ui --include-workspace-root=false --install-strategy=nested --fund=false --audit=false" const serverPrepareUiCommand = "npm run prepare-ui --workspace @neuralnomads/codenomad" @@ -45,6 +46,12 @@ const serverBuildDependencyPaths = [ path.join(serverRoot, "node_modules", "@types", "yauzl", "package.json"), ] +const pluginRoot = path.resolve(root, "..", "opencode-plugin") +const pluginBuildDependencyPaths = [ + path.join(pluginRoot, "node_modules", "typescript", "package.json"), + path.join(pluginRoot, "node_modules", "@types", "node", "package.json"), +] + const viteBinPath = path.join(uiRoot, "node_modules", ".bin", "vite") async function ensureMonacoAssets() { @@ -118,6 +125,19 @@ function ensureServerDevDependencies() { }) } +function ensurePluginDevDependencies() { + if (pluginBuildDependencyPaths.every((filePath) => fs.existsSync(filePath))) { + return + } + + console.log("[prebuild] ensuring OpenCode plugin build dependencies...") + execSync(pluginDevInstallCommand, { + cwd: workspaceRoot, + stdio: "inherit", + env: envWithRootBin, + }) +} + function ensureServerDependencies() { if (fs.existsSync(braceExpansionPath)) { return @@ -227,60 +247,6 @@ function ensureEsbuildPlatformBinary() { }) } -function copyServerArtifacts() { - fs.rmSync(serverDest, { recursive: true, force: true }) - fs.mkdirSync(serverDest, { recursive: true }) - - for (const name of sources) { - const from = path.join(serverRoot, name) - const to = path.join(serverDest, name) - if (!fs.existsSync(from)) { - console.warn(`[prebuild] skipped missing ${from}`) - continue - } - fs.cpSync(from, to, { recursive: true, dereference: true }) - console.log(`[prebuild] copied ${from} -> ${to}`) - } -} - -function stripNodeModuleBins() { - const root = path.join(serverDest, "node_modules") - if (!fs.existsSync(root)) { - return - } - - const stack = [root] - let removed = 0 - - while (stack.length > 0) { - const current = stack.pop() - if (!current) break - - let entries - try { - entries = fs.readdirSync(current, { withFileTypes: true }) - } catch { - continue - } - - for (const entry of entries) { - const full = path.join(current, entry.name) - if (entry.name === ".bin") { - fs.rmSync(full, { recursive: true, force: true }) - removed += 1 - continue - } - if (entry.isDirectory()) { - stack.push(full) - } - } - } - - if (removed > 0) { - console.log(`[prebuild] removed ${removed} node_modules/.bin directories`) - } -} - function copyUiLoadingAssets() { const loadingSource = path.join(uiDist, "loading.html") const assetsSource = path.join(uiDist, "assets") @@ -302,6 +268,7 @@ function copyUiLoadingAssets() { ;(async () => { ensureServerDevDependencies() + ensurePluginDevDependencies() ensureUiDevDependencies() await ensureMonacoAssets() ensureRollupPlatformBinary() @@ -310,10 +277,20 @@ function copyUiLoadingAssets() { ensureServerDependencies() ensureUiBuild() syncServerUiBundle() - copyServerArtifacts() - stripNodeModuleBins() + copyPackagedServerResources({ + serverRoot, + serverDest, + log: (message) => console.log(`[prebuild] ${message}`), + }) copyUiLoadingAssets() await prepareBundledNodeRuntime({ resourcesRoot }) + execSync( + `${JSON.stringify(process.execPath)} ${JSON.stringify(path.join(workspaceRoot, "scripts", "smoke-packaged-resources.cjs"))} --resources ${JSON.stringify(resourcesRoot)} --loading ${JSON.stringify(uiLoadingDest)}`, + { + cwd: workspaceRoot, + stdio: "inherit", + }, + ) })().catch((err) => { console.error("[prebuild] failed:", err) process.exit(1) diff --git a/packages/tauri-app/src-tauri/Cargo.toml b/packages/tauri-app/src-tauri/Cargo.toml index 188c8723a..f464f23b7 100644 --- a/packages/tauri-app/src-tauri/Cargo.toml +++ b/packages/tauri-app/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "codenomad-tauri" -version = "0.15.0" +version = "0.16.0" edition = "2021" license = "MIT" diff --git a/packages/tauri-app/src-tauri/src/main.rs b/packages/tauri-app/src-tauri/src/main.rs index fd05dbb89..43fccc43a 100644 --- a/packages/tauri-app/src-tauri/src/main.rs +++ b/packages/tauri-app/src-tauri/src/main.rs @@ -3,9 +3,9 @@ #[allow(dead_code)] mod cert_manager; mod cli_manager; -mod managed_node; #[cfg(target_os = "linux")] mod linux_tls; +mod managed_node; use cli_manager::{CliProcessManager, CliStatus}; use keepawake::KeepAwake; @@ -17,7 +17,7 @@ use std::sync::Mutex; use std::time::{SystemTime, UNIX_EPOCH}; use tauri::menu::{MenuBuilder, MenuItem, SubmenuBuilder}; use tauri::plugin::{Builder as PluginBuilder, TauriPlugin}; -use tauri::webview::Webview; +use tauri::webview::{PageLoadEvent, Webview}; use tauri::{ AppHandle, Emitter, Manager, Runtime, WebviewUrl, WebviewWindowBuilder, WindowEvent, Wry, }; @@ -55,6 +55,7 @@ pub struct AppState { pub remote_proxy_sessions: Mutex>, pub remote_skip_tls_verify: Mutex>, pub remote_tls_handlers: Mutex>, + pub remote_titles: Mutex>, } #[derive(Debug, Deserialize)] @@ -227,26 +228,38 @@ fn intercept_navigation(webview: &Webview, url: &Url) -> bool { false } +fn apply_remote_window_title(app_handle: &AppHandle, window_label: &str) { + let Some(title) = app_handle + .state::() + .remote_titles + .lock() + .ok() + .and_then(|titles| titles.get(window_label).cloned()) + else { + return; + }; + + if let Some(window) = app_handle.get_webview_window(window_label) { + let _ = window.set_title(&title); + } +} + async fn open_remote_window_impl( app: AppHandle, payload: RemoteWindowPayload, ) -> Result<(), String> { - let entry_url = payload.entry_url.as_deref().unwrap_or(payload.base_url.as_str()); + let entry_url = payload + .entry_url + .as_deref() + .unwrap_or(payload.base_url.as_str()); let parsed = Url::parse(entry_url).map_err(|err| err.to_string())?; let label = format!("remote-{}", payload.id); - let title = format!( - "{} - {}", - payload.name, - Url::parse(&payload.base_url) - .ok() - .and_then(|url| url.host_str().map(str::to_string)) - .unwrap_or_else(|| payload.base_url.clone()) - ); + let title = format!("{} - {}", payload.name, payload.base_url); let window_url = parsed.clone(); - let allow_linux_tls_certificate = - parsed.scheme() == "https" && (payload.proxy_session_id.is_some() || payload.skip_tls_verify); + let allow_linux_tls_certificate = parsed.scheme() == "https" + && (payload.proxy_session_id.is_some() || payload.skip_tls_verify); app.state::() .remote_origins @@ -258,6 +271,11 @@ async fn open_remote_window_impl( .lock() .map_err(|err| err.to_string())? .insert(label.clone(), allow_linux_tls_certificate); + app.state::() + .remote_titles + .lock() + .map_err(|err| err.to_string())? + .insert(label.clone(), title.clone()); let replaced_session = { let state = app.state::(); @@ -281,8 +299,9 @@ async fn open_remote_window_impl( #[cfg(target_os = "linux")] linux_tls::ensure_remote_window_tls_handler(&existing, &app, &label)?; - let _ = existing.navigate(window_url.clone()); let _ = existing.set_title(&title); + let _ = existing.navigate(window_url.clone()); + apply_remote_window_title(&app, &label); let _ = existing.show(); let _ = existing.unminimize(); let _ = existing.set_focus(); @@ -290,25 +309,27 @@ async fn open_remote_window_impl( } #[cfg(target_os = "linux")] - let initial_url = if linux_tls::should_bootstrap_tls_navigation( - &window_url, - allow_linux_tls_certificate, - ) { - Url::parse("about:blank").map_err(|err| err.to_string())? - } else { - window_url.clone() - }; + let initial_url = + if linux_tls::should_bootstrap_tls_navigation(&window_url, allow_linux_tls_certificate) { + Url::parse("about:blank").map_err(|err| err.to_string())? + } else { + window_url.clone() + }; #[cfg(not(target_os = "linux"))] let initial_url = window_url.clone(); - let window = WebviewWindowBuilder::new(&app, label.clone(), WebviewUrl::External(initial_url.clone())) - .initialization_script(REMOTE_WINDOW_CONTEXT_SCRIPT) - .title(title) - .inner_size(1400.0, 900.0) - .min_inner_size(800.0, 600.0) - .build() - .map_err(|err| err.to_string())?; + let window = WebviewWindowBuilder::new( + &app, + label.clone(), + WebviewUrl::External(initial_url.clone()), + ) + .initialization_script(REMOTE_WINDOW_CONTEXT_SCRIPT) + .title(title) + .inner_size(1400.0, 900.0) + .min_inner_size(800.0, 600.0) + .build() + .map_err(|err| err.to_string())?; #[cfg(target_os = "linux")] { @@ -336,6 +357,9 @@ async fn open_remote_window_impl( if let Ok(mut handlers) = app_handle.state::().remote_tls_handlers.lock() { handlers.remove(&label_for_cleanup); } + if let Ok(mut titles) = app_handle.state::().remote_titles.lock() { + titles.remove(&label_for_cleanup); + } } }); @@ -364,7 +388,10 @@ fn needs_local_certificate_install() -> Result { async fn open_remote_window(app: AppHandle, payload: RemoteWindowPayload) -> Result<(), String> { #[cfg(not(target_os = "linux"))] { - let entry_url = payload.entry_url.as_deref().unwrap_or(payload.base_url.as_str()); + let entry_url = payload + .entry_url + .as_deref() + .unwrap_or(payload.base_url.as_str()); let parsed = Url::parse(entry_url).map_err(|err| err.to_string())?; if payload.proxy_session_id.is_some() && parsed.scheme() == "https" { let local_cert = cert_manager::ensure_local_cert().map_err(|err| { @@ -542,6 +569,15 @@ fn main() { remote_proxy_sessions: Mutex::new(HashMap::new()), remote_skip_tls_verify: Mutex::new(HashMap::new()), remote_tls_handlers: Mutex::new(HashSet::new()), + remote_titles: Mutex::new(HashMap::new()), + }) + .on_page_load(|webview, payload| { + if matches!( + payload.event(), + PageLoadEvent::Started | PageLoadEvent::Finished + ) { + apply_remote_window_title(&webview.app_handle(), webview.label()); + } }) .setup(|app| { set_windows_app_user_model_id(); diff --git a/packages/tauri-app/src-tauri/tauri.conf.json b/packages/tauri-app/src-tauri/tauri.conf.json index 09b0da169..7011ded16 100644 --- a/packages/tauri-app/src-tauri/tauri.conf.json +++ b/packages/tauri-app/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "CodeNomad", - "version": "0.15.0", + "version": "0.16.0", "identifier": "ai.neuralnomads.codenomad.client", "build": { "beforeDevCommand": "npm run dev:bootstrap", diff --git a/packages/ui/package.json b/packages/ui/package.json index c87a652a1..bf7293ae9 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@codenomad/ui", - "version": "0.15.0", + "version": "0.16.0", "private": true, "license": "MIT", "type": "module", diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 774d16b84..1a312edf5 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -34,6 +34,7 @@ import { import { useConfig } from "./stores/preferences" import { createInstance, + getExistingInstanceForFolder, instances, stopInstance, disconnectedInstance, @@ -80,6 +81,7 @@ const App: Component = () => { recordWorkspaceLaunch, toggleShowThinkingBlocks, toggleKeyboardShortcutHints, + toggleShowMessageTimeline, toggleShowTimelineTools, toggleAutoCleanupBlankSessions, toggleUsageMetrics, @@ -94,7 +96,11 @@ const App: Component = () => { const [escapeInDebounce, setEscapeInDebounce] = createSignal(false) const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0) const [sidecarPickerOpen, setSidecarPickerOpen] = createSignal(false) - + const [alreadyOpenFolderChoice, setAlreadyOpenFolderChoice] = createSignal<{ + folderPath: string + binaryPath: string + instanceId: string + } | null>(null) const phoneQuery = useMediaQuery("(max-width: 767px)") const isPhoneLayout = createMemo(() => phoneQuery()) @@ -257,15 +263,24 @@ const App: Component = () => { const launchErrorMessage = () => launchError()?.message ?? "" - async function handleSelectFolder(folderPath: string, binaryPath?: string) { + async function handleSelectFolder(folderPath: string, binaryPath?: string, options?: { forceNew?: boolean }) { if (!folderPath) { return } - setIsSelectingFolder(true) const selectedBinary = binaryPath || serverSettings().opencodeBinary || "opencode" + recordWorkspaceLaunch(folderPath, selectedBinary) + clearLaunchError() + + if (!options?.forceNew) { + const existingInstance = getExistingInstanceForFolder(folderPath) + if (existingInstance) { + setAlreadyOpenFolderChoice({ folderPath, binaryPath: selectedBinary, instanceId: existingInstance.id }) + return + } + } + + setIsSelectingFolder(true) try { - recordWorkspaceLaunch(folderPath, selectedBinary) - clearLaunchError() const instanceId = await createInstance(folderPath, selectedBinary) selectInstanceTab(instanceId) setShowFolderSelection(false) @@ -284,6 +299,26 @@ const App: Component = () => { } } + function dismissAlreadyOpenFolderChoice() { + setAlreadyOpenFolderChoice(null) + } + + function switchToAlreadyOpenFolder() { + const choice = alreadyOpenFolderChoice() + if (!choice) return + setAlreadyOpenFolderChoice(null) + selectInstanceTab(choice.instanceId) + setShowFolderSelection(false) + log.info("Selected existing instance", { instanceId: choice.instanceId, folderPath: choice.folderPath }) + } + + function openAnotherFolderInstance() { + const choice = alreadyOpenFolderChoice() + if (!choice) return + setAlreadyOpenFolderChoice(null) + void handleSelectFolder(choice.folderPath, choice.binaryPath, { forceNew: true }) + } + function handleLaunchErrorClose() { clearLaunchError() } @@ -411,6 +446,7 @@ const App: Component = () => { toggleAutoCleanupBlankSessions, toggleShowThinkingBlocks, toggleKeyboardShortcutHints, + toggleShowMessageTimeline, toggleShowTimelineTools, toggleUsageMetrics, togglePromptSubmitOnEnter, @@ -618,6 +654,30 @@ const App: Component = () => { setSidecarPickerOpen(false)} onOpenSidecar={handleOpenSidecar} /> + + !open && dismissAlreadyOpenFolderChoice()}> + + + + + {t("folderSelection.recent.alreadyOpenTitle")} + + + {t("folderSelection.recent.alreadyOpenMessage")} + + +
+ + +
+
+
+
+
diff --git a/packages/ui/src/components/action-overflow-menu.tsx b/packages/ui/src/components/action-overflow-menu.tsx new file mode 100644 index 000000000..a66a25f3f --- /dev/null +++ b/packages/ui/src/components/action-overflow-menu.tsx @@ -0,0 +1,85 @@ +import { DropdownMenu } from "@kobalte/core/dropdown-menu" +import { For, Show, createSignal, onCleanup, type JSXElement } from "solid-js" +import { MoreHorizontal } from "lucide-solid" + +export interface ActionOverflowMenuItem { + key: string + label: string + icon?: JSXElement + disabled?: boolean + destructive?: boolean + onSelect: () => void | Promise + onMouseEnter?: () => void + onMouseLeave?: () => void +} + +interface ActionOverflowMenuProps { + items: ActionOverflowMenuItem[] + label: string + triggerClass?: string + minItems?: number +} + +export default function ActionOverflowMenu(props: ActionOverflowMenuProps) { + const [hoveredItem, setHoveredItem] = createSignal(null) + const enabledItems = () => props.items.filter((item) => !item.disabled) + const hasItems = () => props.items.length >= (props.minItems ?? 1) + const clearHoveredItem = () => { + const item = hoveredItem() + if (!item) return + item.onMouseLeave?.() + setHoveredItem(null) + } + + onCleanup(clearHoveredItem) + + return ( + + { if (!open) clearHoveredItem() }}> + + + + + + + {(item) => ( + { + if (item.disabled) return + const previous = hoveredItem() + if (previous !== item) previous?.onMouseLeave?.() + setHoveredItem(item) + item.onMouseEnter?.() + }} + onPointerLeave={() => { + if (item.disabled) return + if (hoveredItem() === item) setHoveredItem(null) + item.onMouseLeave?.() + }} + onSelect={() => { + clearHoveredItem() + void item.onSelect() + }} + > + + {item.label} + + )} + + + + + + ) +} diff --git a/packages/ui/src/components/agent-selector.tsx b/packages/ui/src/components/agent-selector.tsx index b5c6d5da5..b3ef06b35 100644 --- a/packages/ui/src/components/agent-selector.tsx +++ b/packages/ui/src/components/agent-selector.tsx @@ -1,8 +1,8 @@ import { Select } from "@kobalte/core/select" -import { For, Show, createEffect, createMemo } from "solid-js" +import { Show, createEffect, createMemo } from "solid-js" import { agents, fetchAgents, sessions } from "../stores/sessions" import { ChevronDown } from "lucide-solid" -import type { Agent } from "../types/session" +import { isSelectablePrimaryAgent, type Agent } from "../types/session" import { useI18n } from "../lib/i18n" import { getLogger } from "../lib/logger" const log = getLogger("session") @@ -34,14 +34,7 @@ export default function AgentSelector(props: AgentSelectorProps) { return allAgents.filter((agent) => !agent.hidden) } - const filtered = allAgents.filter((agent) => !agent.hidden && agent.mode !== "subagent") - - const currentAgent = allAgents.find((a) => a.name === props.currentAgent) - if (currentAgent && !filtered.find((a) => a.name === props.currentAgent)) { - return [currentAgent, ...filtered] - } - - return filtered + return allAgents.filter(isSelectablePrimaryAgent) }) createEffect(() => { @@ -58,7 +51,6 @@ export default function AgentSelector(props: AgentSelectorProps) { } }) - const handleChange = async (value: Agent | null) => { if (value && value.name !== props.currentAgent) { await props.onAgentChange(value.name) diff --git a/packages/ui/src/components/branded-empty-state.tsx b/packages/ui/src/components/branded-empty-state.tsx new file mode 100644 index 000000000..2a916eaf8 --- /dev/null +++ b/packages/ui/src/components/branded-empty-state.tsx @@ -0,0 +1,31 @@ +import type { Component, JSX } from "solid-js" +import { useI18n } from "../lib/i18n" + +const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href + +interface BrandedEmptyStateProps { + title?: JSX.Element + description: JSX.Element + class?: string + children?: JSX.Element +} + +const BrandedEmptyState: Component = (props) => { + const { t } = useI18n() + + return ( +
+
+
+ +

{t("messageSection.empty.brandTitle")}

+
+ {props.title ?

{props.title}

: null} +

{props.description}

+ {props.children} +
+
+ ) +} + +export default BrandedEmptyState diff --git a/packages/ui/src/components/browser-frame.tsx b/packages/ui/src/components/browser-frame.tsx new file mode 100644 index 000000000..48c374b1e --- /dev/null +++ b/packages/ui/src/components/browser-frame.tsx @@ -0,0 +1,381 @@ +import { ArrowLeft, ArrowRight, ChevronDown, Expand, MessageSquarePlus, Monitor, RefreshCw, RotateCw, Smartphone, Tablet } from "lucide-solid" +import { Show, createEffect, createMemo, createSignal, onCleanup, type Component } from "solid-js" + +export interface BrowserFrameElementTarget { + pagePath: string + tagName: string + text?: string + role?: string + ariaLabel?: string + selector?: string + rect: { x: number; y: number; width: number; height: number } +} + +interface BrowserFrameLabels { + back: string + refresh: string + path: string + go: string + commentMode?: string + viewport?: string + viewportResponsive?: string + viewportDesktop?: string + viewportTablet?: string + viewportTabletLandscape?: string + viewportMobile?: string + viewportMobileLandscape?: string +} + +type BrowserViewportPreset = "responsive" | "desktop" | "tablet" | "tabletLandscape" | "mobile" | "mobileLandscape" + +const VIEWPORT_PRESETS: Record = { + responsive: { width: null, height: null }, + desktop: { width: 1440, height: 900 }, + tablet: { width: 768, height: 1024 }, + tabletLandscape: { width: 1024, height: 768 }, + mobile: { width: 390, height: 844 }, + mobileLandscape: { width: 844, height: 390 }, +} + +const VIEWPORT_OPTIONS = [ + { id: "responsive" as const, icon: Expand, getLabel: (labels: BrowserFrameLabels) => labels.viewportResponsive }, + { id: "desktop" as const, icon: Monitor, getLabel: (labels: BrowserFrameLabels) => labels.viewportDesktop }, + { id: "tablet" as const, icon: Tablet, getLabel: (labels: BrowserFrameLabels) => labels.viewportTablet }, + { id: "tabletLandscape" as const, icon: RotateCw, getLabel: (labels: BrowserFrameLabels) => labels.viewportTabletLandscape }, + { id: "mobile" as const, icon: Smartphone, getLabel: (labels: BrowserFrameLabels) => labels.viewportMobile }, + { id: "mobileLandscape" as const, icon: RotateCw, getLabel: (labels: BrowserFrameLabels) => labels.viewportMobileLandscape }, +] + +interface BrowserFrameProps { + title: string + initialUrl: string + proxyBasePath: string + lockedBaseLabel: string + labels: BrowserFrameLabels + commentMode?: boolean + onToggleCommentMode?: () => void + onCommentTarget?: (target: BrowserFrameElementTarget) => void +} + +function getElementText(element: Element): string | undefined { + const text = (element.textContent ?? "").replace(/\s+/g, " ").trim() + return text ? text.slice(0, 120) : undefined +} + +function getElementSelector(element: Element): string { + const parts: string[] = [] + let current: Element | null = element + while (current && current.nodeType === Node.ELEMENT_NODE && parts.length < 5) { + const tag = current.tagName.toLowerCase() + const id = current.getAttribute("id") + if (id) { + parts.unshift(`${tag}#${CSS.escape(id)}`) + break + } + + const className = Array.from(current.classList).slice(0, 2).map((item) => `.${CSS.escape(item)}`).join("") + let part = `${tag}${className}` + const parentElement: Element | null = current.parentElement + if (parentElement) { + const siblings = Array.from(parentElement.children as HTMLCollectionOf).filter((child) => child.tagName === current?.tagName) + if (siblings.length > 1) { + part = `${part}:nth-of-type(${siblings.indexOf(current) + 1})` + } + } + parts.unshift(part) + current = parentElement + } + return parts.join(" > ") +} + +export const BrowserFrame: Component = (props) => { + const [frameSrc, setFrameSrc] = createSignal(props.initialUrl) + const [pathInput, setPathInput] = createSignal("/") + const [viewportPreset, setViewportPreset] = createSignal("responsive") + const [viewportMenuOpen, setViewportMenuOpen] = createSignal(false) + const [highlight, setHighlight] = createSignal<{ x: number; y: number; width: number; height: number } | null>(null) + let iframeRef: HTMLIFrameElement | undefined + let frameWrapRef: HTMLDivElement | undefined + let cleanupFrameListeners: (() => void) | null = null + + const canComment = createMemo(() => Boolean(props.onToggleCommentMode && props.onCommentTarget)) + const viewport = createMemo(() => VIEWPORT_PRESETS[viewportPreset()]) + const isResponsiveViewport = createMemo(() => viewportPreset() === "responsive") + const selectedViewportOption = createMemo(() => VIEWPORT_OPTIONS.find((option) => option.id === viewportPreset()) ?? VIEWPORT_OPTIONS[0]) + + const getEditablePathFromUrl = (url: string): string => { + try { + const parsed = new URL(url, window.location.origin) + const basePath = props.proxyBasePath + let pathname = parsed.pathname + + if (basePath && pathname.startsWith(basePath)) { + pathname = pathname.slice(basePath.length) || "/" + } + + if (!pathname.startsWith("/")) { + pathname = `/${pathname}` + } + + return `${pathname}${parsed.search}${parsed.hash}` + } catch { + return "/" + } + } + + const buildNormalizedTargetUrl = (rawInput: string): string => { + const trimmed = rawInput.trim() + const withLeadingSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}` + const parsed = new URL(withLeadingSlash || "/", window.location.origin) + + const safeSegments: string[] = [] + for (const segment of parsed.pathname.split("/")) { + if (!segment || segment === ".") continue + if (segment === "..") { + if (safeSegments.length > 0) safeSegments.pop() + continue + } + safeSegments.push(segment) + } + + const normalizedPath = `/${safeSegments.join("/")}` || "/" + return `${props.proxyBasePath}${normalizedPath}${parsed.search}${parsed.hash}` + } + + const buildElementTarget = (element: Element): BrowserFrameElementTarget => { + const rect = element.getBoundingClientRect() + const pagePath = getEditablePathFromUrl(iframeRef?.contentWindow?.location.href ?? frameSrc()) + return { + pagePath, + tagName: element.tagName.toLowerCase(), + text: getElementText(element), + role: element.getAttribute("role") ?? undefined, + ariaLabel: element.getAttribute("aria-label") ?? undefined, + selector: getElementSelector(element), + rect: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) }, + } + } + + const attachCommentListeners = () => { + cleanupFrameListeners?.() + cleanupFrameListeners = null + setHighlight(null) + + if (!props.commentMode || !iframeRef?.contentDocument || !iframeRef.contentWindow || !frameWrapRef) return + const doc = iframeRef.contentDocument + const frameWindow = iframeRef.contentWindow + + const handleMove = (event: MouseEvent) => { + const target = event.target + if (!target || !(target instanceof (frameWindow as any).Element)) return + const element = target as Element + const rect = element.getBoundingClientRect() + const frameRect = iframeRef?.getBoundingClientRect() + const wrapRect = frameWrapRef?.getBoundingClientRect() + if (!frameRect || !wrapRect) return + setHighlight({ + x: frameRect.left - wrapRect.left + rect.x, + y: frameRect.top - wrapRect.top + rect.y, + width: rect.width, + height: rect.height, + }) + } + + const handleLeave = () => setHighlight(null) + + const handleClick = (event: MouseEvent) => { + const target = event.target + if (!target || !(target instanceof (frameWindow as any).Element)) return + event.preventDefault() + event.stopPropagation() + props.onCommentTarget?.(buildElementTarget(target as Element)) + } + + doc.addEventListener("mousemove", handleMove, true) + doc.addEventListener("mouseleave", handleLeave, true) + doc.addEventListener("click", handleClick, true) + cleanupFrameListeners = () => { + doc.removeEventListener("mousemove", handleMove, true) + doc.removeEventListener("mouseleave", handleLeave, true) + doc.removeEventListener("click", handleClick, true) + } + } + + const syncPathInputFromFrame = () => { + try { + const currentHref = iframeRef?.contentWindow?.location.href + if (currentHref) setPathInput(getEditablePathFromUrl(currentHref)) + } catch { + setPathInput(getEditablePathFromUrl(frameSrc())) + } + attachCommentListeners() + } + + createEffect(() => { + setFrameSrc(props.initialUrl) + setPathInput(getEditablePathFromUrl(props.initialUrl)) + }) + + createEffect(() => { + props.commentMode + attachCommentListeners() + }) + + onCleanup(() => cleanupFrameListeners?.()) + + const handleBack = (event: MouseEvent) => { + event.preventDefault() + event.stopPropagation() + try { + iframeRef?.contentWindow?.history.go(-1) + } catch { + // Ignore navigation errors from pages that do not expose history access. + } + } + + const handleRefresh = () => { + try { + iframeRef?.contentWindow?.location.reload() + return + } catch { + // Fall back to resetting the iframe source if the frame cannot be reloaded directly. + } + setFrameSrc("about:blank") + requestAnimationFrame(() => setFrameSrc(props.initialUrl)) + } + + const handleGo = (event?: Event) => { + event?.preventDefault() + const nextUrl = buildNormalizedTargetUrl(pathInput()) + setFrameSrc(nextUrl) + setPathInput(getEditablePathFromUrl(nextUrl)) + } + + return ( +
+
+ + +
+ {props.lockedBaseLabel} +
+
handleGo(event)}> + setPathInput(event.currentTarget.value)} + spellcheck={false} + autocomplete="off" + autocorrect="off" + autocapitalize="off" + aria-label={props.labels.path} + /> + +
+
+ + + + +
+ + + +
+
+
+