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.
Files changed (125) hide show
  1. package/dist/api-client.d.ts +208 -0
  2. package/dist/api-client.js +237 -0
  3. package/dist/commands/annotate.d.ts +6 -0
  4. package/dist/commands/annotate.js +433 -0
  5. package/dist/commands/audit.d.ts +7 -0
  6. package/dist/commands/audit.js +82 -0
  7. package/dist/commands/auto.d.ts +8 -0
  8. package/dist/commands/auto.js +268 -0
  9. package/dist/commands/capture.d.ts +14 -0
  10. package/dist/commands/capture.js +271 -0
  11. package/dist/commands/check.d.ts +6 -0
  12. package/dist/commands/check.js +408 -0
  13. package/dist/commands/codegen.d.ts +21 -0
  14. package/dist/commands/codegen.js +129 -0
  15. package/dist/commands/coverage.d.ts +13 -0
  16. package/dist/commands/coverage.js +126 -0
  17. package/dist/commands/dashboard.d.ts +1 -0
  18. package/dist/commands/dashboard.js +83 -0
  19. package/dist/commands/dev.d.ts +14 -0
  20. package/dist/commands/dev.js +319 -0
  21. package/dist/commands/diff.d.ts +7 -0
  22. package/dist/commands/diff.js +79 -0
  23. package/dist/commands/docs.d.ts +13 -0
  24. package/dist/commands/docs.js +383 -0
  25. package/dist/commands/errors.d.ts +7 -0
  26. package/dist/commands/errors.js +180 -0
  27. package/dist/commands/export.d.ts +18 -0
  28. package/dist/commands/export.js +238 -0
  29. package/dist/commands/functions.d.ts +6 -0
  30. package/dist/commands/functions.js +71 -0
  31. package/dist/commands/infer.d.ts +14 -0
  32. package/dist/commands/infer.js +275 -0
  33. package/dist/commands/init.d.ts +5 -0
  34. package/dist/commands/init.js +395 -0
  35. package/dist/commands/mock.d.ts +5 -0
  36. package/dist/commands/mock.js +232 -0
  37. package/dist/commands/openapi.d.ts +8 -0
  38. package/dist/commands/openapi.js +82 -0
  39. package/dist/commands/overview.d.ts +11 -0
  40. package/dist/commands/overview.js +266 -0
  41. package/dist/commands/pack.d.ts +11 -0
  42. package/dist/commands/pack.js +133 -0
  43. package/dist/commands/proxy.d.ts +13 -0
  44. package/dist/commands/proxy.js +312 -0
  45. package/dist/commands/replay.d.ts +14 -0
  46. package/dist/commands/replay.js +289 -0
  47. package/dist/commands/run.d.ts +17 -0
  48. package/dist/commands/run.js +997 -0
  49. package/dist/commands/sample.d.ts +13 -0
  50. package/dist/commands/sample.js +260 -0
  51. package/dist/commands/search.d.ts +5 -0
  52. package/dist/commands/search.js +80 -0
  53. package/dist/commands/stubs.d.ts +6 -0
  54. package/dist/commands/stubs.js +187 -0
  55. package/dist/commands/tail.d.ts +4 -0
  56. package/dist/commands/tail.js +76 -0
  57. package/dist/commands/test-gen.d.ts +13 -0
  58. package/dist/commands/test-gen.js +237 -0
  59. package/dist/commands/trace.d.ts +14 -0
  60. package/dist/commands/trace.js +417 -0
  61. package/dist/commands/types.d.ts +7 -0
  62. package/dist/commands/types.js +128 -0
  63. package/dist/commands/unpack.d.ts +11 -0
  64. package/dist/commands/unpack.js +166 -0
  65. package/dist/commands/validate.d.ts +13 -0
  66. package/dist/commands/validate.js +310 -0
  67. package/dist/commands/watch.d.ts +9 -0
  68. package/dist/commands/watch.js +267 -0
  69. package/dist/config.d.ts +1 -0
  70. package/dist/config.js +66 -0
  71. package/dist/formatters/diff-formatter.d.ts +5 -0
  72. package/dist/formatters/diff-formatter.js +43 -0
  73. package/dist/formatters/type-formatter.d.ts +22 -0
  74. package/dist/formatters/type-formatter.js +135 -0
  75. package/dist/index.d.ts +2 -0
  76. package/dist/index.js +419 -0
  77. package/dist/local-codegen.d.ts +22 -0
  78. package/dist/local-codegen.js +762 -0
  79. package/dist/ui/badges.d.ts +16 -0
  80. package/dist/ui/badges.js +71 -0
  81. package/dist/ui/helpers.d.ts +13 -0
  82. package/dist/ui/helpers.js +85 -0
  83. package/package.json +23 -0
  84. package/src/api-client.ts +407 -0
  85. package/src/commands/annotate.ts +450 -0
  86. package/src/commands/audit.ts +103 -0
  87. package/src/commands/auto.ts +268 -0
  88. package/src/commands/capture.ts +257 -0
  89. package/src/commands/check.ts +437 -0
  90. package/src/commands/codegen.ts +128 -0
  91. package/src/commands/coverage.ts +170 -0
  92. package/src/commands/dashboard.ts +46 -0
  93. package/src/commands/dev.ts +323 -0
  94. package/src/commands/diff.ts +99 -0
  95. package/src/commands/docs.ts +392 -0
  96. package/src/commands/errors.ts +205 -0
  97. package/src/commands/export.ts +287 -0
  98. package/src/commands/functions.ts +81 -0
  99. package/src/commands/infer.ts +260 -0
  100. package/src/commands/init.ts +419 -0
  101. package/src/commands/mock.ts +220 -0
  102. package/src/commands/openapi.ts +53 -0
  103. package/src/commands/overview.ts +310 -0
  104. package/src/commands/pack.ts +139 -0
  105. package/src/commands/proxy.ts +314 -0
  106. package/src/commands/replay.ts +356 -0
  107. package/src/commands/run.ts +1190 -0
  108. package/src/commands/sample.ts +259 -0
  109. package/src/commands/search.ts +107 -0
  110. package/src/commands/stubs.ts +211 -0
  111. package/src/commands/tail.ts +94 -0
  112. package/src/commands/test-gen.ts +236 -0
  113. package/src/commands/trace.ts +440 -0
  114. package/src/commands/types.ts +161 -0
  115. package/src/commands/unpack.ts +179 -0
  116. package/src/commands/validate.ts +368 -0
  117. package/src/commands/watch.ts +277 -0
  118. package/src/config.ts +38 -0
  119. package/src/formatters/diff-formatter.ts +51 -0
  120. package/src/formatters/type-formatter.ts +161 -0
  121. package/src/index.ts +454 -0
  122. package/src/local-codegen.ts +859 -0
  123. package/src/ui/badges.ts +66 -0
  124. package/src/ui/helpers.ts +80 -0
  125. 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
+ }