diff --git a/.env.example b/.env.example index 181b273..04c769f 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,3 @@ CLAUDE_BIN=claude # Backend server port (default: 9998) PORT=9998 - -# Allowed CORS origin for the Vite dev server (dev only; not needed in production) -FRONTEND_ORIGIN=http://localhost:9999 diff --git a/CLAUDE.md b/CLAUDE.md index 4993eb0..e4ee894 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,7 +46,6 @@ cp .env.example backend/.env | `CLAUDE_BIN` | `claude` | Path to the `claude` binary | | `PORT` | `9998` | Backend listen port | | `HOST` | `0.0.0.0` | Backend listen address | -| `FRONTEND_ORIGIN` | `http://localhost:9999` | CORS allowed origin (dev only) | ## Dev ports diff --git a/Makefile b/Makefile index ee2522d..f09ee18 100644 --- a/Makefile +++ b/Makefile @@ -1,16 +1,10 @@ .PHONY: dev dev-backend dev-frontend build lint test install clean run service-install service-uninstall service-test -# Fedora / RHEL hosts lack the Google Trust Services intermediate CA that -# Anthropic's API uses. Set NODE_EXTRA_CA_CERTS only when the bundle exists -# so the fix is a no-op on macOS, Ubuntu, Alpine, etc. -CA_BUNDLE := /etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem -CA_ENV := $(if $(wildcard $(CA_BUNDLE)),NODE_EXTRA_CA_CERTS=$(CA_BUNDLE),) - dev: @make -j2 dev-backend dev-frontend dev-backend: - cd backend && $(CA_ENV) npm run dev + cd backend && npm run dev dev-frontend: cd frontend && npm run dev diff --git a/README.md b/README.md index 0b067ed..cc8f78a 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,6 @@ cp .env.example backend/.env | `CLAUDE_BIN` | `claude` | Path to the `claude` binary | | `PORT` | `9998` | Backend listen port | | `HOST` | `0.0.0.0` | Backend listen address | -| `FRONTEND_ORIGIN` | `http://localhost:9999` | Allowed CORS origin (dev only) | ## Development diff --git a/backend/src/routes/account.ts b/backend/src/routes/account.ts index b23ae4f..079a070 100644 --- a/backend/src/routes/account.ts +++ b/backend/src/routes/account.ts @@ -1,9 +1,8 @@ import type { FastifyInstance } from 'fastify' -import { getAccountInfo } from '../services/claudeAccount' +import { getCachedAccount } from '../services/accountCache' export async function accountRoutes(fastify: FastifyInstance) { fastify.get('/api/account', async (_req, reply) => { - const info = await getAccountInfo() - return reply.send(info) + return reply.send(getCachedAccount()) }) } diff --git a/backend/src/routes/sessions.ts b/backend/src/routes/sessions.ts index b43e57d..bfddf35 100644 --- a/backend/src/routes/sessions.ts +++ b/backend/src/routes/sessions.ts @@ -27,6 +27,23 @@ export async function sessionRoutes(fastify: FastifyInstance) { } }) + fastify.get<{ Params: { id: string } }>('/api/sessions/:id', async (req, reply) => { + const { id } = req.params + const row = db + .prepare( + `SELECT s.*, + CASE WHEN s.ended_at IS NULL THEN 1 ELSE 0 END as is_active, + COUNT(m.id) as message_count + FROM sessions s + LEFT JOIN messages m ON m.session_id = s.id + WHERE s.id = ? + GROUP BY s.id`, + ) + .get(id) + if (!row) return reply.status(404).send({ error: 'Session not found' }) + return reply.send(row) + }) + fastify.get('/api/sessions', async (_req, reply) => { const rows = db .prepare( diff --git a/backend/src/routes/settings.test.ts b/backend/src/routes/settings.test.ts index d52fcdd..310c183 100644 --- a/backend/src/routes/settings.test.ts +++ b/backend/src/routes/settings.test.ts @@ -32,7 +32,7 @@ describe('settings routes', () => { expect(res.statusCode).toBe(200) const body = res.json() expect(body).toMatchObject({ - bypass_permissions: 'true', + bypass_permissions: 'false', session_mode: 'terminal', }) }) diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts index 4beffd3..a183a2f 100644 --- a/backend/src/routes/settings.ts +++ b/backend/src/routes/settings.ts @@ -2,7 +2,7 @@ import type { FastifyInstance } from 'fastify' import { db } from '../db/schema' const DEFAULTS: Record = { - bypass_permissions: 'true', + bypass_permissions: 'false', session_mode: 'terminal', } diff --git a/backend/src/server.ts b/backend/src/server.ts index 91e68af..734de65 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -10,6 +10,7 @@ import { sessionRoutes } from './routes/sessions' import { sessionWsRoutes } from './ws/session' import { terminalWsRoutes } from './ws/terminal' import { settingsRoutes } from './routes/settings' +import { initAccountCache } from './services/accountCache' // TODO: add bearer token auth — add @fastify/bearer-auth plugin here // and set token via DASHBOARD_TOKEN env var @@ -18,15 +19,16 @@ import { settingsRoutes } from './routes/settings' const fastify = Fastify({ logger: true }) async function start() { - const devOrigin = process.env.FRONTEND_ORIGIN + const PORT = Number(process.env.PORT ?? 9998) + const HOST = process.env.HOST ?? '0.0.0.0' await fastify.register(cors, { methods: ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE'], origin: (origin, cb) => { if (!origin) return cb(null, true) // same-origin / curl / no-CORS try { - if (new URL(origin).port === '9999') return cb(null, true) + const { port } = new URL(origin) + if (port === '9999' || port === String(PORT)) return cb(null, true) } catch { /* ignore malformed */ } - if (devOrigin && origin === devOrigin) return cb(null, true) cb(new Error('Not allowed by CORS'), false) }, }) @@ -57,15 +59,14 @@ async function start() { }) } - const PORT = Number(process.env.PORT ?? 9998) - const HOST = process.env.HOST ?? '0.0.0.0' - try { await fastify.listen({ port: PORT, host: HOST }) } catch (err) { fastify.log.error(err) process.exit(1) } + + initAccountCache().catch((err) => fastify.log.error(err)) } start() diff --git a/backend/src/services/accountCache.ts b/backend/src/services/accountCache.ts new file mode 100644 index 0000000..d394b75 --- /dev/null +++ b/backend/src/services/accountCache.ts @@ -0,0 +1,17 @@ +import { getAccountInfo } from './claudeAccount' +import type { AccountInfo } from './claudeAccount' + +const REFRESH_MS = 60_000 + +let cached: AccountInfo | null = null + +export async function initAccountCache(): Promise { + cached = await getAccountInfo() + setInterval(async () => { + cached = await getAccountInfo() + }, REFRESH_MS).unref() +} + +export function getCachedAccount(): AccountInfo | null { + return cached +} diff --git a/backend/src/services/claudeAccount.ts b/backend/src/services/claudeAccount.ts index 6afbdee..90f9c14 100644 --- a/backend/src/services/claudeAccount.ts +++ b/backend/src/services/claudeAccount.ts @@ -27,7 +27,11 @@ export async function getAccountInfo(): Promise { authStatus: 'unknown', } - const versionResult = await execFileNoThrow(claudeBin(), ['--version']) + const [versionResult, authResult] = await Promise.all([ + execFileNoThrow(claudeBin(), ['--version']), + execFileNoThrow(claudeBin(), ['config', 'get', 'oauthToken']), + ]) + if (versionResult.status !== 0) return base base.claudeInstalled = true @@ -42,7 +46,6 @@ export async function getAccountInfo(): Promise { // settings.json absent or malformed — model stays null } - const authResult = await execFileNoThrow(claudeBin(), ['config', 'get', 'oauthToken']) base.authStatus = authResult.status === 0 && authResult.stdout.trim().length > 0 ? 'authenticated' : 'unauthenticated' diff --git a/backend/src/utils/resolveBin.test.ts b/backend/src/utils/resolveBin.test.ts new file mode 100644 index 0000000..57bcf05 --- /dev/null +++ b/backend/src/utils/resolveBin.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' + +const existsSyncMock = vi.hoisted(() => vi.fn<(p: string) => boolean>()) + +vi.mock('fs', async (importOriginal) => { + const original = await importOriginal() + return { ...original, existsSync: existsSyncMock, default: { ...original, existsSync: existsSyncMock } } +}) + +const { resolveBin } = await import('./resolveBin') + +const originalPlatform = process.platform +const originalPath = process.env.PATH +const originalPathExt = process.env.PATHEXT + +function setPlatform(p: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { value: p, configurable: true }) +} + +describe('resolveBin', () => { + beforeEach(() => { + existsSyncMock.mockReset() + }) + + afterEach(() => { + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }) + process.env.PATH = originalPath + process.env.PATHEXT = originalPathExt + }) + + it('returns input unchanged on POSIX', () => { + setPlatform('linux') + expect(resolveBin('claude')).toBe('claude') + expect(resolveBin('/usr/local/bin/claude')).toBe('/usr/local/bin/claude') + expect(existsSyncMock).not.toHaveBeenCalled() + }) + + it('on Windows, resolves a bare name to \\.CMD when only .cmd exists', () => { + setPlatform('win32') + process.env.PATH = 'C:\\bin;C:\\other' + process.env.PATHEXT = '.COM;.EXE;.CMD' + existsSyncMock.mockImplementation((p) => p === 'C:\\bin\\claude.CMD') + + expect(resolveBin('claude')).toBe('C:\\bin\\claude.CMD') + }) + + it('on Windows, falls back to input when nothing matches on PATH', () => { + setPlatform('win32') + process.env.PATH = 'C:\\bin' + process.env.PATHEXT = '.EXE;.CMD' + existsSyncMock.mockReturnValue(false) + + expect(resolveBin('claude')).toBe('claude') + }) + + it('on Windows, an absolute path with an existing extension is returned as-is', () => { + setPlatform('win32') + existsSyncMock.mockImplementation((p) => p === 'C:\\tools\\claude.cmd') + + expect(resolveBin('C:\\tools\\claude.cmd')).toBe('C:\\tools\\claude.cmd') + }) + + it('on Windows, an absolute path without extension gets PATHEXT appended', () => { + setPlatform('win32') + process.env.PATHEXT = '.EXE;.CMD' + existsSyncMock.mockImplementation((p) => p === 'C:\\tools\\claude.CMD') + + expect(resolveBin('C:\\tools\\claude')).toBe('C:\\tools\\claude.CMD') + }) +}) diff --git a/backend/src/utils/resolveBin.ts b/backend/src/utils/resolveBin.ts new file mode 100644 index 0000000..c61b6bf --- /dev/null +++ b/backend/src/utils/resolveBin.ts @@ -0,0 +1,39 @@ +import * as fs from 'fs' +import * as path from 'path' + +// node-pty on Windows uses CreateProcessW, which does not honor PATHEXT. +// Spawning a bare name like "claude" fails with "File not found: claude" +// even though `claude.cmd` is on PATH. Resolve PATH + PATHEXT explicitly here. +// On POSIX this is a no-op — node-pty's own PATH search works fine there. +export function resolveBin(bin: string): string { + if (process.platform !== 'win32') return bin + + const exts = (process.env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD') + .split(';') + .map((e) => e.trim()) + .filter(Boolean) + + if (path.win32.isAbsolute(bin)) { + if (path.win32.extname(bin) && fs.existsSync(bin)) return bin + for (const ext of exts) { + const candidate = bin + ext + if (fs.existsSync(candidate)) return candidate + } + return bin + } + + const dirs = (process.env.PATH ?? '').split(';').filter(Boolean) + const hasExt = !!path.win32.extname(bin) + for (const dir of dirs) { + if (hasExt) { + const candidate = path.win32.join(dir, bin) + if (fs.existsSync(candidate)) return candidate + } else { + for (const ext of exts) { + const candidate = path.win32.join(dir, bin + ext) + if (fs.existsSync(candidate)) return candidate + } + } + } + return bin +} diff --git a/backend/src/ws/session.ts b/backend/src/ws/session.ts index 159026c..574e917 100644 --- a/backend/src/ws/session.ts +++ b/backend/src/ws/session.ts @@ -4,6 +4,7 @@ import * as path from 'path' import type { WebSocket } from 'ws' import type { FastifyInstance } from 'fastify' import { db } from '../db/schema' +import { resolveBin } from '../utils/resolveBin' const IDLE_TIMEOUT_MS = 30 * 60 * 1000 @@ -128,7 +129,7 @@ class ActiveSession { ).run(this.id, 'user', text, Date.now()) } - const claudeBin = process.env.CLAUDE_BIN ?? 'claude' + const claudeBin = resolveBin(process.env.CLAUDE_BIN?.trim() || 'claude') const bypassPermissions = getBypassPermissions() // --print + -p: non-interactive print mode with the prompt passed as a CLI diff --git a/backend/src/ws/terminal.ts b/backend/src/ws/terminal.ts index d118468..a68139a 100644 --- a/backend/src/ws/terminal.ts +++ b/backend/src/ws/terminal.ts @@ -4,6 +4,7 @@ import * as path from 'path' import type { WebSocket } from 'ws' import type { FastifyInstance } from 'fastify' import { db } from '../db/schema' +import { resolveBin } from '../utils/resolveBin' const IDLE_TIMEOUT_MS = 30 * 60 * 1000 @@ -52,7 +53,7 @@ class ActiveTerminalSession { } private spawnPty(cols: number, rows: number) { - const claudeBin = process.env.CLAUDE_BIN ?? 'claude' + const claudeBin = resolveBin(process.env.CLAUDE_BIN?.trim() || 'claude') const bypassPermissions = getBypassPermissions() const args: string[] = [] if (bypassPermissions) args.push('--dangerously-skip-permissions') diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 262f6c3..d544e3d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,16 +1,19 @@ -import { HashRouter, Routes, Route, Navigate } from 'react-router-dom' +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' import DashboardView from './views/DashboardView' +import SessionRoute from './views/SessionRoute' import SettingsView from './views/SettingsView' import { SessionProvider } from './context/SessionContext' export default function App() { return ( - +
} /> + } /> + } /> } /> } /> } /> @@ -19,6 +22,6 @@ export default function App() {
-
+ ) } diff --git a/frontend/src/components/SessionHeader.tsx b/frontend/src/components/SessionHeader.tsx index c03ad1e..6737a1d 100644 --- a/frontend/src/components/SessionHeader.tsx +++ b/frontend/src/components/SessionHeader.tsx @@ -1,5 +1,6 @@ import { useState, useEffect } from 'react' import { Plus, List, Square, Pencil } from 'lucide-react' +import { Link } from 'react-router-dom' import { useSession } from '../context/SessionContext' import { formatModelName, formatTokens, formatDuration } from '../utils/format' @@ -164,13 +165,14 @@ export default function SessionHeader({
- + +
{/* Session rows */} @@ -51,13 +51,14 @@ export default function SessionList({ {sessions.length === 0 ? (

No sessions yet

- +
) : ( sessions.map((session) => ( @@ -92,13 +93,14 @@ export default function SessionList({ {/* Right: actions */}
- +