fix: restore complete source code and fix launchers

- Copy complete source code packages from original CodeNomad project
- Add root package.json with npm workspace configuration
- Include electron-app, server, ui, tauri-app, and opencode-config packages
- Fix Launch-Windows.bat and Launch-Dev-Windows.bat to work with correct npm scripts
- Fix Launch-Unix.sh to work with correct npm scripts
- Launchers now correctly call npm run dev:electron which launches Electron app
This commit is contained in:
Gemini AI
2025-12-23 12:57:55 +04:00
Unverified
parent f365d64c97
commit b448d11991
249 changed files with 48776 additions and 0 deletions

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

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

View File

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

58
packages/server/README.md Normal file
View File

@@ -0,0 +1,58 @@
# CodeNomad Server
**CodeNomad Server** is the high-performance engine behind the CodeNomad cockpit. It transforms your machine into a robust development host, managing the lifecycle of multiple OpenCode instances and providing the low-latency data streams that long-haul builders demand. It bridges your local filesystem with the UI, ensuring that whether you are on localhost or a remote tunnel, you have the speed, clarity, and control of a native workspace.
## Features & Capabilities
### 🌍 Deployment Freedom
- **Remote Access**: Host CodeNomad on a powerful workstation and access it from your lightweight laptop.
- **Code Anywhere**: Tunnel in via VPN or SSH to code securely from coffee shops or while traveling.
- **Multi-Device**: The responsive web client works on tablets and iPads, turning any screen into a dev terminal.
- **Always-On**: Run as a background service so your sessions are always ready when you connect.
### ⚡️ Workspace Power
- **Multi-Instance**: Juggle multiple OpenCode sessions side-by-side with per-instance tabs.
- **Long-Context Native**: Scroll through massive transcripts without hitches.
- **Deep Task Awareness**: Monitor background tasks and child sessions without losing your flow.
- **Command Palette**: A single, global palette to jump tabs, launch tools, and fire shortcuts.
## Prerequisites
- **OpenCode**: `opencode` must be installed and configured on your system.
- Node.js 18+ and npm (for running or building from source).
- A workspace folder on disk you want to serve.
- Optional: a Chromium-based browser if you want `--launch` to open the UI automatically.
## Usage
### Run via npx (Recommended)
You can run CodeNomad directly without installing it:
```sh
npx @neuralnomads/codenomad --launch
```
### Install Globally
Or install it globally to use the `codenomad` command:
```sh
npm install -g @neuralnomads/codenomad
codenomad --launch
```
### Common Flags
You can configure the server using flags or environment variables:
| Flag | Env Variable | Description |
|------|--------------|-------------|
| `--port <number>` | `CLI_PORT` | HTTP port (default 9898) |
| `--host <addr>` | `CLI_HOST` | Interface to bind (default 127.0.0.1) |
| `--workspace-root <path>` | `CLI_WORKSPACE_ROOT` | Default root for new workspaces |
| `--unrestricted-root` | `CLI_UNRESTRICTED_ROOT` | Allow full-filesystem browsing |
| `--config <path>` | `CLI_CONFIG` | Config file location |
| `--launch` | `CLI_LAUNCH` | Open the UI in a Chromium-based browser |
| `--log-level <level>` | `CLI_LOG_LEVEL` | Logging level (trace, debug, info, warn, error) |
### Data Storage
- **Config**: `~/.config/codenomad/config.json`
- **Instance Data**: `~/.config/codenomad/instances` (chat history, etc.)

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,43 @@
{
"name": "@neuralnomads/codenomad",
"version": "0.4.0",
"description": "CodeNomad Server",
"author": {
"name": "Neural Nomads",
"email": "codenomad@neuralnomads.ai"
},
"repository": {
"type": "git",
"url": "https://github.com/NeuralNomadsAI/CodeNomad.git"
},
"type": "module",
"main": "dist/index.js",
"bin": {
"codenomad": "dist/bin.js"
},
"scripts": {
"build": "npm run build:ui && npm run prepare-ui && tsc -p tsconfig.json && npm run prepare-config",
"build:ui": "npm run build --prefix ../ui",
"prepare-ui": "node ./scripts/copy-ui-dist.mjs",
"prepare-config": "node ./scripts/copy-opencode-config.mjs",
"dev": "cross-env CODENOMAD_DEV=1 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",
"fuzzysort": "^2.0.4",
"pino": "^9.4.0",
"undici": "^6.19.8",
"zod": "^3.23.8"
},
"devDependencies": {
"cross-env": "^7.0.3",
"ts-node": "^10.9.2",
"tsx": "^4.20.6",
"typescript": "^5.6.3"
}
}

View File

@@ -0,0 +1,21 @@
#!/usr/bin/env node
import { cpSync, existsSync, mkdirSync, rmSync } from "fs"
import path from "path"
import { fileURLToPath } from "url"
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const cliRoot = path.resolve(__dirname, "..")
const sourceDir = path.resolve(cliRoot, "../opencode-config")
const targetDir = path.resolve(cliRoot, "dist/opencode-config")
if (!existsSync(sourceDir)) {
console.error(`[copy-opencode-config] Missing source directory at ${sourceDir}`)
process.exit(1)
}
rmSync(targetDir, { recursive: true, force: true })
mkdirSync(path.dirname(targetDir), { recursive: true })
cpSync(sourceDir, targetDir, { recursive: true })
console.log(`[copy-opencode-config] Copied ${sourceDir} -> ${targetDir}`)

View File

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

View File

@@ -0,0 +1,228 @@
import type {
AgentModelSelection,
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 type WorkspaceFileSearchResponse = FileSystemEntry[]
export interface InstanceData {
messageHistory: string[]
agentModelSelections: AgentModelSelection
}
export type InstanceStreamStatus = "connecting" | "connected" | "error" | "disconnected"
export interface InstanceStreamEvent {
type: string
properties?: Record<string, unknown>
[key: string]: unknown
}
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"
| "instance.dataChanged"
| "instance.event"
| "instance.eventStatus"
| "app.releaseAvailable"
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[] }
| { type: "instance.dataChanged"; instanceId: string; data: InstanceData }
| { type: "instance.event"; instanceId: string; event: InstanceStreamEvent }
| { type: "instance.eventStatus"; instanceId: string; status: InstanceStreamStatus; reason?: string }
| { type: "app.releaseAvailable"; release: LatestReleaseInfo }
export interface NetworkAddress {
ip: string
family: "ipv4" | "ipv6"
scope: "external" | "internal" | "loopback"
url: string
}
export interface LatestReleaseInfo {
version: string
tag: string
url: string
channel: "stable" | "dev"
publishedAt?: string
notes?: string
}
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
/** Host the server is bound to (e.g., 127.0.0.1 or 0.0.0.0). */
host: string
/** Listening mode derived from host binding. */
listeningMode: "local" | "all"
/** Actual port in use after binding. */
port: number
/** Display label for the host (e.g., hostname or friendly name). */
hostLabel: string
/** Absolute path of the filesystem root exposed to clients. */
workspaceRoot: string
/** Reachable addresses for this server, external first. */
addresses: NetworkAddress[]
/** Optional metadata about the most recent public release. */
latestRelease?: LatestReleaseInfo
}
export type {
Preferences,
ModelPreference,
AgentModelSelections,
RecentFolder,
OpenCodeBinary,
}

View File

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

View File

@@ -0,0 +1,156 @@
import {
BinaryCreateRequest,
BinaryRecord,
BinaryUpdateRequest,
BinaryValidationResult,
} from "../api-types"
import { ConfigStore } from "./store"
import { EventBus } from "../events/bus"
import type { ConfigFile } 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 nextConfig = this.cloneConfig(config)
const deduped = nextConfig.opencodeBinaries.filter((binary) => binary.path !== request.path)
nextConfig.opencodeBinaries = [entry, ...deduped]
if (request.makeDefault) {
nextConfig.preferences.lastUsedBinary = request.path
}
this.configStore.replace(nextConfig)
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 nextConfig = this.cloneConfig(config)
nextConfig.opencodeBinaries = nextConfig.opencodeBinaries.map((binary) =>
binary.path === id ? { ...binary, label: updates.label ?? binary.label } : binary,
)
if (updates.makeDefault) {
nextConfig.preferences.lastUsedBinary = id
}
this.configStore.replace(nextConfig)
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 nextConfig = this.cloneConfig(config)
const remaining = nextConfig.opencodeBinaries.filter((binary) => binary.path !== id)
nextConfig.opencodeBinaries = remaining
if (nextConfig.preferences.lastUsedBinary === id) {
nextConfig.preferences.lastUsedBinary = remaining[0]?.path
}
this.configStore.replace(nextConfig)
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 cloneConfig(config: ConfigFile): ConfigFile {
return JSON.parse(JSON.stringify(config)) as ConfigFile
}
private mapRecords(): BinaryRecord[] {
const config = this.configStore.get()
const configuredBinaries = config.opencodeBinaries.map<BinaryRecord>((binary) => ({
id: binary.path,
path: binary.path,
label: binary.label ?? this.prettyLabel(binary.path),
version: binary.version,
isDefault: false,
}))
const defaultPath = config.preferences.lastUsedBinary ?? configuredBinaries[0]?.path ?? "opencode"
const annotated = configuredBinaries.map((binary) => ({
...binary,
isDefault: binary.path === defaultPath,
}))
if (!annotated.some((binary) => binary.path === defaultPath)) {
annotated.unshift(this.buildFallbackRecord(defaultPath))
}
return annotated
}
private getById(id: string): BinaryRecord {
return this.mapRecords().find((binary) => binary.id === id) ?? this.buildFallbackRecord(id)
}
private emitChange() {
this.logger.debug("Emitting binaries changed event")
this.eventBus?.publish({ type: "config.binariesChanged", binaries: this.mapRecords() })
}
private validateRecord(record: BinaryRecord): BinaryValidationResult {
// TODO: call actual binary -v check.
return { valid: true, version: record.version }
}
private buildFallbackRecord(path: string): BinaryRecord {
return {
id: path,
path,
label: this.prettyLabel(path),
isDefault: true,
}
}
private prettyLabel(path: string) {
const parts = path.split(/[\\/]/)
const last = parts[parts.length - 1] || path
return last || path
}
}

