fetchsandbox 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/check.d.ts +1 -0
- package/dist/commands/check.js +102 -0
- package/dist/commands/delete.d.ts +3 -0
- package/dist/commands/delete.js +30 -0
- package/dist/commands/docs.d.ts +1 -0
- package/dist/commands/docs.js +22 -0
- package/dist/commands/endpoints.d.ts +3 -0
- package/dist/commands/endpoints.js +38 -0
- package/dist/commands/logs.d.ts +5 -0
- package/dist/commands/logs.js +66 -0
- package/dist/commands/request.d.ts +3 -0
- package/dist/commands/request.js +44 -0
- package/dist/commands/run.d.ts +4 -0
- package/dist/commands/run.js +167 -0
- package/dist/commands/scenario.d.ts +3 -0
- package/dist/commands/scenario.js +40 -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/commands/workflows.d.ts +3 -0
- package/dist/commands/workflows.js +68 -0
- package/dist/constants.d.ts +1 -1
- package/dist/constants.js +1 -1
- package/dist/index.js +110 -10
- package/dist/lib/api.d.ts +101 -0
- package/dist/lib/api.js +56 -0
- package/package.json +1 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function check(sandboxId: string): Promise<void>;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import pc from "picocolors";
|
|
2
|
+
import ora from "ora";
|
|
3
|
+
import { getSandbox, getSandboxLogs, getWorkflows, getWebhooks, validateSandbox, } from "../lib/api.js";
|
|
4
|
+
import { fail, blank, heading, friendlyError } from "../lib/output.js";
|
|
5
|
+
export async function check(sandboxId) {
|
|
6
|
+
blank();
|
|
7
|
+
const spinner = ora({ text: "Running integration readiness check...", indent: 2 }).start();
|
|
8
|
+
try {
|
|
9
|
+
const sb = await getSandbox(sandboxId);
|
|
10
|
+
// Fetch all data in parallel
|
|
11
|
+
const [validation, logs, workflowData, webhooks] = await Promise.all([
|
|
12
|
+
validateSandbox(sandboxId).catch(() => null),
|
|
13
|
+
getSandboxLogs(sandboxId, 200).catch(() => []),
|
|
14
|
+
getWorkflows(sb.spec_id).catch(() => ({ workflows: [] })),
|
|
15
|
+
getWebhooks(sandboxId).catch(() => []),
|
|
16
|
+
]);
|
|
17
|
+
spinner.stop();
|
|
18
|
+
heading(`Integration Readiness: ${sb.spec_name || sb.name}`);
|
|
19
|
+
blank();
|
|
20
|
+
// ── 1. Endpoint Health (30%) ──────────────────────────────────
|
|
21
|
+
const healthScore = validation ? validation.pass_rate / 100 : 0;
|
|
22
|
+
const healthPct = Math.round(healthScore * 100);
|
|
23
|
+
const healthIcon = healthPct >= 90 ? pc.green("✓") : healthPct >= 60 ? pc.yellow("⚠") : pc.red("✗");
|
|
24
|
+
console.log(` ${healthIcon} Endpoint health: ${healthPct >= 90 ? pc.green(healthPct + "%") : healthPct >= 60 ? pc.yellow(healthPct + "%") : pc.red(healthPct + "%")}${validation ? pc.dim(` (${validation.passed}/${validation.total} passed)`) : pc.dim(" (unable to validate)")}`);
|
|
25
|
+
// ── 2. Endpoint Coverage (30%) ────────────────────────────────
|
|
26
|
+
const uniqueEndpointsCalled = new Set(logs.map((l) => `${l.method} ${l.matched_path_template || l.path}`));
|
|
27
|
+
const totalEndpoints = sb.endpoints_count || 1;
|
|
28
|
+
const coverageRatio = Math.min(uniqueEndpointsCalled.size / totalEndpoints, 1);
|
|
29
|
+
const coveragePct = Math.round(coverageRatio * 100);
|
|
30
|
+
const coverageIcon = coveragePct >= 25 ? pc.green("✓") : coveragePct >= 10 ? pc.yellow("⚠") : pc.red("✗");
|
|
31
|
+
console.log(` ${coverageIcon} Endpoints exercised: ${coveragePct >= 25 ? pc.green(coveragePct + "%") : coveragePct >= 10 ? pc.yellow(coveragePct + "%") : pc.red(coveragePct + "%")}${pc.dim(` (${uniqueEndpointsCalled.size}/${totalEndpoints} unique endpoints called)`)}`);
|
|
32
|
+
// ── 3. Workflow Completion (25%) ──────────────────────────────
|
|
33
|
+
const wfs = workflowData.workflows || [];
|
|
34
|
+
let workflowScore = 0;
|
|
35
|
+
let completedWorkflows = 0;
|
|
36
|
+
const incompleteWorkflows = [];
|
|
37
|
+
if (wfs.length > 0) {
|
|
38
|
+
for (const wf of wfs) {
|
|
39
|
+
let stepsMatched = 0;
|
|
40
|
+
for (const step of wf.steps) {
|
|
41
|
+
const found = logs.some((l) => l.method === step.method && (l.matched_path_template === step.path || l.path.includes(step.path.replace(/\{[^}]+\}/g, ""))));
|
|
42
|
+
if (found)
|
|
43
|
+
stepsMatched++;
|
|
44
|
+
}
|
|
45
|
+
if (stepsMatched === wf.steps.length)
|
|
46
|
+
completedWorkflows++;
|
|
47
|
+
else if (stepsMatched > 0)
|
|
48
|
+
incompleteWorkflows.push({ name: wf.name, completed: stepsMatched, total: wf.steps.length });
|
|
49
|
+
}
|
|
50
|
+
workflowScore = completedWorkflows / wfs.length;
|
|
51
|
+
}
|
|
52
|
+
const wfPct = Math.round(workflowScore * 100);
|
|
53
|
+
const wfIcon = completedWorkflows > 0 ? pc.green("✓") : wfs.length > 0 ? pc.red("✗") : pc.dim("—");
|
|
54
|
+
console.log(` ${wfIcon} Workflows completed: ${completedWorkflows > 0 ? pc.green(`${completedWorkflows}/${wfs.length}`) : wfs.length > 0 ? pc.red(`${completedWorkflows}/${wfs.length}`) : pc.dim("none discovered")}`);
|
|
55
|
+
for (const inc of incompleteWorkflows.slice(0, 3)) {
|
|
56
|
+
console.log(` ${pc.dim("⤷")} ${inc.name}: ${pc.yellow(`${inc.completed}/${inc.total} steps`)}`);
|
|
57
|
+
}
|
|
58
|
+
// ── 4. Webhook Readiness (15%) ────────────────────────────────
|
|
59
|
+
const webhookScore = webhooks.length > 0 ? 1 : 0;
|
|
60
|
+
const whIcon = webhookScore > 0 ? pc.green("✓") : pc.yellow("⚠");
|
|
61
|
+
console.log(` ${whIcon} Webhook handler: ${webhookScore > 0 ? pc.green(`${webhooks.length} registered`) : pc.yellow("not registered")}`);
|
|
62
|
+
// ── Overall Score ─────────────────────────────────────────────
|
|
63
|
+
const overall = Math.round(healthScore * 30 +
|
|
64
|
+
coverageRatio * 30 +
|
|
65
|
+
workflowScore * 25 +
|
|
66
|
+
webhookScore * 15);
|
|
67
|
+
blank();
|
|
68
|
+
const overallColor = overall >= 80 ? pc.green : overall >= 50 ? pc.yellow : pc.red;
|
|
69
|
+
console.log(` ${pc.bold("Overall:")} ${overallColor(pc.bold(`${overall}%`))}`);
|
|
70
|
+
// ── Next Steps ────────────────────────────────────────────────
|
|
71
|
+
const nextSteps = [];
|
|
72
|
+
if (coveragePct < 10) {
|
|
73
|
+
nextSteps.push(`Try an API call: ${pc.cyan(`fetchsandbox request ${sandboxId} GET /v1`)}`);
|
|
74
|
+
}
|
|
75
|
+
if (wfs.length > 0 && completedWorkflows === 0) {
|
|
76
|
+
nextSteps.push(`Run a workflow: ${pc.cyan(`fetchsandbox workflows ${sandboxId}`)}`);
|
|
77
|
+
}
|
|
78
|
+
if (webhookScore === 0) {
|
|
79
|
+
nextSteps.push(`Register a webhook in the sandbox dashboard`);
|
|
80
|
+
}
|
|
81
|
+
if (healthPct < 90 && healthPct > 0) {
|
|
82
|
+
nextSteps.push(`Check failing endpoints: ${pc.cyan(`fetchsandbox endpoints ${sandboxId}`)}`);
|
|
83
|
+
}
|
|
84
|
+
if (sb.active_scenario === "default") {
|
|
85
|
+
nextSteps.push(`Test error scenarios: ${pc.cyan(`fetchsandbox scenario ${sandboxId} auth-failure`)}`);
|
|
86
|
+
}
|
|
87
|
+
if (nextSteps.length > 0) {
|
|
88
|
+
blank();
|
|
89
|
+
heading("Next steps:");
|
|
90
|
+
for (let i = 0; i < nextSteps.length; i++) {
|
|
91
|
+
console.log(` ${pc.dim(`${i + 1}.`)} ${nextSteps[i]}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
blank();
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
spinner.stop();
|
|
98
|
+
fail(friendlyError(error));
|
|
99
|
+
blank();
|
|
100
|
+
process.exit(1);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import pc from "picocolors";
|
|
2
|
+
import { getSandbox, deleteSandbox } from "../lib/api.js";
|
|
3
|
+
import { ok, fail, blank, friendlyError } from "../lib/output.js";
|
|
4
|
+
import { createInterface } from "node:readline";
|
|
5
|
+
export async function deleteCmd(sandboxId, options) {
|
|
6
|
+
blank();
|
|
7
|
+
try {
|
|
8
|
+
const sb = await getSandbox(sandboxId);
|
|
9
|
+
if (!options.force) {
|
|
10
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
11
|
+
const answer = await new Promise((resolve) => {
|
|
12
|
+
rl.question(` ${pc.yellow("?")} Delete "${sb.spec_name || sb.name}" (${sb.id})? [y/N] `, resolve);
|
|
13
|
+
});
|
|
14
|
+
rl.close();
|
|
15
|
+
if (answer.toLowerCase() !== "y") {
|
|
16
|
+
console.log(` ${pc.dim("Cancelled.")}`);
|
|
17
|
+
blank();
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
await deleteSandbox(sandboxId);
|
|
22
|
+
ok(`Deleted sandbox "${sb.spec_name || sb.name}" (${sb.id})`);
|
|
23
|
+
blank();
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
fail(friendlyError(error));
|
|
27
|
+
blank();
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function docs(sandboxId: string): Promise<void>;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { getSandbox } from "../lib/api.js";
|
|
2
|
+
import { ok, fail, blank, friendlyError } from "../lib/output.js";
|
|
3
|
+
import { API_BASE } from "../constants.js";
|
|
4
|
+
import { exec } from "node:child_process";
|
|
5
|
+
export async function docs(sandboxId) {
|
|
6
|
+
blank();
|
|
7
|
+
try {
|
|
8
|
+
const sb = await getSandbox(sandboxId);
|
|
9
|
+
const slug = sb.slug || sb.spec_id;
|
|
10
|
+
const url = `${API_BASE}/docs/${slug}`;
|
|
11
|
+
// Open in default browser
|
|
12
|
+
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
13
|
+
exec(`${cmd} ${url}`);
|
|
14
|
+
ok(`Opening ${url}`);
|
|
15
|
+
blank();
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
fail(friendlyError(error));
|
|
19
|
+
blank();
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import pc from "picocolors";
|
|
2
|
+
import { getEndpoints } from "../lib/api.js";
|
|
3
|
+
import { fail, blank, heading, method as methodColor, tableHeader, row, friendlyError } from "../lib/output.js";
|
|
4
|
+
export async function endpoints(sandboxId, options) {
|
|
5
|
+
blank();
|
|
6
|
+
try {
|
|
7
|
+
let eps = await getEndpoints(sandboxId);
|
|
8
|
+
if (options.search) {
|
|
9
|
+
const term = options.search.toLowerCase();
|
|
10
|
+
eps = eps.filter((e) => e.path.toLowerCase().includes(term) ||
|
|
11
|
+
e.summary?.toLowerCase().includes(term) ||
|
|
12
|
+
e.operation_id?.toLowerCase().includes(term) ||
|
|
13
|
+
e.tags?.some((t) => t.toLowerCase().includes(term)));
|
|
14
|
+
}
|
|
15
|
+
if (eps.length === 0) {
|
|
16
|
+
console.log(` ${pc.dim(options.search ? `No endpoints matching "${options.search}".` : "No endpoints found.")}`);
|
|
17
|
+
blank();
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
heading(`${eps.length} endpoints${options.search ? ` matching "${options.search}"` : ""}:`);
|
|
21
|
+
blank();
|
|
22
|
+
const widths = [8, 42, 40];
|
|
23
|
+
tableHeader(["Method", "Path", "Summary"], widths);
|
|
24
|
+
for (const ep of eps) {
|
|
25
|
+
const m = methodColor(ep.method);
|
|
26
|
+
const summary = (ep.summary || "").slice(0, 38);
|
|
27
|
+
row([m, ep.path.slice(0, 40), pc.dim(summary)], widths);
|
|
28
|
+
}
|
|
29
|
+
blank();
|
|
30
|
+
console.log(` ${pc.dim("Try one:")} ${pc.cyan(`fetchsandbox request ${sandboxId} GET ${eps[0]?.path || "/v1"}`)}`);
|
|
31
|
+
blank();
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
fail(friendlyError(error));
|
|
35
|
+
blank();
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import pc from "picocolors";
|
|
2
|
+
import { getSandboxLogs } from "../lib/api.js";
|
|
3
|
+
import { fail, blank, heading, method as methodColor, status as statusColor, timeAgo, friendlyError } from "../lib/output.js";
|
|
4
|
+
export async function logs(sandboxId, options) {
|
|
5
|
+
blank();
|
|
6
|
+
try {
|
|
7
|
+
const limit = options.limit ? parseInt(options.limit, 10) : 30;
|
|
8
|
+
const statusFilter = options.status ? parseInt(options.status, 10) : 0;
|
|
9
|
+
const printLogs = (entries) => {
|
|
10
|
+
for (const log of entries) {
|
|
11
|
+
if (statusFilter) {
|
|
12
|
+
// Support range filters: 4xx matches 400-499, 5xx matches 500-599
|
|
13
|
+
if (String(statusFilter).includes("x")) {
|
|
14
|
+
const base = parseInt(String(statusFilter).replace(/x/gi, "0"), 10);
|
|
15
|
+
if (log.response_status < base || log.response_status >= base + 100)
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
else if (log.response_status !== statusFilter) {
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
const time = timeAgo(log.timestamp).padEnd(8);
|
|
23
|
+
const m = methodColor(log.method);
|
|
24
|
+
const path = log.path.length > 40 ? log.path.slice(0, 37) + "..." : log.path;
|
|
25
|
+
console.log(` ${pc.dim(time)}${m} ${path.padEnd(41)} ${statusColor(log.response_status)} ${pc.dim(log.duration_ms + "ms")}`);
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
if (options.follow) {
|
|
29
|
+
heading("Tailing logs (Ctrl+C to stop):");
|
|
30
|
+
blank();
|
|
31
|
+
let lastSeen = "";
|
|
32
|
+
const poll = async () => {
|
|
33
|
+
const entries = await getSandboxLogs(sandboxId, 20);
|
|
34
|
+
const newEntries = lastSeen
|
|
35
|
+
? entries.filter((e) => e.timestamp > lastSeen)
|
|
36
|
+
: entries;
|
|
37
|
+
if (newEntries.length > 0) {
|
|
38
|
+
printLogs(newEntries);
|
|
39
|
+
lastSeen = newEntries[0].timestamp; // logs are newest-first
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
await poll();
|
|
43
|
+
const interval = setInterval(poll, 2000);
|
|
44
|
+
process.on("SIGINT", () => { clearInterval(interval); blank(); process.exit(0); });
|
|
45
|
+
// Keep alive
|
|
46
|
+
await new Promise(() => { });
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
const entries = await getSandboxLogs(sandboxId, limit);
|
|
50
|
+
if (entries.length === 0) {
|
|
51
|
+
console.log(` ${pc.dim("No request logs yet. Make an API call to see logs.")}`);
|
|
52
|
+
blank();
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
heading(`Last ${entries.length} requests:`);
|
|
56
|
+
blank();
|
|
57
|
+
printLogs(entries);
|
|
58
|
+
blank();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
fail(friendlyError(error));
|
|
63
|
+
blank();
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import pc from "picocolors";
|
|
2
|
+
import { getSandbox, sandboxRequest } from "../lib/api.js";
|
|
3
|
+
import { fail, blank, friendlyError, method as methodColor, status as statusColor } from "../lib/output.js";
|
|
4
|
+
export async function sendRequest(sandboxId, httpMethod, path, options) {
|
|
5
|
+
blank();
|
|
6
|
+
try {
|
|
7
|
+
const sb = await getSandbox(sandboxId);
|
|
8
|
+
const apiKey = sb.credentials?.[0]?.api_key;
|
|
9
|
+
const m = httpMethod.toUpperCase();
|
|
10
|
+
console.log(` ${methodColor(m)} ${pc.cyan(path)}`);
|
|
11
|
+
if (options.data) {
|
|
12
|
+
console.log(` ${pc.dim("Body:")} ${options.data.length > 80 ? options.data.slice(0, 77) + "..." : options.data}`);
|
|
13
|
+
}
|
|
14
|
+
blank();
|
|
15
|
+
const res = await sandboxRequest(sandboxId, m, path, options.data, apiKey);
|
|
16
|
+
// Status line
|
|
17
|
+
const statusStr = statusColor(res.status);
|
|
18
|
+
const statusText = res.status < 300 ? "OK" : res.status < 500 ? "Client Error" : "Server Error";
|
|
19
|
+
console.log(` ${pc.dim("←")} ${statusStr} ${statusText}`);
|
|
20
|
+
blank();
|
|
21
|
+
// Response body
|
|
22
|
+
if (res.body !== undefined && res.body !== null && res.body !== "") {
|
|
23
|
+
const formatted = typeof res.body === "string"
|
|
24
|
+
? res.body
|
|
25
|
+
: JSON.stringify(res.body, null, 2);
|
|
26
|
+
// Truncate very large responses for terminal readability
|
|
27
|
+
const lines = formatted.split("\n");
|
|
28
|
+
const maxLines = 60;
|
|
29
|
+
for (const line of lines.slice(0, maxLines)) {
|
|
30
|
+
console.log(` ${pc.dim(line)}`);
|
|
31
|
+
}
|
|
32
|
+
if (lines.length > maxLines) {
|
|
33
|
+
blank();
|
|
34
|
+
console.log(` ${pc.dim(`... ${lines.length - maxLines} more lines (${formatted.length} bytes total)`)}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
blank();
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
fail(friendlyError(error));
|
|
41
|
+
blank();
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
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
|
+
// If a scenario was requested, switch to it first
|
|
9
|
+
if (options.scenario) {
|
|
10
|
+
const spinner = ora({ text: ` Switching to scenario: ${options.scenario}`, indent: 0 }).start();
|
|
11
|
+
try {
|
|
12
|
+
await setScenario(sandboxId, options.scenario);
|
|
13
|
+
spinner.succeed(` Scenario: ${options.scenario}`);
|
|
14
|
+
}
|
|
15
|
+
catch (err) {
|
|
16
|
+
spinner.fail(` Failed to set scenario: ${friendlyError(err)}`);
|
|
17
|
+
blank();
|
|
18
|
+
process.exit(1);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
// 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
|
+
}
|
|
91
|
+
// If using a scenario and it failed as expected, that's actually a good thing
|
|
92
|
+
if (options.scenario && !result.passed) {
|
|
93
|
+
blank();
|
|
94
|
+
info(`Scenario "${options.scenario}" correctly caused failure at step ${passedSteps + 1}.`);
|
|
95
|
+
info("This confirms your error handling works.");
|
|
96
|
+
}
|
|
97
|
+
blank();
|
|
98
|
+
// Reset scenario back to default if we changed it
|
|
99
|
+
if (options.scenario) {
|
|
100
|
+
try {
|
|
101
|
+
await setScenario(sandboxId, "default");
|
|
102
|
+
info("Scenario reset to default.");
|
|
103
|
+
blank();
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
// Silent — best effort reset
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
// Exit with appropriate code
|
|
110
|
+
if (!result.passed && !options.scenario) {
|
|
111
|
+
process.exit(1);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
fail(friendlyError(error));
|
|
116
|
+
blank();
|
|
117
|
+
process.exit(1);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
// ── Helpers ──────────────────────────────────────────────────────────────
|
|
121
|
+
/** Parse HTTP method and path from step detail string. */
|
|
122
|
+
function parseMethodPath(detail) {
|
|
123
|
+
const match = detail.match(/^(GET|POST|PUT|PATCH|DELETE|HEAD)\s+(\S+)/);
|
|
124
|
+
if (match) {
|
|
125
|
+
return { method: match[1], path: match[2] };
|
|
126
|
+
}
|
|
127
|
+
return { method: "", path: "" };
|
|
128
|
+
}
|
|
129
|
+
/** Format a human-readable detail line from step data. */
|
|
130
|
+
function formatDetail(step) {
|
|
131
|
+
if (step.status === "failed") {
|
|
132
|
+
return pc.red(step.detail);
|
|
133
|
+
}
|
|
134
|
+
// Extract meaningful info from data
|
|
135
|
+
const id = step.data.id || step.data.resource_id || "";
|
|
136
|
+
const status = step.data.status || "";
|
|
137
|
+
const responseStatus = step.data.response_status;
|
|
138
|
+
const parts = [];
|
|
139
|
+
if (responseStatus)
|
|
140
|
+
parts.push(`→ ${pc.green(String(responseStatus))}`);
|
|
141
|
+
if (id)
|
|
142
|
+
parts.push(pc.white(String(id)));
|
|
143
|
+
if (status)
|
|
144
|
+
parts.push(pc.dim(`status: ${status}`));
|
|
145
|
+
return parts.length > 0 ? parts.join(" ") : "";
|
|
146
|
+
}
|
|
147
|
+
/** Show available workflows when one isn't found. */
|
|
148
|
+
async function showAvailableWorkflows(sandboxId, attempted) {
|
|
149
|
+
fail(`Workflow "${attempted}" not found.`);
|
|
150
|
+
try {
|
|
151
|
+
const sb = await getSandbox(sandboxId);
|
|
152
|
+
const { workflows } = await getWorkflows(sb.spec_id);
|
|
153
|
+
if (workflows.length > 0) {
|
|
154
|
+
blank();
|
|
155
|
+
info("Available workflows:");
|
|
156
|
+
for (const wf of workflows) {
|
|
157
|
+
const id = wf.id || wf.name;
|
|
158
|
+
console.log(` ${pc.dim("•")} ${id}`);
|
|
159
|
+
}
|
|
160
|
+
blank();
|
|
161
|
+
info(`Try: fetchsandbox run ${sandboxId} "${workflows[0].id || workflows[0].name}"`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
// Silent — best effort
|
|
166
|
+
}
|
|
167
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import pc from "picocolors";
|
|
2
|
+
import { getSandbox, getScenarios, setScenario } from "../lib/api.js";
|
|
3
|
+
import { ok, fail, blank, heading, friendlyError } from "../lib/output.js";
|
|
4
|
+
export async function scenario(sandboxId, options) {
|
|
5
|
+
blank();
|
|
6
|
+
try {
|
|
7
|
+
const sb = await getSandbox(sandboxId);
|
|
8
|
+
if (options.name) {
|
|
9
|
+
// Set scenario
|
|
10
|
+
const result = await setScenario(sandboxId, options.name);
|
|
11
|
+
ok(`Scenario set to "${result.active_scenario}"`);
|
|
12
|
+
blank();
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
// List scenarios
|
|
16
|
+
const scenarios = await getScenarios();
|
|
17
|
+
heading(`Scenarios for ${sb.spec_name || sb.name}:`);
|
|
18
|
+
console.log(` ${pc.dim("Active:")} ${pc.bold(sb.active_scenario)}`);
|
|
19
|
+
blank();
|
|
20
|
+
if (scenarios.length === 0) {
|
|
21
|
+
console.log(` ${pc.dim("No custom scenarios available.")}`);
|
|
22
|
+
blank();
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
for (const s of scenarios) {
|
|
26
|
+
const active = s.name === sb.active_scenario ? pc.green(" ● active") : "";
|
|
27
|
+
console.log(` ${pc.bold(s.name)}${active}`);
|
|
28
|
+
console.log(` ${pc.dim(s.description)}`);
|
|
29
|
+
console.log(` ${pc.dim(`${s.overrides_count} overrides, auth ${s.auth_enabled ? "enabled" : "disabled"}`)}`);
|
|
30
|
+
blank();
|
|
31
|
+
}
|
|
32
|
+
console.log(` ${pc.dim("Switch:")} ${pc.cyan(`fetchsandbox scenario ${sandboxId} --name <scenario>`)}`);
|
|
33
|
+
blank();
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
fail(friendlyError(error));
|
|
37
|
+
blank();
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import pc from "picocolors";
|
|
2
|
+
import { getSandbox, getWorkflows } from "../lib/api.js";
|
|
3
|
+
import { fail, blank, heading, method as methodColor, friendlyError } from "../lib/output.js";
|
|
4
|
+
export async function workflows(sandboxId, options) {
|
|
5
|
+
blank();
|
|
6
|
+
try {
|
|
7
|
+
const sb = await getSandbox(sandboxId);
|
|
8
|
+
const data = await getWorkflows(sb.spec_id);
|
|
9
|
+
const wfs = data.workflows || [];
|
|
10
|
+
if (wfs.length === 0) {
|
|
11
|
+
console.log(` ${pc.dim("No workflows discovered for this sandbox.")}`);
|
|
12
|
+
blank();
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
// If a specific workflow name is given, show detail view
|
|
16
|
+
if (options.name) {
|
|
17
|
+
const match = wfs.find((w) => w.id === options.name ||
|
|
18
|
+
w.name.toLowerCase().includes(options.name.toLowerCase()));
|
|
19
|
+
if (!match) {
|
|
20
|
+
fail(`No workflow matching "${options.name}". Run ${pc.cyan("fetchsandbox workflows " + sandboxId)} to see all.`);
|
|
21
|
+
blank();
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
heading(`${match.name}`);
|
|
25
|
+
console.log(` ${pc.dim(match.description)}`);
|
|
26
|
+
if (match.category)
|
|
27
|
+
console.log(` ${pc.dim("Category:")} ${match.category}`);
|
|
28
|
+
blank();
|
|
29
|
+
for (const step of match.steps) {
|
|
30
|
+
const m = methodColor(step.method);
|
|
31
|
+
console.log(` ${pc.bold(String(step.step) + ".")} ${step.name}`);
|
|
32
|
+
console.log(` ${m} ${pc.cyan(step.path)}`);
|
|
33
|
+
if (step.description)
|
|
34
|
+
console.log(` ${pc.dim(step.description)}`);
|
|
35
|
+
blank();
|
|
36
|
+
}
|
|
37
|
+
if (match.webhook_events && match.webhook_events.length > 0) {
|
|
38
|
+
heading("Webhook events:");
|
|
39
|
+
for (const evt of match.webhook_events) {
|
|
40
|
+
console.log(` ${pc.dim("→")} ${evt}`);
|
|
41
|
+
}
|
|
42
|
+
blank();
|
|
43
|
+
}
|
|
44
|
+
// Show helpful next step
|
|
45
|
+
console.log(` ${pc.dim("Try it:")} ${pc.cyan(`fetchsandbox request ${sandboxId} ${match.steps[0].method} ${match.steps[0].path}`)}`);
|
|
46
|
+
blank();
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
// List all workflows
|
|
50
|
+
heading(`${wfs.length} workflows for ${sb.spec_name || sb.name}:`);
|
|
51
|
+
blank();
|
|
52
|
+
for (const wf of wfs) {
|
|
53
|
+
const stepCount = `${wf.steps.length} steps`;
|
|
54
|
+
console.log(` ${pc.bold(wf.name)} ${pc.dim(stepCount)}`);
|
|
55
|
+
console.log(` ${pc.dim(wf.description)}`);
|
|
56
|
+
const methods = wf.steps.map((s) => `${s.method} ${s.path}`).join(" → ");
|
|
57
|
+
console.log(` ${pc.dim(methods.length > 80 ? methods.slice(0, 77) + "..." : methods)}`);
|
|
58
|
+
blank();
|
|
59
|
+
}
|
|
60
|
+
console.log(` ${pc.dim("Show detail:")} ${pc.cyan(`fetchsandbox workflows ${sandboxId} --name "<workflow name>"`)}`);
|
|
61
|
+
blank();
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
fail(friendlyError(error));
|
|
65
|
+
blank();
|
|
66
|
+
process.exit(1);
|
|
67
|
+
}
|
|
68
|
+
}
|
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.0";
|
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.0";
|
package/dist/index.js
CHANGED
|
@@ -6,42 +6,142 @@ import { generate } from "./commands/generate.js";
|
|
|
6
6
|
import { showStatus } from "./commands/status.js";
|
|
7
7
|
import { reset } from "./commands/reset.js";
|
|
8
8
|
import { list } from "./commands/list.js";
|
|
9
|
+
import { workflows } from "./commands/workflows.js";
|
|
10
|
+
import { sendRequest } from "./commands/request.js";
|
|
11
|
+
import { check } from "./commands/check.js";
|
|
12
|
+
import { endpoints } from "./commands/endpoints.js";
|
|
13
|
+
import { logs } from "./commands/logs.js";
|
|
14
|
+
import { scenario } from "./commands/scenario.js";
|
|
15
|
+
import { deleteCmd } from "./commands/delete.js";
|
|
16
|
+
import { docs } from "./commands/docs.js";
|
|
17
|
+
import { state } from "./commands/state.js";
|
|
18
|
+
import { webhookListen } from "./commands/webhook.js";
|
|
19
|
+
import { run } from "./commands/run.js";
|
|
9
20
|
const program = new Command();
|
|
10
21
|
program
|
|
11
22
|
.name("fetchsandbox")
|
|
12
|
-
.description(pc.dim("
|
|
23
|
+
.description(pc.dim("Ship your API integration in minutes, not months"))
|
|
13
24
|
.version(VERSION, "-v, --version");
|
|
25
|
+
// ── Setup ─────────────────────────────────────────────────────────────
|
|
14
26
|
program
|
|
15
27
|
.command("generate <spec>")
|
|
16
|
-
.description("Create a
|
|
28
|
+
.description("Create a sandbox from an OpenAPI spec file or URL")
|
|
17
29
|
.action(generate);
|
|
30
|
+
program
|
|
31
|
+
.command("list")
|
|
32
|
+
.description("List all sandboxes")
|
|
33
|
+
.action(list);
|
|
34
|
+
program
|
|
35
|
+
.command("delete <sandbox-id>")
|
|
36
|
+
.description("Delete a sandbox")
|
|
37
|
+
.option("-f, --force", "Skip confirmation prompt")
|
|
38
|
+
.action((id, opts) => deleteCmd(id, opts));
|
|
39
|
+
// ── Understand ────────────────────────────────────────────────────────
|
|
40
|
+
program
|
|
41
|
+
.command("endpoints <sandbox-id>")
|
|
42
|
+
.description("List all endpoints (use --search to filter)")
|
|
43
|
+
.option("-s, --search <term>", "Filter by path, summary, or tag")
|
|
44
|
+
.action((id, opts) => endpoints(id, opts));
|
|
45
|
+
program
|
|
46
|
+
.command("docs <sandbox-id>")
|
|
47
|
+
.description("Open the docs portal in your browser")
|
|
48
|
+
.action(docs);
|
|
49
|
+
// ── Plan ──────────────────────────────────────────────────────────────
|
|
50
|
+
program
|
|
51
|
+
.command("workflows <sandbox-id>")
|
|
52
|
+
.description("List integration workflows (use --name for detail)")
|
|
53
|
+
.option("-n, --name <name>", "Show detail for a specific workflow")
|
|
54
|
+
.action((id, opts) => workflows(id, opts));
|
|
55
|
+
// ── Implement ─────────────────────────────────────────────────────────
|
|
56
|
+
program
|
|
57
|
+
.command("request <sandbox-id> <method> <path>")
|
|
58
|
+
.description("Make an API call to the sandbox")
|
|
59
|
+
.option("-d, --data <json>", "Request body (JSON string)")
|
|
60
|
+
.action((id, method, path, opts) => sendRequest(id, method, path, opts));
|
|
61
|
+
// ── Prove ─────────────────────────────────────────────────────────────
|
|
62
|
+
program
|
|
63
|
+
.command("run <sandbox-id> <workflow>")
|
|
64
|
+
.description("Execute a workflow end-to-end and verify each step")
|
|
65
|
+
.option("--scenario <name>", "Run under an error scenario (resets after)")
|
|
66
|
+
.option("--verbose", "Show full response data for each step")
|
|
67
|
+
.action((id, workflow, opts) => run(id, workflow, opts));
|
|
68
|
+
// ── Test ──────────────────────────────────────────────────────────────
|
|
18
69
|
program
|
|
19
70
|
.command("status <sandbox-id>")
|
|
20
71
|
.description("Show sandbox state, resources, and recent activity")
|
|
21
72
|
.action(showStatus);
|
|
73
|
+
program
|
|
74
|
+
.command("logs <sandbox-id>")
|
|
75
|
+
.description("Show request logs (use --follow for live tail)")
|
|
76
|
+
.option("-f, --follow", "Tail logs in real-time")
|
|
77
|
+
.option("--status <code>", "Filter by status code (e.g. 200, 4xx, 5xx)")
|
|
78
|
+
.option("-l, --limit <n>", "Number of log entries (default: 30)")
|
|
79
|
+
.action((id, opts) => logs(id, opts));
|
|
80
|
+
program
|
|
81
|
+
.command("scenario <sandbox-id>")
|
|
82
|
+
.description("List or switch test scenarios")
|
|
83
|
+
.option("-n, --name <name>", "Set active scenario")
|
|
84
|
+
.action((id, opts) => scenario(id, opts));
|
|
85
|
+
program
|
|
86
|
+
.command("state <sandbox-id> [resource]")
|
|
87
|
+
.description("Inspect resource state (use --id for single record)")
|
|
88
|
+
.option("--id <resource-id>", "Show a single record by ID")
|
|
89
|
+
.action((id, resource, opts) => state(id, resource, opts));
|
|
22
90
|
program
|
|
23
91
|
.command("reset <sandbox-id>")
|
|
24
92
|
.description("Reset sandbox to its original seed data")
|
|
25
93
|
.action(reset);
|
|
94
|
+
// ── Observe ──────────────────────────────────────────────────────────
|
|
26
95
|
program
|
|
27
|
-
.command("
|
|
28
|
-
.description("
|
|
29
|
-
.
|
|
96
|
+
.command("webhook-listen <sandbox-id>")
|
|
97
|
+
.description("Live-tail webhook events (Ctrl+C to stop)")
|
|
98
|
+
.option("--verbose", "Show full event payload")
|
|
99
|
+
.option("--event-type <type>", "Filter by event type (e.g. charge.created)")
|
|
100
|
+
.action((id, opts) => webhookListen(id, opts));
|
|
101
|
+
// ── Go Live ───────────────────────────────────────────────────────────
|
|
102
|
+
program
|
|
103
|
+
.command("check <sandbox-id>")
|
|
104
|
+
.description("Run integration readiness check")
|
|
105
|
+
.action(check);
|
|
30
106
|
// If no command given, show help with a friendly message
|
|
31
107
|
if (process.argv.length <= 2) {
|
|
32
108
|
console.log();
|
|
33
109
|
console.log(` ${pc.bold("fetchsandbox")} ${pc.dim(`v${VERSION}`)}`);
|
|
34
|
-
console.log(` ${pc.dim("
|
|
110
|
+
console.log(` ${pc.dim("Ship your API integration in minutes, not months")}`);
|
|
35
111
|
console.log();
|
|
36
112
|
console.log(` ${pc.dim("Quick start:")}`);
|
|
37
113
|
console.log(` ${pc.cyan("fetchsandbox generate ./openapi.yaml")}`);
|
|
38
|
-
console.log(` ${pc.cyan("fetchsandbox generate https://api.example.com/openapi.yaml")}`);
|
|
39
114
|
console.log();
|
|
40
|
-
console.log(` ${pc.dim("
|
|
41
|
-
console.log(` ${pc.white("
|
|
42
|
-
console.log(` ${pc.white("
|
|
115
|
+
console.log(` ${pc.dim("Understand:")}`);
|
|
116
|
+
console.log(` ${pc.white("endpoints <id>")} List all endpoints (--search to filter)`);
|
|
117
|
+
console.log(` ${pc.white("docs <id>")} Open docs portal in browser`);
|
|
118
|
+
console.log();
|
|
119
|
+
console.log(` ${pc.dim("Plan:")}`);
|
|
120
|
+
console.log(` ${pc.white("workflows <id>")} List integration workflows`);
|
|
121
|
+
console.log();
|
|
122
|
+
console.log(` ${pc.dim("Implement:")}`);
|
|
123
|
+
console.log(` ${pc.white("request <id> <method> <path>")} Make an API call`);
|
|
124
|
+
console.log();
|
|
125
|
+
console.log(` ${pc.dim("Prove:")}`);
|
|
126
|
+
console.log(` ${pc.white("run <id> <workflow>")} Run a workflow end-to-end`);
|
|
127
|
+
console.log();
|
|
128
|
+
console.log(` ${pc.dim("Test:")}`);
|
|
129
|
+
console.log(` ${pc.white("status <id>")} Show sandbox state and activity`);
|
|
130
|
+
console.log(` ${pc.white("state <id> [type]")} Inspect resource data (--id for single)`);
|
|
131
|
+
console.log(` ${pc.white("logs <id>")} Show request logs (--follow for live)`);
|
|
132
|
+
console.log(` ${pc.white("scenario <id>")} List or switch test scenarios`);
|
|
43
133
|
console.log(` ${pc.white("reset <id>")} Reset sandbox to seed data`);
|
|
134
|
+
console.log();
|
|
135
|
+
console.log(` ${pc.dim("Observe:")}`);
|
|
136
|
+
console.log(` ${pc.white("webhook-listen <id>")} Live-tail webhook events`);
|
|
137
|
+
console.log();
|
|
138
|
+
console.log(` ${pc.dim("Go Live:")}`);
|
|
139
|
+
console.log(` ${pc.white("check <id>")} Integration readiness score`);
|
|
140
|
+
console.log();
|
|
141
|
+
console.log(` ${pc.dim("Manage:")}`);
|
|
142
|
+
console.log(` ${pc.white("generate <spec>")} Create sandbox from spec file or URL`);
|
|
44
143
|
console.log(` ${pc.white("list")} List all sandboxes`);
|
|
144
|
+
console.log(` ${pc.white("delete <id>")} Delete a sandbox`);
|
|
45
145
|
console.log();
|
|
46
146
|
console.log(` ${pc.dim("Learn more:")} ${pc.cyan("https://fetchsandbox.com")}`);
|
|
47
147
|
console.log();
|
package/dist/lib/api.d.ts
CHANGED
|
@@ -28,6 +28,87 @@ export interface LogEntry {
|
|
|
28
28
|
response_status: number;
|
|
29
29
|
duration_ms: number;
|
|
30
30
|
timestamp: string;
|
|
31
|
+
matched_operation?: string;
|
|
32
|
+
matched_path_template?: string;
|
|
33
|
+
request_body?: unknown;
|
|
34
|
+
response_body?: unknown;
|
|
35
|
+
}
|
|
36
|
+
export interface EndpointInfo {
|
|
37
|
+
method: string;
|
|
38
|
+
path: string;
|
|
39
|
+
operation_id: string;
|
|
40
|
+
summary: string;
|
|
41
|
+
tags: string[];
|
|
42
|
+
sandbox_url: string;
|
|
43
|
+
parameters: Array<{
|
|
44
|
+
name: string;
|
|
45
|
+
in: string;
|
|
46
|
+
required: boolean;
|
|
47
|
+
schema?: unknown;
|
|
48
|
+
}>;
|
|
49
|
+
has_request_body: boolean;
|
|
50
|
+
request_body_schema?: unknown;
|
|
51
|
+
sample_values?: Record<string, string>;
|
|
52
|
+
sample_body?: unknown;
|
|
53
|
+
}
|
|
54
|
+
export interface Workflow {
|
|
55
|
+
id: string;
|
|
56
|
+
name: string;
|
|
57
|
+
description: string;
|
|
58
|
+
category: string;
|
|
59
|
+
steps: Array<{
|
|
60
|
+
step: number;
|
|
61
|
+
name: string;
|
|
62
|
+
method: string;
|
|
63
|
+
path: string;
|
|
64
|
+
operation_id: string;
|
|
65
|
+
description: string;
|
|
66
|
+
request_body?: Record<string, unknown>;
|
|
67
|
+
expected_status?: number;
|
|
68
|
+
}>;
|
|
69
|
+
webhook_events?: string[];
|
|
70
|
+
}
|
|
71
|
+
export interface ScenarioInfo {
|
|
72
|
+
name: string;
|
|
73
|
+
description: string;
|
|
74
|
+
overrides_count: number;
|
|
75
|
+
auth_enabled: boolean;
|
|
76
|
+
}
|
|
77
|
+
export interface WebhookRegistration {
|
|
78
|
+
id: string;
|
|
79
|
+
sandbox_id: string;
|
|
80
|
+
url: string;
|
|
81
|
+
events: string[];
|
|
82
|
+
enabled: boolean;
|
|
83
|
+
created_at: string;
|
|
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
|
+
}
|
|
108
|
+
export interface SandboxProxyResponse {
|
|
109
|
+
status: number;
|
|
110
|
+
headers: Record<string, string>;
|
|
111
|
+
body: unknown;
|
|
31
112
|
}
|
|
32
113
|
export declare function uploadSpec(input: string): Promise<SpecResult>;
|
|
33
114
|
export declare function createSandbox(specId: string, name?: string): Promise<SandboxResult>;
|
|
@@ -44,4 +125,24 @@ export declare function validateSandbox(id: string): Promise<{
|
|
|
44
125
|
passed: number;
|
|
45
126
|
failed: number;
|
|
46
127
|
pass_rate: number;
|
|
128
|
+
results?: Array<{
|
|
129
|
+
method: string;
|
|
130
|
+
path: string;
|
|
131
|
+
status: number;
|
|
132
|
+
ok: boolean;
|
|
133
|
+
}>;
|
|
134
|
+
}>;
|
|
135
|
+
export declare function getEndpoints(id: string): Promise<EndpointInfo[]>;
|
|
136
|
+
export declare function getWorkflows(specId: string): Promise<{
|
|
137
|
+
workflows: Workflow[];
|
|
138
|
+
}>;
|
|
139
|
+
export declare function getScenarios(): Promise<ScenarioInfo[]>;
|
|
140
|
+
export declare function setScenario(sandboxId: string, scenario: string): Promise<{
|
|
141
|
+
status: string;
|
|
142
|
+
active_scenario: string;
|
|
47
143
|
}>;
|
|
144
|
+
export declare function deleteSandbox(id: string): Promise<void>;
|
|
145
|
+
export declare function getWebhooks(sandboxId: string): Promise<WebhookRegistration[]>;
|
|
146
|
+
export declare function getWebhookEvents(sandboxId: string, limit?: number): Promise<WebhookEvent[]>;
|
|
147
|
+
export declare function runWorkflow(sandboxId: string, workflowName: string): Promise<WorkflowRunResult>;
|
|
148
|
+
export declare function sandboxRequest(sandboxId: string, method: string, path: string, body?: string, apiKey?: string): Promise<SandboxProxyResponse>;
|
package/dist/lib/api.js
CHANGED
|
@@ -71,3 +71,59 @@ export async function resetSandbox(id) {
|
|
|
71
71
|
export async function validateSandbox(id) {
|
|
72
72
|
return request(`/api/sandboxes/${id}/validate`);
|
|
73
73
|
}
|
|
74
|
+
export async function getEndpoints(id) {
|
|
75
|
+
return request(`/api/sandboxes/${id}/endpoints`);
|
|
76
|
+
}
|
|
77
|
+
export async function getWorkflows(specId) {
|
|
78
|
+
return request(`/api/specs/${specId}/workflows`);
|
|
79
|
+
}
|
|
80
|
+
export async function getScenarios() {
|
|
81
|
+
return request("/api/scenarios");
|
|
82
|
+
}
|
|
83
|
+
export async function setScenario(sandboxId, scenario) {
|
|
84
|
+
return request(`/api/sandboxes/${sandboxId}/scenario`, {
|
|
85
|
+
method: "POST",
|
|
86
|
+
headers: { "Content-Type": "application/json" },
|
|
87
|
+
body: JSON.stringify({ scenario }),
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
export async function deleteSandbox(id) {
|
|
91
|
+
return request(`/api/sandboxes/${id}`, { method: "DELETE" });
|
|
92
|
+
}
|
|
93
|
+
export async function getWebhooks(sandboxId) {
|
|
94
|
+
return request(`/api/sandboxes/${sandboxId}/webhooks`);
|
|
95
|
+
}
|
|
96
|
+
export async function getWebhookEvents(sandboxId, limit = 50) {
|
|
97
|
+
return request(`/api/sandboxes/${sandboxId}/webhooks/events?limit=${limit}`);
|
|
98
|
+
}
|
|
99
|
+
export async function runWorkflow(sandboxId, workflowName) {
|
|
100
|
+
return request(`/api/sandboxes/${sandboxId}/workflows/${encodeURIComponent(workflowName)}/run`, {
|
|
101
|
+
method: "POST",
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
export async function sandboxRequest(sandboxId, method, path, body, apiKey) {
|
|
105
|
+
// Use the path-based proxy: /sandbox/{id}/{path}
|
|
106
|
+
const sandboxPath = path.startsWith("/") ? path : `/${path}`;
|
|
107
|
+
const url = `${API_BASE}/sandbox/${sandboxId}${sandboxPath}`;
|
|
108
|
+
const headers = {};
|
|
109
|
+
if (apiKey)
|
|
110
|
+
headers["api-key"] = apiKey;
|
|
111
|
+
if (body)
|
|
112
|
+
headers["Content-Type"] = "application/json";
|
|
113
|
+
const res = await fetch(url, {
|
|
114
|
+
method: method.toUpperCase(),
|
|
115
|
+
headers,
|
|
116
|
+
body: body || undefined,
|
|
117
|
+
});
|
|
118
|
+
const resBody = await res.text();
|
|
119
|
+
let parsed;
|
|
120
|
+
try {
|
|
121
|
+
parsed = JSON.parse(resBody);
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
parsed = resBody;
|
|
125
|
+
}
|
|
126
|
+
const resHeaders = {};
|
|
127
|
+
res.headers.forEach((v, k) => { resHeaders[k] = v; });
|
|
128
|
+
return { status: res.status, headers: resHeaders, body: parsed };
|
|
129
|
+
}
|