Compare commits

...

11 Commits

Author SHA1 Message Date
Shantur Rathore
7aa94e7a88 Integrate reply-from for workspace proxy 2025-11-19 02:27:07 +00:00
Shantur Rathore
146eae5220 Add CLI instance proxy and route UI traffic through it 2025-11-19 02:03:15 +00:00
Shantur Rathore
defa637dbc Serve bundled or proxied UI from CLI 2025-11-18 14:33:48 +00:00
Shantur Rathore
a43a004e23 add unrestricted filesystem browsing mode 2025-11-17 23:40:02 +00:00
Shantur Rathore
a3f02befa7 Refine CLI args and lifecycle logging 2025-11-17 22:08:50 +00:00
Shantur Rathore
40e8c90bab Implement workspace-aware folder browser 2025-11-17 22:05:38 +00:00
Shantur Rathore
f53564bb06 Add depth-limited filesystem browsing 2025-11-17 21:16:10 +00:00
Shantur Rathore
719a9c9c74 Add structured logging and ensure CLI shuts down cleanly 2025-11-17 20:21:39 +00:00
Shantur Rathore
08d81f8bb5 Add CLI server and move UI to HTTP API 2025-11-17 18:18:45 +00:00
Shantur Rathore
89bd32814f Split workspace into electron and ui packages 2025-11-17 12:06:58 +00:00
Shantur Rathore
aa77ca2931 add linux rpm packaging pipeline 2025-11-17 01:50:16 +00:00
174 changed files with 7157 additions and 1792 deletions

View File

@@ -79,10 +79,10 @@ jobs:
cache: npm cache: npm
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci --workspaces
- name: Build macOS binaries - name: Build macOS binaries
run: npm run build:mac run: npm run build:mac --workspace @codenomad/electron-app
- name: Upload release assets - name: Upload release assets
env: env:
@@ -91,7 +91,7 @@ jobs:
run: | run: |
set -euo pipefail set -euo pipefail
shopt -s nullglob shopt -s nullglob
for file in release/*; do for file in packages/electron-app/release/*; do
[ -f "$file" ] || continue [ -f "$file" ] || continue
case "$file" in case "$file" in
*.dmg|*.zip) *.dmg|*.zip)
@@ -119,10 +119,10 @@ jobs:
cache: npm cache: npm
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci --workspaces
- name: Build Windows binaries - name: Build Windows binaries
run: npm run build:win run: npm run build:win --workspace @codenomad/electron-app
- name: Upload release assets - name: Upload release assets
shell: pwsh shell: pwsh
@@ -130,7 +130,7 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ needs.prepare-release.outputs.tag }} TAG: ${{ needs.prepare-release.outputs.tag }}
run: | run: |
Get-ChildItem -Path "release" -File | Where-Object { Get-ChildItem -Path "packages/electron-app/release" -File | Where-Object {
$_.Name -match '\.(exe|zip)$' $_.Name -match '\.(exe|zip)$'
} | ForEach-Object { } | ForEach-Object {
gh release upload $env:TAG $_.FullName --clobber gh release upload $env:TAG $_.FullName --clobber
@@ -152,10 +152,10 @@ jobs:
cache: npm cache: npm
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci --workspaces
- name: Build Linux binaries - name: Build Linux binaries
run: npm run build:linux run: npm run build:linux --workspace @codenomad/electron-app
- name: Upload release assets - name: Upload release assets
env: env:
@@ -164,7 +164,7 @@ jobs:
run: | run: |
set -euo pipefail set -euo pipefail
shopt -s nullglob shopt -s nullglob
for file in release/*; do for file in packages/electron-app/release/*; do
[ -f "$file" ] || continue [ -f "$file" ] || continue
case "$file" in case "$file" in
*.AppImage|*.deb|*.tar.gz) *.AppImage|*.deb|*.tar.gz)
@@ -175,3 +175,42 @@ jobs:
;; ;;
esac esac
done done
build-linux-rpm:
needs: prepare-release
runs-on: ubuntu-latest
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
- name: Install rpm packaging dependencies
run: |
sudo apt-get update
sudo apt-get install -y rpm ruby ruby-dev build-essential
sudo gem install --no-document fpm
- name: Install project dependencies
run: npm ci --workspaces
- name: Build Linux RPM binaries
run: npm run build:linux-rpm --workspace @codenomad/electron-app
- name: Upload RPM release assets
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ needs.prepare-release.outputs.tag }}
run: |
set -euo pipefail
shopt -s nullglob
for file in packages/electron-app/release/*.rpm; do
[ -f "$file" ] || continue
gh release upload "$TAG" "$file" --clobber
done

View File

@@ -14,3 +14,7 @@
- Enforce single responsibility; split large files when concerns diverge (state, actions, API, events, etc.). - Enforce single responsibility; split large files when concerns diverge (state, actions, API, events, etc.).
- Prefer composable primitives (signals, hooks, utilities) over deep inheritance or implicit global state. - Prefer composable primitives (signals, hooks, utilities) over deep inheritance or implicit global state.
- When adding platform integrations (SSE, IPC, SDK), isolate them in thin adapters that surface typed events/actions. - When adding platform integrations (SSE, IPC, SDK), isolate them in thin adapters that surface typed events/actions.
## Tooling Preferences
- Use the `edit` tool for modifying existing files; prefer it over other editing methods.
- Use the `write` tool only when creating new files from scratch.

View File

@@ -10,6 +10,12 @@ This guide explains how to build distributable binaries for CodeNomad.
## Quick Start ## Quick Start
All commands now run inside the workspace packages. From the repo root you can target the Electron app package directly:
```bash
npm run build --workspace @codenomad/electron-app
```
### Build for Current Platform (macOS default) ### Build for Current Platform (macOS default)
```bash ```bash

View File

@@ -22,6 +22,15 @@ CodeNomad is built for people who live inside OpenCode for hours on end and need
- [OpenCode CLI](https://opencode.ai) installed and available in your `PATH`, or point CodeNomad to a local binary through Advanced Settings. - [OpenCode CLI](https://opencode.ai) installed and available in your `PATH`, or point CodeNomad to a local binary through Advanced Settings.
## Repository Layout
CodeNomad now ships as a small workspace with two packages:
- `packages/ui` — SolidJS renderer, Tailwind styles, and standalone Vite configuration for building the UI bundle independently.
- `packages/electron-app` — Electron main/preload processes plus packaging scripts. It consumes the UI package during development/build via `electron-vite`.
Use `npm run dev --workspace @codenomad/electron-app` for the Electron shell and `npm run dev --workspace @codenomad/ui` for UI-only work. Working with the workspace requires Node.js 18+ with npm 7 or newer so the workspace protocol is available.
## Downloads ## Downloads
Grab the latest build for macOS, Windows, and Linux from the [GitHub Releases page](https://github.com/shantur/CodeNomad/releases). Grab the latest build for macOS, Windows, and Linux from the [GitHub Releases page](https://github.com/shantur/CodeNomad/releases).
@@ -33,3 +42,17 @@ Grab the latest build for macOS, Windows, and Linux from the [GitHub Releases pa
3. Connect to one or more OpenCode instances, set keyboard shortcuts in preferences, and start a session. 3. Connect to one or more OpenCode instances, set keyboard shortcuts in preferences, and start a session.
4. Use tabs to swap between instances, the task sidebar to dive into child sessions, and the prompt input to keep shipping. 4. Use tabs to swap between instances, the task sidebar to dive into child sessions, and the prompt input to keep shipping.
## CLI Server Flags
The bundled CLI server (`@codenomad/cli`) controls which folders the UI can browse when you pick a workspace:
- `--workspace-root <path>` (default: current working directory) scopes browsing to a safe subtree. The UI can only see folders beneath this root.
- `--unrestricted-root` explicitly allows full-machine browsing for the current process. In this mode the UI starts from the host home directory, adds a "parent" option so you can reach `/` on macOS/Linux, and lists drives/UNC paths on Windows. The flag is runtime-only—restart the CLI without it to go back to restricted mode.
- `--ui-dev-server <url>` proxies UI asset requests to a running Vite dev server while the CLI continues to expose its REST APIs and workspace proxies from the same port. Point this at `http://localhost:3000` when developing the renderer to keep hot reloads without sacrificing the single entry point.
Use unrestricted mode only when you trust the host; the CLI will skip directories it cannot read and never persists the opt-in.
### Single Port Proxying
Every OpenCode instance now tunnels through the CLI port. Each workspace descriptor publishes a stable `proxyPath` (e.g., `/workspaces/<id>/instance`), and the CLI exposes `GET/POST/...` + SSE at `http(s)://<cli-host>:<cli-port>${proxyPath}`. That means the UI, Electron shell, and browser clients only need firewall access to the CLI; instance ports stay private on `127.0.0.1`. In development, the `--ui-dev-server` flag still routes UI traffic through the CLI proxy so all instance calls share the same origin.

View File

@@ -104,6 +104,12 @@ CodeNomad is a cross-platform desktop application built with Electron that provi
- Event type routing - Event type routing
- Reconnection logic - Reconnection logic
**CLI Proxy Paths:**
- The CLI server terminates all HTTP/SSE traffic and forwards it to the correct OpenCode instance.
- Each `WorkspaceDescriptor` exposes `proxyPath` (e.g., `/workspaces/<id>/instance`), which acts as the base URL for both REST and SSE calls.
- The renderer never touches the random per-instance port directly; it only talks to `window.location.origin + proxyPath` so a single CLI port can front every session.
## Data Flow ## Data Flow
### Instance Creation Flow ### Instance Creation Flow
@@ -144,6 +150,7 @@ instances: Map<instanceId, {
folder: string folder: string
port: number port: number
pid: number pid: number
proxyPath: string // `/workspaces/:id/instance`
status: 'starting' | 'ready' | 'error' | 'stopped' status: 'starting' | 'ready' | 'error' | 'stopped'
client: OpenCodeClient client: OpenCodeClient
eventSource: EventSource eventSource: EventSource

2509
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,161 +1,25 @@
{ {
"name": "@shantur/codenomad", "name": "codenomad-workspace",
"version": "0.1.2", "version": "0.1.2",
"description": "CodeNomad - AI coding assistant", "private": true,
"author": { "description": "CodeNomad monorepo workspace",
"name": "Shantur Rathore", "workspaces": {
"email": "codenomad@shantur.com" "packages": [
"packages/*"
]
}, },
"type": "module",
"main": "dist/main/main.js",
"scripts": { "scripts": {
"dev": "electron-vite dev", "dev": "npm run dev --workspace @codenomad/electron-app",
"dev:electron": "NODE_ENV=development electron .", "dev:electron": "npm run dev --workspace @codenomad/electron-app",
"build": "electron-vite build", "dev:ui": "npm run dev --workspace @codenomad/ui",
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.node.json", "build": "npm run build --workspace @codenomad/electron-app",
"preview": "electron-vite preview", "build:ui": "npm run build --workspace @codenomad/ui",
"build:binaries": "node scripts/build.js", "build:mac-x64": "npm run build:mac-x64 --workspace @codenomad/electron-app",
"build:mac": "node scripts/build.js mac", "build:binaries": "npm run build:binaries --workspace @codenomad/electron-app",
"build:mac-x64": "node scripts/build.js mac-x64", "typecheck": "npm run typecheck --workspace @codenomad/ui && npm run typecheck --workspace @codenomad/electron-app"
"build:mac-arm64": "node scripts/build.js mac-arm64",
"build:win": "node scripts/build.js win",
"build:win-arm64": "node scripts/build.js win-arm64",
"build:linux": "node scripts/build.js linux",
"build:linux-arm64": "node scripts/build.js linux-arm64",
"build:all": "node scripts/build.js all",
"package:mac": "electron-builder --mac",
"package:win": "electron-builder --win",
"package:linux": "electron-builder --linux"
}, },
"dependencies": { "dependencies": {
"@git-diff-view/solid": "^0.0.8", "7zip-bin": "^5.2.0",
"@kobalte/core": "0.13.11", "google-auth-library": "^10.5.0"
"@opencode-ai/sdk": "1.0.68", }
"@solidjs/router": "^0.13.0",
"github-markdown-css": "^5.8.1",
"ignore": "7.0.5",
"lucide-solid": "^0.300.0",
"marked": "^12.0.0",
"shiki": "^3.13.0",
"solid-js": "^1.8.0",
"solid-toast": "^0.5.0"
},
"devDependencies": {
"@tsconfig/bun": "^1.0.9",
"autoprefixer": "10.4.21",
"electron": "39.0.0",
"png2icons": "^2.0.1",
"pngjs": "^7.0.0",
"electron-builder": "^24.0.0",
"electron-vite": "4.0.1",
"postcss": "8.5.6",
"tailwindcss": "3",
"typescript": "^5.3.0",
"vite": "^5.0.0",
"vite-plugin-solid": "^2.10.0"
},
"build": {
"appId": "ai.opencode.client",
"productName": "CodeNomad",
"directories": {
"output": "release",
"buildResources": "electron/resources"
},
"files": [
"dist/**/*",
"package.json"
],
"mac": {
"category": "public.app-category.developer-tools",
"target": [
{
"target": "dmg",
"arch": [
"x64",
"arm64",
"universal"
]
},
{
"target": "zip",
"arch": [
"x64",
"arm64",
"universal"
]
}
],
"artifactName": "${productName}-${version}-${os}-${arch}.${ext}",
"icon": "electron/resources/icon.icns"
},
"dmg": {
"contents": [
{
"x": 130,
"y": 220
},
{
"x": 410,
"y": 220,
"type": "link",
"path": "/Applications"
}
]
},
"win": {
"target": [
{
"target": "nsis",
"arch": [
"x64",
"arm64"
]
},
{
"target": "zip",
"arch": [
"x64",
"arm64"
]
}
],
"artifactName": "${productName}-${version}-${os}-${arch}.${ext}",
"icon": "electron/resources/icon.ico"
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true,
"createDesktopShortcut": true,
"createStartMenuShortcut": true
},
"linux": {
"target": [
{
"target": "AppImage",
"arch": [
"x64",
"arm64"
]
},
{
"target": "deb",
"arch": [
"x64",
"arm64"
]
},
{
"target": "tar.gz",
"arch": [
"x64",
"arm64"
]
}
],
"artifactName": "${productName}-${version}-${os}-${arch}.${ext}",
"category": "Development",
"icon": "electron/resources/icon.png"
}
},
"private": true
} }

1
packages/cli/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
public/

5
packages/cli/.npmignore Normal file
View File

@@ -0,0 +1,5 @@
node_modules
scripts/
src/
tsconfig.json
*.tsbuildinfo

1333
packages/cli/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

33
packages/cli/package.json Normal file
View File

