Skip to content

Commit 6bc403d

Browse files
Ark0Nclaude
andcommitted
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>
1 parent 1ad05a5 commit 6bc403d

4 files changed

Lines changed: 47 additions & 94 deletions

File tree

CLAUDE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
109109
| **Infra** | `src/hooks-config.ts`, `src/push-store.ts`, `src/tunnel-manager.ts`, `src/image-watcher.ts`, `src/file-stream-manager.ts` | |
110110
| **Plan** | `src/plan-orchestrator.ts`, `src/prompts/*.ts`, `src/templates/claude-md.ts` | |
111111
| **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` | |
112-
| **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`) | |
112+
| **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` | |
113113
| **Types** | `src/types/index.ts` → 13 domain files | See `@fileoverview` in index.ts |
114114
115115
★ = 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
166166
167167
### SSE Event Registry
168168
169-
~100 event types in `src/web/sse-events.ts` (backend) and `SSE_EVENTS` in `constants.js` (frontend). Both must be kept in sync.
169+
~106 event types in `src/web/sse-events.ts` (backend) and `SSE_EVENTS` in `constants.js` (frontend). Both must be kept in sync.
170170
171171
### API Routes
172172

src/types/api.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ export interface CaseInfo {
117117
* @param value The value to check
118118
* @returns True if the value is an Error instance
119119
*/
120-
export function isError(value: unknown): value is Error {
120+
function isError(value: unknown): value is Error {
121121
return value instanceof Error;
122122
}
123123

src/web/routes/case-routes.ts

Lines changed: 41 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,28 @@ import { CASES_DIR, validatePathWithinBase } from '../route-helpers.js';
1818
import { SseEvent } from '../sse-events.js';
1919
import type { EventPort, ConfigPort } from '../ports/index.js';
2020

21+
const LINKED_CASES_FILE = join(homedir(), '.codeman', 'linked-cases.json');
22+
23+
/** Read and parse linked-cases.json, returning empty object on missing/invalid file. */
24+
async function readLinkedCases(): Promise<Record<string, string>> {
25+
try {
26+
return JSON.parse(await fs.readFile(LINKED_CASES_FILE, 'utf-8'));
27+
} catch (err) {
28+
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
29+
console.warn('[Server] Failed to read linked cases:', err);
30+
}
31+
return {};
32+
}
33+
}
34+
35+
/** Resolve a case name to its directory path, checking linked cases if not in CASES_DIR. */
36+
async function resolveCasePath(name: string): Promise<string> {
37+
const casePath = join(CASES_DIR, name);
38+
if (existsSync(casePath)) return casePath;
39+
const linkedCases = await readLinkedCases();
40+
return linkedCases[name] ?? casePath;
41+
}
42+
2143
export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & ConfigPort): void {
2244
// ═══════════════════════════════════════════════════════════════
2345
// Case CRUD (list, create, link, detail, fix-plan)
@@ -45,22 +67,15 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
4567
}
4668

4769
// Get linked cases
48-
const linkedCasesFile = join(homedir(), '.codeman', 'linked-cases.json');
49-
try {
50-
const linkedCases: Record<string, string> = JSON.parse(await fs.readFile(linkedCasesFile, 'utf-8'));
51-
for (const [name, path] of Object.entries(linkedCases)) {
52-
// Only add if not already in cases (avoid duplicates) and path exists
53-
if (!cases.some((c) => c.name === name) && existsSync(path)) {
54-
cases.push({
55-
name,
56-
path,
57-
hasClaudeMd: existsSync(join(path, 'CLAUDE.md')),
58-
});
59-
}
60-
}
61-
} catch (err) {
62-
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
63-
console.warn('[Server] Failed to read linked cases:', err);
70+
const linkedCases = await readLinkedCases();
71+
const existingNames = new Set(cases.map((c) => c.name));
72+
for (const [name, path] of Object.entries(linkedCases)) {
73+
if (!existingNames.has(name) && existsSync(path)) {
74+
cases.push({
75+
name,
76+
path,
77+
hasClaudeMd: existsSync(join(path, 'CLAUDE.md')),
78+
});
6479
}
6580
}
6681

@@ -126,15 +141,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
126141
}
127142

128143
// Load existing linked cases
129-
const linkedCasesFile = join(homedir(), '.codeman', 'linked-cases.json');
130-
let linkedCases: Record<string, string> = {};
131-
try {
132-
linkedCases = JSON.parse(await fs.readFile(linkedCasesFile, 'utf-8'));
133-
} catch (err) {
134-
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
135-
console.warn('[Server] Failed to read linked cases:', err);
136-
}
137-
}
144+
const linkedCases = await readLinkedCases();
138145

139146
// Check if name is already linked
140147
if (linkedCases[name]) {
@@ -151,7 +158,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
151158
if (!existsSync(codemanDir)) {
152159
mkdirSync(codemanDir, { recursive: true });
153160
}
154-
await fs.writeFile(linkedCasesFile, JSON.stringify(linkedCases, null, 2));
161+
await fs.writeFile(LINKED_CASES_FILE, JSON.stringify(linkedCases, null, 2));
155162
ctx.broadcast(SseEvent.CaseLinked, { name, path: expandedPath });
156163
return { success: true, data: { case: { name, path: expandedPath } } };
157164
} catch (err) {
@@ -166,34 +173,18 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
166173
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
167174
}
168175

169-
// First check linked cases
170-
const linkedCasesFile = join(homedir(), '.codeman', 'linked-cases.json');
171-
try {
172-
const linkedCases: Record<string, string> = JSON.parse(await fs.readFile(linkedCasesFile, 'utf-8'));
173-
if (linkedCases[name]) {
174-
const linkedPath = linkedCases[name];
175-
return {
176-
name,
177-
path: linkedPath,
178-
hasClaudeMd: existsSync(join(linkedPath, 'CLAUDE.md')),
179-
linked: true,
180-
};
181-
}
182-
} catch {
183-
// ENOENT or parse errors - fall through to CASES_DIR check
184-
}
185-
186-
// Then check CASES_DIR
187-
const casePath = join(CASES_DIR, name);
176+
const casePath = await resolveCasePath(name);
188177

189178
if (!existsSync(casePath)) {
190179
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Case not found');
191180
}
192181

182+
const linked = casePath !== join(CASES_DIR, name);
193183
return {
194184
name,
195185
path: casePath,
196186
hasClaudeMd: existsSync(join(casePath, 'CLAUDE.md')),
187+
...(linked && { linked: true }),
197188
};
198189
});
199190

@@ -206,21 +197,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
206197
}
207198

208199
// Get case path (check linked cases first, then CASES_DIR)
209-
let casePath: string | null = null;
210-
211-
const linkedCasesFile = join(homedir(), '.codeman', 'linked-cases.json');
212-
try {
213-
const linkedCases: Record<string, string> = JSON.parse(await fs.readFile(linkedCasesFile, 'utf-8'));
214-
if (linkedCases[name]) {
215-
casePath = linkedCases[name];
216-
}
217-
} catch {
218-
// ENOENT or parse errors - fall through to CASES_DIR
219-
}
220-
221-
if (!casePath) {
222-
casePath = join(CASES_DIR, name);
223-
}
200+
const casePath = await resolveCasePath(name);
224201

225202
const fixPlanPath = join(casePath, '@fix_plan.md');
226203

@@ -321,23 +298,11 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
321298

322299
app.get('/api/cases/:caseName/ralph-wizard/files', async (req) => {
323300
const { caseName } = req.params as { caseName: string };
324-
let casePath = validatePathWithinBase(caseName, CASES_DIR);
325-
if (!casePath) {
301+
if (!validatePathWithinBase(caseName, CASES_DIR)) {
326302
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
327303
}
328304

329-
// Check linked cases if path doesn't exist
330-
if (!existsSync(casePath)) {
331-
const linkedCasesFile = join(homedir(), '.codeman', 'linked-cases.json');
332-
try {
333-
const linkedCases: Record<string, string> = JSON.parse(await fs.readFile(linkedCasesFile, 'utf-8'));
334-
if (linkedCases[caseName]) {
335-
casePath = linkedCases[caseName];
336-
}
337-
} catch {
338-
// No linked cases file
339-
}
340-
}
305+
const casePath = await resolveCasePath(caseName);
341306

342307
const wizardDir = join(casePath, 'ralph-wizard');
343308

@@ -376,8 +341,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
376341
// Cache disabled to ensure fresh prompts when starting new plan generations
377342
app.get('/api/cases/:caseName/ralph-wizard/file/:filePath', async (req, reply) => {
378343
const { caseName, filePath } = req.params as { caseName: string; filePath: string };
379-
let casePath = validatePathWithinBase(caseName, CASES_DIR);
380-
if (!casePath) {
344+
if (!validatePathWithinBase(caseName, CASES_DIR)) {
381345
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
382346
}
383347

@@ -386,18 +350,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
386350
reply.header('Pragma', 'no-cache');
387351
reply.header('Expires', '0');
388352

389-
// Check linked cases if path doesn't exist
390-
if (!existsSync(casePath)) {
391-
const linkedCasesFile = join(homedir(), '.codeman', 'linked-cases.json');
392-
try {
393-
const linkedCases: Record<string, string> = JSON.parse(await fs.readFile(linkedCasesFile, 'utf-8'));
394-
if (linkedCases[caseName]) {
395-
casePath = linkedCases[caseName];
396-
}
397-
} catch {
398-
// No linked cases file
399-
}
400-
}
353+
const casePath = await resolveCasePath(caseName);
401354

402355
const wizardDir = join(casePath, 'ralph-wizard');
403356

src/web/routes/system-routes.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -709,7 +709,7 @@ export function registerSystemRoutes(
709709
for await (const chunk of req.raw) {
710710
totalSize += chunk.length;
711711
if (totalSize > MAX_SCREENSHOT_SIZE) {
712-
reply.status(413);
712+
reply.code(413);
713713
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'File too large (max 10MB)');
714714
}
715715
chunks.push(chunk as Buffer);
@@ -794,12 +794,12 @@ export function registerSystemRoutes(
794794
const { name } = req.params as { name: string };
795795
// Prevent path traversal
796796
if (name.includes('/') || name.includes('\\') || name.includes('..')) {
797-
reply.status(400);
797+
reply.code(400);
798798
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid filename');
799799
}
800800
const filepath = join(SCREENSHOTS_DIR, name);
801801
if (!existsSync(filepath)) {
802-
reply.status(404);
802+
reply.code(404);
803803
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Screenshot not found');
804804
}
805805
const ext = name.match(/\.(png|jpg|jpeg|webp|gif)$/i)?.[1]?.toLowerCase() ?? 'png';

0 commit comments

Comments
 (0)