mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 16:39:42 +02:00
feat: add remote host case routes
This commit is contained in:
committed by
Aamer Akhter
parent
3bf991d730
commit
568d93efb0
@@ -123,6 +123,18 @@ export interface CaseInfo {
|
|||||||
path: string;
|
path: string;
|
||||||
/** Whether CLAUDE.md exists */
|
/** Whether CLAUDE.md exists */
|
||||||
hasClaudeMd?: boolean;
|
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 ==========
|
// ========== Error Handling Utilities ==========
|
||||||
|
|||||||
@@ -11,15 +11,29 @@ import { join, resolve } from 'node:path';
|
|||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
import type { ApiResponse, CaseInfo } from '../../types.js';
|
import type { ApiResponse, CaseInfo } from '../../types.js';
|
||||||
import { ApiErrorCode, createErrorResponse, getErrorMessage } 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 { generateClaudeMd } from '../../templates/claude-md.js';
|
||||||
import { writeHooksConfig } from '../../hooks-config.js';
|
import { writeHooksConfig } from '../../hooks-config.js';
|
||||||
import { CASES_DIR, SETTINGS_PATH, validatePathWithinBase, parseBody, readJsonConfig } from '../route-helpers.js';
|
import { CASES_DIR, SETTINGS_PATH, validatePathWithinBase, parseBody, readJsonConfig } from '../route-helpers.js';
|
||||||
import { SseEvent } from '../sse-events.js';
|
import { SseEvent } from '../sse-events.js';
|
||||||
import type { EventPort, ConfigPort } from '../ports/index.js';
|
import type { EventPort, ConfigPort } from '../ports/index.js';
|
||||||
import { dataPath, getDataDir } from '../../config/instance.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 LINKED_CASES_FILE = dataPath('linked-cases.json');
|
||||||
|
const CODEMAN_CONFIG_DIR = getDataDir();
|
||||||
const SAFE_CASE_NAME = /^[a-zA-Z0-9_-]+$/;
|
const SAFE_CASE_NAME = /^[a-zA-Z0-9_-]+$/;
|
||||||
|
|
||||||
/** Read and parse linked-cases.json, returning empty object on missing/invalid file. */
|
/** 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,
|
name: e.name,
|
||||||
path: join(CASES_DIR, e.name),
|
path: join(CASES_DIR, e.name),
|
||||||
hasClaudeMd: existsSync(join(CASES_DIR, e.name, 'CLAUDE.md')),
|
hasClaudeMd: existsSync(join(CASES_DIR, e.name, 'CLAUDE.md')),
|
||||||
|
location: 'local',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -69,10 +84,34 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
|||||||
name,
|
name,
|
||||||
path,
|
path,
|
||||||
hasClaudeMd: existsSync(join(path, 'CLAUDE.md')),
|
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
|
// Sort by persisted caseOrder from settings.json
|
||||||
const settings = await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'settings', {});
|
const settings = await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'settings', {});
|
||||||
const caseOrder = Array.isArray(settings.caseOrder) ? (settings.caseOrder as string[]) : [];
|
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
|
// Link an existing folder as a case
|
||||||
app.post('/api/cases/link', async (req): Promise<ApiResponse<{ case: { name: string; path: string } }>> => {
|
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');
|
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');
|
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
|
// Check linked cases first — unlink only, don't delete the actual directory
|
||||||
const linkedCases = await readLinkedCases();
|
const linkedCases = await readLinkedCases();
|
||||||
if (linkedCases[name]) {
|
if (linkedCases[name]) {
|
||||||
|
|||||||
@@ -271,6 +271,40 @@ export const CreateCaseSchema = z.object({
|
|||||||
description: z.string().max(1000).optional(),
|
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 ==========
|
// ========== Quick Start ==========
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ const mockedMkdirSync = vi.mocked(mkdirSync);
|
|||||||
const mockedReaddirSync = vi.mocked(readdirSync);
|
const mockedReaddirSync = vi.mocked(readdirSync);
|
||||||
const mockedReaddir = vi.mocked(fs.readdir);
|
const mockedReaddir = vi.mocked(fs.readdir);
|
||||||
const mockedReadFile = vi.mocked(fs.readFile);
|
const mockedReadFile = vi.mocked(fs.readFile);
|
||||||
|
const mockedWriteFile = vi.mocked(fs.writeFile);
|
||||||
|
|
||||||
interface CaseRouteHarness {
|
interface CaseRouteHarness {
|
||||||
app: FastifyInstance;
|
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 ==========
|
// ========== POST /api/cases ==========
|
||||||
|
|
||||||
describe('POST /api/cases', () => {
|
describe('POST /api/cases', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user