@testchimp/cli 0.1.0
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/LICENSE +504 -0
- package/README.md +57 -0
- package/dist/bin/legacy-mcp.d.ts +2 -0
- package/dist/bin/legacy-mcp.js +9 -0
- package/dist/bin/testchimp.d.ts +2 -0
- package/dist/bin/testchimp.js +7 -0
- package/dist/cli/program.d.ts +3 -0
- package/dist/cli/program.js +366 -0
- package/dist/core/client.d.ts +6 -0
- package/dist/core/client.js +32 -0
- package/dist/core/ephemeralWait.d.ts +11 -0
- package/dist/core/ephemeralWait.js +194 -0
- package/dist/core/merge.d.ts +2 -0
- package/dist/core/merge.js +13 -0
- package/dist/core/normalize.d.ts +7 -0
- package/dist/core/normalize.js +18 -0
- package/dist/core/schemas.d.ts +70 -0
- package/dist/core/schemas.js +66 -0
- package/dist/core/tools.d.ts +16 -0
- package/dist/core/tools.js +270 -0
- package/dist/ephemeralProvisionWait.d.ts +11 -0
- package/dist/ephemeralProvisionWait.js +206 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +402 -0
- package/dist/mcp/server.d.ts +1 -0
- package/dist/mcp/server.js +37 -0
- package/package.json +43 -0
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
import { Command, Option } from "commander";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import { DEFAULT_BACKEND, postMcp } from "../core/client.js";
|
|
5
|
+
import { deepMerge } from "../core/merge.js";
|
|
6
|
+
import { runTool } from "../core/tools.js";
|
|
7
|
+
import { TOOL_DEFINITIONS } from "../core/tools.js";
|
|
8
|
+
export const PACKAGE_VERSION = "0.1.0";
|
|
9
|
+
function parseJsonInput(raw) {
|
|
10
|
+
if (raw == null || raw.trim() === "")
|
|
11
|
+
return {};
|
|
12
|
+
const trimmed = raw.trim();
|
|
13
|
+
if (trimmed.startsWith("@")) {
|
|
14
|
+
const path = trimmed.slice(1);
|
|
15
|
+
const buf = readFileSync(path, "utf8");
|
|
16
|
+
return JSON.parse(buf);
|
|
17
|
+
}
|
|
18
|
+
return JSON.parse(trimmed);
|
|
19
|
+
}
|
|
20
|
+
function mergeBodies(flagBody, jsonInputRaw) {
|
|
21
|
+
const extra = parseJsonInput(jsonInputRaw);
|
|
22
|
+
if (Object.keys(extra).length === 0)
|
|
23
|
+
return flagBody;
|
|
24
|
+
return deepMerge(flagBody, extra);
|
|
25
|
+
}
|
|
26
|
+
function stderrProgress(msg) {
|
|
27
|
+
console.error(`[testchimp] ${msg}`);
|
|
28
|
+
}
|
|
29
|
+
export function buildCliProgram() {
|
|
30
|
+
const program = new Command();
|
|
31
|
+
program.name("testchimp").description("TestChimp CLI — call project MCP HTTP APIs").version(PACKAGE_VERSION);
|
|
32
|
+
program
|
|
33
|
+
.command("mcp")
|
|
34
|
+
.description("Start the TestChimp MCP server (stdio transport)")
|
|
35
|
+
.action(async () => {
|
|
36
|
+
const { runMcpServer } = await import("../mcp/server.js");
|
|
37
|
+
await runMcpServer();
|
|
38
|
+
});
|
|
39
|
+
function jsonInputOption() {
|
|
40
|
+
return new Option("--json-input <json>", "Advanced: JSON object or @path; merged over flags (JSON wins on conflicts)");
|
|
41
|
+
}
|
|
42
|
+
program
|
|
43
|
+
.command("get-requirement-coverage")
|
|
44
|
+
.description(TOOL_DEFINITIONS.find((t) => t.kebab === "get-requirement-coverage").description)
|
|
45
|
+
.addOption(jsonInputOption())
|
|
46
|
+
.option("--release <s>")
|
|
47
|
+
.option("--environment <s>")
|
|
48
|
+
.option("--branch-name <s>")
|
|
49
|
+
.option("--file-paths <csv>", "comma-separated paths under platform tests root")
|
|
50
|
+
.option("--folder-path <path>", "folder under tests root, slash-separated")
|
|
51
|
+
.action(async (opts) => {
|
|
52
|
+
const body = {};
|
|
53
|
+
if (opts.release)
|
|
54
|
+
body.release = opts.release;
|
|
55
|
+
if (opts.environment)
|
|
56
|
+
body.environment = opts.environment;
|
|
57
|
+
if (opts.branchName)
|
|
58
|
+
body.branchName = opts.branchName;
|
|
59
|
+
const scope = {};
|
|
60
|
+
if (opts.filePaths)
|
|
61
|
+
scope.filePaths = String(opts.filePaths).split(",").map((s) => s.trim()).filter(Boolean);
|
|
62
|
+
if (opts.folderPath)
|
|
63
|
+
scope.folderPath = opts.folderPath;
|
|
64
|
+
if (Object.keys(scope).length)
|
|
65
|
+
body.scope = scope;
|
|
66
|
+
const merged = mergeBodies(body, opts.jsonInput);
|
|
67
|
+
const out = await runTool("get-requirement-coverage", merged, { postMcp });
|
|
68
|
+
console.log(out);
|
|
69
|
+
});
|
|
70
|
+
program
|
|
71
|
+
.command("get-execution-history")
|
|
72
|
+
.description(TOOL_DEFINITIONS.find((t) => t.kebab === "get-execution-history").description)
|
|
73
|
+
.addOption(jsonInputOption())
|
|
74
|
+
.option("--release <s>")
|
|
75
|
+
.option("--environment <s>")
|
|
76
|
+
.option("--branch-name <s>")
|
|
77
|
+
.option("--file-paths <csv>")
|
|
78
|
+
.option("--folder-path <path>")
|
|
79
|
+
.action(async (opts) => {
|
|
80
|
+
const body = {};
|
|
81
|
+
if (opts.release)
|
|
82
|
+
body.release = opts.release;
|
|
83
|
+
if (opts.environment)
|
|
84
|
+
body.environment = opts.environment;
|
|
85
|
+
if (opts.branchName)
|
|
86
|
+
body.branchName = opts.branchName;
|
|
87
|
+
const scope = {};
|
|
88
|
+
if (opts.filePaths)
|
|
89
|
+
scope.filePaths = String(opts.filePaths).split(",").map((s) => s.trim()).filter(Boolean);
|
|
90
|
+
if (opts.folderPath)
|
|
91
|
+
scope.folderPath = opts.folderPath;
|
|
92
|
+
if (Object.keys(scope).length)
|
|
93
|
+
body.scope = scope;
|
|
94
|
+
const merged = mergeBodies(body, opts.jsonInput);
|
|
95
|
+
const out = await runTool("get-execution-history", merged, { postMcp });
|
|
96
|
+
console.log(out);
|
|
97
|
+
});
|
|
98
|
+
program
|
|
99
|
+
.command("create-user-story")
|
|
100
|
+
.description(TOOL_DEFINITIONS.find((t) => t.kebab === "create-user-story").description)
|
|
101
|
+
.addOption(jsonInputOption())
|
|
102
|
+
.requiredOption("--platform-file-path <path>")
|
|
103
|
+
.requiredOption("--title <title>")
|
|
104
|
+
.action(async (opts) => {
|
|
105
|
+
const body = { platformFilePath: opts.platformFilePath, title: opts.title };
|
|
106
|
+
const merged = mergeBodies(body, opts.jsonInput);
|
|
107
|
+
const out = await runTool("create-user-story", merged, { postMcp });
|
|
108
|
+
console.log(out);
|
|
109
|
+
});
|
|
110
|
+
program
|
|
111
|
+
.command("create-test-scenario")
|
|
112
|
+
.description(TOOL_DEFINITIONS.find((t) => t.kebab === "create-test-scenario").description)
|
|
113
|
+
.addOption(jsonInputOption())
|
|
114
|
+
.requiredOption("--platform-file-path <path>")
|
|
115
|
+
.requiredOption("--title <title>")
|
|
116
|
+
.requiredOption("--user-story-ordinal-id <n>")
|
|
117
|
+
.action(async (opts) => {
|
|
118
|
+
const body = {
|
|
119
|
+
platformFilePath: opts.platformFilePath,
|
|
120
|
+
title: opts.title,
|
|
121
|
+
userStoryOrdinalId: Number(opts.userStoryOrdinalId),
|
|
122
|
+
};
|
|
123
|
+
const merged = mergeBodies(body, opts.jsonInput);
|
|
124
|
+
const out = await runTool("create-test-scenario", merged, { postMcp });
|
|
125
|
+
console.log(out);
|
|
126
|
+
});
|
|
127
|
+
program
|
|
128
|
+
.command("update-user-story")
|
|
129
|
+
.description(TOOL_DEFINITIONS.find((t) => t.kebab === "update-user-story").description)
|
|
130
|
+
.addOption(jsonInputOption())
|
|
131
|
+
.option("--content <markdown>", "full markdown including frontmatter")
|
|
132
|
+
.option("--content-file <path>", "read markdown from file")
|
|
133
|
+
.action(async (opts) => {
|
|
134
|
+
let content = opts.content;
|
|
135
|
+
if (opts.contentFile)
|
|
136
|
+
content = await readFile(String(opts.contentFile), "utf8");
|
|
137
|
+
if (!content)
|
|
138
|
+
throw new Error("Provide --content or --content-file (or full body via --json-input)");
|
|
139
|
+
const body = { content };
|
|
140
|
+
const merged = mergeBodies(body, opts.jsonInput);
|
|
141
|
+
const out = await runTool("update-user-story", merged, { postMcp });
|
|
142
|
+
console.log(out);
|
|
143
|
+
});
|
|
144
|
+
program
|
|
145
|
+
.command("update-test-scenario")
|
|
146
|
+
.description(TOOL_DEFINITIONS.find((t) => t.kebab === "update-test-scenario").description)
|
|
147
|
+
.addOption(jsonInputOption())
|
|
148
|
+
.option("--content <markdown>")
|
|
149
|
+
.option("--content-file <path>")
|
|
150
|
+
.action(async (opts) => {
|
|
151
|
+
let content = opts.content;
|
|
152
|
+
if (opts.contentFile)
|
|
153
|
+
content = await readFile(String(opts.contentFile), "utf8");
|
|
154
|
+
if (!content)
|
|
155
|
+
throw new Error("Provide --content or --content-file (or full body via --json-input)");
|
|
156
|
+
const body = { content };
|
|
157
|
+
const merged = mergeBodies(body, opts.jsonInput);
|
|
158
|
+
const out = await runTool("update-test-scenario", merged, { postMcp });
|
|
159
|
+
console.log(out);
|
|
160
|
+
});
|
|
161
|
+
program
|
|
162
|
+
.command("get-eaas-config")
|
|
163
|
+
.description(TOOL_DEFINITIONS.find((t) => t.kebab === "get-eaas-config").description)
|
|
164
|
+
.addOption(jsonInputOption())
|
|
165
|
+
.action(async (opts) => {
|
|
166
|
+
const merged = mergeBodies({}, opts.jsonInput);
|
|
167
|
+
const out = await runTool("get-eaas-config", merged, { postMcp });
|
|
168
|
+
console.log(out);
|
|
169
|
+
});
|
|
170
|
+
program
|
|
171
|
+
.command("get-branch-specific-endpoint-config")
|
|
172
|
+
.description(TOOL_DEFINITIONS.find((t) => t.kebab === "get-branch-specific-endpoint-config").description)
|
|
173
|
+
.addOption(jsonInputOption())
|
|
174
|
+
.option("--branch-name <s>")
|
|
175
|
+
.action(async (opts) => {
|
|
176
|
+
const body = {};
|
|
177
|
+
if (opts.branchName)
|
|
178
|
+
body.branchName = opts.branchName;
|
|
179
|
+
const merged = mergeBodies(body, opts.jsonInput);
|
|
180
|
+
const out = await runTool("get-branch-specific-endpoint-config", merged, { postMcp });
|
|
181
|
+
console.log(out);
|
|
182
|
+
});
|
|
183
|
+
program
|
|
184
|
+
.command("provision-ephemeral-environment-and-wait")
|
|
185
|
+
.description(TOOL_DEFINITIONS.find((t) => t.kebab === "provision-ephemeral-environment-and-wait").description)
|
|
186
|
+
.addOption(jsonInputOption())
|
|
187
|
+
.option("--branch-name <s>")
|
|
188
|
+
.option("--poll-interval-seconds <n>")
|
|
189
|
+
.option("--max-wait-minutes <n>")
|
|
190
|
+
.action(async (opts) => {
|
|
191
|
+
const body = {};
|
|
192
|
+
if (opts.branchName)
|
|
193
|
+
body.branchName = opts.branchName;
|
|
194
|
+
if (opts.pollIntervalSeconds != null)
|
|
195
|
+
body.pollIntervalSeconds = Number(opts.pollIntervalSeconds);
|
|
196
|
+
if (opts.maxWaitMinutes != null)
|
|
197
|
+
body.maxWaitMinutes = Number(opts.maxWaitMinutes);
|
|
198
|
+
const merged = mergeBodies(body, opts.jsonInput);
|
|
199
|
+
const out = await runTool("provision-ephemeral-environment-and-wait", merged, {
|
|
200
|
+
postMcp,
|
|
201
|
+
onProgress: stderrProgress,
|
|
202
|
+
});
|
|
203
|
+
console.log(out);
|
|
204
|
+
});
|
|
205
|
+
program
|
|
206
|
+
.command("provision-ephemeral-environment")
|
|
207
|
+
.description(TOOL_DEFINITIONS.find((t) => t.kebab === "provision-ephemeral-environment").description)
|
|
208
|
+
.addOption(jsonInputOption())
|
|
209
|
+
.option("--branch-name <s>")
|
|
210
|
+
.action(async (opts) => {
|
|
211
|
+
const body = {};
|
|
212
|
+
if (opts.branchName)
|
|
213
|
+
body.branchName = opts.branchName;
|
|
214
|
+
const merged = mergeBodies(body, opts.jsonInput);
|
|
215
|
+
const out = await runTool("provision-ephemeral-environment", merged, { postMcp });
|
|
216
|
+
console.log(out);
|
|
217
|
+
});
|
|
218
|
+
program
|
|
219
|
+
.command("get-ephemeral-environment-status")
|
|
220
|
+
.description(TOOL_DEFINITIONS.find((t) => t.kebab === "get-ephemeral-environment-status").description)
|
|
221
|
+
.addOption(jsonInputOption())
|
|
222
|
+
.requiredOption("--bns-environment-id <id>")
|
|
223
|
+
.action(async (opts) => {
|
|
224
|
+
const body = { bnsEnvironmentId: opts.bnsEnvironmentId };
|
|
225
|
+
const merged = mergeBodies(body, opts.jsonInput);
|
|
226
|
+
const out = await runTool("get-ephemeral-environment-status", merged, { postMcp });
|
|
227
|
+
console.log(out);
|
|
228
|
+
});
|
|
229
|
+
program
|
|
230
|
+
.command("destroy-ephemeral-environment")
|
|
231
|
+
.description(TOOL_DEFINITIONS.find((t) => t.kebab === "destroy-ephemeral-environment").description)
|
|
232
|
+
.addOption(jsonInputOption())
|
|
233
|
+
.requiredOption("--bns-environment-id <id>")
|
|
234
|
+
.action(async (opts) => {
|
|
235
|
+
const body = { bnsEnvironmentId: opts.bnsEnvironmentId };
|
|
236
|
+
const merged = mergeBodies(body, opts.jsonInput);
|
|
237
|
+
const out = await runTool("destroy-ephemeral-environment", merged, { postMcp });
|
|
238
|
+
console.log(out);
|
|
239
|
+
});
|
|
240
|
+
program
|
|
241
|
+
.command("list-bunnyshell-environment-events")
|
|
242
|
+
.description(TOOL_DEFINITIONS.find((t) => t.kebab === "list-bunnyshell-environment-events").description)
|
|
243
|
+
.addOption(jsonInputOption())
|
|
244
|
+
.requiredOption("--bns-environment-id <id>")
|
|
245
|
+
.option("--event-type <s>")
|
|
246
|
+
.option("--event-status <s>")
|
|
247
|
+
.option("--page <n>")
|
|
248
|
+
.action(async (opts) => {
|
|
249
|
+
const body = { bnsEnvironmentId: opts.bnsEnvironmentId };
|
|
250
|
+
if (opts.eventType)
|
|
251
|
+
body.eventType = opts.eventType;
|
|
252
|
+
if (opts.eventStatus)
|
|
253
|
+
body.eventStatus = opts.eventStatus;
|
|
254
|
+
if (opts.page != null)
|
|
255
|
+
body.page = Number(opts.page);
|
|
256
|
+
const merged = mergeBodies(body, opts.jsonInput);
|
|
257
|
+
const out = await runTool("list-bunnyshell-environment-events", merged, { postMcp });
|
|
258
|
+
console.log(out);
|
|
259
|
+
});
|
|
260
|
+
program
|
|
261
|
+
.command("list-bunnyshell-workflow-jobs")
|
|
262
|
+
.description(TOOL_DEFINITIONS.find((t) => t.kebab === "list-bunnyshell-workflow-jobs").description)
|
|
263
|
+
.addOption(jsonInputOption())
|
|
264
|
+
.requiredOption("--bns-environment-id <id>")
|
|
265
|
+
.option("--page <n>")
|
|
266
|
+
.action(async (opts) => {
|
|
267
|
+
const body = { bnsEnvironmentId: opts.bnsEnvironmentId };
|
|
268
|
+
if (opts.page != null)
|
|
269
|
+
body.page = Number(opts.page);
|
|
270
|
+
const merged = mergeBodies(body, opts.jsonInput);
|
|
271
|
+
const out = await runTool("list-bunnyshell-workflow-jobs", merged, { postMcp });
|
|
272
|
+
console.log(out);
|
|
273
|
+
});
|
|
274
|
+
program
|
|
275
|
+
.command("get-bunnyshell-workflow-job-logs")
|
|
276
|
+
.description(TOOL_DEFINITIONS.find((t) => t.kebab === "get-bunnyshell-workflow-job-logs").description)
|
|
277
|
+
.addOption(jsonInputOption())
|
|
278
|
+
.requiredOption("--bns-environment-id <id>")
|
|
279
|
+
.requiredOption("--workflow-job-id <id>")
|
|
280
|
+
.action(async (opts) => {
|
|
281
|
+
const body = { bnsEnvironmentId: opts.bnsEnvironmentId, workflowJobId: opts.workflowJobId };
|
|
282
|
+
const merged = mergeBodies(body, opts.jsonInput);
|
|
283
|
+
const out = await runTool("get-bunnyshell-workflow-job-logs", merged, { postMcp });
|
|
284
|
+
console.log(out);
|
|
285
|
+
});
|
|
286
|
+
program
|
|
287
|
+
.command("list-rum-environments")
|
|
288
|
+
.description(TOOL_DEFINITIONS.find((t) => t.kebab === "list-rum-environments").description)
|
|
289
|
+
.addOption(jsonInputOption())
|
|
290
|
+
.action(async (opts) => {
|
|
291
|
+
const merged = mergeBodies({}, opts.jsonInput);
|
|
292
|
+
const out = await runTool("list-rum-environments", merged, { postMcp });
|
|
293
|
+
console.log(out);
|
|
294
|
+
});
|
|
295
|
+
const truecoverageHelp = "Prefer --json-input with full request JSON (proto-shaped). Flags are optional shortcuts where listed.";
|
|
296
|
+
program
|
|
297
|
+
.command("get-truecoverage-events")
|
|
298
|
+
.description(TOOL_DEFINITIONS.find((t) => t.kebab === "get-truecoverage-events").description + " " + truecoverageHelp)
|
|
299
|
+
.addOption(jsonInputOption())
|
|
300
|
+
.action(async (opts) => {
|
|
301
|
+
const merged = mergeBodies({}, opts.jsonInput);
|
|
302
|
+
const out = await runTool("get-truecoverage-events", merged, { postMcp });
|
|
303
|
+
console.log(out);
|
|
304
|
+
});
|
|
305
|
+
program
|
|
306
|
+
.command("get-truecoverage-event-details")
|
|
307
|
+
.description(TOOL_DEFINITIONS.find((t) => t.kebab === "get-truecoverage-event-details").description + " " + truecoverageHelp)
|
|
308
|
+
.addOption(jsonInputOption())
|
|
309
|
+
.action(async (opts) => {
|
|
310
|
+
const merged = mergeBodies({}, opts.jsonInput);
|
|
311
|
+
const out = await runTool("get-truecoverage-event-details", merged, { postMcp });
|
|
312
|
+
console.log(out);
|
|
313
|
+
});
|
|
314
|
+
program
|
|
315
|
+
.command("get-truecoverage-child-event-tree")
|
|
316
|
+
.description(TOOL_DEFINITIONS.find((t) => t.kebab === "get-truecoverage-child-event-tree").description + " " + truecoverageHelp)
|
|
317
|
+
.addOption(jsonInputOption())
|
|
318
|
+
.action(async (opts) => {
|
|
319
|
+
const merged = mergeBodies({}, opts.jsonInput);
|
|
320
|
+
const out = await runTool("get-truecoverage-child-event-tree", merged, { postMcp });
|
|
321
|
+
console.log(out);
|
|
322
|
+
});
|
|
323
|
+
program
|
|
324
|
+
.command("get-truecoverage-event-transition")
|
|
325
|
+
.description(TOOL_DEFINITIONS.find((t) => t.kebab === "get-truecoverage-event-transition").description + " " + truecoverageHelp)
|
|
326
|
+
.addOption(jsonInputOption())
|
|
327
|
+
.action(async (opts) => {
|
|
328
|
+
const merged = mergeBodies({}, opts.jsonInput);
|
|
329
|
+
const out = await runTool("get-truecoverage-event-transition", merged, { postMcp });
|
|
330
|
+
console.log(out);
|
|
331
|
+
});
|
|
332
|
+
program
|
|
333
|
+
.command("get-truecoverage-event-time-series")
|
|
334
|
+
.description(TOOL_DEFINITIONS.find((t) => t.kebab === "get-truecoverage-event-time-series").description + " " + truecoverageHelp)
|
|
335
|
+
.addOption(jsonInputOption())
|
|
336
|
+
.action(async (opts) => {
|
|
337
|
+
const merged = mergeBodies({}, opts.jsonInput);
|
|
338
|
+
const out = await runTool("get-truecoverage-event-time-series", merged, { postMcp });
|
|
339
|
+
console.log(out);
|
|
340
|
+
});
|
|
341
|
+
program
|
|
342
|
+
.command("get-truecoverage-session-metadata-keys")
|
|
343
|
+
.description(TOOL_DEFINITIONS.find((t) => t.kebab === "get-truecoverage-session-metadata-keys").description)
|
|
344
|
+
.addOption(jsonInputOption())
|
|
345
|
+
.action(async (opts) => {
|
|
346
|
+
const merged = mergeBodies({}, opts.jsonInput);
|
|
347
|
+
const out = await runTool("get-truecoverage-session-metadata-keys", merged, { postMcp });
|
|
348
|
+
console.log(out);
|
|
349
|
+
});
|
|
350
|
+
program
|
|
351
|
+
.command("get-truecoverage-event-metadata-keys")
|
|
352
|
+
.description(TOOL_DEFINITIONS.find((t) => t.kebab === "get-truecoverage-event-metadata-keys").description)
|
|
353
|
+
.addOption(jsonInputOption())
|
|
354
|
+
.requiredOption("--event-title <title>")
|
|
355
|
+
.action(async (opts) => {
|
|
356
|
+
const body = { eventTitle: opts.eventTitle };
|
|
357
|
+
const merged = mergeBodies(body, opts.jsonInput);
|
|
358
|
+
const out = await runTool("get-truecoverage-event-metadata-keys", merged, { postMcp });
|
|
359
|
+
console.log(out);
|
|
360
|
+
});
|
|
361
|
+
program.on("--help", () => {
|
|
362
|
+
/* default */
|
|
363
|
+
});
|
|
364
|
+
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`);
|
|
365
|
+
return program;
|
|
366
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** HTTP client for TestChimp MCP proxy APIs. */
|
|
2
|
+
export declare const DEFAULT_BACKEND = "https://featureservice.testchimp.io";
|
|
3
|
+
export declare function getBackendUrl(): string;
|
|
4
|
+
export declare function requireApiKey(): string;
|
|
5
|
+
export declare function postMcp(path: string, body: unknown): Promise<string>;
|
|
6
|
+
export type PostMcpFn = (path: string, body: unknown) => Promise<string>;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** HTTP client for TestChimp MCP proxy APIs. */
|
|
2
|
+
export const DEFAULT_BACKEND = "https://featureservice.testchimp.io";
|
|
3
|
+
export function getBackendUrl() {
|
|
4
|
+
const raw = process.env.TESTCHIMP_BACKEND_URL?.trim();
|
|
5
|
+
if (!raw)
|
|
6
|
+
return DEFAULT_BACKEND;
|
|
7
|
+
return raw.replace(/\/$/, "");
|
|
8
|
+
}
|
|
9
|
+
export function requireApiKey() {
|
|
10
|
+
const k = process.env.TESTCHIMP_API_KEY?.trim();
|
|
11
|
+
if (!k) {
|
|
12
|
+
throw new Error("TESTCHIMP_API_KEY is required. Set it in your project MCP config env (e.g. Cursor .cursor/mcp.json), then export it in the shell for CLI, or rely on the IDE for MCP.");
|
|
13
|
+
}
|
|
14
|
+
return k;
|
|
15
|
+
}
|
|
16
|
+
export async function postMcp(path, body) {
|
|
17
|
+
const apiKey = requireApiKey();
|
|
18
|
+
const url = `${getBackendUrl()}${path}`;
|
|
19
|
+
const res = await fetch(url, {
|
|
20
|
+
method: "POST",
|
|
21
|
+
headers: {
|
|
22
|
+
"Content-Type": "application/json",
|
|
23
|
+
"TestChimp-Api-Key": apiKey,
|
|
24
|
+
},
|
|
25
|
+
body: JSON.stringify(body ?? {}),
|
|
26
|
+
});
|
|
27
|
+
const text = await res.text();
|
|
28
|
+
if (!res.ok) {
|
|
29
|
+
throw new Error(`TestChimp API ${res.status} ${res.statusText}: ${text}`);
|
|
30
|
+
}
|
|
31
|
+
return text;
|
|
32
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Poll TestChimp MCP EaaS endpoints until BunnyShell reports deployed + component URLs,
|
|
3
|
+
* or a terminal failure / timeout.
|
|
4
|
+
*/
|
|
5
|
+
export interface ProvisionWaitArgs {
|
|
6
|
+
branchName?: string;
|
|
7
|
+
pollIntervalSeconds?: number;
|
|
8
|
+
maxWaitMinutes?: number;
|
|
9
|
+
}
|
|
10
|
+
export type ProgressLog = (message: string) => void | Promise<void>;
|
|
11
|
+
export declare function runProvisionEphemeralEnvironmentAndWait(postMcp: (path: string, body: unknown) => Promise<string>, log: ProgressLog | undefined, args: ProvisionWaitArgs): Promise<string>;
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
function sleep(ms) {
|
|
2
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
3
|
+
}
|
|
4
|
+
function clamp(n, min, max) {
|
|
5
|
+
return Math.min(max, Math.max(min, n));
|
|
6
|
+
}
|
|
7
|
+
function pickStr(obj, ...keys) {
|
|
8
|
+
for (const k of keys) {
|
|
9
|
+
const v = obj[k];
|
|
10
|
+
if (typeof v === "string" && v.trim() !== "")
|
|
11
|
+
return v;
|
|
12
|
+
}
|
|
13
|
+
return "";
|
|
14
|
+
}
|
|
15
|
+
function parseJsonObject(text) {
|
|
16
|
+
try {
|
|
17
|
+
const v = JSON.parse(text);
|
|
18
|
+
return v !== null && typeof v === "object" && !Array.isArray(v)
|
|
19
|
+
? v
|
|
20
|
+
: {};
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return {};
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function componentUrlsNonEmpty(componentUrlsJson) {
|
|
27
|
+
const t = componentUrlsJson.trim();
|
|
28
|
+
if (!t)
|
|
29
|
+
return false;
|
|
30
|
+
try {
|
|
31
|
+
const v = JSON.parse(t);
|
|
32
|
+
if (v === null || typeof v !== "object" || Array.isArray(v))
|
|
33
|
+
return false;
|
|
34
|
+
return Object.keys(v).length > 0;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function isTerminalDeployFailure(aggregateStatus, operationStatus, clusterStatus) {
|
|
41
|
+
const ag = aggregateStatus.toLowerCase().trim();
|
|
42
|
+
if (ag === "deployed")
|
|
43
|
+
return false;
|
|
44
|
+
const op = operationStatus.toLowerCase();
|
|
45
|
+
const cl = clusterStatus.toLowerCase();
|
|
46
|
+
if (op.includes("deploying") || op.includes("in_progress") || op.includes("in progress")) {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
const bad = /\b(fail|failed|failure|error|deleted)\b/;
|
|
50
|
+
if (bad.test(op) || bad.test(cl) || bad.test(ag))
|
|
51
|
+
return true;
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
function lastStatusSnapshot(obj) {
|
|
55
|
+
return {
|
|
56
|
+
status: pickStr(obj, "status", "Status"),
|
|
57
|
+
operationStatus: pickStr(obj, "operationStatus", "operation_status"),
|
|
58
|
+
clusterStatus: pickStr(obj, "clusterStatus", "cluster_status"),
|
|
59
|
+
environmentType: pickStr(obj, "environmentType", "environment_type"),
|
|
60
|
+
dashboardUrl: pickStr(obj, "dashboardUrl", "dashboard_url"),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
export async function runProvisionEphemeralEnvironmentAndWait(postMcp, log, args) {
|
|
64
|
+
const pollIntervalSec = clamp(Math.round(args.pollIntervalSeconds ?? 60), 30, 120);
|
|
65
|
+
const maxWaitMin = clamp(Math.round(args.maxWaitMinutes ?? 25), 5, 45);
|
|
66
|
+
const deadline = Date.now() + maxWaitMin * 60 * 1000;
|
|
67
|
+
const intervalMs = pollIntervalSec * 1000;
|
|
68
|
+
const provisionBody = {};
|
|
69
|
+
if (args.branchName != null && args.branchName.trim() !== "") {
|
|
70
|
+
provisionBody.branchName = args.branchName.trim();
|
|
71
|
+
}
|
|
72
|
+
let bnsEnvironmentId = "";
|
|
73
|
+
let branch = "";
|
|
74
|
+
let provisionRaw = "";
|
|
75
|
+
try {
|
|
76
|
+
provisionRaw = await postMcp("/api/mcp/provision_ephemeral_environment", provisionBody);
|
|
77
|
+
}
|
|
78
|
+
catch (e) {
|
|
79
|
+
const errMsg = e instanceof Error ? e.message : String(e);
|
|
80
|
+
return JSON.stringify({
|
|
81
|
+
outcome: "failed",
|
|
82
|
+
failure_phase: "provision",
|
|
83
|
+
message: `Could not start ephemeral environment (create/deploy trigger failed). ${errMsg}`,
|
|
84
|
+
bns_environment_id: "",
|
|
85
|
+
branch: "",
|
|
86
|
+
component_urls_json: "",
|
|
87
|
+
last_status: {},
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
const prov = parseJsonObject(provisionRaw);
|
|
91
|
+
bnsEnvironmentId = pickStr(prov, "bnsEnvironmentId", "bns_environment_id");
|
|
92
|
+
branch = pickStr(prov, "branch", "branch_name");
|
|
93
|
+
if (!bnsEnvironmentId) {
|
|
94
|
+
return JSON.stringify({
|
|
95
|
+
outcome: "failed",
|
|
96
|
+
failure_phase: "provision",
|
|
97
|
+
message: "Provision response did not include bns_environment_id. Check BunnyShell + GitHub integration and project EaaS settings.",
|
|
98
|
+
bns_environment_id: "",
|
|
99
|
+
branch,
|
|
100
|
+
component_urls_json: "",
|
|
101
|
+
last_status: prov,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
await Promise.resolve(log?.(`Ephemeral env provision started (bns=${bnsEnvironmentId}, branch=${branch || "?"}). Polling up to ${maxWaitMin} min, every ${pollIntervalSec}s.`));
|
|
105
|
+
let pollIndex = 0;
|
|
106
|
+
let lastSnapshot = {};
|
|
107
|
+
let lastComponentUrlsJson = "";
|
|
108
|
+
let lastDashboardUrl = "";
|
|
109
|
+
while (Date.now() < deadline) {
|
|
110
|
+
pollIndex += 1;
|
|
111
|
+
let statusRaw;
|
|
112
|
+
try {
|
|
113
|
+
statusRaw = await postMcp("/api/mcp/get_ephemeral_environment_status", {
|
|
114
|
+
bnsEnvironmentId,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
catch (e) {
|
|
118
|
+
const errMsg = e instanceof Error ? e.message : String(e);
|
|
119
|
+
return JSON.stringify({
|
|
120
|
+
outcome: "failed",
|
|
121
|
+
failure_phase: "deploy",
|
|
122
|
+
message: `Status check failed while waiting for deployment: ${errMsg}`,
|
|
123
|
+
bns_environment_id: bnsEnvironmentId,
|
|
124
|
+
branch,
|
|
125
|
+
component_urls_json: "",
|
|
126
|
+
last_status: lastSnapshot,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
const st = parseJsonObject(statusRaw);
|
|
130
|
+
lastSnapshot = lastStatusSnapshot(st);
|
|
131
|
+
const aggregateStatus = pickStr(st, "status", "Status");
|
|
132
|
+
const operationStatus = pickStr(st, "operationStatus", "operation_status");
|
|
133
|
+
const clusterStatus = pickStr(st, "clusterStatus", "cluster_status");
|
|
134
|
+
const componentUrlsJson = pickStr(st, "componentUrlsJson", "component_urls_json");
|
|
135
|
+
const dashboardUrl = pickStr(st, "dashboardUrl", "dashboard_url");
|
|
136
|
+
lastComponentUrlsJson = componentUrlsJson;
|
|
137
|
+
lastDashboardUrl = dashboardUrl;
|
|
138
|
+
await Promise.resolve(log?.(`Still waiting for ephemeral environment (poll ${pollIndex}): status=${aggregateStatus} op=${operationStatus} cluster=${clusterStatus} bns=${bnsEnvironmentId}`));
|
|
139
|
+
const deployed = aggregateStatus.toLowerCase() === "deployed";
|
|
140
|
+
const urlsOk = componentUrlsNonEmpty(componentUrlsJson);
|
|
141
|
+
if (deployed && urlsOk) {
|
|
142
|
+
return JSON.stringify({
|
|
143
|
+
outcome: "success",
|
|
144
|
+
message: "Ephemeral environment is deployed and component URLs are available.",
|
|
145
|
+
bns_environment_id: bnsEnvironmentId,
|
|
146
|
+
branch,
|
|
147
|
+
dashboard_url: dashboardUrl,
|
|
148
|
+
component_urls_json: componentUrlsJson,
|
|
149
|
+
ready_for_seeding: true,
|
|
150
|
+
last_status: lastSnapshot,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
if (deployed && !urlsOk) {
|
|
154
|
+
return JSON.stringify({
|
|
155
|
+
outcome: "success",
|
|
156
|
+
message: "Environment reports deployed but component URL map is empty or missing; check BunnyShell definition or dashboard.",
|
|
157
|
+
warnings: ["component_urls_json_empty_or_unparsed"],
|
|
158
|
+
bns_environment_id: bnsEnvironmentId,
|
|
159
|
+
branch,
|
|
160
|
+
dashboard_url: dashboardUrl,
|
|
161
|
+
component_urls_json: componentUrlsJson,
|
|
162
|
+
ready_for_seeding: false,
|
|
163
|
+
last_status: lastSnapshot,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
if (isTerminalDeployFailure(aggregateStatus, operationStatus, clusterStatus)) {
|
|
167
|
+
return JSON.stringify({
|
|
168
|
+
outcome: "failed",
|
|
169
|
+
failure_phase: "deploy",
|
|
170
|
+
message: `BunnyShell deployment did not succeed (status=${aggregateStatus}, operation=${operationStatus}, cluster=${clusterStatus}).`,
|
|
171
|
+
bns_environment_id: bnsEnvironmentId,
|
|
172
|
+
branch,
|
|
173
|
+
dashboard_url: dashboardUrl,
|
|
174
|
+
component_urls_json: componentUrlsJson,
|
|
175
|
+
last_status: lastSnapshot,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
const remainingMs = deadline - Date.now();
|
|
179
|
+
if (remainingMs <= 0)
|
|
180
|
+
break;
|
|
181
|
+
const sleepMs = Math.min(intervalMs, remainingMs);
|
|
182
|
+
await sleep(sleepMs);
|
|
183
|
+
}
|
|
184
|
+
return JSON.stringify({
|
|
185
|
+
outcome: "timeout",
|
|
186
|
+
failure_phase: "wait",
|
|
187
|
+
message: `Timed out after ${maxWaitMin} minutes waiting for ephemeral environment ${bnsEnvironmentId} to become ready.`,
|
|
188
|
+
bns_environment_id: bnsEnvironmentId,
|
|
189
|
+
branch,
|
|
190
|
+
dashboard_url: lastDashboardUrl,
|
|
191
|
+
component_urls_json: lastComponentUrlsJson,
|
|
192
|
+
last_status: lastSnapshot,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Deep-merge plain objects; values from `override` win on conflicts. Arrays and primitives replace entirely. */
|
|
2
|
+
export function deepMerge(base, override) {
|
|
3
|
+
const out = { ...base };
|
|
4
|
+
for (const [k, v] of Object.entries(override)) {
|
|
5
|
+
if (v !== null && typeof v === "object" && !Array.isArray(v) && out[k] !== null && typeof out[k] === "object" && !Array.isArray(out[k])) {
|
|
6
|
+
out[k] = deepMerge(out[k], v);
|
|
7
|
+
}
|
|
8
|
+
else {
|
|
9
|
+
out[k] = v;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
return out;
|
|
13
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export function normalizeScope(scope) {
|
|
2
|
+
let folderPath;
|
|
3
|
+
if (Array.isArray(scope.folderPath)) {
|
|
4
|
+
folderPath = scope.folderPath;
|
|
5
|
+
}
|
|
6
|
+
else if (typeof scope.folderPath === "string" && scope.folderPath.trim() !== "") {
|
|
7
|
+
folderPath = scope.folderPath
|
|
8
|
+
.split("/")
|
|
9
|
+
.map((seg) => seg.trim())
|
|
10
|
+
.filter(Boolean);
|
|
11
|
+
}
|
|
12
|
+
const out = {};
|
|
13
|
+
if (scope.filePaths?.length)
|
|
14
|
+
out.filePaths = scope.filePaths;
|
|
15
|
+
if (folderPath)
|
|
16
|
+
out.folderPath = folderPath;
|
|
17
|
+
return out;
|
|
18
|
+
}
|