feat: add remote host case routes

This commit is contained in:
Saqeb Akhter
2026-07-09 08:51:12 -04:00
committed by Aamer Akhter
parent 3bf991d730
commit 568d93efb0
4 changed files with 241 additions and 1 deletions
+12
View File
@@ -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 ==========
+109 -1
View File
@@ -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<Record<string, unknown>>(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<ApiResponse<{ host: unknown }>> => {
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<ApiResponse<{ host: unknown }>> => {
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<ApiResponse<{ id: string }>> => {
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<ApiResponse<{ case: unknown }>> => {
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<ApiResponse<{ case: { name: string; path: string } }>> => {
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]) {
+34
View File
@@ -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 ==========
/**
+86
View File
@@ -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<string, string>();
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', () => {