fetchsandbox 0.2.0 → 0.3.1

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.
@@ -69,20 +69,27 @@ export async function check(sandboxId) {
69
69
  console.log(` ${pc.bold("Overall:")} ${overallColor(pc.bold(`${overall}%`))}`);
70
70
  // ── Next Steps ────────────────────────────────────────────────
71
71
  const nextSteps = [];
72
+ if (wfs.length > 0 && completedWorkflows < wfs.length) {
73
+ if (completedWorkflows === 0) {
74
+ nextSteps.push(`Prove all workflows: ${pc.cyan(`fetchsandbox run ${sandboxId} --all`)}`);
75
+ }
76
+ else {
77
+ for (const inc of incompleteWorkflows.slice(0, 2)) {
78
+ nextSteps.push(`Complete workflow: ${pc.cyan(`fetchsandbox run ${sandboxId} "${inc.name}"`)}`);
79
+ }
80
+ }
81
+ }
72
82
  if (coveragePct < 10) {
73
83
  nextSteps.push(`Try an API call: ${pc.cyan(`fetchsandbox request ${sandboxId} GET /v1`)}`);
74
84
  }
75
- if (wfs.length > 0 && completedWorkflows === 0) {
76
- nextSteps.push(`Run a workflow: ${pc.cyan(`fetchsandbox workflows ${sandboxId}`)}`);
77
- }
78
85
  if (webhookScore === 0) {
79
- nextSteps.push(`Register a webhook in the sandbox dashboard`);
86
+ nextSteps.push(`Watch webhooks: ${pc.cyan(`fetchsandbox webhook-listen ${sandboxId}`)}`);
80
87
  }
81
88
  if (healthPct < 90 && healthPct > 0) {
82
- nextSteps.push(`Check failing endpoints: ${pc.cyan(`fetchsandbox endpoints ${sandboxId}`)}`);
89
+ nextSteps.push(`Inspect state: ${pc.cyan(`fetchsandbox state ${sandboxId}`)}`);
83
90
  }
84
- if (sb.active_scenario === "default") {
85
- nextSteps.push(`Test error scenarios: ${pc.cyan(`fetchsandbox scenario ${sandboxId} auth-failure`)}`);
91
+ if (sb.active_scenario === "default" && overall >= 50) {
92
+ nextSteps.push(`Test error handling: ${pc.cyan(`fetchsandbox run ${sandboxId} --all --scenario auth_failure`)}`);
86
93
  }
87
94
  if (nextSteps.length > 0) {
88
95
  blank();
@@ -0,0 +1,3 @@
1
+ export declare function diff(sandboxId: string, options?: {
2
+ last?: string;
3
+ }): Promise<void>;
@@ -0,0 +1,133 @@
1
+ import pc from "picocolors";
2
+ import { getSandboxState, getSandboxLogs } from "../lib/api.js";
3
+ import { fail, blank, heading, info, method as methodColor, timeAgo, friendlyError } from "../lib/output.js";
4
+ export async function diff(sandboxId, options = {}) {
5
+ blank();
6
+ try {
7
+ // Fetch current state and recent logs in parallel
8
+ const [state, logs] = await Promise.all([
9
+ getSandboxState(sandboxId),
10
+ getSandboxLogs(sandboxId, 100).catch(() => []),
11
+ ]);
12
+ // Determine the time window for "recent" changes
13
+ const lastN = options.last ? parseInt(options.last, 10) : 0;
14
+ // Separate mutation logs (POST/PUT/PATCH/DELETE) from reads
15
+ const mutations = logs.filter((l) => ["POST", "PUT", "PATCH", "DELETE"].includes(l.method) && l.response_status < 400);
16
+ if (mutations.length === 0 && lastN === 0) {
17
+ info("No mutations found in recent logs. Make some API calls first.");
18
+ blank();
19
+ info(`Tip: fetchsandbox run ${sandboxId} --all`);
20
+ blank();
21
+ return;
22
+ }
23
+ // Show recent mutations
24
+ const recentMutations = lastN > 0 ? mutations.slice(0, lastN) : mutations;
25
+ heading(`Recent changes — ${recentMutations.length} mutation${recentMutations.length === 1 ? "" : "s"}`);
26
+ blank();
27
+ // Group mutations by resource type (inferred from path)
28
+ const byResource = new Map();
29
+ for (const log of recentMutations) {
30
+ const resource = inferResource(log.path);
31
+ if (!byResource.has(resource))
32
+ byResource.set(resource, []);
33
+ byResource.get(resource).push(log);
34
+ }
35
+ for (const [resource, resourceLogs] of byResource) {
36
+ const stateRecords = state[resource];
37
+ const count = Array.isArray(stateRecords) ? stateRecords.length : 0;
38
+ console.log(` ${pc.bold(resource)} ${pc.dim(`(${count} record${count === 1 ? "" : "s"} now)`)}`);
39
+ for (const log of resourceLogs) {
40
+ const time = timeAgo(log.timestamp);
41
+ const m = methodColor(log.method);
42
+ const action = actionFromMethod(log.method);
43
+ const icon = actionIcon(log.method);
44
+ // Try to extract resource ID from response
45
+ const resId = extractIdFromResponse(log.response_body);
46
+ const idStr = resId ? pc.white(resId) : "";
47
+ console.log(` ${icon} ${m} ${log.path.length > 40 ? log.path.slice(0, 37) + "..." : log.path} ${idStr} ${pc.dim(time)}`);
48
+ }
49
+ blank();
50
+ }
51
+ // Show current state summary
52
+ heading("Current state:");
53
+ blank();
54
+ const types = Object.entries(state).filter(([, records]) => Array.isArray(records) && records.length > 0);
55
+ for (const [type, records] of types) {
56
+ const items = records;
57
+ const statusField = items[0]?.status !== undefined ? "status" : items[0]?.state !== undefined ? "state" : null;
58
+ let dist = "";
59
+ if (statusField) {
60
+ const counts = {};
61
+ for (const r of items) {
62
+ const val = String(r[statusField] ?? "unknown");
63
+ counts[val] = (counts[val] || 0) + 1;
64
+ }
65
+ dist = Object.entries(counts)
66
+ .sort(([, a], [, b]) => b - a)
67
+ .map(([val, count]) => `${count} ${val}`)
68
+ .join(", ");
69
+ }
70
+ const countStr = pc.bold(String(items.length));
71
+ const distStr = dist ? pc.dim(` (${dist})`) : "";
72
+ console.log(` ${type.padEnd(25)} ${countStr}${distStr}`);
73
+ }
74
+ blank();
75
+ info(`Tip: fetchsandbox state ${sandboxId} <resource> to inspect records`);
76
+ blank();
77
+ }
78
+ catch (error) {
79
+ fail(friendlyError(error));
80
+ blank();
81
+ process.exit(1);
82
+ }
83
+ }
84
+ // ── Helpers ──────────────────────────────────────────────────────────────
85
+ /** Infer resource type from API path. */
86
+ function inferResource(path) {
87
+ const segments = path.split("/").filter(Boolean);
88
+ // Walk backwards, skip path params (they contain IDs)
89
+ for (let i = segments.length - 1; i >= 0; i--) {
90
+ const seg = segments[i];
91
+ // Skip version prefixes
92
+ if (/^v\d+/.test(seg))
93
+ continue;
94
+ // Skip segments that look like IDs
95
+ if (seg.includes("_") && seg.length > 15)
96
+ continue;
97
+ if (/^[0-9a-f]{8,}$/i.test(seg))
98
+ continue;
99
+ // Skip common action verbs
100
+ if (["capture", "confirm", "cancel", "refund", "void", "close", "approve", "reject", "send"].includes(seg))
101
+ continue;
102
+ return seg;
103
+ }
104
+ return segments[0] || "unknown";
105
+ }
106
+ /** Action description from HTTP method. */
107
+ function actionFromMethod(method) {
108
+ switch (method) {
109
+ case "POST": return "created";
110
+ case "PUT": return "replaced";
111
+ case "PATCH": return "updated";
112
+ case "DELETE": return "deleted";
113
+ default: return "modified";
114
+ }
115
+ }
116
+ /** Action icon from HTTP method. */
117
+ function actionIcon(method) {
118
+ switch (method) {
119
+ case "POST": return pc.green("+");
120
+ case "PUT": return pc.yellow("~");
121
+ case "PATCH": return pc.yellow("~");
122
+ case "DELETE": return pc.red("-");
123
+ default: return pc.dim("·");
124
+ }
125
+ }
126
+ /** Extract resource ID from a response body. */
127
+ function extractIdFromResponse(body) {
128
+ if (!body || typeof body !== "object")
129
+ return "";
130
+ const obj = body;
131
+ const id = obj.id || obj.ID || "";
132
+ return id ? String(id) : "";
133
+ }
@@ -0,0 +1,5 @@
1
+ export declare function run(sandboxId: string, workflowName: string | undefined, options?: {
2
+ scenario?: string;
3
+ verbose?: boolean;
4
+ all?: boolean;
5
+ }): Promise<void>;
@@ -0,0 +1,231 @@
1
+ import pc from "picocolors";
2
+ import ora from "ora";
3
+ import { runWorkflow, getWorkflows, getSandbox, setScenario } from "../lib/api.js";
4
+ import { fail, blank, heading, info, method as methodColor, friendlyError } from "../lib/output.js";
5
+ export async function run(sandboxId, workflowName, options = {}) {
6
+ blank();
7
+ try {
8
+ // --all mode: run every workflow and report summary
9
+ if (options.all || workflowName === undefined) {
10
+ await runAll(sandboxId, options);
11
+ return;
12
+ }
13
+ // If a scenario was requested, switch to it first
14
+ if (options.scenario) {
15
+ const spinner = ora({ text: ` Switching to scenario: ${options.scenario}`, indent: 0 }).start();
16
+ try {
17
+ await setScenario(sandboxId, options.scenario);
18
+ spinner.succeed(` Scenario: ${options.scenario}`);
19
+ }
20
+ catch (err) {
21
+ spinner.fail(` Failed to set scenario: ${friendlyError(err)}`);
22
+ blank();
23
+ process.exit(1);
24
+ }
25
+ }
26
+ // Run the workflow
27
+ const result = await executeAndDisplay(sandboxId, workflowName, options);
28
+ // If using a scenario and it failed as expected, that's actually a good thing
29
+ if (options.scenario && !result.passed) {
30
+ blank();
31
+ info(`Scenario "${options.scenario}" correctly caused failure.`);
32
+ info("This confirms your error handling works.");
33
+ }
34
+ blank();
35
+ // Reset scenario back to default if we changed it
36
+ if (options.scenario) {
37
+ try {
38
+ await setScenario(sandboxId, "default");
39
+ info("Scenario reset to default.");
40
+ blank();
41
+ }
42
+ catch {
43
+ // Silent — best effort reset
44
+ }
45
+ }
46
+ // Exit with appropriate code
47
+ if (!result.passed && !options.scenario) {
48
+ process.exit(1);
49
+ }
50
+ }
51
+ catch (error) {
52
+ fail(friendlyError(error));
53
+ blank();
54
+ process.exit(1);
55
+ }
56
+ }
57
+ // ── Run All ─────────────────────────────────────────────────────────────
58
+ async function runAll(sandboxId, options = {}) {
59
+ const spinner = ora({ text: ` Loading workflows...`, indent: 0 }).start();
60
+ let sb;
61
+ let workflows;
62
+ try {
63
+ sb = await getSandbox(sandboxId);
64
+ const data = await getWorkflows(sb.spec_id);
65
+ workflows = data.workflows;
66
+ spinner.stop();
67
+ }
68
+ catch (err) {
69
+ spinner.fail(` Failed to load workflows`);
70
+ blank();
71
+ fail(friendlyError(err));
72
+ blank();
73
+ process.exit(1);
74
+ }
75
+ if (workflows.length === 0) {
76
+ info("No workflows configured for this sandbox.");
77
+ blank();
78
+ return;
79
+ }
80
+ heading(`Running all ${workflows.length} workflows for ${sb.spec_name || sb.name}`);
81
+ blank();
82
+ const results = [];
83
+ let totalPassed = 0;
84
+ for (const wf of workflows) {
85
+ const wfId = wf.id || wf.name;
86
+ const result = await executeAndDisplay(sandboxId, wfId, options);
87
+ results.push({
88
+ name: wf.name,
89
+ passed: result.passed,
90
+ steps: result.steps.length,
91
+ duration: result.total_duration_ms,
92
+ });
93
+ if (result.passed)
94
+ totalPassed++;
95
+ blank();
96
+ }
97
+ // Summary
98
+ const totalDuration = results.reduce((sum, r) => sum + r.duration, 0);
99
+ const allPassed = totalPassed === results.length;
100
+ console.log(` ${pc.bold("━".repeat(50))}`);
101
+ blank();
102
+ if (allPassed) {
103
+ console.log(` ${pc.green("✓")} All workflows passed — ${totalPassed}/${results.length} ${pc.dim(`(${totalDuration.toFixed(1)}ms)`)}`);
104
+ }
105
+ else {
106
+ console.log(` ${pc.red("✗")} ${totalPassed}/${results.length} workflows passed ${pc.dim(`(${totalDuration.toFixed(1)}ms)`)}`);
107
+ blank();
108
+ for (const r of results) {
109
+ if (!r.passed) {
110
+ console.log(` ${pc.red("✗")} ${r.name}`);
111
+ }
112
+ }
113
+ }
114
+ blank();
115
+ if (!allPassed) {
116
+ process.exit(1);
117
+ }
118
+ }
119
+ // ── Execute and Display ─────────────────────────────────────────────────
120
+ async function executeAndDisplay(sandboxId, workflowName, options = {}) {
121
+ const spinner = ora({ text: ` Running ${workflowName}...`, indent: 0 }).start();
122
+ let result;
123
+ try {
124
+ result = await runWorkflow(sandboxId, workflowName);
125
+ spinner.stop();
126
+ }
127
+ catch (err) {
128
+ spinner.fail(` Failed to run ${workflowName}`);
129
+ blank();
130
+ const errMsg = friendlyError(err);
131
+ if (errMsg.includes("404") || errMsg.includes("not found") || errMsg.includes("Not found")) {
132
+ await showAvailableWorkflows(sandboxId, workflowName);
133
+ }
134
+ else {
135
+ fail(errMsg);
136
+ }
137
+ blank();
138
+ process.exit(1);
139
+ }
140
+ const totalSteps = result.steps.length;
141
+ const passedSteps = result.steps.filter((s) => s.status === "passed").length;
142
+ const workflowLabel = result.flow_description || result.flow_name;
143
+ heading(`${workflowLabel} (${totalSteps} step${totalSteps === 1 ? "" : "s"})`);
144
+ blank();
145
+ for (let i = 0; i < result.steps.length; i++) {
146
+ const step = result.steps[i];
147
+ const stepNum = `Step ${i + 1}/${totalSteps}`;
148
+ const icon = step.status === "passed" ? pc.green("✓") : pc.red("✗");
149
+ const duration = pc.dim(`${step.duration_ms}ms`);
150
+ const { method: httpMethod, path } = parseMethodPath(step.detail);
151
+ if (httpMethod && path) {
152
+ console.log(` ${icon} ${pc.dim(stepNum)} ${methodColor(httpMethod)} ${path} ${duration}`);
153
+ }
154
+ else {
155
+ console.log(` ${icon} ${pc.dim(stepNum)} ${step.name} ${duration}`);
156
+ }
157
+ const detail = formatDetail(step);
158
+ if (detail) {
159
+ console.log(` ${detail}`);
160
+ }
161
+ if (options.verbose && Object.keys(step.data).length > 0) {
162
+ const json = JSON.stringify(step.data, null, 2);
163
+ for (const line of json.split("\n")) {
164
+ console.log(` ${pc.dim(line)}`);
165
+ }
166
+ }
167
+ if (step.name === "webhook_verification") {
168
+ const matched = (step.data.matched || []);
169
+ for (const evt of matched) {
170
+ console.log(` ${pc.yellow("⚡")} ${evt}`);
171
+ }
172
+ }
173
+ }
174
+ blank();
175
+ const totalTime = `${result.total_duration_ms}ms`;
176
+ if (result.passed) {
177
+ console.log(` ${pc.green("✓")} Workflow complete — ${passedSteps}/${totalSteps} steps passed ${pc.dim(`(${totalTime})`)}`);
178
+ }
179
+ else {
180
+ console.log(` ${pc.red("✗")} Workflow failed at step ${passedSteps + 1}/${totalSteps} ${pc.dim(`(${totalTime})`)}`);
181
+ }
182
+ return result;
183
+ }
184
+ // ── Helpers ──────────────────────────────────────────────────────────────
185
+ /** Parse HTTP method and path from step detail string. */
186
+ function parseMethodPath(detail) {
187
+ const match = detail.match(/^(GET|POST|PUT|PATCH|DELETE|HEAD)\s+(\S+)/);
188
+ if (match) {
189
+ return { method: match[1], path: match[2] };
190
+ }
191
+ return { method: "", path: "" };
192
+ }
193
+ /** Format a human-readable detail line from step data. */
194
+ function formatDetail(step) {
195
+ if (step.status === "failed") {
196
+ return pc.red(step.detail);
197
+ }
198
+ // Extract meaningful info from data
199
+ const id = step.data.id || step.data.resource_id || "";
200
+ const status = step.data.status || "";
201
+ const responseStatus = step.data.response_status;
202
+ const parts = [];
203
+ if (responseStatus)
204
+ parts.push(`→ ${pc.green(String(responseStatus))}`);
205
+ if (id)
206
+ parts.push(pc.white(String(id)));
207
+ if (status)
208
+ parts.push(pc.dim(`status: ${status}`));
209
+ return parts.length > 0 ? parts.join(" ") : "";
210
+ }
211
+ /** Show available workflows when one isn't found. */
212
+ async function showAvailableWorkflows(sandboxId, attempted) {
213
+ fail(`Workflow "${attempted}" not found.`);
214
+ try {
215
+ const sb = await getSandbox(sandboxId);
216
+ const { workflows } = await getWorkflows(sb.spec_id);
217
+ if (workflows.length > 0) {
218
+ blank();
219
+ info("Available workflows:");
220
+ for (const wf of workflows) {
221
+ const id = wf.id || wf.name;
222
+ console.log(` ${pc.dim("•")} ${id}`);
223
+ }
224
+ blank();
225
+ info(`Try: fetchsandbox run ${sandboxId} "${workflows[0].id || workflows[0].name}"`);
226
+ }
227
+ }
228
+ catch {
229
+ // Silent — best effort
230
+ }
231
+ }
@@ -0,0 +1,3 @@
1
+ export declare function state(sandboxId: string, resource?: string, options?: {
2
+ id?: string;
3
+ }): Promise<void>;
@@ -0,0 +1,135 @@
1
+ import pc from "picocolors";
2
+ import { getSandboxState } from "../lib/api.js";
3
+ import { fail, blank, heading, info, row, tableHeader, friendlyError } from "../lib/output.js";
4
+ export async function state(sandboxId, resource, options = {}) {
5
+ blank();
6
+ try {
7
+ const snapshot = await getSandboxState(sandboxId);
8
+ const types = Object.entries(snapshot).filter(([, records]) => Array.isArray(records) && records.length > 0);
9
+ if (types.length === 0) {
10
+ info("No resources in this sandbox. Run some requests first.");
11
+ blank();
12
+ return;
13
+ }
14
+ // ── Single resource type ──────────────────────────────────────────
15
+ if (resource) {
16
+ const records = snapshot[resource];
17
+ if (!records || !Array.isArray(records) || records.length === 0) {
18
+ const available = types.map(([t]) => t).join(", ");
19
+ fail(`No resource type "${resource}" found.`);
20
+ info(`Available: ${available}`);
21
+ blank();
22
+ return;
23
+ }
24
+ // Single record by --id
25
+ if (options.id) {
26
+ const typedRecords = records;
27
+ const record = typedRecords.find((r) => r.id === options.id || r.ID === options.id);
28
+ if (!record) {
29
+ fail(`No record with id "${options.id}" in ${resource}.`);
30
+ info(`${records.length} records available. Try without --id to list all.`);
31
+ blank();
32
+ return;
33
+ }
34
+ heading(`${resource} / ${options.id}`);
35
+ blank();
36
+ console.log(formatJson(record));
37
+ blank();
38
+ return;
39
+ }
40
+ // Table view for all records of this type
41
+ heading(`${resource} — ${records.length} record${records.length === 1 ? "" : "s"}`);
42
+ blank();
43
+ const cols = pickColumns(records);
44
+ const widths = cols.map((c) => Math.max(c.length + 2, Math.min(30, maxWidth(records, c) + 2)));
45
+ tableHeader(cols, widths);
46
+ for (const record of records) {
47
+ const values = cols.map((c) => truncate(String(record[c] ?? ""), widths[cols.indexOf(c)] - 2));
48
+ row(values, widths);
49
+ }
50
+ blank();
51
+ return;
52
+ }
53
+ // ── Summary of all resource types ─────────────────────────────────
54
+ heading(`State snapshot — ${types.length} resource type${types.length === 1 ? "" : "s"}, ${types.reduce((sum, [, r]) => sum + r.length, 0)} records`);
55
+ blank();
56
+ for (const [type, records] of types) {
57
+ const items = records;
58
+ const dist = stateDistribution(items);
59
+ const countStr = pc.bold(String(items.length));
60
+ const distStr = dist ? pc.dim(` (${dist})`) : "";
61
+ console.log(` ${type.padEnd(25)} ${countStr}${distStr}`);
62
+ }
63
+ blank();
64
+ info("Tip: fetchsandbox state <sandbox> <resource> to see records");
65
+ blank();
66
+ }
67
+ catch (error) {
68
+ fail(friendlyError(error));
69
+ blank();
70
+ process.exit(1);
71
+ }
72
+ }
73
+ // ── Helpers ──────────────────────────────────────────────────────────────
74
+ /** Pick the most useful columns to display in a table. */
75
+ function pickColumns(records) {
76
+ if (records.length === 0)
77
+ return [];
78
+ const allKeys = Object.keys(records[0]);
79
+ // Priority: id first, then status, then name/email/type, then rest
80
+ const priority = ["id", "ID", "status", "state", "name", "email", "type", "amount", "currency", "description"];
81
+ const picked = [];
82
+ for (const key of priority) {
83
+ if (allKeys.includes(key) && !picked.includes(key)) {
84
+ picked.push(key);
85
+ }
86
+ }
87
+ // Fill up to 5 columns with remaining keys (skip large objects/arrays)
88
+ for (const key of allKeys) {
89
+ if (picked.length >= 5)
90
+ break;
91
+ if (picked.includes(key))
92
+ continue;
93
+ const sample = records[0][key];
94
+ if (typeof sample === "object" && sample !== null)
95
+ continue; // skip nested
96
+ picked.push(key);
97
+ }
98
+ return picked;
99
+ }
100
+ /** Get the max display width of a column across all records. */
101
+ function maxWidth(records, key) {
102
+ let max = key.length;
103
+ for (const r of records) {
104
+ const len = String(r[key] ?? "").length;
105
+ if (len > max)
106
+ max = len;
107
+ }
108
+ return Math.min(max, 28);
109
+ }
110
+ /** Truncate a string to fit a column width. */
111
+ function truncate(s, max) {
112
+ return s.length > max ? s.slice(0, max - 1) + "…" : s;
113
+ }
114
+ /** Compute state/status distribution string, e.g. "2 active, 1 suspended". */
115
+ function stateDistribution(records) {
116
+ const field = records[0]?.status !== undefined ? "status" : records[0]?.state !== undefined ? "state" : null;
117
+ if (!field)
118
+ return "";
119
+ const counts = {};
120
+ for (const r of records) {
121
+ const val = String(r[field] ?? "unknown");
122
+ counts[val] = (counts[val] || 0) + 1;
123
+ }
124
+ return Object.entries(counts)
125
+ .sort(([, a], [, b]) => b - a)
126
+ .map(([val, count]) => `${count} ${val}`)
127
+ .join(", ");
128
+ }
129
+ /** Pretty-print a JSON object with 2-space indent, indented for CLI output. */
130
+ function formatJson(obj) {
131
+ return JSON.stringify(obj, null, 2)
132
+ .split("\n")
133
+ .map((line) => ` ${pc.white(line)}`)
134
+ .join("\n");
135
+ }
@@ -0,0 +1,4 @@
1
+ export declare function webhookListen(sandboxId: string, options?: {
2
+ verbose?: boolean;
3
+ eventType?: string;
4
+ }): Promise<void>;
@@ -0,0 +1,111 @@
1
+ import pc from "picocolors";
2
+ import { getWebhookEvents } from "../lib/api.js";
3
+ import { fail, blank, heading, info, friendlyError } from "../lib/output.js";
4
+ export async function webhookListen(sandboxId, options = {}) {
5
+ blank();
6
+ try {
7
+ heading("Listening for webhook events... (Ctrl+C to stop)");
8
+ blank();
9
+ let lastSeen = "";
10
+ let totalShown = 0;
11
+ const poll = async () => {
12
+ const events = await getWebhookEvents(sandboxId, 30);
13
+ // Filter to only new events (same dedup pattern as logs --follow)
14
+ const newEvents = lastSeen
15
+ ? events.filter((e) => e.timestamp > lastSeen)
16
+ : events;
17
+ if (newEvents.length === 0)
18
+ return;
19
+ // Update watermark — events are newest-first from API
20
+ lastSeen = newEvents[0].timestamp;
21
+ // Display oldest-first for natural reading order
22
+ const ordered = [...newEvents].reverse();
23
+ for (const event of ordered) {
24
+ // Optional event type filter
25
+ if (options.eventType && !event.event_type.includes(options.eventType)) {
26
+ continue;
27
+ }
28
+ const time = formatTime(event.timestamp);
29
+ const type = colorEventType(event.event_type);
30
+ const resourceId = extractResourceId(event.payload);
31
+ const statusField = extractStatus(event.payload);
32
+ console.log(` ${pc.dim(time)} ${type} ${pc.white(resourceId)}${statusField}`);
33
+ if (options.verbose && event.payload) {
34
+ const json = JSON.stringify(event.payload, null, 2);
35
+ for (const line of json.split("\n")) {
36
+ console.log(` ${pc.dim(" ")}${pc.dim(line)}`);
37
+ }
38
+ }
39
+ totalShown++;
40
+ }
41
+ };
42
+ // Initial poll
43
+ await poll();
44
+ if (totalShown === 0) {
45
+ info("No events yet. Make API calls in another terminal to see webhooks fire.");
46
+ }
47
+ // Poll every 2 seconds
48
+ const interval = setInterval(async () => {
49
+ try {
50
+ await poll();
51
+ }
52
+ catch {
53
+ // Silently retry on transient errors during polling
54
+ }
55
+ }, 2000);
56
+ process.on("SIGINT", () => {
57
+ clearInterval(interval);
58
+ blank();
59
+ if (totalShown > 0) {
60
+ console.log(` ${pc.dim(`${totalShown} event${totalShown === 1 ? "" : "s"} received.`)}`);
61
+ }
62
+ blank();
63
+ process.exit(0);
64
+ });
65
+ // Keep alive
66
+ await new Promise(() => { });
67
+ }
68
+ catch (error) {
69
+ fail(friendlyError(error));
70
+ blank();
71
+ process.exit(1);
72
+ }
73
+ }
74
+ // ── Helpers ──────────────────────────────────────────────────────────────
75
+ /** Format ISO timestamp to HH:MM:SS. */
76
+ function formatTime(ts) {
77
+ try {
78
+ const d = new Date(ts);
79
+ return d.toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" });
80
+ }
81
+ catch {
82
+ return ts.slice(11, 19);
83
+ }
84
+ }
85
+ /** Color event type based on action. */
86
+ function colorEventType(eventType) {
87
+ const padded = eventType.padEnd(30);
88
+ if (eventType.includes("created"))
89
+ return pc.green(padded);
90
+ if (eventType.includes("succeeded") || eventType.includes("completed"))
91
+ return pc.green(padded);
92
+ if (eventType.includes("failed") || eventType.includes("declined"))
93
+ return pc.red(padded);
94
+ if (eventType.includes("deleted") || eventType.includes("canceled"))
95
+ return pc.red(padded);
96
+ if (eventType.includes("updated") || eventType.includes("captured"))
97
+ return pc.yellow(padded);
98
+ return pc.white(padded);
99
+ }
100
+ /** Extract the resource ID from a webhook payload. */
101
+ function extractResourceId(payload) {
102
+ const id = payload?.id ?? payload?.ID ?? "";
103
+ return id ? String(id) : "";
104
+ }
105
+ /** Extract a status-like field from payload for compact display. */
106
+ function extractStatus(payload) {
107
+ const status = payload?.status ?? payload?.state ?? null;
108
+ if (!status)
109
+ return "";
110
+ return pc.dim(` → ${status}`);
111
+ }
@@ -1,2 +1,2 @@
1
1
  export declare const API_BASE: string;
2
- export declare const VERSION = "0.2.0";
2
+ export declare const VERSION = "0.3.1";
package/dist/constants.js CHANGED
@@ -1,2 +1,2 @@
1
1
  export const API_BASE = process.env.FETCHSANDBOX_API_URL ?? "https://fetchsandbox.com";
2
- export const VERSION = "0.2.0";
2
+ export const VERSION = "0.3.1";
package/dist/index.js CHANGED
@@ -2,6 +2,7 @@
2
2
  import { Command } from "commander";
3
3
  import pc from "picocolors";
4
4
  import { VERSION } from "./constants.js";
5
+ import { resolveSandboxId } from "./lib/api.js";
5
6
  import { generate } from "./commands/generate.js";
6
7
  import { showStatus } from "./commands/status.js";
7
8
  import { reset } from "./commands/reset.js";
@@ -14,6 +15,26 @@ import { logs } from "./commands/logs.js";
14
15
  import { scenario } from "./commands/scenario.js";
15
16
  import { deleteCmd } from "./commands/delete.js";
16
17
  import { docs } from "./commands/docs.js";
18
+ import { state } from "./commands/state.js";
19
+ import { webhookListen } from "./commands/webhook.js";
20
+ import { run } from "./commands/run.js";
21
+ import { diff } from "./commands/diff.js";
22
+ /** Wraps a handler to resolve sandbox name → ID before calling. */
23
+ function withResolve(fn) {
24
+ return async (id, ...rest) => {
25
+ try {
26
+ const resolved = await resolveSandboxId(id);
27
+ if (resolved !== id) {
28
+ console.log(` ${pc.dim(`→ resolved "${id}" to ${resolved}`)}`);
29
+ }
30
+ await fn(resolved, ...rest);
31
+ }
32
+ catch (err) {
33
+ console.error(`\n ${pc.red("✗")} ${err instanceof Error ? err.message : String(err)}\n`);
34
+ process.exit(1);
35
+ }
36
+ };
37
+ }
17
38
  const program = new Command();
18
39
  program
19
40
  .name("fetchsandbox")
@@ -30,57 +51,82 @@ program
30
51
  .action(list);
31
52
  program
32
53
  .command("delete <sandbox-id>")
33
- .description("Delete a sandbox")
54
+ .description("Delete a sandbox (accepts name or ID)")
34
55
  .option("-f, --force", "Skip confirmation prompt")
35
- .action((id, opts) => deleteCmd(id, opts));
56
+ .action(withResolve((id, opts) => deleteCmd(id, opts)));
36
57
  // ── Understand ────────────────────────────────────────────────────────
37
58
  program
38
59
  .command("endpoints <sandbox-id>")
39
60
  .description("List all endpoints (use --search to filter)")
40
61
  .option("-s, --search <term>", "Filter by path, summary, or tag")
41
- .action((id, opts) => endpoints(id, opts));
62
+ .action(withResolve((id, opts) => endpoints(id, opts)));
42
63
  program
43
64
  .command("docs <sandbox-id>")
44
65
  .description("Open the docs portal in your browser")
45
- .action(docs);
66
+ .action(withResolve(docs));
46
67
  // ── Plan ──────────────────────────────────────────────────────────────
47
68
  program
48
69
  .command("workflows <sandbox-id>")
49
70
  .description("List integration workflows (use --name for detail)")
50
71
  .option("-n, --name <name>", "Show detail for a specific workflow")
51
- .action((id, opts) => workflows(id, opts));
72
+ .action(withResolve((id, opts) => workflows(id, opts)));
52
73
  // ── Implement ─────────────────────────────────────────────────────────
53
74
  program
54
75
  .command("request <sandbox-id> <method> <path>")
55
76
  .description("Make an API call to the sandbox")
56
77
  .option("-d, --data <json>", "Request body (JSON string)")
57
- .action((id, method, path, opts) => sendRequest(id, method, path, opts));
78
+ .action(withResolve((id, method, path, opts) => sendRequest(id, method, path, opts)));
79
+ // ── Prove ─────────────────────────────────────────────────────────────
80
+ program
81
+ .command("run <sandbox-id> [workflow]")
82
+ .description("Execute a workflow end-to-end (use --all for every workflow)")
83
+ .option("--all", "Run all workflows and report summary")
84
+ .option("--scenario <name>", "Run under an error scenario (resets after)")
85
+ .option("--verbose", "Show full response data for each step")
86
+ .action(withResolve((id, workflow, opts) => run(id, workflow, opts)));
58
87
  // ── Test ──────────────────────────────────────────────────────────────
59
88
  program
60
89
  .command("status <sandbox-id>")
61
90
  .description("Show sandbox state, resources, and recent activity")
62
- .action(showStatus);
91
+ .action(withResolve(showStatus));
63
92
  program
64
93
  .command("logs <sandbox-id>")
65
94
  .description("Show request logs (use --follow for live tail)")
66
95
  .option("-f, --follow", "Tail logs in real-time")
67
96
  .option("--status <code>", "Filter by status code (e.g. 200, 4xx, 5xx)")
68
97
  .option("-l, --limit <n>", "Number of log entries (default: 30)")
69
- .action((id, opts) => logs(id, opts));
98
+ .action(withResolve((id, opts) => logs(id, opts)));
70
99
  program
71
100
  .command("scenario <sandbox-id>")
72
101
  .description("List or switch test scenarios")
73
102
  .option("-n, --name <name>", "Set active scenario")
74
- .action((id, opts) => scenario(id, opts));
103
+ .action(withResolve((id, opts) => scenario(id, opts)));
104
+ program
105
+ .command("state <sandbox-id> [resource]")
106
+ .description("Inspect resource state (use --id for single record)")
107
+ .option("--id <resource-id>", "Show a single record by ID")
108
+ .action(withResolve((id, resource, opts) => state(id, resource, opts)));
75
109
  program
76
110
  .command("reset <sandbox-id>")
77
111
  .description("Reset sandbox to its original seed data")
78
- .action(reset);
112
+ .action(withResolve(reset));
113
+ program
114
+ .command("diff <sandbox-id>")
115
+ .description("Show recent mutations and current state summary")
116
+ .option("--last <n>", "Show only the last N mutations")
117
+ .action(withResolve((id, opts) => diff(id, opts)));
118
+ // ── Observe ──────────────────────────────────────────────────────────
119
+ program
120
+ .command("webhook-listen <sandbox-id>")
121
+ .description("Live-tail webhook events (Ctrl+C to stop)")
122
+ .option("--verbose", "Show full event payload")
123
+ .option("--event-type <type>", "Filter by event type (e.g. charge.created)")
124
+ .action(withResolve((id, opts) => webhookListen(id, opts)));
79
125
  // ── Go Live ───────────────────────────────────────────────────────────
80
126
  program
81
127
  .command("check <sandbox-id>")
82
128
  .description("Run integration readiness check")
83
- .action(check);
129
+ .action(withResolve(check));
84
130
  // If no command given, show help with a friendly message
85
131
  if (process.argv.length <= 2) {
86
132
  console.log();
@@ -100,11 +146,20 @@ if (process.argv.length <= 2) {
100
146
  console.log(` ${pc.dim("Implement:")}`);
101
147
  console.log(` ${pc.white("request <id> <method> <path>")} Make an API call`);
102
148
  console.log();
149
+ console.log(` ${pc.dim("Prove:")}`);
150
+ console.log(` ${pc.white("run <id> <workflow>")} Run a workflow end-to-end`);
151
+ console.log(` ${pc.white("run <id> --all")} Run every workflow, report summary`);
152
+ console.log();
103
153
  console.log(` ${pc.dim("Test:")}`);
104
154
  console.log(` ${pc.white("status <id>")} Show sandbox state and activity`);
155
+ console.log(` ${pc.white("state <id> [type]")} Inspect resource data (--id for single)`);
105
156
  console.log(` ${pc.white("logs <id>")} Show request logs (--follow for live)`);
106
157
  console.log(` ${pc.white("scenario <id>")} List or switch test scenarios`);
107
158
  console.log(` ${pc.white("reset <id>")} Reset sandbox to seed data`);
159
+ console.log(` ${pc.white("diff <id>")} Show recent mutations + state summary`);
160
+ console.log();
161
+ console.log(` ${pc.dim("Observe:")}`);
162
+ console.log(` ${pc.white("webhook-listen <id>")} Live-tail webhook events`);
108
163
  console.log();
109
164
  console.log(` ${pc.dim("Go Live:")}`);
110
165
  console.log(` ${pc.white("check <id>")} Integration readiness score`);
package/dist/lib/api.d.ts CHANGED
@@ -82,11 +82,41 @@ export interface WebhookRegistration {
82
82
  enabled: boolean;
83
83
  created_at: string;
84
84
  }
85
+ export interface WebhookEvent {
86
+ id: string;
87
+ event_type: string;
88
+ timestamp: string;
89
+ payload: Record<string, unknown>;
90
+ delivered: boolean;
91
+ delivery_status: number;
92
+ }
93
+ export interface WorkflowRunStep {
94
+ name: string;
95
+ description: string;
96
+ status: "pending" | "passed" | "failed";
97
+ detail: string;
98
+ duration_ms: number;
99
+ data: Record<string, unknown>;
100
+ }
101
+ export interface WorkflowRunResult {
102
+ flow_name: string;
103
+ flow_description: string;
104
+ passed: boolean;
105
+ total_duration_ms: number;
106
+ steps: WorkflowRunStep[];
107
+ }
85
108
  export interface SandboxProxyResponse {
86
109
  status: number;
87
110
  headers: Record<string, string>;
88
111
  body: unknown;
89
112
  }
113
+ /**
114
+ * Resolve a sandbox identifier — accepts either a hash ID (beed86d499)
115
+ * or a name/keyword (stripe, workos, elevenlabs). Name matching is
116
+ * case-insensitive and matches against spec_name, name, and slug.
117
+ * Returns the hash ID.
118
+ */
119
+ export declare function resolveSandboxId(input: string): Promise<string>;
90
120
  export declare function uploadSpec(input: string): Promise<SpecResult>;
91
121
  export declare function createSandbox(specId: string, name?: string): Promise<SandboxResult>;
92
122
  export declare function getSandbox(id: string): Promise<SandboxResult>;
@@ -120,4 +150,6 @@ export declare function setScenario(sandboxId: string, scenario: string): Promis
120
150
  }>;
121
151
  export declare function deleteSandbox(id: string): Promise<void>;
122
152
  export declare function getWebhooks(sandboxId: string): Promise<WebhookRegistration[]>;
153
+ export declare function getWebhookEvents(sandboxId: string, limit?: number): Promise<WebhookEvent[]>;
154
+ export declare function runWorkflow(sandboxId: string, workflowName: string): Promise<WorkflowRunResult>;
123
155
  export declare function sandboxRequest(sandboxId: string, method: string, path: string, body?: string, apiKey?: string): Promise<SandboxProxyResponse>;
package/dist/lib/api.js CHANGED
@@ -22,6 +22,38 @@ function githubBlobToRaw(url) {
22
22
  .replace("github.com", "raw.githubusercontent.com")
23
23
  .replace("/blob/", "/");
24
24
  }
25
+ // ── Sandbox ID Resolution ─────────────────────────────────────────────
26
+ /**
27
+ * Resolve a sandbox identifier — accepts either a hash ID (beed86d499)
28
+ * or a name/keyword (stripe, workos, elevenlabs). Name matching is
29
+ * case-insensitive and matches against spec_name, name, and slug.
30
+ * Returns the hash ID.
31
+ */
32
+ export async function resolveSandboxId(input) {
33
+ // If it looks like a hex hash (all hex chars, 8-12 length), use as-is
34
+ if (/^[a-f0-9]{6,14}$/.test(input))
35
+ return input;
36
+ // Otherwise, search by name
37
+ const sandboxes = await listSandboxes();
38
+ const lower = input.toLowerCase();
39
+ // Exact match first (name, spec_name, slug)
40
+ const exact = sandboxes.find((sb) => sb.name?.toLowerCase() === lower ||
41
+ sb.spec_name?.toLowerCase() === lower ||
42
+ sb.slug?.toLowerCase() === lower);
43
+ if (exact)
44
+ return exact.id;
45
+ // Partial match — input appears anywhere in the name
46
+ const partial = sandboxes.filter((sb) => sb.name?.toLowerCase().includes(lower) ||
47
+ sb.spec_name?.toLowerCase().includes(lower) ||
48
+ (sb.slug && sb.slug.toLowerCase().includes(lower)));
49
+ if (partial.length === 1)
50
+ return partial[0].id;
51
+ if (partial.length > 1) {
52
+ const names = partial.map((sb) => ` • ${sb.id} ${sb.spec_name || sb.name}`).join("\n");
53
+ throw new Error(`Multiple sandboxes match "${input}":\n${names}\nUse the full ID to be specific.`);
54
+ }
55
+ throw new Error(`No sandbox found matching "${input}". Run \`fetchsandbox list\` to see available sandboxes.`);
56
+ }
25
57
  // ── API Functions ──────────────────────────────────────────────────────
26
58
  export async function uploadSpec(input) {
27
59
  let content;
@@ -93,6 +125,14 @@ export async function deleteSandbox(id) {
93
125
  export async function getWebhooks(sandboxId) {
94
126
  return request(`/api/sandboxes/${sandboxId}/webhooks`);
95
127
  }
128
+ export async function getWebhookEvents(sandboxId, limit = 50) {
129
+ return request(`/api/sandboxes/${sandboxId}/webhooks/events?limit=${limit}`);
130
+ }
131
+ export async function runWorkflow(sandboxId, workflowName) {
132
+ return request(`/api/sandboxes/${sandboxId}/workflows/${encodeURIComponent(workflowName)}/run`, {
133
+ method: "POST",
134
+ });
135
+ }
96
136
  export async function sandboxRequest(sandboxId, method, path, body, apiKey) {
97
137
  // Use the path-based proxy: /sandbox/{id}/{path}
98
138
  const sandboxPath = path.startsWith("/") ? path : `/${path}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fetchsandbox",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "Turn any OpenAPI spec into a live developer portal with a stateful sandbox",
5
5
  "type": "module",
6
6
  "bin": {