From ab0bc0aecdb4c4370c1e9d370dd706f175f84cee Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:27:30 +0800 Subject: [PATCH] feat(cases): rename a case and its folder, with a lock and live-session handling --- .changeset/rename-cases.md | 5 + CLAUDE.md | 2 + docs/api-reference.md | 3 + docs/wiki/Core-Concepts.md | 17 +++ src/case-rename.ts | 171 +++++++++++++++++++++++ src/session.ts | 6 + src/web/public/constants.js | 1 + src/web/public/session-ui.js | 67 +++++++++ src/web/routes/case-routes.ts | 140 +++++++++++++++++++ src/web/schemas.ts | 15 +++ src/web/sse-events.ts | 3 + test/case-rename-ui.test.ts | 110 +++++++++++++++ test/case-rename.test.ts | 165 +++++++++++++++++++++++ test/routes/case-rename-routes.test.ts | 179 +++++++++++++++++++++++++ 14 files changed, 884 insertions(+) create mode 100644 .changeset/rename-cases.md create mode 100644 src/case-rename.ts create mode 100644 test/case-rename-ui.test.ts create mode 100644 test/case-rename.test.ts create mode 100644 test/routes/case-rename-routes.test.ts diff --git a/.changeset/rename-cases.md b/.changeset/rename-cases.md new file mode 100644 index 00000000..031b69cb --- /dev/null +++ b/.changeset/rename-cases.md @@ -0,0 +1,5 @@ +--- +"aicodeman": minor +--- + +Rename a case and its folder from the case manager. A case under `~/codeman-cases` is renamed as a name and folder; a linked case can rename only its name or its real folder too. Live sessions that have the folder open are listed and only stopped if you agree, the folder is locked while it moves so nothing starts in it half-moved, a folder another program holds open is reported instead of forced, and Claude Code's conversation history, the case order and the last used case follow the new name. diff --git a/CLAUDE.md b/CLAUDE.md index 02704d0f..973b8783 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -181,6 +181,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **DeepSeek web UI** (`POST`/`GET`/`DELETE /api/deepseek/web`, `deepseek-web-server.ts`): the Run menu's "DeepSeek web UI..." entry supervises ONE background `dsh web` child process, deliberately **NOT a shell session**. ⚠️ Every piece is load-bearing: exactly one server (a second click REUSES it), restarted when the browser authority changes (`--trusted-host`, last asker wins), killed on shutdown via `stopDeepSeekWeb()` (the detached child would otherwise outlive Codeman and hold its port), and failures returned to the caller. ⚠️ Never hardcode the port: search from 3080 across 40, detect free ports by BINDING, then wait for the server to really answer. ⚠️ Both `POST` and `DELETE` must stay behind `canUsernameRunPrivilegedCommands` (booting a profile runs its plugin code; the server is shared). → [architecture-invariants#deepseek-web-ui](docs/architecture-invariants.md#deepseek-web-ui) +**Case rename** (`POST /api/cases/:name/rename`, `src/case-rename.ts`; `docs/wiki/Core-Concepts.md#case`): a case under the cases dir is its folder name and always moves; a linked case renames its alias and moves its real folder only with `renameFolder`; remote and Docker cases are refused. ⚠️ A folder that sessions have open is the hazard: live sessions at or under it make the route answer `409 requiresStop` with their names, and only `stopSessions: true` ends them (via `ctx.cleanupSession`, nothing is restarted). `withCaseLock` holds the old and new paths for the whole operation (`isCasePathLocked`; the `Session` constructor throws for a working directory inside a held path, so nothing starts mid-move), `moveCaseFolder` refuses an existing target itself (POSIX `rename` would silently replace an EMPTY directory) and retries `EBUSY`/`EPERM`/`EACCES` before answering `423 LOCKED`. If the registries (`linked-cases.json`, `caseOrder`, `lastUsedCase`) fail after the move, the folder is moved back. Claude Code keys history by path (`claudeProjectDirName`: every non-alphanumeric becomes `-`), so `/projects/` is renamed too; other CLIs' history, Docker bind mounts and `git worktree` links are NOT migrated. + **Custom Model Endpoint Profiles** (opt-in, `customModelEndpointsEnabled`, SYNCED, default OFF; `docs/custom-model-endpoints.md`): points a session at a user-configured OpenAI-compatible endpoint (store `custom-model-hosts.ts`, `~/.codeman/custom-model-hosts.json`, 0600). `authStyle` is `bearer` or `api-key`, never both headers (hangs the server). The per-CLI redirect is registry data, `capabilities.customModelInjection` (`env` / `configContentEnv` / `configDir` / `unsupported`), computed by the pure `custom-model-injection.ts`; ⚠️ `configDir` writes an isolated per-session config, NEVER the user's real `~/.codex`/`~/.pi`/`~/.omp`/grok config. ⚠️ Claude applies via `Session.restartCli()` (a relaunch in the existing pane, so the conversation is pinned as `resumeSessionId`), the rest one-shot at launch; retired env keys must also be `setenv -u`'d (`_pendingEnvUnsets`), since tmux env survives `respawn-pane`. ⚠️ Remote/Docker sessions are refused (400). ⚠️ Persist only KEYS (`__customModel`), never values (they carry the API key). ⚠️ Every redirectable var must be in that CLI's `privilegedEnvKeys`, and `ANTHROPIC_*` stays out of claude's `allowedPrefixes`. ⚠️ The Run-menu picker builds entries from `window.__codemanCustomModelClis` (escaped via `escapeScriptJson()`), never a hardcoded CLI id list, and launches through `run()` via a temporary `_runMode` swap, never `setRunMode()`. → [architecture-invariants#custom-model-endpoint-profiles](docs/architecture-invariants.md#custom-model-endpoint-profiles) ⚠️ **llama-swap endpoints** (one model at a time): the apply routes check `GET /running` and return `requiresConfirmation` before evicting a model another live session uses; `confirmedSwap` and `confirmedContext` are SEPARATE flags and must stay so. Claude alone gets a context floor (`CLAUDE_MIN_SAFE_CONTEXT_TOKENS`); context is parsed from `/running`'s `cmd`, never trusted from `/props`. The floor also divides that context by the model's parallel slot count (`modelSlotCounts`: `--parallel`/`-np` from `cmd`, else `/props` `total_slots`; `perSlotContext()`), and `/props` only ever RAISES that warning: it supplies the context only when `cmd` states none and the count only when `cmd` has no `--parallel`, never a larger pool. `/props` is probed once per launch command (it refreshes llama-swap's last-use time, so a `ttl` would never expire), and a `/props` context beside an explicit `--parallel N` is already per slot, so it is not divided again. Backend log lines come from llama-swap's `/api/events` `upstream` source, never `/logs`. → [architecture-invariants#custom-model-endpoint-profiles](docs/architecture-invariants.md#custom-model-endpoint-profiles) diff --git a/docs/api-reference.md b/docs/api-reference.md index 32774fbf..09b58a86 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1129,11 +1129,14 @@ A local case whose folder is the top of a git repository can keep **worktrees** | `PUT` | `/api/settings` | partial settings (strict schema) | Merged into the stored file; services (watchers, tunnel, tmux history limit) reconcile to the merged result. `403 FORBIDDEN` for a non-admin in multi-user mode. A tunnel enable without `CODEMAN_PASSWORD` is refused separately (`403 OPERATION_FAILED`, resend with `acknowledgeUnauthTunnel: true`). | | `GET` | `/api/execution/model-config` | none | `modelConfig` (cron's and the plan orchestrator's default model). | | `PUT` | `/api/execution/model-config` | `{ defaultModel?, agentTypeOverrides?, ... }` | Replaces `modelConfig`. `403 FORBIDDEN` for a non-admin in multi-user mode. | +| `POST` | `/api/cases/:name/rename` | `{ newName, renameFolder?, stopSessions? }` (strict) | Rename a case and its folder (see below). | | `PUT` | `/api/cases/order` | `{ order: string[] }` | The one case order every user's `GET /api/cases` is sorted by. `403 FORBIDDEN` for a non-admin in multi-user mode. | | `PUT` | `/api/config` | `{ pollIntervalMs?, defaultTimeoutMs?, maxConcurrentSessions?, respawn? }` (strict) | The process-wide app config in `state.json`. `403 FORBIDDEN` for a non-admin in multi-user mode. | A non-admin's `POST /api/quick-start` and `POST /api/ralph-loop/start` work as before but do not record their case as `lastUsedCase` (a server-wide key). +`POST /api/cases/:name/rename` renames a case. A case under the cases directory is its folder, so it always moves; a linked case renames its alias in `linked-cases.json` and moves its real folder (next to the old one, named `newName`) only with `renameFolder: true`. Remote and Docker cases are `400`. The new name must be free in every namespace the list merges (`409`). Live sessions whose working directory is at or inside the folder that would move make it `409` with `requiresStop: true` and `sessions: [{ id, name }]`; retry with `stopSessions: true` to end them first (nothing is restarted); another user's session is `403`. The folder is held by an in-process lock for the whole operation (an overlapping rename is `409 BUSY`, and `new Session` refuses a working directory inside it), the move is retried for a transient `EBUSY`/`EPERM`/`EACCES` and then answers `423` with `renameCode: "LOCKED"`, a cross-device move is `400`, and if the registries cannot be written afterwards the folder is moved back. On success the response is `{ oldName, newName, oldPath, newPath, folderMoved, stoppedSessions }`; `caseOrder`, `lastUsedCase`, `linked-cases.json` and Claude Code's `projects/` history are updated, and `case:renamed` is broadcast. A linked case is admin only in multi-user mode. + ### System operations The same multi-user rule covers the operations that act on the whole server. For a non-admin: diff --git a/docs/wiki/Core-Concepts.md b/docs/wiki/Core-Concepts.md index ae5ac504..ab3d50b3 100644 --- a/docs/wiki/Core-Concepts.md +++ b/docs/wiki/Core-Concepts.md @@ -31,6 +31,23 @@ Three ways to get one, all under **New or link a case…** at the bottom of the Linked cases keep living where they are. Deleting a case in Codeman removes the registration, and for a linked case that is all it removes. +**Renaming a case.** In the case manager (Settings, or the case picker's manage view) the pencil +button renames a case and its folder. A case under `~/codeman-cases` *is* its folder name, so it +always moves. A linked case renames its name in the list only; Codeman then asks whether to rename +the real folder as well, next to where it is (Cancel leaves the folder alone). Remote and Docker +cases cannot be renamed. Renaming does the following for you: + +- **Sessions are never moved from under you.** If live sessions have the folder open, the rename is + refused and lists them; you can then choose to stop them (nothing is restarted). While a rename runs, + Codeman holds the folder so no session can start inside it half-moved. +- **A folder another program holds open is reported, not forced.** On Windows, SMB shares and some FUSE + mounts a directory with an open handle cannot be renamed; Codeman retries briefly, then says the folder + is in use and to close whatever has it (a terminal, an editor, a file browser, a sync client). +- **Claude Code's history follows.** It keys conversations by the folder's path, so the rename moves + `/projects/` to the new path and `--resume` still finds them. Other CLIs' + history, Docker mounts and `git worktree` links that point at the old path are not updated. +- **The case order, the last used case and this browser's per-case options follow the new name.** + **Clone Repo never asks for credentials.** It uses whatever the server's own git already has: an ssh key, or a credential helper such as `gh auth setup-git`. The Docker image can include helpers for GitHub (`gh`) and Azure DevOps (`az`), turned on in `docker-compose.override.yml`; diff --git a/src/case-rename.ts b/src/case-rename.ts new file mode 100644 index 00000000..6c0ece4f --- /dev/null +++ b/src/case-rename.ts @@ -0,0 +1,171 @@ +/** + * @fileoverview Renaming a case (its name and its folder) without losing what points at the folder. + * + * A case folder is held in more places than an open file. A running agent has it as its working + * directory and its tmux pane was started `cd`-ed into it; Claude Code keys its conversation history + * by the folder's path (`/projects/`), so a moved + * folder silently orphans every conversation; on Windows, SMB and some FUSE mounts a directory with an + * open handle simply cannot be renamed (EBUSY / EPERM / EACCES). This module owns the three parts of + * that which are not route logic: + * + * - the in-process lock (`withCaseLock` / `isCasePathLocked`) that keeps a session from starting in a + * folder while it is being moved, and two renames from overlapping; + * - the move itself, retried a few times for a transient lock and turned into a message that says + * what to do when it is a real one; + * - carrying Claude Code's history for the folder over to the new path. + * + * Pure of the server: every function takes the paths it works on. + * + * @module case-rename + */ + +import fs from 'node:fs/promises'; +import { dirname, join, resolve, sep } from 'node:path'; +import { claudeProjectsDir } from './utils/claude-transcript.js'; + +export type CaseRenameErrorCode = + | 'INVALID' // a name that is not allowed, or the same name + | 'EXISTS' // the new name or folder is already taken + | 'BUSY' // another rename (or a session being stopped) holds the folder + | 'LOCKED' // the OS refused the move: something still has the folder open + | 'CROSS_DEVICE' // the move would cross filesystems + | 'UNSUPPORTED'; // remote and Docker cases + +export class CaseRenameError extends Error { + constructor( + readonly code: CaseRenameErrorCode, + message: string + ) { + super(message); + this.name = 'CaseRenameError'; + } +} + +const locked = new Set(); + +const within = (child: string, parent: string): boolean => child === parent || child.startsWith(parent + sep); + +/** True when `path` is, or is inside, a folder that is being renamed right now. */ +export function isCasePathLocked(path: string): boolean { + const target = resolve(path); + for (const held of locked) { + if (within(target, held)) return true; + } + return false; +} + +/** + * Hold `from` and `to` for the length of `fn`. A second caller touching either (or anything inside + * them) gets `BUSY` instead of racing the move; `Session`'s constructor refuses a working directory + * inside a held folder, so nothing starts in a case that is half-moved. + */ +export async function withCaseLock(from: string, to: string, fn: () => Promise): Promise { + const held = [resolve(from), resolve(to)]; + for (const p of held) { + for (const other of locked) { + if (within(p, other) || within(other, p)) { + throw new CaseRenameError('BUSY', 'Another rename is already working on this case. Try again in a moment.'); + } + } + } + for (const p of held) locked.add(p); + try { + return await fn(); + } finally { + for (const p of held) locked.delete(p); + } +} + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); +const LOCK_CODES = new Set(['EBUSY', 'EPERM', 'EACCES']); + +/** + * `rename(from, to)` for a folder that must not already exist at `to`. A lock error is retried (a + * handle another process is closing, an antivirus scan) before it is reported as locked. + * `fs.rename` onto an EMPTY existing directory replaces it on POSIX, so the target is checked first. + */ +export async function moveCaseFolder(from: string, to: string, attempts = 4, delayMs = 150): Promise { + if ( + await fs.lstat(to).then( + () => true, + () => false + ) + ) { + throw new CaseRenameError('EXISTS', `A folder named ${to} already exists.`); + } + for (let i = 0; ; i++) { + try { + await fs.rename(from, to); + return; + } catch (err) { + const code = (err as NodeJS.ErrnoException).code ?? ''; + if (code === 'EXDEV') { + throw new CaseRenameError( + 'CROSS_DEVICE', + 'The new folder would be on a different drive or mount; it is not moved.' + ); + } + if (LOCK_CODES.has(code)) { + if (i < attempts - 1) { + await sleep(delayMs * (i + 1)); + continue; + } + throw new CaseRenameError( + 'LOCKED', + `The folder is in use by another program (${code}), so it could not be renamed. Close anything that has it open ` + + '(a terminal, an editor, a file browser or a sync client), or stop the sessions using it, and try again.' + ); + } + if (code === 'ENOTEMPTY' || code === 'EEXIST') { + throw new CaseRenameError('EXISTS', `A folder named ${to} already exists.`); + } + throw err; + } + } +} + +/** The directory name Claude Code uses for a project path: every non-alphanumeric becomes `-`. */ +export function claudeProjectDirName(path: string): string { + return resolve(path).replace(/[^a-zA-Z0-9]/g, '-'); +} + +/** + * Move Claude Code's history for the old folder to the new one, so `--resume` and the past-sessions list + * still find the conversations. Leaves it alone when the new path already has history (nothing is merged + * or overwritten) or when there is none. Returns whether anything moved. + */ +export async function moveClaudeHistory(oldPath: string, newPath: string, configDir?: string): Promise { + const projects = claudeProjectsDir(configDir); + const from = join(projects, claudeProjectDirName(oldPath)); + const to = join(projects, claudeProjectDirName(newPath)); + if (from === to) return false; + if ( + !(await fs.lstat(from).then( + () => true, + () => false + )) + ) + return false; + if ( + await fs.lstat(to).then( + () => true, + () => false + ) + ) + return false; + try { + await fs.rename(from, to); + return true; + } catch { + return false; // history is a convenience; the folder move already succeeded + } +} + +/** Replace `from` by `to` in a list, keeping its position (the case order). */ +export function renameInOrder(order: unknown, from: string, to: string): string[] | null { + if (!Array.isArray(order) || !order.includes(from)) return null; + return order.map((n) => (n === from ? to : n)) as string[]; +} + +/** The folder a linked case moves to: its own parent, the new name. */ +export const siblingPath = (path: string, newName: string): string => join(dirname(path), newName); diff --git a/src/session.ts b/src/session.ts index 8ded0775..18f6ac32 100644 --- a/src/session.ts +++ b/src/session.ts @@ -151,6 +151,7 @@ import { } from './session-attachment-history.js'; import type { SessionAttachmentHistoryItem, DisplayModel } from './types/session.js'; import { resolveConfigModel } from './model-config-resolvers.js'; +import { isCasePathLocked } from './case-rename.js'; import { legacyConfigForMode } from './session-cli-registry-bridge.js'; import { launchModelFor, @@ -948,6 +949,11 @@ export class Session extends EventEmitter { } ) { super(); + // A case folder being renamed (src/case-rename.ts) must not gain a session mid-move: its pane would + // start in a directory that is about to have a different path. + if (config.workingDir && isCasePathLocked(config.workingDir)) { + throw new Error(`The case folder ${config.workingDir} is being renamed. Try again in a moment.`); + } this.setMaxListeners(25); // Default error handler prevents unhandled 'error' events from crashing the process. diff --git a/src/web/public/constants.js b/src/web/public/constants.js index 4cb2f099..67f83b89 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -1914,6 +1914,7 @@ const SSE_EVENTS = { CASE_CREATED: 'case:created', CASE_LINKED: 'case:linked', CASE_DELETED: 'case:deleted', + CASE_RENAMED: 'case:renamed', CASE_ORDER_CHANGED: 'case:order-changed', DOCKER_EXPORT_COMPLETE: 'docker:exportComplete', DOCKER_EXPORT_FAILED: 'docker:exportFailed', diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index 5215d54d..ecd82e3c 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -4957,6 +4957,12 @@ Object.assign(CodemanApp.prototype, { title="${reorderTitle || 'Move up'}" ${isFirst ? 'disabled' : ''}>▲ + ${ + c.location === 'docker' || c.location === 'remote' + ? '' + : `` + } @@ -4989,6 +4995,67 @@ Object.assign(CodemanApp.prototype, { await this.saveCaseOrder(reordered.map(c => c.name)); }, + /** + * Rename a case and its folder (POST /api/cases/:name/rename). A linked case asks whether the real folder + * should be renamed too (Cancel renames only the alias). When live sessions have the folder open the server + * refuses and lists them; they are only stopped after a second, explicit confirmation. + */ + async renameCase(name) { + const info = (this.cases || []).find((c) => c.name === name); + const entered = prompt(`Rename case "${name}" to (letters, numbers, - and _):`, name); + const newName = (entered || '').trim(); + if (!newName || newName === name) return; + if (!/^[a-zA-Z0-9_-]+$/.test(newName)) { + this.showToast('Use only letters, numbers, hyphens and underscores', 'error'); + return; + } + const body = { newName }; + if (info?.linked) { + body.renameFolder = confirm( + `Also rename the folder on disk?\n\n${info.path}\n\nOK renames the folder to "${newName}" next to it. ` + + `Cancel keeps the folder and only changes the case name.` + ); + } + const send = async () => { + const res = await fetch(`/api/cases/${encodeURIComponent(name)}/rename`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + return res.json().catch(() => ({ success: false, error: 'Unexpected response' })); + }; + try { + let data = await send(); + if (data.requiresStop) { + const list = (data.sessions || []).map((s) => ` - ${s.name}`).join('\n'); + if (!confirm(`These sessions have the folder open and will be stopped (nothing is restarted):\n\n${list}\n\nStop them and rename?`)) { + return; + } + body.stopSessions = true; + data = await send(); + } + if (!data.success) { + this.showToast(data.error || 'Failed to rename case', 'error'); + return; + } + this.showToast(`Case renamed to "${newName}"`, 'success'); + // The per-case run options are stored under the case name in this browser. + try { + const stored = localStorage.getItem('caseSettings_' + name); + if (stored !== null) { + localStorage.setItem('caseSettings_' + newName, stored); + localStorage.removeItem('caseSettings_' + name); + } + } catch { /* storage blocked: the options fall back to their defaults */ } + const select = document.getElementById('quickStartCase'); + const current = select?.value; + await this.loadQuickStartCases(current === name ? newName : current); + this.renderCaseManageList(); + } catch (err) { + this.showToast('Failed to rename case: ' + err.message, 'error'); + } + }, + async deleteCase(name) { if (!confirm(`Delete case "${name}"? Linked cases will only be unlinked (folder preserved). Created cases will be permanently deleted.`)) { return; diff --git a/src/web/routes/case-routes.ts b/src/web/routes/case-routes.ts index 8a679b91..6934634c 100644 --- a/src/web/routes/case-routes.ts +++ b/src/web/routes/case-routes.ts @@ -30,6 +30,7 @@ import { LinkCaseSchema, CaseOrderSchema, CaseDefaultGroupSchema, + RenameCaseSchema, RemoteCaseLinkSchema, RemoteHostSchema, DockerCaseLinkSchema, @@ -52,6 +53,14 @@ import { import type { GitRemoteProbe, GitUrlParse } from '../../git-clone.js'; import { generateClaudeMd } from '../../templates/claude-md.js'; import { prepareNewCasePath } from '../case-path.js'; +import { + CaseRenameError, + moveCaseFolder, + moveClaudeHistory, + renameInOrder, + siblingPath, + withCaseLock, +} from '../../case-rename.js'; import { boundedPathExists, describeUnknownPath, probePath, probePathKind } from '../../utils/index.js'; import { readAgentCaseMarker, type AgentCaseMarker } from '../../agent-case-marker.js'; import { settingsWriteBlocker, writeHooksConfig } from '../../hooks-config.js'; @@ -1747,6 +1756,137 @@ export function registerCaseRoutes( } }); + // ========== Rename Case ========== + + /** + * Rename a case: its name and, with it, its folder. + * + * - A case under the cases directory IS its folder name, so it always moves. + * - A linked case has an alias that is only a key in linked-cases.json; the real folder moves too only + * with `renameFolder: true` (it can be shared with other tools). + * - Remote and Docker cases are not renamable here: their folder lives on another host or in a container + * whose name derives from the case name. + * + * ⚠️ Moving a folder that sessions have open is the dangerous part (see src/case-rename.ts): live + * sessions in it are listed and refused (409 `requiresStop`) unless `stopSessions` is set, in which + * case they are ended first. The folder is held by an in-process lock for the whole operation, so a + * session cannot start in it half-moved. + */ + app.post('/api/cases/:name/rename', async (req, reply) => { + const { name } = req.params as { name: string }; + const user = getAuthUser(req); + const body = parseBody(RenameCaseSchema, req.body); + const newName = body.newName; + const fail = (status: number, code: ApiErrorCode, message: string, extra: Record = {}) => { + reply.code(status); + return { ...createErrorResponse(code, message), ...extra }; + }; + if (!SAFE_CASE_NAME.test(name) || !validatePathWithinBase(name, resolveCasesDir(user))) { + return fail(400, ApiErrorCode.INVALID_INPUT, 'Invalid case name'); + } + if (newName === name) return fail(400, ApiErrorCode.INVALID_INPUT, 'The new name is the same as the old one'); + + // Remote and Docker cases first, so a same-named local case cannot be confused for them. + const remoteCases = await readRemoteCases(CODEMAN_CONFIG_DIR); + const dockerCases = await readDockerCases(CODEMAN_CONFIG_DIR); + if ( + remoteCases.some((c) => c.name === name && canAccessOwned(user, c.owner)) || + dockerCases.some((c) => c.name === name && canAccessOwned(user, c.owner)) + ) { + return fail(400, ApiErrorCode.INVALID_INPUT, 'Remote and Docker cases cannot be renamed here'); + } + + const casesBase = resolveCasesDir(user); + const linkedCases = await readLinkedCases(); + const isLinked = Boolean(linkedCases[name]) && (!isMultiUserMode() || isAdmin(req)); + if (isLinked && !isAdmin(req)) return fail(403, ApiErrorCode.FORBIDDEN, 'Admin only in multi-user mode'); + const oldPath = isLinked ? linkedCases[name] : join(casesBase, name); + + // The new name must be free in every namespace the list merges. + const localTaken = existsSync(join(casesBase, newName)); + const taken = + localTaken || + Boolean(linkedCases[newName]) || + remoteCases.some((c) => c.name === newName) || + dockerCases.some((c) => c.name === newName); + if (taken) return fail(409, ApiErrorCode.ALREADY_EXISTS, `A case named "${newName}" already exists`); + + const state = await probePath(oldPath); + if (state === 'absent') return fail(404, ApiErrorCode.NOT_FOUND, `Case "${name}" not found`); + if (state === 'unknown') { + return fail(503, ApiErrorCode.OPERATION_FAILED, describeUnknownPath('Case folder', oldPath, { pastCap: true })); + } + + const moveFolder = !isLinked || body.renameFolder === true; + const newPath = isLinked ? (moveFolder ? siblingPath(oldPath, newName) : oldPath) : join(casesBase, newName); + if (moveFolder && newPath !== oldPath && (await probePath(newPath, { pastCap: true })) !== 'absent') { + return fail(409, ApiErrorCode.ALREADY_EXISTS, `A folder named ${newPath} already exists`); + } + + // Sessions with the folder open. Only a move disturbs them; an alias-only rename leaves them running. + const open = moveFolder + ? Array.from(ctx.sessions.values()).filter( + (s) => s.workingDir === oldPath || s.workingDir.startsWith(oldPath + '/') + ) + : []; + if (open.some((s) => !canAccessOwned(user, s.owner))) { + return fail(403, ApiErrorCode.FORBIDDEN, "Another user's session has this case open"); + } + if (open.length > 0 && !body.stopSessions) { + return fail( + 409, + ApiErrorCode.CONFLICT, + `${open.length} live session${open.length === 1 ? ' has' : 's have'} this folder open. Stop them to rename it.`, + { requiresStop: true, sessions: open.map((s) => ({ id: s.id, name: s.name })) } + ); + } + + try { + await withCaseLock(oldPath, newPath, async () => { + for (const s of open) await ctx.cleanupSession(s.id, true, 'case_rename'); + if (moveFolder) await moveCaseFolder(oldPath, newPath); + try { + if (isLinked) { + const links = await readLinkedCases(); + delete links[name]; + links[newName] = newPath; + await fs.writeFile(LINKED_CASES_FILE, JSON.stringify(links, null, 2)); + } + const existing = await readJsonConfig>(SETTINGS_PATH, 'settings.json', {}); + const order = renameInOrder(existing.caseOrder, name, newName); + const last = existing.lastUsedCase === name ? newName : undefined; + if (order || last) { + await fs.writeFile( + SETTINGS_PATH, + JSON.stringify( + { ...existing, ...(order ? { caseOrder: order } : {}), ...(last ? { lastUsedCase: last } : {}) }, + null, + 2 + ) + ); + } + } catch (err) { + // The folder moved but the registries did not follow: put it back rather than leave a case + // whose name and folder disagree. + if (moveFolder) await fs.rename(newPath, oldPath).catch(() => {}); + throw err; + } + if (moveFolder) await moveClaudeHistory(oldPath, newPath); + }); + ctx.broadcast(SseEvent.CaseRenamed, { oldName: name, newName, oldPath, newPath, folderMoved: moveFolder }); + return { + success: true, + data: { oldName: name, newName, oldPath, newPath, folderMoved: moveFolder, stoppedSessions: open.length }, + }; + } catch (err) { + if (err instanceof CaseRenameError) { + const status = err.code === 'BUSY' ? 409 : err.code === 'EXISTS' ? 409 : err.code === 'LOCKED' ? 423 : 400; + return fail(status, ApiErrorCode.OPERATION_FAILED, err.message, { renameCode: err.code }); + } + return fail(500, ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); + } + }); + // ========== Reorder Cases ========== app.put('/api/cases/order', async (req, reply): Promise | undefined> => { diff --git a/src/web/schemas.ts b/src/web/schemas.ts index 86aa3f25..dff5ef49 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -1992,6 +1992,21 @@ export const LinkCaseSchema = z.object({ path: safePathSchema, }); +/** POST /api/cases/:name/rename */ +export const RenameCaseSchema = z + .object({ + newName: z + .string() + .min(1) + .max(100) + .regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format. Use only letters, numbers, hyphens, underscores.'), + /** Linked cases only: also rename the real folder on disk (a case under the cases directory always moves). */ + renameFolder: z.boolean().optional(), + /** Stop the live sessions that have the folder open, instead of refusing with the list of them. */ + stopSessions: z.boolean().optional(), + }) + .strict(); + /** PUT /api/cases/order */ export const CaseOrderSchema = z.object({ order: z.array(z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format')), diff --git a/src/web/sse-events.ts b/src/web/sse-events.ts index 8e79c856..9e6f0a30 100644 --- a/src/web/sse-events.ts +++ b/src/web/sse-events.ts @@ -458,6 +458,8 @@ export const CaseCreated = 'case:created' as const; export const CaseLinked = 'case:linked' as const; /** Case deleted or unlinked. */ export const CaseDeleted = 'case:deleted' as const; +/** Case renamed (name and folder). */ +export const CaseRenamed = 'case:renamed' as const; /** Case ordering changed. */ export const CaseOrderChanged = 'case:order-changed' as const; @@ -702,6 +704,7 @@ export const SseEvent = { CaseCreated, CaseLinked, CaseDeleted, + CaseRenamed, CaseOrderChanged, // Docker cases diff --git a/test/case-rename-ui.test.ts b/test/case-rename-ui.test.ts new file mode 100644 index 00000000..2b250395 --- /dev/null +++ b/test/case-rename-ui.test.ts @@ -0,0 +1,110 @@ +/** + * @fileoverview The Rename button's flow in session-ui.js (renameCase): the prompts, the second confirmation + * before sessions are stopped, and the follow-up refresh. Evaluates the real file against a stub app. + */ +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import vm from 'node:vm'; + +const SRC = readFileSync(new URL('../src/web/public/session-ui.js', import.meta.url), 'utf-8'); + +function makeApp(opts: { prompt: string | null; confirms: boolean[]; responses: unknown[]; cases: unknown[] }) { + const calls: Array<{ url: string; body: Record }> = []; + const confirmMessages: string[] = []; + const toasts: string[] = []; + const store = new Map([['caseSettings_alpha', '{"agentTeams":true}']]); + const queue = [...opts.responses]; + function CodemanApp(this: unknown) {} + const ctx = vm.createContext({ + CodemanApp, + window: {}, + document: { getElementById: () => ({ value: 'alpha' }) }, + prompt: () => opts.prompt, + confirm: (m: string) => (confirmMessages.push(m), opts.confirms.shift() ?? false), + localStorage: { + getItem: (k: string) => store.get(k) ?? null, + setItem: (k: string, v: string) => void store.set(k, v), + removeItem: (k: string) => void store.delete(k), + }, + fetch: async (url: string, init: { body: string }) => { + calls.push({ url, body: JSON.parse(init.body) }); + return { json: async () => queue.shift() }; + }, + escapeHtml: (s: string) => s, + }); + vm.runInContext(SRC, ctx); + const app = Object.create((CodemanApp as unknown as { prototype: object }).prototype) as Record; + const refreshed: unknown[] = []; + Object.assign(app, { + cases: opts.cases, + showToast: (m: string) => void toasts.push(m), + loadQuickStartCases: async (n: unknown) => void refreshed.push(n), + renderCaseManageList: () => void refreshed.push('render'), + }); + return { app, calls, confirmMessages, toasts, store, refreshed }; +} + +const ok = { success: true, data: {} }; + +describe('renameCase (case manager)', () => { + it('renames a local case, moves its run options and reselects it under the new name', async () => { + const t = makeApp({ + prompt: ' beta ', + confirms: [], + responses: [ok], + cases: [{ name: 'alpha', location: 'local' }], + }); + await t.app.renameCase('alpha'); + expect(t.calls).toEqual([{ url: '/api/cases/alpha/rename', body: { newName: 'beta' } }]); + expect(t.store.get('caseSettings_beta')).toBe('{"agentTeams":true}'); + expect(t.store.has('caseSettings_alpha')).toBe(false); + expect(t.refreshed).toEqual(['beta', 'render']); + }); + + it('does nothing for a cancelled prompt, the same name, or a name that is not allowed', async () => { + for (const entered of [null, '', 'alpha', 'a b', '../x']) { + const t = makeApp({ prompt: entered, confirms: [], responses: [], cases: [{ name: 'alpha' }] }); + await t.app.renameCase('alpha'); + expect(t.calls, String(entered)).toEqual([]); + } + }); + + it('asks a linked case whether to rename the real folder, and sends the answer', async () => { + const t = makeApp({ + prompt: 'beta', + confirms: [true], + responses: [ok], + cases: [{ name: 'alpha', linked: true, path: '/srv/app' }], + }); + await t.app.renameCase('alpha'); + expect(t.confirmMessages[0]).toContain('/srv/app'); + expect(t.calls[0].body).toEqual({ newName: 'beta', renameFolder: true }); + }); + + it('lists the live sessions and only stops them after a second confirmation', async () => { + const refusal = { + success: false, + error: '1 live session has this folder open.', + requiresStop: true, + sessions: [{ id: 's', name: 'w1-alpha' }], + }; + const t = makeApp({ prompt: 'beta', confirms: [true], responses: [refusal, ok], cases: [{ name: 'alpha' }] }); + await t.app.renameCase('alpha'); + expect(t.confirmMessages[0]).toContain('w1-alpha'); + expect(t.calls.map((c) => c.body)).toEqual([{ newName: 'beta' }, { newName: 'beta', stopSessions: true }]); + + const declined = makeApp({ prompt: 'beta', confirms: [false], responses: [refusal], cases: [{ name: 'alpha' }] }); + await declined.app.renameCase('alpha'); + expect(declined.calls).toHaveLength(1); + expect(declined.refreshed).toEqual([]); + }); + + it('shows the server’s message when the folder is locked, and leaves the lists alone', async () => { + const locked = { success: false, error: 'The folder is in use by another program (EBUSY).' }; + const t = makeApp({ prompt: 'beta', confirms: [], responses: [locked], cases: [{ name: 'alpha' }] }); + await t.app.renameCase('alpha'); + expect(t.toasts).toEqual(['The folder is in use by another program (EBUSY).']); + expect(t.refreshed).toEqual([]); + expect(t.store.has('caseSettings_alpha')).toBe(true); + }); +}); diff --git a/test/case-rename.test.ts b/test/case-rename.test.ts new file mode 100644 index 00000000..5d3d2fe6 --- /dev/null +++ b/test/case-rename.test.ts @@ -0,0 +1,165 @@ +/** + * @fileoverview src/case-rename.ts: the in-process lock, the retried folder move and Claude history + * migration. Real filesystem under test/setup.ts's temp HOME. Port: N/A. + */ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import fsp from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + CaseRenameError, + claudeProjectDirName, + isCasePathLocked, + moveCaseFolder, + moveClaudeHistory, + renameInOrder, + siblingPath, + withCaseLock, +} from '../src/case-rename.js'; +import { Session } from '../src/session.js'; + +const roots: string[] = []; +const tmp = () => { + const dir = mkdtempSync(join(tmpdir(), 'case-rename-')); + roots.push(dir); + return dir; +}; +afterEach(async () => { + vi.restoreAllMocks(); + for (const dir of roots.splice(0)) await fsp.rm(dir, { recursive: true, force: true }); +}); + +describe('withCaseLock', () => { + it('holds both paths and everything inside them for the length of the call, then lets go', async () => { + const root = tmp(); + const from = join(root, 'old'); + const to = join(root, 'new'); + let during: boolean[] = []; + await withCaseLock(from, to, async () => { + during = [ + isCasePathLocked(from), + isCasePathLocked(join(from, 'src')), + isCasePathLocked(to), + isCasePathLocked(root), + ]; + }); + expect(during).toEqual([true, true, true, false]); + expect(isCasePathLocked(from)).toBe(false); + expect(isCasePathLocked(to)).toBe(false); + }); + + it('refuses an overlapping rename with BUSY and releases on failure', async () => { + const root = tmp(); + await withCaseLock(join(root, 'a'), join(root, 'b'), async () => { + await expect(withCaseLock(join(root, 'b'), join(root, 'c'), async () => {})).rejects.toMatchObject({ + code: 'BUSY', + }); + await expect(withCaseLock(join(root, 'a', 'inner'), join(root, 'x'), async () => {})).rejects.toBeInstanceOf( + CaseRenameError + ); + }); + await expect( + withCaseLock(join(root, 'a'), join(root, 'b'), async () => { + throw new Error('boom'); + }) + ).rejects.toThrow('boom'); + expect(isCasePathLocked(join(root, 'a'))).toBe(false); + }); + + it('keeps a session from starting in a folder that is being moved', async () => { + const root = tmp(); + const from = join(root, 'case'); + mkdirSync(from); + await withCaseLock(from, join(root, 'renamed'), async () => { + expect(() => new Session({ workingDir: from })).toThrow(/being renamed/); + expect(() => new Session({ workingDir: join(from, 'sub') })).toThrow(/being renamed/); + }); + }); +}); + +describe('moveCaseFolder', () => { + it('moves a folder and its contents', async () => { + const root = tmp(); + mkdirSync(join(root, 'old')); + writeFileSync(join(root, 'old', 'CLAUDE.md'), 'x'); + await moveCaseFolder(join(root, 'old'), join(root, 'new')); + expect(existsSync(join(root, 'old'))).toBe(false); + expect(existsSync(join(root, 'new', 'CLAUDE.md'))).toBe(true); + }); + + it('never replaces an existing folder, even an empty one that rename would silently overwrite', async () => { + const root = tmp(); + mkdirSync(join(root, 'old')); + mkdirSync(join(root, 'taken')); + await expect(moveCaseFolder(join(root, 'old'), join(root, 'taken'))).rejects.toMatchObject({ code: 'EXISTS' }); + expect(existsSync(join(root, 'old'))).toBe(true); + }); + + it('retries a lock error, and succeeds once the other program lets go', async () => { + const root = tmp(); + mkdirSync(join(root, 'old')); + const real = fsp.rename.bind(fsp); + let calls = 0; + vi.spyOn(fsp, 'rename').mockImplementation(async (a, b) => { + if (++calls < 3) throw Object.assign(new Error('busy'), { code: 'EBUSY' }); + return real(a, b); + }); + await moveCaseFolder(join(root, 'old'), join(root, 'new'), 4, 1); + expect(calls).toBe(3); + expect(existsSync(join(root, 'new'))).toBe(true); + }); + + it('reports a lock that never clears as LOCKED, with what to do about it', async () => { + const root = tmp(); + mkdirSync(join(root, 'old')); + vi.spyOn(fsp, 'rename').mockRejectedValue(Object.assign(new Error('perm'), { code: 'EPERM' })); + const err = await moveCaseFolder(join(root, 'old'), join(root, 'new'), 2, 1).catch((e) => e); + expect(err).toBeInstanceOf(CaseRenameError); + expect(err.code).toBe('LOCKED'); + expect(err.message).toMatch(/in use by another program/); + expect(existsSync(join(root, 'old'))).toBe(true); + }); + + it('refuses to cross filesystems', async () => { + const root = tmp(); + mkdirSync(join(root, 'old')); + vi.spyOn(fsp, 'rename').mockRejectedValue(Object.assign(new Error('xdev'), { code: 'EXDEV' })); + await expect(moveCaseFolder(join(root, 'old'), join(root, 'new'))).rejects.toMatchObject({ code: 'CROSS_DEVICE' }); + }); +}); + +describe('Claude history', () => { + it('names a project directory the way Claude Code does', () => { + expect(claudeProjectDirName('/home/devvyn/codeman-cases/my_case')).toBe('-home-devvyn-codeman-cases-my-case'); + expect(claudeProjectDirName('/home/devvyn/.dotcase')).toBe('-home-devvyn--dotcase'); + }); + + it('moves the old path’s history to the new one, and leaves existing history alone', async () => { + const cfg = tmp(); + const projects = join(cfg, 'projects'); + const oldPath = join(tmp(), 'alpha'); + const newPath = join(tmp(), 'beta'); + mkdirSync(join(projects, claudeProjectDirName(oldPath)), { recursive: true }); + writeFileSync(join(projects, claudeProjectDirName(oldPath), 'c.jsonl'), '{}'); + expect(await moveClaudeHistory(oldPath, newPath, cfg)).toBe(true); + expect(existsSync(join(projects, claudeProjectDirName(newPath), 'c.jsonl'))).toBe(true); + expect(existsSync(join(projects, claudeProjectDirName(oldPath)))).toBe(false); + + mkdirSync(join(projects, claudeProjectDirName(oldPath)), { recursive: true }); + expect(await moveClaudeHistory(oldPath, newPath, cfg)).toBe(false); // the target already has history + expect(existsSync(join(projects, claudeProjectDirName(oldPath)))).toBe(true); + expect(await moveClaudeHistory(join(tmp(), 'none'), newPath, cfg)).toBe(false); + }); +}); + +describe('small helpers', () => { + it('renames in an order list keeping the position, and says nothing when absent', () => { + expect(renameInOrder(['a', 'b', 'c'], 'b', 'z')).toEqual(['a', 'z', 'c']); + expect(renameInOrder(['a'], 'q', 'z')).toBeNull(); + expect(renameInOrder(undefined, 'a', 'z')).toBeNull(); + }); + it('puts the new name next to the old folder', () => { + expect(siblingPath('/srv/work/app', 'app2')).toBe('/srv/work/app2'); + }); +}); diff --git a/test/routes/case-rename-routes.test.ts b/test/routes/case-rename-routes.test.ts new file mode 100644 index 00000000..609c967b --- /dev/null +++ b/test/routes/case-rename-routes.test.ts @@ -0,0 +1,179 @@ +/** + * @fileoverview POST /api/cases/:name/rename: a local case moves with its name, a linked case renames its + * alias (and, on request, its real folder), live sessions are listed and only stopped on request, and the + * registries (caseOrder, lastUsedCase, linked-cases.json) and Claude's history follow. Real filesystem under + * test/setup.ts's temp HOME; every folder is removed through safeRmHomeTree. Port: N/A (app.inject()). + */ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { basename, 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 { SETTINGS_PATH } from '../../src/web/route-helpers.js'; +import { claudeProjectDirName } from '../../src/case-rename.js'; +import { safeRmHomeTree } from '../mocks/index.js'; + +const LINKED = () => dataPath('linked-cases.json'); +const CASES_DIR = () => join(homedir(), 'codeman-cases'); +const sandboxed = () => basename(homedir()).startsWith('codeman-vitest-'); +let work = ''; +let claudeCfg = ''; +const made: string[] = []; + +const linked = (): Record => (existsSync(LINKED()) ? JSON.parse(readFileSync(LINKED(), 'utf8')) : {}); +const settings = (): Record => JSON.parse(readFileSync(SETTINGS_PATH, 'utf8')); +const rename = (app: Awaited>['app'], name: string, payload: object) => + app.inject({ method: 'POST', url: `/api/cases/${name}/rename`, payload }); +const localCase = (name: string) => { + const dir = join(CASES_DIR(), name); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'CLAUDE.md'), name); + made.push(dir); + return dir; +}; + +beforeEach(() => { + if (!sandboxed()) throw new Error('case-rename-routes.test.ts renames folders: run it via npm test'); + safeRmHomeTree(LINKED()); + work = realpathSync(mkdtempSync(join(homedir(), 'case-rename-'))); + claudeCfg = join(work, 'claude-config'); + process.env.CLAUDE_CONFIG_DIR = claudeCfg; + mkdirSync(join(homedir(), '.codeman'), { recursive: true }); + writeFileSync(SETTINGS_PATH, JSON.stringify({ caseOrder: ['keep', 'alpha', 'tail'], lastUsedCase: 'alpha' })); +}); +afterEach(() => { + delete process.env.CLAUDE_CONFIG_DIR; + delete process.env.CODEMAN_MULTIUSER; + for (const dir of made.splice(0)) safeRmHomeTree(dir); + if (work) safeRmHomeTree(work); + work = ''; + if (sandboxed()) safeRmHomeTree(LINKED()); +}); + +describe('POST /api/cases/:name/rename (a case under the cases directory)', () => { + it('moves the folder, keeps its place in the order, follows lastUsedCase and carries Claude history', async () => { + const { app } = await createRouteTestHarness(registerCaseRoutes); + const oldDir = localCase('alpha'); + mkdirSync(join(claudeCfg, 'projects', claudeProjectDirName(oldDir)), { recursive: true }); + writeFileSync(join(claudeCfg, 'projects', claudeProjectDirName(oldDir), 'c.jsonl'), '{}'); + + const res = await rename(app, 'alpha', { newName: 'beta' }); + + expect(res.statusCode).toBe(200); + const newDir = join(CASES_DIR(), 'beta'); + made.push(newDir); + expect(res.json().data).toMatchObject({ oldName: 'alpha', newName: 'beta', newPath: newDir, folderMoved: true }); + expect(existsSync(oldDir)).toBe(false); + expect(readFileSync(join(newDir, 'CLAUDE.md'), 'utf8')).toBe('alpha'); + expect(settings()).toMatchObject({ caseOrder: ['keep', 'beta', 'tail'], lastUsedCase: 'beta' }); + expect(existsSync(join(claudeCfg, 'projects', claudeProjectDirName(newDir), 'c.jsonl'))).toBe(true); + }); + + it('refuses a name that is taken (local or linked), the same name, or one that is not allowed', async () => { + const { app } = await createRouteTestHarness(registerCaseRoutes); + localCase('alpha'); + localCase('taken'); + const target = join(work, 'elsewhere'); + mkdirSync(target); + writeFileSync(LINKED(), JSON.stringify({ lk: target })); + expect((await rename(app, 'alpha', { newName: 'taken' })).statusCode).toBe(409); + expect((await rename(app, 'alpha', { newName: 'lk' })).statusCode).toBe(409); + expect((await rename(app, 'alpha', { newName: 'alpha' })).statusCode).toBe(400); + expect((await rename(app, 'alpha', { newName: '../evil' })).statusCode).toBe(400); + expect((await rename(app, 'nope', { newName: 'whatever' })).statusCode).toBe(404); + expect(existsSync(join(CASES_DIR(), 'alpha'))).toBe(true); + }); + + it('lists the live sessions that have the folder open and does not touch it until told to stop them', async () => { + const { app, ctx } = await createRouteTestHarness(registerCaseRoutes); + const oldDir = localCase('alpha'); + (ctx.sessions as Map).set('s-live', { + id: 's-live', + name: 'w1-alpha', + workingDir: join(oldDir, 'sub'), + }); + + const refused = await rename(app, 'alpha', { newName: 'beta' }); + expect(refused.statusCode).toBe(409); + expect(refused.json()).toMatchObject({ requiresStop: true, sessions: [{ id: 's-live', name: 'w1-alpha' }] }); + expect(existsSync(oldDir)).toBe(true); + expect(ctx.cleanupSession).not.toHaveBeenCalled(); + + const done = await rename(app, 'alpha', { newName: 'beta', stopSessions: true }); + made.push(join(CASES_DIR(), 'beta')); + expect(done.statusCode).toBe(200); + expect(done.json().data.stoppedSessions).toBe(1); + expect(ctx.cleanupSession).toHaveBeenCalledWith('s-live', true, 'case_rename'); + expect(existsSync(join(CASES_DIR(), 'beta'))).toBe(true); + }); + + it('does not touch a case whose name merely starts with the same letters', async () => { + const { app, ctx } = await createRouteTestHarness(registerCaseRoutes); + localCase('alpha'); + const sibling = localCase('alpha2'); + (ctx.sessions as Map).set('s-other', { id: 's-other', name: 'x', workingDir: sibling }); + const res = await rename(app, 'alpha', { newName: 'beta' }); + made.push(join(CASES_DIR(), 'beta')); + expect(res.statusCode).toBe(200); + expect(ctx.cleanupSession).not.toHaveBeenCalled(); + }); +}); + +describe('POST /api/cases/:name/rename (a linked case)', () => { + it('renames only the alias by default: the real folder and its sessions are left alone', async () => { + const { app, ctx } = await createRouteTestHarness(registerCaseRoutes); + const real = join(work, 'my-project'); + mkdirSync(real); + writeFileSync(LINKED(), JSON.stringify({ proj: real, other: join(work, 'o') })); + (ctx.sessions as Map).set('s1', { id: 's1', name: 'w1', workingDir: real }); + + const res = await rename(app, 'proj', { newName: 'renamed' }); + + expect(res.statusCode).toBe(200); + expect(res.json().data).toMatchObject({ folderMoved: false, newPath: real, stoppedSessions: 0 }); + expect(linked()).toEqual({ other: join(work, 'o'), renamed: real }); + expect(existsSync(real)).toBe(true); + expect(ctx.cleanupSession).not.toHaveBeenCalled(); + }); + + it('renames the real folder too, next to the old one, when asked', async () => { + const { app } = await createRouteTestHarness(registerCaseRoutes); + const real = join(work, 'my-project'); + mkdirSync(real); + writeFileSync(LINKED(), JSON.stringify({ proj: real })); + + const res = await rename(app, 'proj', { newName: 'renamed', renameFolder: true }); + + expect(res.statusCode).toBe(200); + expect(existsSync(real)).toBe(false); + expect(existsSync(join(work, 'renamed'))).toBe(true); + expect(linked()).toEqual({ renamed: join(work, 'renamed') }); + }); + + it('puts the folder back when the registry cannot be written', async () => { + const { app } = await createRouteTestHarness(registerCaseRoutes); + const real = join(work, 'my-project'); + mkdirSync(real); + writeFileSync(LINKED(), JSON.stringify({ proj: real })); + // the registry reads fine but cannot be written: the rename must undo the folder move + chmodSync(LINKED(), 0o444); + const res = await rename(app, 'proj', { newName: 'renamed', renameFolder: true }); + expect(res.statusCode).toBe(500); + expect(existsSync(real)).toBe(true); + expect(linked()).toEqual({ proj: real }); + expect(existsSync(join(work, 'renamed'))).toBe(false); + }); + + it('is admin only in multi-user mode', async () => { + process.env.CODEMAN_MULTIUSER = '1'; + const { app } = await createRouteTestHarness(registerCaseRoutes, { authUser: { username: 'bob', role: 'user' } }); + const real = join(work, 'p'); + mkdirSync(real); + writeFileSync(LINKED(), JSON.stringify({ proj: real })); + const res = await rename(app, 'proj', { newName: 'renamed' }); + expect(res.statusCode).not.toBe(200); + expect(linked()).toEqual({ proj: real }); + }); +});