@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.
@@ -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,2 @@
1
+ /** Deep-merge plain objects; values from `override` win on conflicts. Arrays and primitives replace entirely. */
2
+ export declare function deepMerge(base: Record<string, unknown>, override: Record<string, unknown>): Record<string, unknown>;
@@ -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,7 @@
1
+ export declare function normalizeScope(scope: {
2
+ filePaths?: string[];
3
+ folderPath?: string[] | string;
4
+ }): {
5
+ filePaths?: string[];
6
+ folderPath?: string[];
7
+ };
@@ -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
+ }