feat(cases): create a new case in a custom folder

POST /api/cases takes an optional path; Add Case > Create New gets a 'Create in a
custom folder' option with Browse. The folder is created (or an empty one filled),
scaffolded like a normal case and registered as a linked case. System, home,
credential and Codeman folders are refused; a folder with files is Link Existing's
job; a failure after the first write undoes what this call created. Admin only in
multi-user mode, like Link Existing.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Devvyn
2026-10-05 07:03:10 +08:00
co-authored by Claude Sonnet 5.5
parent ffaa5ee80c
commit 4150707a6b
13 changed files with 775 additions and 8 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"aicodeman": minor
---
Create a new case in a folder of your choice. Add Case → Create New has a "Create in a custom folder" option with a Browse button: the case folder is created inside the parent you pick, scaffolded like any other case (`CLAUDE.md`, `src/`, hooks), and listed alongside the rest. `POST /api/cases` accepts an optional `path` for the same thing. The folder must not exist or must be empty (use Link Existing for a project that already has files), system folders, the home folder and credential folders are refused, and nothing is left behind if creation fails part-way. Admin only in multi-user mode, like Link Existing.
+1
View File
@@ -35,6 +35,7 @@ export const BROWSER_TEST_GLOBS = [
'test/shift-enter-keypress.browser.test.ts',
'test/key-tester.browser.test.ts',
'test/webhook-settings.browser.test.ts',
'test/case-custom-path.browser.test.ts',
'test/split-pane-orchestration.browser.test.ts',
'test/split-pane-auto-collapse.browser.test.ts',
'test/mobile-ime-preview.browser.test.ts',
+14
View File
@@ -700,6 +700,20 @@ normal `caseName`/`mode`/etc. body)
jarring than a full relaunch, and folding it into the one-shot path is
separate work — see `docs/custom-model-endpoints-plan.md`).
## Creating a case in a custom folder
`POST /api/cases` takes `{ name, description?, path? }`. Without `path` it creates `<cases dir>/<name>` as always. With `path` (absolute, or starting with `~`) the case folder is created at that exact path instead, scaffolded the same way (`CLAUDE.md`, `src/`, `.claude/settings.local.json`), and registered in the linked-cases registry, so it lists, resolves and deletes like a linked case (deleting unlinks; it never removes files). Response: `{ case: { name, path } }`, where `path` is the symlink-resolved folder.
The target is judged before anything is written:
- It must be absolute with no `..` and none of the shell metacharacters a session working directory is rejected for (spaces are fine). `400 INVALID_INPUT` otherwise.
- It must not be a system directory (`/etc`, `/usr`, `/proc`, ...), the home folder itself, Codeman's own data folder, or a credential/config tree (`~/.ssh`, `~/.aws`, `~/.claude`, ...). Judged on the path as typed and on its symlink-resolved form. `400`.
- Its parent must already exist (one folder is created, never a chain): `404 NOT_FOUND`.
- The folder must not exist, or must be an **empty** directory; a folder with contents is Link Existing's job: `409 ALREADY_EXISTS`. A symlink or a plain file at the target is `400`.
- `409 ALREADY_EXISTS` also for a case name already in use (in the cases dir or the registry) and for a folder that is already a case.
Admin only in multi-user mode (`403`), like `POST /api/cases/link`: it writes outside the cases directory and into the shared, ownerless registry. If anything fails after the first write, what this call created is removed (the whole folder if it created it, otherwise only the scaffold inside the empty folder you picked) and the response is `500`.
## CLI management
Read and write the CLI registry (`docs/cli-registry.md`). Every **write** route answers `403 FORBIDDEN` while `cliManagementEnabled` is off (the default), and for a non-admin in multi-user mode. A write that would overwrite a `clis.json` which does not parse, or which has group/world permission bits, is refused with `409 CONFLICT` and a message naming the fix; the file is left untouched.
+1 -1
View File
@@ -19,7 +19,7 @@ Three ways to get one, all under **+** next to the case picker:
| How | Result |
| ----------------- | ------------------------------------------------------------------------------------------------------ |
| **Create New** | A fresh `~/codeman-cases/<name>` with a scaffolded `CLAUDE.md`. |
| **Create New** | A fresh `~/codeman-cases/<name>` with a scaffolded `CLAUDE.md`, or with **Create in a custom folder**, a new folder inside a parent you choose, scaffolded the same way and registered in place like a linked case. |
| **Clone Repo** | A repo cloned into `~/codeman-cases/<name>` and registered as a case. Private repos need this machine's own git credentials (see below). |
| **Link Existing** | An existing folder anywhere on disk, registered in place. Nothing is copied or moved. |
+1 -1
View File
@@ -42,7 +42,7 @@ To make a new one, click **+** next to the picker. The Add Case dialog has three
| Tab | Use it when |
| ----------------- | ------------------------------------------------------------------------------------------------------------------ |
| **Create New** | Starting a fresh project. Creates `~/codeman-cases/<name>` and scaffolds a `CLAUDE.md` into it. |
| **Create New** | Starting a fresh project. Creates `~/codeman-cases/<name>` and scaffolds a `CLAUDE.md` into it. Tick **Create in a custom folder** to put it somewhere else instead. |
| **Clone Repo** | Working on an existing repo: public, or private once this machine's git can authenticate (the Docker image can include `gh`/`az` helpers for this). Paste the URL; Codeman preflights it as you type, offers the repo's real branches and tags, and fills in the case name. |
| **Link Existing** | The code is already on disk. Point at the folder, with **Browse** if you would rather click than type. |
+137
View File
@@ -0,0 +1,137 @@
/**
* @fileoverview Validation for "create a new case in a custom folder" (`POST /api/cases` with a
* `path`). Creating a case writes a scaffold (`CLAUDE.md`, `src/`, `.claude/settings.local.json`)
* and registers the folder in the shared, ownerless linked-cases registry, so the target has to be
* judged before anything is created:
*
* - it must be an absolute path (a leading `~` is expanded) with no traversal and none of the shell
* metacharacters a session's working directory is later rejected for (`isValidWorkingDir`), so
* a case this accepts is one a session can actually start in;
* - it must not be a system directory, the home directory itself, Codeman's own data directory, or
* a credential/config tree (`~/.ssh`, `~/.aws`, `~/.claude`, ...). Judged on the path as typed AND on
* its symlink-resolved form, so a link into `/etc` is not a way around it;
* - its parent must already exist (one folder is created, never a whole chain), and the folder
* itself must not exist or must be an EMPTY directory (a folder with contents is Link Existing's
* job, and silently scaffolding into someone's project is the one thing this must never do);
* - it must not be a symlink.
*
* Pure except for the filesystem reads in `prepareNewCasePath`; the policy lives in `blockedReason`
* so it can be tested without a disk.
*
* @module web/case-path
*/
import { promises as fs } from 'node:fs';
import { basename, dirname, join, resolve, sep } from 'node:path';
import { isValidWorkingDir } from './schemas.js';
/** System trees nobody creates a project in; creating one here is a mistake or an attack. */
const BLOCKED_SYSTEM_ROOTS = [
'/bin',
'/boot',
'/dev',
'/etc',
'/lib',
'/lib32',
'/lib64',
'/proc',
'/run',
'/sbin',
'/sys',
'/usr',
];
/** Home-relative trees that hold credentials or other tools' own configuration. */
const BLOCKED_HOME_DIRS = ['.ssh', '.gnupg', '.aws', '.kube', '.docker', '.claude', '.codex', '.gemini'];
export interface NewCasePathContext {
home: string;
/** Codeman's own state directory (`getDataDir()`), which must never become a case. */
dataDir: string;
}
export type NewCasePathResult =
| { ok: true; path: string; existedEmpty: boolean }
| { ok: false; code: 'INVALID' | 'BLOCKED' | 'NOT_FOUND' | 'EXISTS'; reason: string };
const isWithin = (child: string, root: string): boolean =>
child === root || child.startsWith(root.endsWith(sep) ? root : root + sep);
/** `~` and `~/x` to the home directory; anything else is returned unchanged. */
export function expandHome(raw: string, home: string): string {
if (raw === '~') return home;
if (raw.startsWith('~/')) return join(home, raw.slice(2));
return raw;
}
/** Why a case may not live at this (already absolute and normalised) path, or null. */
export function blockedReason(absPath: string, ctx: NewCasePathContext): string | null {
if (absPath === sep) return 'The filesystem root cannot be a case';
for (const root of BLOCKED_SYSTEM_ROOTS) {
if (isWithin(absPath, root)) return `${root} is a system directory`;
}
if (absPath === ctx.home) return 'The home folder itself cannot be a case; pick a folder inside it';
for (const dir of BLOCKED_HOME_DIRS) {
if (isWithin(absPath, join(ctx.home, dir))) return `~/${dir} holds credentials or another tool's configuration`;
}
if (isWithin(absPath, ctx.dataDir)) return "Codeman's own data folder cannot be a case";
// Any Codeman instance's data dir under the home folder (~/.codeman, ~/.codeman-beta, ...), not only
// the one this process uses.
if (absPath.startsWith(ctx.home + sep)) {
const firstSegment = absPath.slice(ctx.home.length + 1).split(sep)[0];
if (/^\.codeman/.test(firstSegment)) return "Codeman's own data folder cannot be a case";
}
return null;
}
/**
* Judge `raw` as the folder for a new case and, if it is acceptable, say what to create.
* Never creates anything.
*/
export async function prepareNewCasePath(raw: string, ctx: NewCasePathContext): Promise<NewCasePathResult> {
const typed = raw.trim();
if (!typed) return { ok: false, code: 'INVALID', reason: 'Enter a folder path' };
const expanded = expandHome(typed, ctx.home);
if (!isValidWorkingDir(expanded)) {
return {
ok: false,
code: 'INVALID',
reason: 'Use an absolute path with letters, numbers, spaces, - _ . only (no .., no shell characters)',
};
}
const target = resolve(expanded);
const typedBlock = blockedReason(target, ctx);
if (typedBlock) return { ok: false, code: 'BLOCKED', reason: typedBlock };
// Resolve the parent's symlinks, then judge again: a link into a blocked tree must not pass.
let realParent: string;
try {
realParent = await fs.realpath(dirname(target));
if (!(await fs.stat(realParent)).isDirectory()) {
return { ok: false, code: 'INVALID', reason: `${dirname(target)} is not a folder` };
}
} catch {
return { ok: false, code: 'NOT_FOUND', reason: `The parent folder ${dirname(target)} does not exist` };
}
const real = join(realParent, basename(target));
const realBlock = blockedReason(real, ctx);
if (realBlock) return { ok: false, code: 'BLOCKED', reason: realBlock };
try {
const st = await fs.lstat(real);
if (st.isSymbolicLink()) return { ok: false, code: 'INVALID', reason: `${target} is a symbolic link` };
if (!st.isDirectory()) return { ok: false, code: 'INVALID', reason: `${target} exists and is not a folder` };
if ((await fs.readdir(real)).length > 0) {
return {
ok: false,
code: 'EXISTS',
reason: `${target} already has files in it. Use "Link Existing" for a project that already exists`,
};
}
return { ok: true, path: real, existedEmpty: true };
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return { ok: true, path: real, existedEmpty: false };
return { ok: false, code: 'INVALID', reason: `Cannot read ${target}: ${(err as Error).message}` };
}
}
+14 -2
View File
@@ -2972,15 +2972,27 @@
<p class="set-section-blurb">A fresh workspace under ~/codeman-cases, scaffolded with its own CLAUDE.md.</p>
<div class="form-row">
<label>Case Name</label>
<input type="text" id="newCaseName" placeholder="my-project" pattern="[a-zA-Z0-9_-]+" autocomplete="off" autocapitalize="off" spellcheck="false">
<input type="text" id="newCaseName" placeholder="my-project" pattern="[a-zA-Z0-9_-]+" autocomplete="off" autocapitalize="off" spellcheck="false" oninput="app.updateNewCasePathPreview()">
<span class="form-hint">Letters, numbers, hyphens, underscores only. Created in ~/codeman-cases/</span>
</div>
<div class="form-row">
<label>Description (optional)</label>
<input type="text" id="newCaseDescription" placeholder="A brief description..." autocomplete="off">
</div>
<div class="form-row" id="newCaseCustomPathToggleRow">
<label class="checkbox-row"><input type="checkbox" id="newCaseCustomPathToggle" onchange="app.toggleNewCaseCustomPath()"> 📁 Create in a custom folder</label>
<span class="form-hint">By default a new case is created under ~/codeman-cases. Choose another folder and the case is created there instead; it is listed like any other case.</span>
</div>
<div class="form-row" id="newCaseCustomPathRow" style="display:none">
<label>Parent Folder</label>
<div class="path-input-group">
<input type="text" id="newCasePath" placeholder="~/projects" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" oninput="app.updateNewCasePathPreview()">
<button type="button" class="btn path-input-browse" onclick="app.openNewCasePathPicker()">Browse&hellip;</button>
</div>
<span class="form-hint" id="newCasePathPreview">Pick the folder the new case folder should be created inside.</span>
</div>
<div class="form-row docker-quick-row">
<label class="checkbox-row"><input type="checkbox" id="newCaseDocker"> 🐳 Run in an isolated Docker container</label>
<label class="checkbox-row"><input type="checkbox" id="newCaseDocker" onchange="app.toggleNewCaseCustomPath()"> 🐳 Run in an isolated Docker container</label>
<span class="form-hint">Runs this case in a hardened, isolated container. The base image is built automatically on first use. Docker/Podman must be installed.</span>
<span class="form-hint">Already have a container running? <button type="button" class="btn-inline-check" id="dockerAdoptJumpBtn">Attach to it instead</button> Codeman only runs docker exec into it and never touches its lifecycle.</span>
</div>
+68 -2
View File
@@ -3098,6 +3098,16 @@ Object.assign(CodemanApp.prototype, {
showCreateCaseModal() {
document.getElementById('newCaseName').value = '';
document.getElementById('newCaseDescription').value = '';
// Custom folder starts off each time, and is not offered to a non-admin in multi-user mode: the
// server refuses it (it writes outside the cases directory and into the shared registry).
const customToggle = document.getElementById('newCaseCustomPathToggle');
if (customToggle) customToggle.checked = false;
const customPath = document.getElementById('newCasePath');
if (customPath) customPath.value = '';
const me = window.__codemanUser || {};
const customRow = document.getElementById('newCaseCustomPathToggleRow');
if (customRow) customRow.style.display = me.multiUser && me.role !== 'admin' ? 'none' : '';
this.toggleNewCaseCustomPath();
document.getElementById('linkCaseName').value = '';
document.getElementById('linkCasePath').value = '';
const remoteFields = [
@@ -3265,6 +3275,55 @@ Object.assign(CodemanApp.prototype, {
}
},
/**
* Custom-folder row for Create New: shows or hides the parent-folder field, and keeps it and the
* Docker option mutually exclusive (a Docker case has its own workspace flow, and the quick-create
* route has no `path`).
*/
toggleNewCaseCustomPath() {
const custom = document.getElementById('newCaseCustomPathToggle');
const docker = document.getElementById('newCaseDocker');
const row = document.getElementById('newCaseCustomPathRow');
if (!custom || !row) return;
row.style.display = custom.checked ? '' : 'none';
custom.disabled = !!docker?.checked;
custom.title = docker?.checked ? 'Not available for a Docker case' : '';
if (docker) {
docker.disabled = custom.checked;
docker.title = custom.checked ? 'Not available with a custom folder' : '';
}
this.updateNewCasePathPreview();
},
/** The folder the case would be created in: the parent field plus the case name. */
_newCaseTargetPath() {
const parent = (document.getElementById('newCasePath')?.value || '').trim().replace(/\/+$/, '');
const name = (document.getElementById('newCaseName')?.value || '').trim();
return parent && name ? `${parent}/${name}` : '';
},
updateNewCasePathPreview() {
const hint = document.getElementById('newCasePathPreview');
if (!hint) return;
const target = this._newCaseTargetPath();
hint.textContent = target ? `Will create: ${target}` : 'Pick the folder the new case folder should be created inside.';
},
openNewCasePathPicker() {
const input = document.getElementById('newCasePath');
PathPicker.open({
title: 'Choose the folder to create the case in',
initialPath: input.value.trim(),
directoriesOnly: true,
onSelect: (path) => {
input.value = path;
this.updateNewCasePathPreview();
input.focus();
input.setSelectionRange(path.length, path.length);
},
});
},
async createCase() {
const name = document.getElementById('newCaseName').value.trim();
const description = document.getElementById('newCaseDescription').value.trim();
@@ -3282,10 +3341,17 @@ Object.assign(CodemanApp.prototype, {
// One-click "Run in Docker": create the case folder AND a container, then start
// a session inside it. Optional expandable settings override the defaults.
const inDocker = document.getElementById('newCaseDocker')?.checked;
const customFolder = !inDocker && document.getElementById('newCaseCustomPathToggle')?.checked;
if (customFolder && !(document.getElementById('newCasePath')?.value || '').trim()) {
this.showToast('Choose the folder to create the case in', 'error');
return;
}
const endpoint = inDocker ? '/api/cases/docker-quickcreate' : '/api/cases';
const payload = inDocker
? { name, description, ...this._collectDockerQuickSettings() }
: { name, description };
: customFolder
? { name, description, path: this._newCaseTargetPath() }
: { name, description };
try {
const res = await fetch(endpoint, {
@@ -3307,7 +3373,7 @@ Object.assign(CodemanApp.prototype, {
// Start a session INSIDE the container (routes through quick-start).
await this.runClaude();
} else {
this.showToast(`Case "${name}" created`, 'success');
this.showToast(customFolder ? `Case "${name}" created in ${payload.path}` : `Case "${name}" created`, 'success');
}
} else {
this.showToast(data.error || 'Failed to create case', 'error');
+93 -2
View File
@@ -50,6 +50,7 @@ import {
} from '../../git-clone.js';
import type { GitRemoteProbe, GitUrlParse } from '../../git-clone.js';
import { generateClaudeMd } from '../../templates/claude-md.js';
import { prepareNewCasePath } from '../case-path.js';
import { readAgentCaseMarker, type AgentCaseMarker } from '../../agent-case-marker.js';
import { settingsWriteBlocker, writeHooksConfig } from '../../hooks-config.js';
import {
@@ -424,8 +425,98 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
return { success: true, data: { cases: summaries } };
});
app.post('/api/cases', async (req): Promise<ApiResponse<{ case: { name: string; path: string } }>> => {
const { name, description } = parseBody(CreateCaseSchema, req.body);
/**
* `POST /api/cases` with a `path`: create the folder (or fill an EMPTY existing one), scaffold it
* exactly like a normal case, and register it in the linked-cases registry so it lists, resolves
* and deletes (unlinks, never removes files) like any linked case. Everything is judged before
* anything is written; a failure after the first write undoes what this call created.
*/
async function createCaseInCustomFolder(
name: string,
description: string | undefined,
customPath: string,
req: FastifyRequest,
reply: { code: (n: number) => unknown }
): Promise<ApiResponse<{ case: { name: string; path: string } }>> {
if (existsSync(join(resolveCasesDir(getAuthUser(req)), name))) {
reply.code(409);
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'A case with this name already exists in codeman-cases.');
}
const linkedCases = await readLinkedCases();
if (linkedCases[name]) {
reply.code(409);
return createErrorResponse(
ApiErrorCode.ALREADY_EXISTS,
`Case "${name}" is already linked to ${linkedCases[name]}`
);
}
const prepared = await prepareNewCasePath(customPath, { home: homedir(), dataDir: getDataDir() });
if (!prepared.ok) {
const status = prepared.code === 'NOT_FOUND' ? 404 : prepared.code === 'EXISTS' ? 409 : 400;
reply.code(status);
const code =
prepared.code === 'NOT_FOUND'
? ApiErrorCode.NOT_FOUND
: prepared.code === 'EXISTS'
? ApiErrorCode.ALREADY_EXISTS
: ApiErrorCode.INVALID_INPUT;
return createErrorResponse(code, prepared.reason);
}
const casePath = prepared.path;
const alreadyAs = Object.entries(linkedCases).find(([, p]) => p === casePath)?.[0];
if (alreadyAs) {
reply.code(409);
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, `That folder is already the case "${alreadyAs}"`);
}
const made: string[] = [];
try {
if (!prepared.existedEmpty) {
mkdirSync(casePath);
made.push(casePath);
}
mkdirSync(join(casePath, 'src'));
made.push(join(casePath, 'src'));
const templatePath = await ctx.getDefaultClaudeMdPath();
writeFileSync(join(casePath, 'CLAUDE.md'), generateClaudeMd(name, description || '', templatePath));
made.push(join(casePath, 'CLAUDE.md'));
made.push(join(casePath, '.claude')); // before the write, so a half-written one is undone too
await writeHooksConfig(casePath);
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.
const fresh = await readLinkedCases();
if (fresh[name]) throw Object.assign(new Error(`Case "${name}" was just linked`), { conflict: true });
fresh[name] = casePath;
await fs.writeFile(LINKED_CASES_FILE, JSON.stringify(fresh, null, 2));
ctx.broadcast(SseEvent.CaseCreated, { name, path: casePath });
return { success: true, data: { case: { name, path: casePath } } };
} catch (err) {
// Undo only what this call made. The whole folder if we created it, otherwise the scaffold
// entries inside the empty folder the user picked; never anything else.
for (const p of made.reverse()) await fs.rm(p, { recursive: true, force: true }).catch(() => undefined);
if ((err as { conflict?: boolean }).conflict) {
reply.code(409);
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, getErrorMessage(err));
}
reply.code(500);
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
}
}
app.post('/api/cases', async (req, reply): Promise<ApiResponse<{ case: { name: string; path: string } }>> => {
const { name, description, path: customPath } = parseBody(CreateCaseSchema, req.body);
// A custom folder writes outside the cases directory and registers the path in the shared,
// ownerless linked-cases registry, so it carries the same bar as POST /api/cases/link.
if (customPath !== undefined) {
const denied = adminOnly(req, reply);
if (denied) return denied;
return createCaseInCustomFolder(name, description, customPath, req, reply);
}
const casePath = validatePathWithinBase(name, resolveCasesDir(getAuthUser(req)));
if (!casePath) {
+7
View File
@@ -668,6 +668,13 @@ export const CreateCaseSchema = z.object({
.string()
.regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format. Use only letters, numbers, hyphens, underscores.'),
description: z.string().max(1000).optional(),
/**
* Create the case in this folder instead of under the cases directory. Absolute, or starting with
* `~`. Only length-bounded here: what makes it acceptable (shape, blocked trees, symlinks, an
* existing folder with contents) is judged by `prepareNewCasePath()` in web/case-path.ts, which
* also produces the user-facing reason.
*/
path: z.string().min(1).max(1000).optional(),
});
/**
+139
View File
@@ -0,0 +1,139 @@
/** @fileoverview Add Case → Create New → "Create in a custom folder", end to end: real server, real Chromium, real folders. */
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { chromium, type Browser, type Page } from 'playwright';
import { WebServer } from '../src/web/server.js';
declare const PathPicker: any; // evaluated inside the page, where it is a global
const PORT = 3193;
describe('Create a case in a custom folder', () => {
let server: WebServer;
let browser: Browser;
let page: Page;
let parent: string;
beforeAll(async () => {
parent = mkdtempSync(join(homedir(), 'custom-case-'));
server = new WebServer(PORT, false, true);
await server.start();
browser = await chromium.launch({ headless: true });
page = await browser.newPage();
await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
}, 90000);
afterAll(async () => {
if (browser) await browser.close();
if (server) await server.stop();
rmSync(parent, { recursive: true, force: true });
}, 60000);
const open = async () => {
await page.evaluate(() => {
(window as any).app.showCreateCaseModal();
(window as any).app.switchCaseModalTab('case-create');
});
};
const toastText = () =>
page.evaluate(() => [...document.querySelectorAll('.toast')].map((t) => t.textContent).join('|'));
it('hides the parent-folder field until the box is ticked, and shows what it will create', async () => {
await open();
expect(await page.isVisible('#newCaseCustomPathRow')).toBe(false);
await page.click('label.checkbox-row:has(#newCaseCustomPathToggle)');
expect(await page.isVisible('#newCaseCustomPathRow')).toBe(true);
await page.fill('#newCaseName', 'my-app');
await page.fill('#newCasePath', '~/projects/');
expect(await page.textContent('#newCasePathPreview')).toBe('Will create: ~/projects/my-app');
});
it('is exclusive with the Docker option, in both directions', async () => {
await open();
await page.click('label.checkbox-row:has(#newCaseCustomPathToggle)');
expect(await page.isDisabled('#newCaseDocker')).toBe(true);
await page.click('label.checkbox-row:has(#newCaseCustomPathToggle)');
expect(await page.isDisabled('#newCaseDocker')).toBe(false);
await page.click('label.checkbox-row:has(#newCaseDocker)');
expect(await page.isDisabled('#newCaseCustomPathToggle')).toBe(true);
await page.click('label.checkbox-row:has(#newCaseDocker)');
});
it('Browse opens the folder picker for directories only and fills the field with the choice', async () => {
await open();
await page.click('label.checkbox-row:has(#newCaseCustomPathToggle)');
await page.fill('#newCaseName', 'picked');
const opts = await page.evaluate(() => {
// A top-level `const` in a classic script: a global binding, not a window property.
const picker = PathPicker;
let captured: any = null;
const original = picker.open;
picker.open = (o: any) => (captured = o);
(document.querySelector('#newCaseCustomPathRow .path-input-browse') as HTMLElement).click();
picker.open = original;
captured.onSelect('/srv/work');
return { directoriesOnly: captured.directoriesOnly };
});
expect(opts.directoriesOnly).toBe(true);
expect(await page.inputValue('#newCasePath')).toBe('/srv/work');
expect(await page.textContent('#newCasePathPreview')).toBe('Will create: /srv/work/picked');
});
it('asks for a folder when the box is ticked and the field is empty, and creates nothing', async () => {
await open();
await page.click('label.checkbox-row:has(#newCaseCustomPathToggle)');
await page.fill('#newCaseName', 'no-folder');
await page.evaluate(() => (window as any).app.createCase());
expect(await toastText()).toMatch(/Choose the folder/);
});
it('creates the case in the chosen folder, scaffolds it, and lists it at that path', async () => {
await open();
await page.click('label.checkbox-row:has(#newCaseCustomPathToggle)');
await page.fill('#newCaseName', 'in-custom');
await page.fill('#newCasePath', parent);
await page.evaluate(() => (window as any).app.submitCaseModal());
await page.waitForFunction(() =>
/created in/.test([...document.querySelectorAll('.toast')].map((t) => t.textContent).join('|'))
);
const target = join(parent, 'in-custom');
expect(readFileSync(join(target, 'CLAUDE.md'), 'utf8')).toContain('in-custom');
expect(existsSync(join(target, 'src'))).toBe(true);
const cases = await page.evaluate(async () => {
const body = await (await fetch('/api/cases')).json();
return Array.isArray(body) ? body : body.data;
});
expect(cases.find((c: { name: string }) => c.name === 'in-custom')).toMatchObject({ path: target });
expect(existsSync(join(homedir(), 'codeman-cases', 'in-custom'))).toBe(false);
expect(await page.isVisible('#createCaseModal.active')).toBe(false);
});
it('shows the server’s reason for a folder that already has files, and leaves it untouched', async () => {
const busy = join(parent, 'busy');
mkdirSync(busy);
writeFileSync(join(busy, 'keep.txt'), 'mine');
await open();
await page.click('label.checkbox-row:has(#newCaseCustomPathToggle)');
await page.fill('#newCaseName', 'busy');
await page.fill('#newCasePath', parent);
await page.evaluate(() => (window as any).app.createCase());
await page.waitForFunction(() =>
/Link Existing/.test([...document.querySelectorAll('.toast')].map((t) => t.textContent).join('|'))
);
expect(readFileSync(join(busy, 'keep.txt'), 'utf8')).toBe('mine');
expect(existsSync(join(busy, 'CLAUDE.md'))).toBe(false);
});
it('starts unticked every time the modal opens', async () => {
await open();
await page.click('label.checkbox-row:has(#newCaseCustomPathToggle)');
await page.fill('#newCasePath', '/tmp');
await open();
expect(await page.isChecked('#newCaseCustomPathToggle')).toBe(false);
expect(await page.inputValue('#newCasePath')).toBe('');
expect(await page.isVisible('#newCaseCustomPathRow')).toBe(false);
});
});
+147
View File
@@ -0,0 +1,147 @@
// @vitest-environment node
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { blockedReason, expandHome, prepareNewCasePath, type NewCasePathContext } from '../src/web/case-path.js';
let root: string;
let home: string;
let ctx: NewCasePathContext;
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'case-path-'));
home = join(root, 'home');
mkdirSync(join(home, 'code'), { recursive: true });
ctx = { home, dataDir: join(home, '.codeman') };
});
afterEach(() => rmSync(root, { recursive: true, force: true }));
describe('expandHome', () => {
it('expands ~ and ~/x only', () => {
expect(expandHome('~', '/h')).toBe('/h');
expect(expandHome('~/code/app', '/h')).toBe('/h/code/app');
expect(expandHome('~other/x', '/h')).toBe('~other/x');
expect(expandHome('/abs/~/x', '/h')).toBe('/abs/~/x');
});
});
describe('blockedReason', () => {
const c = { home: '/home/u', dataDir: '/home/u/.codeman' };
it.each([
['/', /root/],
['/etc', /system directory/],
['/etc/cron.d/x', /system directory/],
['/usr/local/src', /system directory/],
['/proc/1', /system directory/],
['/home/u', /home folder itself/],
['/home/u/.ssh', /credentials/],
['/home/u/.ssh/proj', /credentials/],
['/home/u/.aws/x', /credentials/],
['/home/u/.claude/skills/x', /configuration/],
['/home/u/.codeman/cases/x', /data folder/],
['/home/u/.codeman-beta/x', /data folder/],
])('refuses %s', (p, why) => expect(blockedReason(p, c)).toMatch(why));
it.each(['/home/u/code/app', '/home/u/.config/app', '/srv/projects/x', '/opt/work', '/tmp/x', '/home/u/etc/app'])(
'allows %s (a name that merely contains a blocked word is fine)',
(p) => expect(blockedReason(p, c)).toBeNull()
);
it('does not treat /etcetera or /usrlocal as the system directories', () => {
expect(blockedReason('/etcetera/x', c)).toBeNull();
expect(blockedReason('/usrlocal', c)).toBeNull();
});
});
describe('prepareNewCasePath', () => {
it('accepts a new folder under an existing parent and reports it does not exist yet', async () => {
const r = await prepareNewCasePath(join(home, 'code', 'new-app'), ctx);
expect(r).toMatchObject({ ok: true, existedEmpty: false });
if (r.ok) expect(r.path).toMatch(/code\/new-app$/);
});
it('accepts an existing EMPTY folder and says so', async () => {
mkdirSync(join(home, 'code', 'empty'));
expect(await prepareNewCasePath(join(home, 'code', 'empty'), ctx)).toMatchObject({ ok: true, existedEmpty: true });
});
it('expands ~, tolerates a trailing slash and a ./ segment, and accepts spaces', async () => {
const a = await prepareNewCasePath('~/code/from-tilde', ctx);
expect(a.ok && a.path).toBe(join(home, 'code', 'from-tilde'));
// A "./" segment and a trailing slash normalise away; the folder name may contain a space.
const b = await prepareNewCasePath(`${join(home, 'code')}/./with space/`, ctx);
expect(b.ok && b.path).toBe(join(home, 'code', 'with space'));
// ...and a folder with a space in it can be the PARENT of the next one.
mkdirSync(join(home, 'code', 'with space'));
expect(await prepareNewCasePath(`${join(home, 'code', 'with space')}/proj/`, ctx)).toMatchObject({ ok: true });
});
it.each([
['empty', ''],
['whitespace', ' '],
['relative', 'code/app'],
['dot-relative', './app'],
['traversal', '/tmp/../etc/x'],
['shell metacharacters', '/tmp/a;rm -rf /'],
['command substitution', '/tmp/$(id)'],
['quotes', "/tmp/it's"],
['newline', '/tmp/a\nb'],
])('rejects %s as invalid', async (_label, raw) => {
expect(await prepareNewCasePath(raw, ctx)).toMatchObject({ ok: false, code: 'INVALID' });
});
it('refuses system, home, credential and Codeman folders as BLOCKED', async () => {
for (const raw of [
'/etc/proj',
'/usr/src/x',
home,
join(home, '.ssh', 'x'),
join(home, '.codeman', 'cases', 'x'),
]) {
expect(await prepareNewCasePath(raw, ctx), raw).toMatchObject({ ok: false, code: 'BLOCKED' });
}
});
it('judges the symlink-resolved path too: a link into a blocked tree is not a way around it', async () => {
symlinkSync('/etc', join(home, 'code', 'sneaky'));
expect(await prepareNewCasePath(join(home, 'code', 'sneaky', 'proj'), ctx)).toMatchObject({
ok: false,
code: 'BLOCKED',
});
});
it('reports a missing parent as NOT_FOUND and never makes a chain of folders', async () => {
const r = await prepareNewCasePath(join(home, 'code', 'nope', 'deeper', 'app'), ctx);
expect(r).toMatchObject({ ok: false, code: 'NOT_FOUND' });
});
it('refuses a parent that is a file', async () => {
writeFileSync(join(home, 'code', 'afile'), 'x');
expect(await prepareNewCasePath(join(home, 'code', 'afile', 'app'), ctx)).toMatchObject({ ok: false });
});
it('refuses a folder that already has files in it, pointing at Link Existing', async () => {
mkdirSync(join(home, 'code', 'mine'));
writeFileSync(join(home, 'code', 'mine', 'README.md'), 'hello');
const r = await prepareNewCasePath(join(home, 'code', 'mine'), ctx);
expect(r).toMatchObject({ ok: false, code: 'EXISTS' });
if (!r.ok) expect(r.reason).toMatch(/Link Existing/);
});
it('refuses a target that is a file or a symbolic link', async () => {
writeFileSync(join(home, 'code', 'plain'), 'x');
expect(await prepareNewCasePath(join(home, 'code', 'plain'), ctx)).toMatchObject({ ok: false, code: 'INVALID' });
mkdirSync(join(home, 'code', 'real'));
symlinkSync(join(home, 'code', 'real'), join(home, 'code', 'link'));
const r = await prepareNewCasePath(join(home, 'code', 'link'), ctx);
expect(r).toMatchObject({ ok: false, code: 'INVALID' });
if (!r.ok) expect(r.reason).toMatch(/symbolic link/);
});
it('never creates anything', async () => {
await prepareNewCasePath(join(home, 'code', 'dry-run'), ctx);
const { existsSync } = await import('node:fs');
expect(existsSync(join(home, 'code', 'dry-run'))).toBe(false);
});
});
+148
View File
@@ -0,0 +1,148 @@
/**
* @fileoverview POST /api/cases with a `path`: create a new case in a custom folder. Real
* filesystem under test/setup.ts's temp HOME (the path policy itself is in test/case-path.test.ts).
* Port: N/A (app.inject()).
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { createRouteTestHarness } from './_route-test-utils.js';
import { registerCaseRoutes } from '../../src/web/routes/case-routes.js';
import { dataPath } from '../../src/config/instance.js';
const LINKED = () => dataPath('linked-cases.json');
const work = () => join(homedir(), 'projects');
const linked = (): Record<string, string> => (existsSync(LINKED()) ? JSON.parse(readFileSync(LINKED(), 'utf8')) : {});
const create = (app: Awaited<ReturnType<typeof createRouteTestHarness>>['app'], payload: Record<string, unknown>) =>
app.inject({ method: 'POST', url: '/api/cases', payload });
beforeEach(() => {
rmSync(work(), { recursive: true, force: true });
rmSync(LINKED(), { recursive: true, force: true });
mkdirSync(work(), { recursive: true });
});
afterEach(() => {
delete process.env.CODEMAN_MULTIUSER;
rmSync(work(), { recursive: true, force: true });
rmSync(LINKED(), { recursive: true, force: true });
});
describe('POST /api/cases with a custom path', () => {
it('creates the folder, scaffolds it like a normal case, and registers it as a linked case', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const target = join(work(), 'my-app');
const res = await create(app, { name: 'my-app', description: 'A thing', path: target });
expect(res.statusCode).toBe(200);
expect(res.json().data.case).toEqual({ name: 'my-app', path: target });
expect(readFileSync(join(target, 'CLAUDE.md'), 'utf8')).toContain('my-app');
expect(existsSync(join(target, 'src'))).toBe(true);
expect(existsSync(join(target, '.claude', 'settings.local.json'))).toBe(true);
expect(linked()).toEqual({ 'my-app': target });
});
it('appears in GET /api/cases at its custom path', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const target = join(work(), 'listed');
await create(app, { name: 'listed', path: target });
const list = (await app.inject({ method: 'GET', url: '/api/cases' })).json();
const cases = Array.isArray(list) ? list : list.data;
expect(cases.find((c: { name: string }) => c.name === 'listed')).toMatchObject({ path: target });
});
it('expands ~ and fills an existing EMPTY folder', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
mkdirSync(join(work(), 'empty-one'));
const res = await create(app, { name: 'empty-one', path: '~/projects/empty-one' });
expect(res.statusCode).toBe(200);
expect(existsSync(join(work(), 'empty-one', 'CLAUDE.md'))).toBe(true);
});
it('leaves the cases directory alone: nothing is created under codeman-cases', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
await create(app, { name: 'elsewhere', path: join(work(), 'elsewhere') });
expect(existsSync(join(homedir(), 'codeman-cases', 'elsewhere'))).toBe(false);
});
it('refuses a folder that already has files (409) and touches nothing', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
mkdirSync(join(work(), 'existing'));
writeFileSync(join(work(), 'existing', 'keep.txt'), 'mine');
const res = await create(app, { name: 'existing', path: join(work(), 'existing') });
expect(res.statusCode).toBe(409);
expect(res.json().error).toMatch(/Link Existing/);
expect(readdirSync(join(work(), 'existing'))).toEqual(['keep.txt']);
expect(linked()).toEqual({});
});
it.each([
['a system folder', () => '/etc/my-case', 400],
['a credential folder', () => join(homedir(), '.ssh', 'x'), 400],
['the home folder itself', () => homedir(), 400],
['a relative path', () => 'projects/x', 400],
['a path with traversal', () => `${work()}/../x`, 400],
['a missing parent', () => join(work(), 'nope', 'deep', 'app'), 404],
] as const)('refuses %s', async (_label, path, status) => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const res = await create(app, { name: 'x', path: path() });
expect(res.statusCode).toBe(status);
expect(linked()).toEqual({});
});
it('refuses a duplicate case name, and a folder that is already a case, without creating anything', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
await create(app, { name: 'one', path: join(work(), 'one') });
const dupName = await create(app, { name: 'one', path: join(work(), 'two') });
expect(dupName.statusCode).toBe(409);
expect(existsSync(join(work(), 'two'))).toBe(false);
// Same folder under another name: the first case's folder now has files, which is refused earlier.
const dupPath = await create(app, { name: 'other', path: join(work(), 'one') });
expect(dupPath.statusCode).toBe(409);
expect(linked()).toEqual({ one: join(work(), 'one') });
});
it('validates the case name like a normal create', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const res = await create(app, { name: '../evil', path: join(work(), 'x') });
expect(res.statusCode).toBe(400);
expect(existsSync(join(work(), 'x'))).toBe(false);
});
it('undoes what it created when registering fails (a new folder is removed entirely)', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
mkdirSync(LINKED(), { recursive: true }); // writeFile onto a directory fails
const res = await create(app, { name: 'doomed', path: join(work(), 'doomed') });
expect(res.statusCode).toBe(500);
expect(existsSync(join(work(), 'doomed'))).toBe(false);
});
it('undoes only the scaffold inside an empty folder the user picked, leaving the folder', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
mkdirSync(join(work(), 'picked'));
mkdirSync(LINKED(), { recursive: true });
const res = await create(app, { name: 'picked', path: join(work(), 'picked') });
expect(res.statusCode).toBe(500);
expect(existsSync(join(work(), 'picked'))).toBe(true);
expect(readdirSync(join(work(), 'picked'))).toEqual([]);
});
it('a request without a path still creates under the cases directory, as before', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const res = await create(app, { name: 'plain-case' });
expect(res.statusCode).toBe(200);
expect(existsSync(join(homedir(), 'codeman-cases', 'plain-case', 'CLAUDE.md'))).toBe(true);
expect(linked()).toEqual({});
rmSync(join(homedir(), 'codeman-cases', 'plain-case'), { recursive: true, force: true });
});
it('multi-user: a non-admin is refused (403) and nothing is created; an admin is allowed', async () => {
process.env.CODEMAN_MULTIUSER = '1';
const user = await createRouteTestHarness(registerCaseRoutes, { authUser: { username: 'bob', role: 'user' } });
const denied = await create(user.app, { name: 'bobs', path: join(work(), 'bobs') });
expect(denied.statusCode).toBe(403);
expect(existsSync(join(work(), 'bobs'))).toBe(false);
expect(linked()).toEqual({});
const admin = await createRouteTestHarness(registerCaseRoutes, { authUser: { username: 'root', role: 'admin' } });
expect((await create(admin.app, { name: 'roots', path: join(work(), 'roots') })).statusCode).toBe(200);
});
});