@testchimp/cli 0.1.84 → 0.1.86
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 +87 -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/agentwatch/pairing.d.ts +36 -0
- package/dist/agentwatch/pairing.js +161 -0
- package/dist/agentwatch/pairing.test.d.ts +1 -0
- package/dist/agentwatch/pairing.test.js +90 -0
- package/dist/cli/program.d.ts +2 -0
- package/dist/cli/program.js +318 -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 +94 -23
- package/dist/core/schemas.js +56 -0
- package/dist/core/tools.d.ts +5 -1
- package/dist/core/tools.js +125 -0
- package/dist/core/tools.test.js +102 -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
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,291 @@ 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("Store this user's id, PAT and the project API key for headless AgentWatch (no TestChimp Studio needed): browser sign-in (OAuth), or --pair / --finish-pair approved by your QA bot")
|
|
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; 60000 with --finish-pair)")
|
|
1909
|
+
.option("--pair", "No browser: print a pairing code for your QA bot to approve (approve-agentwatch-pairing), then run --finish-pair")
|
|
1910
|
+
.option("--finish-pair", "Store the credentials once the QA bot has approved the pairing code from --pair")
|
|
1911
|
+
.action(async (opts) => {
|
|
1912
|
+
const { connectAgentWatch, ingressUrlForBackend } = await import("../agentwatch/connect.js");
|
|
1913
|
+
const { getBackendUrl, getIngressUrl } = await import("../core/client.js");
|
|
1914
|
+
try {
|
|
1915
|
+
const backendUrl = getBackendUrl();
|
|
1916
|
+
const ingressUrl = process.env.TESTCHIMP_INGRESS_URL?.trim() ? getIngressUrl() : ingressUrlForBackend(backendUrl);
|
|
1917
|
+
const expectedProjectId = opts.projectId ? String(opts.projectId).trim() : undefined;
|
|
1918
|
+
if (opts.pair && opts.finishPair)
|
|
1919
|
+
throw new Error("Use either --pair or --finish-pair, not both");
|
|
1920
|
+
if (opts.pair) {
|
|
1921
|
+
const { startAgentWatchPairing } = await import("../agentwatch/pairing.js");
|
|
1922
|
+
const started = startAgentWatchPairing({ backendUrl, ingressUrl, expectedProjectId });
|
|
1923
|
+
stderrProgress("Ask your QA bot to approve this pairing code (approve-agentwatch-pairing), then run: testchimp bot connect --finish-pair");
|
|
1924
|
+
console.log(JSON.stringify(started, null, 2));
|
|
1925
|
+
return;
|
|
1926
|
+
}
|
|
1927
|
+
let result;
|
|
1928
|
+
if (opts.finishPair) {
|
|
1929
|
+
const { finishAgentWatchPairing } = await import("../agentwatch/pairing.js");
|
|
1930
|
+
result = await finishAgentWatchPairing({
|
|
1931
|
+
backendUrl,
|
|
1932
|
+
timeoutMs: opts.timeoutMs != null ? Number(opts.timeoutMs) : undefined,
|
|
1933
|
+
});
|
|
1934
|
+
}
|
|
1935
|
+
else {
|
|
1936
|
+
result = await connectAgentWatch({
|
|
1937
|
+
backendUrl,
|
|
1938
|
+
ingressUrl,
|
|
1939
|
+
expectedProjectId,
|
|
1940
|
+
port: opts.port != null ? Number(opts.port) : undefined,
|
|
1941
|
+
timeoutMs: opts.timeoutMs != null ? Number(opts.timeoutMs) : undefined,
|
|
1942
|
+
openUrl: opts.browser === false ? () => undefined : undefined,
|
|
1943
|
+
log: stderrProgress,
|
|
1944
|
+
});
|
|
1945
|
+
}
|
|
1946
|
+
stderrProgress(`Saved AgentWatch credentials for project ${result.projectId} in ${result.credentialsPath}` +
|
|
1947
|
+
(result.botId ? `; set TESTCHIMP_BOT_ID=${result.botId} for this bot` : ""));
|
|
1948
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1949
|
+
}
|
|
1950
|
+
catch (e) {
|
|
1951
|
+
console.error(`[testchimp bot connect] ${e instanceof Error ? e.message : String(e)}`);
|
|
1952
|
+
process.exitCode = 1;
|
|
1953
|
+
}
|
|
1954
|
+
});
|
|
1955
|
+
bot
|
|
1956
|
+
.command("approve-pairing")
|
|
1957
|
+
.description(TOOL_DEFINITIONS.find((t) => t.kebab === "approve-agentwatch-pairing").description)
|
|
1958
|
+
.argument("<pairingCode>", "Code printed by testchimp bot connect --pair on the user's computer")
|
|
1959
|
+
.action(async (pairingCode) => {
|
|
1960
|
+
console.log(await runTool("approve-agentwatch-pairing", { pairingCode }, { postMcp }));
|
|
1961
|
+
});
|
|
1962
|
+
bot
|
|
1963
|
+
.command("disconnect")
|
|
1964
|
+
.description("Remove the stored AgentWatch credentials for a project")
|
|
1965
|
+
.requiredOption("--project-id <id>", "TestChimp project id")
|
|
1966
|
+
.action(async (opts) => {
|
|
1967
|
+
const { removeProjectCredentials, agentwatchCredentialsPath } = await import("../agentwatch/credentialsFile.js");
|
|
1968
|
+
const projectId = String(opts.projectId).trim();
|
|
1969
|
+
const removed = removeProjectCredentials(projectId);
|
|
1970
|
+
console.log(JSON.stringify({ projectId, removed, credentialsPath: agentwatchCredentialsPath() }));
|
|
1971
|
+
});
|
|
1972
|
+
const workspace = program
|
|
1973
|
+
.command("workspace")
|
|
1974
|
+
.description("Per-user local folder ↔ TestChimp project mapping (~/.testchimp/projects.json, shared with TestChimp Studio)");
|
|
1975
|
+
workspace
|
|
1976
|
+
.command("map")
|
|
1977
|
+
.description("Map a local git repo folder to a TestChimp project (same rules as Studio folder mapping)")
|
|
1978
|
+
.requiredOption("--project-id <id>", "TestChimp project id")
|
|
1979
|
+
.requiredOption("--folder <path>", "Local repository folder (must be a git repo)")
|
|
1980
|
+
.option("--project-name <name>", "Project display name stored with the mapping")
|
|
1981
|
+
.option("--reassign", "Move the folder from another project's mapping to this project")
|
|
1982
|
+
.option("--skip-repo-check", "Do not compare the git remote with the project's connected repository")
|
|
1983
|
+
.action(async (opts) => {
|
|
1984
|
+
const { upsertWorkspaceFolder, projectsRegistryPath } = await import("../workspace/projectsRegistry.js");
|
|
1985
|
+
const { assertFolderMatchesRepo, inspectLocalGitRepo } = await import("../workspace/gitRepo.js");
|
|
1986
|
+
const projectId = String(opts.projectId).trim();
|
|
1987
|
+
try {
|
|
1988
|
+
if (!projectId)
|
|
1989
|
+
throw new Error("INVALID_PAYLOAD: --project-id");
|
|
1990
|
+
const inspection = inspectLocalGitRepo(String(opts.folder));
|
|
1991
|
+
let expectedRepo = null;
|
|
1992
|
+
if (opts.skipRepoCheck) {
|
|
1993
|
+
stderrProgress("Skipping connected-repository check (--skip-repo-check).");
|
|
1994
|
+
}
|
|
1995
|
+
else if (!process.env.TESTCHIMP_API_KEY?.trim() && !process.env.TESTCHIMP_OAUTH_TOKEN?.trim()) {
|
|
1996
|
+
stderrProgress("No TESTCHIMP_API_KEY / TESTCHIMP_OAUTH_TOKEN set; skipping connected-repository check.");
|
|
1997
|
+
}
|
|
1998
|
+
else {
|
|
1999
|
+
try {
|
|
2000
|
+
const out = await runTool("get-git-folder-mapping", {}, { postMcp });
|
|
2001
|
+
const parsed = JSON.parse(out);
|
|
2002
|
+
expectedRepo = parsed.repositoryFullName?.trim() || null;
|
|
2003
|
+
if (!expectedRepo)
|
|
2004
|
+
stderrProgress("Project has no connected repository; skipping remote check.");
|
|
2005
|
+
}
|
|
2006
|
+
catch (e) {
|
|
2007
|
+
stderrProgress(`Could not load the project's connected repository (${e instanceof Error ? e.message : String(e)}); skipping remote check.`);
|
|
2008
|
+
}
|
|
2009
|
+
}
|
|
2010
|
+
assertFolderMatchesRepo(inspection, expectedRepo);
|
|
2011
|
+
const mapping = upsertWorkspaceFolder({
|
|
2012
|
+
projectId,
|
|
2013
|
+
projectName: opts.projectName != null ? String(opts.projectName).trim() || undefined : undefined,
|
|
2014
|
+
rootPath: inspection.selectedPath,
|
|
2015
|
+
reassign: opts.reassign === true,
|
|
2016
|
+
});
|
|
2017
|
+
stderrProgress(`Mapped ${inspection.selectedPath} → project ${projectId} in ${projectsRegistryPath()}`);
|
|
2018
|
+
console.log(JSON.stringify(mapping, null, 2));
|
|
2019
|
+
}
|
|
2020
|
+
catch (e) {
|
|
2021
|
+
console.error(`[testchimp workspace map] ${e instanceof Error ? e.message : String(e)}`);
|
|
2022
|
+
process.exitCode = 1;
|
|
2023
|
+
}
|
|
2024
|
+
});
|
|
2025
|
+
workspace
|
|
2026
|
+
.command("get")
|
|
2027
|
+
.description("Print the folder mapping JSON for a project (exit 1 when unmapped)")
|
|
2028
|
+
.requiredOption("--project-id <id>", "TestChimp project id")
|
|
2029
|
+
.action(async (opts) => {
|
|
2030
|
+
const { findWorkspaceMapping } = await import("../workspace/projectsRegistry.js");
|
|
2031
|
+
try {
|
|
2032
|
+
const mapping = findWorkspaceMapping(String(opts.projectId).trim());
|
|
2033
|
+
if (!mapping) {
|
|
2034
|
+
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>`);
|
|
2035
|
+
process.exitCode = 1;
|
|
2036
|
+
return;
|
|
2037
|
+
}
|
|
2038
|
+
console.log(JSON.stringify(mapping, null, 2));
|
|
2039
|
+
}
|
|
2040
|
+
catch (e) {
|
|
2041
|
+
console.error(`[testchimp workspace get] ${e instanceof Error ? e.message : String(e)}`);
|
|
2042
|
+
process.exitCode = 1;
|
|
2043
|
+
}
|
|
2044
|
+
});
|
|
1731
2045
|
const chimphands = program.command("chimphands").description("ChimpHands GitHub Actions agent bridge");
|
|
1732
2046
|
chimphands
|
|
1733
2047
|
.command("report-branch")
|
|
@@ -1811,6 +2125,6 @@ export function buildCliProgram() {
|
|
|
1811
2125
|
program.on("--help", () => {
|
|
1812
2126
|
/* default */
|
|
1813
2127
|
});
|
|
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`);
|
|
2128
|
+
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
2129
|
return program;
|
|
1816
2130
|
}
|
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
|
+
}
|