mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 08:59:40 +02:00
refactor: clean up case routes DRY violations, remove dead export, standardize reply API
- Extract readLinkedCases() helper and resolveCasePath() to eliminate 6x duplicated linked-cases.json path construction and 5x duplicated file read/parse logic - Replace O(n) .some() duplicate check with O(1) Set.has() in case listing - Un-export isError() in types/api.ts (only used internally by getErrorMessage) - Standardize reply.status() → reply.code() in system-routes (Fastify canonical API) - Update CLAUDE.md: accurate frontend module listing, SSE event count (~106) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -109,7 +109,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
|||||||
| **Infra** | `src/hooks-config.ts`, `src/push-store.ts`, `src/tunnel-manager.ts`, `src/image-watcher.ts`, `src/file-stream-manager.ts` | |
|
| **Infra** | `src/hooks-config.ts`, `src/push-store.ts`, `src/tunnel-manager.ts`, `src/image-watcher.ts`, `src/file-stream-manager.ts` | |
|
||||||
| **Plan** | `src/plan-orchestrator.ts`, `src/prompts/*.ts`, `src/templates/claude-md.ts` | |
|
| **Plan** | `src/plan-orchestrator.ts`, `src/prompts/*.ts`, `src/templates/claude-md.ts` | |
|
||||||
| **Web** | `src/web/server.ts`, `src/web/sse-events.ts`, `src/web/routes/*.ts` (13 route modules incl. `ws-routes.ts` + barrel), `src/web/ports/*.ts`, `src/web/middleware/auth.ts`, `src/web/schemas.ts` | |
|
| **Web** | `src/web/server.ts`, `src/web/sse-events.ts`, `src/web/routes/*.ts` (13 route modules incl. `ws-routes.ts` + barrel), `src/web/ports/*.ts`, `src/web/middleware/auth.ts`, `src/web/schemas.ts` | |
|
||||||
| **Frontend** | `src/web/public/app.js` (~2.6K lines, core) + 6 domain modules (`terminal-ui.js`, `respawn-ui.js`, `ralph-panel.js`, `settings-ui.js`, `panels-ui.js`, `session-ui.js`) + 5 existing modules (`ralph-wizard.js`, `api-client.js`, `subagent-windows.js`, `sw.js`, `input-cjk.js`) | |
|
| **Frontend** | `src/web/public/app.js` (~2.6K lines, core) + 5 infra modules (`constants.js`, `mobile-handlers.js`, `voice-input.js`, `notification-manager.js`, `keyboard-accessory.js`) + 6 domain modules (`terminal-ui.js`, `respawn-ui.js`, `ralph-panel.js`, `settings-ui.js`, `panels-ui.js`, `session-ui.js`) + 4 feature modules (`ralph-wizard.js`, `api-client.js`, `subagent-windows.js`, `input-cjk.js`) + `sw.js` | |
|
||||||
| **Types** | `src/types/index.ts` → 13 domain files | See `@fileoverview` in index.ts |
|
| **Types** | `src/types/index.ts` → 13 domain files | See `@fileoverview` in index.ts |
|
||||||
|
|
||||||
★ = Large file (>50KB). All files have `@fileoverview` JSDoc — read that before diving in.
|
★ = Large file (>50KB). All files have `@fileoverview` JSDoc — read that before diving in.
|
||||||
@@ -166,7 +166,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
|||||||
|
|
||||||
### SSE Event Registry
|
### SSE Event Registry
|
||||||
|
|
||||||
~100 event types in `src/web/sse-events.ts` (backend) and `SSE_EVENTS` in `constants.js` (frontend). Both must be kept in sync.
|
~106 event types in `src/web/sse-events.ts` (backend) and `SSE_EVENTS` in `constants.js` (frontend). Both must be kept in sync.
|
||||||
|
|
||||||
### API Routes
|
### API Routes
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -117,7 +117,7 @@ export interface CaseInfo {
|
|||||||
* @param value The value to check
|
* @param value The value to check
|
||||||
* @returns True if the value is an Error instance
|
* @returns True if the value is an Error instance
|
||||||
*/
|
*/
|
||||||
export function isError(value: unknown): value is Error {
|
function isError(value: unknown): value is Error {
|
||||||
return value instanceof Error;
|
return value instanceof Error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,28 @@ import { CASES_DIR, validatePathWithinBase } 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';
|
||||||
|
|
||||||
|
const LINKED_CASES_FILE = join(homedir(), '.codeman', 'linked-cases.json');
|
||||||
|
|
||||||
|
/** Read and parse linked-cases.json, returning empty object on missing/invalid file. */
|
||||||
|
async function readLinkedCases(): Promise<Record<string, string>> {
|
||||||
|
try {
|
||||||
|
return JSON.parse(await fs.readFile(LINKED_CASES_FILE, 'utf-8'));
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||||
|
console.warn('[Server] Failed to read linked cases:', err);
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve a case name to its directory path, checking linked cases if not in CASES_DIR. */
|
||||||
|
async function resolveCasePath(name: string): Promise<string> {
|
||||||
|
const casePath = join(CASES_DIR, name);
|
||||||
|
if (existsSync(casePath)) return casePath;
|
||||||
|
const linkedCases = await readLinkedCases();
|
||||||
|
return linkedCases[name] ?? casePath;
|
||||||
|
}
|
||||||
|
|
||||||
export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & ConfigPort): void {
|
export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & ConfigPort): void {
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
// Case CRUD (list, create, link, detail, fix-plan)
|
// Case CRUD (list, create, link, detail, fix-plan)
|
||||||
@@ -45,22 +67,15 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get linked cases
|
// Get linked cases
|
||||||
const linkedCasesFile = join(homedir(), '.codeman', 'linked-cases.json');
|
const linkedCases = await readLinkedCases();
|
||||||
try {
|
const existingNames = new Set(cases.map((c) => c.name));
|
||||||
const linkedCases: Record<string, string> = JSON.parse(await fs.readFile(linkedCasesFile, 'utf-8'));
|
for (const [name, path] of Object.entries(linkedCases)) {
|
||||||
for (const [name, path] of Object.entries(linkedCases)) {
|
if (!existingNames.has(name) && existsSync(path)) {
|
||||||
// Only add if not already in cases (avoid duplicates) and path exists
|
cases.push({
|
||||||
if (!cases.some((c) => c.name === name) && existsSync(path)) {
|
name,
|
||||||
cases.push({
|
path,
|
||||||
name,
|
hasClaudeMd: existsSync(join(path, 'CLAUDE.md')),
|
||||||
path,
|
});
|
||||||
hasClaudeMd: existsSync(join(path, 'CLAUDE.md')),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
||||||
console.warn('[Server] Failed to read linked cases:', err);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,15 +141,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Load existing linked cases
|
// Load existing linked cases
|
||||||
const linkedCasesFile = join(homedir(), '.codeman', 'linked-cases.json');
|
const linkedCases = await readLinkedCases();
|
||||||
let linkedCases: Record<string, string> = {};
|
|
||||||
try {
|
|
||||||
linkedCases = JSON.parse(await fs.readFile(linkedCasesFile, 'utf-8'));
|
|
||||||
} catch (err) {
|
|
||||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
||||||
console.warn('[Server] Failed to read linked cases:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if name is already linked
|
// Check if name is already linked
|
||||||
if (linkedCases[name]) {
|
if (linkedCases[name]) {
|
||||||
@@ -151,7 +158,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
|||||||
if (!existsSync(codemanDir)) {
|
if (!existsSync(codemanDir)) {
|
||||||
mkdirSync(codemanDir, { recursive: true });
|
mkdirSync(codemanDir, { recursive: true });
|
||||||
}
|
}
|
||||||
await fs.writeFile(linkedCasesFile, JSON.stringify(linkedCases, null, 2));
|
await fs.writeFile(LINKED_CASES_FILE, JSON.stringify(linkedCases, null, 2));
|
||||||
ctx.broadcast(SseEvent.CaseLinked, { name, path: expandedPath });
|
ctx.broadcast(SseEvent.CaseLinked, { name, path: expandedPath });
|
||||||
return { success: true, data: { case: { name, path: expandedPath } } };
|
return { success: true, data: { case: { name, path: expandedPath } } };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -166,34 +173,18 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
|||||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
|
||||||
}
|
}
|
||||||
|
|
||||||
// First check linked cases
|
const casePath = await resolveCasePath(name);
|
||||||
const linkedCasesFile = join(homedir(), '.codeman', 'linked-cases.json');
|
|
||||||
try {
|
|
||||||
const linkedCases: Record<string, string> = JSON.parse(await fs.readFile(linkedCasesFile, 'utf-8'));
|
|
||||||
if (linkedCases[name]) {
|
|
||||||
const linkedPath = linkedCases[name];
|
|
||||||
return {
|
|
||||||
name,
|
|
||||||
path: linkedPath,
|
|
||||||
hasClaudeMd: existsSync(join(linkedPath, 'CLAUDE.md')),
|
|
||||||
linked: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ENOENT or parse errors - fall through to CASES_DIR check
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then check CASES_DIR
|
|
||||||
const casePath = join(CASES_DIR, name);
|
|
||||||
|
|
||||||
if (!existsSync(casePath)) {
|
if (!existsSync(casePath)) {
|
||||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Case not found');
|
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Case not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const linked = casePath !== join(CASES_DIR, name);
|
||||||
return {
|
return {
|
||||||
name,
|
name,
|
||||||
path: casePath,
|
path: casePath,
|
||||||
hasClaudeMd: existsSync(join(casePath, 'CLAUDE.md')),
|
hasClaudeMd: existsSync(join(casePath, 'CLAUDE.md')),
|
||||||
|
...(linked && { linked: true }),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -206,21 +197,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get case path (check linked cases first, then CASES_DIR)
|
// Get case path (check linked cases first, then CASES_DIR)
|
||||||
let casePath: string | null = null;
|
const casePath = await resolveCasePath(name);
|
||||||
|
|
||||||
const linkedCasesFile = join(homedir(), '.codeman', 'linked-cases.json');
|
|
||||||
try {
|
|
||||||
const linkedCases: Record<string, string> = JSON.parse(await fs.readFile(linkedCasesFile, 'utf-8'));
|
|
||||||
if (linkedCases[name]) {
|
|
||||||
casePath = linkedCases[name];
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ENOENT or parse errors - fall through to CASES_DIR
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!casePath) {
|
|
||||||
casePath = join(CASES_DIR, name);
|
|
||||||
}
|
|
||||||
|
|
||||||
const fixPlanPath = join(casePath, '@fix_plan.md');
|
const fixPlanPath = join(casePath, '@fix_plan.md');
|
||||||
|
|
||||||
@@ -321,23 +298,11 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
|||||||
|
|
||||||
app.get('/api/cases/:caseName/ralph-wizard/files', async (req) => {
|
app.get('/api/cases/:caseName/ralph-wizard/files', async (req) => {
|
||||||
const { caseName } = req.params as { caseName: string };
|
const { caseName } = req.params as { caseName: string };
|
||||||
let casePath = validatePathWithinBase(caseName, CASES_DIR);
|
if (!validatePathWithinBase(caseName, CASES_DIR)) {
|
||||||
if (!casePath) {
|
|
||||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check linked cases if path doesn't exist
|
const casePath = await resolveCasePath(caseName);
|
||||||
if (!existsSync(casePath)) {
|
|
||||||
const linkedCasesFile = join(homedir(), '.codeman', 'linked-cases.json');
|
|
||||||
try {
|
|
||||||
const linkedCases: Record<string, string> = JSON.parse(await fs.readFile(linkedCasesFile, 'utf-8'));
|
|
||||||
if (linkedCases[caseName]) {
|
|
||||||
casePath = linkedCases[caseName];
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// No linked cases file
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const wizardDir = join(casePath, 'ralph-wizard');
|
const wizardDir = join(casePath, 'ralph-wizard');
|
||||||
|
|
||||||
@@ -376,8 +341,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
|||||||
// Cache disabled to ensure fresh prompts when starting new plan generations
|
// Cache disabled to ensure fresh prompts when starting new plan generations
|
||||||
app.get('/api/cases/:caseName/ralph-wizard/file/:filePath', async (req, reply) => {
|
app.get('/api/cases/:caseName/ralph-wizard/file/:filePath', async (req, reply) => {
|
||||||
const { caseName, filePath } = req.params as { caseName: string; filePath: string };
|
const { caseName, filePath } = req.params as { caseName: string; filePath: string };
|
||||||
let casePath = validatePathWithinBase(caseName, CASES_DIR);
|
if (!validatePathWithinBase(caseName, CASES_DIR)) {
|
||||||
if (!casePath) {
|
|
||||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -386,18 +350,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
|||||||
reply.header('Pragma', 'no-cache');
|
reply.header('Pragma', 'no-cache');
|
||||||
reply.header('Expires', '0');
|
reply.header('Expires', '0');
|
||||||
|
|
||||||
// Check linked cases if path doesn't exist
|
const casePath = await resolveCasePath(caseName);
|
||||||
if (!existsSync(casePath)) {
|
|
||||||
const linkedCasesFile = join(homedir(), '.codeman', 'linked-cases.json');
|
|
||||||
try {
|
|
||||||
const linkedCases: Record<string, string> = JSON.parse(await fs.readFile(linkedCasesFile, 'utf-8'));
|
|
||||||
if (linkedCases[caseName]) {
|
|
||||||
casePath = linkedCases[caseName];
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// No linked cases file
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const wizardDir = join(casePath, 'ralph-wizard');
|
const wizardDir = join(casePath, 'ralph-wizard');
|
||||||
|
|
||||||
|
|||||||
@@ -709,7 +709,7 @@ export function registerSystemRoutes(
|
|||||||
for await (const chunk of req.raw) {
|
for await (const chunk of req.raw) {
|
||||||
totalSize += chunk.length;
|
totalSize += chunk.length;
|
||||||
if (totalSize > MAX_SCREENSHOT_SIZE) {
|
if (totalSize > MAX_SCREENSHOT_SIZE) {
|
||||||
reply.status(413);
|
reply.code(413);
|
||||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'File too large (max 10MB)');
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'File too large (max 10MB)');
|
||||||
}
|
}
|
||||||
chunks.push(chunk as Buffer);
|
chunks.push(chunk as Buffer);
|
||||||
@@ -794,12 +794,12 @@ export function registerSystemRoutes(
|
|||||||
const { name } = req.params as { name: string };
|
const { name } = req.params as { name: string };
|
||||||
// Prevent path traversal
|
// Prevent path traversal
|
||||||
if (name.includes('/') || name.includes('\\') || name.includes('..')) {
|
if (name.includes('/') || name.includes('\\') || name.includes('..')) {
|
||||||
reply.status(400);
|
reply.code(400);
|
||||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid filename');
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid filename');
|
||||||
}
|
}
|
||||||
const filepath = join(SCREENSHOTS_DIR, name);
|
const filepath = join(SCREENSHOTS_DIR, name);
|
||||||
if (!existsSync(filepath)) {
|
if (!existsSync(filepath)) {
|
||||||
reply.status(404);
|
reply.code(404);
|
||||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Screenshot not found');
|
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Screenshot not found');
|
||||||
}
|
}
|
||||||
const ext = name.match(/\.(png|jpg|jpeg|webp|gif)$/i)?.[1]?.toLowerCase() ?? 'png';
|
const ext = name.match(/\.(png|jpg|jpeg|webp|gif)$/i)?.[1]?.toLowerCase() ?? 'png';
|
||||||
|
|||||||
Reference in New Issue
Block a user