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,287 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
import { fetchCodegen, fetchOpenApiSpec, fetchMockConfig } from "../api-client";
|
|
5
|
+
import { testGenCommand } from "./test-gen";
|
|
6
|
+
|
|
7
|
+
export interface ExportOptions {
|
|
8
|
+
dir?: string;
|
|
9
|
+
env?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface ExportResult {
|
|
13
|
+
file: string;
|
|
14
|
+
label: string;
|
|
15
|
+
ok: boolean;
|
|
16
|
+
count?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* `trickle export` — Generate all output formats into a directory at once.
|
|
21
|
+
*
|
|
22
|
+
* Creates a complete `.trickle/` directory with:
|
|
23
|
+
* - types.d.ts — TypeScript type declarations
|
|
24
|
+
* - api-client.ts — Typed fetch-based API client
|
|
25
|
+
* - handlers.d.ts — Express handler type aliases
|
|
26
|
+
* - schemas.ts — Zod validation schemas
|
|
27
|
+
* - hooks.ts — TanStack React Query hooks
|
|
28
|
+
* - guards.ts — Runtime type guard functions
|
|
29
|
+
* - openapi.json — OpenAPI 3.0 specification
|
|
30
|
+
* - api.test.ts — Generated API test scaffolds
|
|
31
|
+
*/
|
|
32
|
+
export async function exportCommand(opts: ExportOptions): Promise<void> {
|
|
33
|
+
const outDir = path.resolve(opts.dir || ".trickle");
|
|
34
|
+
|
|
35
|
+
// Ensure directory exists
|
|
36
|
+
if (!fs.existsSync(outDir)) {
|
|
37
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
console.log("");
|
|
41
|
+
console.log(chalk.bold(" trickle export"));
|
|
42
|
+
console.log(chalk.gray(" " + "─".repeat(50)));
|
|
43
|
+
console.log(chalk.gray(` Output directory: ${outDir}`));
|
|
44
|
+
if (opts.env) {
|
|
45
|
+
console.log(chalk.gray(` Environment: ${opts.env}`));
|
|
46
|
+
}
|
|
47
|
+
console.log(chalk.gray(" " + "─".repeat(50)));
|
|
48
|
+
console.log("");
|
|
49
|
+
|
|
50
|
+
const results: ExportResult[] = [];
|
|
51
|
+
const queryOpts = { env: opts.env };
|
|
52
|
+
|
|
53
|
+
// 1. TypeScript types
|
|
54
|
+
results.push(await generateFile(
|
|
55
|
+
path.join(outDir, "types.d.ts"),
|
|
56
|
+
"TypeScript types",
|
|
57
|
+
() => fetchCodegen({ ...queryOpts }).then((r) => r.types),
|
|
58
|
+
countInterfaces,
|
|
59
|
+
));
|
|
60
|
+
|
|
61
|
+
// 2. API client
|
|
62
|
+
results.push(await generateFile(
|
|
63
|
+
path.join(outDir, "api-client.ts"),
|
|
64
|
+
"Typed API client",
|
|
65
|
+
() => fetchCodegen({ ...queryOpts, format: "client" }).then((r) => r.types),
|
|
66
|
+
countFunctions,
|
|
67
|
+
));
|
|
68
|
+
|
|
69
|
+
// 3. Express handler types
|
|
70
|
+
results.push(await generateFile(
|
|
71
|
+
path.join(outDir, "handlers.d.ts"),
|
|
72
|
+
"Express handler types",
|
|
73
|
+
() => fetchCodegen({ ...queryOpts, format: "handlers" }).then((r) => r.types),
|
|
74
|
+
countHandlers,
|
|
75
|
+
));
|
|
76
|
+
|
|
77
|
+
// 4. Zod schemas
|
|
78
|
+
results.push(await generateFile(
|
|
79
|
+
path.join(outDir, "schemas.ts"),
|
|
80
|
+
"Zod schemas",
|
|
81
|
+
() => fetchCodegen({ ...queryOpts, format: "zod" }).then((r) => r.types),
|
|
82
|
+
countSchemas,
|
|
83
|
+
));
|
|
84
|
+
|
|
85
|
+
// 5. React Query hooks
|
|
86
|
+
results.push(await generateFile(
|
|
87
|
+
path.join(outDir, "hooks.ts"),
|
|
88
|
+
"React Query hooks",
|
|
89
|
+
() => fetchCodegen({ ...queryOpts, format: "react-query" }).then((r) => r.types),
|
|
90
|
+
countHooks,
|
|
91
|
+
));
|
|
92
|
+
|
|
93
|
+
// 6. Type guards
|
|
94
|
+
results.push(await generateFile(
|
|
95
|
+
path.join(outDir, "guards.ts"),
|
|
96
|
+
"Type guards",
|
|
97
|
+
() => fetchCodegen({ ...queryOpts, format: "guards" }).then((r) => r.types),
|
|
98
|
+
countGuards,
|
|
99
|
+
));
|
|
100
|
+
|
|
101
|
+
// OpenAPI spec
|
|
102
|
+
results.push(await generateFile(
|
|
103
|
+
path.join(outDir, "openapi.json"),
|
|
104
|
+
"OpenAPI 3.0 spec",
|
|
105
|
+
async () => {
|
|
106
|
+
const spec = await fetchOpenApiSpec({ env: opts.env });
|
|
107
|
+
return JSON.stringify(spec, null, 2);
|
|
108
|
+
},
|
|
109
|
+
countPaths,
|
|
110
|
+
));
|
|
111
|
+
|
|
112
|
+
// API tests
|
|
113
|
+
results.push(await generateFile(
|
|
114
|
+
path.join(outDir, "api.test.ts"),
|
|
115
|
+
"API test scaffolds",
|
|
116
|
+
async () => {
|
|
117
|
+
const { routes } = await fetchMockConfig();
|
|
118
|
+
if (routes.length === 0) return null;
|
|
119
|
+
// Use the testGenCommand's internal logic by fetching via codegen format?
|
|
120
|
+
// Actually, let's generate a simple version directly
|
|
121
|
+
return generateTestContent(routes);
|
|
122
|
+
},
|
|
123
|
+
countTests,
|
|
124
|
+
));
|
|
125
|
+
|
|
126
|
+
// Summary
|
|
127
|
+
console.log("");
|
|
128
|
+
const successCount = results.filter((r) => r.ok).length;
|
|
129
|
+
const skipCount = results.filter((r) => !r.ok).length;
|
|
130
|
+
|
|
131
|
+
for (const r of results) {
|
|
132
|
+
if (r.ok) {
|
|
133
|
+
const countStr = r.count ? chalk.gray(` (${r.count})`) : "";
|
|
134
|
+
console.log(chalk.green(" ✓ ") + chalk.bold(r.file) + countStr);
|
|
135
|
+
} else {
|
|
136
|
+
console.log(chalk.yellow(" ─ ") + chalk.gray(r.file) + chalk.gray(" (skipped — no data)"));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
console.log("");
|
|
141
|
+
if (successCount > 0) {
|
|
142
|
+
console.log(chalk.green(` ${successCount} files generated`) + (skipCount > 0 ? chalk.gray(`, ${skipCount} skipped`) : ""));
|
|
143
|
+
} else {
|
|
144
|
+
console.log(chalk.yellow(" No files generated — instrument your app and make some requests first."));
|
|
145
|
+
}
|
|
146
|
+
console.log("");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function generateFile(
|
|
150
|
+
filePath: string,
|
|
151
|
+
label: string,
|
|
152
|
+
generator: () => Promise<string | null>,
|
|
153
|
+
counter: (content: string) => string | undefined,
|
|
154
|
+
): Promise<ExportResult> {
|
|
155
|
+
const fileName = path.basename(filePath);
|
|
156
|
+
try {
|
|
157
|
+
const content = await generator();
|
|
158
|
+
if (!content || content.includes("No functions found") || content.includes("No API routes found") || content.includes("No observations")) {
|
|
159
|
+
return { file: fileName, label, ok: false };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
fs.writeFileSync(filePath, content, "utf-8");
|
|
163
|
+
const count = counter(content);
|
|
164
|
+
return { file: fileName, label, ok: true, count };
|
|
165
|
+
} catch {
|
|
166
|
+
return { file: fileName, label, ok: false };
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function countInterfaces(content: string): string | undefined {
|
|
171
|
+
const count = (content.match(/export (interface|type) /g) || []).length;
|
|
172
|
+
return count > 0 ? `${count} types` : undefined;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function countFunctions(content: string): string | undefined {
|
|
176
|
+
if (content.includes("createTrickleClient")) return "client factory";
|
|
177
|
+
return undefined;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function countHandlers(content: string): string | undefined {
|
|
181
|
+
const count = (content.match(/export type \w+Handler/g) || []).length;
|
|
182
|
+
return count > 0 ? `${count} handlers` : undefined;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function countSchemas(content: string): string | undefined {
|
|
186
|
+
const count = (content.match(/Schema = /g) || []).length;
|
|
187
|
+
return count > 0 ? `${count} schemas` : undefined;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function countHooks(content: string): string | undefined {
|
|
191
|
+
const count = (content.match(/export function use\w+/g) || []).length;
|
|
192
|
+
return count > 0 ? `${count} hooks` : undefined;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function countPaths(content: string): string | undefined {
|
|
196
|
+
try {
|
|
197
|
+
const spec = JSON.parse(content);
|
|
198
|
+
const paths = Object.keys(spec.paths || {}).length;
|
|
199
|
+
return paths > 0 ? `${paths} paths` : undefined;
|
|
200
|
+
} catch {
|
|
201
|
+
return undefined;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function countTests(content: string): string | undefined {
|
|
206
|
+
const count = (content.match(/it\("/g) || []).length;
|
|
207
|
+
return count > 0 ? `${count} tests` : undefined;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function countGuards(content: string): string | undefined {
|
|
211
|
+
const count = (content.match(/export function is\w+/g) || []).length;
|
|
212
|
+
return count > 0 ? `${count} guards` : undefined;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Simplified test generation (reuses the same logic as test-gen but inline)
|
|
216
|
+
interface MockRoute {
|
|
217
|
+
method: string;
|
|
218
|
+
path: string;
|
|
219
|
+
functionName: string;
|
|
220
|
+
sampleInput: unknown;
|
|
221
|
+
sampleOutput: unknown;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function generateTestContent(routes: MockRoute[]): string {
|
|
225
|
+
const lines: string[] = [];
|
|
226
|
+
lines.push("// Auto-generated API tests by trickle");
|
|
227
|
+
lines.push(`// Generated at ${new Date().toISOString()}`);
|
|
228
|
+
lines.push("// Do not edit manually — re-run `trickle export` to update");
|
|
229
|
+
lines.push("");
|
|
230
|
+
lines.push('import { describe, it, expect } from "vitest";');
|
|
231
|
+
lines.push("");
|
|
232
|
+
lines.push('const BASE_URL = process.env.TEST_API_URL || "http://localhost:3000";');
|
|
233
|
+
lines.push("");
|
|
234
|
+
|
|
235
|
+
// Group by resource
|
|
236
|
+
const groups: Record<string, MockRoute[]> = {};
|
|
237
|
+
for (const r of routes) {
|
|
238
|
+
const parts = r.path.split("/").filter(Boolean);
|
|
239
|
+
const resource = parts[0] === "api" && parts.length >= 2 ? `/api/${parts[1]}` : `/${parts[0] || "root"}`;
|
|
240
|
+
if (!groups[resource]) groups[resource] = [];
|
|
241
|
+
groups[resource].push(r);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
for (const [resource, resourceRoutes] of Object.entries(groups)) {
|
|
245
|
+
lines.push(`describe("${resource}", () => {`);
|
|
246
|
+
for (const route of resourceRoutes) {
|
|
247
|
+
const hasBody = ["POST", "PUT", "PATCH"].includes(route.method);
|
|
248
|
+
const fetchPath = route.path.replace(/:(\w+)/g, "test-$1");
|
|
249
|
+
|
|
250
|
+
lines.push(` it("${route.method} ${route.path} — returns expected shape", async () => {`);
|
|
251
|
+
lines.push(` const res = await fetch(\`\${BASE_URL}${fetchPath}\`, {`);
|
|
252
|
+
lines.push(` method: "${route.method}",`);
|
|
253
|
+
if (hasBody && route.sampleInput) {
|
|
254
|
+
const body = typeof route.sampleInput === "object" && route.sampleInput !== null
|
|
255
|
+
? (route.sampleInput as Record<string, unknown>).body || route.sampleInput
|
|
256
|
+
: route.sampleInput;
|
|
257
|
+
if (body && typeof body === "object" && Object.keys(body as object).length > 0) {
|
|
258
|
+
lines.push(` headers: { "Content-Type": "application/json" },`);
|
|
259
|
+
lines.push(` body: JSON.stringify(${JSON.stringify(body)}),`);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
lines.push(" });");
|
|
263
|
+
lines.push(" expect(res.ok).toBe(true);");
|
|
264
|
+
|
|
265
|
+
if (route.sampleOutput && typeof route.sampleOutput === "object") {
|
|
266
|
+
lines.push(" const body = await res.json();");
|
|
267
|
+
for (const [key, value] of Object.entries(route.sampleOutput as Record<string, unknown>)) {
|
|
268
|
+
if (Array.isArray(value)) {
|
|
269
|
+
lines.push(` expect(Array.isArray(body.${key})).toBe(true);`);
|
|
270
|
+
} else if (typeof value === "string") {
|
|
271
|
+
lines.push(` expect(typeof body.${key}).toBe("string");`);
|
|
272
|
+
} else if (typeof value === "number") {
|
|
273
|
+
lines.push(` expect(typeof body.${key}).toBe("number");`);
|
|
274
|
+
} else if (typeof value === "boolean") {
|
|
275
|
+
lines.push(` expect(typeof body.${key}).toBe("boolean");`);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
lines.push(" });");
|
|
280
|
+
lines.push("");
|
|
281
|
+
}
|
|
282
|
+
lines.push("});");
|
|
283
|
+
lines.push("");
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return lines.join("\n").trimEnd() + "\n";
|
|
287
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import Table from "cli-table3";
|
|
3
|
+
import { listFunctions } from "../api-client";
|
|
4
|
+
import { envBadge, langBadge, timeBadge } from "../ui/badges";
|
|
5
|
+
import { relativeTime } from "../ui/helpers";
|
|
6
|
+
|
|
7
|
+
export interface FunctionsOptions {
|
|
8
|
+
env?: string;
|
|
9
|
+
lang?: string;
|
|
10
|
+
search?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function functionsCommand(opts: FunctionsOptions): Promise<void> {
|
|
14
|
+
const result = await listFunctions({
|
|
15
|
+
env: opts.env,
|
|
16
|
+
language: opts.lang,
|
|
17
|
+
search: opts.search,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const { functions } = result;
|
|
21
|
+
|
|
22
|
+
if (functions.length === 0) {
|
|
23
|
+
console.log(chalk.yellow("\n No functions found.\n"));
|
|
24
|
+
if (opts.env || opts.lang || opts.search) {
|
|
25
|
+
console.log(chalk.gray(" Try adjusting your filters.\n"));
|
|
26
|
+
}
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
console.log("");
|
|
31
|
+
|
|
32
|
+
const table = new Table({
|
|
33
|
+
head: [
|
|
34
|
+
chalk.cyan.bold("Name"),
|
|
35
|
+
chalk.cyan.bold("Module"),
|
|
36
|
+
chalk.cyan.bold("Language"),
|
|
37
|
+
chalk.cyan.bold("Environment"),
|
|
38
|
+
chalk.cyan.bold("Last Seen"),
|
|
39
|
+
],
|
|
40
|
+
style: {
|
|
41
|
+
head: [],
|
|
42
|
+
border: ["gray"],
|
|
43
|
+
},
|
|
44
|
+
chars: {
|
|
45
|
+
top: "─",
|
|
46
|
+
"top-mid": "┬",
|
|
47
|
+
"top-left": "┌",
|
|
48
|
+
"top-right": "┐",
|
|
49
|
+
bottom: "─",
|
|
50
|
+
"bottom-mid": "┴",
|
|
51
|
+
"bottom-left": "└",
|
|
52
|
+
"bottom-right": "┘",
|
|
53
|
+
left: "│",
|
|
54
|
+
"left-mid": "├",
|
|
55
|
+
mid: "─",
|
|
56
|
+
"mid-mid": "┼",
|
|
57
|
+
right: "│",
|
|
58
|
+
"right-mid": "┤",
|
|
59
|
+
middle: "│",
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
for (const fn of functions) {
|
|
64
|
+
table.push([
|
|
65
|
+
chalk.white.bold(fn.function_name),
|
|
66
|
+
chalk.gray(fn.module),
|
|
67
|
+
langBadge(fn.language),
|
|
68
|
+
envBadge(fn.environment),
|
|
69
|
+
timeBadge(fn.last_seen_at),
|
|
70
|
+
]);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
console.log(table.toString());
|
|
74
|
+
console.log(
|
|
75
|
+
chalk.gray(`\n Showing ${chalk.white.bold(String(functions.length))} functions`) +
|
|
76
|
+
(result.total > functions.length
|
|
77
|
+
? chalk.gray(` of ${result.total} total`)
|
|
78
|
+
: "") +
|
|
79
|
+
"\n"
|
|
80
|
+
);
|
|
81
|
+
}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as crypto from "crypto";
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
import { getBackendUrl } from "../config";
|
|
5
|
+
|
|
6
|
+
export interface InferOptions {
|
|
7
|
+
name: string;
|
|
8
|
+
env?: string;
|
|
9
|
+
module?: string;
|
|
10
|
+
requestBody?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface TypeNode {
|
|
14
|
+
kind: string;
|
|
15
|
+
[key: string]: unknown;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* `trickle infer <file>` — Infer types from a JSON file or stdin and store them.
|
|
20
|
+
*
|
|
21
|
+
* Reads JSON from a file (or stdin if file is "-" or omitted with piped input),
|
|
22
|
+
* infers TypeNode from the data, and sends the observation to the trickle backend.
|
|
23
|
+
* Works offline with saved API responses, test fixtures, or piped command output.
|
|
24
|
+
*/
|
|
25
|
+
export async function inferCommand(
|
|
26
|
+
file: string | undefined,
|
|
27
|
+
opts: InferOptions,
|
|
28
|
+
): Promise<void> {
|
|
29
|
+
const backendUrl = getBackendUrl();
|
|
30
|
+
|
|
31
|
+
// Check backend connectivity
|
|
32
|
+
try {
|
|
33
|
+
const res = await fetch(`${backendUrl}/api/health`, { signal: AbortSignal.timeout(3000) });
|
|
34
|
+
if (!res.ok) throw new Error("not ok");
|
|
35
|
+
} catch {
|
|
36
|
+
console.error(chalk.red(`\n Cannot reach trickle backend at ${chalk.bold(backendUrl)}\n`));
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Read JSON input
|
|
41
|
+
let jsonText: string;
|
|
42
|
+
let sourceName: string;
|
|
43
|
+
|
|
44
|
+
if (!file || file === "-") {
|
|
45
|
+
// Read from stdin
|
|
46
|
+
if (process.stdin.isTTY) {
|
|
47
|
+
console.error(chalk.red("\n No input provided."));
|
|
48
|
+
console.error(chalk.gray(" Pipe JSON via stdin or provide a file path:\n"));
|
|
49
|
+
console.error(chalk.gray(' echo \'{"key":"value"}\' | trickle infer --name "GET /api/data"'));
|
|
50
|
+
console.error(chalk.gray(' trickle infer response.json --name "GET /api/data"\n'));
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
jsonText = await readStdin();
|
|
54
|
+
sourceName = "stdin";
|
|
55
|
+
} else {
|
|
56
|
+
// Read from file
|
|
57
|
+
if (!fs.existsSync(file)) {
|
|
58
|
+
console.error(chalk.red(`\n File not found: ${file}\n`));
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
61
|
+
jsonText = fs.readFileSync(file, "utf-8");
|
|
62
|
+
sourceName = file;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Parse JSON
|
|
66
|
+
let jsonData: unknown;
|
|
67
|
+
try {
|
|
68
|
+
jsonData = JSON.parse(jsonText.trim());
|
|
69
|
+
} catch {
|
|
70
|
+
console.error(chalk.red("\n Input is not valid JSON.\n"));
|
|
71
|
+
process.exit(1);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Build type observations
|
|
75
|
+
const functionName = opts.name;
|
|
76
|
+
const returnType = jsonToTypeNode(jsonData);
|
|
77
|
+
|
|
78
|
+
const argsProperties: Record<string, TypeNode> = {};
|
|
79
|
+
|
|
80
|
+
// Parse request body example if provided
|
|
81
|
+
if (opts.requestBody) {
|
|
82
|
+
try {
|
|
83
|
+
const reqJson = JSON.parse(opts.requestBody);
|
|
84
|
+
argsProperties.body = jsonToTypeNode(reqJson);
|
|
85
|
+
} catch {
|
|
86
|
+
console.error(chalk.red("\n --request-body is not valid JSON.\n"));
|
|
87
|
+
process.exit(1);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const argsType: TypeNode = Object.keys(argsProperties).length > 0
|
|
92
|
+
? { kind: "object", properties: argsProperties }
|
|
93
|
+
: { kind: "object", properties: {} };
|
|
94
|
+
|
|
95
|
+
const typeHash = computeTypeHash(argsType, returnType);
|
|
96
|
+
|
|
97
|
+
const payload = {
|
|
98
|
+
functionName,
|
|
99
|
+
module: opts.module || "infer",
|
|
100
|
+
language: "js",
|
|
101
|
+
environment: opts.env || "development",
|
|
102
|
+
typeHash,
|
|
103
|
+
argsType,
|
|
104
|
+
returnType,
|
|
105
|
+
sampleInput: Object.keys(argsProperties).length > 0 ? argsProperties : undefined,
|
|
106
|
+
sampleOutput: jsonData,
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
// Send to backend
|
|
110
|
+
try {
|
|
111
|
+
const res = await fetch(`${backendUrl}/api/ingest`, {
|
|
112
|
+
method: "POST",
|
|
113
|
+
headers: { "Content-Type": "application/json" },
|
|
114
|
+
body: JSON.stringify(payload),
|
|
115
|
+
signal: AbortSignal.timeout(5000),
|
|
116
|
+
});
|
|
117
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
118
|
+
} catch (err: unknown) {
|
|
119
|
+
const msg = err instanceof Error ? err.message : "Unknown error";
|
|
120
|
+
console.error(chalk.red(`\n Failed to send types to backend: ${msg}\n`));
|
|
121
|
+
process.exit(1);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
console.log("");
|
|
125
|
+
console.log(chalk.bold(" trickle infer"));
|
|
126
|
+
console.log(chalk.gray(" " + "─".repeat(50)));
|
|
127
|
+
console.log(chalk.gray(` Source: `) + chalk.white(sourceName));
|
|
128
|
+
console.log(chalk.gray(` Name: `) + chalk.white(functionName));
|
|
129
|
+
console.log(chalk.gray(` Backend: `) + chalk.white(backendUrl));
|
|
130
|
+
console.log(chalk.gray(" " + "─".repeat(50)));
|
|
131
|
+
console.log(chalk.green(" Types inferred and stored successfully!"));
|
|
132
|
+
|
|
133
|
+
// Show a preview of the inferred shape
|
|
134
|
+
const shape = describeShape(returnType, 1);
|
|
135
|
+
console.log("");
|
|
136
|
+
console.log(chalk.gray(" Inferred shape:"));
|
|
137
|
+
for (const line of shape) {
|
|
138
|
+
console.log(chalk.gray(" ") + line);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (opts.requestBody) {
|
|
142
|
+
const reqShape = describeShape(argsProperties.body, 1);
|
|
143
|
+
console.log("");
|
|
144
|
+
console.log(chalk.gray(" Request body shape:"));
|
|
145
|
+
for (const line of reqShape) {
|
|
146
|
+
console.log(chalk.gray(" ") + line);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
console.log("");
|
|
151
|
+
console.log(chalk.gray(" Run ") + chalk.white("trickle codegen") + chalk.gray(" to generate type definitions."));
|
|
152
|
+
console.log("");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function readStdin(): Promise<string> {
|
|
156
|
+
return new Promise((resolve, reject) => {
|
|
157
|
+
let data = "";
|
|
158
|
+
process.stdin.setEncoding("utf-8");
|
|
159
|
+
process.stdin.on("data", (chunk) => { data += chunk; });
|
|
160
|
+
process.stdin.on("end", () => resolve(data));
|
|
161
|
+
process.stdin.on("error", reject);
|
|
162
|
+
// Timeout after 10 seconds
|
|
163
|
+
setTimeout(() => {
|
|
164
|
+
if (data.length === 0) {
|
|
165
|
+
reject(new Error("No input received from stdin"));
|
|
166
|
+
} else {
|
|
167
|
+
resolve(data);
|
|
168
|
+
}
|
|
169
|
+
}, 10000);
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function jsonToTypeNode(value: unknown): TypeNode {
|
|
174
|
+
if (value === null) return { kind: "primitive", name: "null" };
|
|
175
|
+
if (value === undefined) return { kind: "primitive", name: "undefined" };
|
|
176
|
+
|
|
177
|
+
switch (typeof value) {
|
|
178
|
+
case "string": return { kind: "primitive", name: "string" };
|
|
179
|
+
case "number": return { kind: "primitive", name: "number" };
|
|
180
|
+
case "boolean": return { kind: "primitive", name: "boolean" };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (Array.isArray(value)) {
|
|
184
|
+
if (value.length === 0) return { kind: "array", element: { kind: "unknown" } };
|
|
185
|
+
return { kind: "array", element: jsonToTypeNode(value[0]) };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const obj = value as Record<string, unknown>;
|
|
189
|
+
const properties: Record<string, TypeNode> = {};
|
|
190
|
+
for (const [key, val] of Object.entries(obj)) {
|
|
191
|
+
properties[key] = jsonToTypeNode(val);
|
|
192
|
+
}
|
|
193
|
+
return { kind: "object", properties };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function computeTypeHash(argsType: TypeNode, returnType: TypeNode): string {
|
|
197
|
+
const data = JSON.stringify({ a: argsType, r: returnType });
|
|
198
|
+
return crypto.createHash("sha256").update(data).digest("hex").slice(0, 16);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Describe a TypeNode shape as human-readable lines.
|
|
203
|
+
*/
|
|
204
|
+
function describeShape(node: TypeNode, maxDepth: number, depth: number = 0): string[] {
|
|
205
|
+
const indent = " ".repeat(depth);
|
|
206
|
+
const lines: string[] = [];
|
|
207
|
+
|
|
208
|
+
if (node.kind === "primitive") {
|
|
209
|
+
lines.push(indent + chalk.cyan(node.name as string));
|
|
210
|
+
} else if (node.kind === "array") {
|
|
211
|
+
const element = node.element as TypeNode;
|
|
212
|
+
if (element.kind === "object" && depth < maxDepth) {
|
|
213
|
+
lines.push(indent + chalk.yellow("Array<{"));
|
|
214
|
+
const subLines = describeShape(element, maxDepth, depth + 1);
|
|
215
|
+
lines.push(...subLines);
|
|
216
|
+
lines.push(indent + chalk.yellow("}>"));
|
|
217
|
+
} else {
|
|
218
|
+
lines.push(indent + chalk.yellow(`${describeTypeCompact(element)}[]`));
|
|
219
|
+
}
|
|
220
|
+
} else if (node.kind === "object") {
|
|
221
|
+
const props = node.properties as Record<string, TypeNode>;
|
|
222
|
+
const keys = Object.keys(props);
|
|
223
|
+
if (keys.length === 0) {
|
|
224
|
+
lines.push(indent + chalk.gray("{}"));
|
|
225
|
+
} else {
|
|
226
|
+
for (const key of keys) {
|
|
227
|
+
const propType = props[key];
|
|
228
|
+
const typeStr = describeTypeCompact(propType);
|
|
229
|
+
lines.push(indent + chalk.white(key) + chalk.gray(": ") + chalk.cyan(typeStr));
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
} else {
|
|
233
|
+
lines.push(indent + chalk.gray(node.kind));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return lines;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function describeTypeCompact(node: TypeNode): string {
|
|
240
|
+
switch (node.kind) {
|
|
241
|
+
case "primitive":
|
|
242
|
+
return node.name as string;
|
|
243
|
+
case "array": {
|
|
244
|
+
const element = node.element as TypeNode;
|
|
245
|
+
return `${describeTypeCompact(element)}[]`;
|
|
246
|
+
}
|
|
247
|
+
case "object": {
|
|
248
|
+
const props = node.properties as Record<string, TypeNode>;
|
|
249
|
+
const keys = Object.keys(props);
|
|
250
|
+
if (keys.length === 0) return "{}";
|
|
251
|
+
if (keys.length <= 3) {
|
|
252
|
+
const inner = keys.map((k) => `${k}: ${describeTypeCompact(props[k])}`).join(", ");
|
|
253
|
+
return `{ ${inner} }`;
|
|
254
|
+
}
|
|
255
|
+
return `{ ${keys.slice(0, 2).map((k) => `${k}: ${describeTypeCompact(props[k])}`).join(", ")}, ... }`;
|
|
256
|
+
}
|
|
257
|
+
default:
|
|
258
|
+
return node.kind;
|
|
259
|
+
}
|
|
260
|
+
}
|