Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7aa94e7a88 | ||
|
|
146eae5220 | ||
|
|
defa637dbc | ||
|
|
a43a004e23 | ||
|
|
a3f02befa7 | ||
|
|
40e8c90bab | ||
|
|
f53564bb06 | ||
|
|
719a9c9c74 | ||
|
|
08d81f8bb5 | ||
|
|
89bd32814f | ||
|
|
aa77ca2931 |
57
.github/workflows/release.yml
vendored
57
.github/workflows/release.yml
vendored
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
6
BUILD.md
6
BUILD.md
@@ -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
|
||||||
|
|||||||
23
README.md
23
README.md
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
2509
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
172
package.json
172
package.json
@@ -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
1
packages/cli/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
public/
|
||||||
5
packages/cli/.npmignore
Normal file
5
packages/cli/.npmignore
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
scripts/
|
||||||
|
src/
|
||||||
|
tsconfig.json
|
||||||
|
*.tsbuildinfo
|
||||||
1333
packages/cli/package-lock.json
generated
Normal file
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
33
packages/cli/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
21
packages/cli/scripts/copy-ui-dist.mjs
Normal file
21
packages/cli/scripts/copy-ui-dist.mjs
Normal 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}`)
|
||||||
181
packages/cli/src/api-types.ts
Normal file
181
packages/cli/src/api-types.ts
Normal 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
29
packages/cli/src/bin.ts
Normal 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)
|
||||||
|
})
|
||||||
155
packages/cli/src/config/binaries.ts
Normal file
155
packages/cli/src/config/binaries.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
80
packages/cli/src/config/schema.ts
Normal file
80
packages/cli/src/config/schema.ts
Normal 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>
|
||||||
120
packages/cli/src/config/store.ts
Normal file
120
packages/cli/src/config/store.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
34
packages/cli/src/events/bus.ts
Normal file
34
packages/cli/src/events/bus.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
295
packages/cli/src/filesystem/browser.ts
Normal file
295
packages/cli/src/filesystem/browser.ts
Normal 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
183
packages/cli/src/index.ts
Normal 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)
|
||||||
|
})
|
||||||
21
packages/cli/src/loader.ts
Normal file
21
packages/cli/src/loader.ts
Normal 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
133
packages/cli/src/logger.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
262
packages/cli/src/server/http-server.ts
Normal file
262
packages/cli/src/server/http-server.ts
Normal 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
|
||||||
|
}
|
||||||
68
packages/cli/src/server/routes/config.ts
Normal file
68
packages/cli/src/server/routes/config.ts
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
49
packages/cli/src/server/routes/events.ts
Normal file
49
packages/cli/src/server/routes/events.ts
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
27
packages/cli/src/server/routes/filesystem.ts
Normal file
27
packages/cli/src/server/routes/filesystem.ts
Normal 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 }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
10
packages/cli/src/server/routes/meta.ts
Normal file
10
packages/cli/src/server/routes/meta.ts
Normal 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)
|
||||||
|
}
|
||||||
44
packages/cli/src/server/routes/storage.ts
Normal file
44
packages/cli/src/server/routes/storage.ts
Normal 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" }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
80
packages/cli/src/server/routes/workspaces.ts
Normal file
80
packages/cli/src/server/routes/workspaces.ts
Normal 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" }
|
||||||
|
}
|
||||||
63
packages/cli/src/storage/instance-store.ts
Normal file
63
packages/cli/src/storage/instance-store.ts
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
173
packages/cli/src/workspaces/manager.ts
Normal file
173
packages/cli/src/workspaces/manager.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
214
packages/cli/src/workspaces/runtime.ts
Normal file
214
packages/cli/src/workspaces/runtime.ts
Normal 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}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
17
packages/cli/tsconfig.json
Normal file
17
packages/cli/tsconfig.json
Normal 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
4
packages/electron-app/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
release/
|
||||||
|
.vite/
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -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"),
|
||||||
|
Before Width: | Height: | Size: 422 KiB After Width: | Height: | Size: 422 KiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.3 MiB |
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"extends": "../tsconfig.node.json",
|
"extends": "../tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"noEmit": true
|
"noEmit": true
|
||||||
},
|
},
|
||||||
124
packages/electron-app/package.json
Normal file
124
packages/electron-app/package.json
Normal 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
|
||||||
|
}
|
||||||
@@ -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
3
packages/ui/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.vite/
|
||||||
32
packages/ui/package.json
Normal file
32
packages/ui/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
11
packages/ui/postcss.config.js
Normal file
11
packages/ui/postcss.config.js
Normal 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: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
375
packages/ui/src/components/directory-browser-dialog.tsx
Normal file
375
packages/ui/src/components/directory-browser-dialog.tsx
Normal 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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
474
packages/ui/src/components/filesystem-browser-dialog.tsx
Normal file
474
packages/ui/src/components/filesystem-browser-dialog.tsx
Normal 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
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
|
||||||
@@ -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)
|
||||||
BIN
packages/ui/src/images/CodeNomad-Icon.png
Normal file
BIN
packages/ui/src/images/CodeNomad-Icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
192
packages/ui/src/lib/api-client.ts
Normal file
192
packages/ui/src/lib/api-client.ts
Normal 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 }
|
||||||
65
packages/ui/src/lib/cli-events.ts
Normal file
65
packages/ui/src/lib/cli-events.ts
Normal 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
Reference in New Issue
Block a user