fix(cases): custom-folder create landing fixes (#535)

- Route test hygiene: each test works in its own mkdtemp folder, every
  deletion goes through safeRmHomeTree, and the suite refuses to start
  outside test/setup.ts's temp HOME, so a raw `npx vitest` can no longer
  delete a real ~/projects or the live linked-cases registry.
- Path policy: the symlink-resolved target is also judged against the
  resolved home, data dir and system roots (home reached through a link,
  macOS /etc -> /private/etc); test expectations are realpath-safe.
- Refuse a target equal to or inside the caller's or the shared cases
  directory, pointing at plain Create New (it would list twice, and
  deleting the local copy removes files).
- The registry re-read comment no longer claims to prevent the
  lost-update race; documented as narrowing it, like /api/cases/link.
- UI: the success toast names the folder the server created, the
  "under ~/codeman-cases" blurb and name hint change while a custom
  folder is ticked, a "/" parent previews and sends /<name> instead of
  an empty path, and the new labels have zh-CN entries.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-05 19:51:59 +02:00
parent 566365e127
commit 192a5994e0
9 changed files with 240 additions and 28 deletions
+8 -3
View File
@@ -446,7 +446,8 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
req: FastifyRequest,
reply: { code: (n: number) => unknown }
): Promise<ApiResponse<{ case: { name: string; path: string } }>> {
if (existsSync(join(resolveCasesDir(getAuthUser(req)), name))) {
const ownCasesDir = resolveCasesDir(getAuthUser(req));
if (existsSync(join(ownCasesDir, name))) {
reply.code(409);
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'A case with this name already exists in codeman-cases.');
}
@@ -459,7 +460,9 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
);
}
const prepared = await prepareNewCasePath(customPath, { home: homedir(), dataDir: getDataDir() });
// The caller's own cases dir and the shared one (the same folder outside multi-user mode).
const casesDirs = [...new Set([ownCasesDir, resolveCasesDir()])];
const prepared = await prepareNewCasePath(customPath, { home: homedir(), dataDir: getDataDir(), casesDirs });
if (!prepared.ok) {
const status = prepared.code === 'NOT_FOUND' ? 404 : prepared.code === 'EXISTS' ? 409 : 400;
reply.code(status);
@@ -494,7 +497,9 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
const codemanDir = getDataDir();
if (!existsSync(codemanDir)) mkdirSync(codemanDir, { recursive: true });
// Re-read right before writing: another request may have linked a case since the check above.
// Re-read right before writing, so a case linked since the check above is not dropped. This only
// narrows the window: like POST /api/cases/link, the registry write is not serialized, and two
// requests that both read before either writes can still lose one entry.
const fresh = await readLinkedCases();
if (fresh[name]) throw Object.assign(new Error(`Case "${name}" was just linked`), { conflict: true });
fresh[name] = casePath;