diff --git a/src/types/api.ts b/src/types/api.ts index 23a97594..1e03a3d6 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -123,6 +123,18 @@ export interface CaseInfo { path: string; /** Whether CLAUDE.md exists */ hasClaudeMd?: boolean; + /** Case storage/execution location */ + location?: 'local' | 'linked-local' | 'remote'; + /** Whether this is a linked local folder */ + linked?: boolean; + /** Remote case metadata for display and session creation */ + remote?: { + hostId: string; + hostLabel: string; + host: string; + username: string; + path: string; + }; } // ========== Error Handling Utilities ========== diff --git a/src/web/routes/case-routes.ts b/src/web/routes/case-routes.ts index 48ab869f..35b9fa82 100644 --- a/src/web/routes/case-routes.ts +++ b/src/web/routes/case-routes.ts @@ -11,15 +11,29 @@ import { join, resolve } from 'node:path'; import { homedir } from 'node:os'; import type { ApiResponse, CaseInfo } from '../../types.js'; import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js'; -import { CreateCaseSchema, LinkCaseSchema, CaseOrderSchema } from '../schemas.js'; +import { + CreateCaseSchema, + LinkCaseSchema, + CaseOrderSchema, + RemoteCaseLinkSchema, + RemoteHostSchema, +} from '../schemas.js'; import { generateClaudeMd } from '../../templates/claude-md.js'; import { writeHooksConfig } from '../../hooks-config.js'; import { CASES_DIR, SETTINGS_PATH, validatePathWithinBase, parseBody, readJsonConfig } from '../route-helpers.js'; import { SseEvent } from '../sse-events.js'; import type { EventPort, ConfigPort } from '../ports/index.js'; import { dataPath, getDataDir } from '../../config/instance.js'; +import { + readRemoteCases, + readRemoteHosts, + remoteDisplayPath, + writeRemoteCases, + writeRemoteHosts, +} from '../../remote-hosts.js'; const LINKED_CASES_FILE = dataPath('linked-cases.json'); +const CODEMAN_CONFIG_DIR = getDataDir(); const SAFE_CASE_NAME = /^[a-zA-Z0-9_-]+$/; /** Read and parse linked-cases.json, returning empty object on missing/invalid file. */ @@ -53,6 +67,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config name: e.name, path: join(CASES_DIR, e.name), hasClaudeMd: existsSync(join(CASES_DIR, e.name, 'CLAUDE.md')), + location: 'local', }); } } @@ -69,10 +84,34 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config name, path, hasClaudeMd: existsSync(join(path, 'CLAUDE.md')), + linked: true, + location: 'linked-local', }); } } + // Get remote cases + const remoteHosts = await readRemoteHosts(CODEMAN_CONFIG_DIR); + const remoteHostMap = new Map(remoteHosts.map((host) => [host.id, host])); + for (const remoteCase of await readRemoteCases(CODEMAN_CONFIG_DIR)) { + const host = remoteHostMap.get(remoteCase.hostId); + if (!host || existingNames.has(remoteCase.name) || !SAFE_CASE_NAME.test(remoteCase.name)) continue; + existingNames.add(remoteCase.name); + cases.push({ + name: remoteCase.name, + path: remoteDisplayPath({ username: host.username, host: host.host, path: remoteCase.remotePath }), + hasClaudeMd: false, + location: 'remote', + remote: { + hostId: host.id, + hostLabel: host.label, + host: host.host, + username: host.username, + path: remoteCase.remotePath, + }, + }); + } + // Sort by persisted caseOrder from settings.json const settings = await readJsonConfig>(SETTINGS_PATH, 'settings', {}); const caseOrder = Array.isArray(settings.caseOrder) ? (settings.caseOrder as string[]) : []; @@ -120,6 +159,65 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config } }); + app.get('/api/remote-hosts', async () => readRemoteHosts(CODEMAN_CONFIG_DIR)); + + app.post('/api/remote-hosts', async (req): Promise> => { + const host = parseBody(RemoteHostSchema, req.body); + const hosts = await readRemoteHosts(CODEMAN_CONFIG_DIR); + if (hosts.some((item) => item.id === host.id)) { + return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Remote host already exists'); + } + await writeRemoteHosts(CODEMAN_CONFIG_DIR, [...hosts, host]); + return { success: true, data: { host } }; + }); + + app.put('/api/remote-hosts/:id', async (req): Promise> => { + const { id } = req.params as { id: string }; + const host = parseBody(RemoteHostSchema, { ...(req.body as object), id }); + const hosts = await readRemoteHosts(CODEMAN_CONFIG_DIR); + const index = hosts.findIndex((item) => item.id === id); + if (index === -1) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Remote host not found'); + const next = [...hosts]; + next[index] = host; + await writeRemoteHosts(CODEMAN_CONFIG_DIR, next); + return { success: true, data: { host } }; + }); + + app.delete('/api/remote-hosts/:id', async (req): Promise> => { + const { id } = req.params as { id: string }; + const cases = await readRemoteCases(CODEMAN_CONFIG_DIR); + if (cases.some((item) => item.hostId === id)) { + return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Remote host is still used by remote cases'); + } + const hosts = await readRemoteHosts(CODEMAN_CONFIG_DIR); + await writeRemoteHosts( + CODEMAN_CONFIG_DIR, + hosts.filter((item) => item.id !== id) + ); + return { success: true, data: { id } }; + }); + + app.post('/api/cases/remote-link', async (req): Promise> => { + const remoteCase = { ...parseBody(RemoteCaseLinkSchema, req.body), type: 'remote' as const }; + const hosts = await readRemoteHosts(CODEMAN_CONFIG_DIR); + const host = hosts.find((item) => item.id === remoteCase.hostId); + if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Remote host not found'); + + const linkedCases = await readLinkedCases(); + const remoteCases = await readRemoteCases(CODEMAN_CONFIG_DIR); + if ( + remoteCases.some((item) => item.name === remoteCase.name) || + linkedCases[remoteCase.name] || + existsSync(join(CASES_DIR, remoteCase.name)) + ) { + return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Case already exists'); + } + + await writeRemoteCases(CODEMAN_CONFIG_DIR, [...remoteCases, remoteCase]); + ctx.broadcast(SseEvent.CaseLinked, { name: remoteCase.name, path: remoteCase.remotePath, type: 'remote' }); + return { success: true, data: { case: remoteCase } }; + }); + // Link an existing folder as a case app.post('/api/cases/link', async (req): Promise> => { const { name, path: folderPath } = parseBody(LinkCaseSchema, req.body, 'Invalid request body'); @@ -173,6 +271,16 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name'); } + const remoteCases = await readRemoteCases(CODEMAN_CONFIG_DIR); + if (remoteCases.some((item) => item.name === name)) { + await writeRemoteCases( + CODEMAN_CONFIG_DIR, + remoteCases.filter((item) => item.name !== name) + ); + ctx.broadcast(SseEvent.CaseDeleted, { name, type: 'remote-unlinked' }); + return { success: true, data: { name } }; + } + // Check linked cases first — unlink only, don't delete the actual directory const linkedCases = await readLinkedCases(); if (linkedCases[name]) { diff --git a/src/web/schemas.ts b/src/web/schemas.ts index 45aeddd4..f3a82e63 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -271,6 +271,40 @@ export const CreateCaseSchema = z.object({ description: z.string().max(1000).optional(), }); +const RemoteCommandOverridesSchema = z + .object({ + shell: z.string().min(1).max(300).optional(), + claude: z.string().min(1).max(300).optional(), + opencode: z.string().min(1).max(300).optional(), + codex: z.string().min(1).max(300).optional(), + gemini: z.string().min(1).max(300).optional(), + }) + .strict() + .optional(); + +export const RemoteHostSchema = z.object({ + id: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid remote host id'), + label: z.string().min(1).max(100), + host: z + .string() + .min(1) + .max(255) + .regex(/^[a-zA-Z0-9._:-]+$/, 'Invalid SSH host'), + username: z + .string() + .min(1) + .max(100) + .regex(/^[a-zA-Z0-9._-]+$/, 'Invalid SSH username'), + port: z.number().int().min(1).max(65535).optional(), + commands: RemoteCommandOverridesSchema, +}); + +export const RemoteCaseLinkSchema = z.object({ + name: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format'), + hostId: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid remote host id'), + remotePath: z.string().min(1).max(2000).regex(/^\//, 'Remote path must be absolute'), +}); + // ========== Quick Start ========== /** diff --git a/test/routes/case-routes.test.ts b/test/routes/case-routes.test.ts index ac1a60f5..2cc6fc21 100644 --- a/test/routes/case-routes.test.ts +++ b/test/routes/case-routes.test.ts @@ -62,6 +62,7 @@ const mockedMkdirSync = vi.mocked(mkdirSync); const mockedReaddirSync = vi.mocked(readdirSync); const mockedReaddir = vi.mocked(fs.readdir); const mockedReadFile = vi.mocked(fs.readFile); +const mockedWriteFile = vi.mocked(fs.writeFile); interface CaseRouteHarness { app: FastifyInstance; @@ -199,6 +200,91 @@ describe('case-routes', () => { }); }); + describe('remote host and remote case routes', () => { + function setupRemoteConfigStore() { + const store = new Map(); + mockedReadFile.mockImplementation(async (path) => { + const key = String(path); + if (store.has(key)) return store.get(key) || ''; + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + mockedWriteFile.mockImplementation(async (path, data) => { + store.set(String(path), String(data)); + }); + } + + it('creates a remote host and lists it', async () => { + setupRemoteConfigStore(); + + const create = await harness.app.inject({ + method: 'POST', + url: '/api/remote-hosts', + payload: { + id: 'gpu-box', + label: 'GPU Box', + host: '10.0.0.42', + username: 'ubuntu', + commands: { codex: 'exec codx personal' }, + }, + }); + expect(create.statusCode).toBe(200); + expect(JSON.parse(create.body)).toMatchObject({ success: true }); + + const list = await harness.app.inject({ method: 'GET', url: '/api/remote-hosts' }); + expect(list.statusCode).toBe(200); + expect(JSON.parse(list.body)).toEqual([ + expect.objectContaining({ id: 'gpu-box', label: 'GPU Box', commands: { codex: 'exec codx personal' } }), + ]); + }); + + it('links a remote case and includes it in GET /api/cases', async () => { + setupRemoteConfigStore(); + mockedReaddir.mockRejectedValue(new Error('ENOENT')); + + await harness.app.inject({ + method: 'POST', + url: '/api/remote-hosts', + payload: { id: 'gpu-box', label: 'GPU Box', host: '10.0.0.42', username: 'ubuntu' }, + }); + + const link = await harness.app.inject({ + method: 'POST', + url: '/api/cases/remote-link', + payload: { name: 'gpu-work', hostId: 'gpu-box', remotePath: '/home/ubuntu/work' }, + }); + expect(link.statusCode).toBe(200); + + const cases = await harness.app.inject({ method: 'GET', url: '/api/cases' }); + expect(JSON.parse(cases.body)).toContainEqual( + expect.objectContaining({ + name: 'gpu-work', + location: 'remote', + path: 'ubuntu@10.0.0.42:/home/ubuntu/work', + remote: expect.objectContaining({ hostId: 'gpu-box', hostLabel: 'GPU Box', path: '/home/ubuntu/work' }), + }) + ); + }); + + it('deletes remote case metadata only', async () => { + setupRemoteConfigStore(); + + await harness.app.inject({ + method: 'POST', + url: '/api/remote-hosts', + payload: { id: 'gpu-box', label: 'GPU Box', host: '10.0.0.42', username: 'ubuntu' }, + }); + await harness.app.inject({ + method: 'POST', + url: '/api/cases/remote-link', + payload: { name: 'gpu-work', hostId: 'gpu-box', remotePath: '/home/ubuntu/work' }, + }); + + const deleted = await harness.app.inject({ method: 'DELETE', url: '/api/cases/gpu-work' }); + expect(deleted.statusCode).toBe(200); + expect(JSON.parse(deleted.body)).toEqual({ success: true, data: { name: 'gpu-work' } }); + }); + }); + // ========== POST /api/cases ========== describe('POST /api/cases', () => {