@@ -0,0 +1,33 @@
{
"name": "@codenomad/cli",
"version": "0.1.0",
"description": "CodeNomad CLI server for HTTP/SSE control plane",
"type": "module",
"main": "dist/index.js",
"bin": {
"codenomad-cli": "dist/bin.js"
},
"scripts": {
"build": "npm run build:ui && npm run prepare-ui && tsc -p tsconfig.json",
"build:ui": "npm run build --prefix ../ui",
"prepare-ui": "node ./scripts/copy-ui-dist.mjs",
"dev": "cross-env CLI_UI_DEV_SERVER=http://localhost:3000 tsx src/index.ts",
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {
"@fastify/cors": "^8.5.0",
"@fastify/reply-from": "^9.8.0",
"@fastify/static": "^7.0.4",
"commander": "^12.1.0",
"fastify": "^4.28.1",
"pino": "^9.4.0",
"undici": "^6.19.8",
"zod": "^3.23.8"
},
"devDependencies": {
"cross-env": "^7.0.3",
"ts-node": "^10.9.2",
"tsx": "^4.20.6",
"typescript": "^5.6.3"
}
}

View File

@@ -0,0 +1,21 @@
#!/usr/bin/env node
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 uiDistDir = path.resolve(cliRoot, "../ui/src/renderer/dist")
const targetDir = path.resolve(cliRoot, "public")
if (!existsSync(uiDistDir)) {
console.error(`[copy-ui-dist] Expected UI build artifacts at ${uiDistDir}. Run the UI build before bundling the CLI.`)
process.exit(1)
}
rmSync(targetDir, { recursive: true, force: true })
mkdirSync(targetDir, { recursive: true })
cpSync(uiDistDir, targetDir, { recursive: true })
console.log(`[copy-ui-dist] Copied UI bundle from ${uiDistDir} -> ${targetDir}`)

View File

@@ -0,0 +1,181 @@
import type {
AgentModelSelections,
ConfigFile,
ModelPreference,
OpenCodeBinary,
Preferences,
RecentFolder,
} from "./config/schema"
/**
* Canonical HTTP/SSE contract for the CLI server.
* These types are consumed by both the CLI implementation and any UI clients.
*/
export type WorkspaceStatus = "starting" | "ready" | "stopped" | "error"
export interface WorkspaceDescriptor {
id: string
/** Absolute path on the server host. */
path: string
name?: string
status: WorkspaceStatus
/** PID/port are populated when the workspace is running. */
pid?: number
port?: number
/** Canonical proxy path the CLI exposes for this instance. */
proxyPath: string
/** Identifier of the binary resolved from config. */
binaryId: string
binaryLabel: string
binaryVersion?: string
createdAt: string
updatedAt: string
/** Present when `status` is "error". */
error?: string
}
export interface WorkspaceCreateRequest {
path: string
name?: string
}
export type WorkspaceCreateResponse = WorkspaceDescriptor
export type WorkspaceListResponse = WorkspaceDescriptor[]
export type WorkspaceDetailResponse = WorkspaceDescriptor
export interface WorkspaceDeleteResponse {
id: string
status: WorkspaceStatus
}
export type LogLevel = "debug" | "info" | "warn" | "error"
export interface WorkspaceLogEntry {
workspaceId: string
timestamp: string
level: LogLevel
message: string
}
export interface FileSystemEntry {
name: string
/** Path relative to the CLI server root ("." represents the root itself). */
path: string
/** Absolute path when available (unrestricted listings). */
absolutePath?: string
type: "file" | "directory"
size?: number
/** ISO timestamp of last modification when available. */
modifiedAt?: string
}
export type FileSystemScope = "restricted" | "unrestricted"
export type FileSystemPathKind = "relative" | "absolute" | "drives"
export interface FileSystemListingMetadata {
scope: FileSystemScope
/** Canonical identifier of the current view ("." for restricted roots, absolute paths otherwise). */
currentPath: string
/** Optional parent path if navigation upward is allowed. */
parentPath?: string
/** Absolute path representing the root or origin point for this listing. */
rootPath: string
/** Absolute home directory of the CLI host (useful defaults for unrestricted mode). */
homePath: string
/** Human-friendly label for the current path. */
displayPath: string
/** Indicates whether entry paths are relative, absolute, or represent drive roots. */
pathKind: FileSystemPathKind
}
export interface FileSystemListResponse {
entries: FileSystemEntry[]
metadata: FileSystemListingMetadata
}
export const WINDOWS_DRIVES_ROOT = "__drives__"
export interface WorkspaceFileResponse {
workspaceId: string
relativePath: string
/** UTF-8 file contents; binary files should be base64 encoded by the caller. */
contents: string
}
export interface InstanceData {
messageHistory: string[]
}
export interface BinaryRecord {
id: string
path: string
label: string
version?: string
/** Indicates that this binary will be picked when workspaces omit an explicit choice. */
isDefault: boolean
lastValidatedAt?: string
validationError?: string
}
export type AppConfig = ConfigFile
export type AppConfigResponse = AppConfig
export type AppConfigUpdateRequest = Partial<AppConfig>
export interface BinaryListResponse {
binaries: BinaryRecord[]
}
export interface BinaryCreateRequest {
path: string
label?: string
makeDefault?: boolean
}
export interface BinaryUpdateRequest {
label?: string
makeDefault?: boolean
}
export interface BinaryValidationResult {
valid: boolean
version?: string
error?: string
}
export type WorkspaceEventType =
| "workspace.created"
| "workspace.started"
| "workspace.error"
| "workspace.stopped"
| "workspace.log"
| "config.appChanged"
| "config.binariesChanged"
export type WorkspaceEventPayload =
| { type: "workspace.created"; workspace: WorkspaceDescriptor }
| { type: "workspace.started"; workspace: WorkspaceDescriptor }
| { type: "workspace.error"; workspace: WorkspaceDescriptor }
| { type: "workspace.stopped"; workspaceId: string }
| { type: "workspace.log"; entry: WorkspaceLogEntry }
| { type: "config.appChanged"; config: AppConfig }
| { type: "config.binariesChanged"; binaries: BinaryRecord[] }
export interface ServerMeta {
/** Base URL clients should target for REST calls (useful for Electron embedding). */
httpBaseUrl: string
/** SSE endpoint advertised to clients (`/api/events` by default). */
eventsUrl: string
/** Display label for the host (e.g., hostname or friendly name). */
hostLabel: string
/** Absolute path of the filesystem root exposed to clients. */
workspaceRoot: string
}
export type {
Preferences,
ModelPreference,
AgentModelSelections,
RecentFolder,
OpenCodeBinary,
}

29
packages/cli/src/bin.ts Normal file
View File

@@ -0,0 +1,29 @@
#!/usr/bin/env node
import { spawn } from "child_process"
import path from "path"
import { fileURLToPath, pathToFileURL } from "url"
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const cliEntry = path.join(__dirname, "index.js")
const loaderFileUrl = pathToFileURL(path.join(__dirname, "loader.js")).href
const registerScript = `import { register } from "node:module"; import { pathToFileURL } from "node:url"; register("${encodeURI(loaderFileUrl)}", pathToFileURL("./"));`
const loaderArg = `data:text/javascript,${registerScript}`
const child = spawn(process.execPath, ["--import", loaderArg, cliEntry, ...process.argv.slice(2)], {
stdio: "inherit",
})
child.on("exit", (code, signal) => {
if (signal) {
process.kill(process.pid, signal)
return
}
process.exit(code ?? 0)
})
child.on("error", (error) => {
console.error("Failed to launch CLI runtime", error)
process.exit(1)
})

View File

@@ -0,0 +1,155 @@
import {
BinaryCreateRequest,
BinaryRecord,
BinaryUpdateRequest,
BinaryValidationResult,
} from "../api-types"
import { ConfigStore } from "./store"
import { EventBus } from "../events/bus"
import type { ConfigFileUpdate } from "./schema"
import { Logger } from "../logger"
export class BinaryRegistry {
constructor(
private readonly configStore: ConfigStore,
private readonly eventBus: EventBus | undefined,
private readonly logger: Logger,
) {}
list(): BinaryRecord[] {
return this.mapRecords()
}
resolveDefault(): BinaryRecord {
const binaries = this.mapRecords()
if (binaries.length === 0) {
this.logger.warn("No configured binaries found, falling back to opencode")
return this.buildFallbackRecord("opencode")
}
return binaries.find((binary) => binary.isDefault) ?? binaries[0]
}
create(request: BinaryCreateRequest): BinaryRecord {
this.logger.debug({ path: request.path }, "Registering OpenCode binary")
const entry = {
path: request.path,
version: undefined,
lastUsed: Date.now(),
label: request.label,
}
const config = this.configStore.get()
const deduped = config.opencodeBinaries.filter((binary) => binary.path !== request.path)
const update: ConfigFileUpdate = {
opencodeBinaries: [entry, ...deduped],
}
if (request.makeDefault) {
update.preferences = { lastUsedBinary: request.path }
}
this.configStore.update(update)
const record = this.getById(request.path)
this.emitChange()
return record
}
update(id: string, updates: BinaryUpdateRequest): BinaryRecord {
this.logger.debug({ id }, "Updating OpenCode binary")
const config = this.configStore.get()
const updatedEntries = config.opencodeBinaries.map((binary) =>
binary.path === id ? { ...binary, label: updates.label ?? binary.label } : binary,
)
const update: ConfigFileUpdate = {
opencodeBinaries: updatedEntries,
}
if (updates.makeDefault) {
update.preferences = { lastUsedBinary: id }
}
this.configStore.update(update)
const record = this.getById(id)
this.emitChange()
return record
}
remove(id: string) {
this.logger.debug({ id }, "Removing OpenCode binary")
const config = this.configStore.get()
const remaining = config.opencodeBinaries.filter((binary) => binary.path !== id)
const update: ConfigFileUpdate = { opencodeBinaries: remaining }
if (config.preferences.lastUsedBinary === id) {
update.preferences = { lastUsedBinary: remaining[0]?.path }
}
this.configStore.update(update)
this.emitChange()
}
validatePath(path: string): BinaryValidationResult {
this.logger.debug({ path }, "Validating OpenCode binary path")
return this.validateRecord({
id: path,
path,
label: this.prettyLabel(path),
isDefault: false,
})
}
private mapRecords(): BinaryRecord[] {
const config = this.configStore.get()
const configuredBinaries = config.opencodeBinaries.map<BinaryRecord>((binary) => ({
id: binary.path,
path: binary.path,
label: binary.label ?? this.prettyLabel(binary.path),
version: binary.version,
isDefault: false,
}))
const defaultPath = config.preferences.lastUsedBinary ?? configuredBinaries[0]?.path ?? "opencode"
const annotated = configuredBinaries.map((binary) => ({
...binary,
isDefault: binary.path === defaultPath,
}))
if (!annotated.some((binary) => binary.path === defaultPath)) {
annotated.unshift(this.buildFallbackRecord(defaultPath))
}
return annotated
}
private getById(id: string): BinaryRecord {
return this.mapRecords().find((binary) => binary.id === id) ?? this.buildFallbackRecord(id)
}
private emitChange() {
this.logger.debug("Emitting binaries changed event")
this.eventBus?.publish({ type: "config.binariesChanged", binaries: this.mapRecords() })
}
private validateRecord(record: BinaryRecord): BinaryValidationResult {
// TODO: call actual binary -v check.
return { valid: true, version: record.version }
}
private buildFallbackRecord(path: string): BinaryRecord {
return {
id: path,
path,
label: this.prettyLabel(path),
isDefault: true,
}
}
private prettyLabel(path: string) {
const parts = path.split(/[\\/]/)
const last = parts[parts.length - 1] || path
return last || path
}
}

View File

@@ -0,0 +1,80 @@
import { z } from "zod"
const ModelPreferenceSchema = z.object({
providerId: z.string(),
modelId: z.string(),
})
const AgentModelSelectionSchema = z.record(z.string(), ModelPreferenceSchema)
const AgentModelSelectionsSchema = z.record(z.string(), AgentModelSelectionSchema)
const PreferencesSchema = z.object({
showThinkingBlocks: z.boolean().default(false),
lastUsedBinary: z.string().optional(),
environmentVariables: z.record(z.string()).default({}),
modelRecents: z.array(ModelPreferenceSchema).default([]),
agentModelSelections: AgentModelSelectionsSchema.default({}),
diffViewMode: z.enum(["split", "unified"]).default("split"),
toolOutputExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
diagnosticsExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
})
const PreferencesUpdateSchema = z.object({
showThinkingBlocks: z.boolean().optional(),
lastUsedBinary: z.string().optional(),
environmentVariables: z.record(z.string()).optional(),
modelRecents: z.array(ModelPreferenceSchema).optional(),
agentModelSelections: AgentModelSelectionsSchema.optional(),
diffViewMode: z.enum(["split", "unified"]).optional(),
toolOutputExpansion: z.enum(["expanded", "collapsed"]).optional(),
diagnosticsExpansion: z.enum(["expanded", "collapsed"]).optional(),
})
const RecentFolderSchema = z.object({
path: z.string(),
lastAccessed: z.number().nonnegative(),
})
const OpenCodeBinarySchema = z.object({
path: z.string(),
version: z.string().optional(),
lastUsed: z.number().nonnegative(),
label: z.string().optional(),
})
const ConfigFileSchema = z.object({
preferences: PreferencesSchema.default({}),
recentFolders: z.array(RecentFolderSchema).default([]),
opencodeBinaries: z.array(OpenCodeBinarySchema).default([]),
theme: z.enum(["light", "dark", "system"]).optional(),
})
const ConfigFileUpdateSchema = z.object({
preferences: PreferencesUpdateSchema.optional(),
recentFolders: z.array(RecentFolderSchema).optional(),
opencodeBinaries: z.array(OpenCodeBinarySchema).optional(),
theme: z.enum(["light", "dark", "system"]).optional(),
})
const DEFAULT_CONFIG = ConfigFileSchema.parse({})
export {
ModelPreferenceSchema,
AgentModelSelectionSchema,
AgentModelSelectionsSchema,
PreferencesSchema,
RecentFolderSchema,
OpenCodeBinarySchema,
ConfigFileSchema,
ConfigFileUpdateSchema,
DEFAULT_CONFIG,
}
export type ModelPreference = z.infer<typeof ModelPreferenceSchema>
export type AgentModelSelection = z.infer<typeof AgentModelSelectionSchema>
export type AgentModelSelections = z.infer<typeof AgentModelSelectionsSchema>
export type Preferences = z.infer<typeof PreferencesSchema>
export type RecentFolder = z.infer<typeof RecentFolderSchema>
export type OpenCodeBinary = z.infer<typeof OpenCodeBinarySchema>
export type ConfigFile = z.infer<typeof ConfigFileSchema>
export type ConfigFileUpdate = z.infer<typeof ConfigFileUpdateSchema>

View File

@@ -0,0 +1,120 @@
import fs from "fs"
import path from "path"
import { EventBus } from "../events/bus"
import { Logger } from "../logger"
import {
AgentModelSelections,
ConfigFile,
ConfigFileUpdate,
ConfigFileSchema,
ConfigFileUpdateSchema,
DEFAULT_CONFIG,
} from "./schema"
export class ConfigStore {
private cache: ConfigFile = DEFAULT_CONFIG
private loaded = false
constructor(
private readonly configPath: string,
private readonly eventBus: EventBus | undefined,
private readonly logger: Logger,
) {}
load(): ConfigFile {
if (this.loaded) {
return this.cache
}
try {
const resolved = this.resolvePath(this.configPath)
if (fs.existsSync(resolved)) {
const content = fs.readFileSync(resolved, "utf-8")
const parsed = JSON.parse(content)
this.cache = ConfigFileSchema.parse(parsed)
this.logger.debug({ resolved }, "Loaded existing config file")
} else {
this.cache = DEFAULT_CONFIG
this.logger.debug({ resolved }, "No config file found, using defaults")
}
} catch (error) {
this.logger.warn({ err: error }, "Failed to load config, using defaults")
this.cache = DEFAULT_CONFIG
}
this.loaded = true
return this.cache
}
get(): ConfigFile {
return this.load()
}
update(partial: ConfigFile | ConfigFileUpdate) {
const safePartial =
"recentFolders" in partial && "opencodeBinaries" in partial
? ConfigFileSchema.parse(partial)
: ConfigFileUpdateSchema.parse(partial ?? {})
const merged = this.mergeConfig(this.load(), safePartial)
this.cache = ConfigFileSchema.parse(merged)
this.persist()
this.eventBus?.publish({ type: "config.appChanged", config: this.cache })
this.logger.debug("Config updated")
}
private mergeConfig(current: ConfigFile, partial: ConfigFile | ConfigFileUpdate): ConfigFile {
const mergedPreferences = {
...current.preferences,
...partial.preferences,
environmentVariables: {
...current.preferences.environmentVariables,
...(partial.preferences?.environmentVariables ?? {}),
},
agentModelSelections: this.mergeAgentSelections(
current.preferences.agentModelSelections,
partial.preferences?.agentModelSelections,
),
}
return {
...current,
...partial,
preferences: mergedPreferences,
recentFolders: partial.recentFolders ?? current.recentFolders,
opencodeBinaries: partial.opencodeBinaries ?? current.opencodeBinaries,
}
}
private mergeAgentSelections(base: AgentModelSelections, update?: AgentModelSelections) {
if (!update) {
return base
}
const result: AgentModelSelections = { ...base }
for (const [instanceId, agentMap] of Object.entries(update)) {
result[instanceId] = {
...(base[instanceId] ?? {}),
...agentMap,
}
}
return result
}
private persist() {
try {
const resolved = this.resolvePath(this.configPath)
fs.mkdirSync(path.dirname(resolved), { recursive: true })
fs.writeFileSync(resolved, JSON.stringify(this.cache, null, 2), "utf-8")
this.logger.debug({ resolved }, "Persisted config file")
} catch (error) {
this.logger.warn({ err: error }, "Failed to persist config")
}
}
private resolvePath(filePath: string) {
if (filePath.startsWith("~/")) {
return path.join(process.env.HOME ?? "", filePath.slice(2))
}
return path.resolve(filePath)
}
}

View File

@@ -0,0 +1,34 @@
import { EventEmitter } from "events"
import { WorkspaceEventPayload } from "../api-types"
import { Logger } from "../logger"
export class EventBus extends EventEmitter {
constructor(private readonly logger?: Logger) {
super()
}
publish(event: WorkspaceEventPayload): boolean {
this.logger?.debug({ event }, "Publishing workspace event")
return super.emit(event.type, event)
}
onEvent(listener: (event: WorkspaceEventPayload) => void) {
const handler = (event: WorkspaceEventPayload) => listener(event)
this.on("workspace.created", handler)
this.on("workspace.started", handler)
this.on("workspace.error", handler)
this.on("workspace.stopped", handler)
this.on("workspace.log", handler)
this.on("config.appChanged", handler)
this.on("config.binariesChanged", handler)
return () => {
this.off("workspace.created", handler)
this.off("workspace.started", handler)
this.off("workspace.error", handler)
this.off("workspace.stopped", handler)
this.off("workspace.log", handler)
this.off("config.appChanged", handler)
this.off("config.binariesChanged", handler)
}
}
}

View File

@@ -0,0 +1,295 @@
import fs from "fs"
import os from "os"
import path from "path"
import {
FileSystemEntry,
FileSystemListResponse,
FileSystemListingMetadata,
WINDOWS_DRIVES_ROOT,
} from "../api-types"
interface FileSystemBrowserOptions {
rootDir: string
unrestricted?: boolean
}
interface DirectoryReadOptions {
includeFiles: boolean
formatPath: (entryName: string) => string
formatAbsolutePath: (entryName: string) => string
}
const WINDOWS_DRIVE_LETTERS = Array.from({ length: 26 }, (_, i) => String.fromCharCode(65 + i))
export class FileSystemBrowser {
private readonly root: string
private readonly unrestricted: boolean
private readonly homeDir: string
private readonly isWindows: boolean
constructor(options: FileSystemBrowserOptions) {
this.root = path.resolve(options.rootDir)
this.unrestricted = Boolean(options.unrestricted)
this.homeDir = os.homedir()
this.isWindows = process.platform === "win32"
}
list(relativePath = ".", options: { includeFiles?: boolean } = {}): FileSystemEntry[] {
if (this.unrestricted) {
throw new Error("Relative listing is unavailable when running with unrestricted root")
}
const includeFiles = options.includeFiles ?? true
const normalizedPath = this.normalizeRelativePath(relativePath)
const absolutePath = this.toRestrictedAbsolute(normalizedPath)
return this.readDirectoryEntries(absolutePath, {
includeFiles,
formatPath: (entryName) => this.buildRelativePath(normalizedPath, entryName),
formatAbsolutePath: (entryName) => this.resolveRestrictedAbsoluteChild(normalizedPath, entryName),
})
}
browse(targetPath?: string, options: { includeFiles?: boolean } = {}): FileSystemListResponse {
const includeFiles = options.includeFiles ?? true
if (this.unrestricted) {
return this.listUnrestricted(targetPath, includeFiles)
}
return this.listRestrictedWithMetadata(targetPath, includeFiles)
}
readFile(relativePath: string): string {
if (this.unrestricted) {
throw new Error("readFile is not available in unrestricted mode")
}
const resolved = this.toRestrictedAbsolute(relativePath)
return fs.readFileSync(resolved, "utf-8")
}
private listRestrictedWithMetadata(relativePath: string | undefined, includeFiles: boolean): FileSystemListResponse {
const normalizedPath = this.normalizeRelativePath(relativePath)
const absolutePath = this.toRestrictedAbsolute(normalizedPath)
const entries = this.readDirectoryEntries(absolutePath, {
includeFiles,
formatPath: (entryName) => this.buildRelativePath(normalizedPath, entryName),
formatAbsolutePath: (entryName) => this.resolveRestrictedAbsoluteChild(normalizedPath, entryName),
})
const metadata: FileSystemListingMetadata = {
scope: "restricted",
currentPath: normalizedPath,
parentPath: normalizedPath === "." ? undefined : this.getRestrictedParent(normalizedPath),
rootPath: this.root,
homePath: this.homeDir,
displayPath: this.resolveRestrictedAbsolute(normalizedPath),
pathKind: "relative",
}
return { entries, metadata }
}
private listUnrestricted(targetPath: string | undefined, includeFiles: boolean): FileSystemListResponse {
const resolvedPath = this.resolveUnrestrictedPath(targetPath)
if (this.isWindows && resolvedPath === WINDOWS_DRIVES_ROOT) {
return this.listWindowsDrives()
}
const entries = this.readDirectoryEntries(resolvedPath, {
includeFiles,
formatPath: (entryName) => this.resolveAbsoluteChild(resolvedPath, entryName),
formatAbsolutePath: (entryName) => this.resolveAbsoluteChild(resolvedPath, entryName),
})
const parentPath = this.getUnrestrictedParent(resolvedPath)
const metadata: FileSystemListingMetadata = {
scope: "unrestricted",
currentPath: resolvedPath,
parentPath,
rootPath: this.homeDir,
homePath: this.homeDir,
displayPath: resolvedPath,
pathKind: "absolute",
}
return { entries, metadata }
}
private listWindowsDrives(): FileSystemListResponse {
if (!this.isWindows) {
throw new Error("Drive listing is only supported on Windows hosts")
}
const entries: FileSystemEntry[] = []
for (const letter of WINDOWS_DRIVE_LETTERS) {
const drivePath = `${letter}:\\`
try {
if (fs.existsSync(drivePath)) {
entries.push({
name: `${letter}:`,
path: drivePath,
absolutePath: drivePath,
type: "directory",
})
}
} catch {
// Ignore inaccessible drives
}
}
// Provide a generic UNC root entry so users can navigate to network shares manually.
entries.push({
name: "UNC Network",
path: "\\\\",
absolutePath: "\\\\",
type: "directory",
})
const metadata: FileSystemListingMetadata = {
scope: "unrestricted",
currentPath: WINDOWS_DRIVES_ROOT,
parentPath: undefined,
rootPath: this.homeDir,
homePath: this.homeDir,
displayPath: "Drives",
pathKind: "drives",
}
return { entries, metadata }
}
private readDirectoryEntries(directory: string, options: DirectoryReadOptions): FileSystemEntry[] {
const dirents = fs.readdirSync(directory, { withFileTypes: true })
const results: FileSystemEntry[] = []
for (const entry of dirents) {
if (!options.includeFiles && !entry.isDirectory()) {
continue
}
const absoluteEntryPath = path.join(directory, entry.name)
let stats: fs.Stats
try {
stats = fs.statSync(absoluteEntryPath)
} catch {
// Skip entries we cannot stat (insufficient permissions, etc.)
continue
}
const isDirectory = entry.isDirectory()
if (!options.includeFiles && !isDirectory) {
continue
}
results.push({
name: entry.name,
path: options.formatPath(entry.name),
absolutePath: options.formatAbsolutePath(entry.name),
type: isDirectory ? "directory" : "file",
size: isDirectory ? undefined : stats.size,
modifiedAt: stats.mtime.toISOString(),
})
}
return results.sort((a, b) => a.name.localeCompare(b.name))
}
private normalizeRelativePath(input: string | undefined) {
if (!input || input === "." || input === "./" || input === "/") {
return "."
}
let normalized = input.replace(/\\+/g, "/")
if (normalized.startsWith("./")) {
normalized = normalized.replace(/^\.\/+/, "")
}
if (normalized.startsWith("/")) {
normalized = normalized.replace(/^\/+/g, "")
}
return normalized === "" ? "." : normalized
}
private buildRelativePath(parent: string, child: string) {
if (!parent || parent === ".") {
return this.normalizeRelativePath(child)
}
return this.normalizeRelativePath(`${parent}/${child}`)
}
private resolveRestrictedAbsolute(relativePath: string) {
return this.toRestrictedAbsolute(relativePath)
}
private resolveRestrictedAbsoluteChild(parent: string, child: string) {
const normalized = this.buildRelativePath(parent, child)
return this.toRestrictedAbsolute(normalized)
}
private toRestrictedAbsolute(relativePath: string) {
const normalized = this.normalizeRelativePath(relativePath)
const target = path.resolve(this.root, normalized)
const relativeToRoot = path.relative(this.root, target)
if (relativeToRoot.startsWith("..") || path.isAbsolute(relativeToRoot) && relativeToRoot !== "") {
throw new Error("Access outside of root is not allowed")
}
return target
}
private resolveUnrestrictedPath(input: string | undefined): string {
if (!input || input === "." || input === "./") {
return this.homeDir
}
if (this.isWindows) {
if (input === WINDOWS_DRIVES_ROOT) {
return WINDOWS_DRIVES_ROOT
}
const normalized = path.win32.normalize(input)
if (/^[a-zA-Z]:/.test(normalized) || normalized.startsWith("\\\\")) {
return normalized
}
return path.win32.resolve(this.homeDir, normalized)
}
if (input.startsWith("/")) {
return path.posix.normalize(input)
}
return path.posix.resolve(this.homeDir, input)
}
private resolveAbsoluteChild(parent: string, child: string) {
if (this.isWindows) {
return path.win32.normalize(path.win32.join(parent, child))
}
return path.posix.normalize(path.posix.join(parent, child))
}
private getRestrictedParent(relativePath: string) {
const normalized = this.normalizeRelativePath(relativePath)
if (normalized === ".") {
return undefined
}
const segments = normalized.split("/")
segments.pop()
return segments.length === 0 ? "." : segments.join("/")
}
private getUnrestrictedParent(currentPath: string) {
if (this.isWindows) {
const normalized = path.win32.normalize(currentPath)
const parsed = path.win32.parse(normalized)
if (normalized === WINDOWS_DRIVES_ROOT) {
return undefined
}
if (normalized === parsed.root) {
return WINDOWS_DRIVES_ROOT
}
return path.win32.dirname(normalized)
}
const normalized = path.posix.normalize(currentPath)
if (normalized === "/") {
return undefined
}
return path.posix.dirname(normalized)
}
}

183
packages/cli/src/index.ts Normal file
View File

@@ -0,0 +1,183 @@
/**
* CLI entry point.
* For now this only wires the typed modules together; actual command handling comes later.
*/
import { Command, InvalidArgumentError, Option } from "commander"
import path from "path"
import { fileURLToPath } from "url"
import { createRequire } from "module"
import { createHttpServer } from "./server/http-server"
import { WorkspaceManager } from "./workspaces/manager"
import { ConfigStore } from "./config/store"
import { BinaryRegistry } from "./config/binaries"
import { FileSystemBrowser } from "./filesystem/browser"
import { EventBus } from "./events/bus"
import { ServerMeta } from "./api-types"
import { InstanceStore } from "./storage/instance-store"
import { createLogger } from "./logger"
const require = createRequire(import.meta.url)
const packageJson = require("../package.json") as { version: string }
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const DEFAULT_UI_STATIC_DIR = path.resolve(__dirname, "../public")
interface CliOptions {
port: number
host: string
rootDir: string
configPath: string
unrestrictedRoot: boolean
logLevel?: string
logDestination?: string
uiStaticDir: string
uiDevServer?: string
}
const DEFAULT_PORT = 9898
const DEFAULT_HOST = "127.0.0.1"
const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json"
function parseCliOptions(argv: string[]): CliOptions {
const program = new Command()
.name("codenomad-cli")
.description("CodeNomad CLI server")
.version(packageJson.version, "-v, --version", "Show the CLI version")
.addOption(new Option("--host <host>", "Host interface to bind").env("CLI_HOST").default(DEFAULT_HOST))
.addOption(new Option("--port <number>", "Port for the HTTP server").env("CLI_PORT").default(DEFAULT_PORT).argParser(parsePort))
.addOption(
new Option("--workspace-root <path>", "Workspace root directory").env("CLI_WORKSPACE_ROOT").default(process.cwd()),
)
.addOption(new Option("--root <path>").env("CLI_ROOT").hideHelp(true))
.addOption(new Option("--unrestricted-root", "Allow browsing the full filesystem").env("CLI_UNRESTRICTED_ROOT").default(false))
.addOption(new Option("--config <path>", "Path to the config file").env("CLI_CONFIG").default(DEFAULT_CONFIG_PATH))
.addOption(new Option("--log-level <level>", "Log level (trace|debug|info|warn|error)").env("CLI_LOG_LEVEL"))
.addOption(new Option("--log-destination <path>", "Log destination file (defaults to stdout)").env("CLI_LOG_DESTINATION"))
.addOption(
new Option("--ui-dir <path>", "Directory containing the built UI bundle").env("CLI_UI_DIR").default(DEFAULT_UI_STATIC_DIR),
)
.addOption(new Option("--ui-dev-server <url>", "Proxy UI requests to a running dev server").env("CLI_UI_DEV_SERVER"))
program.parse(argv, { from: "user" })
const parsed = program.opts<{
host: string
port: number
workspaceRoot?: string
root?: string
unrestrictedRoot?: boolean
config: string
logLevel?: string
logDestination?: string
uiDir: string
uiDevServer?: string
}>()
const resolvedRoot = parsed.workspaceRoot ?? parsed.root ?? process.cwd()
return {
port: parsed.port,
host: parsed.host,
rootDir: resolvedRoot,
configPath: parsed.config,
unrestrictedRoot: Boolean(parsed.unrestrictedRoot),
logLevel: parsed.logLevel,
logDestination: parsed.logDestination,
uiStaticDir: parsed.uiDir,
uiDevServer: parsed.uiDevServer,
}
}
function parsePort(input: string): number {
const value = Number(input)
if (!Number.isInteger(value) || value < 1 || value > 65535) {
throw new InvalidArgumentError("Port must be an integer between 1 and 65535")
}
return value
}
async function main() {
const options = parseCliOptions(process.argv.slice(2))
const logger = createLogger({ level: options.logLevel, destination: options.logDestination, component: "app" })
const workspaceLogger = logger.child({ component: "workspace" })
const configLogger = logger.child({ component: "config" })
const eventLogger = logger.child({ component: "events" })
logger.info({ options }, "Starting CodeNomad CLI server")
const eventBus = new EventBus(eventLogger)
const configStore = new ConfigStore(options.configPath, eventBus, configLogger)
const binaryRegistry = new BinaryRegistry(configStore, eventBus, configLogger)
const workspaceManager = new WorkspaceManager({
rootDir: options.rootDir,
configStore,
binaryRegistry,
eventBus,
logger: workspaceLogger,
})
const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot })
const instanceStore = new InstanceStore()
const serverMeta: ServerMeta = {
httpBaseUrl: `http://${options.host}:${options.port}`,
eventsUrl: `/api/events`,
hostLabel: options.host,
workspaceRoot: options.rootDir,
}
const server = createHttpServer({
host: options.host,
port: options.port,
workspaceManager,
configStore,
binaryRegistry,
fileSystemBrowser,
eventBus,
serverMeta,
instanceStore,
uiStaticDir: options.uiStaticDir,
uiDevServerUrl: options.uiDevServer,
logger,
})
await server.start()
logger.info({ port: options.port, host: options.host }, "HTTP server listening")
const displayHost = options.host === "127.0.0.1" || options.host === "0.0.0.0" ? "localhost" : options.host
console.log(`CodeNomad Server is ready at http://${displayHost}:${options.port}`)
let shuttingDown = false
const shutdown = async () => {
if (shuttingDown) {
logger.info("Shutdown already in progress, ignoring signal")
return
}
shuttingDown = true
logger.info("Received shutdown signal, closing server")
try {
await server.stop()
logger.info("HTTP server stopped")
} catch (error) {
logger.error({ err: error }, "Failed to stop HTTP server")
}
try {
await workspaceManager.shutdown()
logger.info("Workspace manager shutdown complete")
} catch (error) {
logger.error({ err: error }, "Workspace manager shutdown failed")
}
logger.info("Exiting process")
process.exit(0)
}
process.on("SIGINT", shutdown)
process.on("SIGTERM", shutdown)
}
main().catch((error) => {
const logger = createLogger({ component: "app" })
logger.error({ err: error }, "CLI server crashed")
process.exit(1)
})

