mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 10:59:32 +01:00
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.
This commit is contained in:
1 parent
f1f7eec4d6
commit
0bd15ddc84
8 files changed
+350
-12
No files matched your search
@@ -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 `<repo>/.claude/worktrees/<name>` on `worktree-<name>`, 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)
|
||||
|
||||
|
||||
@@ -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 <cmd>` makes git run an arbitrary command as its transport, and ANY `<name>::<payload>` dispatches to a `git-remote-<name>` 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.
|
||||
|
||||
@@ -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/<name>` with a scaffolded `CLAUDE.md`, or with **Create in a custom folder**, a new folder inside a parent you choose, scaffolded the same way and registered in place like a linked case. |
|
||||
| **Clone Repo** | A repo cloned into `~/codeman-cases/<name>` and registered as a case. Private repos need this machine's own git credentials (see below). |
|
||||
| **Clone Repo** | A repo cloned into `~/codeman-cases/<name>` (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
|
||||
|
||||
@@ -3654,7 +3654,19 @@
|
||||
<div class="form-row">
|
||||
<label>Case Name</label>
|
||||
<input type="text" id="cloneCaseName" placeholder="repo" pattern="[a-zA-Z0-9_-]+" autocomplete="off" autocapitalize="off" spellcheck="false" oninput="app.onCloneNameEdited()">
|
||||
<span class="form-hint">Filled in from the URL. Cloned into ~/codeman-cases/<name>, so deleting the case deletes this working tree.</span>
|
||||
<span class="form-hint" id="cloneCaseNameHint">Filled in from the URL. Cloned into ~/codeman-cases/<name>, so deleting the case deletes this working tree.</span>
|
||||
</div>
|
||||
<div class="form-row" id="cloneCustomPathToggleRow">
|
||||
<label class="checkbox-row"><input type="checkbox" id="cloneCustomPathToggle" onchange="app.toggleCloneCustomPath()"> 📁 Clone into a custom folder</label>
|
||||
<span class="form-hint">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).</span>
|
||||
</div>
|
||||
<div class="form-row" id="cloneCustomPathRow" style="display:none">
|
||||
<label>Parent Folder</label>
|
||||
<div class="path-input-group">
|
||||
<input type="text" id="cloneCasePath" placeholder="~/projects" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" oninput="app.updateCloneCasePathPreview()">
|
||||
<button type="button" class="btn path-input-browse" onclick="app.openCloneCasePathPicker()">Browse…</button>
|
||||
</div>
|
||||
<span class="form-hint" id="cloneCasePathPreview">Pick the folder the clone should be created inside.</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Branch or Tag (optional)</label>
|
||||
|
||||
@@ -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/<name>, 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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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<string, string> => (existsSync(LINKED()) ? JSON.parse(readFileSync(LINKED(), 'utf8')) : {});
|
||||
const clone = (app: Awaited<ReturnType<typeof createRouteTestHarness>>['app'], payload: Record<string, unknown>) =>
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user