@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.
@@ -1,5 +1,6 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { describe, it } from "node:test";
3
+ import { TestChimpHttpError } from "./client.js";
3
4
  import { getToolDefinition, runTool } from "./tools.js";
4
5
  const runtimeObservation = {
5
6
  windowStartMillis: "1789497600000",
@@ -163,3 +164,96 @@ describe("list-meeting-filter-options", () => {
163
164
  assert.deepEqual(request, { path: "/api/mcp/list_meeting_filter_options", body: {} });
164
165
  });
165
166
  });
167
+ describe("ack-bot-events", () => {
168
+ it("posts deduped eventIds to the delivery ackUrl", async () => {
169
+ let request;
170
+ const response = JSON.stringify({ results: [{ eventId: "e1", status: "BOT_ACK_ACKED" }] });
171
+ const result = await runTool("ack-bot-events", { eventIds: ["e1", "e1", "e2"], ackUrl: "https://ingress.testchimp.io/bot/events/ack" }, {
172
+ postMcp: async () => assert.fail("ack must not go to featureservice"),
173
+ postIngress: async (pathOrUrl, body) => {
174
+ request = { pathOrUrl, body };
175
+ return response;
176
+ },
177
+ });
178
+ assert.deepEqual(request, {
179
+ pathOrUrl: "https://ingress.testchimp.io/bot/events/ack",
180
+ body: { eventIds: ["e1", "e2"] },
181
+ });
182
+ assert.equal(result, response);
183
+ });
184
+ it("defaults to the ingress ack path", async () => {
185
+ let target;
186
+ await runTool("ack-bot-events", { eventIds: ["e1"] }, {
187
+ postMcp: async () => "{}",
188
+ postIngress: async (pathOrUrl) => {
189
+ target = pathOrUrl;
190
+ return "{}";
191
+ },
192
+ });
193
+ assert.equal(target, "/bot/events/ack");
194
+ });
195
+ it("explains a 404 from an older backend", async () => {
196
+ await assert.rejects(runTool("ack-bot-events", { eventIds: ["e1"] }, {
197
+ postMcp: async () => "{}",
198
+ postIngress: async () => {
199
+ throw new TestChimpHttpError(404, "Not Found", "");
200
+ },
201
+ }), /does not support bot acks yet/);
202
+ });
203
+ it("validates eventIds count and characters", async () => {
204
+ const ctx = { postMcp: async () => "{}", postIngress: async () => "{}" };
205
+ await assert.rejects(runTool("ack-bot-events", { eventIds: [] }, ctx), /Invalid input/);
206
+ const tooMany = Array.from({ length: 101 }, (_, i) => `e${i}`);
207
+ await assert.rejects(runTool("ack-bot-events", { eventIds: tooMany }, ctx), /Invalid input/);
208
+ await assert.rejects(runTool("ack-bot-events", { eventIds: ["é"] }, ctx), /Invalid input/);
209
+ });
210
+ });
211
+ describe("bot tools", () => {
212
+ async function capture(kebab, args) {
213
+ let request;
214
+ await runTool(kebab, args, {
215
+ postMcp: async (path, body) => {
216
+ request = { path, body };
217
+ return "{}";
218
+ },
219
+ });
220
+ return request;
221
+ }
222
+ it("maps read tools to their endpoints", async () => {
223
+ assert.deepEqual(await capture("get-my-tasks", { userId: "u1" }), {
224
+ path: "/api/mcp/get_my_tasks",
225
+ body: { userId: "u1" },
226
+ });
227
+ assert.deepEqual(await capture("get-my-tasks", {}), { path: "/api/mcp/get_my_tasks", body: {} });
228
+ assert.deepEqual(await capture("list-tests-awaiting-verification", { limit: 5 }), {
229
+ path: "/api/mcp/list_tests_awaiting_verification",
230
+ body: { limit: 5 },
231
+ });
232
+ assert.deepEqual(await capture("get-qa-posture", {}), { path: "/api/mcp/get_qa_posture", body: {} });
233
+ assert.deepEqual(await capture("get-bot-compat", {}), { path: "/api/mcp/get_bot_compat", body: {} });
234
+ assert.deepEqual(await capture("get-bot-profile", {}), { path: "/api/mcp/get_bot_profile", body: {} });
235
+ });
236
+ it("registers a bot profile with default filter op", async () => {
237
+ assert.deepEqual(await capture("register-bot-profile", {
238
+ role: "QA_ENGINEER",
239
+ responsibilities: " checkout flows ",
240
+ capabilities: ["E2E_AUTHORING", "TEST_BATCH_FIX"],
241
+ subscriptions: [
242
+ { eventType: "git-push", filters: [{ field: "author", value: "me" }] },
243
+ { eventType: "e2e-batch-completed" },
244
+ ],
245
+ }), {
246
+ path: "/bots/register_profile",
247
+ body: {
248
+ role: "QA_ENGINEER",
249
+ responsibilities: "checkout flows",
250
+ capabilities: ["E2E_AUTHORING", "TEST_BATCH_FIX"],
251
+ subscriptions: [
252
+ { eventType: "git-push", filters: [{ field: "author", op: "eq", value: "me" }] },
253
+ { eventType: "e2e-batch-completed", filters: [] },
254
+ ],
255
+ },
256
+ });
257
+ await assert.rejects(capture("register-bot-profile", { role: "CEO" }), /Invalid input/);
258
+ });
259
+ });
@@ -0,0 +1,14 @@
1
+ import { type Server } from "node:http";
2
+ export declare const MAX_MCP_BODY_BYTES: number;
3
+ export interface McpHttpServerOptions {
4
+ port: number;
5
+ host: string;
6
+ }
7
+ /**
8
+ * Unverified peek at a JWT `exp` so expired access tokens get an HTTP 401 (which makes MCP clients
9
+ * refresh) instead of a tool-level error. Signature verification stays with TestChimp.
10
+ */
11
+ export declare function isExpiredJwt(token: string, nowSeconds?: number): boolean;
12
+ /** Remote MCP (Streamable HTTP, stateless). Each caller's bearer is forwarded to TestChimp; env credentials are never used. */
13
+ export declare function createMcpHttpServer(): Server;
14
+ export declare function runMcpHttpServer(options: McpHttpServerOptions): Promise<Server>;
@@ -0,0 +1,169 @@
1
+ import { createServer } from "node:http";
2
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
3
+ import { getBackendUrl, runWithRequestAuth } from "../core/client.js";
4
+ import { createMcpServer } from "./server.js";
5
+ export const MAX_MCP_BODY_BYTES = 1024 * 1024;
6
+ const PROTECTED_RESOURCE_PATH = "/.well-known/oauth-protected-resource";
7
+ class BodyTooLargeError extends Error {
8
+ }
9
+ function firstHeader(value) {
10
+ return Array.isArray(value) ? value[0] : value;
11
+ }
12
+ /** Public origin of this server: TESTCHIMP_MCP_PUBLIC_URL, else derived from the request (Cloud Run sets X-Forwarded-Proto). */
13
+ function publicBaseUrl(req) {
14
+ const configured = process.env.TESTCHIMP_MCP_PUBLIC_URL?.trim();
15
+ if (configured)
16
+ return configured.replace(/\/+$/, "").replace(/\/mcp$/, "");
17
+ const proto = firstHeader(req.headers["x-forwarded-proto"])?.split(",")[0].trim() === "https" ? "https" : "http";
18
+ const host = firstHeader(req.headers["x-forwarded-host"])?.split(",")[0].trim() || req.headers.host || "localhost";
19
+ return `${proto}://${host}`;
20
+ }
21
+ function authorizationServer() {
22
+ return process.env.TESTCHIMP_OAUTH_ISSUER?.trim().replace(/\/+$/, "") || getBackendUrl();
23
+ }
24
+ function sendJson(res, status, body, headers = {}) {
25
+ const payload = JSON.stringify(body);
26
+ res.writeHead(status, {
27
+ "Content-Type": "application/json",
28
+ "Content-Length": Buffer.byteLength(payload),
29
+ ...headers,
30
+ });
31
+ res.end(payload);
32
+ }
33
+ function jsonRpcError(res, status, message, headers = {}) {
34
+ sendJson(res, status, { jsonrpc: "2.0", error: { code: -32000, message }, id: null }, headers);
35
+ }
36
+ function bearerToken(req) {
37
+ const match = /^Bearer\s+(\S+)\s*$/i.exec(firstHeader(req.headers.authorization) ?? "");
38
+ return match?.[1];
39
+ }
40
+ /**
41
+ * Unverified peek at a JWT `exp` so expired access tokens get an HTTP 401 (which makes MCP clients
42
+ * refresh) instead of a tool-level error. Signature verification stays with TestChimp.
43
+ */
44
+ export function isExpiredJwt(token, nowSeconds = Math.floor(Date.now() / 1000)) {
45
+ const parts = token.split(".");
46
+ if (parts.length !== 3)
47
+ return false;
48
+ try {
49
+ const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
50
+ return typeof payload.exp === "number" && payload.exp <= nowSeconds;
51
+ }
52
+ catch {
53
+ return false;
54
+ }
55
+ }
56
+ async function readJsonBody(req) {
57
+ const chunks = [];
58
+ let size = 0;
59
+ for await (const chunk of req) {
60
+ const buf = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
61
+ size += buf.length;
62
+ if (size > MAX_MCP_BODY_BYTES)
63
+ throw new BodyTooLargeError();
64
+ chunks.push(buf);
65
+ }
66
+ const text = Buffer.concat(chunks).toString("utf8");
67
+ return text.trim() === "" ? undefined : JSON.parse(text);
68
+ }
69
+ async function handleMcpPost(req, res) {
70
+ const token = bearerToken(req);
71
+ if (!token) {
72
+ const metadataUrl = `${publicBaseUrl(req)}${PROTECTED_RESOURCE_PATH}`;
73
+ jsonRpcError(res, 401, "Unauthorized: Authorization: Bearer <token> is required", {
74
+ "WWW-Authenticate": `Bearer resource_metadata="${metadataUrl}"`,
75
+ });
76
+ return;
77
+ }
78
+ if (isExpiredJwt(token)) {
79
+ const metadataUrl = `${publicBaseUrl(req)}${PROTECTED_RESOURCE_PATH}`;
80
+ jsonRpcError(res, 401, "Unauthorized: access token expired", {
81
+ "WWW-Authenticate": `Bearer error="invalid_token", error_description="expired", resource_metadata="${metadataUrl}"`,
82
+ });
83
+ return;
84
+ }
85
+ const declaredLength = Number(firstHeader(req.headers["content-length"]) ?? 0);
86
+ if (declaredLength > MAX_MCP_BODY_BYTES) {
87
+ jsonRpcError(res, 413, "Request body too large");
88
+ return;
89
+ }
90
+ let body;
91
+ try {
92
+ body = await readJsonBody(req);
93
+ }
94
+ catch (e) {
95
+ if (e instanceof BodyTooLargeError)
96
+ jsonRpcError(res, 413, "Request body too large");
97
+ else
98
+ jsonRpcError(res, 400, "Parse error: request body must be JSON");
99
+ return;
100
+ }
101
+ const server = createMcpServer();
102
+ const transport = new StreamableHTTPServerTransport({
103
+ sessionIdGenerator: undefined,
104
+ enableJsonResponse: true,
105
+ });
106
+ res.on("close", () => {
107
+ void transport.close();
108
+ void server.close();
109
+ });
110
+ const botId = firstHeader(req.headers["bot-id"])?.trim() || undefined;
111
+ await runWithRequestAuth({ bearerToken: token, botId, isolated: true }, async () => {
112
+ await server.connect(transport);
113
+ await transport.handleRequest(req, res, body);
114
+ });
115
+ }
116
+ async function route(req, res) {
117
+ const path = new URL(req.url ?? "/", "http://localhost").pathname.replace(/\/+$/, "") || "/";
118
+ if (path === "/healthz") {
119
+ sendJson(res, 200, { status: "ok" });
120
+ return;
121
+ }
122
+ if (path === PROTECTED_RESOURCE_PATH || path === `${PROTECTED_RESOURCE_PATH}/mcp`) {
123
+ if (req.method !== "GET" && req.method !== "HEAD") {
124
+ sendJson(res, 405, { error: "Method not allowed" }, { Allow: "GET" });
125
+ return;
126
+ }
127
+ // RFC 9728 protected-resource metadata keys are spec-mandated snake_case.
128
+ sendJson(res, 200, {
129
+ resource: `${publicBaseUrl(req)}/mcp`,
130
+ authorization_servers: [authorizationServer()],
131
+ bearer_methods_supported: ["header"],
132
+ scopes_supported: ["testchimp"],
133
+ });
134
+ return;
135
+ }
136
+ if (path === "/mcp") {
137
+ if (req.method === "POST") {
138
+ await handleMcpPost(req, res);
139
+ return;
140
+ }
141
+ jsonRpcError(res, 405, "Method not allowed (stateless server: use POST)", { Allow: "POST" });
142
+ return;
143
+ }
144
+ sendJson(res, 404, { error: "Not found" });
145
+ }
146
+ /** Remote MCP (Streamable HTTP, stateless). Each caller's bearer is forwarded to TestChimp; env credentials are never used. */
147
+ export function createMcpHttpServer() {
148
+ return createServer((req, res) => {
149
+ route(req, res).catch((e) => {
150
+ console.error(`[testchimp mcp] request failed: ${e instanceof Error ? e.message : String(e)}`);
151
+ if (!res.headersSent)
152
+ jsonRpcError(res, 500, "Internal server error");
153
+ else
154
+ res.end();
155
+ });
156
+ });
157
+ }
158
+ export async function runMcpHttpServer(options) {
159
+ const server = createMcpHttpServer();
160
+ await new Promise((resolve, reject) => {
161
+ server.once("error", reject);
162
+ server.listen(options.port, options.host, () => resolve());
163
+ });
164
+ console.error(`[testchimp mcp] Streamable HTTP listening on http://${options.host}:${options.port}/mcp`);
165
+ const shutdown = () => server.close(() => process.exit(0));
166
+ process.once("SIGTERM", shutdown);
167
+ process.once("SIGINT", shutdown);
168
+ return server;
169
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,108 @@
1
+ import assert from "node:assert/strict";
2
+ import { after, afterEach, before, describe, it } from "node:test";
3
+ import { createMcpHttpServer, isExpiredJwt } from "./httpServer.js";
4
+ const originalFetch = globalThis.fetch;
5
+ const originalEnv = { ...process.env };
6
+ let server;
7
+ let baseUrl;
8
+ before(async () => {
9
+ server = createMcpHttpServer();
10
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve()));
11
+ baseUrl = `http://127.0.0.1:${server.address().port}`;
12
+ });
13
+ after(async () => {
14
+ await new Promise((resolve) => server.close(() => resolve()));
15
+ });
16
+ afterEach(() => {
17
+ globalThis.fetch = originalFetch;
18
+ process.env = { ...originalEnv };
19
+ });
20
+ describe("isExpiredJwt", () => {
21
+ const jwt = (payload) => `e30.${Buffer.from(JSON.stringify(payload)).toString("base64url")}.sig`;
22
+ it("flags expired tokens and passes live or opaque ones", () => {
23
+ assert.equal(isExpiredJwt(jwt({ exp: 100 }), 200), true);
24
+ assert.equal(isExpiredJwt(jwt({ exp: 300 }), 200), false);
25
+ assert.equal(isExpiredJwt(jwt({}), 200), false);
26
+ assert.equal(isExpiredJwt("opaque-token", 200), false);
27
+ });
28
+ });
29
+ describe("remote MCP over HTTP", () => {
30
+ it("serves /healthz", async () => {
31
+ const res = await originalFetch(`${baseUrl}/healthz`);
32
+ assert.equal(res.status, 200);
33
+ });
34
+ it("rejects /mcp without a bearer and points at the resource metadata", async () => {
35
+ process.env.TESTCHIMP_MCP_PUBLIC_URL = "https://mcp.example.com";
36
+ const res = await originalFetch(`${baseUrl}/mcp`, {
37
+ method: "POST",
38
+ headers: { "Content-Type": "application/json" },
39
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }),
40
+ });
41
+ assert.equal(res.status, 401);
42
+ assert.equal(res.headers.get("www-authenticate"), 'Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource"');
43
+ });
44
+ it("returns 405 for GET /mcp in stateless mode", async () => {
45
+ const res = await originalFetch(`${baseUrl}/mcp`, { headers: { Authorization: "Bearer t" } });
46
+ assert.equal(res.status, 405);
47
+ });
48
+ it("publishes RFC 9728 protected-resource metadata", async () => {
49
+ process.env.TESTCHIMP_BACKEND_URL = "https://featureservice-staging.testchimp.io";
50
+ delete process.env.TESTCHIMP_MCP_PUBLIC_URL;
51
+ delete process.env.TESTCHIMP_OAUTH_ISSUER;
52
+ for (const path of ["/.well-known/oauth-protected-resource", "/.well-known/oauth-protected-resource/mcp"]) {
53
+ const res = await originalFetch(`${baseUrl}${path}`, { headers: { "X-Forwarded-Proto": "https" } });
54
+ assert.equal(res.status, 200);
55
+ const host = new URL(baseUrl).host;
56
+ assert.deepEqual(await res.json(), {
57
+ resource: `https://${host}/mcp`,
58
+ authorization_servers: ["https://featureservice-staging.testchimp.io"],
59
+ bearer_methods_supported: ["header"],
60
+ scopes_supported: ["testchimp"],
61
+ });
62
+ }
63
+ });
64
+ it("rejects oversized bodies", async () => {
65
+ const res = await originalFetch(`${baseUrl}/mcp`, {
66
+ method: "POST",
67
+ headers: { Authorization: "Bearer t", "Content-Type": "application/json" },
68
+ body: JSON.stringify({ pad: "x".repeat(1024 * 1024 + 10) }),
69
+ });
70
+ assert.equal(res.status, 413);
71
+ });
72
+ it("forwards the caller's bearer (never the server API key) on tool calls", async () => {
73
+ process.env.TESTCHIMP_BACKEND_URL = "https://featureservice.example.com";
74
+ process.env.TESTCHIMP_API_KEY = "server-key";
75
+ process.env.TESTCHIMP_OAUTH_TOKEN = "server-token";
76
+ const upstream = [];
77
+ globalThis.fetch = (async (input, init) => {
78
+ const url = String(input);
79
+ if (url.startsWith(baseUrl))
80
+ return originalFetch(input, init);
81
+ upstream.push({ url, headers: init?.headers });
82
+ return new Response(JSON.stringify({ minSkillVersion: "1.0.53", minCliVersion: "0.1.85", eventSchemaVersion: 1 }));
83
+ });
84
+ const res = await originalFetch(`${baseUrl}/mcp`, {
85
+ method: "POST",
86
+ headers: {
87
+ Authorization: "Bearer caller-jwt",
88
+ "Content-Type": "application/json",
89
+ Accept: "application/json, text/event-stream",
90
+ },
91
+ body: JSON.stringify({
92
+ jsonrpc: "2.0",
93
+ id: 7,
94
+ method: "tools/call",
95
+ params: { name: "get-bot-compat", arguments: {} },
96
+ }),
97
+ });
98
+ assert.equal(res.status, 200);
99
+ const payload = (await res.json());
100
+ assert.equal(JSON.parse(payload.result?.content?.[0]?.text ?? "{}").minCliVersion, "0.1.85");
101
+ assert.deepEqual(upstream, [
102
+ {
103
+ url: "https://featureservice.example.com/api/mcp/get_bot_compat",
104
+ headers: { "Content-Type": "application/json", Authorization: "Bearer caller-jwt" },
105
+ },
106
+ ]);
107
+ });
108
+ });
@@ -1 +1,4 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ /** McpServer with every TestChimp tool registered (shared by stdio and HTTP transports). */
3
+ export declare function createMcpServer(): McpServer;
1
4
  export declare function runMcpServer(): Promise<void>;