View File

@@ -0,0 +1,21 @@
export async function resolve(specifier: string, context: any, defaultResolve: any) {
try {
return await defaultResolve(specifier, context, defaultResolve)
} catch (error: any) {
if (shouldRetry(specifier, error)) {
const retried = specifier.endsWith(".js") ? specifier : `${specifier}.js`
return defaultResolve(retried, context, defaultResolve)
}
throw error
}
}
function shouldRetry(specifier: string, error: any) {
if (!error || error.code !== "ERR_MODULE_NOT_FOUND") {
return false
}
if (specifier.startsWith("./") || specifier.startsWith("../")) {
return true
}
return false
}

133
packages/cli/src/logger.ts Normal file
View File

@@ -0,0 +1,133 @@
import { Transform } from "node:stream"
import pino, { Logger as PinoLogger } from "pino"
export type Logger = PinoLogger
interface LoggerOptions {
level?: string
destination?: string
component?: string
}
const LEVEL_LABELS: Record<number, string> = {
10: "trace",
20: "debug",
30: "info",
40: "warn",
50: "error",
60: "fatal",
}
const LIFECYCLE_COMPONENTS = new Set(["app", "workspace"])
const OMITTED_FIELDS = new Set(["time", "msg", "level", "component", "module"])
export function createLogger(options: LoggerOptions = {}): Logger {
const level = (options.level ?? process.env.CLI_LOG_LEVEL ?? "info").toLowerCase()
const destination = options.destination ?? process.env.CLI_LOG_DESTINATION ?? "stdout"
const baseComponent = options.component ?? "app"
const loggerOptions = {
level,
base: { component: baseComponent },
timestamp: false,
} as const
if (destination && destination !== "stdout") {
const stream = pino.destination({ dest: destination, mkdir: true, sync: false })
return pino(loggerOptions, stream)
}
const lifecycleStream = new LifecycleLogStream({ restrictInfoToLifecycle: level === "info" })
lifecycleStream.pipe(process.stdout)
return pino(loggerOptions, lifecycleStream)
}
interface LifecycleStreamOptions {
restrictInfoToLifecycle: boolean
}
class LifecycleLogStream extends Transform {
private buffer = ""
constructor(private readonly options: LifecycleStreamOptions) {
super()
}
_transform(chunk: Buffer, _encoding: BufferEncoding, callback: () => void) {
this.buffer += chunk.toString()
let newlineIndex = this.buffer.indexOf("\n")
while (newlineIndex >= 0) {
const line = this.buffer.slice(0, newlineIndex)
this.buffer = this.buffer.slice(newlineIndex + 1)
this.pushFormatted(line)
newlineIndex = this.buffer.indexOf("\n")
}
callback()
}
_flush(callback: () => void) {
if (this.buffer.length > 0) {
this.pushFormatted(this.buffer)
this.buffer = ""
}
callback()
}
private pushFormatted(line: string) {
if (!line.trim()) {
return
}
let entry: Record<string, unknown>
try {
entry = JSON.parse(line)
} catch {
return
}
const levelNumber = typeof entry.level === "number" ? entry.level : 30
const levelLabel = LEVEL_LABELS[levelNumber] ?? "info"
const component = (entry.component as string | undefined) ?? (entry.module as string | undefined) ?? "app"
if (this.options.restrictInfoToLifecycle && levelNumber <= 30 && !LIFECYCLE_COMPONENTS.has(component)) {
return
}
const message = typeof entry.msg === "string" ? entry.msg : ""
const metadata = this.formatMetadata(entry)
const formatted = metadata.length > 0 ? `[${levelLabel.toUpperCase()}] [${component}] ${message} ${metadata}` : `[${levelLabel.toUpperCase()}] [${component}] ${message}`
this.push(`${formatted}\n`)
}
private formatMetadata(entry: Record<string, unknown>): string {
const pairs: string[] = []
for (const [key, value] of Object.entries(entry)) {
if (OMITTED_FIELDS.has(key)) {
continue
}
if (key === "err" && value && typeof value === "object") {
const err = value as { type?: string; message?: string; stack?: string }
const errLabel = err.type ?? "Error"
const errMessage = err.message ? `: ${err.message}` : ""
pairs.push(`err=${errLabel}${errMessage}`)
if (err.stack) {
pairs.push(`stack="${err.stack}"`)
}
continue
}
pairs.push(`${key}=${this.stringifyValue(value)}`)
}
return pairs.join(" ").trim()
}
private stringifyValue(value: unknown): string {
if (value === undefined) return "undefined"
if (value === null) return "null"
if (typeof value === "string") return value
if (typeof value === "number" || typeof value === "boolean") return String(value)
if (value instanceof Error) return value.message ?? value.name
return JSON.stringify(value)
}
}

View File