View File

@@ -0,0 +1,64 @@
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),
thinkingBlocksExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
showTimelineTools: z.boolean().default(true),
lastUsedBinary: z.string().optional(),
environmentVariables: z.record(z.string()).default({}),
modelRecents: z.array(ModelPreferenceSchema).default([]),
diffViewMode: z.enum(["split", "unified"]).default("split"),
toolOutputExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
diagnosticsExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
showUsageMetrics: z.boolean().default(true),
autoCleanupBlankSessions: z.boolean().default(true),
listeningMode: z.enum(["local", "all"]).default("local"),
})
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 DEFAULT_CONFIG = ConfigFileSchema.parse({})
export {
ModelPreferenceSchema,
AgentModelSelectionSchema,
AgentModelSelectionsSchema,
PreferencesSchema,
RecentFolderSchema,
OpenCodeBinarySchema,
ConfigFileSchema,
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>

View File

@@ -0,0 +1,78 @@
import fs from "fs"
import path from "path"
import { EventBus } from "../events/bus"
import { Logger } from "../logger"
import { ConfigFile, ConfigFileSchema, 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()
}
replace(config: ConfigFile) {
const validated = ConfigFileSchema.parse(config)
this.commit(validated)
}
private commit(next: ConfigFile) {
this.cache = next
this.loaded = true
this.persist()
const published = Boolean(this.eventBus)
this.eventBus?.publish({ type: "config.appChanged", config: this.cache })
this.logger.debug({ broadcast: published }, "Config SSE event emitted")
this.logger.trace({ config: this.cache }, "Config payload")
}
private persist() {
try {
const resolved = this.resolvePath(this.configPath)
fs.mkdirSync(path.dirname(resolved), { recursive: true })
fs.writeFileSync(resolved, JSON.stringify(this.cache, null, 2), "utf-8")
this.logger.debug({ resolved }, "Persisted config file")
} catch (error) {
this.logger.warn({ err: error }, "Failed to persist config")
}
}
private resolvePath(filePath: string) {
if (filePath.startsWith("~/")) {
return path.join(process.env.HOME ?? "", filePath.slice(2))
}
return path.resolve(filePath)
}
}

View File

@@ -0,0 +1,47 @@
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 {
if (event.type !== "instance.event" && event.type !== "instance.eventStatus") {
this.logger?.debug({ type: event.type }, "Publishing workspace event")
if (this.logger?.isLevelEnabled("trace")) {
this.logger.trace({ event }, "Workspace event payload")
}
}
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)
this.on("instance.dataChanged", handler)
this.on("instance.event", handler)
this.on("instance.eventStatus", handler)
this.on("app.releaseAvailable", 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)
this.off("instance.dataChanged", handler)
this.off("instance.event", handler)
this.off("instance.eventStatus", handler)
this.off("app.releaseAvailable", handler)
}
}
}

View File

@@ -0,0 +1,61 @@
import assert from "node:assert/strict"
import { beforeEach, describe, it } from "node:test"
import type { FileSystemEntry } from "../../api-types"
import {
clearWorkspaceSearchCache,
getWorkspaceCandidates,
refreshWorkspaceCandidates,
WORKSPACE_CANDIDATE_CACHE_TTL_MS,
} from "../search-cache"
describe("workspace search cache", () => {
beforeEach(() => {
clearWorkspaceSearchCache()
})
it("expires cached candidates after the TTL", () => {
const workspacePath = "/tmp/workspace"
const startTime = 1_000
refreshWorkspaceCandidates(workspacePath, () => [createEntry("file-a")], startTime)
const beforeExpiry = getWorkspaceCandidates(
workspacePath,
startTime + WORKSPACE_CANDIDATE_CACHE_TTL_MS - 1,
)
assert.ok(beforeExpiry)
assert.equal(beforeExpiry.length, 1)
assert.equal(beforeExpiry[0].name, "file-a")
const afterExpiry = getWorkspaceCandidates(
workspacePath,
startTime + WORKSPACE_CANDIDATE_CACHE_TTL_MS + 1,
)
assert.equal(afterExpiry, undefined)
})
it("replaces cached entries when manually refreshed", () => {
const workspacePath = "/tmp/workspace"
refreshWorkspaceCandidates(workspacePath, () => [createEntry("file-a")], 5_000)
const initial = getWorkspaceCandidates(workspacePath)
assert.ok(initial)
assert.equal(initial[0].name, "file-a")
refreshWorkspaceCandidates(workspacePath, () => [createEntry("file-b")], 6_000)
const refreshed = getWorkspaceCandidates(workspacePath)
assert.ok(refreshed)
assert.equal(refreshed[0].name, "file-b")
})
})
function createEntry(name: string): FileSystemEntry {
return {
name,
path: name,
absolutePath: `/tmp/${name}`,
type: "file",
size: 1,
modifiedAt: new Date().toISOString(),
}
}

View File

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

View File

@@ -0,0 +1,66 @@
import path from "path"
import type { FileSystemEntry } from "../api-types"
export const WORKSPACE_CANDIDATE_CACHE_TTL_MS = 30_000
interface WorkspaceCandidateCacheEntry {
expiresAt: number
candidates: FileSystemEntry[]
}
const workspaceCandidateCache = new Map<string, WorkspaceCandidateCacheEntry>()
export function getWorkspaceCandidates(rootDir: string, now = Date.now()): FileSystemEntry[] | undefined {
const key = normalizeKey(rootDir)
const cached = workspaceCandidateCache.get(key)
if (!cached) {
return undefined
}
if (cached.expiresAt <= now) {
workspaceCandidateCache.delete(key)
return undefined
}
return cloneEntries(cached.candidates)
}
export function refreshWorkspaceCandidates(
rootDir: string,
builder: () => FileSystemEntry[],
now = Date.now(),
): FileSystemEntry[] {
const key = normalizeKey(rootDir)
const freshCandidates = builder()
if (!freshCandidates || freshCandidates.length === 0) {
workspaceCandidateCache.delete(key)
return []
}
const storedCandidates = cloneEntries(freshCandidates)
workspaceCandidateCache.set(key, {
expiresAt: now + WORKSPACE_CANDIDATE_CACHE_TTL_MS,
candidates: storedCandidates,
})
return cloneEntries(storedCandidates)
}
export function clearWorkspaceSearchCache(rootDir?: string) {
if (typeof rootDir === "undefined") {
workspaceCandidateCache.clear()
return
}
const key = normalizeKey(rootDir)
workspaceCandidateCache.delete(key)
}
function cloneEntries(entries: FileSystemEntry[]): FileSystemEntry[] {
return entries.map((entry) => ({ ...entry }))
}
function normalizeKey(rootDir: string) {
return path.resolve(rootDir)
}

View File