@@ -1,6 +1,6 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
- import { postMcp } from "../core/client.js";
3
+ import { postIngress, postMcp } from "../core/client.js";
4
4
  import { TOOL_DEFINITIONS, runTool } from "../core/tools.js";
5
5
  import { PACKAGE_VERSION } from "../core/version.js";
6
6
  function textResult(json) {
@@ -8,7 +8,8 @@ function textResult(json) {
8
8
  content: [{ type: "text", text: json }],
9
9
  };
10
10
  }
11
- export async function runMcpServer() {
11
+ /** McpServer with every TestChimp tool registered (shared by stdio and HTTP transports). */
12
+ export function createMcpServer() {
12
13
  const server = new McpServer({ name: "testchimp", version: PACKAGE_VERSION }, { capabilities: { tools: {}, logging: {} } });
13
14
  for (const def of TOOL_DEFINITIONS) {
14
15
  server.registerTool(def.kebab, {
@@ -27,11 +28,16 @@ export async function runMcpServer() {
27
28
  : undefined;
28
29
  const json = await runTool(def.kebab, args ?? {}, {
29
30
  postMcp,
31
+ postIngress,
30
32
  onProgress,
31
33
  });
32
34
  return textResult(json);
33
35
  });
34
36
  }
37
+ return server;
38
+ }
39
+ export async function runMcpServer() {
40
+ const server = createMcpServer();
35
41
  const transport = new StdioServerTransport();
36
42
  await server.connect(transport);
37
43
  }
@@ -0,0 +1,18 @@
1
+ /** Local git inspection for `testchimp workspace map` (same rules as TestChimp Studio folder mapping). */
2
+ /** Normalize a git remote URL to `owner/repo` (or GitLab nested path); host is ignored. */
3
+ export declare function normalizeGitRemoteToFullName(remoteUrl: string | undefined | null): string | null;
4
+ /** Case-insensitive compare of repositoryFullName values. */
5
+ export declare function repositoryFullNamesMatch(expected: string | undefined | null, actual: string | undefined | null): boolean;
6
+ export type LocalGitRepoInspection = {
7
+ selectedPath: string;
8
+ /** Canonical git toplevel, or null if not a work tree. */
9
+ toplevelPath: string | null;
10
+ isRepoRoot: boolean;
11
+ remoteFullNames: string[];
12
+ };
13
+ export declare function inspectLocalGitRepo(folderPath: string): LocalGitRepoInspection;
14
+ /**
15
+ * Throw a user-visible error unless `inspection` is a git work tree; when `expectedRepositoryFullName`
16
+ * is known, also require the repository root and a matching remote.
17
+ */
18
+ export declare function assertFolderMatchesRepo(inspection: LocalGitRepoInspection, expectedRepositoryFullName: string | null): void;
@@ -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;