@testchimp/cli 0.1.83 → 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.
- package/README.md +78 -1
- package/dist/agentwatch/connect.d.ts +37 -0
- package/dist/agentwatch/connect.js +250 -0
- package/dist/agentwatch/connect.test.d.ts +1 -0
- package/dist/agentwatch/connect.test.js +166 -0
- package/dist/agentwatch/credentialsFile.d.ts +40 -0
- package/dist/agentwatch/credentialsFile.js +76 -0
- package/dist/cli/program.d.ts +2 -0
- package/dist/cli/program.js +291 -4
- package/dist/core/client.d.ts +32 -0
- package/dist/core/client.js +133 -9
- package/dist/core/client.test.js +130 -2
- package/dist/core/schemas.d.ts +92 -23
- package/dist/core/schemas.js +53 -0
- package/dist/core/tools.d.ts +5 -1
- package/dist/core/tools.js +114 -2
- package/dist/core/tools.test.js +94 -0
- package/dist/mcp/httpServer.d.ts +14 -0
- package/dist/mcp/httpServer.js +169 -0
- package/dist/mcp/httpServer.test.d.ts +1 -0
- package/dist/mcp/httpServer.test.js +108 -0
- package/dist/mcp/server.d.ts +3 -0
- package/dist/mcp/server.js +8 -2
- package/dist/workspace/gitRepo.d.ts +18 -0
- package/dist/workspace/gitRepo.js +103 -0
- package/dist/workspace/projectsRegistry.d.ts +82 -0
- package/dist/workspace/projectsRegistry.js +246 -0
- package/dist/workspace/projectsRegistry.test.d.ts +1 -0
- package/dist/workspace/projectsRegistry.test.js +249 -0
- package/package.json +5 -5
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/** Local git inspection for `testchimp workspace map` (same rules as TestChimp Studio folder mapping). */
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { canonicalizePath } from "./projectsRegistry.js";
|
|
4
|
+
function runGit(cwd, args) {
|
|
5
|
+
const res = spawnSync("git", args, {
|
|
6
|
+
cwd,
|
|
7
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
|
|
8
|
+
encoding: "utf8",
|
|
9
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
10
|
+
});
|
|
11
|
+
if (res.error)
|
|
12
|
+
throw res.error;
|
|
13
|
+
return { code: res.status ?? 1, stdout: res.stdout ?? "" };
|
|
14
|
+
}
|
|
15
|
+
function cleanFullName(pathOrName) {
|
|
16
|
+
const cleaned = String(pathOrName ?? "")
|
|
17
|
+
.replace(/\\/g, "/")
|
|
18
|
+
.replace(/^\/+/, "")
|
|
19
|
+
.replace(/\/+$/, "")
|
|
20
|
+
.replace(/\.git$/i, "")
|
|
21
|
+
.trim();
|
|
22
|
+
if (!cleaned || cleaned.includes(".."))
|
|
23
|
+
return null;
|
|
24
|
+
return cleaned;
|
|
25
|
+
}
|
|
26
|
+
/** Normalize a git remote URL to `owner/repo` (or GitLab nested path); host is ignored. */
|
|
27
|
+
export function normalizeGitRemoteToFullName(remoteUrl) {
|
|
28
|
+
let raw = String(remoteUrl ?? "").trim();
|
|
29
|
+
if (!raw)
|
|
30
|
+
return null;
|
|
31
|
+
raw = raw.replace(/\.git$/i, "");
|
|
32
|
+
const scp = raw.match(/^git@[^:]+:(.+)$/);
|
|
33
|
+
if (scp?.[1])
|
|
34
|
+
return cleanFullName(scp[1]);
|
|
35
|
+
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(raw)) {
|
|
36
|
+
try {
|
|
37
|
+
return cleanFullName(new URL(raw).pathname);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (raw.includes("/") && !raw.startsWith("~"))
|
|
44
|
+
return cleanFullName(raw);
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
/** Case-insensitive compare of repositoryFullName values. */
|
|
48
|
+
export function repositoryFullNamesMatch(expected, actual) {
|
|
49
|
+
const a = String(expected ?? "").trim().replace(/\.git$/i, "").toLowerCase();
|
|
50
|
+
const b = String(actual ?? "").trim().replace(/\.git$/i, "").toLowerCase();
|
|
51
|
+
return Boolean(a && b && a === b);
|
|
52
|
+
}
|
|
53
|
+
export function inspectLocalGitRepo(folderPath) {
|
|
54
|
+
const selectedPath = canonicalizePath(folderPath);
|
|
55
|
+
const inside = runGit(selectedPath, ["rev-parse", "--is-inside-work-tree"]);
|
|
56
|
+
if (inside.code !== 0 || inside.stdout.trim() !== "true") {
|
|
57
|
+
return { selectedPath, toplevelPath: null, isRepoRoot: false, remoteFullNames: [] };
|
|
58
|
+
}
|
|
59
|
+
const top = runGit(selectedPath, ["rev-parse", "--show-toplevel"]);
|
|
60
|
+
let toplevelPath = null;
|
|
61
|
+
if (top.code === 0 && top.stdout.trim()) {
|
|
62
|
+
try {
|
|
63
|
+
toplevelPath = canonicalizePath(top.stdout.trim());
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
toplevelPath = null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const remoteFullNames = [];
|
|
70
|
+
const remotes = runGit(selectedPath, ["remote", "-v"]);
|
|
71
|
+
if (remotes.code === 0) {
|
|
72
|
+
for (const line of remotes.stdout.split("\n")) {
|
|
73
|
+
const m = line.match(/^\S+\s+(\S+)\s+/);
|
|
74
|
+
if (!m?.[1])
|
|
75
|
+
continue;
|
|
76
|
+
const full = normalizeGitRemoteToFullName(m[1]);
|
|
77
|
+
if (full && !remoteFullNames.some((n) => repositoryFullNamesMatch(n, full)))
|
|
78
|
+
remoteFullNames.push(full);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const prefix = runGit(selectedPath, ["rev-parse", "--show-prefix"]);
|
|
82
|
+
const isRepoRoot = prefix.code === 0 && prefix.stdout.replace(/\\/g, "/").trim() === "";
|
|
83
|
+
return { selectedPath, toplevelPath, isRepoRoot, remoteFullNames };
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Throw a user-visible error unless `inspection` is a git work tree; when `expectedRepositoryFullName`
|
|
87
|
+
* is known, also require the repository root and a matching remote.
|
|
88
|
+
*/
|
|
89
|
+
export function assertFolderMatchesRepo(inspection, expectedRepositoryFullName) {
|
|
90
|
+
if (!inspection.toplevelPath) {
|
|
91
|
+
throw new Error(`NOT_A_GIT_REPO: ${inspection.selectedPath} is not a Git repository${expectedRepositoryFullName ? ` (connected repo: ${expectedRepositoryFullName})` : ""}.`);
|
|
92
|
+
}
|
|
93
|
+
const expected = String(expectedRepositoryFullName ?? "").trim();
|
|
94
|
+
if (!expected)
|
|
95
|
+
return;
|
|
96
|
+
if (!inspection.isRepoRoot) {
|
|
97
|
+
throw new Error(`NOT_REPO_ROOT: folder is inside a Git repository but is not its root. Use ${inspection.toplevelPath} (connected repo: ${expected}).`);
|
|
98
|
+
}
|
|
99
|
+
if (!inspection.remoteFullNames.some((n) => repositoryFullNamesMatch(expected, n))) {
|
|
100
|
+
const found = inspection.remoteFullNames.length > 0 ? inspection.remoteFullNames.join(", ") : "none";
|
|
101
|
+
throw new Error(`REPO_MISMATCH: this folder's Git remote does not match the repository connected to the project (expected: ${expected}; found: ${found}).`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
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 { z } from "zod";
|
|
10
|
+
export declare const WorkspaceFolderSchema: z.ZodObject<{
|
|
11
|
+
id: z.ZodString;
|
|
12
|
+
path: z.ZodString;
|
|
13
|
+
name: z.ZodOptional<z.ZodString>;
|
|
14
|
+
}, z.core.$loose>;
|
|
15
|
+
export type WorkspaceFolder = z.infer<typeof WorkspaceFolderSchema>;
|
|
16
|
+
export declare const WorkspaceMappingSchema: z.ZodObject<{
|
|
17
|
+
id: z.ZodString;
|
|
18
|
+
projectId: z.ZodString;
|
|
19
|
+
projectName: z.ZodOptional<z.ZodString>;
|
|
20
|
+
folders: z.ZodArray<z.ZodObject<{
|
|
21
|
+
id: z.ZodString;
|
|
22
|
+
path: z.ZodString;
|
|
23
|
+
name: z.ZodOptional<z.ZodString>;
|
|
24
|
+
}, z.core.$loose>>;
|
|
25
|
+
createdAtMillis: z.ZodNumber;
|
|
26
|
+
lastOpenedAtMillis: z.ZodNumber;
|
|
27
|
+
browserStartUrl: z.ZodOptional<z.ZodString>;
|
|
28
|
+
}, z.core.$loose>;
|
|
29
|
+
export type WorkspaceMapping = z.infer<typeof WorkspaceMappingSchema>;
|
|
30
|
+
export declare const ProjectsRegistrySchema: z.ZodObject<{
|
|
31
|
+
schemaVersion: z.ZodLiteral<2>;
|
|
32
|
+
activeWorkspaceId: z.ZodNullable<z.ZodString>;
|
|
33
|
+
mappings: z.ZodArray<z.ZodObject<{
|
|
34
|
+
id: z.ZodString;
|
|
35
|
+
projectId: z.ZodString;
|
|
36
|
+
projectName: z.ZodOptional<z.ZodString>;
|
|
37
|
+
folders: z.ZodArray<z.ZodObject<{
|
|
38
|
+
id: z.ZodString;
|
|
39
|
+
path: z.ZodString;
|
|
40
|
+
name: z.ZodOptional<z.ZodString>;
|
|
41
|
+
}, z.core.$loose>>;
|
|
42
|
+
createdAtMillis: z.ZodNumber;
|
|
43
|
+
lastOpenedAtMillis: z.ZodNumber;
|
|
44
|
+
browserStartUrl: z.ZodOptional<z.ZodString>;
|
|
45
|
+
}, z.core.$loose>>;
|
|
46
|
+
}, z.core.$loose>;
|
|
47
|
+
export type ProjectsRegistry = z.infer<typeof ProjectsRegistrySchema>;
|
|
48
|
+
/** Injectable clock / id source (tests pin these to compare bytes with Studio). */
|
|
49
|
+
export type RegistryEnv = {
|
|
50
|
+
home?: string;
|
|
51
|
+
now?: () => number;
|
|
52
|
+
newId?: () => string;
|
|
53
|
+
};
|
|
54
|
+
export declare function getTestchimpHome(env?: RegistryEnv): string;
|
|
55
|
+
export declare function projectsRegistryPath(env?: RegistryEnv): string;
|
|
56
|
+
/** Serialized bytes exactly as written to disk (2-space JSON + trailing newline). */
|
|
57
|
+
export declare function serializeRegistry(value: unknown): string;
|
|
58
|
+
/** Real absolute directory path (follows symlinks); same error codes as Studio. */
|
|
59
|
+
export declare function canonicalizePath(input: string): string;
|
|
60
|
+
/**
|
|
61
|
+
* Read projects.json for a write. Missing → empty file created; v1 → migrated + persisted;
|
|
62
|
+
* schema-invalid or unparseable → moved to `projects.invalid.<millis>.json` and reset to empty.
|
|
63
|
+
*/
|
|
64
|
+
export declare function readProjectsRegistry(env?: RegistryEnv): ProjectsRegistry;
|
|
65
|
+
/** Side-effect-free lookup for `workspace get` (never creates, migrates, or quarantines). */
|
|
66
|
+
export declare function findWorkspaceMapping(projectId: string, env?: RegistryEnv): WorkspaceMapping | null;
|
|
67
|
+
export type FolderMappingConflict = {
|
|
68
|
+
projectId: string;
|
|
69
|
+
projectName?: string;
|
|
70
|
+
};
|
|
71
|
+
export type UpsertWorkspaceFolderInput = {
|
|
72
|
+
projectId: string;
|
|
73
|
+
projectName?: string;
|
|
74
|
+
rootPath: string;
|
|
75
|
+
/** When true, steal `rootPath` from another project's mapping if present. */
|
|
76
|
+
reassign?: boolean;
|
|
77
|
+
};
|
|
78
|
+
/**
|
|
79
|
+
* Ensure a project workspace exists and add `rootPath` as a folder (or refresh if already present).
|
|
80
|
+
* Rejects if the path is mapped to a different project unless `reassign` is set.
|
|
81
|
+
*/
|
|
82
|
+
export declare function upsertWorkspaceFolder(input: UpsertWorkspaceFolderInput, env?: RegistryEnv): WorkspaceMapping;
|
|
@@ -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
|
+
});
|