fetchsandbox 0.3.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 +2 -1
- package/dist/commands/run.js +134 -70
- package/dist/constants.d.ts +1 -1
- package/dist/constants.js +1 -1
- package/dist/index.js +42 -16
- package/dist/lib/api.d.ts +7 -0
- package/dist/lib/api.js +32 -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
|
+
}
|
package/dist/commands/run.d.ts
CHANGED
package/dist/commands/run.js
CHANGED
|
@@ -5,6 +5,11 @@ import { fail, blank, heading, info, method as methodColor, friendlyError } from
|
|
|
5
5
|
export async function run(sandboxId, workflowName, options = {}) {
|
|
6
6
|
blank();
|
|
7
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
|
+
}
|
|
8
13
|
// If a scenario was requested, switch to it first
|
|
9
14
|
if (options.scenario) {
|
|
10
15
|
const spinner = ora({ text: ` Switching to scenario: ${options.scenario}`, indent: 0 }).start();
|
|
@@ -19,79 +24,11 @@ export async function run(sandboxId, workflowName, options = {}) {
|
|
|
19
24
|
}
|
|
20
25
|
}
|
|
21
26
|
// Run the workflow
|
|
22
|
-
const
|
|
23
|
-
let result;
|
|
24
|
-
try {
|
|
25
|
-
result = await runWorkflow(sandboxId, workflowName);
|
|
26
|
-
spinner.stop();
|
|
27
|
-
}
|
|
28
|
-
catch (err) {
|
|
29
|
-
spinner.fail(` Failed to run workflow`);
|
|
30
|
-
blank();
|
|
31
|
-
// If 404, try to list available workflows and show helpful error
|
|
32
|
-
const errMsg = friendlyError(err);
|
|
33
|
-
if (errMsg.includes("404") || errMsg.includes("not found") || errMsg.includes("Not found")) {
|
|
34
|
-
await showAvailableWorkflows(sandboxId, workflowName);
|
|
35
|
-
}
|
|
36
|
-
else {
|
|
37
|
-
fail(errMsg);
|
|
38
|
-
}
|
|
39
|
-
blank();
|
|
40
|
-
process.exit(1);
|
|
41
|
-
}
|
|
42
|
-
// Display results
|
|
43
|
-
const totalSteps = result.steps.length;
|
|
44
|
-
const passedSteps = result.steps.filter((s) => s.status === "passed").length;
|
|
45
|
-
const workflowLabel = result.flow_description || result.flow_name;
|
|
46
|
-
heading(`${workflowLabel} (${totalSteps} step${totalSteps === 1 ? "" : "s"})`);
|
|
47
|
-
blank();
|
|
48
|
-
for (let i = 0; i < result.steps.length; i++) {
|
|
49
|
-
const step = result.steps[i];
|
|
50
|
-
const stepNum = `Step ${i + 1}/${totalSteps}`;
|
|
51
|
-
const icon = step.status === "passed" ? pc.green("✓") : pc.red("✗");
|
|
52
|
-
const duration = pc.dim(`${step.duration_ms}ms`);
|
|
53
|
-
// Parse method and path from detail or step name
|
|
54
|
-
const { method: httpMethod, path } = parseMethodPath(step.detail);
|
|
55
|
-
// Step header
|
|
56
|
-
if (httpMethod && path) {
|
|
57
|
-
console.log(` ${icon} ${pc.dim(stepNum)} ${methodColor(httpMethod)} ${path} ${duration}`);
|
|
58
|
-
}
|
|
59
|
-
else {
|
|
60
|
-
console.log(` ${icon} ${pc.dim(stepNum)} ${step.name} ${duration}`);
|
|
61
|
-
}
|
|
62
|
-
// Step detail
|
|
63
|
-
const detail = formatDetail(step);
|
|
64
|
-
if (detail) {
|
|
65
|
-
console.log(` ${detail}`);
|
|
66
|
-
}
|
|
67
|
-
// Verbose: show full data
|
|
68
|
-
if (options.verbose && Object.keys(step.data).length > 0) {
|
|
69
|
-
const json = JSON.stringify(step.data, null, 2);
|
|
70
|
-
for (const line of json.split("\n")) {
|
|
71
|
-
console.log(` ${pc.dim(line)}`);
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
// Show webhook events in step
|
|
75
|
-
if (step.name === "webhook_verification") {
|
|
76
|
-
const matched = (step.data.matched || []);
|
|
77
|
-
for (const evt of matched) {
|
|
78
|
-
console.log(` ${pc.yellow("⚡")} ${evt}`);
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
blank();
|
|
83
|
-
// Summary
|
|
84
|
-
const totalTime = `${result.total_duration_ms}ms`;
|
|
85
|
-
if (result.passed) {
|
|
86
|
-
console.log(` ${pc.green("✓")} Workflow complete — ${passedSteps}/${totalSteps} steps passed ${pc.dim(`(${totalTime})`)}`);
|
|
87
|
-
}
|
|
88
|
-
else {
|
|
89
|
-
console.log(` ${pc.red("✗")} Workflow failed at step ${passedSteps + 1}/${totalSteps} ${pc.dim(`(${totalTime})`)}`);
|
|
90
|
-
}
|
|
27
|
+
const result = await executeAndDisplay(sandboxId, workflowName, options);
|
|
91
28
|
// If using a scenario and it failed as expected, that's actually a good thing
|
|
92
29
|
if (options.scenario && !result.passed) {
|
|
93
30
|
blank();
|
|
94
|
-
info(`Scenario "${options.scenario}" correctly caused failure
|
|
31
|
+
info(`Scenario "${options.scenario}" correctly caused failure.`);
|
|
95
32
|
info("This confirms your error handling works.");
|
|
96
33
|
}
|
|
97
34
|
blank();
|
|
@@ -117,6 +54,133 @@ export async function run(sandboxId, workflowName, options = {}) {
|
|
|
117
54
|
process.exit(1);
|
|
118
55
|
}
|
|
119
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
|
+
}
|
|
120
184
|
// ── Helpers ──────────────────────────────────────────────────────────────
|
|
121
185
|
/** Parse HTTP method and path from step detail string. */
|
|
122
186
|
function parseMethodPath(detail) {
|
package/dist/constants.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export declare const API_BASE: string;
|
|
2
|
-
export declare const VERSION = "0.3.
|
|
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.3.
|
|
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";
|
|
@@ -17,6 +18,23 @@ import { docs } from "./commands/docs.js";
|
|
|
17
18
|
import { state } from "./commands/state.js";
|
|
18
19
|
import { webhookListen } from "./commands/webhook.js";
|
|
19
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
|
+
}
|
|
20
38
|
const program = new Command();
|
|
21
39
|
program
|
|
22
40
|
.name("fetchsandbox")
|
|
@@ -33,76 +51,82 @@ program
|
|
|
33
51
|
.action(list);
|
|
34
52
|
program
|
|
35
53
|
.command("delete <sandbox-id>")
|
|
36
|
-
.description("Delete a sandbox")
|
|
54
|
+
.description("Delete a sandbox (accepts name or ID)")
|
|
37
55
|
.option("-f, --force", "Skip confirmation prompt")
|
|
38
|
-
.action((id, opts) => deleteCmd(id, opts));
|
|
56
|
+
.action(withResolve((id, opts) => deleteCmd(id, opts)));
|
|
39
57
|
// ── Understand ────────────────────────────────────────────────────────
|
|
40
58
|
program
|
|
41
59
|
.command("endpoints <sandbox-id>")
|
|
42
60
|
.description("List all endpoints (use --search to filter)")
|
|
43
61
|
.option("-s, --search <term>", "Filter by path, summary, or tag")
|
|
44
|
-
.action((id, opts) => endpoints(id, opts));
|
|
62
|
+
.action(withResolve((id, opts) => endpoints(id, opts)));
|
|
45
63
|
program
|
|
46
64
|
.command("docs <sandbox-id>")
|
|
47
65
|
.description("Open the docs portal in your browser")
|
|
48
|
-
.action(docs);
|
|
66
|
+
.action(withResolve(docs));
|
|
49
67
|
// ── Plan ──────────────────────────────────────────────────────────────
|
|
50
68
|
program
|
|
51
69
|
.command("workflows <sandbox-id>")
|
|
52
70
|
.description("List integration workflows (use --name for detail)")
|
|
53
71
|
.option("-n, --name <name>", "Show detail for a specific workflow")
|
|
54
|
-
.action((id, opts) => workflows(id, opts));
|
|
72
|
+
.action(withResolve((id, opts) => workflows(id, opts)));
|
|
55
73
|
// ── Implement ─────────────────────────────────────────────────────────
|
|
56
74
|
program
|
|
57
75
|
.command("request <sandbox-id> <method> <path>")
|
|
58
76
|
.description("Make an API call to the sandbox")
|
|
59
77
|
.option("-d, --data <json>", "Request body (JSON string)")
|
|
60
|
-
.action((id, method, path, opts) => sendRequest(id, method, path, opts));
|
|
78
|
+
.action(withResolve((id, method, path, opts) => sendRequest(id, method, path, opts)));
|
|
61
79
|
// ── Prove ─────────────────────────────────────────────────────────────
|
|
62
80
|
program
|
|
63
|
-
.command("run <sandbox-id>
|
|
64
|
-
.description("Execute a workflow end-to-end
|
|
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")
|
|
65
84
|
.option("--scenario <name>", "Run under an error scenario (resets after)")
|
|
66
85
|
.option("--verbose", "Show full response data for each step")
|
|
67
|
-
.action((id, workflow, opts) => run(id, workflow, opts));
|
|
86
|
+
.action(withResolve((id, workflow, opts) => run(id, workflow, opts)));
|
|
68
87
|
// ── Test ──────────────────────────────────────────────────────────────
|
|
69
88
|
program
|
|
70
89
|
.command("status <sandbox-id>")
|
|
71
90
|
.description("Show sandbox state, resources, and recent activity")
|
|
72
|
-
.action(showStatus);
|
|
91
|
+
.action(withResolve(showStatus));
|
|
73
92
|
program
|
|
74
93
|
.command("logs <sandbox-id>")
|
|
75
94
|
.description("Show request logs (use --follow for live tail)")
|
|
76
95
|
.option("-f, --follow", "Tail logs in real-time")
|
|
77
96
|
.option("--status <code>", "Filter by status code (e.g. 200, 4xx, 5xx)")
|
|
78
97
|
.option("-l, --limit <n>", "Number of log entries (default: 30)")
|
|
79
|
-
.action((id, opts) => logs(id, opts));
|
|
98
|
+
.action(withResolve((id, opts) => logs(id, opts)));
|
|
80
99
|
program
|
|
81
100
|
.command("scenario <sandbox-id>")
|
|
82
101
|
.description("List or switch test scenarios")
|
|
83
102
|
.option("-n, --name <name>", "Set active scenario")
|
|
84
|
-
.action((id, opts) => scenario(id, opts));
|
|
103
|
+
.action(withResolve((id, opts) => scenario(id, opts)));
|
|
85
104
|
program
|
|
86
105
|
.command("state <sandbox-id> [resource]")
|
|
87
106
|
.description("Inspect resource state (use --id for single record)")
|
|
88
107
|
.option("--id <resource-id>", "Show a single record by ID")
|
|
89
|
-
.action((id, resource, opts) => state(id, resource, opts));
|
|
108
|
+
.action(withResolve((id, resource, opts) => state(id, resource, opts)));
|
|
90
109
|
program
|
|
91
110
|
.command("reset <sandbox-id>")
|
|
92
111
|
.description("Reset sandbox to its original seed data")
|
|
93
|
-
.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)));
|
|
94
118
|
// ── Observe ──────────────────────────────────────────────────────────
|
|
95
119
|
program
|
|
96
120
|
.command("webhook-listen <sandbox-id>")
|
|
97
121
|
.description("Live-tail webhook events (Ctrl+C to stop)")
|
|
98
122
|
.option("--verbose", "Show full event payload")
|
|
99
123
|
.option("--event-type <type>", "Filter by event type (e.g. charge.created)")
|
|
100
|
-
.action((id, opts) => webhookListen(id, opts));
|
|
124
|
+
.action(withResolve((id, opts) => webhookListen(id, opts)));
|
|
101
125
|
// ── Go Live ───────────────────────────────────────────────────────────
|
|
102
126
|
program
|
|
103
127
|
.command("check <sandbox-id>")
|
|
104
128
|
.description("Run integration readiness check")
|
|
105
|
-
.action(check);
|
|
129
|
+
.action(withResolve(check));
|
|
106
130
|
// If no command given, show help with a friendly message
|
|
107
131
|
if (process.argv.length <= 2) {
|
|
108
132
|
console.log();
|
|
@@ -124,6 +148,7 @@ if (process.argv.length <= 2) {
|
|
|
124
148
|
console.log();
|
|
125
149
|
console.log(` ${pc.dim("Prove:")}`);
|
|
126
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`);
|
|
127
152
|
console.log();
|
|
128
153
|
console.log(` ${pc.dim("Test:")}`);
|
|
129
154
|
console.log(` ${pc.white("status <id>")} Show sandbox state and activity`);
|
|
@@ -131,6 +156,7 @@ if (process.argv.length <= 2) {
|
|
|
131
156
|
console.log(` ${pc.white("logs <id>")} Show request logs (--follow for live)`);
|
|
132
157
|
console.log(` ${pc.white("scenario <id>")} List or switch test scenarios`);
|
|
133
158
|
console.log(` ${pc.white("reset <id>")} Reset sandbox to seed data`);
|
|
159
|
+
console.log(` ${pc.white("diff <id>")} Show recent mutations + state summary`);
|
|
134
160
|
console.log();
|
|
135
161
|
console.log(` ${pc.dim("Observe:")}`);
|
|
136
162
|
console.log(` ${pc.white("webhook-listen <id>")} Live-tail webhook events`);
|
package/dist/lib/api.d.ts
CHANGED
|
@@ -110,6 +110,13 @@ export interface SandboxProxyResponse {
|
|
|
110
110
|
headers: Record<string, string>;
|
|
111
111
|
body: unknown;
|
|
112
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>;
|
|
113
120
|
export declare function uploadSpec(input: string): Promise<SpecResult>;
|
|
114
121
|
export declare function createSandbox(specId: string, name?: string): Promise<SandboxResult>;
|
|
115
122
|
export declare function getSandbox(id: string): Promise<SandboxResult>;
|
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;
|