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.
- package/dist/commands/check.js +14 -7
- package/dist/commands/diff.d.ts +3 -0
- package/dist/commands/diff.js +133 -0
- package/dist/commands/run.d.ts +5 -0
- package/dist/commands/run.js +231 -0
- package/dist/commands/state.d.ts +3 -0
- package/dist/commands/state.js +135 -0
- package/dist/commands/webhook.d.ts +4 -0
- package/dist/commands/webhook.js +111 -0
- package/dist/constants.d.ts +1 -1
- package/dist/constants.js +1 -1
- package/dist/index.js +66 -11
- package/dist/lib/api.d.ts +32 -0
- package/dist/lib/api.js +40 -0
- package/package.json +1 -1
package/dist/commands/check.js
CHANGED
|
@@ -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(`
|
|
86
|
+
nextSteps.push(`Watch webhooks: ${pc.cyan(`fetchsandbox webhook-listen ${sandboxId}`)}`);
|
|
80
87
|
}
|
|
81
88
|
if (healthPct < 90 && healthPct > 0) {
|
|
82
|
-
nextSteps.push(`
|
|
89
|
+
nextSteps.push(`Inspect state: ${pc.cyan(`fetchsandbox state ${sandboxId}`)}`);
|
|
83
90
|
}
|
|
84
|
-
if (sb.active_scenario === "default") {
|
|
85
|
-
nextSteps.push(`Test error
|
|
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,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,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,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,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
|
+
}
|
package/dist/constants.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export declare const API_BASE: string;
|
|
2
|
-
export declare const VERSION = "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
|
+
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}`;
|