@@ -0,0 +1,262 @@
import Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from "fastify"
import cors from "@fastify/cors"
import fastifyStatic from "@fastify/static"
import replyFrom, { type FastifyReplyFromOptions } from "@fastify/reply-from"
import fs from "fs"
import path from "path"
import { fetch } from "undici"
import type { Logger } from "../logger"
import { WorkspaceManager } from "../workspaces/manager"
import { ConfigStore } from "../config/store"
import { BinaryRegistry } from "../config/binaries"
import { FileSystemBrowser } from "../filesystem/browser"
import { EventBus } from "../events/bus"
import { registerWorkspaceRoutes } from "./routes/workspaces"
import { registerConfigRoutes } from "./routes/config"
import { registerFilesystemRoutes } from "./routes/filesystem"
import { registerMetaRoutes } from "./routes/meta"
import { registerEventRoutes } from "./routes/events"
import { registerStorageRoutes } from "./routes/storage"
import { ServerMeta } from "../api-types"
import { InstanceStore } from "../storage/instance-store"
interface HttpServerDeps {
host: string
port: number
workspaceManager: WorkspaceManager
configStore: ConfigStore
binaryRegistry: BinaryRegistry
fileSystemBrowser: FileSystemBrowser
eventBus: EventBus
serverMeta: ServerMeta
instanceStore: InstanceStore
uiStaticDir: string
uiDevServerUrl?: string
logger: Logger
}
export function createHttpServer(deps: HttpServerDeps) {
const app = Fastify({ logger: false })
const proxyLogger = deps.logger.child({ component: "proxy" })
const sseClients = new Set<() => void>()
const registerSseClient = (cleanup: () => void) => {
sseClients.add(cleanup)
return () => sseClients.delete(cleanup)
}
const closeSseClients = () => {
for (const cleanup of Array.from(sseClients)) {
cleanup()
}
sseClients.clear()
}
app.register(cors, {
origin: true,
credentials: true,
})
app.register(replyFrom, {
contentTypesToEncode: [],
})
registerWorkspaceRoutes(app, { workspaceManager: deps.workspaceManager })
registerConfigRoutes(app, { configStore: deps.configStore, binaryRegistry: deps.binaryRegistry })
registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser })
registerMetaRoutes(app, { serverMeta: deps.serverMeta })
registerEventRoutes(app, { eventBus: deps.eventBus, registerClient: registerSseClient })
registerStorageRoutes(app, { instanceStore: deps.instanceStore })
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger })
if (deps.uiDevServerUrl) {
setupDevProxy(app, deps.uiDevServerUrl)
} else {
setupStaticUi(app, deps.uiStaticDir)
}
return {
instance: app,
start: () => app.listen({ port: deps.port, host: deps.host }),
stop: () => {
closeSseClients()
return app.close()
},
}
}
interface InstanceProxyDeps {
workspaceManager: WorkspaceManager
logger: Logger
}
function registerInstanceProxyRoutes(app: FastifyInstance, deps: InstanceProxyDeps) {
app.register(async (instance) => {
instance.removeAllContentTypeParsers()
instance.addContentTypeParser("*", (req, body, done) => done(null, body))
const proxyBaseHandler = async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
await proxyWorkspaceRequest({
request,
reply,
workspaceManager: deps.workspaceManager,
pathSuffix: "",
logger: deps.logger,
})
}
const proxyWildcardHandler = async (
request: FastifyRequest<{ Params: { id: string; "*": string } }>,
reply: FastifyReply,
) => {
await proxyWorkspaceRequest({
request,
reply,
workspaceManager: deps.workspaceManager,
pathSuffix: request.params["*"] ?? "",
logger: deps.logger,
})
}
instance.all("/workspaces/:id/instance", proxyBaseHandler)
instance.all("/workspaces/:id/instance/*", proxyWildcardHandler)
})
}
const INSTANCE_PROXY_HOST = "127.0.0.1"
async function proxyWorkspaceRequest(args: {
request: FastifyRequest
reply: FastifyReply
workspaceManager: WorkspaceManager
logger: Logger
pathSuffix?: string
}) {
const { request, reply, workspaceManager, logger } = args
const workspaceId = (request.params as { id: string }).id
const workspace = workspaceManager.get(workspaceId)
if (!workspace) {
reply.code(404).send({ error: "Workspace not found" })
return
}
const port = workspaceManager.getInstancePort(workspaceId)
if (!port) {
reply.code(502).send({ error: "Workspace instance is not ready" })
return
}
const normalizedSuffix = normalizeInstanceSuffix(args.pathSuffix)
const queryIndex = (request.raw.url ?? "").indexOf("?")
const search = queryIndex >= 0 ? (request.raw.url ?? "").slice(queryIndex) : ""
const targetUrl = `http://${INSTANCE_PROXY_HOST}:${port}${normalizedSuffix}${search}`
return reply.from(targetUrl, {
onError: (proxyReply, { error }) => {
logger.error({ err: error, workspaceId, targetUrl }, "Failed to proxy workspace request")
if (!proxyReply.sent) {
proxyReply.code(502).send({ error: "Workspace instance proxy failed" })
}
},
})
}
function normalizeInstanceSuffix(pathSuffix: string | undefined) {
if (!pathSuffix || pathSuffix === "/") {
return "/"
}
const trimmed = pathSuffix.replace(/^\/+/, "")
return trimmed.length === 0 ? "/" : `/${trimmed}`
}
function setupStaticUi(app: FastifyInstance, uiDir: string) {
if (!uiDir) {
app.log.warn("UI static directory not provided; API endpoints only")
return
}
if (!fs.existsSync(uiDir)) {
app.log.warn({ uiDir }, "UI static directory missing; API endpoints only")
return
}
app.register(fastifyStatic, {
root: uiDir,
prefix: "/",
decorateReply: false,
})
const indexPath = path.join(uiDir, "index.html")
app.setNotFoundHandler((request: FastifyRequest, reply: FastifyReply) => {
const url = request.raw.url ?? ""
if (isApiRequest(url)) {
reply.code(404).send({ message: "Not Found" })
return
}
if (fs.existsSync(indexPath)) {
reply.type("text/html").send(fs.readFileSync(indexPath, "utf-8"))
} else {
reply.code(404).send({ message: "UI bundle missing" })
}
})
}
function setupDevProxy(app: FastifyInstance, upstreamBase: string) {
app.log.info({ upstreamBase }, "Proxying UI requests to development server")
app.setNotFoundHandler((request: FastifyRequest, reply: FastifyReply) => {
const url = request.raw.url ?? ""
if (isApiRequest(url)) {
reply.code(404).send({ message: "Not Found" })
return
}
void proxyToDevServer(request, reply, upstreamBase)
})
}
async function proxyToDevServer(request: FastifyRequest, reply: FastifyReply, upstreamBase: string) {
try {
const targetUrl = new URL(request.raw.url ?? "/", upstreamBase)
const response = await fetch(targetUrl, {
method: request.method,
headers: buildProxyHeaders(request.headers),
})
response.headers.forEach((value, key) => {
reply.header(key, value)
})
reply.code(response.status)
if (!response.body || request.method === "HEAD") {
reply.send()
return
}
const buffer = Buffer.from(await response.arrayBuffer())
reply.send(buffer)
} catch (error) {
request.log.error({ err: error }, "Failed to proxy UI request to dev server")
if (!reply.sent) {
reply.code(502).send("UI dev server is unavailable")
}
}
}
function isApiRequest(rawUrl: string | null | undefined) {
if (!rawUrl) return false
const pathname = rawUrl.split("?")[0] ?? ""
return pathname === "/api" || pathname.startsWith("/api/")
}
function buildProxyHeaders(headers: FastifyRequest["headers"]): Record<string, string> {
const result: Record<string, string> = {}
for (const [key, value] of Object.entries(headers ?? {})) {
if (!value || key.toLowerCase() === "host") continue
result[key] = Array.isArray(value) ? value.join(",") : value
}
return result
}

View File

@@ -0,0 +1,68 @@
import { FastifyInstance } from "fastify"
import { z } from "zod"
import { ConfigStore } from "../../config/store"
import { BinaryRegistry } from "../../config/binaries"
import { ConfigFileSchema, ConfigFileUpdateSchema } from "../../config/schema"
interface RouteDeps {
configStore: ConfigStore
binaryRegistry: BinaryRegistry
}
const BinaryCreateSchema = z.object({
path: z.string(),
label: z.string().optional(),
makeDefault: z.boolean().optional(),
})
const BinaryUpdateSchema = z.object({
label: z.string().optional(),
makeDefault: z.boolean().optional(),
})
const BinaryValidateSchema = z.object({
path: z.string(),
})
export function registerConfigRoutes(app: FastifyInstance, deps: RouteDeps) {
app.get("/api/config/app", async () => deps.configStore.get())
app.put("/api/config/app", async (request) => {
const body = ConfigFileSchema.parse(request.body ?? {})
deps.configStore.update(body)
return deps.configStore.get()
})
app.patch("/api/config/app", async (request) => {
const body = ConfigFileUpdateSchema.parse(request.body ?? {})
deps.configStore.update(body)
return deps.configStore.get()
})
app.get("/api/config/binaries", async () => {
return { binaries: deps.binaryRegistry.list() }
})
app.post("/api/config/binaries", async (request, reply) => {
const body = BinaryCreateSchema.parse(request.body ?? {})
const binary = deps.binaryRegistry.create(body)
reply.code(201)
return { binary }
})
app.patch<{ Params: { id: string } }>("/api/config/binaries/:id", async (request) => {
const body = BinaryUpdateSchema.parse(request.body ?? {})
const binary = deps.binaryRegistry.update(request.params.id, body)
return { binary }
})
app.delete<{ Params: { id: string } }>("/api/config/binaries/:id", async (request, reply) => {
deps.binaryRegistry.remove(request.params.id)
reply.code(204)
})
app.post("/api/config/binaries/validate", async (request) => {
const body = BinaryValidateSchema.parse(request.body ?? {})
return deps.binaryRegistry.validatePath(body.path)
})
}

View File

@@ -0,0 +1,49 @@
import { FastifyInstance } from "fastify"
import { EventBus } from "../../events/bus"
import { WorkspaceEventPayload } from "../../api-types"
interface RouteDeps {
eventBus: EventBus
registerClient: (cleanup: () => void) => () => void
}
export function registerEventRoutes(app: FastifyInstance, deps: RouteDeps) {
app.get("/api/events", (request, reply) => {
const origin = request.headers.origin ?? "*"
reply.raw.setHeader("Access-Control-Allow-Origin", origin)
reply.raw.setHeader("Access-Control-Allow-Credentials", "true")
reply.raw.setHeader("Content-Type", "text/event-stream")
reply.raw.setHeader("Cache-Control", "no-cache")
reply.raw.setHeader("Connection", "keep-alive")
reply.raw.flushHeaders?.()
reply.hijack()
const send = (event: WorkspaceEventPayload) => {
reply.raw.write(`data: ${JSON.stringify(event)}\n\n`)
}
const unsubscribe = deps.eventBus.onEvent(send)
const heartbeat = setInterval(() => {
reply.raw.write(`:hb ${Date.now()}\n\n`)
}, 15000)
let closed = false
const close = () => {
if (closed) return
closed = true
clearInterval(heartbeat)
unsubscribe()
reply.raw.end?.()
}
const unregister = deps.registerClient(close)
const handleClose = () => {
close()
unregister()
}
request.raw.on("close", handleClose)
request.raw.on("error", handleClose)
})
}

View File

@@ -0,0 +1,27 @@
import { FastifyInstance } from "fastify"
import { z } from "zod"
import { FileSystemBrowser } from "../../filesystem/browser"
interface RouteDeps {
fileSystemBrowser: FileSystemBrowser
}
const FilesystemQuerySchema = z.object({
path: z.string().optional(),
includeFiles: z.coerce.boolean().optional(),
})
export function registerFilesystemRoutes(app: FastifyInstance, deps: RouteDeps) {
app.get("/api/filesystem", async (request, reply) => {
const query = FilesystemQuerySchema.parse(request.query ?? {})
try {
return deps.fileSystemBrowser.browse(query.path, {
includeFiles: query.includeFiles,
})
} catch (error) {
reply.code(400)
return { error: (error as Error).message }
}
})
}

View File

@@ -0,0 +1,10 @@
import { FastifyInstance } from "fastify"
import { ServerMeta } from "../../api-types"
interface RouteDeps {
serverMeta: ServerMeta
}
export function registerMetaRoutes(app: FastifyInstance, deps: RouteDeps) {
app.get("/api/meta", async () => deps.serverMeta)
}

View File

@@ -0,0 +1,44 @@
import { FastifyInstance } from "fastify"
import { z } from "zod"
import { InstanceStore } from "../../storage/instance-store"
interface RouteDeps {
instanceStore: InstanceStore
}
const InstanceDataSchema = z.object({
messageHistory: z.array(z.string()).default([]),
})
export function registerStorageRoutes(app: FastifyInstance, deps: RouteDeps) {
app.get<{ Params: { id: string } }>("/api/storage/instances/:id", async (request, reply) => {
try {
const data = await deps.instanceStore.read(request.params.id)
return data
} catch (error) {
reply.code(500)
return { error: error instanceof Error ? error.message : "Failed to read instance data" }
}
})
app.put<{ Params: { id: string } }>("/api/storage/instances/:id", async (request, reply) => {
try {
const body = InstanceDataSchema.parse(request.body ?? {})
await deps.instanceStore.write(request.params.id, body)
reply.code(204)
} catch (error) {
reply.code(400)
return { error: error instanceof Error ? error.message : "Failed to save instance data" }
}
})
app.delete<{ Params: { id: string } }>("/api/storage/instances/:id", async (request, reply) => {
try {
await deps.instanceStore.delete(request.params.id)
reply.code(204)
} catch (error) {
reply.code(500)
return { error: error instanceof Error ? error.message : "Failed to delete instance data" }
}
})
}

View File

@@ -0,0 +1,80 @@
import { FastifyInstance, FastifyReply } from "fastify"
import { z } from "zod"
import { WorkspaceManager } from "../../workspaces/manager"
interface RouteDeps {
workspaceManager: WorkspaceManager
}
const WorkspaceCreateSchema = z.object({
path: z.string(),
name: z.string().optional(),
})
const WorkspaceFilesQuerySchema = z.object({
path: z.string().optional(),
})
const WorkspaceFileContentQuerySchema = z.object({
path: z.string(),
})
export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) {
app.get("/api/workspaces", async () => {
return deps.workspaceManager.list()
})
app.post("/api/workspaces", async (request, reply) => {
const body = WorkspaceCreateSchema.parse(request.body ?? {})
const workspace = await deps.workspaceManager.create(body.path, body.name)
reply.code(201)
return workspace
})
app.get<{ Params: { id: string } }>("/api/workspaces/:id", async (request, reply) => {
const workspace = deps.workspaceManager.get(request.params.id)
if (!workspace) {
reply.code(404)
return { error: "Workspace not found" }
}
return workspace
})
app.delete<{ Params: { id: string } }>("/api/workspaces/:id", async (request, reply) => {
await deps.workspaceManager.delete(request.params.id)
reply.code(204)
})
app.get<{
Params: { id: string }
Querystring: { path?: string }
}>("/api/workspaces/:id/files", async (request, reply) => {
try {
const query = WorkspaceFilesQuerySchema.parse(request.query ?? {})
return deps.workspaceManager.listFiles(request.params.id, query.path ?? ".")
} catch (error) {
return handleWorkspaceError(error, reply)
}
})
app.get<{
Params: { id: string }
Querystring: { path?: string }
}>("/api/workspaces/:id/files/content", async (request, reply) => {
try {
const query = WorkspaceFileContentQuerySchema.parse(request.query ?? {})
return deps.workspaceManager.readFile(request.params.id, query.path)
} catch (error) {
return handleWorkspaceError(error, reply)
}
})
}
function handleWorkspaceError(error: unknown, reply: FastifyReply) {
if (error instanceof Error && error.message === "Workspace not found") {
reply.code(404)
return { error: "Workspace not found" }
}
reply.code(400)
return { error: error instanceof Error ? error.message : "Unable to fulfill request" }
}

View File

@@ -0,0 +1,63 @@
import fs from "fs"
import { promises as fsp } from "fs"
import os from "os"
import path from "path"
import type { InstanceData } from "../api-types"
const DEFAULT_INSTANCE_DATA: InstanceData = {
messageHistory: [],
}
export class InstanceStore {
private readonly instancesDir: string
constructor(baseDir = path.join(os.homedir(), ".config", "codenomad", "instances")) {
this.instancesDir = baseDir
fs.mkdirSync(this.instancesDir, { recursive: true })
}
async read(id: string): Promise<InstanceData> {
try {
const filePath = this.resolvePath(id)
const content = await fsp.readFile(filePath, "utf-8")
const parsed = JSON.parse(content)
return { ...DEFAULT_INSTANCE_DATA, ...parsed }
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return DEFAULT_INSTANCE_DATA
}
throw error
}
}
async write(id: string, data: InstanceData): Promise<void> {
const filePath = this.resolvePath(id)
await fsp.mkdir(path.dirname(filePath), { recursive: true })
await fsp.writeFile(filePath, JSON.stringify(data, null, 2), "utf-8")
}
async delete(id: string): Promise<void> {
try {
const filePath = this.resolvePath(id)
await fsp.unlink(filePath)
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
throw error
}
}
}
private resolvePath(id: string): string {
const filename = this.sanitizeId(id)
return path.join(this.instancesDir, `${filename}.json`)
}
private sanitizeId(id: string): string {
return id
.replace(/[\\/]/g, "_")
.replace(/[^a-zA-Z0-9_.-]/g, "_")
.replace(/_{2,}/g, "_")
.replace(/^_|_$/g, "")
.toLowerCase()
}
}

View File

