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.
@@ -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
+ }
@@ -1,4 +1,5 @@
1
- export declare function run(sandboxId: string, workflowName: string, options?: {
1
+ export declare function run(sandboxId: string, workflowName: string | undefined, options?: {
2
2
  scenario?: string;
3
3
  verbose?: boolean;
4
+ all?: boolean;
4
5
  }): Promise<void>;
@@ -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 spinner = ora({ text: ` Running workflow...`, indent: 0 }).start();
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 at step ${passedSteps + 1}.`);
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) {
@@ -1,2 +1,2 @@
1
1
  export declare const API_BASE: string;
2
- export declare const VERSION = "0.3.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.3.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";
@@ -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> <workflow>")
64
- .description("Execute a workflow end-to-end and verify each step")
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fetchsandbox",
3
- "version": "0.3.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": {