@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.
- 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 +288 -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 +91 -23
- package/dist/core/schemas.js +49 -0
- package/dist/core/tools.d.ts +5 -1
- package/dist/core/tools.js +110 -0
- 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 +2 -2
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local AgentWatch credentials in `~/.testchimp/agentwatch/credentials.json` (TESTCHIMP_HOME honoured).
|
|
3
|
+
*
|
|
4
|
+
* Written by `testchimp bot connect`, read by headless AgentWatch (desktop
|
|
5
|
+
* `src/main/auth/agentwatchCredentials.ts`) so it runs without TestChimp Studio sign-in. Keyed by
|
|
6
|
+
* project because the API key is per project. Holds the user's PAT: directory 0700, file 0600.
|
|
7
|
+
*/
|
|
8
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
import { getTestchimpHome } from "../workspace/projectsRegistry.js";
|
|
12
|
+
export const AgentwatchProjectCredentialsSchema = z.looseObject({
|
|
13
|
+
userId: z.string().min(1),
|
|
14
|
+
email: z.string().optional(),
|
|
15
|
+
userAuthKey: z.string().min(8),
|
|
16
|
+
projectApiKey: z.string().min(8),
|
|
17
|
+
botId: z.string().optional(),
|
|
18
|
+
backendUrl: z.string().url(),
|
|
19
|
+
ingressUrl: z.string().url().optional(),
|
|
20
|
+
savedAtMillis: z.number().int().nonnegative(),
|
|
21
|
+
});
|
|
22
|
+
export const AgentwatchCredentialsFileSchema = z.looseObject({
|
|
23
|
+
schemaVersion: z.literal(1),
|
|
24
|
+
projects: z.record(z.string(), AgentwatchProjectCredentialsSchema),
|
|
25
|
+
});
|
|
26
|
+
export function agentwatchCredentialsPath(env = {}) {
|
|
27
|
+
return join(getTestchimpHome(env), "agentwatch", "credentials.json");
|
|
28
|
+
}
|
|
29
|
+
/** Parsed file, or null when missing / unreadable / another schema. */
|
|
30
|
+
export function readAgentwatchCredentials(env = {}) {
|
|
31
|
+
const path = agentwatchCredentialsPath(env);
|
|
32
|
+
if (!existsSync(path))
|
|
33
|
+
return null;
|
|
34
|
+
try {
|
|
35
|
+
const parsed = AgentwatchCredentialsFileSchema.safeParse(JSON.parse(readFileSync(path, "utf8")));
|
|
36
|
+
return parsed.success ? parsed.data : null;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function writeAtomic(file, env) {
|
|
43
|
+
const path = agentwatchCredentialsPath(env);
|
|
44
|
+
const dir = join(getTestchimpHome(env), "agentwatch");
|
|
45
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
46
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
47
|
+
writeFileSync(tmp, `${JSON.stringify(file, null, 2)}\n`, { mode: 0o600 });
|
|
48
|
+
renameSync(tmp, path);
|
|
49
|
+
try {
|
|
50
|
+
chmodSync(path, 0o600);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// Windows ignores POSIX modes.
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
export function saveProjectCredentials(projectId, creds, env = {}) {
|
|
57
|
+
const entry = AgentwatchProjectCredentialsSchema.parse(creds);
|
|
58
|
+
const file = readAgentwatchCredentials(env) ?? { schemaVersion: 1, projects: {} };
|
|
59
|
+
file.projects[projectId] = entry;
|
|
60
|
+
writeAtomic(file, env);
|
|
61
|
+
return agentwatchCredentialsPath(env);
|
|
62
|
+
}
|
|
63
|
+
/** True when an entry was removed. Deletes the file once no project is left. */
|
|
64
|
+
export function removeProjectCredentials(projectId, env = {}) {
|
|
65
|
+
const file = readAgentwatchCredentials(env);
|
|
66
|
+
if (!file || !(projectId in file.projects))
|
|
67
|
+
return false;
|
|
68
|
+
delete file.projects[projectId];
|
|
69
|
+
if (Object.keys(file.projects).length === 0) {
|
|
70
|
+
unlinkSync(agentwatchCredentialsPath(env));
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
writeAtomic(file, env);
|
|
74
|
+
}
|
|
75
|
+
return true;
|
|
76
|
+
}
|
package/dist/cli/program.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
2
|
import { PACKAGE_VERSION } from "../core/version.js";
|
|
3
3
|
export { PACKAGE_VERSION };
|
|
4
|
+
/** Numeric x.y.z comparison; prerelease / build suffixes are ignored. */
|
|
5
|
+
export declare function compareSemver(a: string, b: string): number;
|
|
4
6
|
export declare function buildCliProgram(): Command;
|
package/dist/cli/program.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Command, Option } from "commander";
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
3
|
import { readFile } from "node:fs/promises";
|
|
4
|
-
import { DEFAULT_BACKEND, postMcp } from "../core/client.js";
|
|
4
|
+
import { DEFAULT_BACKEND, DEFAULT_INGRESS, postIngress, postMcp } from "../core/client.js";
|
|
5
5
|
import { deepMerge } from "../core/merge.js";
|
|
6
6
|
import { runTool } from "../core/tools.js";
|
|
7
7
|
import { TOOL_DEFINITIONS } from "../core/tools.js";
|
|
@@ -15,6 +15,24 @@ function isPerfComparisonRegressed(parsed) {
|
|
|
15
15
|
const body = parsed;
|
|
16
16
|
return body.regressed === true || body.comparison?.regressed === true;
|
|
17
17
|
}
|
|
18
|
+
/** Ack statuses that mean the id or bot identity is wrong (exit non-zero). */
|
|
19
|
+
const BOT_ACK_FAILURE_STATUSES = new Set([
|
|
20
|
+
"BOT_ACK_UNKNOWN_EVENT",
|
|
21
|
+
"BOT_ACK_NOT_A_TARGET",
|
|
22
|
+
"BOT_ACK_MISSING_BOT_ID",
|
|
23
|
+
]);
|
|
24
|
+
/** Numeric x.y.z comparison; prerelease / build suffixes are ignored. */
|
|
25
|
+
export function compareSemver(a, b) {
|
|
26
|
+
const parts = (v) => v.trim().replace(/^v/, "").split(/[-+]/)[0].split(".").map((p) => parseInt(p, 10) || 0);
|
|
27
|
+
const pa = parts(a);
|
|
28
|
+
const pb = parts(b);
|
|
29
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
30
|
+
const d = (pa[i] ?? 0) - (pb[i] ?? 0);
|
|
31
|
+
if (d !== 0)
|
|
32
|
+
return d < 0 ? -1 : 1;
|
|
33
|
+
}
|
|
34
|
+
return 0;
|
|
35
|
+
}
|
|
18
36
|
function parseRecordTypesCsv(raw) {
|
|
19
37
|
return String(raw)
|
|
20
38
|
.split(",")
|
|
@@ -102,8 +120,19 @@ export function buildCliProgram() {
|
|
|
102
120
|
program.name("testchimp").description("TestChimp CLI — call project MCP HTTP APIs").version(PACKAGE_VERSION);
|
|
103
121
|
program
|
|
104
122
|
.command("mcp")
|
|
105
|
-
.description("Start the TestChimp MCP server (stdio transport)")
|
|
106
|
-
.
|
|
123
|
+
.description("Start the TestChimp MCP server (stdio transport; --http for remote Streamable HTTP with OAuth bearer auth)")
|
|
124
|
+
.option("--http", "Serve MCP over Streamable HTTP (stateless) at /mcp instead of stdio")
|
|
125
|
+
.option("--port <n>", "HTTP port (default: PORT env or 8080)", (v) => parseInt(v, 10))
|
|
126
|
+
.option("--host <h>", "HTTP bind host", "0.0.0.0")
|
|
127
|
+
.action(async (opts) => {
|
|
128
|
+
if (opts.http) {
|
|
129
|
+
const port = opts.port ?? (process.env.PORT ? parseInt(process.env.PORT, 10) : 8080);
|
|
130
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535)
|
|
131
|
+
throw new Error(`Invalid port: ${port}`);
|
|
132
|
+
const { runMcpHttpServer } = await import("../mcp/httpServer.js");
|
|
133
|
+
await runMcpHttpServer({ port, host: String(opts.host) });
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
107
136
|
const { runMcpServer } = await import("../mcp/server.js");
|
|
108
137
|
await runMcpServer();
|
|
109
138
|
});
|
|
@@ -1728,6 +1757,261 @@ export function buildCliProgram() {
|
|
|
1728
1757
|
body.limit = opts.limit;
|
|
1729
1758
|
console.log(await runTool("list-api-operation-interactions", mergeBodies(body, opts.jsonInput), { postMcp }));
|
|
1730
1759
|
});
|
|
1760
|
+
for (const kebab of ["get-qa-posture", "get-bot-compat"]) {
|
|
1761
|
+
program
|
|
1762
|
+
.command(kebab)
|
|
1763
|
+
.description(TOOL_DEFINITIONS.find((t) => t.kebab === kebab).description)
|
|
1764
|
+
.addOption(jsonInputOption())
|
|
1765
|
+
.action(async (opts) => {
|
|
1766
|
+
console.log(await runTool(kebab, mergeBodies({}, opts.jsonInput), { postMcp }));
|
|
1767
|
+
});
|
|
1768
|
+
}
|
|
1769
|
+
program
|
|
1770
|
+
.command("get-my-tasks")
|
|
1771
|
+
.description(TOOL_DEFINITIONS.find((t) => t.kebab === "get-my-tasks").description)
|
|
1772
|
+
.addOption(jsonInputOption())
|
|
1773
|
+
.option("--user-id <id>", "Team member user id (required with an API key; OAuth tokens imply the user)")
|
|
1774
|
+
.action(async (opts) => {
|
|
1775
|
+
const body = {};
|
|
1776
|
+
if (opts.userId)
|
|
1777
|
+
body.userId = String(opts.userId);
|
|
1778
|
+
console.log(await runTool("get-my-tasks", mergeBodies(body, opts.jsonInput), { postMcp }));
|
|
1779
|
+
});
|
|
1780
|
+
program
|
|
1781
|
+
.command("list-tests-awaiting-verification")
|
|
1782
|
+
.description(TOOL_DEFINITIONS.find((t) => t.kebab === "list-tests-awaiting-verification").description)
|
|
1783
|
+
.addOption(jsonInputOption())
|
|
1784
|
+
.option("--user-id <id>", "Team member user id")
|
|
1785
|
+
.option("--limit <n>", "Maximum results", (v) => parseInt(v, 10))
|
|
1786
|
+
.action(async (opts) => {
|
|
1787
|
+
const body = {};
|
|
1788
|
+
if (opts.userId)
|
|
1789
|
+
body.userId = String(opts.userId);
|
|
1790
|
+
if (opts.limit != null && !Number.isNaN(opts.limit))
|
|
1791
|
+
body.limit = opts.limit;
|
|
1792
|
+
console.log(await runTool("list-tests-awaiting-verification", mergeBodies(body, opts.jsonInput), { postMcp }));
|
|
1793
|
+
});
|
|
1794
|
+
program
|
|
1795
|
+
.command("get-bot-profile")
|
|
1796
|
+
.description(TOOL_DEFINITIONS.find((t) => t.kebab === "get-bot-profile").description)
|
|
1797
|
+
.addOption(jsonInputOption())
|
|
1798
|
+
.option("--bot-id <id>", "Bot id (defaults to TESTCHIMP_BOT_ID / OAuth token)")
|
|
1799
|
+
.action(async (opts) => {
|
|
1800
|
+
const body = {};
|
|
1801
|
+
if (opts.botId)
|
|
1802
|
+
body.botId = String(opts.botId);
|
|
1803
|
+
console.log(await runTool("get-bot-profile", mergeBodies(body, opts.jsonInput), { postMcp }));
|
|
1804
|
+
});
|
|
1805
|
+
const bot = program.command("bot").description("TestChimp QA-bot helpers (profile, event acks, compatibility)");
|
|
1806
|
+
bot
|
|
1807
|
+
.command("ack")
|
|
1808
|
+
.description(TOOL_DEFINITIONS.find((t) => t.kebab === "ack-bot-events").description)
|
|
1809
|
+
.argument("<eventIds...>", "Event ids from the webhook delivery (max 100)")
|
|
1810
|
+
.option("--ack-url <url>", "Delivery ackUrl (default: TESTCHIMP_INGRESS_URL + /bot/events/ack)")
|
|
1811
|
+
.action(async (eventIds, opts) => {
|
|
1812
|
+
const body = { eventIds };
|
|
1813
|
+
if (opts.ackUrl)
|
|
1814
|
+
body.ackUrl = String(opts.ackUrl);
|
|
1815
|
+
const out = await runTool("ack-bot-events", body, { postMcp, postIngress });
|
|
1816
|
+
let results = [];
|
|
1817
|
+
try {
|
|
1818
|
+
const parsed = JSON.parse(out);
|
|
1819
|
+
results = Array.isArray(parsed.results) ? parsed.results : [];
|
|
1820
|
+
}
|
|
1821
|
+
catch {
|
|
1822
|
+
console.log(out);
|
|
1823
|
+
process.exitCode = 1;
|
|
1824
|
+
return;
|
|
1825
|
+
}
|
|
1826
|
+
let failed = results.length === 0;
|
|
1827
|
+
for (const r of results) {
|
|
1828
|
+
const status = String(r.status ?? "UNKNOWN");
|
|
1829
|
+
console.log(`${r.eventId ?? "?"}\t${status}`);
|
|
1830
|
+
if (BOT_ACK_FAILURE_STATUSES.has(status))
|
|
1831
|
+
failed = true;
|
|
1832
|
+
}
|
|
1833
|
+
if (failed)
|
|
1834
|
+
process.exitCode = 1;
|
|
1835
|
+
});
|
|
1836
|
+
bot
|
|
1837
|
+
.command("register-profile")
|
|
1838
|
+
.description(TOOL_DEFINITIONS.find((t) => t.kebab === "register-bot-profile").description)
|
|
1839
|
+
.addOption(jsonInputOption())
|
|
1840
|
+
.option("--bot-id <id>", "Bot id (defaults to TESTCHIMP_BOT_ID / OAuth token)")
|
|
1841
|
+
.option("--role <role>", "QA_LEAD | PM | QA_ENGINEER | DEVELOPER")
|
|
1842
|
+
.option("--responsibilities <text>", "Free-text responsibilities in the user's words")
|
|
1843
|
+
.option("--capability <name>", "Capability (repeatable): REQUIREMENTS_UPDATE, E2E_AUTHORING, ISSUE_FIX, MANUAL_TEST_COORDINATION, TEST_BATCH_FIX, QA_POSTURE", (v, prev = []) => [...prev, ...v.split(",").map((s) => s.trim()).filter(Boolean)])
|
|
1844
|
+
.option("--subscriptions-json <json>", "Subscriptions array JSON or @file: [{eventType, filters:[{field, op, value}]}]")
|
|
1845
|
+
.action(async (opts) => {
|
|
1846
|
+
const body = {};
|
|
1847
|
+
if (opts.botId)
|
|
1848
|
+
body.botId = String(opts.botId);
|
|
1849
|
+
if (opts.role)
|
|
1850
|
+
body.role = String(opts.role).trim().toUpperCase();
|
|
1851
|
+
if (opts.responsibilities != null)
|
|
1852
|
+
body.responsibilities = String(opts.responsibilities);
|
|
1853
|
+
if (opts.capability?.length)
|
|
1854
|
+
body.capabilities = opts.capability.map((c) => c.toUpperCase());
|
|
1855
|
+
if (opts.subscriptionsJson) {
|
|
1856
|
+
const raw = String(opts.subscriptionsJson).trim();
|
|
1857
|
+
const text = raw.startsWith("@") ? readFileSync(raw.slice(1), "utf8") : raw;
|
|
1858
|
+
const parsed = JSON.parse(text);
|
|
1859
|
+
body.subscriptions = Array.isArray(parsed)
|
|
1860
|
+
? parsed
|
|
1861
|
+
: parsed.subscriptions ?? parsed;
|
|
1862
|
+
}
|
|
1863
|
+
console.log(await runTool("register-bot-profile", mergeBodies(body, opts.jsonInput), { postMcp }));
|
|
1864
|
+
});
|
|
1865
|
+
bot
|
|
1866
|
+
.command("get-profile")
|
|
1867
|
+
.description(TOOL_DEFINITIONS.find((t) => t.kebab === "get-bot-profile").description)
|
|
1868
|
+
.addOption(jsonInputOption())
|
|
1869
|
+
.option("--bot-id <id>", "Bot id (defaults to TESTCHIMP_BOT_ID / OAuth token)")
|
|
1870
|
+
.action(async (opts) => {
|
|
1871
|
+
const body = {};
|
|
1872
|
+
if (opts.botId)
|
|
1873
|
+
body.botId = String(opts.botId);
|
|
1874
|
+
console.log(await runTool("get-bot-profile", mergeBodies(body, opts.jsonInput), { postMcp }));
|
|
1875
|
+
});
|
|
1876
|
+
bot
|
|
1877
|
+
.command("compat")
|
|
1878
|
+
.description("Check this CLI (and optionally the testchimp skill) against the deployment's minimum bot versions")
|
|
1879
|
+
.option("--skill-version <semver>", "Installed testchimp skill version (SKILL.md frontmatter) to check too")
|
|
1880
|
+
.action(async (opts) => {
|
|
1881
|
+
const out = await runTool("get-bot-compat", {}, { postMcp });
|
|
1882
|
+
const compat = JSON.parse(out);
|
|
1883
|
+
const cliUpgradeRequired = !!compat.minCliVersion && compareSemver(PACKAGE_VERSION, compat.minCliVersion) < 0;
|
|
1884
|
+
const skillVersion = opts.skillVersion ? String(opts.skillVersion).trim() : undefined;
|
|
1885
|
+
const skillUpgradeRequired = skillVersion != null && !!compat.minSkillVersion && compareSemver(skillVersion, compat.minSkillVersion) < 0;
|
|
1886
|
+
console.log(JSON.stringify({
|
|
1887
|
+
...compat,
|
|
1888
|
+
cliVersion: PACKAGE_VERSION,
|
|
1889
|
+
cliUpgradeRequired,
|
|
1890
|
+
...(skillVersion != null ? { skillVersion, skillUpgradeRequired } : {}),
|
|
1891
|
+
}, null, 2));
|
|
1892
|
+
if (cliUpgradeRequired) {
|
|
1893
|
+
console.error(`[testchimp] CLI ${PACKAGE_VERSION} is older than required ${compat.minCliVersion}; run: npm i -g @testchimp/cli@latest`);
|
|
1894
|
+
}
|
|
1895
|
+
else {
|
|
1896
|
+
console.error(`[testchimp] CLI ${PACKAGE_VERSION} meets minimum ${compat.minCliVersion ?? "(none)"}; no upgrade required.`);
|
|
1897
|
+
}
|
|
1898
|
+
if (skillUpgradeRequired) {
|
|
1899
|
+
console.error(`[testchimp] testchimp skill ${skillVersion} is older than required ${compat.minSkillVersion}; reinstall the skill.`);
|
|
1900
|
+
}
|
|
1901
|
+
});
|
|
1902
|
+
bot
|
|
1903
|
+
.command("connect")
|
|
1904
|
+
.description("Sign in through the browser (OAuth) and store this user's id, PAT and the project API key for headless AgentWatch (no TestChimp Studio needed)")
|
|
1905
|
+
.option("--project-id <id>", "Fail unless the project approved on the consent page is this one")
|
|
1906
|
+
.option("--port <n>", "Loopback port for the OAuth redirect (default: a free port)")
|
|
1907
|
+
.option("--no-browser", "Only print the approval URL")
|
|
1908
|
+
.option("--timeout-ms <n>", "How long to wait for approval (default 300000)")
|
|
1909
|
+
.action(async (opts) => {
|
|
1910
|
+
const { connectAgentWatch, ingressUrlForBackend } = await import("../agentwatch/connect.js");
|
|
1911
|
+
const { getBackendUrl, getIngressUrl } = await import("../core/client.js");
|
|
1912
|
+
try {
|
|
1913
|
+
const backendUrl = getBackendUrl();
|
|
1914
|
+
const result = await connectAgentWatch({
|
|
1915
|
+
backendUrl,
|
|
1916
|
+
ingressUrl: process.env.TESTCHIMP_INGRESS_URL?.trim() ? getIngressUrl() : ingressUrlForBackend(backendUrl),
|
|
1917
|
+
expectedProjectId: opts.projectId ? String(opts.projectId).trim() : undefined,
|
|
1918
|
+
port: opts.port != null ? Number(opts.port) : undefined,
|
|
1919
|
+
timeoutMs: opts.timeoutMs != null ? Number(opts.timeoutMs) : undefined,
|
|
1920
|
+
openUrl: opts.browser === false ? () => undefined : undefined,
|
|
1921
|
+
log: stderrProgress,
|
|
1922
|
+
});
|
|
1923
|
+
stderrProgress(`Saved AgentWatch credentials for project ${result.projectId} in ${result.credentialsPath}` +
|
|
1924
|
+
(result.botId ? `; set TESTCHIMP_BOT_ID=${result.botId} for this bot` : ""));
|
|
1925
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1926
|
+
}
|
|
1927
|
+
catch (e) {
|
|
1928
|
+
console.error(`[testchimp bot connect] ${e instanceof Error ? e.message : String(e)}`);
|
|
1929
|
+
process.exitCode = 1;
|
|
1930
|
+
}
|
|
1931
|
+
});
|
|
1932
|
+
bot
|
|
1933
|
+
.command("disconnect")
|
|
1934
|
+
.description("Remove the stored AgentWatch credentials for a project")
|
|
1935
|
+
.requiredOption("--project-id <id>", "TestChimp project id")
|
|
1936
|
+
.action(async (opts) => {
|
|
1937
|
+
const { removeProjectCredentials, agentwatchCredentialsPath } = await import("../agentwatch/credentialsFile.js");
|
|
1938
|
+
const projectId = String(opts.projectId).trim();
|
|
1939
|
+
const removed = removeProjectCredentials(projectId);
|
|
1940
|
+
console.log(JSON.stringify({ projectId, removed, credentialsPath: agentwatchCredentialsPath() }));
|
|
1941
|
+
});
|
|
1942
|
+
const workspace = program
|
|
1943
|
+
.command("workspace")
|
|
1944
|
+
.description("Per-user local folder ↔ TestChimp project mapping (~/.testchimp/projects.json, shared with TestChimp Studio)");
|
|
1945
|
+
workspace
|
|
1946
|
+
.command("map")
|
|
1947
|
+
.description("Map a local git repo folder to a TestChimp project (same rules as Studio folder mapping)")
|
|
1948
|
+
.requiredOption("--project-id <id>", "TestChimp project id")
|
|
1949
|
+
.requiredOption("--folder <path>", "Local repository folder (must be a git repo)")
|
|
1950
|
+
.option("--project-name <name>", "Project display name stored with the mapping")
|
|
1951
|
+
.option("--reassign", "Move the folder from another project's mapping to this project")
|
|
1952
|
+
.option("--skip-repo-check", "Do not compare the git remote with the project's connected repository")
|
|
1953
|
+
.action(async (opts) => {
|
|
1954
|
+
const { upsertWorkspaceFolder, projectsRegistryPath } = await import("../workspace/projectsRegistry.js");
|
|
1955
|
+
const { assertFolderMatchesRepo, inspectLocalGitRepo } = await import("../workspace/gitRepo.js");
|
|
1956
|
+
const projectId = String(opts.projectId).trim();
|
|
1957
|
+
try {
|
|
1958
|
+
if (!projectId)
|
|
1959
|
+
throw new Error("INVALID_PAYLOAD: --project-id");
|
|
1960
|
+
const inspection = inspectLocalGitRepo(String(opts.folder));
|
|
1961
|
+
let expectedRepo = null;
|
|
1962
|
+
if (opts.skipRepoCheck) {
|
|
1963
|
+
stderrProgress("Skipping connected-repository check (--skip-repo-check).");
|
|
1964
|
+
}
|
|
1965
|
+
else if (!process.env.TESTCHIMP_API_KEY?.trim() && !process.env.TESTCHIMP_OAUTH_TOKEN?.trim()) {
|
|
1966
|
+
stderrProgress("No TESTCHIMP_API_KEY / TESTCHIMP_OAUTH_TOKEN set; skipping connected-repository check.");
|
|
1967
|
+
}
|
|
1968
|
+
else {
|
|
1969
|
+
try {
|
|
1970
|
+
const out = await runTool("get-git-folder-mapping", {}, { postMcp });
|
|
1971
|
+
const parsed = JSON.parse(out);
|
|
1972
|
+
expectedRepo = parsed.repositoryFullName?.trim() || null;
|
|
1973
|
+
if (!expectedRepo)
|
|
1974
|
+
stderrProgress("Project has no connected repository; skipping remote check.");
|
|
1975
|
+
}
|
|
1976
|
+
catch (e) {
|
|
1977
|
+
stderrProgress(`Could not load the project's connected repository (${e instanceof Error ? e.message : String(e)}); skipping remote check.`);
|
|
1978
|
+
}
|
|
1979
|
+
}
|
|
1980
|
+
assertFolderMatchesRepo(inspection, expectedRepo);
|
|
1981
|
+
const mapping = upsertWorkspaceFolder({
|
|
1982
|
+
projectId,
|
|
1983
|
+
projectName: opts.projectName != null ? String(opts.projectName).trim() || undefined : undefined,
|
|
1984
|
+
rootPath: inspection.selectedPath,
|
|
1985
|
+
reassign: opts.reassign === true,
|
|
1986
|
+
});
|
|
1987
|
+
stderrProgress(`Mapped ${inspection.selectedPath} → project ${projectId} in ${projectsRegistryPath()}`);
|
|
1988
|
+
console.log(JSON.stringify(mapping, null, 2));
|
|
1989
|
+
}
|
|
1990
|
+
catch (e) {
|
|
1991
|
+
console.error(`[testchimp workspace map] ${e instanceof Error ? e.message : String(e)}`);
|
|
1992
|
+
process.exitCode = 1;
|
|
1993
|
+
}
|
|
1994
|
+
});
|
|
1995
|
+
workspace
|
|
1996
|
+
.command("get")
|
|
1997
|
+
.description("Print the folder mapping JSON for a project (exit 1 when unmapped)")
|
|
1998
|
+
.requiredOption("--project-id <id>", "TestChimp project id")
|
|
1999
|
+
.action(async (opts) => {
|
|
2000
|
+
const { findWorkspaceMapping } = await import("../workspace/projectsRegistry.js");
|
|
2001
|
+
try {
|
|
2002
|
+
const mapping = findWorkspaceMapping(String(opts.projectId).trim());
|
|
2003
|
+
if (!mapping) {
|
|
2004
|
+
console.error(`[testchimp workspace get] project ${opts.projectId} is not mapped to a local folder; run: testchimp workspace map --project-id ${opts.projectId} --folder <path>`);
|
|
2005
|
+
process.exitCode = 1;
|
|
2006
|
+
return;
|
|
2007
|
+
}
|
|
2008
|
+
console.log(JSON.stringify(mapping, null, 2));
|
|
2009
|
+
}
|
|
2010
|
+
catch (e) {
|
|
2011
|
+
console.error(`[testchimp workspace get] ${e instanceof Error ? e.message : String(e)}`);
|
|
2012
|
+
process.exitCode = 1;
|
|
2013
|
+
}
|
|
2014
|
+
});
|
|
1731
2015
|
const chimphands = program.command("chimphands").description("ChimpHands GitHub Actions agent bridge");
|
|
1732
2016
|
chimphands
|
|
1733
2017
|
.command("report-branch")
|
|
@@ -1811,6 +2095,6 @@ export function buildCliProgram() {
|
|
|
1811
2095
|
program.on("--help", () => {
|
|
1812
2096
|
/* default */
|
|
1813
2097
|
});
|
|
1814
|
-
program.addHelpText("after", `\nEnvironment:\n TESTCHIMP_API_KEY required\n TESTCHIMP_BACKEND_URL optional (default ${DEFAULT_BACKEND})\n\nOutput:\n Response JSON on stdout.\n provision-ephemeral-environment-and-wait progress on stderr.\n\nAdvanced:\n --json-input merges a JSON object over flags (JSON wins on key conflicts). Use @file.json to read from disk.\n`);
|
|
2098
|
+
program.addHelpText("after", `\nEnvironment:\n TESTCHIMP_API_KEY required unless TESTCHIMP_OAUTH_TOKEN is set\n TESTCHIMP_OAUTH_TOKEN optional OAuth access token (sent as Authorization: Bearer)\n TESTCHIMP_BOT_ID optional QA bot id (sent as bot-id header)\n TESTCHIMP_BACKEND_URL optional (default ${DEFAULT_BACKEND})\n TESTCHIMP_INGRESS_URL optional (default ${DEFAULT_INGRESS})\n TESTCHIMP_HOME optional Studio/CLI home for workspace mappings (default ~/.testchimp)\n\nOutput:\n Response JSON on stdout.\n provision-ephemeral-environment-and-wait progress on stderr.\n\nAdvanced:\n --json-input merges a JSON object over flags (JSON wins on key conflicts). Use @file.json to read from disk.\n`);
|
|
1815
2099
|
return program;
|
|
1816
2100
|
}
|
package/dist/core/client.d.ts
CHANGED
|
@@ -1,6 +1,38 @@
|
|
|
1
1
|
/** HTTP client for TestChimp MCP proxy APIs. */
|
|
2
2
|
export declare const DEFAULT_BACKEND = "https://featureservice.testchimp.io";
|
|
3
|
+
export declare const DEFAULT_INGRESS = "https://ingress.testchimp.io";
|
|
3
4
|
export declare function getBackendUrl(): string;
|
|
5
|
+
export declare function getIngressUrl(): string;
|
|
6
|
+
/**
|
|
7
|
+
* Validate an explicit absolute ingress URL (e.g. webhook `ackUrl`). Credentials are attached, so only
|
|
8
|
+
* https (or http on loopback) to the configured ingress host or a TestChimp SaaS host is allowed.
|
|
9
|
+
*/
|
|
10
|
+
export declare function resolveIngressTargetUrl(absoluteUrl: string): string;
|
|
4
11
|
export declare function requireApiKey(): string;
|
|
12
|
+
/**
|
|
13
|
+
* Per-request caller credentials (remote MCP over HTTP). When `isolated` is true the process env
|
|
14
|
+
* credentials (TESTCHIMP_API_KEY / TESTCHIMP_OAUTH_TOKEN / TESTCHIMP_BOT_ID) are never used.
|
|
15
|
+
*/
|
|
16
|
+
export interface RequestAuth {
|
|
17
|
+
bearerToken?: string;
|
|
18
|
+
botId?: string;
|
|
19
|
+
isolated: boolean;
|
|
20
|
+
}
|
|
21
|
+
export declare function runWithRequestAuth<T>(auth: RequestAuth, fn: () => T): T;
|
|
22
|
+
/** 1..64 printable ASCII characters, no spaces or control characters. */
|
|
23
|
+
export declare function isValidBotId(value: string): boolean;
|
|
24
|
+
/** Test hook: allow the once-per-process TESTCHIMP_BOT_ID warning to fire again. */
|
|
25
|
+
export declare function resetClientWarningsForTests(): void;
|
|
26
|
+
/** Headers for featureservice / ingress calls. API-key-only callers get exactly the legacy header set. */
|
|
27
|
+
export declare function buildRequestHeaders(): Record<string, string>;
|
|
28
|
+
/** Non-2xx TestChimp API response (message format matches the historical client errors). */
|
|
29
|
+
export declare class TestChimpHttpError extends Error {
|
|
30
|
+
readonly status: number;
|
|
31
|
+
readonly body: string;
|
|
32
|
+
constructor(status: number, statusText: string, body: string);
|
|
33
|
+
}
|
|
5
34
|
export declare function postMcp(path: string, body: unknown): Promise<string>;
|
|
35
|
+
/** POST to TestChimp ingress: a path under TESTCHIMP_INGRESS_URL, or an explicit absolute URL (e.g. ackUrl). */
|
|
36
|
+
export declare function postIngress(pathOrUrl: string, body: unknown): Promise<string>;
|
|
6
37
|
export type PostMcpFn = (path: string, body: unknown) => Promise<string>;
|
|
38
|
+
export type PostIngressFn = (pathOrUrl: string, body: unknown) => Promise<string>;
|
package/dist/core/client.js
CHANGED
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
/** HTTP client for TestChimp MCP proxy APIs. */
|
|
2
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
3
|
export const DEFAULT_BACKEND = "https://featureservice.testchimp.io";
|
|
4
|
+
export const DEFAULT_INGRESS = "https://ingress.testchimp.io";
|
|
3
5
|
function isTestChimpSaasFeatureservice(hostname) {
|
|
4
6
|
return hostname === "featureservice.testchimp.io"
|
|
5
7
|
|| /^featureservice-[a-z0-9-]+\.testchimp\.io$/i.test(hostname);
|
|
6
8
|
}
|
|
9
|
+
function isTestChimpSaasIngress(hostname) {
|
|
10
|
+
return hostname === "ingress.testchimp.io"
|
|
11
|
+
|| /^ingress-[a-z0-9-]+\.testchimp\.io$/i.test(hostname);
|
|
12
|
+
}
|
|
13
|
+
function isLoopbackHost(hostname) {
|
|
14
|
+
return hostname === "localhost" || hostname === "127.0.0.1";
|
|
15
|
+
}
|
|
7
16
|
export function getBackendUrl() {
|
|
8
17
|
const raw = process.env.TESTCHIMP_BACKEND_URL?.trim();
|
|
9
18
|
if (!raw)
|
|
@@ -23,6 +32,51 @@ export function getBackendUrl() {
|
|
|
23
32
|
}
|
|
24
33
|
return raw.replace(/\/+$/, "");
|
|
25
34
|
}
|
|
35
|
+
export function getIngressUrl() {
|
|
36
|
+
const raw = process.env.TESTCHIMP_INGRESS_URL?.trim();
|
|
37
|
+
if (!raw)
|
|
38
|
+
return DEFAULT_INGRESS;
|
|
39
|
+
let parsed;
|
|
40
|
+
try {
|
|
41
|
+
parsed = new URL(raw);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
throw new Error(`TESTCHIMP_INGRESS_URL must be an absolute http(s) URL; received ${JSON.stringify(raw)}`);
|
|
45
|
+
}
|
|
46
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
47
|
+
throw new Error(`TESTCHIMP_INGRESS_URL must use http or https; received ${parsed.protocol}`);
|
|
48
|
+
}
|
|
49
|
+
if (parsed.protocol === "http:" && isTestChimpSaasIngress(parsed.hostname)) {
|
|
50
|
+
throw new Error(`TESTCHIMP_INGRESS_URL must use https for TestChimp SaaS; change it to https://${parsed.host}`);
|
|
51
|
+
}
|
|
52
|
+
return raw.replace(/\/+$/, "");
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Validate an explicit absolute ingress URL (e.g. webhook `ackUrl`). Credentials are attached, so only
|
|
56
|
+
* https (or http on loopback) to the configured ingress host or a TestChimp SaaS host is allowed.
|
|
57
|
+
*/
|
|
58
|
+
export function resolveIngressTargetUrl(absoluteUrl) {
|
|
59
|
+
let parsed;
|
|
60
|
+
try {
|
|
61
|
+
parsed = new URL(absoluteUrl.trim());
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
throw new Error(`Invalid ingress URL ${JSON.stringify(absoluteUrl)}; expected an absolute https URL`);
|
|
65
|
+
}
|
|
66
|
+
const loopback = isLoopbackHost(parsed.hostname);
|
|
67
|
+
if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && loopback)) {
|
|
68
|
+
throw new Error(`Ingress URL must use https (http is only allowed for localhost / 127.0.0.1); received ${parsed.protocol}//${parsed.host}`);
|
|
69
|
+
}
|
|
70
|
+
const configuredHost = new URL(getIngressUrl()).hostname;
|
|
71
|
+
const trustedHost = loopback
|
|
72
|
+
|| parsed.hostname === configuredHost
|
|
73
|
+
|| parsed.hostname === "testchimp.io"
|
|
74
|
+
|| parsed.hostname.endsWith(".testchimp.io");
|
|
75
|
+
if (!trustedHost) {
|
|
76
|
+
throw new Error(`Refusing to send credentials to untrusted ingress host ${parsed.hostname}; set TESTCHIMP_INGRESS_URL to your TestChimp ingress URL`);
|
|
77
|
+
}
|
|
78
|
+
return parsed.toString();
|
|
79
|
+
}
|
|
26
80
|
export function requireApiKey() {
|
|
27
81
|
const k = process.env.TESTCHIMP_API_KEY?.trim();
|
|
28
82
|
if (!k) {
|
|
@@ -30,26 +84,96 @@ export function requireApiKey() {
|
|
|
30
84
|
}
|
|
31
85
|
return k;
|
|
32
86
|
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
87
|
+
const requestAuthStorage = new AsyncLocalStorage();
|
|
88
|
+
export function runWithRequestAuth(auth, fn) {
|
|
89
|
+
return requestAuthStorage.run(auth, fn);
|
|
90
|
+
}
|
|
91
|
+
const BOT_ID_PATTERN = /^[\x21-\x7e]{1,64}$/;
|
|
92
|
+
/** 1..64 printable ASCII characters, no spaces or control characters. */
|
|
93
|
+
export function isValidBotId(value) {
|
|
94
|
+
return BOT_ID_PATTERN.test(value);
|
|
95
|
+
}
|
|
96
|
+
let warnedInvalidBotId = false;
|
|
97
|
+
/** Test hook: allow the once-per-process TESTCHIMP_BOT_ID warning to fire again. */
|
|
98
|
+
export function resetClientWarningsForTests() {
|
|
99
|
+
warnedInvalidBotId = false;
|
|
100
|
+
}
|
|
101
|
+
function envBotId() {
|
|
102
|
+
const raw = process.env.TESTCHIMP_BOT_ID;
|
|
103
|
+
if (raw == null || raw === "")
|
|
104
|
+
return undefined;
|
|
105
|
+
if (isValidBotId(raw))
|
|
106
|
+
return raw;
|
|
107
|
+
if (!warnedInvalidBotId) {
|
|
108
|
+
warnedInvalidBotId = true;
|
|
109
|
+
console.error("[testchimp] Ignoring TESTCHIMP_BOT_ID: must be 1-64 printable ASCII characters without spaces; bot-id header not sent.");
|
|
110
|
+
}
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
/** Headers for featureservice / ingress calls. API-key-only callers get exactly the legacy header set. */
|
|
114
|
+
export function buildRequestHeaders() {
|
|
115
|
+
const headers = { "Content-Type": "application/json" };
|
|
116
|
+
const requestAuth = requestAuthStorage.getStore();
|
|
117
|
+
if (requestAuth?.isolated) {
|
|
118
|
+
if (!requestAuth.bearerToken) {
|
|
119
|
+
throw new Error("Authorization: Bearer token is required for remote TestChimp MCP requests.");
|
|
120
|
+
}
|
|
121
|
+
headers.Authorization = `Bearer ${requestAuth.bearerToken}`;
|
|
122
|
+
if (requestAuth.botId && isValidBotId(requestAuth.botId))
|
|
123
|
+
headers["bot-id"] = requestAuth.botId;
|
|
124
|
+
return headers;
|
|
125
|
+
}
|
|
126
|
+
const bearer = requestAuth?.bearerToken || process.env.TESTCHIMP_OAUTH_TOKEN?.trim();
|
|
127
|
+
const apiKey = process.env.TESTCHIMP_API_KEY?.trim();
|
|
128
|
+
if (!bearer) {
|
|
129
|
+
headers["TestChimp-Api-Key"] = requireApiKey();
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
if (apiKey)
|
|
133
|
+
headers["TestChimp-Api-Key"] = apiKey;
|
|
134
|
+
headers.Authorization = `Bearer ${bearer}`;
|
|
135
|
+
}
|
|
136
|
+
const botId = requestAuth?.botId && isValidBotId(requestAuth.botId) ? requestAuth.botId : envBotId();
|
|
137
|
+
if (botId)
|
|
138
|
+
headers["bot-id"] = botId;
|
|
139
|
+
return headers;
|
|
140
|
+
}
|
|
141
|
+
/** Non-2xx TestChimp API response (message format matches the historical client errors). */
|
|
142
|
+
export class TestChimpHttpError extends Error {
|
|
143
|
+
status;
|
|
144
|
+
body;
|
|
145
|
+
constructor(status, statusText, body) {
|
|
146
|
+
super(`TestChimp API ${status} ${statusText}: ${body}`);
|
|
147
|
+
this.status = status;
|
|
148
|
+
this.body = body;
|
|
149
|
+
this.name = "TestChimpHttpError";
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
async function postJson(url, body, redirectHint) {
|
|
36
153
|
const res = await fetch(url, {
|
|
37
154
|
method: "POST",
|
|
38
155
|
redirect: "manual",
|
|
39
|
-
headers:
|
|
40
|
-
"Content-Type": "application/json",
|
|
41
|
-
"TestChimp-Api-Key": apiKey,
|
|
42
|
-
},
|
|
156
|
+
headers: buildRequestHeaders(),
|
|
43
157
|
body: JSON.stringify(body ?? {}),
|
|
44
158
|
});
|
|
45
159
|
if (res.status >= 300 && res.status < 400) {
|
|
46
160
|
const location = res.headers.get("location");
|
|
47
161
|
const destination = location ? ` to ${location}` : "";
|
|
48
|
-
throw new Error(`TestChimp API redirected ${res.status}${destination}. Refusing to follow because an HTTP redirect may rewrite POST to GET;
|
|
162
|
+
throw new Error(`TestChimp API redirected ${res.status}${destination}. Refusing to follow because an HTTP redirect may rewrite POST to GET; ${redirectHint}`);
|
|
49
163
|
}
|
|
50
164
|
const text = await res.text();
|
|
51
165
|
if (!res.ok) {
|
|
52
|
-
throw new
|
|
166
|
+
throw new TestChimpHttpError(res.status, res.statusText, text);
|
|
53
167
|
}
|
|
54
168
|
return text;
|
|
55
169
|
}
|
|
170
|
+
export async function postMcp(path, body) {
|
|
171
|
+
return postJson(`${getBackendUrl()}${path}`, body, "set TESTCHIMP_BACKEND_URL to the final HTTPS featureservice URL.");
|
|
172
|
+
}
|
|
173
|
+
/** POST to TestChimp ingress: a path under TESTCHIMP_INGRESS_URL, or an explicit absolute URL (e.g. ackUrl). */
|
|
174
|
+
export async function postIngress(pathOrUrl, body) {
|
|
175
|
+
const url = /^[a-z][a-z0-9+.-]*:\/\//i.test(pathOrUrl.trim())
|
|
176
|
+
? resolveIngressTargetUrl(pathOrUrl)
|
|
177
|
+
: `${getIngressUrl()}${pathOrUrl}`;
|
|
178
|
+
return postJson(url, body, "set TESTCHIMP_INGRESS_URL to the final HTTPS ingress URL.");
|
|
179
|
+
}
|