trickle-cli 0.1.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/api-client.d.ts +208 -0
- package/dist/api-client.js +237 -0
- package/dist/commands/annotate.d.ts +6 -0
- package/dist/commands/annotate.js +433 -0
- package/dist/commands/audit.d.ts +7 -0
- package/dist/commands/audit.js +82 -0
- package/dist/commands/auto.d.ts +8 -0
- package/dist/commands/auto.js +268 -0
- package/dist/commands/capture.d.ts +14 -0
- package/dist/commands/capture.js +271 -0
- package/dist/commands/check.d.ts +6 -0
- package/dist/commands/check.js +408 -0
- package/dist/commands/codegen.d.ts +21 -0
- package/dist/commands/codegen.js +129 -0
- package/dist/commands/coverage.d.ts +13 -0
- package/dist/commands/coverage.js +126 -0
- package/dist/commands/dashboard.d.ts +1 -0
- package/dist/commands/dashboard.js +83 -0
- package/dist/commands/dev.d.ts +14 -0
- package/dist/commands/dev.js +319 -0
- package/dist/commands/diff.d.ts +7 -0
- package/dist/commands/diff.js +79 -0
- package/dist/commands/docs.d.ts +13 -0
- package/dist/commands/docs.js +383 -0
- package/dist/commands/errors.d.ts +7 -0
- package/dist/commands/errors.js +180 -0
- package/dist/commands/export.d.ts +18 -0
- package/dist/commands/export.js +238 -0
- package/dist/commands/functions.d.ts +6 -0
- package/dist/commands/functions.js +71 -0
- package/dist/commands/infer.d.ts +14 -0
- package/dist/commands/infer.js +275 -0
- package/dist/commands/init.d.ts +5 -0
- package/dist/commands/init.js +395 -0
- package/dist/commands/mock.d.ts +5 -0
- package/dist/commands/mock.js +232 -0
- package/dist/commands/openapi.d.ts +8 -0
- package/dist/commands/openapi.js +82 -0
- package/dist/commands/overview.d.ts +11 -0
- package/dist/commands/overview.js +266 -0
- package/dist/commands/pack.d.ts +11 -0
- package/dist/commands/pack.js +133 -0
- package/dist/commands/proxy.d.ts +13 -0
- package/dist/commands/proxy.js +312 -0
- package/dist/commands/replay.d.ts +14 -0
- package/dist/commands/replay.js +289 -0
- package/dist/commands/run.d.ts +17 -0
- package/dist/commands/run.js +997 -0
- package/dist/commands/sample.d.ts +13 -0
- package/dist/commands/sample.js +260 -0
- package/dist/commands/search.d.ts +5 -0
- package/dist/commands/search.js +80 -0
- package/dist/commands/stubs.d.ts +6 -0
- package/dist/commands/stubs.js +187 -0
- package/dist/commands/tail.d.ts +4 -0
- package/dist/commands/tail.js +76 -0
- package/dist/commands/test-gen.d.ts +13 -0
- package/dist/commands/test-gen.js +237 -0
- package/dist/commands/trace.d.ts +14 -0
- package/dist/commands/trace.js +417 -0
- package/dist/commands/types.d.ts +7 -0
- package/dist/commands/types.js +128 -0
- package/dist/commands/unpack.d.ts +11 -0
- package/dist/commands/unpack.js +166 -0
- package/dist/commands/validate.d.ts +13 -0
- package/dist/commands/validate.js +310 -0
- package/dist/commands/watch.d.ts +9 -0
- package/dist/commands/watch.js +267 -0
- package/dist/config.d.ts +1 -0
- package/dist/config.js +66 -0
- package/dist/formatters/diff-formatter.d.ts +5 -0
- package/dist/formatters/diff-formatter.js +43 -0
- package/dist/formatters/type-formatter.d.ts +22 -0
- package/dist/formatters/type-formatter.js +135 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +419 -0
- package/dist/local-codegen.d.ts +22 -0
- package/dist/local-codegen.js +762 -0
- package/dist/ui/badges.d.ts +16 -0
- package/dist/ui/badges.js +71 -0
- package/dist/ui/helpers.d.ts +13 -0
- package/dist/ui/helpers.js +85 -0
- package/package.json +23 -0
- package/src/api-client.ts +407 -0
- package/src/commands/annotate.ts +450 -0
- package/src/commands/audit.ts +103 -0
- package/src/commands/auto.ts +268 -0
- package/src/commands/capture.ts +257 -0
- package/src/commands/check.ts +437 -0
- package/src/commands/codegen.ts +128 -0
- package/src/commands/coverage.ts +170 -0
- package/src/commands/dashboard.ts +46 -0
- package/src/commands/dev.ts +323 -0
- package/src/commands/diff.ts +99 -0
- package/src/commands/docs.ts +392 -0
- package/src/commands/errors.ts +205 -0
- package/src/commands/export.ts +287 -0
- package/src/commands/functions.ts +81 -0
- package/src/commands/infer.ts +260 -0
- package/src/commands/init.ts +419 -0
- package/src/commands/mock.ts +220 -0
- package/src/commands/openapi.ts +53 -0
- package/src/commands/overview.ts +310 -0
- package/src/commands/pack.ts +139 -0
- package/src/commands/proxy.ts +314 -0
- package/src/commands/replay.ts +356 -0
- package/src/commands/run.ts +1190 -0
- package/src/commands/sample.ts +259 -0
- package/src/commands/search.ts +107 -0
- package/src/commands/stubs.ts +211 -0
- package/src/commands/tail.ts +94 -0
- package/src/commands/test-gen.ts +236 -0
- package/src/commands/trace.ts +440 -0
- package/src/commands/types.ts +161 -0
- package/src/commands/unpack.ts +179 -0
- package/src/commands/validate.ts +368 -0
- package/src/commands/watch.ts +277 -0
- package/src/config.ts +38 -0
- package/src/formatters/diff-formatter.ts +51 -0
- package/src/formatters/type-formatter.ts +161 -0
- package/src/index.ts +454 -0
- package/src/local-codegen.ts +859 -0
- package/src/ui/badges.ts +66 -0
- package/src/ui/helpers.ts +80 -0
- package/tsconfig.json +8 -0
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
import { fetchMockConfig, MockRoute } from "../api-client";
|
|
5
|
+
|
|
6
|
+
export interface TestGenOptions {
|
|
7
|
+
out?: string;
|
|
8
|
+
framework?: string;
|
|
9
|
+
baseUrl?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* `trickle test --generate` — Generate API test files from runtime observations.
|
|
14
|
+
*
|
|
15
|
+
* Uses real sample request/response data captured at runtime to generate
|
|
16
|
+
* ready-to-run test files with correct endpoints, request bodies, and
|
|
17
|
+
* response shape assertions.
|
|
18
|
+
*/
|
|
19
|
+
export async function testGenCommand(opts: TestGenOptions): Promise<void> {
|
|
20
|
+
const framework = opts.framework || "vitest";
|
|
21
|
+
const baseUrl = opts.baseUrl || "http://localhost:3000";
|
|
22
|
+
|
|
23
|
+
if (framework !== "vitest" && framework !== "jest") {
|
|
24
|
+
console.error(chalk.red(`\n Unsupported framework: ${framework}`));
|
|
25
|
+
console.error(chalk.gray(" Supported: vitest, jest\n"));
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const { routes } = await fetchMockConfig();
|
|
31
|
+
|
|
32
|
+
if (routes.length === 0) {
|
|
33
|
+
console.error(chalk.yellow("\n No API routes observed yet."));
|
|
34
|
+
console.error(chalk.gray(" Instrument your app and make some requests first.\n"));
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const testCode = generateTestFile(routes, framework, baseUrl);
|
|
39
|
+
|
|
40
|
+
if (opts.out) {
|
|
41
|
+
const resolvedPath = path.resolve(opts.out);
|
|
42
|
+
const dir = path.dirname(resolvedPath);
|
|
43
|
+
if (!fs.existsSync(dir)) {
|
|
44
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
45
|
+
}
|
|
46
|
+
fs.writeFileSync(resolvedPath, testCode, "utf-8");
|
|
47
|
+
console.log("");
|
|
48
|
+
console.log(chalk.green(` Tests written to ${chalk.bold(opts.out)}`));
|
|
49
|
+
console.log(chalk.gray(` ${routes.length} route tests generated (${framework})`));
|
|
50
|
+
console.log(chalk.gray(` Run with: npx ${framework === "vitest" ? "vitest run" : "jest"} ${opts.out}`));
|
|
51
|
+
console.log("");
|
|
52
|
+
} else {
|
|
53
|
+
console.log("");
|
|
54
|
+
console.log(testCode);
|
|
55
|
+
}
|
|
56
|
+
} catch (err: unknown) {
|
|
57
|
+
if (err instanceof Error) {
|
|
58
|
+
console.error(chalk.red(`\n Error: ${err.message}\n`));
|
|
59
|
+
}
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function generateTestFile(routes: MockRoute[], framework: string, baseUrl: string): string {
|
|
65
|
+
const lines: string[] = [];
|
|
66
|
+
|
|
67
|
+
lines.push("// Auto-generated API tests by trickle");
|
|
68
|
+
lines.push(`// Generated at ${new Date().toISOString()}`);
|
|
69
|
+
lines.push("// Do not edit manually — re-run `trickle test --generate` to update");
|
|
70
|
+
lines.push("");
|
|
71
|
+
|
|
72
|
+
// Import block
|
|
73
|
+
if (framework === "vitest") {
|
|
74
|
+
lines.push('import { describe, it, expect } from "vitest";');
|
|
75
|
+
}
|
|
76
|
+
// jest needs no import — globals are available
|
|
77
|
+
lines.push("");
|
|
78
|
+
|
|
79
|
+
lines.push(`const BASE_URL = process.env.TEST_API_URL || "${baseUrl}";`);
|
|
80
|
+
lines.push("");
|
|
81
|
+
|
|
82
|
+
// Group routes by resource path prefix
|
|
83
|
+
const groups = groupByResource(routes);
|
|
84
|
+
|
|
85
|
+
for (const [resource, resourceRoutes] of Object.entries(groups)) {
|
|
86
|
+
lines.push(`describe("${resource}", () => {`);
|
|
87
|
+
|
|
88
|
+
for (const route of resourceRoutes) {
|
|
89
|
+
const testName = `${route.method} ${route.path}`;
|
|
90
|
+
const hasBody = ["POST", "PUT", "PATCH"].includes(route.method);
|
|
91
|
+
|
|
92
|
+
lines.push(` it("${testName} — returns expected shape", async () => {`);
|
|
93
|
+
|
|
94
|
+
// Build fetch call
|
|
95
|
+
const fetchPath = route.path.replace(/:(\w+)/g, (_, param) => {
|
|
96
|
+
// Use sample data to get a real param value if available
|
|
97
|
+
const sampleValue = extractParamFromSample(route.sampleInput, param);
|
|
98
|
+
return sampleValue || `test-${param}`;
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
lines.push(` const res = await fetch(\`\${BASE_URL}${fetchPath}\`, {`);
|
|
102
|
+
lines.push(` method: "${route.method}",`);
|
|
103
|
+
if (hasBody && route.sampleInput) {
|
|
104
|
+
const bodyData = extractBodyFromSample(route.sampleInput);
|
|
105
|
+
if (bodyData && Object.keys(bodyData).length > 0) {
|
|
106
|
+
lines.push(` headers: { "Content-Type": "application/json" },`);
|
|
107
|
+
lines.push(` body: JSON.stringify(${JSON.stringify(bodyData, null, 6).replace(/\n/g, "\n ")}),`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
lines.push(" });");
|
|
111
|
+
lines.push("");
|
|
112
|
+
|
|
113
|
+
// Status assertion
|
|
114
|
+
lines.push(" expect(res.ok).toBe(true);");
|
|
115
|
+
lines.push(` expect(res.status).toBe(200);`);
|
|
116
|
+
lines.push("");
|
|
117
|
+
|
|
118
|
+
// Response body assertions
|
|
119
|
+
lines.push(" const body = await res.json();");
|
|
120
|
+
|
|
121
|
+
if (route.sampleOutput && typeof route.sampleOutput === "object") {
|
|
122
|
+
const assertions = generateAssertions(route.sampleOutput as Record<string, unknown>, "body");
|
|
123
|
+
for (const assertion of assertions) {
|
|
124
|
+
lines.push(` ${assertion}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
lines.push(" });");
|
|
129
|
+
lines.push("");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
lines.push("});");
|
|
133
|
+
lines.push("");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return lines.join("\n").trimEnd() + "\n";
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Group routes by their first meaningful path segment.
|
|
141
|
+
*/
|
|
142
|
+
function groupByResource(routes: MockRoute[]): Record<string, MockRoute[]> {
|
|
143
|
+
const groups: Record<string, MockRoute[]> = {};
|
|
144
|
+
|
|
145
|
+
for (const route of routes) {
|
|
146
|
+
const parts = route.path.split("/").filter(Boolean);
|
|
147
|
+
// /api/users → "api/users", /users → "users"
|
|
148
|
+
let resource: string;
|
|
149
|
+
if (parts[0] === "api" && parts.length >= 2) {
|
|
150
|
+
resource = `/api/${parts[1]}`;
|
|
151
|
+
} else {
|
|
152
|
+
resource = `/${parts[0] || "root"}`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (!groups[resource]) groups[resource] = [];
|
|
156
|
+
groups[resource].push(route);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return groups;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Try to extract a path param value from sample input.
|
|
164
|
+
*/
|
|
165
|
+
function extractParamFromSample(sampleInput: unknown, param: string): string | null {
|
|
166
|
+
if (!sampleInput || typeof sampleInput !== "object") return null;
|
|
167
|
+
const input = sampleInput as Record<string, unknown>;
|
|
168
|
+
|
|
169
|
+
// Check params object
|
|
170
|
+
if (input.params && typeof input.params === "object") {
|
|
171
|
+
const params = input.params as Record<string, unknown>;
|
|
172
|
+
if (params[param] !== undefined) return String(params[param]);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Extract request body from sample input.
|
|
180
|
+
*/
|
|
181
|
+
function extractBodyFromSample(sampleInput: unknown): Record<string, unknown> | null {
|
|
182
|
+
if (!sampleInput || typeof sampleInput !== "object") return null;
|
|
183
|
+
const input = sampleInput as Record<string, unknown>;
|
|
184
|
+
|
|
185
|
+
if (input.body && typeof input.body === "object") {
|
|
186
|
+
return input.body as Record<string, unknown>;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Generate expect() assertions for a sample response object.
|
|
194
|
+
* Checks structure (property existence and types), not exact values.
|
|
195
|
+
*/
|
|
196
|
+
function generateAssertions(obj: Record<string, unknown>, path: string, depth = 0): string[] {
|
|
197
|
+
if (depth > 3) return []; // Prevent deeply nested assertions
|
|
198
|
+
|
|
199
|
+
const assertions: string[] = [];
|
|
200
|
+
|
|
201
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
202
|
+
const propPath = `${path}.${key}`;
|
|
203
|
+
|
|
204
|
+
if (value === null) {
|
|
205
|
+
assertions.push(`expect(${propPath}).toBeNull();`);
|
|
206
|
+
} else if (Array.isArray(value)) {
|
|
207
|
+
assertions.push(`expect(Array.isArray(${propPath})).toBe(true);`);
|
|
208
|
+
// If array has items, assert shape of first element
|
|
209
|
+
if (value.length > 0 && typeof value[0] === "object" && value[0] !== null) {
|
|
210
|
+
assertions.push(`expect(${propPath}.length).toBeGreaterThan(0);`);
|
|
211
|
+
const itemAssertions = generateAssertions(
|
|
212
|
+
value[0] as Record<string, unknown>,
|
|
213
|
+
`${propPath}[0]`,
|
|
214
|
+
depth + 1,
|
|
215
|
+
);
|
|
216
|
+
assertions.push(...itemAssertions);
|
|
217
|
+
}
|
|
218
|
+
} else if (typeof value === "object") {
|
|
219
|
+
assertions.push(`expect(typeof ${propPath}).toBe("object");`);
|
|
220
|
+
const nestedAssertions = generateAssertions(
|
|
221
|
+
value as Record<string, unknown>,
|
|
222
|
+
propPath,
|
|
223
|
+
depth + 1,
|
|
224
|
+
);
|
|
225
|
+
assertions.push(...nestedAssertions);
|
|
226
|
+
} else if (typeof value === "string") {
|
|
227
|
+
assertions.push(`expect(typeof ${propPath}).toBe("string");`);
|
|
228
|
+
} else if (typeof value === "number") {
|
|
229
|
+
assertions.push(`expect(typeof ${propPath}).toBe("number");`);
|
|
230
|
+
} else if (typeof value === "boolean") {
|
|
231
|
+
assertions.push(`expect(typeof ${propPath}).toBe("boolean");`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return assertions;
|
|
236
|
+
}
|
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
import * as crypto from "crypto";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import { getBackendUrl } from "../config";
|
|
4
|
+
|
|
5
|
+
export interface TraceOptions {
|
|
6
|
+
header?: string[];
|
|
7
|
+
body?: string;
|
|
8
|
+
save?: boolean;
|
|
9
|
+
env?: string;
|
|
10
|
+
module?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface TypeNode {
|
|
14
|
+
kind: string;
|
|
15
|
+
[key: string]: unknown;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* `trickle trace <method> <url>` — Make an HTTP request and show the response
|
|
20
|
+
* with inline type annotations. Like curl but type-aware.
|
|
21
|
+
*
|
|
22
|
+
* Optionally saves the types to the backend with --save.
|
|
23
|
+
*/
|
|
24
|
+
export async function traceCommand(
|
|
25
|
+
method: string,
|
|
26
|
+
url: string,
|
|
27
|
+
opts: TraceOptions,
|
|
28
|
+
): Promise<void> {
|
|
29
|
+
const httpMethod = method.toUpperCase();
|
|
30
|
+
const validMethods = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"];
|
|
31
|
+
if (!validMethods.includes(httpMethod)) {
|
|
32
|
+
console.error(chalk.red(`\n Invalid HTTP method: ${method}\n`));
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let parsedUrl: URL;
|
|
37
|
+
try {
|
|
38
|
+
parsedUrl = new URL(url);
|
|
39
|
+
} catch {
|
|
40
|
+
console.error(chalk.red(`\n Invalid URL: ${url}\n`));
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Build request headers
|
|
45
|
+
const headers: Record<string, string> = { Accept: "application/json" };
|
|
46
|
+
if (opts.header) {
|
|
47
|
+
for (const h of opts.header) {
|
|
48
|
+
const colonIdx = h.indexOf(":");
|
|
49
|
+
if (colonIdx === -1) {
|
|
50
|
+
console.error(chalk.red(`\n Invalid header: ${h}\n`));
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
headers[h.slice(0, colonIdx).trim()] = h.slice(colonIdx + 1).trim();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let reqBody: string | undefined;
|
|
58
|
+
let reqJson: unknown = undefined;
|
|
59
|
+
if (opts.body) {
|
|
60
|
+
reqBody = opts.body;
|
|
61
|
+
headers["Content-Type"] = headers["Content-Type"] || "application/json";
|
|
62
|
+
try {
|
|
63
|
+
reqJson = JSON.parse(opts.body);
|
|
64
|
+
} catch {}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
console.log("");
|
|
68
|
+
console.log(chalk.bold(" trickle trace"));
|
|
69
|
+
console.log(chalk.gray(" " + "─".repeat(50)));
|
|
70
|
+
|
|
71
|
+
// Make the request
|
|
72
|
+
const startTime = Date.now();
|
|
73
|
+
let response: Response;
|
|
74
|
+
try {
|
|
75
|
+
response = await fetch(url, {
|
|
76
|
+
method: httpMethod,
|
|
77
|
+
headers,
|
|
78
|
+
body: reqBody,
|
|
79
|
+
signal: AbortSignal.timeout(30000),
|
|
80
|
+
});
|
|
81
|
+
} catch (err: unknown) {
|
|
82
|
+
const msg = err instanceof Error ? err.message : "Unknown error";
|
|
83
|
+
console.error(chalk.red(`\n Request failed: ${msg}\n`));
|
|
84
|
+
process.exit(1);
|
|
85
|
+
}
|
|
86
|
+
const elapsed = Date.now() - startTime;
|
|
87
|
+
|
|
88
|
+
// Status line
|
|
89
|
+
const status = response.status;
|
|
90
|
+
const statusColor = status < 300 ? chalk.green : status < 400 ? chalk.yellow : chalk.red;
|
|
91
|
+
console.log(
|
|
92
|
+
chalk.gray(` ${chalk.bold(httpMethod)} ${url}`),
|
|
93
|
+
);
|
|
94
|
+
console.log(
|
|
95
|
+
chalk.gray(` Status: `) +
|
|
96
|
+
statusColor(`${status} ${response.statusText}`) +
|
|
97
|
+
chalk.gray(` (${elapsed}ms)`),
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
// Show response headers summary
|
|
101
|
+
const contentType = response.headers.get("content-type") || "";
|
|
102
|
+
const contentLength = response.headers.get("content-length");
|
|
103
|
+
console.log(
|
|
104
|
+
chalk.gray(` Type: ${contentType}`),
|
|
105
|
+
);
|
|
106
|
+
if (contentLength) {
|
|
107
|
+
console.log(chalk.gray(` Size: ${formatBytes(parseInt(contentLength, 10))}`));
|
|
108
|
+
}
|
|
109
|
+
console.log(chalk.gray(" " + "─".repeat(50)));
|
|
110
|
+
|
|
111
|
+
// Read and parse body
|
|
112
|
+
const bodyText = await response.text();
|
|
113
|
+
if (!contentType.includes("json") || bodyText.length === 0) {
|
|
114
|
+
console.log("");
|
|
115
|
+
if (bodyText.length > 0) {
|
|
116
|
+
console.log(chalk.gray(" (non-JSON response)"));
|
|
117
|
+
console.log(chalk.gray(" " + bodyText.slice(0, 500)));
|
|
118
|
+
} else {
|
|
119
|
+
console.log(chalk.gray(" (empty response)"));
|
|
120
|
+
}
|
|
121
|
+
console.log("");
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
let jsonData: unknown;
|
|
126
|
+
try {
|
|
127
|
+
jsonData = JSON.parse(bodyText);
|
|
128
|
+
} catch {
|
|
129
|
+
console.error(chalk.red("\n Response is not valid JSON.\n"));
|
|
130
|
+
process.exit(1);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Render annotated JSON
|
|
134
|
+
console.log("");
|
|
135
|
+
const lines = renderAnnotatedJson(jsonData, 2);
|
|
136
|
+
for (const line of lines) {
|
|
137
|
+
console.log(line);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Summary
|
|
141
|
+
const typeNode = jsonToTypeNode(jsonData);
|
|
142
|
+
const stats = countTypeStats(typeNode);
|
|
143
|
+
console.log("");
|
|
144
|
+
console.log(chalk.gray(" " + "─".repeat(50)));
|
|
145
|
+
console.log(
|
|
146
|
+
chalk.gray(` ${stats.fields} fields, ${stats.uniqueTypes} unique types, ${stats.depth} depth`),
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
// Optionally save to backend
|
|
150
|
+
if (opts.save) {
|
|
151
|
+
await saveTypes(parsedUrl, httpMethod, jsonData, reqJson, opts);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
console.log("");
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Render JSON with inline type annotations.
|
|
159
|
+
*/
|
|
160
|
+
function renderAnnotatedJson(
|
|
161
|
+
value: unknown,
|
|
162
|
+
baseIndent: number,
|
|
163
|
+
): string[] {
|
|
164
|
+
const lines: string[] = [];
|
|
165
|
+
renderValue(value, baseIndent, 0, lines, false, true);
|
|
166
|
+
return lines;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function renderValue(
|
|
170
|
+
value: unknown,
|
|
171
|
+
baseIndent: number,
|
|
172
|
+
depth: number,
|
|
173
|
+
lines: string[],
|
|
174
|
+
trailingComma: boolean,
|
|
175
|
+
isLast: boolean,
|
|
176
|
+
): void {
|
|
177
|
+
const indent = " ".repeat(baseIndent + depth * 2);
|
|
178
|
+
const comma = trailingComma ? "," : "";
|
|
179
|
+
const annotationGap = 2;
|
|
180
|
+
|
|
181
|
+
if (value === null) {
|
|
182
|
+
lines.push(indent + chalk.gray("null") + comma + typeAnnotation("null", indent.length + 4, annotationGap));
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (value === undefined) {
|
|
187
|
+
lines.push(indent + chalk.gray("undefined") + comma);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
switch (typeof value) {
|
|
192
|
+
case "string": {
|
|
193
|
+
const display = value.length > 60 ? `"${value.slice(0, 57)}..."` : `"${value}"`;
|
|
194
|
+
lines.push(indent + chalk.green(display) + comma + typeAnnotation("string", indent.length + display.length, annotationGap));
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
case "number":
|
|
198
|
+
lines.push(indent + chalk.yellow(String(value)) + comma + typeAnnotation("number", indent.length + String(value).length, annotationGap));
|
|
199
|
+
return;
|
|
200
|
+
case "boolean":
|
|
201
|
+
lines.push(indent + chalk.blue(String(value)) + comma + typeAnnotation("boolean", indent.length + String(value).length, annotationGap));
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (Array.isArray(value)) {
|
|
206
|
+
if (value.length === 0) {
|
|
207
|
+
lines.push(indent + "[]" + comma + typeAnnotation("unknown[]", indent.length + 2, annotationGap));
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Show type annotation on the opening bracket
|
|
212
|
+
const elemType = compactType(jsonToTypeNode(value[0]));
|
|
213
|
+
lines.push(indent + "[" + typeAnnotation(`${elemType}[]`, indent.length + 1, annotationGap));
|
|
214
|
+
|
|
215
|
+
// Show first few elements, collapse if too many
|
|
216
|
+
const maxShow = Math.min(value.length, 3);
|
|
217
|
+
for (let i = 0; i < maxShow; i++) {
|
|
218
|
+
renderValue(value[i], baseIndent, depth + 1, lines, i < value.length - 1, i === maxShow - 1);
|
|
219
|
+
}
|
|
220
|
+
if (value.length > maxShow) {
|
|
221
|
+
const innerIndent = " ".repeat(baseIndent + (depth + 1) * 2);
|
|
222
|
+
lines.push(innerIndent + chalk.gray(`// ... +${value.length - maxShow} more items`));
|
|
223
|
+
}
|
|
224
|
+
lines.push(indent + "]" + comma);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Object
|
|
229
|
+
const obj = value as Record<string, unknown>;
|
|
230
|
+
const keys = Object.keys(obj);
|
|
231
|
+
|
|
232
|
+
if (keys.length === 0) {
|
|
233
|
+
lines.push(indent + "{}" + comma + typeAnnotation("{}", indent.length + 2, annotationGap));
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
lines.push(indent + "{");
|
|
238
|
+
|
|
239
|
+
for (let i = 0; i < keys.length; i++) {
|
|
240
|
+
const key = keys[i];
|
|
241
|
+
const val = obj[key];
|
|
242
|
+
const isLastKey = i === keys.length - 1;
|
|
243
|
+
const keyComma = isLastKey ? "" : ",";
|
|
244
|
+
const innerIndent = " ".repeat(baseIndent + (depth + 1) * 2);
|
|
245
|
+
|
|
246
|
+
// For simple values, render key: value on one line
|
|
247
|
+
if (val === null || typeof val === "string" || typeof val === "number" || typeof val === "boolean") {
|
|
248
|
+
const valStr = formatSimpleValue(val);
|
|
249
|
+
const valType = val === null ? "null" : typeof val;
|
|
250
|
+
const lineContent = `${innerIndent}${chalk.white(`"${key}"`)}: ${valStr}${keyComma}`;
|
|
251
|
+
const rawLen = innerIndent.length + `"${key}": `.length + rawValueLen(val) + keyComma.length;
|
|
252
|
+
lines.push(lineContent + typeAnnotation(valType, rawLen, annotationGap));
|
|
253
|
+
} else {
|
|
254
|
+
// Complex value — render key then value
|
|
255
|
+
lines.push(`${innerIndent}${chalk.white(`"${key}"`)}:`);
|
|
256
|
+
renderValue(val, baseIndent, depth + 1, lines, !isLastKey, isLastKey);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
lines.push(indent + "}" + comma);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function formatSimpleValue(val: unknown): string {
|
|
264
|
+
if (val === null) return chalk.gray("null");
|
|
265
|
+
if (typeof val === "string") {
|
|
266
|
+
const display = val.length > 50 ? `"${val.slice(0, 47)}..."` : `"${val}"`;
|
|
267
|
+
return chalk.green(display);
|
|
268
|
+
}
|
|
269
|
+
if (typeof val === "number") return chalk.yellow(String(val));
|
|
270
|
+
if (typeof val === "boolean") return chalk.blue(String(val));
|
|
271
|
+
return String(val);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function rawValueLen(val: unknown): number {
|
|
275
|
+
if (val === null) return 4;
|
|
276
|
+
if (typeof val === "string") {
|
|
277
|
+
return Math.min(val.length + 2, 53);
|
|
278
|
+
}
|
|
279
|
+
return String(val).length;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function typeAnnotation(type: string, currentCol: number, gap: number): string {
|
|
283
|
+
const targetCol = 45;
|
|
284
|
+
const padding = Math.max(gap, targetCol - currentCol);
|
|
285
|
+
return " ".repeat(padding) + chalk.gray(`// ${type}`);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function jsonToTypeNode(value: unknown): TypeNode {
|
|
289
|
+
if (value === null) return { kind: "primitive", name: "null" };
|
|
290
|
+
if (value === undefined) return { kind: "primitive", name: "undefined" };
|
|
291
|
+
switch (typeof value) {
|
|
292
|
+
case "string": return { kind: "primitive", name: "string" };
|
|
293
|
+
case "number": return { kind: "primitive", name: "number" };
|
|
294
|
+
case "boolean": return { kind: "primitive", name: "boolean" };
|
|
295
|
+
}
|
|
296
|
+
if (Array.isArray(value)) {
|
|
297
|
+
if (value.length === 0) return { kind: "array", element: { kind: "unknown" } };
|
|
298
|
+
return { kind: "array", element: jsonToTypeNode(value[0]) };
|
|
299
|
+
}
|
|
300
|
+
const obj = value as Record<string, unknown>;
|
|
301
|
+
const properties: Record<string, TypeNode> = {};
|
|
302
|
+
for (const [key, val] of Object.entries(obj)) {
|
|
303
|
+
properties[key] = jsonToTypeNode(val);
|
|
304
|
+
}
|
|
305
|
+
return { kind: "object", properties };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function compactType(node: TypeNode): string {
|
|
309
|
+
switch (node.kind) {
|
|
310
|
+
case "primitive":
|
|
311
|
+
return node.name as string;
|
|
312
|
+
case "object": {
|
|
313
|
+
const props = node.properties as Record<string, TypeNode>;
|
|
314
|
+
const keys = Object.keys(props);
|
|
315
|
+
if (keys.length === 0) return "{}";
|
|
316
|
+
if (keys.length <= 4) {
|
|
317
|
+
return `{${keys.join(", ")}}`;
|
|
318
|
+
}
|
|
319
|
+
return `{${keys.slice(0, 3).join(", ")}, …+${keys.length - 3}}`;
|
|
320
|
+
}
|
|
321
|
+
case "array":
|
|
322
|
+
return `${compactType(node.element as TypeNode)}[]`;
|
|
323
|
+
default:
|
|
324
|
+
return node.kind;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function countTypeStats(node: TypeNode): { fields: number; uniqueTypes: number; depth: number } {
|
|
329
|
+
const types = new Set<string>();
|
|
330
|
+
let fields = 0;
|
|
331
|
+
let maxDepth = 0;
|
|
332
|
+
|
|
333
|
+
function walk(n: TypeNode, depth: number) {
|
|
334
|
+
maxDepth = Math.max(maxDepth, depth);
|
|
335
|
+
if (n.kind === "primitive") {
|
|
336
|
+
types.add(n.name as string);
|
|
337
|
+
} else if (n.kind === "object") {
|
|
338
|
+
const props = n.properties as Record<string, TypeNode>;
|
|
339
|
+
for (const val of Object.values(props)) {
|
|
340
|
+
fields++;
|
|
341
|
+
walk(val, depth + 1);
|
|
342
|
+
}
|
|
343
|
+
} else if (n.kind === "array") {
|
|
344
|
+
types.add("array");
|
|
345
|
+
walk(n.element as TypeNode, depth + 1);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
walk(node, 0);
|
|
350
|
+
return { fields, uniqueTypes: types.size, depth: maxDepth };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function formatBytes(bytes: number): string {
|
|
354
|
+
if (bytes < 1024) return `${bytes}B`;
|
|
355
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
|
356
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function normalizePath(urlPath: string): string {
|
|
360
|
+
return urlPath.split("/").map((part, i) => {
|
|
361
|
+
if (!part) return part;
|
|
362
|
+
if (/^\d+$/.test(part)) return ":id";
|
|
363
|
+
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(part)) return ":id";
|
|
364
|
+
if (/^[0-9a-f]{16,}$/i.test(part) && i > 1) return ":id";
|
|
365
|
+
return part;
|
|
366
|
+
}).join("/");
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function computeTypeHash(argsType: TypeNode, returnType: TypeNode): string {
|
|
370
|
+
const data = JSON.stringify({ a: argsType, r: returnType });
|
|
371
|
+
return crypto.createHash("sha256").update(data).digest("hex").slice(0, 16);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
async function saveTypes(
|
|
375
|
+
parsedUrl: URL,
|
|
376
|
+
httpMethod: string,
|
|
377
|
+
resJson: unknown,
|
|
378
|
+
reqJson: unknown,
|
|
379
|
+
opts: TraceOptions,
|
|
380
|
+
): Promise<void> {
|
|
381
|
+
const backendUrl = getBackendUrl();
|
|
382
|
+
|
|
383
|
+
try {
|
|
384
|
+
const res = await fetch(`${backendUrl}/api/health`, { signal: AbortSignal.timeout(3000) });
|
|
385
|
+
if (!res.ok) throw new Error("not ok");
|
|
386
|
+
} catch {
|
|
387
|
+
console.log(chalk.yellow(" Could not save types — backend not reachable."));
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const routePath = normalizePath(parsedUrl.pathname);
|
|
392
|
+
const functionName = `${httpMethod} ${routePath}`;
|
|
393
|
+
|
|
394
|
+
const argsProperties: Record<string, TypeNode> = {};
|
|
395
|
+
if (reqJson !== undefined && reqJson !== null) {
|
|
396
|
+
argsProperties.body = jsonToTypeNode(reqJson);
|
|
397
|
+
}
|
|
398
|
+
if (parsedUrl.search) {
|
|
399
|
+
const queryProps: Record<string, TypeNode> = {};
|
|
400
|
+
for (const [key] of parsedUrl.searchParams) {
|
|
401
|
+
queryProps[key] = { kind: "primitive", name: "string" };
|
|
402
|
+
}
|
|
403
|
+
if (Object.keys(queryProps).length > 0) {
|
|
404
|
+
argsProperties.query = { kind: "object", properties: queryProps };
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const argsType: TypeNode = Object.keys(argsProperties).length > 0
|
|
409
|
+
? { kind: "object", properties: argsProperties }
|
|
410
|
+
: { kind: "object", properties: {} };
|
|
411
|
+
|
|
412
|
+
const returnType = jsonToTypeNode(resJson);
|
|
413
|
+
const typeHash = computeTypeHash(argsType, returnType);
|
|
414
|
+
|
|
415
|
+
const payload = {
|
|
416
|
+
functionName,
|
|
417
|
+
module: opts.module || "trace",
|
|
418
|
+
language: "js",
|
|
419
|
+
environment: opts.env || "development",
|
|
420
|
+
typeHash,
|
|
421
|
+
argsType,
|
|
422
|
+
returnType,
|
|
423
|
+
sampleOutput: resJson,
|
|
424
|
+
sampleInput: Object.keys(argsProperties).length > 0 ? argsProperties : undefined,
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
try {
|
|
428
|
+
const ingestRes = await fetch(`${backendUrl}/api/ingest`, {
|
|
429
|
+
method: "POST",
|
|
430
|
+
headers: { "Content-Type": "application/json" },
|
|
431
|
+
body: JSON.stringify(payload),
|
|
432
|
+
signal: AbortSignal.timeout(5000),
|
|
433
|
+
});
|
|
434
|
+
if (!ingestRes.ok) throw new Error(`HTTP ${ingestRes.status}`);
|
|
435
|
+
console.log(chalk.green(` Types saved as "${functionName}"`));
|
|
436
|
+
} catch (err: unknown) {
|
|
437
|
+
const msg = err instanceof Error ? err.message : "Unknown error";
|
|
438
|
+
console.log(chalk.yellow(` Could not save types: ${msg}`));
|
|
439
|
+
}
|
|
440
|
+
}
|