@@ -0,0 +1,184 @@
import fs from "fs"
import path from "path"
import fuzzysort from "fuzzysort"
import type { FileSystemEntry } from "../api-types"
import { clearWorkspaceSearchCache, getWorkspaceCandidates, refreshWorkspaceCandidates } from "./search-cache"
const DEFAULT_LIMIT = 100
const MAX_LIMIT = 200
const MAX_CANDIDATES = 8000
const IGNORED_DIRECTORIES = new Set(
[".git", ".hg", ".svn", "node_modules", "dist", "build", ".next", ".nuxt", ".turbo", ".cache", "coverage"].map(
(name) => name.toLowerCase(),
),
)
export type WorkspaceFileSearchType = "all" | "file" | "directory"
export interface WorkspaceFileSearchOptions {
limit?: number
type?: WorkspaceFileSearchType
refresh?: boolean
}
interface CandidateEntry {
entry: FileSystemEntry
key: string
}
export function searchWorkspaceFiles(
rootDir: string,
query: string,
options: WorkspaceFileSearchOptions = {},
): FileSystemEntry[] {
const trimmedQuery = query.trim()
if (!trimmedQuery) {
throw new Error("Search query is required")
}
const normalizedRoot = path.resolve(rootDir)
const limit = normalizeLimit(options.limit)
const typeFilter: WorkspaceFileSearchType = options.type ?? "all"
const refreshRequested = options.refresh === true
let entries: FileSystemEntry[] | undefined
try {
if (!refreshRequested) {
entries = getWorkspaceCandidates(normalizedRoot)
}
if (!entries) {
entries = refreshWorkspaceCandidates(normalizedRoot, () => collectCandidates(normalizedRoot))
}
} catch (error) {
clearWorkspaceSearchCache(normalizedRoot)
throw error
}
if (!entries || entries.length === 0) {
clearWorkspaceSearchCache(normalizedRoot)
return []
}
const candidates = buildCandidateEntries(entries, typeFilter)
if (candidates.length === 0) {
return []
}
const matches = fuzzysort.go<CandidateEntry>(trimmedQuery, candidates, {
key: "key",
limit,
})
if (!matches || matches.length === 0) {
return []
}
return matches.map((match) => match.obj.entry)
}
function collectCandidates(rootDir: string): FileSystemEntry[] {
const queue: string[] = [""]
const entries: FileSystemEntry[] = []
while (queue.length > 0 && entries.length < MAX_CANDIDATES) {
const relativeDir = queue.pop() || ""
const absoluteDir = relativeDir ? path.join(rootDir, relativeDir) : rootDir
let dirents: fs.Dirent[]
try {
dirents = fs.readdirSync(absoluteDir, { withFileTypes: true })
} catch {
continue
}
for (const dirent of dirents) {
const entryName = dirent.name
const lowerName = entryName.toLowerCase()
const relativePath = relativeDir ? `${relativeDir}/${entryName}` : entryName
const absolutePath = path.join(absoluteDir, entryName)
if (dirent.isDirectory() && IGNORED_DIRECTORIES.has(lowerName)) {
continue
}
let stats: fs.Stats
try {
stats = fs.statSync(absolutePath)
} catch {
continue
}
const isDirectory = stats.isDirectory()
if (isDirectory && !IGNORED_DIRECTORIES.has(lowerName)) {
if (entries.length < MAX_CANDIDATES) {
queue.push(relativePath)
}
}
const entryType: FileSystemEntry["type"] = isDirectory ? "directory" : "file"
const normalizedPath = normalizeRelativeEntryPath(relativePath)
const entry: FileSystemEntry = {
name: entryName,
path: normalizedPath,
absolutePath: path.resolve(rootDir, normalizedPath === "." ? "" : normalizedPath),
type: entryType,
size: entryType === "file" ? stats.size : undefined,
modifiedAt: stats.mtime.toISOString(),
}
entries.push(entry)
if (entries.length >= MAX_CANDIDATES) {
break
}
}
}
return entries
}
function buildCandidateEntries(entries: FileSystemEntry[], filter: WorkspaceFileSearchType): CandidateEntry[] {
const filtered: CandidateEntry[] = []
for (const entry of entries) {
if (!shouldInclude(entry.type, filter)) {
continue
}
filtered.push({ entry, key: buildSearchKey(entry) })
}
return filtered
}
function normalizeLimit(limit?: number) {
if (!limit || Number.isNaN(limit)) {
return DEFAULT_LIMIT
}
const clamped = Math.min(Math.max(limit, 1), MAX_LIMIT)
return clamped
}
function shouldInclude(entryType: FileSystemEntry["type"], filter: WorkspaceFileSearchType) {
return filter === "all" || entryType === filter
}
function normalizeRelativeEntryPath(relativePath: string): string {
if (!relativePath) {
return "."
}
let normalized = relativePath.replace(/\\+/g, "/")
if (normalized.startsWith("./")) {
normalized = normalized.replace(/^\.\/+/, "")
}
if (normalized.startsWith("/")) {
normalized = normalized.replace(/^\/+/g, "")
}
return normalized || "."
}
function buildSearchKey(entry: FileSystemEntry) {
return entry.path.toLowerCase()
}

View File

