@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,70 @@
1
+ import { z } from "zod";
2
+ export declare const scopeSchema: z.ZodOptional<z.ZodObject<{
3
+ filePaths: z.ZodOptional<z.ZodArray<z.ZodString>>;
4
+ folderPath: z.ZodOptional<z.ZodUnion<readonly [z.ZodArray<z.ZodString>, z.ZodString]>>;
5
+ }, z.core.$strip>>;
6
+ export declare const listCoverageInput: z.ZodObject<{
7
+ release: z.ZodOptional<z.ZodString>;
8
+ environment: z.ZodOptional<z.ZodString>;
9
+ scope: z.ZodOptional<z.ZodObject<{
10
+ filePaths: z.ZodOptional<z.ZodArray<z.ZodString>>;
11
+ folderPath: z.ZodOptional<z.ZodUnion<readonly [z.ZodArray<z.ZodString>, z.ZodString]>>;
12
+ }, z.core.$strip>>;
13
+ includeNonCoveredUserStories: z.ZodOptional<z.ZodBoolean>;
14
+ includeNonCoveredTestScenarios: z.ZodOptional<z.ZodBoolean>;
15
+ branchName: z.ZodOptional<z.ZodString>;
16
+ }, z.core.$strip>;
17
+ export declare const listExecutionInput: z.ZodObject<{
18
+ release: z.ZodOptional<z.ZodString>;
19
+ environment: z.ZodOptional<z.ZodString>;
20
+ scope: z.ZodOptional<z.ZodObject<{
21
+ filePaths: z.ZodOptional<z.ZodArray<z.ZodString>>;
22
+ folderPath: z.ZodOptional<z.ZodUnion<readonly [z.ZodArray<z.ZodString>, z.ZodString]>>;
23
+ }, z.core.$strip>>;
24
+ branchName: z.ZodOptional<z.ZodString>;
25
+ }, z.core.$strip>;
26
+ export declare const createUserStoryInput: z.ZodObject<{
27
+ platformFilePath: z.ZodString;
28
+ title: z.ZodString;
29
+ }, z.core.$strip>;
30
+ export declare const createTestScenarioInput: z.ZodObject<{
31
+ platformFilePath: z.ZodString;
32
+ title: z.ZodString;
33
+ userStoryOrdinalId: z.ZodCoercedNumber<unknown>;
34
+ }, z.core.$strip>;
35
+ export declare const updatePlanMarkdownInput: z.ZodObject<{
36
+ content: z.ZodString;
37
+ }, z.core.$strip>;
38
+ export declare const emptyInput: z.ZodObject<{}, z.core.$strip>;
39
+ export declare const getBranchSpecificEndpointConfigInput: z.ZodObject<{
40
+ branchName: z.ZodOptional<z.ZodString>;
41
+ }, z.core.$strip>;
42
+ export declare const truecoverageJsonInput: z.ZodRecord<z.ZodString, z.ZodUnknown>;
43
+ export declare const eventMetadataKeysInput: z.ZodObject<{
44
+ eventTitle: z.ZodString;
45
+ }, z.core.$strip>;
46
+ export declare const provisionEphemeralInput: z.ZodObject<{
47
+ branchName: z.ZodOptional<z.ZodString>;
48
+ }, z.core.$strip>;
49
+ export declare const bnsEnvironmentIdInput: z.ZodObject<{
50
+ bnsEnvironmentId: z.ZodString;
51
+ }, z.core.$strip>;
52
+ export declare const provisionEphemeralWaitInput: z.ZodObject<{
53
+ branchName: z.ZodOptional<z.ZodString>;
54
+ pollIntervalSeconds: z.ZodOptional<z.ZodNumber>;
55
+ maxWaitMinutes: z.ZodOptional<z.ZodNumber>;
56
+ }, z.core.$strip>;
57
+ export declare const listBunnyshellEnvironmentEventsInput: z.ZodObject<{
58
+ bnsEnvironmentId: z.ZodString;
59
+ eventType: z.ZodOptional<z.ZodString>;
60
+ eventStatus: z.ZodOptional<z.ZodString>;
61
+ page: z.ZodOptional<z.ZodNumber>;
62
+ }, z.core.$strip>;
63
+ export declare const listBunnyshellWorkflowJobsInput: z.ZodObject<{
64
+ bnsEnvironmentId: z.ZodString;
65
+ page: z.ZodOptional<z.ZodNumber>;
66
+ }, z.core.$strip>;
67
+ export declare const getBunnyshellWorkflowJobLogsInput: z.ZodObject<{
68
+ bnsEnvironmentId: z.ZodString;
69
+ workflowJobId: z.ZodString;
70
+ }, z.core.$strip>;
@@ -0,0 +1,66 @@
1
+ import { z } from "zod";
2
+ export const scopeSchema = z
3
+ .object({
4
+ filePaths: z.array(z.string()).optional(),
5
+ folderPath: z.union([z.array(z.string()), z.string()]).optional(),
6
+ })
7
+ .optional();
8
+ export const listCoverageInput = z.object({
9
+ release: z.string().optional(),
10
+ environment: z.string().optional(),
11
+ scope: scopeSchema,
12
+ includeNonCoveredUserStories: z.boolean().optional(),
13
+ includeNonCoveredTestScenarios: z.boolean().optional(),
14
+ branchName: z.string().optional(),
15
+ });
16
+ export const listExecutionInput = z.object({
17
+ release: z.string().optional(),
18
+ environment: z.string().optional(),
19
+ scope: scopeSchema,
20
+ branchName: z.string().optional(),
21
+ });
22
+ export const createUserStoryInput = z.object({
23
+ platformFilePath: z.string().min(1),
24
+ title: z.string().min(1),
25
+ });
26
+ export const createTestScenarioInput = z.object({
27
+ platformFilePath: z.string().min(1),
28
+ title: z.string().min(1),
29
+ userStoryOrdinalId: z.coerce.number().int().positive(),
30
+ });
31
+ export const updatePlanMarkdownInput = z.object({
32
+ content: z.string().min(1),
33
+ });
34
+ export const emptyInput = z.object({});
35
+ export const getBranchSpecificEndpointConfigInput = z.object({
36
+ branchName: z.string().optional(),
37
+ });
38
+ export const truecoverageJsonInput = z.record(z.string(), z.unknown());
39
+ export const eventMetadataKeysInput = z.object({
40
+ eventTitle: z.string().min(1),
41
+ });
42
+ export const provisionEphemeralInput = z.object({
43
+ branchName: z.string().optional(),
44
+ });
45
+ export const bnsEnvironmentIdInput = z.object({
46
+ bnsEnvironmentId: z.string().min(1),
47
+ });
48
+ export const provisionEphemeralWaitInput = z.object({
49
+ branchName: z.string().optional(),
50
+ pollIntervalSeconds: z.number().optional(),
51
+ maxWaitMinutes: z.number().optional(),
52
+ });
53
+ export const listBunnyshellEnvironmentEventsInput = z.object({
54
+ bnsEnvironmentId: z.string().min(1),
55
+ eventType: z.string().optional(),
56
+ eventStatus: z.string().optional(),
57
+ page: z.number().int().positive().optional(),
58
+ });
59
+ export const listBunnyshellWorkflowJobsInput = z.object({
60
+ bnsEnvironmentId: z.string().min(1),
61
+ page: z.number().int().positive().optional(),
62
+ });
63
+ export const getBunnyshellWorkflowJobLogsInput = z.object({
64
+ bnsEnvironmentId: z.string().min(1),
65
+ workflowJobId: z.string().min(1),
66
+ });
@@ -0,0 +1,16 @@
1
+ import { type ZodTypeAny } from "zod";
2
+ import type { PostMcpFn } from "./client.js";
3
+ import { type ProgressLog } from "./ephemeralWait.js";
4
+ export interface ToolContext {
5
+ postMcp: PostMcpFn;
6
+ onProgress?: ProgressLog;
7
+ }
8
+ export interface ToolDefinition {
9
+ kebab: string;
10
+ description: string;
11
+ inputSchema: ZodTypeAny;
12
+ execute: (args: unknown, ctx: ToolContext) => Promise<string>;
13
+ }
14
+ export declare const TOOL_DEFINITIONS: ToolDefinition[];
15
+ export declare function getToolDefinition(kebab: string): ToolDefinition | undefined;
16
+ export declare function runTool(kebab: string, rawArgs: unknown, ctx: ToolContext): Promise<string>;
@@ -0,0 +1,270 @@
1
+ import { normalizeScope } from "./normalize.js";
2
+ import { runProvisionEphemeralEnvironmentAndWait } from "./ephemeralWait.js";
3
+ import * as S from "./schemas.js";
4
+ function listCoverageBody(args) {
5
+ const body = {};
6
+ if (args.release != null)
7
+ body.release = args.release;
8
+ if (args.environment != null)
9
+ body.environment = args.environment;
10
+ if (args.scope != null)
11
+ body.scope = normalizeScope(args.scope);
12
+ if (args.includeNonCoveredUserStories != null)
13
+ body.includeNonCoveredUserStories = args.includeNonCoveredUserStories;
14
+ if (args.includeNonCoveredTestScenarios != null) {
15
+ body.includeNonCoveredTestScenarios = args.includeNonCoveredTestScenarios;
16
+ }
17
+ if (args.branchName != null && args.branchName.trim() !== "")
18
+ body.branchName = args.branchName.trim();
19
+ return body;
20
+ }
21
+ function listExecutionBody(args) {
22
+ const body = {};
23
+ if (args.release != null)
24
+ body.release = args.release;
25
+ if (args.environment != null)
26
+ body.environment = args.environment;
27
+ if (args.scope != null)
28
+ body.scope = normalizeScope(args.scope);
29
+ if (args.branchName != null && args.branchName.trim() !== "")
30
+ body.branchName = args.branchName.trim();
31
+ return body;
32
+ }
33
+ export const TOOL_DEFINITIONS = [
34
+ {
35
+ kebab: "get-requirement-coverage",
36
+ description: "Fetch requirement (scenario) coverage under an optional platform-rooted folder scope (tests/... or plans/...). " +
37
+ "Use branchName (Git branch) and scope.filePaths (paths under platform tests root) rather than internal ids.",
38
+ inputSchema: S.listCoverageInput,
39
+ execute: async (args, { postMcp }) => {
40
+ const json = await postMcp("/api/mcp/list_requirement_coverage", listCoverageBody(args));
41
+ return json;
42
+ },
43
+ },
44
+ {
45
+ kebab: "get-execution-history",
46
+ description: "Fetch SmartTest execution history for an optional platform-rooted folder scope. " +
47
+ "Use branchName and scope.filePaths as for coverage.",
48
+ inputSchema: S.listExecutionInput,
49
+ execute: async (args, { postMcp }) => {
50
+ const json = await postMcp("/api/mcp/list_execution_history", listExecutionBody(args));
51
+ return json;
52
+ },
53
+ },
54
+ {
55
+ kebab: "create-user-story",
56
+ description: "Create a user story on the TestChimp project and its plan file stub. " +
57
+ "Always call this before writing a new story markdown file; use the returned ordinalId as US-<ordinalId> in frontmatter. " +
58
+ "platformFilePath must be under plans/stories/ and end with .md.",
59
+ inputSchema: S.createUserStoryInput,
60
+ execute: async (args, { postMcp }) => {
61
+ const a = args;
62
+ return postMcp("/api/mcp/create_user_story", {
63
+ platformFilePath: a.platformFilePath,
64
+ title: a.title,
65
+ });
66
+ },
67
+ },
68
+ {
69
+ kebab: "create-test-scenario",
70
+ description: "Create a test scenario linked to a user story. platformFilePath must be under plans/scenarios/ and end with .md. " +
71
+ "userStoryOrdinalId is the numeric part of the parent US-<n> id.",
72
+ inputSchema: S.createTestScenarioInput,
73
+ execute: async (args, { postMcp }) => {
74
+ const a = args;
75
+ return postMcp("/api/mcp/create_test_scenario", {
76
+ platformFilePath: a.platformFilePath,
77
+ title: a.title,
78
+ userStoryOrdinalId: a.userStoryOrdinalId,
79
+ });
80
+ },
81
+ },
82
+ {
83
+ kebab: "update-user-story",
84
+ description: "Sync a user story markdown file to the platform after local edits. " +
85
+ "Parses frontmatter (id: US-..., title, priority, status) and updates the linked support file and entity.",
86
+ inputSchema: S.updatePlanMarkdownInput,
87
+ execute: async (args, { postMcp }) => {
88
+ const a = args;
89
+ return postMcp("/api/mcp/update_user_story", { content: a.content });
90
+ },
91
+ },
92
+ {
93
+ kebab: "update-test-scenario",
94
+ description: "Sync a test scenario markdown file to the platform after local edits. " +
95
+ "Parses frontmatter (id: TS-..., story: US-..., title, priority, status) and updates linking if story changes.",
96
+ inputSchema: S.updatePlanMarkdownInput,
97
+ execute: async (args, { postMcp }) => {
98
+ const a = args;
99
+ return postMcp("/api/mcp/update_test_scenario", { content: a.content });
100
+ },
101
+ },
102
+ {
103
+ kebab: "get-eaas-config",
104
+ description: "Return the project's BunnyShell (Environment-as-a-Service) settings. Secrets are never returned.",
105
+ inputSchema: S.emptyInput,
106
+ execute: async (_args, { postMcp }) => postMcp("/api/mcp/get_eaas_config", {}),
107
+ },
108
+ {
109
+ kebab: "get-branch-specific-endpoint-config",
110
+ description: "Resolve BASE_URL for a Git branch from TestChimp Branch Management (URL template and per-branch overrides).",
111
+ inputSchema: S.getBranchSpecificEndpointConfigInput,
112
+ execute: async (args, { postMcp }) => {
113
+ const a = args;
114
+ const body = {};
115
+ if (a.branchName != null && a.branchName.trim() !== "")
116
+ body.branchName = a.branchName.trim();
117
+ return postMcp("/api/mcp/get_branch_specific_endpoint_config", body);
118
+ },
119
+ },
120
+ {
121
+ kebab: "provision-ephemeral-environment-and-wait",
122
+ description: "Provision a BunnyShell ephemeral environment, then poll until deployed and component URLs are available. " +
123
+ "Progress is logged to stderr (CLI) or MCP logging when supported.",
124
+ inputSchema: S.provisionEphemeralWaitInput,
125
+ execute: async (args, { postMcp, onProgress }) => {
126
+ const a = args;
127
+ return runProvisionEphemeralEnvironmentAndWait(postMcp, onProgress, {
128
+ branchName: a.branchName,
129
+ pollIntervalSeconds: a.pollIntervalSeconds,
130
+ maxWaitMinutes: a.maxWaitMinutes,
131
+ });
132
+ },
133
+ },
134
+ {
135
+ kebab: "provision-ephemeral-environment",
136
+ description: "Create a BunnyShell ephemeral environment (create + deploy trigger only). Prefer provision-ephemeral-environment-and-wait for normal flows.",
137
+ inputSchema: S.provisionEphemeralInput,
138
+ execute: async (args, { postMcp }) => {
139
+ const a = args;
140
+ const body = {};
141
+ if (a.branchName != null && a.branchName.trim() !== "")
142
+ body.branchName = a.branchName.trim();
143
+ return postMcp("/api/mcp/provision_ephemeral_environment", body);
144
+ },
145
+ },
146
+ {
147
+ kebab: "get-ephemeral-environment-status",
148
+ description: "Poll BunnyShell for environment status and component_urls_json.",
149
+ inputSchema: S.bnsEnvironmentIdInput,
150
+ execute: async (args, { postMcp }) => {
151
+ const a = args;
152
+ return postMcp("/api/mcp/get_ephemeral_environment_status", { bnsEnvironmentId: a.bnsEnvironmentId });
153
+ },
154
+ },
155
+ {
156
+ kebab: "destroy-ephemeral-environment",
157
+ description: "Delete a BunnyShell environment created for this project.",
158
+ inputSchema: S.bnsEnvironmentIdInput,
159
+ execute: async (args, { postMcp }) => {
160
+ const a = args;
161
+ return postMcp("/api/mcp/destroy_ephemeral_environment", { bnsEnvironmentId: a.bnsEnvironmentId });
162
+ },
163
+ },
164
+ {
165
+ kebab: "list-bunnyshell-environment-events",
166
+ description: "Troubleshooting: list BunnyShell platform events for an environment.",
167
+ inputSchema: S.listBunnyshellEnvironmentEventsInput,
168
+ execute: async (args, { postMcp }) => {
169
+ const a = args;
170
+ const body = { bnsEnvironmentId: a.bnsEnvironmentId };
171
+ if (a.eventType != null && a.eventType.trim() !== "")
172
+ body.eventType = a.eventType.trim();
173
+ if (a.eventStatus != null && a.eventStatus.trim() !== "")
174
+ body.eventStatus = a.eventStatus.trim();
175
+ if (a.page != null)
176
+ body.page = a.page;
177
+ return postMcp("/api/mcp/list_bunnyshell_environment_events", body);
178
+ },
179
+ },
180
+ {
181
+ kebab: "list-bunnyshell-workflow-jobs",
182
+ description: "Troubleshooting: list BunnyShell workflow jobs for an environment.",
183
+ inputSchema: S.listBunnyshellWorkflowJobsInput,
184
+ execute: async (args, { postMcp }) => {
185
+ const a = args;
186
+ const body = { bnsEnvironmentId: a.bnsEnvironmentId };
187
+ if (a.page != null)
188
+ body.page = a.page;
189
+ return postMcp("/api/mcp/list_bunnyshell_workflow_jobs", body);
190
+ },
191
+ },
192
+ {
193
+ kebab: "get-bunnyshell-workflow-job-logs",
194
+ description: "Troubleshooting: fetch logs for a BunnyShell workflow job.",
195
+ inputSchema: S.getBunnyshellWorkflowJobLogsInput,
196
+ execute: async (args, { postMcp }) => {
197
+ const a = args;
198
+ return postMcp("/api/mcp/get_bunnyshell_workflow_job_logs", {
199
+ bnsEnvironmentId: a.bnsEnvironmentId,
200
+ workflowJobId: a.workflowJobId,
201
+ });
202
+ },
203
+ },
204
+ {
205
+ kebab: "list-rum-environments",
206
+ description: "List distinct RUM environment tags for TrueCoverage scoping.",
207
+ inputSchema: S.emptyInput,
208
+ execute: async (_args, { postMcp }) => postMcp("/api/mcp/list_rum_environments", {}),
209
+ },
210
+ {
211
+ kebab: "get-truecoverage-events",
212
+ description: "TrueCoverage event funnel summaries (ListEventsRequest JSON: baseExecutionScope, comparisonExecutionScope).",
213
+ inputSchema: S.truecoverageJsonInput,
214
+ execute: async (args, { postMcp }) => postMcp("/api/mcp/truecoverage_list_events", args ?? {}),
215
+ },
216
+ {
217
+ kebab: "get-truecoverage-event-details",
218
+ description: "TrueCoverage drill-down for one event title (GetEventDetailsRequest JSON).",
219
+ inputSchema: S.truecoverageJsonInput,
220
+ execute: async (args, { postMcp }) => postMcp("/api/mcp/truecoverage_event_details", args ?? {}),
221
+ },
222
+ {
223
+ kebab: "get-truecoverage-child-event-tree",
224
+ description: "TrueCoverage next-event tree for an event (ListChildEventTreeRequest JSON).",
225
+ inputSchema: S.truecoverageJsonInput,
226
+ execute: async (args, { postMcp }) => postMcp("/api/mcp/truecoverage_list_child_event_tree", args ?? {}),
227
+ },
228
+ {
229
+ kebab: "get-truecoverage-event-transition",
230
+ description: "TrueCoverage detailed transition summary between events (GetDetailedEventTransitionSummaryRequest JSON).",
231
+ inputSchema: S.truecoverageJsonInput,
232
+ execute: async (args, { postMcp }) => postMcp("/api/mcp/truecoverage_detailed_event_transition", args ?? {}),
233
+ },
234
+ {
235
+ kebab: "get-truecoverage-event-time-series",
236
+ description: "TrueCoverage time series for sessions or metrics (EventTimeSeriesRequest JSON).",
237
+ inputSchema: S.truecoverageJsonInput,
238
+ execute: async (args, { postMcp }) => postMcp("/api/mcp/truecoverage_event_time_series", args ?? {}),
239
+ },
240
+ {
241
+ kebab: "get-truecoverage-session-metadata-keys",
242
+ description: "List session-level metadata keys observed for TrueCoverage.",
243
+ inputSchema: S.emptyInput,
244
+ execute: async (_args, { postMcp }) => postMcp("/api/mcp/truecoverage_session_metadata_keys", {}),
245
+ },
246
+ {
247
+ kebab: "get-truecoverage-event-metadata-keys",
248
+ description: "List metadata keys for a given event title.",
249
+ inputSchema: S.eventMetadataKeysInput,
250
+ execute: async (args, { postMcp }) => {
251
+ const a = args;
252
+ return postMcp("/api/mcp/truecoverage_event_metadata_keys", { eventTitle: a.eventTitle });
253
+ },
254
+ },
255
+ ];
256
+ const TOOL_BY_KEBAB = new Map(TOOL_DEFINITIONS.map((t) => [t.kebab, t]));
257
+ export function getToolDefinition(kebab) {
258
+ return TOOL_BY_KEBAB.get(kebab);
259
+ }
260
+ export async function runTool(kebab, rawArgs, ctx) {
261
+ const def = TOOL_BY_KEBAB.get(kebab);
262
+ if (!def)
263
+ throw new Error(`Unknown tool: ${kebab}`);
264
+ const raw = rawArgs === undefined || rawArgs === null ? {} : rawArgs;
265
+ const parsed = def.inputSchema.safeParse(raw);
266
+ if (!parsed.success) {
267
+ throw new Error(`Invalid input for ${kebab}: ${parsed.error.message}`);
268
+ }
269
+ return def.execute(parsed.data, ctx);
270
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Poll TestChimp MCP EaaS endpoints until BunnyShell reports deployed + component URLs,
3
+ * or a terminal failure / timeout. See plan: provision_ephemeral_environment_and_wait.
4
+ */
5
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
6
+ export interface ProvisionWaitArgs {
7
+ branchName?: string;
8
+ pollIntervalSeconds?: number;
9
+ maxWaitMinutes?: number;
10
+ }
11
+ export declare function runProvisionEphemeralEnvironmentAndWait(postMcp: (path: string, body: unknown) => Promise<string>, server: McpServer, args: ProvisionWaitArgs): Promise<string>;
@@ -0,0 +1,206 @@
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
+ /** True if BNS / aggregate status indicates we should stop polling with failure (deploy phase). */
41
+ function isTerminalDeployFailure(aggregateStatus, operationStatus, clusterStatus) {
42
+ const ag = aggregateStatus.toLowerCase().trim();
43
+ if (ag === "deployed")
44
+ return false;
45
+ const op = operationStatus.toLowerCase();
46
+ const cl = clusterStatus.toLowerCase();
47
+ if (op.includes("deploying") || op.includes("in_progress") || op.includes("in progress")) {
48
+ return false;
49
+ }
50
+ const bad = /\b(fail|failed|failure|error|deleted)\b/;
51
+ if (bad.test(op) || bad.test(cl) || bad.test(ag))
52
+ return true;
53
+ return false;
54
+ }
55
+ function lastStatusSnapshot(obj) {
56
+ return {
57
+ status: pickStr(obj, "status", "Status"),
58
+ operationStatus: pickStr(obj, "operationStatus", "operation_status"),
59
+ clusterStatus: pickStr(obj, "clusterStatus", "cluster_status"),
60
+ environmentType: pickStr(obj, "environmentType", "environment_type"),
61
+ dashboardUrl: pickStr(obj, "dashboardUrl", "dashboard_url"),
62
+ };
63
+ }
64
+ async function logInfo(server, message) {
65
+ try {
66
+ await server.sendLoggingMessage({
67
+ level: "info",
68
+ data: message,
69
+ });
70
+ }
71
+ catch {
72
+ // Client may not support logging; ignore.
73
+ }
74
+ }
75
+ export async function runProvisionEphemeralEnvironmentAndWait(postMcp, server, args) {
76
+ const pollIntervalSec = clamp(Math.round(args.pollIntervalSeconds ?? 60), 30, 120);
77
+ const maxWaitMin = clamp(Math.round(args.maxWaitMinutes ?? 25), 5, 45);
78
+ const deadline = Date.now() + maxWaitMin * 60 * 1000;
79
+ const intervalMs = pollIntervalSec * 1000;
80
+ const provisionBody = {};
81
+ if (args.branchName != null && args.branchName.trim() !== "") {
82
+ provisionBody.branchName = args.branchName.trim();
83
+ }
84
+ let bnsEnvironmentId = "";
85
+ let branch = "";
86
+ let provisionRaw = "";
87
+ try {
88
+ provisionRaw = await postMcp("/api/mcp/provision_ephemeral_environment", provisionBody);
89
+ }
90
+ catch (e) {
91
+ const errMsg = e instanceof Error ? e.message : String(e);
92
+ return JSON.stringify({
93
+ outcome: "failed",
94
+ failure_phase: "provision",
95
+ message: `Could not start ephemeral environment (create/deploy trigger failed). ${errMsg}`,
96
+ bns_environment_id: "",
97
+ branch: "",
98
+ component_urls_json: "",
99
+ last_status: {},
100
+ });
101
+ }
102
+ const prov = parseJsonObject(provisionRaw);
103
+ bnsEnvironmentId = pickStr(prov, "bnsEnvironmentId", "bns_environment_id");
104
+ branch = pickStr(prov, "branch", "branch_name");
105
+ if (!bnsEnvironmentId) {
106
+ return JSON.stringify({
107
+ outcome: "failed",
108
+ failure_phase: "provision",
109
+ message: "Provision response did not include bns_environment_id. Check BunnyShell + GitHub integration and project EaaS settings.",
110
+ bns_environment_id: "",
111
+ branch,
112
+ component_urls_json: "",
113
+ last_status: prov,
114
+ });
115
+ }
116
+ await logInfo(server, `Ephemeral env provision started (bns=${bnsEnvironmentId}, branch=${branch || "?"}). Polling up to ${maxWaitMin} min, every ${pollIntervalSec}s.`);
117
+ let pollIndex = 0;
118
+ let lastSnapshot = {};
119
+ let lastComponentUrlsJson = "";
120
+ let lastDashboardUrl = "";
121
+ while (Date.now() < deadline) {
122
+ pollIndex += 1;
123
+ let statusRaw;
124
+ try {
125
+ statusRaw = await postMcp("/api/mcp/get_ephemeral_environment_status", {
126
+ bnsEnvironmentId,
127
+ });
128
+ }
129
+ catch (e) {
130
+ const errMsg = e instanceof Error ? e.message : String(e);
131
+ return JSON.stringify({
132
+ outcome: "failed",
133
+ failure_phase: "deploy",
134
+ message: `Status check failed while waiting for deployment: ${errMsg}`,
135
+ bns_environment_id: bnsEnvironmentId,
136
+ branch,
137
+ component_urls_json: "",
138
+ last_status: lastSnapshot,
139
+ });
140
+ }
141
+ const st = parseJsonObject(statusRaw);
142
+ lastSnapshot = lastStatusSnapshot(st);
143
+ const aggregateStatus = pickStr(st, "status", "Status");
144
+ const operationStatus = pickStr(st, "operationStatus", "operation_status");
145
+ const clusterStatus = pickStr(st, "clusterStatus", "cluster_status");
146
+ const componentUrlsJson = pickStr(st, "componentUrlsJson", "component_urls_json");
147
+ const dashboardUrl = pickStr(st, "dashboardUrl", "dashboard_url");
148
+ lastComponentUrlsJson = componentUrlsJson;
149
+ lastDashboardUrl = dashboardUrl;
150
+ await logInfo(server, `Ephemeral env poll ${pollIndex}: status=${aggregateStatus} op=${operationStatus} cluster=${clusterStatus} bns=${bnsEnvironmentId}`);
151
+ const deployed = aggregateStatus.toLowerCase() === "deployed";
152
+ const urlsOk = componentUrlsNonEmpty(componentUrlsJson);
153
+ if (deployed && urlsOk) {
154
+ return JSON.stringify({
155
+ outcome: "success",
156
+ message: "Ephemeral environment is deployed and component URLs are available.",
157
+ bns_environment_id: bnsEnvironmentId,
158
+ branch,
159
+ dashboard_url: dashboardUrl,
160
+ component_urls_json: componentUrlsJson,
161
+ ready_for_seeding: true,
162
+ last_status: lastSnapshot,
163
+ });
164
+ }
165
+ if (deployed && !urlsOk) {
166
+ return JSON.stringify({
167
+ outcome: "success",
168
+ message: "Environment reports deployed but component URL map is empty or missing; check BunnyShell definition or dashboard.",
169
+ warnings: ["component_urls_json_empty_or_unparsed"],
170
+ bns_environment_id: bnsEnvironmentId,
171
+ branch,
172
+ dashboard_url: dashboardUrl,
173
+ component_urls_json: componentUrlsJson,
174
+ ready_for_seeding: false,
175
+ last_status: lastSnapshot,
176
+ });
177
+ }
178
+ if (isTerminalDeployFailure(aggregateStatus, operationStatus, clusterStatus)) {
179
+ return JSON.stringify({
180
+ outcome: "failed",
181
+ failure_phase: "deploy",
182
+ message: `BunnyShell deployment did not succeed (status=${aggregateStatus}, operation=${operationStatus}, cluster=${clusterStatus}).`,
183
+ bns_environment_id: bnsEnvironmentId,
184
+ branch,
185
+ dashboard_url: dashboardUrl,
186
+ component_urls_json: componentUrlsJson,
187
+ last_status: lastSnapshot,
188
+ });
189
+ }
190
+ const remainingMs = deadline - Date.now();
191
+ if (remainingMs <= 0)
192
+ break;
193
+ const sleepMs = Math.min(intervalMs, remainingMs);
194
+ await sleep(sleepMs);
195
+ }
196
+ return JSON.stringify({
197
+ outcome: "timeout",
198
+ failure_phase: "wait",
199
+ message: `Timed out after ${maxWaitMin} minutes waiting for ephemeral environment ${bnsEnvironmentId} to become ready.`,
200
+ bns_environment_id: bnsEnvironmentId,
201
+ branch,
202
+ dashboard_url: lastDashboardUrl,
203
+ component_urls_json: lastComponentUrlsJson,
204
+ last_status: lastSnapshot,
205
+ });
206
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};