@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.
@@ -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
- .action(async () => {
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
  }
@@ -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>;
@@ -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
- export async function postMcp(path, body) {
34
- const apiKey = requireApiKey();
35
- const url = `${getBackendUrl()}${path}`;
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; set TESTCHIMP_BACKEND_URL to the final HTTPS featureservice URL.`);
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 Error(`TestChimp API ${res.status} ${res.statusText}: ${text}`);
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
+ }