From 4150707a6b9b538a3d7f133880e6a5bdd837ea97 Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Sat, 3 Oct 2026 13:54:16 +0800 Subject: [PATCH] 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 --- .changeset/case-custom-path.md | 5 + config/test-suites.ts | 1 + docs/api-reference.md | 14 ++ docs/wiki/Core-Concepts.md | 2 +- docs/wiki/Quick-Start.md | 2 +- src/web/case-path.ts | 137 ++++++++++++++++++ src/web/public/index.html | 16 ++- src/web/public/session-ui.js | 70 ++++++++- src/web/routes/case-routes.ts | 95 ++++++++++++- src/web/schemas.ts | 7 + test/case-custom-path.browser.test.ts | 139 ++++++++++++++++++ test/case-path.test.ts | 147 +++++++++++++++++++ test/routes/case-custom-path-routes.test.ts | 148 ++++++++++++++++++++ 13 files changed, 775 insertions(+), 8 deletions(-) create mode 100644 .changeset/case-custom-path.md create mode 100644 src/web/case-path.ts create mode 100644 test/case-custom-path.browser.test.ts create mode 100644 test/case-path.test.ts create mode 100644 test/routes/case-custom-path-routes.test.ts diff --git a/.changeset/case-custom-path.md b/.changeset/case-custom-path.md new file mode 100644 index 00000000..8fa97cd9 --- /dev/null +++ b/.changeset/case-custom-path.md @@ -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. diff --git a/config/test-suites.ts b/config/test-suites.ts index 004af462..6fd76d11 100644 --- a/config/test-suites.ts +++ b/config/test-suites.ts @@ -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', diff --git a/docs/api-reference.md b/docs/api-reference.md index 7b534ddc..ba24ef3c 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -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 `/` 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. diff --git a/docs/wiki/Core-Concepts.md b/docs/wiki/Core-Concepts.md index b32c2bba..3695340f 100644 --- a/docs/wiki/Core-Concepts.md +++ b/docs/wiki/Core-Concepts.md @@ -19,7 +19,7 @@ Three ways to get one, all under **+** next to the case picker: | How | Result | | ----------------- | ------------------------------------------------------------------------------------------------------ | -| **Create New** | A fresh `~/codeman-cases/` with a scaffolded `CLAUDE.md`. | +| **Create New** | A fresh `~/codeman-cases/` 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/` 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. | diff --git a/docs/wiki/Quick-Start.md b/docs/wiki/Quick-Start.md index 5fff5707..e3caa75b 100644 --- a/docs/wiki/Quick-Start.md +++ b/docs/wiki/Quick-Start.md @@ -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/` and scaffolds a `CLAUDE.md` into it. | +| **Create New** | Starting a fresh project. Creates `~/codeman-cases/` 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. | diff --git a/src/web/case-path.ts b/src/web/case-path.ts new file mode 100644 index 00000000..7c91e8f6 --- /dev/null +++ b/src/web/case-path.ts @@ -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 { + 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}` }; + } +} diff --git a/src/web/public/index.html b/src/web/public/index.html index cc8f0337..f7e11f1f 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -2972,15 +2972,27 @@

A fresh workspace under ~/codeman-cases, scaffolded with its own CLAUDE.md.

- + Letters, numbers, hyphens, underscores only. Created in ~/codeman-cases/
+
+ + 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. +
+
- + Runs this case in a hardened, isolated container. The base image is built automatically on first use. Docker/Podman must be installed. Already have a container running? Codeman only runs docker exec into it and never touches its lifecycle.
diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index 27a74ec8..f284d4f3 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -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'); diff --git a/src/web/routes/case-routes.ts b/src/web/routes/case-routes.ts index b4802344..ad287547 100644 --- a/src/web/routes/case-routes.ts +++ b/src/web/routes/case-routes.ts @@ -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> => { - 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> { + 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> => { + 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) { diff --git a/src/web/schemas.ts b/src/web/schemas.ts index 48845fd0..775b889a 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -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(), }); /** diff --git a/test/case-custom-path.browser.test.ts b/test/case-custom-path.browser.test.ts new file mode 100644 index 00000000..91fdb8c7 --- /dev/null +++ b/test/case-custom-path.browser.test.ts @@ -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); + }); +}); diff --git a/test/case-path.test.ts b/test/case-path.test.ts new file mode 100644 index 00000000..ce3364c0 --- /dev/null +++ b/test/case-path.test.ts @@ -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); + }); +}); diff --git a/test/routes/case-custom-path-routes.test.ts b/test/routes/case-custom-path-routes.test.ts new file mode 100644 index 00000000..f6a6a656 --- /dev/null +++ b/test/routes/case-custom-path-routes.test.ts @@ -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 => (existsSync(LINKED()) ? JSON.parse(readFileSync(LINKED(), 'utf8')) : {}); +const create = (app: Awaited>['app'], payload: Record) => + 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); + }); +});