@testchimp/cli 0.1.84 → 0.1.85

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,246 @@
1
+ /**
2
+ * Per-user folder ↔ project mapping in `~/.testchimp/projects.json` (TESTCHIMP_HOME honoured).
3
+ *
4
+ * Format contract shared with TestChimp Studio (desktop `src/main/workspace/projectsRegistry.ts`):
5
+ * same schema v2, canonical real paths, conflict rule, v1 → v2 migration, atomic writes (0600) and
6
+ * quarantine naming. `fixtures/projects-registry/` (mirrored byte-for-byte in the desktop repo)
7
+ * pins the on-disk bytes both writers must produce.
8
+ */
9
+ import { randomUUID } from "node:crypto";
10
+ import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, statSync, writeFileSync } from "node:fs";
11
+ import { homedir } from "node:os";
12
+ import { basename, isAbsolute, join, normalize, resolve } from "node:path";
13
+ import { z } from "zod";
14
+ // Loose objects: fields written by a newer Studio survive a CLI rewrite.
15
+ export const WorkspaceFolderSchema = z.looseObject({
16
+ id: z.string().min(1),
17
+ path: z.string().min(1),
18
+ name: z.string().optional(),
19
+ });
20
+ export const WorkspaceMappingSchema = z.looseObject({
21
+ id: z.string().min(1),
22
+ projectId: z.string().min(1),
23
+ projectName: z.string().optional(),
24
+ folders: z.array(WorkspaceFolderSchema).min(0),
25
+ createdAtMillis: z.number().int().nonnegative(),
26
+ lastOpenedAtMillis: z.number().int().nonnegative(),
27
+ browserStartUrl: z.string().url().optional(),
28
+ });
29
+ export const ProjectsRegistrySchema = z.looseObject({
30
+ schemaVersion: z.literal(2),
31
+ activeWorkspaceId: z.string().nullable(),
32
+ mappings: z.array(WorkspaceMappingSchema),
33
+ });
34
+ const EMPTY = { schemaVersion: 2, activeWorkspaceId: null, mappings: [] };
35
+ export function getTestchimpHome(env = {}) {
36
+ if (env.home)
37
+ return env.home;
38
+ const override = process.env.TESTCHIMP_HOME?.trim();
39
+ if (override)
40
+ return override;
41
+ return join(homedir(), ".testchimp");
42
+ }
43
+ export function projectsRegistryPath(env = {}) {
44
+ return join(getTestchimpHome(env), "projects.json");
45
+ }
46
+ function nowOf(env) {
47
+ return env.now ? env.now() : Date.now();
48
+ }
49
+ function idOf(env) {
50
+ return env.newId ? env.newId() : randomUUID();
51
+ }
52
+ function ensureHome(env) {
53
+ const home = getTestchimpHome(env);
54
+ if (!existsSync(home))
55
+ mkdirSync(home, { recursive: true, mode: 0o700 });
56
+ }
57
+ /** Serialized bytes exactly as written to disk (2-space JSON + trailing newline). */
58
+ export function serializeRegistry(value) {
59
+ return `${JSON.stringify(value, null, 2)}\n`;
60
+ }
61
+ function writeJsonAtomic(path, value, env, mode = 0o600) {
62
+ ensureHome(env);
63
+ const tmp = `${path}.${process.pid}.tmp`;
64
+ writeFileSync(tmp, serializeRegistry(value), { mode });
65
+ renameSync(tmp, path);
66
+ try {
67
+ chmodSync(path, mode);
68
+ }
69
+ catch {
70
+ /* ignore */
71
+ }
72
+ }
73
+ /** Real absolute directory path (follows symlinks); same error codes as Studio. */
74
+ export function canonicalizePath(input) {
75
+ const trimmed = String(input || "").trim();
76
+ if (!trimmed)
77
+ throw new Error("PATH_ESCAPE: empty path");
78
+ const absolute = isAbsolute(trimmed) ? normalize(trimmed) : resolve(trimmed);
79
+ if (!existsSync(absolute))
80
+ throw new Error("NOT_FOUND: path does not exist");
81
+ if (!statSync(absolute).isDirectory())
82
+ throw new Error("INVALID_PAYLOAD: path is not a directory");
83
+ try {
84
+ return realpathSync(absolute);
85
+ }
86
+ catch {
87
+ throw new Error("PATH_ESCAPE: cannot resolve real path");
88
+ }
89
+ }
90
+ function folderNameFromPath(path) {
91
+ return basename(path) || path;
92
+ }
93
+ /** v1 → v2: single rootPath becomes folders[0]. */
94
+ function migrateRawRegistry(raw, env) {
95
+ if (!raw || typeof raw !== "object")
96
+ return raw;
97
+ const obj = raw;
98
+ if (obj.schemaVersion === 2)
99
+ return raw;
100
+ if (obj.schemaVersion !== 1 || !Array.isArray(obj.mappings))
101
+ return raw;
102
+ const mappings = obj.mappings.map((m) => {
103
+ if (!m || typeof m !== "object")
104
+ return m;
105
+ const row = m;
106
+ if (Array.isArray(row.folders))
107
+ return row;
108
+ const rootPath = typeof row.rootPath === "string" ? row.rootPath : "";
109
+ return {
110
+ id: row.id,
111
+ projectId: row.projectId,
112
+ projectName: row.projectName,
113
+ folders: rootPath ? [{ id: idOf(env), path: rootPath, name: folderNameFromPath(rootPath) }] : [],
114
+ createdAtMillis: row.createdAtMillis,
115
+ lastOpenedAtMillis: row.lastOpenedAtMillis,
116
+ };
117
+ });
118
+ return { schemaVersion: 2, activeWorkspaceId: obj.activeWorkspaceId ?? null, mappings };
119
+ }
120
+ function quarantine(env) {
121
+ const target = join(getTestchimpHome(env), `projects.invalid.${nowOf(env)}.json`);
122
+ renameSync(projectsRegistryPath(env), target);
123
+ return target;
124
+ }
125
+ /**
126
+ * Read projects.json for a write. Missing → empty file created; v1 → migrated + persisted;
127
+ * schema-invalid or unparseable → moved to `projects.invalid.<millis>.json` and reset to empty.
128
+ */
129
+ export function readProjectsRegistry(env = {}) {
130
+ ensureHome(env);
131
+ const path = projectsRegistryPath(env);
132
+ if (!existsSync(path)) {
133
+ writeJsonAtomic(path, EMPTY, env);
134
+ return { ...EMPTY, mappings: [] };
135
+ }
136
+ let raw;
137
+ try {
138
+ raw = JSON.parse(readFileSync(path, "utf8"));
139
+ }
140
+ catch {
141
+ quarantine(env);
142
+ writeJsonAtomic(path, EMPTY, env);
143
+ return { ...EMPTY, mappings: [] };
144
+ }
145
+ const parsed = ProjectsRegistrySchema.safeParse(migrateRawRegistry(raw, env));
146
+ if (!parsed.success) {
147
+ quarantine(env);
148
+ writeJsonAtomic(path, EMPTY, env);
149
+ return { ...EMPTY, mappings: [] };
150
+ }
151
+ if (raw.schemaVersion !== 2) {
152
+ writeJsonAtomic(path, parsed.data, env);
153
+ }
154
+ return parsed.data;
155
+ }
156
+ /** Side-effect-free lookup for `workspace get` (never creates, migrates, or quarantines). */
157
+ export function findWorkspaceMapping(projectId, env = {}) {
158
+ const path = projectsRegistryPath(env);
159
+ if (!existsSync(path))
160
+ return null;
161
+ let raw;
162
+ try {
163
+ raw = JSON.parse(readFileSync(path, "utf8"));
164
+ }
165
+ catch {
166
+ throw new Error(`INVALID_REGISTRY: ${path} is not valid JSON`);
167
+ }
168
+ const migrated = migrateRawRegistry(raw, { ...env, newId: () => "migrated" });
169
+ const parsed = ProjectsRegistrySchema.safeParse(migrated);
170
+ if (!parsed.success)
171
+ throw new Error(`INVALID_REGISTRY: ${path} does not match schema v2`);
172
+ return parsed.data.mappings.find((m) => m.projectId === projectId) ?? null;
173
+ }
174
+ function saveRegistry(registry, env) {
175
+ const next = ProjectsRegistrySchema.parse(registry);
176
+ writeJsonAtomic(projectsRegistryPath(env), next, env);
177
+ return next;
178
+ }
179
+ function conflictIn(registry, projectId, root) {
180
+ for (const m of registry.mappings) {
181
+ if (m.projectId === projectId)
182
+ continue;
183
+ if (m.folders.some((f) => f.path === root)) {
184
+ return { projectId: m.projectId, ...(m.projectName ? { projectName: m.projectName } : {}) };
185
+ }
186
+ }
187
+ return null;
188
+ }
189
+ /** Remove `root` from every mapping that is not `projectId`; drop empty rows. */
190
+ function stripFolderFromOtherProjects(mappings, projectId, root) {
191
+ return mappings
192
+ .map((m) => {
193
+ if (m.projectId === projectId)
194
+ return m;
195
+ if (!m.folders.some((f) => f.path === root))
196
+ return m;
197
+ return { ...m, folders: m.folders.filter((f) => f.path !== root) };
198
+ })
199
+ .filter((m) => m.projectId === projectId || m.folders.length > 0);
200
+ }
201
+ /**
202
+ * Ensure a project workspace exists and add `rootPath` as a folder (or refresh if already present).
203
+ * Rejects if the path is mapped to a different project unless `reassign` is set.
204
+ */
205
+ export function upsertWorkspaceFolder(input, env = {}) {
206
+ const root = canonicalizePath(input.rootPath);
207
+ const reg = readProjectsRegistry(env);
208
+ const conflict = conflictIn(reg, input.projectId, root);
209
+ if (conflict && !input.reassign) {
210
+ throw new Error(`INVALID_PAYLOAD: folder already mapped to project ${conflict.projectId}`);
211
+ }
212
+ let baseMappings = reg.mappings;
213
+ if (conflict && input.reassign) {
214
+ baseMappings = stripFolderFromOtherProjects(baseMappings, input.projectId, root);
215
+ }
216
+ const now = nowOf(env);
217
+ const existing = baseMappings.find((m) => m.projectId === input.projectId);
218
+ let mapping;
219
+ let mappings;
220
+ if (existing) {
221
+ const already = existing.folders.find((f) => f.path === root);
222
+ const folders = already
223
+ ? existing.folders.map((f) => (f.path === root ? { ...f, path: root, name: f.name || folderNameFromPath(root) } : f))
224
+ : [...existing.folders, { id: idOf(env), path: root, name: folderNameFromPath(root) }];
225
+ mapping = {
226
+ ...existing,
227
+ projectName: input.projectName ?? existing.projectName,
228
+ folders,
229
+ lastOpenedAtMillis: now,
230
+ };
231
+ mappings = baseMappings.map((m) => (m.id === existing.id ? mapping : m));
232
+ }
233
+ else {
234
+ mapping = {
235
+ id: idOf(env),
236
+ projectId: input.projectId,
237
+ projectName: input.projectName,
238
+ folders: [{ id: idOf(env), path: root, name: folderNameFromPath(root) }],
239
+ createdAtMillis: now,
240
+ lastOpenedAtMillis: now,
241
+ };
242
+ mappings = [...baseMappings, mapping];
243
+ }
244
+ const saved = saveRegistry({ ...reg, schemaVersion: 2, activeWorkspaceId: mapping.id, mappings }, env);
245
+ return saved.mappings.find((m) => m.id === mapping.id);
246
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,249 @@
1
+ import assert from "node:assert/strict";
2
+ import { spawnSync } from "node:child_process";
3
+ import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { dirname, join, relative, resolve } from "node:path";
6
+ import { afterEach, beforeEach, describe, it } from "node:test";
7
+ import { fileURLToPath } from "node:url";
8
+ import { assertFolderMatchesRepo, inspectLocalGitRepo, normalizeGitRemoteToFullName } from "./gitRepo.js";
9
+ import { findWorkspaceMapping, ProjectsRegistrySchema, projectsRegistryPath, readProjectsRegistry, upsertWorkspaceFolder, } from "./projectsRegistry.js";
10
+ const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
11
+ const fixturesDir = join(repoRoot, "fixtures", "projects-registry");
12
+ const desktopFixturesDir = process.env.TESTCHIMP_DESKTOP_FIXTURES_DIR ??
13
+ resolve(repoRoot, "..", "AwareRepo", "desktop", "src", "main", "workspace", "__fixtures__", "projectsRegistry");
14
+ const cliBin = join(repoRoot, "dist", "bin", "testchimp.js");
15
+ const sequenceFile = JSON.parse(readFileSync(join(fixturesDir, "sequences.json"), "utf8"));
16
+ function listFiles(dir) {
17
+ const out = [];
18
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
19
+ const full = join(dir, entry.name);
20
+ if (entry.isDirectory())
21
+ out.push(...listFiles(full));
22
+ else
23
+ out.push(relative(fixturesDir, full));
24
+ }
25
+ return out;
26
+ }
27
+ function withRoot(text, root) {
28
+ return text.split("__ROOT__").join(JSON.stringify(root).slice(1, -1));
29
+ }
30
+ function readFixture(rel, root) {
31
+ return withRoot(readFileSync(join(fixturesDir, rel), "utf8"), root);
32
+ }
33
+ function makeScratch() {
34
+ const base = realpathSync(mkdtempSync(join(tmpdir(), "tc-projects-registry-")));
35
+ const home = join(base, "home");
36
+ const root = join(base, "work");
37
+ mkdirSync(home);
38
+ mkdirSync(root);
39
+ for (const f of sequenceFile.folders)
40
+ mkdirSync(join(root, f));
41
+ return { home, root };
42
+ }
43
+ function idSource() {
44
+ let n = 0;
45
+ return () => sequenceFile.idPattern.replace("{n12}", String(++n).padStart(12, "0"));
46
+ }
47
+ describe("projects.json fixtures parse with the CLI schema", () => {
48
+ const root = "/fixture-root";
49
+ for (const rel of listFiles(join(fixturesDir, "valid")).concat(listFiles(join(fixturesDir, "edge")))) {
50
+ it(`accepts ${rel}`, () => {
51
+ assert.equal(ProjectsRegistrySchema.safeParse(JSON.parse(readFixture(rel, root))).success, true);
52
+ });
53
+ }
54
+ for (const rel of listFiles(join(fixturesDir, "invalid"))) {
55
+ it(`rejects ${rel}`, () => {
56
+ assert.equal(ProjectsRegistrySchema.safeParse(JSON.parse(readFixture(rel, root))).success, false);
57
+ });
58
+ }
59
+ for (const rel of listFiles(join(fixturesDir, "expected"))) {
60
+ it(`accepts writer output ${rel} (incl. Studio's)`, () => {
61
+ assert.equal(ProjectsRegistrySchema.safeParse(JSON.parse(readFixture(rel, root))).success, true);
62
+ });
63
+ }
64
+ });
65
+ describe("upsert sequences produce the contract bytes", () => {
66
+ for (const seq of sequenceFile.sequences) {
67
+ it(seq.name, () => {
68
+ const { home, root } = makeScratch();
69
+ try {
70
+ let step = 0;
71
+ const env = {
72
+ home,
73
+ newId: idSource(),
74
+ now: () => sequenceFile.clock.startMillis + step * sequenceFile.clock.stepMillis,
75
+ };
76
+ const registryPath = projectsRegistryPath(env);
77
+ let initialBytes = null;
78
+ if (seq.initial) {
79
+ initialBytes = readFixture(seq.initial, root);
80
+ writeFileSync(registryPath, initialBytes);
81
+ }
82
+ for (; step < seq.steps.length; step++) {
83
+ const s = seq.steps[step];
84
+ const input = { projectId: s.projectId, projectName: s.projectName, rootPath: join(root, s.folder), reassign: s.reassign };
85
+ if (s.expectError) {
86
+ const before = readFileSync(registryPath, "utf8");
87
+ assert.throws(() => upsertWorkspaceFolder(input, env), (e) => e.message.includes(s.expectError));
88
+ assert.equal(readFileSync(registryPath, "utf8"), before);
89
+ }
90
+ else {
91
+ upsertWorkspaceFolder(input, env);
92
+ }
93
+ }
94
+ const actual = readFileSync(registryPath, "utf8");
95
+ const expectedRel = seq.expectedCli ?? seq.expected;
96
+ if (process.env.UPDATE_PROJECTS_REGISTRY_FIXTURES === "1") {
97
+ writeFileSync(join(fixturesDir, expectedRel), actual.split(JSON.stringify(root).slice(1, -1)).join("__ROOT__"));
98
+ }
99
+ assert.equal(actual, readFixture(expectedRel, root));
100
+ assert.equal(statSync(registryPath).mode & 0o777, 0o600);
101
+ const quarantined = readdirSync(home).filter((f) => /^projects\.invalid\.\d+\.json$/.test(f));
102
+ if (seq.expectQuarantine) {
103
+ assert.equal(quarantined.length, 1);
104
+ assert.equal(readFileSync(join(home, quarantined[0]), "utf8"), initialBytes);
105
+ }
106
+ else {
107
+ assert.deepEqual(quarantined, []);
108
+ }
109
+ }
110
+ finally {
111
+ rmSync(dirname(home), { recursive: true, force: true });
112
+ }
113
+ });
114
+ }
115
+ });
116
+ describe("fixture copy in TestChimp Studio (desktop) is byte-identical", () => {
117
+ it("matches every file", { skip: existsSync(desktopFixturesDir) ? false : `no desktop checkout at ${desktopFixturesDir}` }, () => {
118
+ const ours = listFiles(fixturesDir).sort();
119
+ const theirs = (function walk(dir) {
120
+ const out = [];
121
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
122
+ const full = join(dir, e.name);
123
+ if (e.isDirectory())
124
+ out.push(...walk(full));
125
+ else
126
+ out.push(relative(desktopFixturesDir, full));
127
+ }
128
+ return out;
129
+ })(desktopFixturesDir).sort();
130
+ assert.deepEqual(theirs, ours);
131
+ for (const rel of ours) {
132
+ assert.equal(readFileSync(join(desktopFixturesDir, rel), "utf8"), readFileSync(join(fixturesDir, rel), "utf8"), rel);
133
+ }
134
+ });
135
+ });
136
+ describe("registry read edge cases", () => {
137
+ let scratch;
138
+ beforeEach(() => {
139
+ scratch = makeScratch();
140
+ });
141
+ afterEach(() => {
142
+ rmSync(dirname(scratch.home), { recursive: true, force: true });
143
+ });
144
+ it("quarantines an unparseable file instead of discarding it", () => {
145
+ const env = { home: scratch.home, now: () => 42 };
146
+ writeFileSync(projectsRegistryPath(env), "{not json");
147
+ assert.deepEqual(readProjectsRegistry(env).mappings, []);
148
+ assert.equal(readFileSync(join(scratch.home, "projects.invalid.42.json"), "utf8"), "{not json");
149
+ });
150
+ it("findWorkspaceMapping is read-only", () => {
151
+ const env = { home: scratch.home };
152
+ assert.equal(findWorkspaceMapping("p1", env), null);
153
+ assert.equal(existsSync(projectsRegistryPath(env)), false);
154
+ writeFileSync(projectsRegistryPath(env), "{not json");
155
+ assert.throws(() => findWorkspaceMapping("p1", env), /INVALID_REGISTRY/);
156
+ assert.equal(readFileSync(projectsRegistryPath(env), "utf8"), "{not json");
157
+ });
158
+ it("honours TESTCHIMP_HOME", () => {
159
+ const prev = process.env.TESTCHIMP_HOME;
160
+ process.env.TESTCHIMP_HOME = scratch.home;
161
+ try {
162
+ upsertWorkspaceFolder({ projectId: "p1", rootPath: join(scratch.root, "repo-a") });
163
+ assert.equal(findWorkspaceMapping("p1")?.folders[0]?.path, join(scratch.root, "repo-a"));
164
+ }
165
+ finally {
166
+ if (prev === undefined)
167
+ delete process.env.TESTCHIMP_HOME;
168
+ else
169
+ process.env.TESTCHIMP_HOME = prev;
170
+ }
171
+ });
172
+ it("rejects a missing folder", () => {
173
+ assert.throws(() => upsertWorkspaceFolder({ projectId: "p1", rootPath: join(scratch.root, "nope") }, { home: scratch.home }), /NOT_FOUND/);
174
+ });
175
+ });
176
+ function git(cwd, ...args) {
177
+ const r = spawnSync("git", args, { cwd, encoding: "utf8" });
178
+ assert.equal(r.status, 0, r.stderr);
179
+ }
180
+ describe("git validation", () => {
181
+ let scratch;
182
+ beforeEach(() => {
183
+ scratch = makeScratch();
184
+ });
185
+ afterEach(() => {
186
+ rmSync(dirname(scratch.home), { recursive: true, force: true });
187
+ });
188
+ it("normalizes remotes like Studio", () => {
189
+ assert.equal(normalizeGitRemoteToFullName("git@github.com:Acme/Shop.git"), "Acme/Shop");
190
+ assert.equal(normalizeGitRemoteToFullName("https://github.com/Acme/Shop.git"), "Acme/Shop");
191
+ assert.equal(normalizeGitRemoteToFullName("ssh://git@gitlab.com/group/sub/repo"), "group/sub/repo");
192
+ });
193
+ it("rejects non-git folders, sub-folders and mismatched remotes", () => {
194
+ const repo = join(scratch.root, "repo-a");
195
+ assert.throws(() => assertFolderMatchesRepo(inspectLocalGitRepo(repo), null), /NOT_A_GIT_REPO/);
196
+ git(repo, "init", "-q");
197
+ git(repo, "remote", "add", "origin", "git@github.com:acme/shop.git");
198
+ assertFolderMatchesRepo(inspectLocalGitRepo(repo), null);
199
+ assertFolderMatchesRepo(inspectLocalGitRepo(repo), "Acme/Shop");
200
+ assert.throws(() => assertFolderMatchesRepo(inspectLocalGitRepo(repo), "acme/other"), /REPO_MISMATCH/);
201
+ const sub = join(repo, "pkg");
202
+ mkdirSync(sub);
203
+ assert.throws(() => assertFolderMatchesRepo(inspectLocalGitRepo(sub), "acme/shop"), /NOT_REPO_ROOT/);
204
+ });
205
+ });
206
+ describe("testchimp workspace map|get (CLI)", () => {
207
+ let scratch;
208
+ beforeEach(() => {
209
+ scratch = makeScratch();
210
+ });
211
+ afterEach(() => {
212
+ rmSync(dirname(scratch.home), { recursive: true, force: true });
213
+ });
214
+ function cli(...args) {
215
+ const env = { ...process.env, TESTCHIMP_HOME: scratch.home };
216
+ delete env.TESTCHIMP_API_KEY;
217
+ delete env.TESTCHIMP_OAUTH_TOKEN;
218
+ return spawnSync(process.execPath, [cliBin, ...args], { env, encoding: "utf8" });
219
+ }
220
+ it("maps a git repo, prints it, and enforces the conflict rule", () => {
221
+ const repo = join(scratch.root, "repo-a");
222
+ git(repo, "init", "-q");
223
+ const unmapped = cli("workspace", "get", "--project-id", "p1");
224
+ assert.equal(unmapped.status, 1);
225
+ const mapped = cli("workspace", "map", "--project-id", "p1", "--folder", repo, "--project-name", "One");
226
+ assert.equal(mapped.status, 0, mapped.stderr);
227
+ assert.match(mapped.stderr, /skipping connected-repository check/);
228
+ const mapping = JSON.parse(mapped.stdout);
229
+ assert.equal(mapping.projectId, "p1");
230
+ assert.equal(mapping.projectName, "One");
231
+ assert.equal(mapping.folders[0]?.path, repo);
232
+ assert.equal(statSync(join(scratch.home, "projects.json")).mode & 0o777, 0o600);
233
+ const got = cli("workspace", "get", "--project-id", "p1");
234
+ assert.equal(got.status, 0, got.stderr);
235
+ assert.deepEqual(JSON.parse(got.stdout), mapping);
236
+ const conflict = cli("workspace", "map", "--project-id", "p2", "--folder", repo);
237
+ assert.equal(conflict.status, 1);
238
+ assert.match(conflict.stderr, /already mapped to project p1/);
239
+ const reassigned = cli("workspace", "map", "--project-id", "p2", "--folder", repo, "--reassign");
240
+ assert.equal(reassigned.status, 0, reassigned.stderr);
241
+ assert.equal(cli("workspace", "get", "--project-id", "p1").status, 1);
242
+ });
243
+ it("refuses a folder that is not a git repo", () => {
244
+ const r = cli("workspace", "map", "--project-id", "p1", "--folder", join(scratch.root, "repo-b"));
245
+ assert.equal(r.status, 1);
246
+ assert.match(r.stderr, /NOT_A_GIT_REPO/);
247
+ assert.equal(existsSync(join(scratch.home, "projects.json")), false);
248
+ });
249
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@testchimp/cli",
3
- "version": "0.1.84",
3
+ "version": "0.1.85",
4
4
  "description": "TestChimp CLI and MCP server — coverage, plans, EaaS, TrueCoverage, API operations (calls /api/mcp/*)",
5
5
  "type": "module",
6
6
  "main": "dist/bin/testchimp.js",
@@ -13,7 +13,7 @@
13
13
  ],
14
14
  "scripts": {
15
15
  "build": "tsc",
16
- "test": "npm run build && node --test dist/core/client.test.js dist/core/tools.test.js dist/chimphands/llmRetryProxy.test.js dist/chimphands/midTurnSteering.test.js dist/chimphands/steeringSidecar.test.js dist/chimphands/agentBranch.test.js dist/chimphands/worktreeCommitPaths.test.js",
16
+ "test": "npm run build && node --test dist/core/client.test.js dist/core/tools.test.js dist/mcp/httpServer.test.js dist/workspace/projectsRegistry.test.js dist/agentwatch/connect.test.js dist/chimphands/llmRetryProxy.test.js dist/chimphands/midTurnSteering.test.js dist/chimphands/steeringSidecar.test.js dist/chimphands/agentBranch.test.js dist/chimphands/worktreeCommitPaths.test.js",
17
17
  "watch": "tsc --watch",
18
18
  "start": "node dist/bin/testchimp.js",
19
19
  "prepublishOnly": "npm run build"