From 0bd15ddc847b55308176e8918a59fbdd4742364c Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Sat, 31 Oct 2026 16:19:20 +0800 Subject: [PATCH] feat(cases): clone a repository into a custom folder Clone Repo could only clone under ~/codeman-cases. POST /api/cases/clone now takes an optional path (parent folder plus case name) and the Clone tab gets a Clone into a custom folder option with a Browse button, mirroring Create in a custom folder. The path is judged by prepareNewCasePath, the clone is registered as a linked case, the option is admin-only in multi-user mode, and an existing target folder is refused. --- CLAUDE.md | 2 +- docs/architecture-invariants.md | 2 +- docs/wiki/Core-Concepts.md | 2 +- src/web/public/index.html | 14 +- src/web/public/session-ui.js | 72 +++++++- src/web/routes/case-routes.ts | 95 ++++++++++- src/web/schemas.ts | 6 + test/routes/case-clone-custom-folder.test.ts | 169 +++++++++++++++++++ 8 files changed, 350 insertions(+), 12 deletions(-) create mode 100644 test/routes/case-clone-custom-folder.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 3926a6b0..56ba4e2d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -244,7 +244,7 @@ The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLin **Ultracode / workflow-run visualization** (opt-in, default OFF): the Workflow tool writes a completion artifact only at run *end*, so live in-flight runs exist solely as transcript dirs. `workflow-run-watcher.ts` therefore synthesizes ACTIVE runs from transcripts until the completion artifact appears and supersedes them. It is **STANDALONE** and deliberately never imports or touches `subagent-watcher.ts`, despite reading the same tree. Two independent toggles: `showUltracodeAgents` (docked panel) and `ultracodeFloatingWindows` (floating windows); the watcher starts if **either** is on. → [architecture-invariants#ultracode-and-workflow-run-visualization](docs/architecture-invariants.md#ultracode-and-workflow-run-visualization) -**Clone a repository as a case** (issue #236, Add Case → **Clone Repo**): `POST /api/cases/clone` clones synchronously into the caller's case space (bounded by `GIT_CLONE_TIMEOUT_MS`, no job store); `POST /api/cases/clone-preflight` checks anonymous cloneability and lists refs. Core in `src/git-clone.ts`. ⚠️ **The URL is a code-execution surface**: refuse every `::` form and a leading `-`, spawn only argv arrays with `--` before operands. ⚠️ Stay non-interactive (`gitNonInteractiveEnv()`) or the open request hangs; never collect credentials, refuse `user:password@` URLs. ⚠️ Timeout kills the process GROUP, remove the destination only if this attempt created it, and repo contents win over scaffolding (existing `CLAUDE.md` kept, hooks merged, repo `.claude/settings*` warned about). → [architecture-invariants#clone-a-repository-as-a-case](docs/architecture-invariants.md#clone-a-repository-as-a-case) +**Clone a repository as a case** (issue #236, Add Case → **Clone Repo**): `POST /api/cases/clone` clones synchronously into the caller's case space (bounded by `GIT_CLONE_TIMEOUT_MS`, no job store); `POST /api/cases/clone-preflight` checks anonymous cloneability and lists refs. Core in `src/git-clone.ts`. ⚠️ **The URL is a code-execution surface**: refuse every `::` form and a leading `-`, spawn only argv arrays with `--` before operands. ⚠️ Stay non-interactive (`gitNonInteractiveEnv()`) or the open request hangs; never collect credentials, refuse `user:password@` URLs. ⚠️ Timeout kills the process GROUP, remove the destination only if this attempt created it, and repo contents win over scaffolding (existing `CLAUDE.md` kept, hooks merged, repo `.claude/settings*` warned about). ⚠️ A `path` clones into a custom folder and registers a linked case: admin-only in multi-user mode, judged by `prepareNewCasePath()`, and an existing target (even empty) is refused. → [architecture-invariants#clone-a-repository-as-a-case](docs/architecture-invariants.md#clone-a-repository-as-a-case) **Git worktrees of a case** (case picker → **Git worktrees…**; `src/git-worktrees.ts`, `routes/worktree-routes.ts`): a local repository case keeps worktrees at `/.claude/worktrees/` on `worktree-`, Claude Code's own `--worktree` layout. ⚠️ Only that managed path is ever created or removed, every component `lstat`ed and realpath-checked (a symlinked `.claude` is refused). ⚠️ git never runs for a Docker or remote case, nor at or inside a Docker workspace (`worktree add` runs hooks and filters on the host). ⚠️ Remove is `git worktree remove` WITHOUT `--force`, refused while a live session is inside; branch delete is `-d`, never `-D`. ⚠️ Sessions start through the existing create paths (quick-start `worktree`, or `POST /api/sessions` `workingDir`), never a new spawn path. → [architecture-invariants#git-worktrees-of-a-case](docs/architecture-invariants.md#git-worktrees-of-a-case) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index ebba7973..63f047b0 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -468,7 +468,7 @@ Tests: `test/file-preview-markdown.test.ts` (jsdom-in-vm, pins every rule above) ### Clone a repository as a case -**Clone Repo tab** (issue #236, proposed by @DodgyBadger): `POST /api/cases/clone` clones a public repository into the caller's case space and registers it as a normal local case; `POST /api/cases/clone-preflight` answers "can this be cloned anonymously, and what refs does it have?" while the user is still typing. Core in `src/git-clone.ts`, split into a PURE half (URL parse, argv/env, `ls-remote` parse, stderr classification) and a thin IO half (`probeGitRemote`, `cloneRepository`). +**Clone Repo tab** (issue #236, proposed by @DodgyBadger): `POST /api/cases/clone` clones a public repository into the caller's case space and registers it as a normal local case; `POST /api/cases/clone-preflight` answers "can this be cloned anonymously, and what refs does it have?" while the user is still typing. Core in `src/git-clone.ts`, split into a PURE half (URL parse, argv/env, `ls-remote` parse, stderr classification) and a thin IO half (`probeGitRemote`, `cloneRepository`). An optional `path` (the **Clone into a custom folder** checkbox, parent folder plus case name) clones into that exact folder instead and registers it as a LINKED case, the same way `POST /api/cases` with a `path` does: it is judged by `prepareNewCasePath()` (`web/case-path.ts`: blocked trees, symlinks, parent must exist), is admin-only in multi-user mode (it writes an absolute path into the shared linked-cases registry), and refuses a folder that already exists, empty or not, because `cloneRepository` needs a fresh destination. Deleting such a case unlinks it and never removes the files. If the registry write fails after git finished, the folder is kept and the error says where it is. - ⚠️ **The URL is a code-execution surface, which is why it is parsed rather than forwarded.** `ext::sh -c ` makes git run an arbitrary command as its transport, and ANY `::` dispatches to a `git-remote-` helper, so every `::` form is refused outright. A repository starting with `-` is read by git as a flag; that is rejected AND every spawn puts `--` before the operands, because either defence alone is one edit away from being a hole. Spawns are argv arrays, never a shell (unlike `remote-hosts.ts`, which does build a shell line and must `shellescape`). The Zod schema deliberately only length-bounds `repository` — a weaker regex duplicate of `parseGitRepositoryUrl` would be the copy that drifts. - ⚠️ **Non-interactive or it hangs the request.** The clone is synchronous by design (no job store, no polling, no cancellation surface), so an invisible credential prompt would pin an open HTTP request until the timeout. `gitNonInteractiveEnv()` closes all four prompt paths at once: `GIT_TERMINAL_PROMPT=0`, empty `GIT_ASKPASS`/`SSH_ASKPASS` + `SSH_ASKPASS_REQUIRE=never` + empty `DISPLAY`, `GCM_INTERACTIVE=never`, and `ssh -oBatchMode=yes`. `HOME`/`PATH` are inherited on purpose — a user whose own agent or credential helper already works keeps working (so a private repo may well clone; Codeman just never collects or stores credentials, and refuses a `user:password@` URL). ⚠️ In multi-user mode a NON-ADMIN's clone and preflight run with `git -c credential.helper=` (`cloneWithoutCredentialHelpers`), because the helpers belong to the one server account every user shares; admins and single-user mode keep them. The Docker server image relies on exactly this: when built with the opt-in `CODEMAN_INSTALL_GH`/`CODEMAN_INSTALL_AZ` args it configures `gh` (github.com) and an `az`-backed helper (`docker/git-credential-azure-cli`, Azure DevOps) in its SYSTEM gitconfig, both silent until the user signs the CLI in, so a private clone there still fails fast rather than prompting. diff --git a/docs/wiki/Core-Concepts.md b/docs/wiki/Core-Concepts.md index c5762d60..85fb0416 100644 --- a/docs/wiki/Core-Concepts.md +++ b/docs/wiki/Core-Concepts.md @@ -25,7 +25,7 @@ Three ways to get one, all under **New or link a case…** at the bottom of the | How | Result | | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **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). | +| **Clone Repo** | A repo cloned into `~/codeman-cases/` (or into a folder you choose) 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. | Linked cases keep living where they are. Deleting a case in Codeman removes the diff --git a/src/web/public/index.html b/src/web/public/index.html index 6611e6c3..195e5900 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -3654,7 +3654,19 @@
- Filled in from the URL. Cloned into ~/codeman-cases/<name>, so deleting the case deletes this working tree. + Filled in from the URL. Cloned into ~/codeman-cases/<name>, so deleting the case deletes this working tree. +
+
+ + By default the clone goes under ~/codeman-cases. Choose another folder and it is cloned there instead; it is listed like any other case, and deleting the case only unlinks it (your files stay). +
+
diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index 1e5961d4..ea462d4b 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -4097,6 +4097,15 @@ Object.assign(CodemanApp.prototype, { set('cloneRepoUrl', ''); set('cloneCaseName', ''); set('cloneRepoRef', ''); + set('cloneCasePath', ''); + // 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('cloneCustomPathToggle'); + if (customToggle) customToggle.checked = false; + const customRow = document.getElementById('cloneCustomPathToggleRow'); + const me = window.__codemanUser || {}; + if (customRow) customRow.style.display = me.multiUser && me.role !== 'admin' ? 'none' : ''; + this.toggleCloneCustomPath(); const shallow = document.getElementById('cloneShallow'); if (shallow) shallow.checked = false; const start = document.getElementById('cloneStartSession'); @@ -4124,6 +4133,52 @@ Object.assign(CodemanApp.prototype, { } }, + /** Custom-folder row for Clone Repo: shows or hides the parent-folder field and rewords the name hint. */ + toggleCloneCustomPath() { + const custom = document.getElementById('cloneCustomPathToggle'); + const row = document.getElementById('cloneCustomPathRow'); + if (!custom || !row) return; + row.style.display = custom.checked ? '' : 'none'; + const hint = document.getElementById('cloneCaseNameHint'); + if (hint) { + hint.textContent = custom.checked + ? 'Filled in from the URL. Cloned into a folder of this name inside the parent folder below.' + : 'Filled in from the URL. Cloned into ~/codeman-cases/, so deleting the case deletes this working tree.'; + } + this.updateCloneCasePathPreview(); + }, + + /** The folder the clone would be created in: the parent field plus the case name. */ + _cloneTargetPath() { + const rawParent = (document.getElementById('cloneCasePath')?.value || '').trim(); + const name = (document.getElementById('cloneCaseName')?.value || '').trim(); + if (!rawParent || !name) return ''; + // Trailing slashes off, but `/` stays the root rather than becoming an empty path. + return `${rawParent.replace(/\/+$/, '')}/${name}`; + }, + + updateCloneCasePathPreview() { + const hint = document.getElementById('cloneCasePathPreview'); + if (!hint) return; + const target = this._cloneTargetPath(); + hint.textContent = target ? `Will clone into: ${target}` : 'Pick the folder the clone should be created inside.'; + }, + + openCloneCasePathPicker() { + const input = document.getElementById('cloneCasePath'); + PathPicker.open({ + title: 'Choose the folder to clone into', + initialPath: input.value.trim(), + directoriesOnly: true, + onSelect: (path) => { + input.value = path; + this.updateCloneCasePathPreview(); + input.focus(); + input.setSelectionRange(path.length, path.length); + }, + }); + }, + _setCloneStatus(message, kind) { const el = document.getElementById('cloneRepoStatus'); if (!el) return; @@ -4159,12 +4214,14 @@ Object.assign(CodemanApp.prototype, { onCloneNameEdited() { // Once the user types a name, autofill stops fighting them. this._cloneNameEdited = !!document.getElementById('cloneCaseName')?.value.trim(); + this.updateCloneCasePathPreview(); }, onCloneUrlInput() { const url = document.getElementById('cloneRepoUrl')?.value.trim() || ''; const nameInput = document.getElementById('cloneCaseName'); if (nameInput && !this._cloneNameEdited) nameInput.value = this._repoNameFromUrl(url); + this.updateCloneCasePathPreview(); clearTimeout(this._clonePreflightTimer); this._clonePreflightAbort?.abort(); this._clonePreflightAbort = null; @@ -4220,6 +4277,7 @@ Object.assign(CodemanApp.prototype, { // the case name is validated against), but never over a name the user typed. const nameInput = document.getElementById('cloneCaseName'); if (nameInput && !this._cloneNameEdited && parse.suggestedName) nameInput.value = parse.suggestedName; + this.updateCloneCasePathPreview(); const where = parse.owner ? `${parse.provider} ${parse.owner}/${parse.repo}` : `${parse.provider} ${parse.repo}`; if (data.gitAvailable === false) { @@ -4272,6 +4330,12 @@ Object.assign(CodemanApp.prototype, { return; } + const customFolder = !!document.getElementById('cloneCustomPathToggle')?.checked; + if (customFolder && !(document.getElementById('cloneCasePath')?.value || '').trim()) { + this.showToast('Choose the folder to clone into', 'error'); + return; + } + this._setCloneStatus(`Cloning ${url}… this can take a while for a large repository.`, ''); try { const res = await fetch('/api/cases/clone', { @@ -4279,7 +4343,13 @@ Object.assign(CodemanApp.prototype, { headers: { 'Content-Type': 'application/json' }, // Zod `.optional()` rejects an explicit null, and JSON.stringify keeps one // on the wire — omit the empty fields instead of sending null. - body: JSON.stringify({ name, repository: url, ...(ref ? { ref } : {}), ...(shallow ? { shallow: true } : {}) }), + body: JSON.stringify({ + name, + repository: url, + ...(ref ? { ref } : {}), + ...(shallow ? { shallow: true } : {}), + ...(customFolder ? { path: this._cloneTargetPath() } : {}), + }), }); const data = await res.json(); if (!data.success) { diff --git a/src/web/routes/case-routes.ts b/src/web/routes/case-routes.ts index b0849767..51c09a39 100644 --- a/src/web/routes/case-routes.ts +++ b/src/web/routes/case-routes.ts @@ -686,7 +686,7 @@ export function registerCaseRoutes( warnings: string[]; }> > => { - const { name, repository, ref, shallow, description } = parseBody(CloneCaseSchema, req.body); + const { name, repository, ref, shallow, description, path: customPath } = parseBody(CloneCaseSchema, req.body); const user = getAuthUser(req); const parsed = parseGitRepositoryUrl(repository); @@ -705,8 +705,63 @@ export function registerCaseRoutes( ); } + // 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; + } + const casesDir = resolveCasesDir(user); - const casePath = validatePathWithinBase(name, casesDir); + let casePath: string | null; + if (customPath !== undefined) { + const prepared = await prepareNewCasePath(customPath, { + home: homedir(), + dataDir: getDataDir(), + casesDirs: [...new Set([casesDir, resolveCasesDir()])], + }); + if (!prepared.ok) { + reply.code( + prepared.code === 'UNREACHABLE' + ? 422 + : prepared.code === 'NOT_FOUND' + ? 404 + : prepared.code === 'EXISTS' + ? 409 + : 400 + ); + return createErrorResponse( + prepared.code === 'UNREACHABLE' + ? ApiErrorCode.OPERATION_FAILED + : prepared.code === 'NOT_FOUND' + ? ApiErrorCode.NOT_FOUND + : prepared.code === 'EXISTS' + ? ApiErrorCode.ALREADY_EXISTS + : ApiErrorCode.INVALID_INPUT, + prepared.reason + ); + } + // git clones into a fresh folder it creates itself; an empty existing one is refused rather than + // removed and re-made (the removal could race another writer). + if (prepared.existedEmpty) { + reply.code(409); + return createErrorResponse( + ApiErrorCode.ALREADY_EXISTS, + `${prepared.path} already exists. Pick a parent folder where the case folder does not exist yet.` + ); + } + casePath = prepared.path; + // The same folder listed as a local case in the cases dir would make deleting it remove files. + if (existsSync(join(casesDir, name))) { + reply.code(409); + return createErrorResponse( + ApiErrorCode.ALREADY_EXISTS, + 'A case with this name already exists in codeman-cases.' + ); + } + } else { + casePath = validatePathWithinBase(name, casesDir); + } if (!casePath) return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path'); // Reject a duplicate name across EVERY case kind before invoking git, so a @@ -715,6 +770,11 @@ export function registerCaseRoutes( const linkedCases = await readLinkedCases(); const dockerCases = await readDockerCases(CODEMAN_CONFIG_DIR); const remoteCases = await readRemoteCases(CODEMAN_CONFIG_DIR); + const linkedAs = Object.entries(linkedCases).find(([, p]) => p === casePath)?.[0]; + if (linkedAs && customPath !== undefined) { + reply.code(409); + return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, `That folder is already the case "${linkedAs}"`); + } if ( existsSync(casePath) || linkedCases[name] || @@ -724,11 +784,14 @@ export function registerCaseRoutes( return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Case already exists'); } - // git creates the leaf, not necessarily the case space above it. - try { - mkdirSync(casesDir, { recursive: true }); - } catch (err) { - return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); + // git creates the leaf, not necessarily the case space above it (a custom folder's parent + // already exists: prepareNewCasePath refused a missing one). + if (customPath === undefined) { + try { + mkdirSync(casesDir, { recursive: true }); + } catch (err) { + return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); + } } const clone = await cloneRepository({ @@ -794,6 +857,24 @@ export function registerCaseRoutes( warnings.push(`Case scaffolding was incomplete: ${getErrorMessage(err)}`); } + if (customPath !== undefined) { + // Re-read right before writing so a case linked while git ran is not dropped. Like + // POST /api/cases/link the write is not serialized, so this only narrows the window. + try { + const fresh = await readLinkedCases(); + if (fresh[name]) throw new Error(`Case "${name}" was linked while the clone ran`); + fresh[name] = casePath; + await fs.writeFile(LINKED_CASES_FILE, JSON.stringify(fresh, null, 2)); + } catch (err) { + // The clone itself succeeded: keep the folder (the user waited for it) and say where it is. + reply.code(500); + return createErrorResponse( + ApiErrorCode.OPERATION_FAILED, + `Cloned into ${casePath} but could not register the case (${getErrorMessage(err)}). Use Link Existing to add it.` + ); + } + } + ctx.broadcast(SseEvent.CaseCreated, { name, path: casePath }); return { success: true, diff --git a/src/web/schemas.ts b/src/web/schemas.ts index 6c1ce474..13a8b038 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -785,6 +785,12 @@ export const CloneCaseSchema = z.object({ /** `--depth 1`. */ shallow: z.boolean().optional(), description: z.string().max(1000).optional(), + /** + * Clone into this exact folder (parent + case name) instead of under the cases directory, and + * register it as a linked case. Absolute or starting with `~`; only length-bounded here, since + * `prepareNewCasePath()` (web/case-path.ts) judges it and produces the user-facing reason. + */ + path: z.string().min(1).max(1000).optional(), }); /** Schema for POST /api/cases/clone-preflight — ask the remote what it has, clone nothing. */ diff --git a/test/routes/case-clone-custom-folder.test.ts b/test/routes/case-clone-custom-folder.test.ts new file mode 100644 index 00000000..0c700387 --- /dev/null +++ b/test/routes/case-clone-custom-folder.test.ts @@ -0,0 +1,169 @@ +/** + * @fileoverview POST /api/cases/clone with a `path`: clone into a custom folder and register it as a + * linked case. Real filesystem under test/setup.ts's temp HOME and a REAL `git` cloning a local bare + * repo (the path policy itself is in test/case-path.test.ts). Port: N/A (app.inject()). + * + * Like case-custom-path-routes.test.ts this suite rewrites the linked-cases registry, so it refuses to + * start outside the sandboxed HOME (a raw `npx vitest` would reach the real ~/.codeman). + */ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { homedir, tmpdir } 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'; +import { isGitAvailable } from '../../src/git-clone.js'; +import { isSandboxedHome, safeRmHomeTree } from '../mocks/index.js'; + +const LINKED = () => dataPath('linked-cases.json'); +const linked = (): Record => (existsSync(LINKED()) ? JSON.parse(readFileSync(LINKED(), 'utf8')) : {}); +const clone = (app: Awaited>['app'], payload: Record) => + app.inject({ method: 'POST', url: '/api/cases/clone', payload }); + +describe.skipIf(!isGitAvailable())('POST /api/cases/clone with a custom folder', () => { + let root: string; + let origin: string; + let workDir = ''; + + const git = (args: string[], cwd: string) => + execFileSync('git', args, { cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] }); + + beforeAll(() => { + root = mkdtempSync(join(tmpdir(), 'codeman-clone-custom-')); + origin = join(root, 'origin.git'); + mkdirSync(origin); + git(['init', '--bare', '--quiet'], origin); + const work = join(root, 'work'); + mkdirSync(work); + git(['init', '--quiet'], work); + git(['config', 'user.email', 'test@example.com'], work); + git(['config', 'user.name', 'Codeman Test'], work); + writeFileSync(join(work, 'README.md'), '# fixture\n'); + git(['add', '.'], work); + git(['commit', '--quiet', '-m', 'initial'], work); + git(['branch', '-M', 'main'], work); + git(['remote', 'add', 'origin', origin], work); + git(['push', '--quiet', 'origin', 'main'], work); + git(['symbolic-ref', 'HEAD', 'refs/heads/main'], origin); + }); + afterAll(() => rmSync(root, { recursive: true, force: true })); + + beforeEach(() => { + if (!isSandboxedHome()) { + throw new Error('case-clone-custom-folder.test.ts rewrites the linked-cases registry: run it via npm test'); + } + safeRmHomeTree(LINKED()); + // Resolved, because the server answers with the symlink-resolved folder. + workDir = realpathSync(mkdtempSync(join(homedir(), 'clone-custom-'))); + }); + afterEach(() => { + delete process.env.CODEMAN_MULTIUSER; + if (workDir) safeRmHomeTree(workDir); + workDir = ''; + if (isSandboxedHome()) safeRmHomeTree(LINKED()); + }); + + it('clones into the chosen folder, scaffolds it and registers it as a linked case', async () => { + const { app, ctx } = await createRouteTestHarness(registerCaseRoutes); + const target = join(workDir, 'my-clone'); + const res = await clone(app, { name: 'my-clone', repository: origin, path: target }); + expect(res.statusCode).toBe(200); + expect(res.json().data.case).toEqual({ name: 'my-clone', path: target }); + expect(existsSync(join(target, 'README.md'))).toBe(true); + expect(existsSync(join(target, '.git'))).toBe(true); + expect(existsSync(join(target, '.claude', 'settings.local.json'))).toBe(true); + expect(linked()).toEqual({ 'my-clone': target }); + expect(ctx.broadcast).toHaveBeenCalledWith('case:created', { name: 'my-clone', path: target }); + // Nothing lands under the cases directory. + expect(existsSync(join(homedir(), 'codeman-cases', 'my-clone'))).toBe(false); + }); + + it('expands ~ in the folder', async () => { + const { app } = await createRouteTestHarness(registerCaseRoutes); + const folder = workDir.slice(homedir().length + 1); + const res = await clone(app, { name: 'tilde', repository: origin, path: `~/${folder}/tilde` }); + expect(res.statusCode).toBe(200); + expect(existsSync(join(workDir, 'tilde', 'README.md'))).toBe(true); + }); + + it('refuses a parent folder that does not exist, and creates nothing', async () => { + const { app } = await createRouteTestHarness(registerCaseRoutes); + const res = await clone(app, { name: 'orphan', repository: origin, path: join(workDir, 'nope', 'orphan') }); + expect(res.statusCode).toBe(404); + expect(existsSync(join(workDir, 'nope'))).toBe(false); + expect(linked()).toEqual({}); + }); + + it('refuses a target folder that already has files, and leaves them alone', async () => { + const { app } = await createRouteTestHarness(registerCaseRoutes); + const target = join(workDir, 'busy'); + mkdirSync(target); + writeFileSync(join(target, 'mine.txt'), 'keep me'); + const res = await clone(app, { name: 'busy', repository: origin, path: target }); + expect(res.statusCode).toBe(409); + expect(readFileSync(join(target, 'mine.txt'), 'utf8')).toBe('keep me'); + expect(linked()).toEqual({}); + }); + + it('refuses an existing EMPTY folder rather than removing and re-making it', async () => { + const { app } = await createRouteTestHarness(registerCaseRoutes); + const target = join(workDir, 'empty'); + mkdirSync(target); + const res = await clone(app, { name: 'empty', repository: origin, path: target }); + expect(res.statusCode).toBe(409); + expect(res.json().error).toMatch(/already exists/); + expect(existsSync(join(target, '.git'))).toBe(false); + }); + + it('refuses a blocked tree such as ~/.ssh', async () => { + const { app } = await createRouteTestHarness(registerCaseRoutes); + mkdirSync(join(homedir(), '.ssh'), { recursive: true }); + const res = await clone(app, { name: 'sneaky', repository: origin, path: join(homedir(), '.ssh', 'sneaky') }); + expect(res.statusCode).toBe(400); + expect(existsSync(join(homedir(), '.ssh', 'sneaky'))).toBe(false); + }); + + it('refuses a name already linked, before running git', async () => { + const { app } = await createRouteTestHarness(registerCaseRoutes); + writeFileSync(LINKED(), JSON.stringify({ taken: join(workDir, 'elsewhere') })); + const res = await clone(app, { name: 'taken', repository: origin, path: join(workDir, 'taken') }); + // The harness has no preSerialization status mapping, so read the envelope's code. + expect(res.json().errorCode).toBe('ALREADY_EXISTS'); + expect(existsSync(join(workDir, 'taken'))).toBe(false); + }); + + it('leaves no folder behind when the clone fails', async () => { + const { app } = await createRouteTestHarness(registerCaseRoutes); + const target = join(workDir, 'ghost'); + const res = await clone(app, { name: 'ghost', repository: join(root, 'no-such-repo.git'), path: target }); + expect(res.json().errorCode).toBe('NOT_FOUND'); + expect(existsSync(target)).toBe(false); + expect(linked()).toEqual({}); + }); + + it('without a path the clone still goes under the cases directory and is not linked', async () => { + const { app } = await createRouteTestHarness(registerCaseRoutes); + const res = await clone(app, { name: 'custom-folder-default', repository: origin }); + try { + expect(res.statusCode).toBe(200); + expect(res.json().data.case.path).toBe(join(homedir(), 'codeman-cases', 'custom-folder-default')); + expect(linked()).toEqual({}); + } finally { + safeRmHomeTree(join(homedir(), 'codeman-cases', 'custom-folder-default')); + } + }); + + it('multi-user: a non-admin is refused (403) and nothing is cloned; an admin is allowed', async () => { + process.env.CODEMAN_MULTIUSER = '1'; + const user = await createRouteTestHarness(registerCaseRoutes, { authUser: { username: 'bob', role: 'user' } }); + const denied = await clone(user.app, { name: 'bobs', repository: origin, path: join(workDir, 'bobs') }); + expect(denied.statusCode).toBe(403); + expect(existsSync(join(workDir, 'bobs'))).toBe(false); + expect(linked()).toEqual({}); + const admin = await createRouteTestHarness(registerCaseRoutes, { authUser: { username: 'root', role: 'admin' } }); + const ok = await clone(admin.app, { name: 'roots', repository: origin, path: join(workDir, 'roots') }); + expect(ok.statusCode).toBe(200); + }); +});