@@ -0,0 +1,173 @@
import path from "path"
import { EventBus } from "../events/bus"
import { ConfigStore } from "../config/store"
import { BinaryRegistry } from "../config/binaries"
import { FileSystemBrowser } from "../filesystem/browser"
import { WorkspaceDescriptor, WorkspaceFileResponse, FileSystemEntry } from "../api-types"
import { WorkspaceRuntime } from "./runtime"
import { Logger } from "../logger"
interface WorkspaceManagerOptions {
rootDir: string
configStore: ConfigStore
binaryRegistry: BinaryRegistry
eventBus: EventBus
logger: Logger
}
interface WorkspaceRecord extends WorkspaceDescriptor {}
export class WorkspaceManager {
private readonly workspaces = new Map<string, WorkspaceRecord>()
private readonly runtime: WorkspaceRuntime
constructor(private readonly options: WorkspaceManagerOptions) {
this.runtime = new WorkspaceRuntime(this.options.eventBus, this.options.logger)
}
list(): WorkspaceDescriptor[] {
return Array.from(this.workspaces.values())
}
get(id: string): WorkspaceDescriptor | undefined {
return this.workspaces.get(id)
}
getInstancePort(id: string): number | undefined {
return this.workspaces.get(id)?.port
}
listFiles(workspaceId: string, relativePath = "."): FileSystemEntry[] {
const workspace = this.requireWorkspace(workspaceId)
const browser = new FileSystemBrowser({ rootDir: workspace.path })
return browser.list(relativePath)
}
readFile(workspaceId: string, relativePath: string): WorkspaceFileResponse {
const workspace = this.requireWorkspace(workspaceId)
const browser = new FileSystemBrowser({ rootDir: workspace.path })
const contents = browser.readFile(relativePath)
return {
workspaceId,
relativePath,
contents,
}
}
async create(folder: string, name?: string): Promise<WorkspaceDescriptor> {
const id = `${Date.now().toString(36)}`
const binary = this.options.binaryRegistry.resolveDefault()
const workspacePath = path.isAbsolute(folder) ? folder : path.resolve(this.options.rootDir, folder)
this.options.logger.info({ workspaceId: id, folder: workspacePath, binary: binary.path }, "Creating workspace")
const proxyPath = `/workspaces/${id}/instance`
const descriptor: WorkspaceRecord = {
id,
path: workspacePath,
name,
status: "starting",
proxyPath,
binaryId: binary.id,
binaryLabel: binary.label,
binaryVersion: binary.version,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}
this.workspaces.set(id, descriptor)
this.options.eventBus.publish({ type: "workspace.created", workspace: descriptor })
const environment = this.options.configStore.get().preferences.environmentVariables ?? {}
try {
const { pid, port } = await this.runtime.launch({
workspaceId: id,
folder: workspacePath,
binaryPath: binary.path,
environment,
onExit: (info) => this.handleProcessExit(info.workspaceId, info),
})
descriptor.pid = pid
descriptor.port = port
descriptor.status = "ready"
descriptor.updatedAt = new Date().toISOString()
this.options.eventBus.publish({ type: "workspace.started", workspace: descriptor })
this.options.logger.info({ workspaceId: id, port }, "Workspace ready")
return descriptor
} catch (error) {
descriptor.status = "error"
descriptor.error = error instanceof Error ? error.message : String(error)
descriptor.updatedAt = new Date().toISOString()
this.options.eventBus.publish({ type: "workspace.error", workspace: descriptor })
this.options.logger.error({ workspaceId: id, err: error }, "Workspace failed to start")
throw error
}
}
async delete(id: string): Promise<WorkspaceDescriptor | undefined> {
const workspace = this.workspaces.get(id)
if (!workspace) return undefined
this.options.logger.info({ workspaceId: id }, "Stopping workspace")
const wasRunning = Boolean(workspace.pid)
if (wasRunning) {
await this.runtime.stop(id).catch((error) => {
this.options.logger.warn({ workspaceId: id, err: error }, "Failed to stop workspace process cleanly")
})
}
this.workspaces.delete(id)
if (!wasRunning) {
this.options.eventBus.publish({ type: "workspace.stopped", workspaceId: id })
}
return workspace
}
async shutdown() {
this.options.logger.info("Shutting down all workspaces")
for (const [id, workspace] of this.workspaces) {
if (workspace.pid) {
this.options.logger.info({ workspaceId: id }, "Stopping workspace during shutdown")
await this.runtime.stop(id).catch((error) => {
this.options.logger.error({ workspaceId: id, err: error }, "Failed to stop workspace during shutdown")
})
} else {
this.options.logger.debug({ workspaceId: id }, "Workspace already stopped")
}
}
this.workspaces.clear()
this.options.logger.info("All workspaces cleared")
}
private requireWorkspace(id: string): WorkspaceRecord {
const workspace = this.workspaces.get(id)
if (!workspace) {
throw new Error("Workspace not found")
}
return workspace
}
private handleProcessExit(workspaceId: string, info: { code: number | null; requested: boolean }) {
const workspace = this.workspaces.get(workspaceId)
if (!workspace) return
this.options.logger.info({ workspaceId, ...info }, "Workspace process exited")
workspace.pid = undefined
workspace.port = undefined
workspace.updatedAt = new Date().toISOString()
if (info.requested || info.code === 0) {
workspace.status = "stopped"
workspace.error = undefined
this.options.eventBus.publish({ type: "workspace.stopped", workspaceId })
} else {
workspace.status = "error"
workspace.error = `Process exited with code ${info.code}`
this.options.eventBus.publish({ type: "workspace.error", workspace })
}
}
}

View File

@@ -0,0 +1,214 @@
import { ChildProcess, spawn } from "child_process"
import { existsSync, statSync } from "fs"
import path from "path"
import { EventBus } from "../events/bus"
import { LogLevel, WorkspaceLogEntry } from "../api-types"
import { Logger } from "../logger"
interface LaunchOptions {
workspaceId: string
folder: string
binaryPath: string
environment?: Record<string, string>
onExit?: (info: ProcessExitInfo) => void
}
interface ProcessExitInfo {
workspaceId: string
code: number | null
signal: NodeJS.Signals | null
requested: boolean
}
interface ManagedProcess {
child: ChildProcess
requestedStop: boolean
}
export class WorkspaceRuntime {
private processes = new Map<string, ManagedProcess>()
constructor(private readonly eventBus: EventBus, private readonly logger: Logger) {}
async launch(options: LaunchOptions): Promise<{ pid: number; port: number }> {
this.validateFolder(options.folder)
const args = ["serve", "--port", "0", "--print-logs", "--log-level", "DEBUG"]
const env = { ...process.env, ...(options.environment ?? {}) }
return new Promise((resolve, reject) => {
this.logger.info({ workspaceId: options.workspaceId, folder: options.folder }, "Launching OpenCode process")
const child = spawn(options.binaryPath, args, {
cwd: options.folder,
env,
stdio: ["ignore", "pipe", "pipe"],
})
const managed: ManagedProcess = { child, requestedStop: false }
this.processes.set(options.workspaceId, managed)
let stdoutBuffer = ""
let stderrBuffer = ""
let portFound = false
let warningTimer: NodeJS.Timeout | null = null
const startWarningTimer = () => {
warningTimer = setInterval(() => {
this.logger.warn({ workspaceId: options.workspaceId }, "Workspace runtime has not reported a port yet")
}, 10000)
}
const stopWarningTimer = () => {
if (warningTimer) {
clearInterval(warningTimer)
warningTimer = null
}
}
startWarningTimer()
const cleanupStreams = () => {
stopWarningTimer()
child.stdout?.removeAllListeners()
child.stderr?.removeAllListeners()
}
const handleExit = (code: number | null, signal: NodeJS.Signals | null) => {
this.logger.info({ workspaceId: options.workspaceId, code, signal }, "OpenCode process exited")
this.processes.delete(options.workspaceId)
cleanupStreams()
child.removeListener("error", handleError)
child.removeListener("exit", handleExit)
if (!portFound) {
const reason = stderrBuffer || `Process exited with code ${code}`
reject(new Error(reason))
} else {
options.onExit?.({ workspaceId: options.workspaceId, code, signal, requested: managed.requestedStop })
}
}
const handleError = (error: Error) => {
cleanupStreams()
child.removeListener("exit", handleExit)
this.processes.delete(options.workspaceId)
this.logger.error({ workspaceId: options.workspaceId, err: error }, "Workspace runtime error")
reject(error)
}
child.on("error", handleError)
child.on("exit", handleExit)
child.stdout?.on("data", (data: Buffer) => {
const text = data.toString()
stdoutBuffer += text
const lines = stdoutBuffer.split("\n")
stdoutBuffer = lines.pop() ?? ""
for (const line of lines) {
if (!line.trim()) continue
this.emitLog(options.workspaceId, "info", line)
if (!portFound) {
const portMatch = line.match(/opencode server listening on http:\/\/.+:(\d+)/i)
if (portMatch) {
portFound = true
cleanupStreams()
child.removeListener("error", handleError)
const port = parseInt(portMatch[1], 10)
this.logger.info({ workspaceId: options.workspaceId, port }, "Workspace runtime allocated port")
resolve({ pid: child.pid!, port })
}
}
}
})
child.stderr?.on("data", (data: Buffer) => {
const text = data.toString()
stderrBuffer += text
const lines = stderrBuffer.split("\n")
stderrBuffer = lines.pop() ?? ""
for (const line of lines) {
if (!line.trim()) continue
this.emitLog(options.workspaceId, "error", line)
}
})
})
}
async stop(workspaceId: string): Promise<void> {
const managed = this.processes.get(workspaceId)
if (!managed) return
managed.requestedStop = true
const child = managed.child
this.logger.info({ workspaceId }, "Stopping OpenCode process")
await new Promise<void>((resolve, reject) => {
const cleanup = () => {
child.removeListener("exit", onExit)
child.removeListener("error", onError)
}
const onExit = () => {
cleanup()
resolve()
}
const onError = (error: Error) => {
cleanup()
reject(error)
}
const resolveIfAlreadyExited = () => {
if (child.exitCode !== null || child.signalCode !== null) {
this.logger.debug({ workspaceId, exitCode: child.exitCode, signal: child.signalCode }, "Process already exited")
cleanup()
resolve()
return true
}
return false
}
child.once("exit", onExit)
child.once("error", onError)
if (resolveIfAlreadyExited()) {
return
}
this.logger.debug({ workspaceId }, "Sending SIGTERM to workspace process")
child.kill("SIGTERM")
setTimeout(() => {
if (!child.killed) {
this.logger.warn({ workspaceId }, "Process did not stop after SIGTERM, force killing")
child.kill("SIGKILL")
} else {
this.logger.debug({ workspaceId }, "Workspace process stopped gracefully before SIGKILL timeout")
}
}, 2000)
})
}
private emitLog(workspaceId: string, level: LogLevel, message: string) {
const entry: WorkspaceLogEntry = {
workspaceId,
timestamp: new Date().toISOString(),
level,
message: message.trim(),
}
this.eventBus.publish({ type: "workspace.log", entry })
}
private validateFolder(folder: string) {
const resolved = path.resolve(folder)
if (!existsSync(resolved)) {
throw new Error(`Folder does not exist: ${resolved}`)
}
const stats = statSync(resolved)
if (!stats.isDirectory()) {
throw new Error(`Path is not a directory: ${resolved}`)
}
}
}

View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "Node",
"strict": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "dist",
"rootDir": "src",
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["dist", "node_modules"]
}

4
packages/electron-app/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules/
dist/
release/
.vite/

View File

@@ -2,6 +2,11 @@ import { defineConfig, externalizeDepsPlugin } from "electron-vite"
import solid from "vite-plugin-solid" import solid from "vite-plugin-solid"
import { resolve } from "path" import { resolve } from "path"
const uiRoot = resolve(__dirname, "../ui")
const uiSrc = resolve(uiRoot, "src")
const uiRendererRoot = resolve(uiRoot, "src/renderer")
const uiRendererEntry = resolve(uiRendererRoot, "index.html")
export default defineConfig({ export default defineConfig({
main: { main: {
plugins: [externalizeDepsPlugin()], plugins: [externalizeDepsPlugin()],
@@ -33,21 +38,24 @@ export default defineConfig({
}, },
}, },
renderer: { renderer: {
root: "./src/renderer", root: uiRendererRoot,
plugins: [solid()], plugins: [solid()],
css: { css: {
postcss: "./postcss.config.js", postcss: resolve(uiRoot, "postcss.config.js"),
}, },
resolve: { resolve: {
alias: { alias: {
"@": resolve(__dirname, "./src"), "@": uiSrc,
}, },
}, },
server: { server: {
port: 3000, port: 3000,
}, },
build: { build: {
outDir: "dist/renderer", outDir: resolve(__dirname, "dist/renderer"),
rollupOptions: {
input: uiRendererEntry,
},
}, },
}, },
}) })

View File