@@ -0,0 +1,227 @@
/**
* 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 { InstanceEventBridge } from "./workspaces/instance-events"
import { createLogger } from "./logger"
import { launchInBrowser } from "./launcher"
import { startReleaseMonitor } from "./releases/release-monitor"
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
launch: boolean
}
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")
.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"))
.addOption(new Option("--launch", "Launch the UI in a browser after start").env("CLI_LAUNCH").default(false))
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
launch?: boolean
}>()
const resolvedRoot = parsed.workspaceRoot ?? parsed.root ?? process.cwd()
const normalizedHost = resolveHost(parsed.host)
return {
port: parsed.port,
host: normalizedHost,
rootDir: resolvedRoot,
configPath: parsed.config,
unrestrictedRoot: Boolean(parsed.unrestrictedRoot),
logLevel: parsed.logLevel,
logDestination: parsed.logDestination,
uiStaticDir: parsed.uiDir,
uiDevServer: parsed.uiDevServer,
launch: Boolean(parsed.launch),
}
}
function parsePort(input: string): number {
const value = Number(input)
if (!Number.isInteger(value) || value < 0 || value > 65535) {
throw new InvalidArgumentError("Port must be an integer between 0 and 65535")
}
return value
}
function resolveHost(input: string | undefined): string {
if (input && input.trim() === "0.0.0.0") {
return "0.0.0.0"
}
return DEFAULT_HOST
}
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 instanceEventBridge = new InstanceEventBridge({
workspaceManager,
eventBus,
logger: logger.child({ component: "instance-events" }),
})
const serverMeta: ServerMeta = {
httpBaseUrl: `http://${options.host}:${options.port}`,
eventsUrl: `/api/events`,
host: options.host,
listeningMode: options.host === "0.0.0.0" ? "all" : "local",
port: options.port,
hostLabel: options.host,
workspaceRoot: options.rootDir,
addresses: [],
}
const releaseMonitor = startReleaseMonitor({
currentVersion: packageJson.version,
logger: logger.child({ component: "release-monitor" }),
onUpdate: (release) => {
if (release) {
serverMeta.latestRelease = release
eventBus.publish({ type: "app.releaseAvailable", release })
} else {
delete serverMeta.latestRelease
}
},
})
const server = createHttpServer({
host: options.host,
port: options.port,
workspaceManager,
configStore,
binaryRegistry,
fileSystemBrowser,
eventBus,
serverMeta,
instanceStore,
uiStaticDir: options.uiStaticDir,
uiDevServerUrl: options.uiDevServer,
logger,
})
const startInfo = await server.start()
logger.info({ port: startInfo.port, host: options.host }, "HTTP server listening")
console.log(`CodeNomad Server is ready at ${startInfo.url}`)
if (options.launch) {
await launchInBrowser(startInfo.url, logger.child({ component: "launcher" }))
}
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 {
instanceEventBridge.shutdown()
await workspaceManager.shutdown()
logger.info("Workspace manager shutdown complete")
} catch (error) {
logger.error({ err: error }, "Workspace manager shutdown failed")
}
releaseMonitor.stop()
logger.info("Exiting process")
process.exit(0)
}
process.on("SIGINT", shutdown)
process.on("SIGTERM", shutdown)
}
main().catch((error) => {
const logger = createLogger({ component: "app" })
logger.error({ err: error }, "CLI server crashed")
process.exit(1)
})

View File

@@ -0,0 +1,177 @@
import { spawn } from "child_process"
import os from "os"
import path from "path"
import type { Logger } from "./logger"
interface BrowserCandidate {
name: string
command: string
args: (url: string) => string[]
}
const APP_ARGS = (url: string) => [`--app=${url}`, "--new-window"]
export async function launchInBrowser(url: string, logger: Logger): Promise<boolean> {
const { platform, candidates, manualExamples } = buildPlatformCandidates(url)
console.log(`Attempting to launch browser (${platform}) using:`)
candidates.forEach((candidate) => console.log(` - ${candidate.name}: ${candidate.command}`))
for (const candidate of candidates) {
const success = await tryLaunch(candidate, url, logger)
if (success) {
return true
}
}
console.error(
"No supported browser found to launch. Run without --launch and use one of the commands below or install a compatible browser.",
)
if (manualExamples.length > 0) {
console.error("Manual launch commands:")
manualExamples.forEach((line) => console.error(` ${line}`))
}
return false
}
async function tryLaunch(candidate: BrowserCandidate, url: string, logger: Logger): Promise<boolean> {
return new Promise((resolve) => {
let resolved = false
try {
const args = candidate.args(url)
const child = spawn(candidate.command, args, { stdio: "ignore", detached: true })
child.once("error", (error) => {
if (resolved) return
resolved = true
logger.debug({ err: error, candidate: candidate.name, command: candidate.command, args }, "Browser launch failed")
resolve(false)
})
child.once("spawn", () => {
if (resolved) return
resolved = true
logger.info(
{
browser: candidate.name,
command: candidate.command,
args,
fullCommand: [candidate.command, ...args].join(" "),
},
"Launched browser in app mode",
)
child.unref()
resolve(true)
})
} catch (error) {
if (resolved) return
resolved = true
logger.debug({ err: error, candidate: candidate.name, command: candidate.command }, "Browser spawn threw")
resolve(false)
}
})
}
function buildPlatformCandidates(url: string) {
switch (os.platform()) {
case "darwin":
return {
platform: "macOS",
candidates: buildMacCandidates(),
manualExamples: buildMacManualExamples(url),
}
case "win32":
return {
platform: "Windows",
candidates: buildWindowsCandidates(),
manualExamples: buildWindowsManualExamples(url),
}
default:
return {
platform: "Linux",
candidates: buildLinuxCandidates(),
manualExamples: buildLinuxManualExamples(url),
}
}
}
function buildMacCandidates(): BrowserCandidate[] {
const apps = [
{ name: "Google Chrome", path: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" },
{ name: "Google Chrome Canary", path: "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary" },
{ name: "Microsoft Edge", path: "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge" },
{ name: "Brave Browser", path: "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser" },
{ name: "Chromium", path: "/Applications/Chromium.app/Contents/MacOS/Chromium" },
{ name: "Vivaldi", path: "/Applications/Vivaldi.app/Contents/MacOS/Vivaldi" },
{ name: "Arc", path: "/Applications/Arc.app/Contents/MacOS/Arc" },
]
return apps.map((entry) => ({ name: entry.name, command: entry.path, args: APP_ARGS }))
}
function buildWindowsCandidates(): BrowserCandidate[] {
const programFiles = process.env["ProgramFiles"]
const programFilesX86 = process.env["ProgramFiles(x86)"]
const localAppData = process.env["LocalAppData"]
const paths = [
[programFiles, "Google/Chrome/Application/chrome.exe", "Google Chrome"],
[programFilesX86, "Google/Chrome/Application/chrome.exe", "Google Chrome (x86)"],
[localAppData, "Google/Chrome/Application/chrome.exe", "Google Chrome (User)"],
[programFiles, "Microsoft/Edge/Application/msedge.exe", "Microsoft Edge"],
[programFilesX86, "Microsoft/Edge/Application/msedge.exe", "Microsoft Edge (x86)"],
[localAppData, "Microsoft/Edge/Application/msedge.exe", "Microsoft Edge (User)"],
[programFiles, "BraveSoftware/Brave-Browser/Application/brave.exe", "Brave"],
[localAppData, "BraveSoftware/Brave-Browser/Application/brave.exe", "Brave (User)"],
[programFiles, "Chromium/Application/chromium.exe", "Chromium"],
] as const
return paths
.filter(([root]) => Boolean(root))
.map(([root, rel, name]) => ({
name,
command: path.join(root as string, rel),
args: APP_ARGS,
}))
}
function buildLinuxCandidates(): BrowserCandidate[] {
const names = [
"google-chrome",
"google-chrome-stable",
"chromium",
"chromium-browser",
"brave-browser",
"microsoft-edge",
"microsoft-edge-stable",
"vivaldi",
]
return names.map((name) => ({ name, command: name, args: APP_ARGS }))
}
function buildMacManualExamples(url: string) {
return [
`"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --app="${url}" --new-window`,
`"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge" --app="${url}" --new-window`,
`"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser" --app="${url}" --new-window`,
]
}
function buildWindowsManualExamples(url: string) {
return [
`"%ProgramFiles%\\Google\\Chrome\\Application\\chrome.exe" --app="${url}" --new-window`,
`"%ProgramFiles%\\Microsoft\\Edge\\Application\\msedge.exe" --app="${url}" --new-window`,
`"%ProgramFiles%\\BraveSoftware\\Brave-Browser\\Application\\brave.exe" --app="${url}" --new-window`,
]
}
function buildLinuxManualExamples(url: string) {
return [
`google-chrome --app="${url}" --new-window`,
`chromium --app="${url}" --new-window`,
`brave-browser --app="${url}" --new-window`,
`microsoft-edge --app="${url}" --new-window`,
]
}

View File

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

View File

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

View File

@@ -0,0 +1,36 @@
import { cpSync, existsSync, mkdirSync, rmSync } from "fs"
import os from "os"
import path from "path"
import { fileURLToPath } from "url"
import { createLogger } from "./logger"
const log = createLogger({ component: "opencode-config" })
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const devTemplateDir = path.resolve(__dirname, "../../opencode-config")
const prodTemplateDir = path.resolve(__dirname, "opencode-config")
const isDevBuild = Boolean(process.env.CODENOMAD_DEV ?? process.env.CLI_UI_DEV_SERVER) || existsSync(devTemplateDir)
const templateDir = isDevBuild ? devTemplateDir : prodTemplateDir
const userConfigDir = path.join(os.homedir(), ".config", "codenomad", "opencode-config")
export function getOpencodeConfigDir(): string {
if (!existsSync(templateDir)) {
throw new Error(`CodeNomad Opencode config template missing at ${templateDir}`)
}
if (isDevBuild) {
log.debug({ templateDir }, "Using Opencode config template directly (dev mode)")
return templateDir
}
refreshUserConfig()
return userConfigDir
}
function refreshUserConfig() {
log.debug({ templateDir, userConfigDir }, "Syncing Opencode config template")
rmSync(userConfigDir, { recursive: true, force: true })
mkdirSync(path.dirname(userConfigDir), { recursive: true })
cpSync(templateDir, userConfigDir, { recursive: true })
}

View File

@@ -0,0 +1,141 @@
import { fetch } from "undici"
import type { LatestReleaseInfo } from "../api-types"
import type { Logger } from "../logger"
const RELEASES_API_URL = "https://api.github.com/repos/NeuralNomadsAI/CodeNomad/releases/latest"
interface ReleaseMonitorOptions {
currentVersion: string
logger: Logger
onUpdate: (release: LatestReleaseInfo | null) => void
}
interface GithubReleaseResponse {
tag_name?: string
name?: string
html_url?: string
body?: string
published_at?: string
created_at?: string
prerelease?: boolean
}
interface NormalizedVersion {
major: number
minor: number
patch: number
prerelease: string | null
}
export interface ReleaseMonitor {
stop(): void
}
export function startReleaseMonitor(options: ReleaseMonitorOptions): ReleaseMonitor {
let stopped = false
const refreshRelease = async () => {
if (stopped) return
try {
const release = await fetchLatestRelease(options)
options.onUpdate(release)
} catch (error) {
options.logger.warn({ err: error }, "Failed to refresh release information")
}
}
void refreshRelease()
return {
stop() {
stopped = true
},
}
}
async function fetchLatestRelease(options: ReleaseMonitorOptions): Promise<LatestReleaseInfo | null> {
const response = await fetch(RELEASES_API_URL, {
headers: {
Accept: "application/vnd.github+json",
"User-Agent": "CodeNomad-CLI",
},
})
if (!response.ok) {
throw new Error(`Release API responded with ${response.status}`)
}
const json = (await response.json()) as GithubReleaseResponse
const tagFromServer = json.tag_name || json.name
if (!tagFromServer) {
return null
}
const normalizedVersion = stripTagPrefix(tagFromServer)
if (!normalizedVersion) {
return null
}
const current = parseVersion(options.currentVersion)
const remote = parseVersion(normalizedVersion)
if (compareVersions(remote, current) <= 0) {
return null
}
return {
version: normalizedVersion,
tag: tagFromServer,
url: json.html_url ?? `https://github.com/NeuralNomadsAI/CodeNomad/releases/tag/${encodeURIComponent(tagFromServer)}`,
channel: json.prerelease || normalizedVersion.includes("-") ? "dev" : "stable",
publishedAt: json.published_at ?? json.created_at,
notes: json.body,
}
}
function stripTagPrefix(tag: string | undefined): string | null {
if (!tag) return null
const trimmed = tag.trim()
if (!trimmed) return null
return trimmed.replace(/^v/i, "")
}
function parseVersion(value: string): NormalizedVersion {
const normalized = stripTagPrefix(value) ?? "0.0.0"
const [core, prerelease = null] = normalized.split("-", 2)
const [major = 0, minor = 0, patch = 0] = core.split(".").map((segment) => {
const parsed = Number.parseInt(segment, 10)
return Number.isFinite(parsed) ? parsed : 0
})
return {
major,
minor,
patch,
prerelease,
}
}
function compareVersions(a: NormalizedVersion, b: NormalizedVersion): number {
if (a.major !== b.major) {
return a.major > b.major ? 1 : -1
}
if (a.minor !== b.minor) {
return a.minor > b.minor ? 1 : -1
}
if (a.patch !== b.patch) {
return a.patch > b.patch ? 1 : -1
}
const aPre = a.prerelease && a.prerelease.length > 0 ? a.prerelease : null
const bPre = b.prerelease && b.prerelease.length > 0 ? b.prerelease : null
if (aPre === bPre) {
return 0
}
if (!aPre) {
return 1
}
if (!bPre) {
return -1
}
return aPre.localeCompare(bPre)
}

View File

@@ -0,0 +1,364 @@
import Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from "fastify"
import cors from "@fastify/cors"
import fastifyStatic from "@fastify/static"
import replyFrom 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
}
interface HttpServerStartResult {
port: number
url: string
displayHost: string
}
const DEFAULT_HTTP_PORT = 9898
export function createHttpServer(deps: HttpServerDeps) {
const app = Fastify({ logger: false })
const proxyLogger = deps.logger.child({ component: "proxy" })
const apiLogger = deps.logger.child({ component: "http" })
const sseLogger = deps.logger.child({ component: "sse" })
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.addHook("onRequest", (request, _reply, done) => {
;(request as FastifyRequest & { __logMeta?: { start: bigint } }).__logMeta = {
start: process.hrtime.bigint(),
}
done()
})
app.addHook("onResponse", (request, reply, done) => {
const meta = (request as FastifyRequest & { __logMeta?: { start: bigint } }).__logMeta
const durationMs = meta ? Number((process.hrtime.bigint() - meta.start) / BigInt(1_000_000)) : undefined
const base = {
method: request.method,
url: request.url,
status: reply.statusCode,
durationMs,
}
apiLogger.debug(base, "HTTP request completed")
if (apiLogger.isLevelEnabled("trace")) {
apiLogger.trace({ ...base, params: request.params, query: request.query, body: request.body }, "HTTP request payload")
}
done()
})
app.register(cors, {
origin: true,
credentials: true,
})
app.register(replyFrom, {
contentTypesToEncode: [],
undici: {
connections: 16,
pipelining: 1,
bodyTimeout: 0,
headersTimeout: 0,
},
})
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, logger: sseLogger })
registerStorageRoutes(app, {
instanceStore: deps.instanceStore,
eventBus: deps.eventBus,
workspaceManager: deps.workspaceManager,
})
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger })
if (deps.uiDevServerUrl) {
setupDevProxy(app, deps.uiDevServerUrl)
} else {
setupStaticUi(app, deps.uiStaticDir)
}
return {
instance: app,
start: async (): Promise<HttpServerStartResult> => {
const attemptListen = async (requestedPort: number) => {
const addressInfo = await app.listen({ port: requestedPort, host: deps.host })
return { addressInfo, requestedPort }
}
const autoPortRequested = deps.port === 0
const primaryPort = autoPortRequested ? DEFAULT_HTTP_PORT : deps.port
const shouldRetryWithEphemeral = (error: unknown) => {
if (!autoPortRequested) return false
const err = error as NodeJS.ErrnoException | undefined
return Boolean(err && err.code === "EADDRINUSE")
}
let listenResult
try {
listenResult = await attemptListen(primaryPort)
} catch (error) {
if (!shouldRetryWithEphemeral(error)) {
throw error
}
deps.logger.warn({ err: error, port: primaryPort }, "Preferred port unavailable, retrying on ephemeral port")
listenResult = await attemptListen(0)
}
let actualPort = listenResult.requestedPort
if (typeof listenResult.addressInfo === "string") {
try {
const parsed = new URL(listenResult.addressInfo)
actualPort = Number(parsed.port) || listenResult.requestedPort
} catch {
actualPort = listenResult.requestedPort
}
} else {
const address = app.server.address()
if (typeof address === "object" && address) {
actualPort = address.port
}
}
const displayHost = deps.host === "0.0.0.0" ? "127.0.0.1" : deps.host === "127.0.0.1" ? "localhost" : deps.host
const serverUrl = `http://${displayHost}:${actualPort}`
deps.serverMeta.httpBaseUrl = serverUrl
deps.serverMeta.host = deps.host
deps.serverMeta.port = actualPort
deps.serverMeta.listeningMode = deps.host === "0.0.0.0" ? "all" : "local"
deps.logger.info({ port: actualPort, host: deps.host }, "HTTP server listening")
console.log(`CodeNomad Server is ready at ${serverUrl}`)
return { port: actualPort, url: serverUrl, displayHost }
},
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}`
logger.debug({ workspaceId, method: request.method, targetUrl }, "Proxying request to instance")
if (logger.isLevelEnabled("trace")) {
logger.trace({ workspaceId, targetUrl, body: request.body }, "Instance proxy payload")
}
return reply.from(targetUrl, {
onError: (proxyReply, { error }) => {
logger.error({ err: error, workspaceId, targetUrl }, "Failed to proxy workspace request")
if (!proxyReply.sent) {
proxyReply.code(502).send({ error: "Workspace instance proxy failed" })
}
},
})
}
function normalizeInstanceSuffix(pathSuffix: string | undefined) {
if (!pathSuffix || pathSuffix === "/") {
return "/"
}
const trimmed = pathSuffix.replace(/^\/+/, "")
return trimmed.length === 0 ? "/" : `/${trimmed}`
}
function setupStaticUi(app: FastifyInstance, uiDir: string) {
if (!uiDir) {
app.log.warn("UI static directory not provided; API endpoints only")
return
}
if (!fs.existsSync(uiDir)) {
app.log.warn({ uiDir }, "UI static directory missing; API endpoints only")
return
}
app.register(fastifyStatic, {
root: uiDir,
prefix: "/",
decorateReply: false,
})
const indexPath = path.join(uiDir, "index.html")
app.setNotFoundHandler((request: FastifyRequest, reply: FastifyReply) => {
const url = request.raw.url ?? ""
if (isApiRequest(url)) {
reply.code(404).send({ message: "Not Found" })
return
}
if (fs.existsSync(indexPath)) {
reply.type("text/html").send(fs.readFileSync(indexPath, "utf-8"))
} else {
reply.code(404).send({ message: "UI bundle missing" })
}
})
}
function setupDevProxy(app: FastifyInstance, upstreamBase: string) {
app.log.info({ upstreamBase }, "Proxying UI requests to development server")
app.setNotFoundHandler((request: FastifyRequest, reply: FastifyReply) => {
const url = request.raw.url ?? ""
if (isApiRequest(url)) {
reply.code(404).send({ message: "Not Found" })
return
}
void proxyToDevServer(request, reply, upstreamBase)
})
}
async function proxyToDevServer(request: FastifyRequest, reply: FastifyReply, upstreamBase: string) {
try {
const targetUrl = new URL(request.raw.url ?? "/", upstreamBase)
const response = await fetch(targetUrl, {
method: request.method,
headers: buildProxyHeaders(request.headers),
})
response.headers.forEach((value, key) => {
reply.header(key, value)
})
reply.code(response.status)
if (!response.body || request.method === "HEAD") {
reply.send()
return
}
const buffer = Buffer.from(await response.arrayBuffer())
reply.send(buffer)
} catch (error) {
request.log.error({ err: error }, "Failed to proxy UI request to dev server")
if (!reply.sent) {
reply.code(502).send("UI dev server is unavailable")
}
}
}
function isApiRequest(rawUrl: string | null | undefined) {
if (!rawUrl) return false
const pathname = rawUrl.split("?")[0] ?? ""
return pathname === "/api" || pathname.startsWith("/api/")
}
function buildProxyHeaders(headers: FastifyRequest["headers"]): Record<string, string> {
const result: Record<string, string> = {}
for (const [key, value] of Object.entries(headers ?? {})) {
if (!value || key.toLowerCase() === "host") continue
result[key] = Array.isArray(value) ? value.join(",") : value
}
return result
}

View File

@@ -0,0 +1,62 @@
import { FastifyInstance } from "fastify"
import { z } from "zod"
import { ConfigStore } from "../../config/store"
import { BinaryRegistry } from "../../config/binaries"
import { ConfigFileSchema } 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.replace(body)
return deps.configStore.get()
})
app.get("/api/config/binaries", async () => {
return { binaries: deps.binaryRegistry.list() }
})
app.post("/api/config/binaries", async (request, reply) => {
const body = BinaryCreateSchema.parse(request.body ?? {})
const binary = deps.binaryRegistry.create(body)
reply.code(201)
return { binary }
})
app.patch<{ Params: { id: string } }>("/api/config/binaries/:id", async (request) => {
const body = BinaryUpdateSchema.parse(request.body ?? {})
const binary = deps.binaryRegistry.update(request.params.id, body)
return { binary }
})
app.delete<{ Params: { id: string } }>("/api/config/binaries/:id", async (request, reply) => {
deps.binaryRegistry.remove(request.params.id)
reply.code(204)
})
app.post("/api/config/binaries/validate", async (request) => {
const body = BinaryValidateSchema.parse(request.body ?? {})
return deps.binaryRegistry.validatePath(body.path)
})
}

View File

@@ -0,0 +1,61 @@
import { FastifyInstance } from "fastify"
import { EventBus } from "../../events/bus"
import { WorkspaceEventPayload } from "../../api-types"
import { Logger } from "../../logger"
interface RouteDeps {
eventBus: EventBus
registerClient: (cleanup: () => void) => () => void
logger: Logger
}
let nextClientId = 0
export function registerEventRoutes(app: FastifyInstance, deps: RouteDeps) {
app.get("/api/events", (request, reply) => {
const clientId = ++nextClientId
deps.logger.debug({ clientId }, "SSE client connected")
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) => {
deps.logger.debug({ clientId, type: event.type }, "SSE event dispatched")
if (deps.logger.isLevelEnabled("trace")) {
deps.logger.trace({ clientId, event }, "SSE event payload")
}
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?.()
deps.logger.debug({ clientId }, "SSE client disconnected")
}
const unregister = deps.registerClient(close)
const handleClose = () => {
close()
unregister()
}
request.raw.on("close", handleClose)
request.raw.on("error", handleClose)
})
}

View File

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

View File

@@ -0,0 +1,104 @@
import { FastifyInstance } from "fastify"
import os from "os"
import { NetworkAddress, ServerMeta } from "../../api-types"
interface RouteDeps {
serverMeta: ServerMeta
}
export function registerMetaRoutes(app: FastifyInstance, deps: RouteDeps) {
app.get("/api/meta", async () => buildMetaResponse(deps.serverMeta))
}
function buildMetaResponse(meta: ServerMeta): ServerMeta {
const port = resolvePort(meta)
const addresses = port > 0 ? resolveAddresses(port, meta.host) : []
return {
...meta,
port,
listeningMode: meta.host === "0.0.0.0" ? "all" : "local",
addresses,
}
}
function resolvePort(meta: ServerMeta): number {
if (Number.isInteger(meta.port) && meta.port > 0) {
return meta.port
}
try {
const parsed = new URL(meta.httpBaseUrl)
const port = Number(parsed.port)
return Number.isInteger(port) && port > 0 ? port : 0
} catch {
return 0
}
}
function resolveAddresses(port: number, host: string): NetworkAddress[] {
const interfaces = os.networkInterfaces()
const seen = new Set<string>()
const results: NetworkAddress[] = []
const addAddress = (ip: string, scope: NetworkAddress["scope"]) => {
if (!ip || ip === "0.0.0.0") return
const key = `ipv4-${ip}`
if (seen.has(key)) return
seen.add(key)
results.push({ ip, family: "ipv4", scope, url: `http://${ip}:${port}` })
}
const normalizeFamily = (value: string | number) => {
if (typeof value === "string") {
const lowered = value.toLowerCase()
if (lowered === "ipv4") {
return "ipv4" as const
}
}
if (value === 4) return "ipv4" as const
return null
}
if (host === "0.0.0.0") {
// Enumerate system interfaces (IPv4 only)
for (const entries of Object.values(interfaces)) {
if (!entries) continue
for (const entry of entries) {
const family = normalizeFamily(entry.family)
if (!family) continue
if (!entry.address || entry.address === "0.0.0.0") continue
const scope: NetworkAddress["scope"] = entry.internal ? "loopback" : "external"
addAddress(entry.address, scope)
}
}
}
// Always include loopback address
addAddress("127.0.0.1", "loopback")
// Include explicitly configured host if it was IPv4
if (isIPv4Address(host) && host !== "0.0.0.0") {
const isLoopback = host.startsWith("127.")
addAddress(host, isLoopback ? "loopback" : "external")
}
const scopeWeight: Record<NetworkAddress["scope"], number> = { external: 0, internal: 1, loopback: 2 }
return results.sort((a, b) => {
const scopeDelta = scopeWeight[a.scope] - scopeWeight[b.scope]
if (scopeDelta !== 0) return scopeDelta
return a.ip.localeCompare(b.ip)
})
}
function isIPv4Address(value: string | undefined): value is string {
if (!value) return false
const parts = value.split(".")
if (parts.length !== 4) return false
return parts.every((part) => {
if (part.length === 0 || part.length > 3) return false
if (!/^[0-9]+$/.test(part)) return false
const num = Number(part)
return Number.isInteger(num) && num >= 0 && num <= 255
})
}

View File

@@ -0,0 +1,66 @@
import { FastifyInstance } from "fastify"
import { z } from "zod"
import { InstanceStore } from "../../storage/instance-store"
import { EventBus } from "../../events/bus"
import { ModelPreferenceSchema } from "../../config/schema"
import type { InstanceData } from "../../api-types"
import { WorkspaceManager } from "../../workspaces/manager"
interface RouteDeps {
instanceStore: InstanceStore
eventBus: EventBus
workspaceManager: WorkspaceManager
}
const InstanceDataSchema = z.object({
messageHistory: z.array(z.string()).default([]),
agentModelSelections: z.record(z.string(), ModelPreferenceSchema).default({}),
})
const EMPTY_INSTANCE_DATA: InstanceData = {
messageHistory: [],
agentModelSelections: {},
}
export function registerStorageRoutes(app: FastifyInstance, deps: RouteDeps) {
const resolveStorageKey = (instanceId: string): string => {
const workspace = deps.workspaceManager.get(instanceId)
return workspace?.path ?? instanceId
}
app.get<{ Params: { id: string } }>("/api/storage/instances/:id", async (request, reply) => {
try {
const storageId = resolveStorageKey(request.params.id)
const data = await deps.instanceStore.read(storageId)
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 ?? {})
const storageId = resolveStorageKey(request.params.id)
await deps.instanceStore.write(storageId, body)
deps.eventBus.publish({ type: "instance.dataChanged", instanceId: request.params.id, data: 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 {
const storageId = resolveStorageKey(request.params.id)
await deps.instanceStore.delete(storageId)
deps.eventBus.publish({ type: "instance.dataChanged", instanceId: request.params.id, data: EMPTY_INSTANCE_DATA })
reply.code(204)
} catch (error) {
reply.code(500)
return { error: error instanceof Error ? error.message : "Failed to delete instance data" }
}
})
}

View File

@@ -0,0 +1,113 @@
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(),
})
const WorkspaceFileSearchQuerySchema = z.object({
q: z.string().trim().min(1, "Query is required"),
limit: z.coerce.number().int().positive().max(200).optional(),
type: z.enum(["all", "file", "directory"]).optional(),
refresh: z
.string()
.optional()
.transform((value) => (value === undefined ? undefined : value === "true")),
})
export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) {
app.get("/api/workspaces", async () => {
return deps.workspaceManager.list()
})
app.post("/api/workspaces", async (request, reply) => {
try {
const body = WorkspaceCreateSchema.parse(request.body ?? {})
const workspace = await deps.workspaceManager.create(body.path, body.name)
reply.code(201)
return workspace
} catch (error) {
request.log.error({ err: error }, "Failed to create workspace")
const message = error instanceof Error ? error.message : "Failed to create workspace"
reply.code(400).type("text/plain").send(message)
}
})
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: { q?: string; limit?: string; type?: "all" | "file" | "directory"; refresh?: string }
}>("/api/workspaces/:id/files/search", async (request, reply) => {
try {
const query = WorkspaceFileSearchQuerySchema.parse(request.query ?? {})
return deps.workspaceManager.searchFiles(request.params.id, query.q, {
limit: query.limit,
type: query.type,
refresh: query.refresh,
})
} catch (error) {
return handleWorkspaceError(error, reply)
}
})
app.get<{
Params: { id: string }
Querystring: { path?: string }
}>("/api/workspaces/:id/files/content", async (request, reply) => {
try {
const query = WorkspaceFileContentQuerySchema.parse(request.query ?? {})
return deps.workspaceManager.readFile(request.params.id, query.path)
} catch (error) {
return handleWorkspaceError(error, reply)
}
})
}
function handleWorkspaceError(error: unknown, reply: FastifyReply) {
if (error instanceof Error && error.message === "Workspace not found") {
reply.code(404)
return { error: "Workspace not found" }
}
reply.code(400)
return { error: error instanceof Error ? error.message : "Unable to fulfill request" }
}

View File

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

View File

@@ -0,0 +1,195 @@
import { Agent, fetch } from "undici"
import { Agent as UndiciAgent } from "undici"
import { EventBus } from "../events/bus"
import { Logger } from "../logger"
import { WorkspaceManager } from "./manager"
import { InstanceStreamEvent, InstanceStreamStatus } from "../api-types"
const INSTANCE_HOST = "127.0.0.1"
const STREAM_AGENT = new UndiciAgent({ bodyTimeout: 0, headersTimeout: 0 })
const RECONNECT_DELAY_MS = 1000
interface InstanceEventBridgeOptions {
workspaceManager: WorkspaceManager
eventBus: EventBus
logger: Logger
}
interface ActiveStream {
controller: AbortController
task: Promise<void>
}
export class InstanceEventBridge {
private readonly streams = new Map<string, ActiveStream>()
constructor(private readonly options: InstanceEventBridgeOptions) {
const bus = this.options.eventBus
bus.on("workspace.started", (event) => this.startStream(event.workspace.id))
bus.on("workspace.stopped", (event) => this.stopStream(event.workspaceId, "workspace stopped"))
bus.on("workspace.error", (event) => this.stopStream(event.workspace.id, "workspace error"))
}
shutdown() {
for (const [id, active] of this.streams) {
active.controller.abort()
this.publishStatus(id, "disconnected")
}
this.streams.clear()
}
private startStream(workspaceId: string) {
if (this.streams.has(workspaceId)) {
return
}
const controller = new AbortController()
const task = this.runStream(workspaceId, controller.signal)
.catch((error) => {
if (!controller.signal.aborted) {
this.options.logger.warn({ workspaceId, err: error }, "Instance event stream failed")
this.publishStatus(workspaceId, "error", error instanceof Error ? error.message : String(error))
}
})
.finally(() => {
const active = this.streams.get(workspaceId)
if (active?.controller === controller) {
this.streams.delete(workspaceId)
}
})
this.streams.set(workspaceId, { controller, task })
}
private stopStream(workspaceId: string, reason?: string) {
const active = this.streams.get(workspaceId)
if (!active) {
return
}
active.controller.abort()
this.streams.delete(workspaceId)
this.publishStatus(workspaceId, "disconnected", reason)
}
private async runStream(workspaceId: string, signal: AbortSignal) {
while (!signal.aborted) {
const port = this.options.workspaceManager.getInstancePort(workspaceId)
if (!port) {
await this.delay(RECONNECT_DELAY_MS, signal)
continue
}
this.publishStatus(workspaceId, "connecting")
try {
await this.consumeStream(workspaceId, port, signal)
} catch (error) {
if (signal.aborted) {
break
}
this.options.logger.warn({ workspaceId, err: error }, "Instance event stream disconnected")
this.publishStatus(workspaceId, "error", error instanceof Error ? error.message : String(error))
await this.delay(RECONNECT_DELAY_MS, signal)
}
}
}
private async consumeStream(workspaceId: string, port: number, signal: AbortSignal) {
const url = `http://${INSTANCE_HOST}:${port}/event`
const response = await fetch(url, {
headers: { Accept: "text/event-stream" },
signal,
dispatcher: STREAM_AGENT,
})
if (!response.ok || !response.body) {
throw new Error(`Instance event stream unavailable (${response.status})`)
}
this.publishStatus(workspaceId, "connected")
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ""
while (!signal.aborted) {
const { done, value } = await reader.read()
if (done || !value) {
break
}
buffer += decoder.decode(value, { stream: true })
buffer = this.flushEvents(buffer, workspaceId)
}
}
private flushEvents(buffer: string, workspaceId: string) {
let separatorIndex = buffer.indexOf("\n\n")
while (separatorIndex >= 0) {
const chunk = buffer.slice(0, separatorIndex)
buffer = buffer.slice(separatorIndex + 2)
this.processChunk(chunk, workspaceId)
separatorIndex = buffer.indexOf("\n\n")
}
return buffer
}
private processChunk(chunk: string, workspaceId: string) {
const lines = chunk.split(/\r?\n/)
const dataLines: string[] = []
for (const line of lines) {
if (line.startsWith(":")) {
continue
}
if (line.startsWith("data:")) {
dataLines.push(line.slice(5).trimStart())
}
}
if (dataLines.length === 0) {
return
}
const payload = dataLines.join("\n").trim()
if (!payload) {
return
}
try {
const event = JSON.parse(payload) as InstanceStreamEvent
this.options.logger.debug({ workspaceId, eventType: event.type }, "Instance SSE event received")
if (this.options.logger.isLevelEnabled("trace")) {
this.options.logger.trace({ workspaceId, event }, "Instance SSE event payload")
}
this.options.eventBus.publish({ type: "instance.event", instanceId: workspaceId, event })
} catch (error) {
this.options.logger.warn({ workspaceId, chunk: payload, err: error }, "Failed to parse instance SSE payload")
}
}
private publishStatus(instanceId: string, status: InstanceStreamStatus, reason?: string) {
this.options.logger.debug({ instanceId, status, reason }, "Instance SSE status updated")
this.options.eventBus.publish({ type: "instance.eventStatus", instanceId, status, reason })
}
private delay(duration: number, signal: AbortSignal) {
if (duration <= 0) {
return Promise.resolve()
}
return new Promise<void>((resolve) => {
const timeout = setTimeout(() => {
signal.removeEventListener("abort", onAbort)
resolve()
}, duration)
const onAbort = () => {
clearTimeout(timeout)
resolve()
}
signal.addEventListener("abort", onAbort, { once: true })
})
}
}

View File

@@ -0,0 +1,422 @@
import path from "path"
import { spawnSync } from "child_process"
import { connect } from "net"
import { EventBus } from "../events/bus"
import { ConfigStore } from "../config/store"
import { BinaryRegistry } from "../config/binaries"
import { FileSystemBrowser } from "../filesystem/browser"
import { searchWorkspaceFiles, WorkspaceFileSearchOptions } from "../filesystem/search"
import { clearWorkspaceSearchCache } from "../filesystem/search-cache"
import { WorkspaceDescriptor, WorkspaceFileResponse, FileSystemEntry } from "../api-types"
import { WorkspaceRuntime, ProcessExitInfo } from "./runtime"
import { Logger } from "../logger"
import { getOpencodeConfigDir } from "../opencode-config"
const STARTUP_STABILITY_DELAY_MS = 1500
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
private readonly opencodeConfigDir: string
constructor(private readonly options: WorkspaceManagerOptions) {
this.runtime = new WorkspaceRuntime(this.options.eventBus, this.options.logger)
this.opencodeConfigDir = getOpencodeConfigDir()
}
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)
}
searchFiles(workspaceId: string, query: string, options?: WorkspaceFileSearchOptions): FileSystemEntry[] {
const workspace = this.requireWorkspace(workspaceId)
return searchWorkspaceFiles(workspace.path, query, options)
}
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 resolvedBinaryPath = this.resolveBinaryPath(binary.path)
const workspacePath = path.isAbsolute(folder) ? folder : path.resolve(this.options.rootDir, folder)
clearWorkspaceSearchCache(workspacePath)
this.options.logger.info({ workspaceId: id, folder: workspacePath, binary: resolvedBinaryPath }, "Creating workspace")
const proxyPath = `/workspaces/${id}/instance`
const descriptor: WorkspaceRecord = {
id,
path: workspacePath,
name,
status: "starting",
proxyPath,
binaryId: resolvedBinaryPath,
binaryLabel: binary.label,
binaryVersion: binary.version,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}
if (!descriptor.binaryVersion) {
descriptor.binaryVersion = this.detectBinaryVersion(resolvedBinaryPath)
}
this.workspaces.set(id, descriptor)
this.options.eventBus.publish({ type: "workspace.created", workspace: descriptor })
const preferences = this.options.configStore.get().preferences ?? {}
const userEnvironment = preferences.environmentVariables ?? {}
const environment = {
...userEnvironment,
OPENCODE_CONFIG_DIR: this.opencodeConfigDir,
}
try {
const { pid, port, exitPromise, getLastOutput } = await this.runtime.launch({
workspaceId: id,
folder: workspacePath,
binaryPath: resolvedBinaryPath,
environment,
onExit: (info) => this.handleProcessExit(info.workspaceId, info),
})
await this.waitForWorkspaceReadiness({ workspaceId: id, port, exitPromise, getLastOutput })
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)
clearWorkspaceSearchCache(workspace.path)
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 resolveBinaryPath(identifier: string): string {
if (!identifier) {
return identifier
}
const looksLikePath = identifier.includes("/") || identifier.includes("\\") || identifier.startsWith(".")
if (path.isAbsolute(identifier) || looksLikePath) {
return identifier
}
const locator = process.platform === "win32" ? "where" : "which"
try {
const result = spawnSync(locator, [identifier], { encoding: "utf8" })
if (result.status === 0 && result.stdout) {
const resolved = result.stdout
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => line.length > 0)
if (resolved) {
this.options.logger.debug({ identifier, resolved }, "Resolved binary path from system PATH")
return resolved
}
} else if (result.error) {
this.options.logger.warn({ identifier, err: result.error }, "Failed to resolve binary path via locator command")
}
} catch (error) {
this.options.logger.warn({ identifier, err: error }, "Failed to resolve binary path from system PATH")
}
return identifier
}
private detectBinaryVersion(resolvedPath: string): string | undefined {
if (!resolvedPath) {
return undefined
}
try {
const result = spawnSync(resolvedPath, ["--version"], { encoding: "utf8" })
if (result.status === 0 && result.stdout) {
const line = result.stdout.split(/\r?\n/).find((entry) => entry.trim().length > 0)
if (line) {
const normalized = line.trim()
const versionMatch = normalized.match(/([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/)
if (versionMatch) {
const version = versionMatch[1]
this.options.logger.debug({ binary: resolvedPath, version }, "Detected binary version")
return version
}
this.options.logger.debug({ binary: resolvedPath, reported: normalized }, "Binary reported version string")
return normalized
}
} else if (result.error) {
this.options.logger.warn({ binary: resolvedPath, err: result.error }, "Failed to read binary version")
}
} catch (error) {
this.options.logger.warn({ binary: resolvedPath, err: error }, "Failed to detect binary version")
}
return undefined
}
private async waitForWorkspaceReadiness(params: {
workspaceId: string
port: number
exitPromise: Promise<ProcessExitInfo>
getLastOutput: () => string
}) {
await Promise.race([
this.waitForPortAvailability(params.port),
params.exitPromise.then((info) => {
throw this.buildStartupError(
params.workspaceId,
"exited before becoming ready",
info,
params.getLastOutput(),
)
}),
])
await this.waitForInstanceHealth(params)
await Promise.race([
this.delay(STARTUP_STABILITY_DELAY_MS),
params.exitPromise.then((info) => {
throw this.buildStartupError(
params.workspaceId,
"exited shortly after start",
info,
params.getLastOutput(),
)
}),
])
}
private async waitForInstanceHealth(params: {
workspaceId: string
port: number
exitPromise: Promise<ProcessExitInfo>
getLastOutput: () => string
}) {
const probeResult = await Promise.race([
this.probeInstance(params.workspaceId, params.port),
params.exitPromise.then((info) => {
throw this.buildStartupError(
params.workspaceId,
"exited during health checks",
info,
params.getLastOutput(),
)
}),
])
if (probeResult.ok) {
return
}
const latestOutput = params.getLastOutput().trim()
const outputDetails = latestOutput ? ` Last output: ${latestOutput}` : ""
const reason = probeResult.reason ?? "Health check failed"
throw new Error(`Workspace ${params.workspaceId} failed health check: ${reason}.${outputDetails}`)
}
private async probeInstance(workspaceId: string, port: number): Promise<{ ok: boolean; reason?: string }> {
const url = `http://127.0.0.1:${port}/project/current`
try {
const response = await fetch(url)
if (!response.ok) {
const reason = `health probe returned HTTP ${response.status}`
this.options.logger.debug({ workspaceId, status: response.status }, "Health probe returned server error")
return { ok: false, reason }
}
return { ok: true }
} catch (error) {
const reason = error instanceof Error ? error.message : String(error)
this.options.logger.debug({ workspaceId, err: error }, "Health probe failed")
return { ok: false, reason }
}
}
private buildStartupError(
workspaceId: string,
phase: string,
exitInfo: ProcessExitInfo,
lastOutput: string,
): Error {
const exitDetails = this.describeExit(exitInfo)
const trimmedOutput = lastOutput.trim()
const outputDetails = trimmedOutput ? ` Last output: ${trimmedOutput}` : ""
return new Error(`Workspace ${workspaceId} ${phase} (${exitDetails}).${outputDetails}`)
}
private waitForPortAvailability(port: number, timeoutMs = 5000): Promise<void> {
return new Promise((resolve, reject) => {
const deadline = Date.now() + timeoutMs
let settled = false
let retryTimer: NodeJS.Timeout | null = null
const cleanup = () => {
settled = true
if (retryTimer) {
clearTimeout(retryTimer)
retryTimer = null
}
}
const tryConnect = () => {
if (settled) {
return
}
const socket = connect({ port, host: "127.0.0.1" }, () => {
cleanup()
socket.end()
resolve()
})
socket.once("error", () => {
socket.destroy()
if (settled) {
return
}
if (Date.now() >= deadline) {
cleanup()
reject(new Error(`Workspace port ${port} did not become ready within ${timeoutMs}ms`))
} else {
retryTimer = setTimeout(() => {
retryTimer = null
tryConnect()
}, 100)
}
})
}
tryConnect()
})
}
private delay(durationMs: number): Promise<void> {
if (durationMs <= 0) {
return Promise.resolve()
}
return new Promise((resolve) => setTimeout(resolve, durationMs))
}
private describeExit(info: ProcessExitInfo): string {
if (info.signal) {
return `signal ${info.signal}`
}
if (info.code !== null) {
return `code ${info.code}`
}
return "unknown reason"
}
private handleProcessExit(workspaceId: string, info: { code: number | null; requested: boolean }) {
const workspace = this.workspaces.get(workspaceId)
if (!workspace) return
this.options.logger.info({ workspaceId, ...info }, "Workspace process exited")
workspace.pid = undefined
workspace.port = undefined
workspace.updatedAt = new Date().toISOString()
if (info.requested || info.code === 0) {
workspace.status = "stopped"
workspace.error = undefined
this.options.eventBus.publish({ type: "workspace.stopped", workspaceId })
} else {
workspace.status = "error"
workspace.error = `Process exited with code ${info.code}`
this.options.eventBus.publish({ type: "workspace.error", workspace })
}
}
}

View File

@@ -0,0 +1,241 @@
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
}
export 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; exitPromise: Promise<ProcessExitInfo>; getLastOutput: () => string }> {
this.validateFolder(options.folder)
const args = ["serve", "--port", "0", "--print-logs", "--log-level", "DEBUG"]
const env = { ...process.env, ...(options.environment ?? {}) }
let exitResolve: ((info: ProcessExitInfo) => void) | null = null
const exitPromise = new Promise<ProcessExitInfo>((resolveExit) => {
exitResolve = resolveExit
})
let lastOutput = ""
return new Promise((resolve, reject) => {
this.logger.info(
{ workspaceId: options.workspaceId, folder: options.folder, binary: options.binaryPath },
"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)
const exitInfo: ProcessExitInfo = {
workspaceId: options.workspaceId,
code,
signal,
requested: managed.requestedStop,
}
if (exitResolve) {
exitResolve(exitInfo)
exitResolve = null
}
if (!portFound) {
const reason = stderrBuffer || `Process exited with code ${code}`
reject(new Error(reason))
} else {
options.onExit?.(exitInfo)
}
}
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")
if (exitResolve) {
exitResolve({ workspaceId: options.workspaceId, code: null, signal: null, requested: managed.requestedStop })
exitResolve = null
}
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) {
const trimmed = line.trim()
if (!trimmed) continue
lastOutput = trimmed
this.emitLog(options.workspaceId, "info", line)
if (!portFound) {
const portMatch = line.match(/opencode server listening on http:\/\/.+:(\d+)/i)
if (portMatch) {
portFound = true
child.removeListener("error", handleError)
const port = parseInt(portMatch[1], 10)
this.logger.info({ workspaceId: options.workspaceId, port }, "Workspace runtime allocated port")
const getLastOutput = () => lastOutput.trim()
resolve({ pid: child.pid!, port, exitPromise, getLastOutput })
}
}
}
})
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) {
const trimmed = line.trim()
if (!trimmed) continue
lastOutput = `[stderr] ${trimmed}`
this.emitLog(options.workspaceId, "error", line)
}
})
})
}
async stop(workspaceId: string): Promise<void> {
const managed = this.processes.get(workspaceId)
if (!managed) return
managed.requestedStop = true
const child = managed.child
this.logger.info({ workspaceId }, "Stopping OpenCode process")
await new Promise<void>((resolve, reject) => {
const cleanup = () => {
child.removeListener("exit", onExit)
child.removeListener("error", onError)
}
const onExit = () => {
cleanup()
resolve()
}
const onError = (error: Error) => {
cleanup()
reject(error)
}
const resolveIfAlreadyExited = () => {
if (child.exitCode !== null || child.signalCode !== null) {
this.logger.debug({ workspaceId, exitCode: child.exitCode, signal: child.signalCode }, "Process already exited")
cleanup()
resolve()
return true
}
return false
}
child.once("exit", onExit)
child.once("error", onError)
if (resolveIfAlreadyExited()) {
return
}
this.logger.debug({ workspaceId }, "Sending SIGTERM to workspace process")
child.kill("SIGTERM")
setTimeout(() => {
if (!child.killed) {
this.logger.warn({ workspaceId }, "Process did not stop after SIGTERM, force killing")
child.kill("SIGKILL")
} else {
this.logger.debug({ workspaceId }, "Workspace process stopped gracefully before SIGKILL timeout")
}
}, 2000)
})
}
private emitLog(workspaceId: string, level: LogLevel, message: string) {
const entry: WorkspaceLogEntry = {
workspaceId,
timestamp: new Date().toISOString(),
level,
message: message.trim(),
}
this.eventBus.publish({ type: "workspace.log", entry })
}
private validateFolder(folder: string) {
const resolved = path.resolve(folder)
if (!existsSync(resolved)) {
throw new Error(`Folder does not exist: ${resolved}`)
}
const stats = statSync(resolved)
if (!stats.isDirectory()) {
throw new Error(`Path is not a directory: ${resolved}`)
}
}
}

View File

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