@@ -1,38 +1,5 @@
import { contextBridge, ipcRenderer } from "electron" import { contextBridge, ipcRenderer } from "electron"
import type { ElectronAPI } from "../../../ui/src/types/electron-api"
export interface ElectronAPI {
selectFolder: () => Promise<string | null>
createInstance: (
id: string,
folder: string,
binaryPath?: string,
environmentVariables?: Record<string, string>,
) => Promise<{ id: string; port: number; pid: number; binaryPath: string }>
stopInstance: (pid: number) => Promise<void>
onInstanceStarted: (callback: (data: { id: string; port: number; pid: number; binaryPath: string }) => void) => void
onInstanceError: (callback: (data: { id: string; error: string }) => void) => void
onInstanceStopped: (callback: (data: { id: string }) => void) => void
onInstanceLog: (
callback: (data: {
id: string
entry: { timestamp: number; level: "info" | "error" | "warn" | "debug"; message: string }
}) => void,
) => void
onNewInstance: (callback: () => void) => void
scanDirectory: (workspaceFolder: string) => Promise<string[]>
// OpenCode binary operations
selectOpenCodeBinary: () => Promise<string | null>
validateOpenCodeBinary: (path: string) => Promise<{ valid: boolean; version?: string; error?: string }>
// Storage operations
getConfigPath: () => Promise<string>
getInstancesDir: () => Promise<string>
readConfigFile: () => Promise<string>
writeConfigFile: (content: string) => Promise<void>
readInstanceFile: (instanceId: string) => Promise<string>
writeInstanceFile: (instanceId: string, content: string) => Promise<void>
deleteInstanceFile: (instanceId: string) => Promise<void>
onConfigChanged: (callback: () => void) => () => void
}
const electronAPI: ElectronAPI = { const electronAPI: ElectronAPI = {
selectFolder: () => ipcRenderer.invoke("dialog:selectFolder"), selectFolder: () => ipcRenderer.invoke("dialog:selectFolder"),

View File

Before

Width:  |  Height:  |  Size: 422 KiB

After

Width:  |  Height:  |  Size: 422 KiB

View File

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

@@ -1,5 +1,5 @@
{ {
"extends": "../tsconfig.node.json", "extends": "../tsconfig.json",
"compilerOptions": { "compilerOptions": {
"noEmit": true "noEmit": true
}, },

View File

@@ -0,0 +1,124 @@
{
"name": "@codenomad/electron-app",
"version": "0.1.2",
"description": "CodeNomad - AI coding assistant",
"author": {
"name": "Shantur Rathore",
"email": "codenomad@shantur.com"
},
"type": "module",
"main": "dist/main/main.js",
"scripts": {
"dev": "electron-vite dev",
"dev:electron": "NODE_ENV=development electron .",
"build": "electron-vite build",
"typecheck": "tsc --noEmit -p tsconfig.json",
"preview": "electron-vite preview",
"build:binaries": "node scripts/build.js",
"build:mac": "node scripts/build.js mac",
"build:mac-x64": "node scripts/build.js mac-x64",
"build:mac-arm64": "node scripts/build.js mac-arm64",
"build:win": "node scripts/build.js win",
"build:win-arm64": "node scripts/build.js win-arm64",
"build:linux": "node scripts/build.js linux",
"build:linux-arm64": "node scripts/build.js linux-arm64",
"build:linux-rpm": "node scripts/build.js linux-rpm",
"build:all": "node scripts/build.js all",
"package:mac": "electron-builder --mac",
"package:win": "electron-builder --win",
"package:linux": "electron-builder --linux"
},
"dependencies": {
"@codenomad/ui": "file:../ui",
"ignore": "7.0.5"
},
"devDependencies": {
"7zip-bin": "^5.2.0",
"app-builder-bin": "^4.2.0",
"electron": "39.0.0",
"electron-builder": "^24.0.0",
"electron-vite": "4.0.1",
"png2icons": "^2.0.1",
"pngjs": "^7.0.0",
"typescript": "^5.3.0",
"vite": "^5.0.0",
"vite-plugin-solid": "^2.10.0"
},
"build": {
"appId": "ai.opencode.client",
"productName": "CodeNomad",
"directories": {
"output": "release",
"buildResources": "electron/resources"
},
"files": [
"dist/**/*",
"package.json"
],
"mac": {
"category": "public.app-category.developer-tools",
"target": [
{
"target": "dmg",
"arch": ["x64", "arm64", "universal"]
},
{
"target": "zip",
"arch": ["x64", "arm64", "universal"]
}
],
"artifactName": "${productName}-${version}-${os}-${arch}.${ext}",
"icon": "electron/resources/icon.icns"
},
"dmg": {
"contents": [
{ "x": 130, "y": 220 },
{ "x": 410, "y": 220, "type": "link", "path": "/Applications" }
]
},
"win": {
"target": [
{
"target": "nsis",
"arch": ["x64", "arm64"]
},
{
"target": "zip",
"arch": ["x64", "arm64"]
}
],
"artifactName": "${productName}-${version}-${os}-${arch}.${ext}",
"icon": "electron/resources/icon.ico"
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true,
"createDesktopShortcut": true,
"createStartMenuShortcut": true
},
"linux": {
"target": [
{
"target": "AppImage",
"arch": ["x64", "arm64"]
},
{
"target": "deb",
"arch": ["x64", "arm64"]
},
{
"target": "rpm",
"arch": ["x64", "arm64"]
},
{
"target": "tar.gz",
"arch": ["x64", "arm64"]
}
],
"artifactName": "${productName}-${version}-${os}-${arch}.${ext}",
"category": "Development",
"icon": "electron/resources/icon.png"
}
},
"private": true
}

View File

@@ -41,6 +41,10 @@ const platforms = {
args: ["--linux", "--arm64"], args: ["--linux", "--arm64"],
description: "Linux (ARM64)", description: "Linux (ARM64)",
}, },
"linux-rpm": {
args: ["--linux", "rpm", "--x64", "--arm64"],
description: "Linux RPM packages (x64 & ARM64)",
},
all: { all: {
args: ["--mac", "--win", "--linux", "--x64", "--arm64"], args: ["--mac", "--win", "--linux", "--x64", "--arm64"],
description: "All platforms (macOS, Windows, Linux)", description: "All platforms (macOS, Windows, Linux)",

3
packages/ui/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules/
dist/
.vite/

32
packages/ui/package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "@codenomad/ui",
"version": "0.1.2",
"private": true,
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {
"@git-diff-view/solid": "^0.0.8",
"@kobalte/core": "0.13.11",
"@opencode-ai/sdk": "1.0.68",
"@solidjs/router": "^0.13.0",
"github-markdown-css": "^5.8.1",
"lucide-solid": "^0.300.0",
"marked": "^12.0.0",
"shiki": "^3.13.0",
"solid-js": "^1.8.0",
"solid-toast": "^0.5.0"
},
"devDependencies": {
"autoprefixer": "10.4.21",
"postcss": "8.5.6",
"tailwindcss": "3",
"typescript": "^5.3.0",
"vite": "^5.0.0",
"vite-plugin-solid": "^2.10.0"
}
}

View File

@@ -0,0 +1,11 @@
import { fileURLToPath } from "url"
import { dirname, resolve } from "path"
const __dirname = dirname(fileURLToPath(import.meta.url))
export default {
plugins: {
tailwindcss: { config: resolve(__dirname, "tailwind.config.js") },
autoprefixer: {},
},
}

View File

@@ -85,22 +85,16 @@ const App: Component = () => {
const clearLaunchError = () => setLaunchErrorBinary(null) const clearLaunchError = () => setLaunchErrorBinary(null)
async function handleSelectFolder(folderPath?: string, binaryPath?: string) { async function handleSelectFolder(folderPath: string, binaryPath?: string) {
if (!folderPath) {
return
}
setIsSelectingFolder(true) setIsSelectingFolder(true)
const selectedBinary = binaryPath || preferences().lastUsedBinary || "opencode" const selectedBinary = binaryPath || preferences().lastUsedBinary || "opencode"
try { try {
let folder: string | null | undefined = folderPath addRecentFolder(folderPath)
if (!folder) {
folder = await window.electronAPI.selectFolder()
if (!folder) {
return
}
}
addRecentFolder(folder)
clearLaunchError() clearLaunchError()
const instanceId = await createInstance(folder, selectedBinary) const instanceId = await createInstance(folderPath, selectedBinary)
setHasInstances(true) setHasInstances(true)
setShowFolderSelection(false) setShowFolderSelection(false)
setIsAdvancedSettingsOpen(false) setIsAdvancedSettingsOpen(false)
@@ -129,8 +123,6 @@ const App: Component = () => {
function handleNewInstanceRequest() { function handleNewInstanceRequest() {
if (hasInstances()) { if (hasInstances()) {
setShowFolderSelection(true) setShowFolderSelection(true)
} else {
void handleSelectFolder()
} }
} }

View File

@@ -0,0 +1,375 @@
import { Component, Show, For, createSignal, createMemo, createEffect, onCleanup } from "solid-js"
import { ArrowUpLeft, Folder as FolderIcon, Loader2, X } from "lucide-solid"
import type { FileSystemEntry, FileSystemListingMetadata } from "../../../cli/src/api-types"
import { WINDOWS_DRIVES_ROOT } from "../../../cli/src/api-types"
import { cliApi } from "../lib/api-client"
function normalizePathKey(input?: string | null) {
if (!input || input === "." || input === "./") {
return "."
}
if (input === WINDOWS_DRIVES_ROOT) {
return WINDOWS_DRIVES_ROOT
}
let normalized = input.replace(/\\/g, "/")
if (/^[a-zA-Z]:/.test(normalized)) {
const [drive, rest = ""] = normalized.split(":")
const suffix = rest.startsWith("/") ? rest : rest ? `/${rest}` : "/"
return `${drive.toUpperCase()}:${suffix.replace(/\/+/g, "/")}`
}
if (normalized.startsWith("//")) {
return `//${normalized.slice(2).replace(/\/+/g, "/")}`
}
if (normalized.startsWith("/")) {
return `/${normalized.slice(1).replace(/\/+/g, "/")}`
}
normalized = normalized.replace(/^\.\/+/, "").replace(/\/+/g, "/")
return normalized === "" ? "." : normalized
}
function isAbsolutePathLike(input: string) {
return input.startsWith("/") || /^[a-zA-Z]:/.test(input) || input.startsWith("\\\\")
}
interface DirectoryBrowserDialogProps {
open: boolean
title: string
description?: string
onSelect: (absolutePath: string) => void
onClose: () => void
}
function resolveAbsolutePath(root: string, relativePath: string) {
if (!root) {
return relativePath
}
if (!relativePath || relativePath === "." || relativePath === "./") {
return root
}
if (isAbsolutePathLike(relativePath)) {
return relativePath
}
const separator = root.includes("\\") ? "\\" : "/"
const trimmedRoot = root.endsWith(separator) ? root : `${root}${separator}`
const normalized = relativePath.replace(/[\\/]+/g, separator).replace(/^[\\/]+/, "")
return `${trimmedRoot}${normalized}`
}
type FolderRow =
| { type: "up"; path: string }
| { type: "folder"; entry: FileSystemEntry }
const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) => {
const [rootPath, setRootPath] = createSignal("")
const [loading, setLoading] = createSignal(false)
const [error, setError] = createSignal<string | null>(null)
const [directoryChildren, setDirectoryChildren] = createSignal<Map<string, FileSystemEntry[]>>(new Map())
const [loadingPaths, setLoadingPaths] = createSignal<Set<string>>(new Set())
const [currentPathKey, setCurrentPathKey] = createSignal<string | null>(null)
const [currentMetadata, setCurrentMetadata] = createSignal<FileSystemListingMetadata | null>(null)
const metadataCache = new Map<string, FileSystemListingMetadata>()
const inFlightRequests = new Map<string, Promise<FileSystemListingMetadata>>()
function resetState() {
setDirectoryChildren(new Map<string, FileSystemEntry[]>())
setLoadingPaths(new Set<string>())
setCurrentPathKey(null)
setCurrentMetadata(null)
metadataCache.clear()
inFlightRequests.clear()
setError(null)
}
createEffect(() => {
if (!props.open) {
return
}
resetState()
void initialize()
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault()
props.onClose()
}
}
window.addEventListener("keydown", handleKeyDown)
onCleanup(() => {
window.removeEventListener("keydown", handleKeyDown)
})
})
async function initialize() {
setLoading(true)
try {
const metadata = await loadDirectory()
applyMetadata(metadata)
} catch (err) {
const message = err instanceof Error ? err.message : "Unable to load filesystem"
setError(message)
} finally {
setLoading(false)
}
}
function applyMetadata(metadata: FileSystemListingMetadata) {
const key = normalizePathKey(metadata.currentPath)
setCurrentPathKey(key)
setCurrentMetadata(metadata)
setRootPath(metadata.rootPath)
}
async function loadDirectory(targetPath?: string): Promise<FileSystemListingMetadata> {
const key = targetPath ? normalizePathKey(targetPath) : undefined
if (key) {
const cached = metadataCache.get(key)
if (cached) {
return cached
}
const pending = inFlightRequests.get(key)
if (pending) {
return pending
}
}
const request = (async () => {
if (key) {
setLoadingPaths((prev) => {
const next = new Set(prev)
next.add(key)
return next
})
}
const response = await cliApi.listFileSystem(targetPath, { includeFiles: false })
const canonicalKey = normalizePathKey(response.metadata.currentPath)
const directories = response.entries
.filter((entry) => entry.type === "directory")
.sort((a, b) => a.name.localeCompare(b.name))
setDirectoryChildren((prev) => {
const next = new Map(prev)
next.set(canonicalKey, directories)
return next
})
metadataCache.set(canonicalKey, response.metadata)
setLoadingPaths((prev) => {
const next = new Set(prev)
if (key) {
next.delete(key)
}
next.delete(canonicalKey)
return next
})
return response.metadata
})()
.catch((err) => {
if (key) {
setLoadingPaths((prev) => {
const next = new Set(prev)
next.delete(key)
return next
})
}
throw err
})
.finally(() => {
if (key) {
inFlightRequests.delete(key)
}
})
if (key) {
inFlightRequests.set(key, request)
}
return request
}
async function navigateTo(path?: string) {
setError(null)
try {
const metadata = await loadDirectory(path)
applyMetadata(metadata)
} catch (err) {
const message = err instanceof Error ? err.message : "Unable to load filesystem"
setError(message)
}
}
const folderRows = createMemo<FolderRow[]>(() => {
const rows: FolderRow[] = []
const metadata = currentMetadata()
if (metadata?.parentPath) {
rows.push({ type: "up", path: metadata.parentPath })
}
const key = currentPathKey()
if (!key) {
return rows
}
const children = directoryChildren().get(key) ?? []
for (const entry of children) {
rows.push({ type: "folder", entry })
}
return rows
})
function handleNavigateTo(path: string) {
void navigateTo(path)
}
function handleNavigateUp() {
const parent = currentMetadata()?.parentPath
if (parent) {
void navigateTo(parent)
}
}
const currentAbsolutePath = createMemo(() => {
const metadata = currentMetadata()
if (!metadata) {
return ""
}
if (metadata.pathKind === "drives") {
return ""
}
if (metadata.pathKind === "relative") {
return resolveAbsolutePath(metadata.rootPath, metadata.currentPath)
}
return metadata.displayPath
})
const canSelectCurrent = createMemo(() => Boolean(currentAbsolutePath()))
function handleEntrySelect(entry: FileSystemEntry) {
const absolutePath = entry.absolutePath
? entry.absolutePath
: isAbsolutePathLike(entry.path)
? entry.path
: resolveAbsolutePath(rootPath(), entry.path)
props.onSelect(absolutePath)
}
function isPathLoading(path: string) {
return loadingPaths().has(normalizePathKey(path))
}
function handleOverlayClick(event: MouseEvent) {
if (event.target === event.currentTarget) {
props.onClose()
}
}
return (
<Show when={props.open}>
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-6" onClick={handleOverlayClick}>
<div class="modal-surface directory-browser-modal" role="dialog" aria-modal="true">
<div class="panel directory-browser-panel">
<div class="directory-browser-header">
<div class="directory-browser-heading">
<h3 class="directory-browser-title">{props.title}</h3>
<p class="directory-browser-description">
{props.description || "Browse folders under the configured workspace root."}
</p>
</div>
<button type="button" class="directory-browser-close" aria-label="Close" onClick={props.onClose}>
<X class="w-5 h-5" />
</button>
</div>
<div class="panel-body directory-browser-body">
<Show when={rootPath()}>
<div class="directory-browser-current">
<div class="directory-browser-current-meta">
<span class="directory-browser-current-label">Current folder</span>
<span class="directory-browser-current-path">{currentAbsolutePath()}</span>
</div>
<button
type="button"
class="selector-button selector-button-secondary directory-browser-select directory-browser-current-select"
disabled={!canSelectCurrent()}
onClick={() => {
const absolute = currentAbsolutePath()
if (absolute) {
props.onSelect(absolute)
}
}}
>
Select Current
</button>
</div>
</Show>
<Show
when={!loading() && !error()}
fallback={
<div class="panel-empty-state flex-1">
<Show when={loading()} fallback={<span class="text-red-500">{error()}</span>}>
<div class="directory-browser-loading">
<Loader2 class="w-5 h-5 animate-spin" />
<span>Loading folders</span>
</div>
</Show>
</div>
}
>
<Show
when={folderRows().length > 0}
fallback={<div class="panel-empty-state flex-1">No folders available.</div>}
>
<div class="panel-list panel-list--fill flex-1 min-h-0 overflow-auto directory-browser-list" role="listbox">
<For each={folderRows()}>
{(item) => {
const isFolder = item.type === "folder"
const label = isFolder ? item.entry.name || item.entry.path : "Up one level"
const navigate = () => (isFolder ? handleNavigateTo(item.entry.path) : handleNavigateUp())
return (
<div class="panel-list-item" role="option">
<div class="panel-list-item-content directory-browser-row">
<button type="button" class="directory-browser-row-main" onClick={navigate}>
<div class="directory-browser-row-icon">
<Show when={!isFolder} fallback={<FolderIcon class="w-4 h-4" />}>
<ArrowUpLeft class="w-4 h-4" />
</Show>
</div>
<div class="directory-browser-row-text">
<span class="directory-browser-row-name">{label}</span>
</div>
<Show when={isFolder && isPathLoading(item.entry.path)}>
<Loader2 class="directory-browser-row-spinner animate-spin" />
</Show>
</button>
{isFolder ? (
<button
type="button"
class="selector-button selector-button-secondary directory-browser-select"
onClick={(event) => {
event.stopPropagation()
handleEntrySelect(item.entry)
}}
>
Select
</button>
) : null}
</div>
</div>
)
}}
</For>
</div>
</Show>
</Show>
</div>
</div>
</div>
</div>
</Show>
)
}
export default DirectoryBrowserDialog

View File

@@ -1,7 +1,7 @@
import { Component } from "solid-js" import { Component } from "solid-js"
import { Loader2 } from "lucide-solid" import { Loader2 } from "lucide-solid"
const codeNomadIcon = new URL("../../images/CodeNomad-Icon.png", import.meta.url).href const codeNomadIcon = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
interface EmptyStateProps { interface EmptyStateProps {
onSelectFolder: () => void onSelectFolder: () => void

View File

@@ -1,6 +1,7 @@
import { Component, createSignal, createEffect, For, Show, onCleanup } from "solid-js" import { Component, createSignal, createEffect, For, Show, onCleanup } from "solid-js"
import type { OpencodeClient } from "@opencode-ai/sdk/client" import type { OpencodeClient } from "@opencode-ai/sdk/client"
import { cliApi } from "../lib/api-client"
interface FileItem { interface FileItem {
path: string path: string
@@ -17,7 +18,7 @@ interface FilePickerProps {
instanceClient: OpencodeClient instanceClient: OpencodeClient
searchQuery: string searchQuery: string
textareaRef?: HTMLTextAreaElement textareaRef?: HTMLTextAreaElement
workspaceFolder: string workspaceId: string
} }
const FilePicker: Component<FilePickerProps> = (props) => { const FilePicker: Component<FilePickerProps> = (props) => {
@@ -36,10 +37,10 @@ const FilePicker: Component<FilePickerProps> = (props) => {
try { try {
if (allFiles().length === 0) { if (allFiles().length === 0) {
console.log(`[FilePicker] Scanning workspace: ${props.workspaceFolder}`) console.log(`[FilePicker] Scanning workspace: ${props.workspaceId}`)
const scannedPaths = await window.electronAPI.scanDirectory(props.workspaceFolder) const entries = await cliApi.listWorkspaceFiles(props.workspaceId)
const scannedFiles: FileItem[] = scannedPaths.map((path) => ({ const scannedFiles: FileItem[] = entries.map<FileItem>((entry) => ({
path, path: entry.path,
isGitFile: false, isGitFile: false,
})) }))
setAllFiles(scannedFiles) setAllFiles(scannedFiles)

View File

@@ -0,0 +1,474 @@
import { Component, Show, For, createSignal, createMemo, createEffect, onCleanup, onMount } from "solid-js"
import { Folder as FolderIcon, File as FileIcon, Loader2, Search, X } from "lucide-solid"
import type { FileSystemEntry } from "../../../cli/src/api-types"
import { cliApi } from "../lib/api-client"
import { getServerMeta } from "../lib/server-meta"
const MAX_RESULTS = 200
type CacheListener = (entries: FileSystemEntry[]) => void
interface FileSystemCacheState {
entriesMap: Map<string, FileSystemEntry>
entriesList: FileSystemEntry[]
loadedDirectories: Set<string>
loadingPromises: Map<string, Promise<void>>
pendingDirectories: string[]
listeners: Set<CacheListener>
queueActive: boolean
}
const fileSystemCache: FileSystemCacheState = {
entriesMap: new Map(),
entriesList: [],
loadedDirectories: new Set(),
loadingPromises: new Map(),
pendingDirectories: [],
listeners: new Set(),
queueActive: false,
}
let cacheWorkspaceRoot: string | null = null
function normalizeEntryPath(path: string): string {
if (!path || path === ".") {
return "."
}
const cleaned = path.replace(/\\/g, "/").replace(/^\.\/+/, "").replace(/\/+/g, "/")
return cleaned || "."
}
function updateCache(entries: FileSystemEntry[]): boolean {
let changed = false
for (const entry of entries) {
const normalizedPath = normalizeEntryPath(entry.path)
const normalizedEntry = normalizedPath === entry.path ? entry : { ...entry, path: normalizedPath }
const existing = fileSystemCache.entriesMap.get(normalizedPath)
if (
!existing ||
existing.name !== normalizedEntry.name ||
existing.type !== normalizedEntry.type ||
existing.size !== normalizedEntry.size ||
existing.modifiedAt !== normalizedEntry.modifiedAt
) {
fileSystemCache.entriesMap.set(normalizedPath, normalizedEntry)
changed = true
}
}
if (changed) {
fileSystemCache.entriesList = Array.from(fileSystemCache.entriesMap.values()).sort((a, b) =>
a.path.localeCompare(b.path),
)
}
return changed
}
function notifyCacheListeners() {
for (const listener of fileSystemCache.listeners) {
listener(fileSystemCache.entriesList)
}
}
function subscribeToCache(listener: CacheListener) {
fileSystemCache.listeners.add(listener)
listener(fileSystemCache.entriesList)
return () => fileSystemCache.listeners.delete(listener)
}
function resetFileSystemCache() {
fileSystemCache.entriesMap.clear()
fileSystemCache.entriesList = []
fileSystemCache.loadedDirectories.clear()
fileSystemCache.loadingPromises.clear()
fileSystemCache.pendingDirectories = []
fileSystemCache.queueActive = false
notifyCacheListeners()
}
function enqueueDirectory(path: string, priority = false) {
const normalized = normalizeEntryPath(path)
if (normalized === "." || fileSystemCache.loadedDirectories.has(normalized) || fileSystemCache.loadingPromises.has(normalized)) {
return
}
const existingIndex = fileSystemCache.pendingDirectories.indexOf(normalized)
if (existingIndex !== -1) {
if (priority) {
fileSystemCache.pendingDirectories.splice(existingIndex, 1)
fileSystemCache.pendingDirectories.unshift(normalized)
}
return
}
if (priority) {
fileSystemCache.pendingDirectories.unshift(normalized)
} else {
fileSystemCache.pendingDirectories.push(normalized)
}
}
async function loadDirectory(path: string): Promise<void> {
const normalized = normalizeEntryPath(path)
if (fileSystemCache.loadedDirectories.has(normalized)) {
return
}
const existing = fileSystemCache.loadingPromises.get(normalized)
if (existing) {
await existing
return
}
const promise = cliApi
.listFileSystem(normalized === "." ? "." : normalized)
.then(({ entries }) => {
const changed = updateCache(entries)
fileSystemCache.loadedDirectories.add(normalized)
for (const entry of entries) {
if (entry.type === "directory") {
enqueueDirectory(entry.path)
}
}
if (changed) {
notifyCacheListeners()
}
})
.finally(() => {
fileSystemCache.loadingPromises.delete(normalized)
})
fileSystemCache.loadingPromises.set(normalized, promise)
await promise
}
async function processDirectoryQueue() {
if (fileSystemCache.queueActive) {
return
}
fileSystemCache.queueActive = true
try {
while (fileSystemCache.pendingDirectories.length > 0) {
const next = fileSystemCache.pendingDirectories.shift()
if (!next) continue
try {
await loadDirectory(next)
} catch (error) {
console.warn("Failed to load directory", next, error)
}
}
} finally {
fileSystemCache.queueActive = false
}
}
function startBackgroundLoading() {
void processDirectoryQueue()
}
function prioritizeDirectoriesForQuery(query: string) {
const normalized = query.replace(/\\/g, "/").trim()
if (!normalized) {
return
}
const segments = normalized.split("/").filter(Boolean)
let prefix = ""
for (const segment of segments) {
prefix = prefix ? `${prefix}/${segment}` : segment
enqueueDirectory(prefix, true)
}
startBackgroundLoading()
}
async function ensureWorkspaceFilesystemLoaded(workspaceRoot: string) {
if (cacheWorkspaceRoot && cacheWorkspaceRoot !== workspaceRoot) {
cacheWorkspaceRoot = workspaceRoot
resetFileSystemCache()
} else if (!cacheWorkspaceRoot) {
cacheWorkspaceRoot = workspaceRoot
}
await loadDirectory(".")
startBackgroundLoading()
}
function resolveAbsolutePath(root: string, relativePath: string): string {
if (!root) {
return relativePath
}
if (!relativePath || relativePath === "." || relativePath === "./") {
return root
}
const separator = root.includes("\\") ? "\\" : "/"
const trimmedRoot = root.endsWith(separator) ? root : `${root}${separator}`
const normalized = relativePath.replace(/[\\/]+/g, separator).replace(/^[\\/]+/, "")
return `${trimmedRoot}${normalized}`
}
function formatRootLabel(root: string): string {
if (!root) return "Workspace Root"
const parts = root.split(/[/\\]/).filter(Boolean)
return parts[parts.length - 1] || root || "Workspace Root"
}
interface FileSystemBrowserDialogProps {
open: boolean
mode: "directories" | "files"
title: string
description?: string
onSelect: (absolutePath: string) => void
onClose: () => void
}
const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props) => {
const [entries, setEntries] = createSignal<FileSystemEntry[]>([])
const [rootPath, setRootPath] = createSignal("")
const [loading, setLoading] = createSignal(false)
const [error, setError] = createSignal<string | null>(null)
const [searchQuery, setSearchQuery] = createSignal("")
const [selectedIndex, setSelectedIndex] = createSignal(0)
let searchInputRef: HTMLInputElement | undefined
onMount(() => {
const unsubscribe = subscribeToCache((items) => setEntries(items))
onCleanup(unsubscribe)
})
createEffect(() => {
const query = searchQuery().trim()
if (!query) {
return
}
prioritizeDirectoriesForQuery(query)
})
async function refreshEntries() {
setLoading(true)
setError(null)
try {
const meta = await getServerMeta()
setRootPath(meta.workspaceRoot)
await ensureWorkspaceFilesystemLoaded(meta.workspaceRoot)
} catch (err) {
const message = err instanceof Error ? err.message : "Unable to load filesystem"
setError(message)
} finally {
setLoading(false)
}
}
const filteredEntries = createMemo(() => {
const query = searchQuery().trim().toLowerCase()
const mode = props.mode
const root = rootPath()
const matchesType = entries().filter((entry) => (mode === "directories" ? entry.type === "directory" : entry.type === "file"))
const baseEntries = mode === "directories" && root
? [
{
name: formatRootLabel(root),
path: ".",
type: "directory" as const,
},
...matchesType,
]
: matchesType
if (!query) {
return baseEntries
}
return baseEntries.filter((entry) => {
const absolute = resolveAbsolutePath(root, entry.path)
return absolute.toLowerCase().includes(query) || entry.name.toLowerCase().includes(query)
})
})
const visibleEntries = createMemo(() => filteredEntries().slice(0, MAX_RESULTS))
createEffect(() => {
const list = visibleEntries()
if (list.length === 0) {
setSelectedIndex(0)
return
}
if (selectedIndex() >= list.length) {
setSelectedIndex(list.length - 1)
}
})
createEffect(() => {
if (!props.open) {
return
}
setSearchQuery("")
setSelectedIndex(0)
void refreshEntries()
setTimeout(() => searchInputRef?.focus(), 50)
const handleKeyDown = (event: KeyboardEvent) => {
if (!props.open) return
const results = visibleEntries()
if (event.key === "Escape") {
event.preventDefault()
props.onClose()
return
}
if (results.length === 0) {
return
}
if (event.key === "ArrowDown") {
event.preventDefault()
setSelectedIndex((prev) => Math.min(prev + 1, results.length - 1))
} else if (event.key === "ArrowUp") {
event.preventDefault()
setSelectedIndex((prev) => Math.max(prev - 1, 0))
} else if (event.key === "Enter") {
event.preventDefault()
const entry = results[selectedIndex()]
if (entry) {
handleEntrySelect(entry)
}
}
}
window.addEventListener("keydown", handleKeyDown)
onCleanup(() => {
window.removeEventListener("keydown", handleKeyDown)
})
})
function handleEntrySelect(entry: FileSystemEntry) {
const absolute = resolveAbsolutePath(rootPath(), entry.path)
props.onSelect(absolute)
}
function handleOverlayClick(event: MouseEvent) {
if (event.target === event.currentTarget) {
props.onClose()
}
}
return (
<Show when={props.open}>
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-6" onClick={handleOverlayClick}>
<div class="modal-surface max-h-full w-full max-w-3xl overflow-hidden rounded-xl bg-surface p-0" role="dialog" aria-modal="true">
<div class="panel flex flex-col">
<div class="panel-header flex items-start justify-between gap-4">
<div>
<h3 class="panel-title">{props.title}</h3>
<p class="panel-subtitle">
{props.description || "Search for a path under the configured workspace root."}
</p>
<Show when={rootPath()}>
<p class="text-xs text-muted mt-1 font-mono break-all">Root: {rootPath()}</p>
</Show>
</div>
<button type="button" class="selector-button selector-button-secondary" onClick={props.onClose}>
<X class="w-4 h-4" />
Close
</button>
</div>
<div class="panel-body">
<label class="w-full text-sm text-secondary mb-2 block">Filter</label>
<div class="selector-input-group">
<div class="flex items-center gap-2 px-3 text-muted">
<Search class="w-4 h-4" />
</div>
<input
ref={(el) => {
searchInputRef = el
}}
type="text"
value={searchQuery()}
onInput={(event) => setSearchQuery(event.currentTarget.value)}
placeholder={props.mode === "directories" ? "Search for folders" : "Search for files"}
class="selector-input"
/>
</div>
</div>
<div class="panel-list panel-list--fill max-h-96 overflow-auto">
<Show
when={!loading() && !error()}
fallback={
<div class="flex items-center justify-center py-6 text-sm text-secondary">
<Show
when={loading()}
fallback={<span class="text-red-500">{error()}</span>}
>
<div class="flex items-center gap-2">
<Loader2 class="w-4 h-4 animate-spin" />
<span>Loading filesystem</span>
</div>
</Show>
</div>
}
>
<Show
when={visibleEntries().length > 0}
fallback={
<div class="flex flex-col items-center justify-center gap-2 py-10 text-sm text-secondary">
<p>No matches.</p>
<Show when={searchQuery().trim().length === 0}>
<button type="button" class="selector-button selector-button-secondary" onClick={refreshEntries}>
Retry
</button>
</Show>
</div>
}
>
<For each={visibleEntries()}>
{(entry, index) => (
<button
type="button"
class="panel-list-item flex items-center gap-3 text-left"
classList={{ "panel-list-item-highlight": selectedIndex() === index() }}
onMouseEnter={() => setSelectedIndex(index())}
onClick={() => handleEntrySelect(entry)}
>
<div class="flex h-8 w-8 items-center justify-center rounded-md bg-surface-secondary text-muted">
<Show when={entry.type === "directory"} fallback={<FileIcon class="w-4 h-4" />}>
<FolderIcon class="w-4 h-4" />
</Show>
</div>
<div class="flex flex-col">
<span class="text-sm font-medium text-primary">{entry.name || entry.path}</span>
<span class="text-xs font-mono text-muted">{resolveAbsolutePath(rootPath(), entry.path)}</span>
</div>
</button>
)}
</For>
</Show>
</Show>
</div>
<div class="panel-footer">
<div class="panel-footer-hints">
<div class="flex items-center gap-1.5">
<kbd class="kbd"></kbd>
<kbd class="kbd"></kbd>
<span>Navigate</span>
</div>
<div class="flex items-center gap-1.5">
<kbd class="kbd">Enter</kbd>
<span>Select</span>
</div>
<div class="flex items-center gap-1.5">
<kbd class="kbd">Esc</kbd>
<span>Close</span>
</div>
</div>
</div>
</div>
</div>
</div>
</Show>
)
}
export default FileSystemBrowserDialog

View File

@@ -2,12 +2,13 @@ import { Component, createSignal, Show, For, onMount, onCleanup, createEffect }
import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight } from "lucide-solid" import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight } from "lucide-solid"
import { useConfig } from "../stores/preferences" import { useConfig } from "../stores/preferences"
import AdvancedSettingsModal from "./advanced-settings-modal" import AdvancedSettingsModal from "./advanced-settings-modal"
import DirectoryBrowserDialog from "./directory-browser-dialog"
import Kbd from "./kbd" import Kbd from "./kbd"
const codeNomadLogo = new URL("../../images/CodeNomad-Icon.png", import.meta.url).href const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
interface FolderSelectionViewProps { interface FolderSelectionViewProps {
onSelectFolder: (folder?: string, binaryPath?: string) => void onSelectFolder: (folder: string, binaryPath?: string) => void
isLoading?: boolean isLoading?: boolean
advancedSettingsOpen?: boolean advancedSettingsOpen?: boolean
onAdvancedSettingsOpen?: () => void onAdvancedSettingsOpen?: () => void
@@ -19,6 +20,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
const [selectedIndex, setSelectedIndex] = createSignal(0) const [selectedIndex, setSelectedIndex] = createSignal(0)
const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent") const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent")
const [selectedBinary, setSelectedBinary] = createSignal(preferences().lastUsedBinary || "opencode") const [selectedBinary, setSelectedBinary] = createSignal(preferences().lastUsedBinary || "opencode")
const [isFolderBrowserOpen, setIsFolderBrowserOpen] = createSignal(false)
let recentListRef: HTMLDivElement | undefined let recentListRef: HTMLDivElement | undefined
const folders = () => recentFolders() const folders = () => recentFolders()
@@ -173,12 +175,17 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
function handleBrowse() { function handleBrowse() {
if (isLoading()) return if (isLoading()) return
updateLastUsedBinary(selectedBinary()) setFocusMode("new")
props.onSelectFolder(undefined, selectedBinary()) setIsFolderBrowserOpen(true)
} }
function handleBrowserSelect(path: string) {
setIsFolderBrowserOpen(false)
handleFolderSelect(path)
}
function handleBinaryChange(binary: string) { function handleBinaryChange(binary: string) {
setSelectedBinary(binary) setSelectedBinary(binary)
} }
@@ -378,6 +385,14 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
onBinaryChange={handleBinaryChange} onBinaryChange={handleBinaryChange}
isLoading={props.isLoading} isLoading={props.isLoading}
/> />
<DirectoryBrowserDialog
open={isFolderBrowserOpen()}
title="Select Workspace"
description="Select workspace to start coding."
onClose={() => setIsFolderBrowserOpen(false)}
onSelect={handleBrowserSelect}
/>
</> </>
) )
} }

View File

@@ -34,7 +34,7 @@ import { useConfig } from "../stores/preferences"
import { getSessionInfo, computeDisplayParts, sessions, setActiveSession, setActiveParentSession } from "../stores/sessions" import { getSessionInfo, computeDisplayParts, sessions, setActiveSession, setActiveParentSession } from "../stores/sessions"
import { setActiveInstanceId } from "../stores/instances" import { setActiveInstanceId } from "../stores/instances"
const codeNomadLogo = new URL("../../images/CodeNomad-Icon.png", import.meta.url).href const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
const SCROLL_OFFSET = 64 const SCROLL_OFFSET = 64
const SCROLL_DIRECTION_THRESHOLD = 10 const SCROLL_DIRECTION_THRESHOLD = 10

View File

@@ -1,6 +1,8 @@
import { Component, For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js" import { Component, For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
import { FolderOpen, Trash2, Check, AlertCircle, Loader2, Plus } from "lucide-solid" import { FolderOpen, Trash2, Check, AlertCircle, Loader2, Plus } from "lucide-solid"
import { useConfig } from "../stores/preferences" import { useConfig } from "../stores/preferences"
import { cliApi } from "../lib/api-client"
import FileSystemBrowserDialog from "./filesystem-browser-dialog"
interface BinaryOption { interface BinaryOption {
path: string path: string
@@ -29,6 +31,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
const [validationError, setValidationError] = createSignal<string | null>(null) const [validationError, setValidationError] = createSignal<string | null>(null)
const [versionInfo, setVersionInfo] = createSignal<Map<string, string>>(new Map<string, string>()) const [versionInfo, setVersionInfo] = createSignal<Map<string, string>>(new Map<string, string>())
const [validatingPaths, setValidatingPaths] = createSignal<Set<string>>(new Set<string>()) const [validatingPaths, setValidatingPaths] = createSignal<Set<string>>(new Set<string>())
const [isBinaryBrowserOpen, setIsBinaryBrowserOpen] = createSignal(false)
const binaries = () => opencodeBinaries() const binaries = () => opencodeBinaries()
const lastUsedBinary = () => preferences().lastUsedBinary const lastUsedBinary = () => preferences().lastUsedBinary
@@ -102,7 +105,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
setValidating(true) setValidating(true)
setValidationError(null) setValidationError(null)
const result = await window.electronAPI.validateOpenCodeBinary(path) const result = await cliApi.validateBinary(path)
if (result.valid && result.version) { if (result.valid && result.version) {
const updatedVersionInfo = new Map(versionInfo()) const updatedVersionInfo = new Map(versionInfo())
@@ -125,18 +128,12 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
} }
} }
async function handleBrowseBinary() { function handleBrowseBinary() {
try { if (props.disabled) return
const path = await window.electronAPI.selectOpenCodeBinary() setValidationError(null)
if (!path) return setIsBinaryBrowserOpen(true)
setCustomPath(path)
await handleValidateAndAdd(path)
} catch (error) {
setValidationError(error instanceof Error ? error.message : "Failed to select binary")
}
} }
async function handleValidateAndAdd(path: string) { async function handleValidateAndAdd(path: string) {
const validation = await validateBinary(path) const validation = await validateBinary(path)
@@ -150,8 +147,15 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
setValidationError(validation.error || "Invalid OpenCode binary") setValidationError(validation.error || "Invalid OpenCode binary")
} }
} }
function handleBinaryBrowserSelect(path: string) {
setIsBinaryBrowserOpen(false)
setCustomPath(path)
void handleValidateAndAdd(path)
}
async function handleCustomPathSubmit() { async function handleCustomPathSubmit() {
const path = customPath().trim() const path = customPath().trim()
if (!path) return if (!path) return
await handleValidateAndAdd(path) await handleValidateAndAdd(path)
@@ -197,128 +201,140 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
const isPathValidating = (path: string) => validatingPaths().has(path) const isPathValidating = (path: string) => validatingPaths().has(path)
return ( return (
<div class="panel"> <>
<div class="panel-header flex items-center justify-between gap-3"> <div class="panel">
<div> <div class="panel-header flex items-center justify-between gap-3">
<h3 class="panel-title">OpenCode Binary</h3> <div>
<p class="panel-subtitle">Choose which executable OpenCode should run</p> <h3 class="panel-title">OpenCode Binary</h3>
</div> <p class="panel-subtitle">Choose which executable OpenCode should run</p>
<Show when={validating()}> </div>
<div class="selector-loading text-xs"> <Show when={validating()}>
<Loader2 class="selector-loading-spinner" /> <div class="selector-loading text-xs">
<span>Checking versions</span> <Loader2 class="selector-loading-spinner" />
<span>Checking versions</span>
</div>
</Show>
</div>
<div class="panel-body space-y-3">
<div class="selector-input-group">
<input
type="text"
value={customPath()}
onInput={(e) => setCustomPath(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
handleCustomPathSubmit()
}
}}
disabled={props.disabled}
placeholder="Enter path to opencode binary…"
class="selector-input"
/>
<button
type="button"
onClick={handleCustomPathSubmit}
disabled={props.disabled || !customPath().trim()}
class="selector-button selector-button-primary"
>
<Plus class="w-4 h-4" />
Add
</button>
</div> </div>
</Show>
</div>
<div class="panel-body space-y-3">
<div class="selector-input-group">
<input
type="text"
value={customPath()}
onInput={(e) => setCustomPath(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
handleCustomPathSubmit()
}
}}
disabled={props.disabled}
placeholder="Enter path to opencode binary…"
class="selector-input"
/>
<button <button
type="button" type="button"
onClick={handleCustomPathSubmit} onClick={handleBrowseBinary}
disabled={props.disabled || !customPath().trim()} disabled={props.disabled}
class="selector-button selector-button-primary" class="selector-button selector-button-secondary w-full flex items-center justify-center gap-2"
> >
<Plus class="w-4 h-4" /> <FolderOpen class="w-4 h-4" />
Add Browse for Binary
</button> </button>
<Show when={validationError()}>
<div class="selector-validation-error">
<div class="selector-validation-error-content">
<AlertCircle class="selector-validation-error-icon" />
<span class="selector-validation-error-text">{validationError()}</span>
</div>
</div>
</Show>
</div> </div>
<button <div class="panel-list panel-list--fill max-h-80 overflow-y-auto">
type="button" <For each={binaryOptions()}>
onClick={handleBrowseBinary} {(binary) => {
disabled={props.disabled} const isDefault = binary.isDefault
class="selector-button selector-button-secondary w-full flex items-center justify-center gap-2" const versionLabel = () => versionInfo().get(binary.path) ?? binary.version
>
<FolderOpen class="w-4 h-4" />
Browse for Binary
</button>
<Show when={validationError()}> return (
<div class="selector-validation-error"> <div
<div class="selector-validation-error-content"> class="panel-list-item flex items-center"
<AlertCircle class="selector-validation-error-icon" /> classList={{ "panel-list-item-highlight": currentSelectionPath() === binary.path }}
<span class="selector-validation-error-text">{validationError()}</span>
</div>
</div>
</Show>
</div>
<div class="panel-list panel-list--fill max-h-80 overflow-y-auto">
<For each={binaryOptions()}>
{(binary) => {
const isDefault = binary.isDefault
const versionLabel = () => versionInfo().get(binary.path) ?? binary.version
return (
<div
class="panel-list-item flex items-center"
classList={{ "panel-list-item-highlight": currentSelectionPath() === binary.path }}
>
<button
type="button"
class="panel-list-item-content flex-1"
onClick={() => handleSelectBinary(binary.path)}
disabled={props.disabled}
> >
<div class="flex flex-col flex-1 min-w-0 gap-1.5">
<div class="flex items-center gap-2">
<Check
class={`w-4 h-4 transition-opacity ${currentSelectionPath() === binary.path ? "opacity-100" : "opacity-0"}`}
/>
<span class="text-sm font-medium truncate text-primary">{getDisplayName(binary.path)}</span>
</div>
<Show when={!isDefault}>
<div class="text-xs font-mono truncate pl-6 text-muted">{binary.path}</div>
</Show>
<div class="flex items-center gap-2 text-xs text-muted pl-6 flex-wrap">
<Show when={versionLabel()}>
<span class="selector-badge-version">v{versionLabel()}</span>
</Show>
<Show when={isPathValidating(binary.path)}>
<span class="selector-badge-time">Checking</span>
</Show>
<Show when={!isDefault && binary.lastUsed}>
<span class="selector-badge-time">{formatRelativeTime(binary.lastUsed)}</span>
</Show>
<Show when={isDefault}>
<span class="selector-badge-time">Use binary from system PATH</span>
</Show>
</div>
</div>
</button>
<Show when={!isDefault}>
<button <button
type="button" type="button"
class="p-2 text-muted hover:text-primary" class="panel-list-item-content flex-1"
onClick={(event) => handleRemoveBinary(binary.path, event)} onClick={() => handleSelectBinary(binary.path)}
disabled={props.disabled} disabled={props.disabled}
title="Remove binary"
> >
<Trash2 class="w-3.5 h-3.5" /> <div class="flex flex-col flex-1 min-w-0 gap-1.5">
<div class="flex items-center gap-2">
<Check
class={`w-4 h-4 transition-opacity ${currentSelectionPath() === binary.path ? "opacity-100" : "opacity-0"}`}
/>
<span class="text-sm font-medium truncate text-primary">{getDisplayName(binary.path)}</span>
</div>
<Show when={!isDefault}>
<div class="text-xs font-mono truncate pl-6 text-muted">{binary.path}</div>
</Show>
<div class="flex items-center gap-2 text-xs text-muted pl-6 flex-wrap">
<Show when={versionLabel()}>
<span class="selector-badge-version">v{versionLabel()}</span>
</Show>
<Show when={isPathValidating(binary.path)}>
<span class="selector-badge-time">Checking</span>
</Show>
<Show when={!isDefault && binary.lastUsed}>
<span class="selector-badge-time">{formatRelativeTime(binary.lastUsed)}</span>
</Show>
<Show when={isDefault}>
<span class="selector-badge-time">Use binary from system PATH</span>
</Show>
</div>
</div>
</button> </button>
</Show> <Show when={!isDefault}>
</div> <button
) type="button"
}} class="p-2 text-muted hover:text-primary"
</For> onClick={(event) => handleRemoveBinary(binary.path, event)}
disabled={props.disabled}
title="Remove binary"
>
<Trash2 class="w-3.5 h-3.5" />
</button>
</Show>
</div>
)
}}
</For>
</div>
</div> </div>
</div>
<FileSystemBrowserDialog
open={isBinaryBrowserOpen()}
mode="files"
title="Select OpenCode Binary"
description="Browse files exposed by the CLI server."
onClose={() => setIsBinaryBrowserOpen(false)}
onSelect={handleBinaryBrowserSelect}
/>
</>
) )
} }
export default OpenCodeBinarySelector export default OpenCodeBinarySelector

View File

@@ -26,7 +26,7 @@ export default function PromptInput(props: PromptInputProps) {
const [history, setHistory] = createSignal<string[]>([]) const [history, setHistory] = createSignal<string[]>([])
const [historyIndex, setHistoryIndex] = createSignal(-1) const [historyIndex, setHistoryIndex] = createSignal(-1)
const [historyDraft, setHistoryDraft] = createSignal<string | null>(null) const [historyDraft, setHistoryDraft] = createSignal<string | null>(null)
const [isFocused, setIsFocused] = createSignal(false) const [, setIsFocused] = createSignal(false)
const [showPicker, setShowPicker] = createSignal(false) const [showPicker, setShowPicker] = createSignal(false)
const [searchQuery, setSearchQuery] = createSignal("") const [searchQuery, setSearchQuery] = createSignal("")
const [atPosition, setAtPosition] = createSignal<number | null>(null) const [atPosition, setAtPosition] = createSignal<number | null>(null)
@@ -744,7 +744,7 @@ export default function PromptInput(props: PromptInputProps) {
instanceClient={instance()!.client} instanceClient={instance()!.client}
searchQuery={searchQuery()} searchQuery={searchQuery()}
textareaRef={textareaRef} textareaRef={textareaRef}
workspaceFolder={props.instanceFolder} workspaceId={props.instanceId}
/> />
</Show> </Show>

View File

@@ -1,6 +1,7 @@
import { Component, createSignal, createEffect, For, Show, onCleanup } from "solid-js" import { Component, createSignal, createEffect, For, Show, onCleanup } from "solid-js"
import type { Agent } from "../types/session" import type { Agent } from "../types/session"
import type { OpencodeClient } from "@opencode-ai/sdk/client" import type { OpencodeClient } from "@opencode-ai/sdk/client"
import { cliApi } from "../lib/api-client"
interface FileItem { interface FileItem {
path: string path: string
@@ -19,7 +20,7 @@ interface UnifiedPickerProps {
instanceClient: OpencodeClient | null instanceClient: OpencodeClient | null
searchQuery: string searchQuery: string
textareaRef?: HTMLTextAreaElement textareaRef?: HTMLTextAreaElement
workspaceFolder: string workspaceId: string
} }
const UnifiedPicker: Component<UnifiedPickerProps> = (props) => { const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
@@ -38,9 +39,9 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
try { try {
if (allFiles().length === 0) { if (allFiles().length === 0) {
const scannedPaths = await window.electronAPI.scanDirectory(props.workspaceFolder) const entries = await cliApi.listWorkspaceFiles(props.workspaceId)
const scannedFiles: FileItem[] = scannedPaths.map((path) => ({ const scannedFiles: FileItem[] = entries.map<FileItem>((entry) => ({
path, path: entry.path,
isGitFile: false, isGitFile: false,
})) }))
setAllFiles(scannedFiles) setAllFiles(scannedFiles)

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

@@ -0,0 +1,192 @@
import type {
AppConfig,
AppConfigUpdateRequest,
BinaryCreateRequest,
BinaryListResponse,
BinaryUpdateRequest,
BinaryValidationResult,
FileSystemEntry,
FileSystemListResponse,
InstanceData,
ServerMeta,
WorkspaceCreateRequest,
WorkspaceDescriptor,
WorkspaceFileResponse,
WorkspaceLogEntry,
WorkspaceEventPayload,
WorkspaceEventType,
} from "../../../cli/src/api-types"
const FALLBACK_API_BASE = "http://127.0.0.1:9898"
const RUNTIME_BASE = typeof window !== "undefined" ? window.location?.origin : undefined
const DEFAULT_BASE = typeof window !== "undefined" ? window.__CODENOMAD_API_BASE__ ?? RUNTIME_BASE ?? FALLBACK_API_BASE : FALLBACK_API_BASE
const DEFAULT_EVENTS_PATH = typeof window !== "undefined" ? window.__CODENOMAD_EVENTS_URL__ ?? "/api/events" : "/api/events"
const API_BASE = import.meta.env.VITE_CODENOMAD_API_BASE ?? DEFAULT_BASE
const EVENTS_URL = buildEventsUrl(API_BASE, DEFAULT_EVENTS_PATH)
export const CODENOMAD_API_BASE = API_BASE
function buildEventsUrl(base: string | undefined, path: string): string {
if (path.startsWith("http://") || path.startsWith("https://")) {
return path
}
if (base) {
const normalized = path.startsWith("/") ? path : `/${path}`
return `${base}${normalized}`
}
return path
}
const HTTP_PREFIX = "[HTTP]"
function logHttp(message: string, context?: Record<string, unknown>) {
if (context) {
console.log(`${HTTP_PREFIX} ${message}`, context)
return
}
console.log(`${HTTP_PREFIX} ${message}`)
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const url = API_BASE ? new URL(path, API_BASE).toString() : path
const headers: HeadersInit = {
"Content-Type": "application/json",
...(init?.headers ?? {}),
}
const method = (init?.method ?? "GET").toUpperCase()
const startedAt = Date.now()
logHttp(`${method} ${path}`)
try {
const response = await fetch(url, { ...init, headers })
if (!response.ok) {
const message = await response.text()
logHttp(`${method} ${path} -> ${response.status}`, { durationMs: Date.now() - startedAt, error: message })
throw new Error(message || `Request failed with ${response.status}`)
}
const duration = Date.now() - startedAt
logHttp(`${method} ${path} -> ${response.status}`, { durationMs: duration })
if (response.status === 204) {
return undefined as T
}
return (await response.json()) as T
} catch (error) {
logHttp(`${method} ${path} failed`, { durationMs: Date.now() - startedAt, error })
throw error
}
}
export const cliApi = {
fetchWorkspaces(): Promise<WorkspaceDescriptor[]> {
return request<WorkspaceDescriptor[]>("/api/workspaces")
},
createWorkspace(payload: WorkspaceCreateRequest): Promise<WorkspaceDescriptor> {
return request<WorkspaceDescriptor>("/api/workspaces", {
method: "POST",
body: JSON.stringify(payload),
})
},
fetchServerMeta(): Promise<ServerMeta> {
return request<ServerMeta>("/api/meta")
},
deleteWorkspace(id: string): Promise<void> {
return request(`/api/workspaces/${encodeURIComponent(id)}`, { method: "DELETE" })
},
listWorkspaceFiles(id: string, relativePath = "."): Promise<FileSystemEntry[]> {
const params = new URLSearchParams({ path: relativePath })
return request<FileSystemEntry[]>(`/api/workspaces/${encodeURIComponent(id)}/files?${params.toString()}`)
},
readWorkspaceFile(id: string, relativePath: string): Promise<WorkspaceFileResponse> {
const params = new URLSearchParams({ path: relativePath })
return request<WorkspaceFileResponse>(
`/api/workspaces/${encodeURIComponent(id)}/files/content?${params.toString()}`,
)
},
fetchConfig(): Promise<AppConfig> {
return request<AppConfig>("/api/config/app")
},
updateConfig(payload: AppConfig): Promise<AppConfig> {
return request<AppConfig>("/api/config/app", {
method: "PUT",
body: JSON.stringify(payload),
})
},
patchConfig(payload: AppConfigUpdateRequest): Promise<AppConfig> {
return request<AppConfig>("/api/config/app", {
method: "PATCH",
body: JSON.stringify(payload),
})
},
listBinaries(): Promise<BinaryListResponse> {
return request<BinaryListResponse>("/api/config/binaries")
},
createBinary(payload: BinaryCreateRequest) {
return request<{ binary: BinaryListResponse["binaries"][number] }>("/api/config/binaries", {
method: "POST",
body: JSON.stringify(payload),
})
},
updateBinary(id: string, updates: BinaryUpdateRequest) {
return request<{ binary: BinaryListResponse["binaries"][number] }>(`/api/config/binaries/${encodeURIComponent(id)}`, {
method: "PATCH",
body: JSON.stringify(updates),
})
},
deleteBinary(id: string): Promise<void> {
return request(`/api/config/binaries/${encodeURIComponent(id)}`, { method: "DELETE" })
},
validateBinary(path: string): Promise<BinaryValidationResult> {
return request<BinaryValidationResult>("/api/config/binaries/validate", {
method: "POST",
body: JSON.stringify({ path }),
})
},
listFileSystem(path?: string, options?: { includeFiles?: boolean }): Promise<FileSystemListResponse> {
const params = new URLSearchParams()
if (path && path !== ".") {
params.set("path", path)
}
if (options?.includeFiles !== undefined) {
params.set("includeFiles", String(options.includeFiles))
}
const query = params.toString()
return request<FileSystemListResponse>(query ? `/api/filesystem?${query}` : "/api/filesystem")
},
readInstanceData(id: string): Promise<InstanceData> {
return request<InstanceData>(`/api/storage/instances/${encodeURIComponent(id)}`)
},
writeInstanceData(id: string, data: InstanceData): Promise<void> {
return request(`/api/storage/instances/${encodeURIComponent(id)}`, {
method: "PUT",
body: JSON.stringify(data),
})
},
deleteInstanceData(id: string): Promise<void> {
return request(`/api/storage/instances/${encodeURIComponent(id)}`, { method: "DELETE" })
},
connectEvents(onEvent: (event: WorkspaceEventPayload) => void, onError?: () => void) {
console.log(`[SSE] Connecting to ${EVENTS_URL}`)
const source = new EventSource(EVENTS_URL)
source.onmessage = (event) => {
try {
const payload = JSON.parse(event.data) as WorkspaceEventPayload
onEvent(payload)
} catch (error) {
console.error("[SSE] Failed to parse event", error)
}
}
source.onerror = () => {
console.warn("[SSE] EventSource error, closing stream")
onError?.()
}
return source
},
}
export type { WorkspaceDescriptor, WorkspaceLogEntry, WorkspaceEventPayload, WorkspaceEventType }

View File

@@ -0,0 +1,65 @@
import type { WorkspaceEventPayload, WorkspaceEventType } from "../../../cli/src/api-types"
import { cliApi } from "./api-client"
const RETRY_BASE_DELAY = 1000
const RETRY_MAX_DELAY = 10000
const SSE_PREFIX = "[SSE]"
function logSse(message: string, context?: Record<string, unknown>) {
if (context) {
console.log(`${SSE_PREFIX} ${message}`, context)
return
}
console.log(`${SSE_PREFIX} ${message}`)
}
class CliEvents {
private handlers = new Map<WorkspaceEventType | "*", Set<(event: WorkspaceEventPayload) => void>>()
private source: EventSource | null = null
private retryDelay = RETRY_BASE_DELAY
constructor() {
this.connect()
}
private connect() {
if (this.source) {
this.source.close()
}
logSse("Connecting to backend events stream")
this.source = cliApi.connectEvents((event) => this.dispatch(event), () => this.scheduleReconnect())
this.source.onopen = () => {
logSse("Events stream connected")
this.retryDelay = RETRY_BASE_DELAY
}
}
private scheduleReconnect() {
if (this.source) {
this.source.close()
this.source = null
}
logSse("Events stream disconnected, scheduling reconnect", { delayMs: this.retryDelay })
setTimeout(() => {
this.retryDelay = Math.min(this.retryDelay * 2, RETRY_MAX_DELAY)
this.connect()
}, this.retryDelay)
}
private dispatch(event: WorkspaceEventPayload) {
logSse(`event ${event.type}`)
this.handlers.get("*")?.forEach((handler) => handler(event))
this.handlers.get(event.type)?.forEach((handler) => handler(event))
}
on(type: WorkspaceEventType | "*", handler: (event: WorkspaceEventPayload) => void): () => void {
if (!this.handlers.has(type)) {
this.handlers.set(type, new Set())
}
const bucket = this.handlers.get(type)!
bucket.add(handler)
return () => bucket.delete(handler)
}
}
export const cliEvents = new CliEvents()

Some files were not shown because too many files have changed in this diff Show More