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,259 @@
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 SampleOptions {
7
+ format?: string;
8
+ out?: string;
9
+ route?: string;
10
+ }
11
+
12
+ /**
13
+ * `trickle sample` — Generate test fixtures from observed runtime data.
14
+ *
15
+ * Produces JSON, TypeScript constants, or factory functions from the
16
+ * actual sample inputs and outputs captured by trickle. Great for tests,
17
+ * seed scripts, and Storybook data.
18
+ */
19
+ export async function sampleCommand(routeFilter: string | undefined, opts: SampleOptions): Promise<void> {
20
+ let routes: MockRoute[];
21
+ try {
22
+ const config = await fetchMockConfig();
23
+ routes = config.routes;
24
+ } catch {
25
+ console.error(chalk.red("\n Cannot connect to trickle backend."));
26
+ console.error(chalk.gray(" Is the backend running?\n"));
27
+ process.exit(1);
28
+ }
29
+
30
+ // Filter routes
31
+ if (routeFilter) {
32
+ const filter = routeFilter.toLowerCase();
33
+ routes = routes.filter((r) =>
34
+ r.functionName.toLowerCase().includes(filter) ||
35
+ r.path.toLowerCase().includes(filter),
36
+ );
37
+ }
38
+
39
+ // Only include routes with sample data
40
+ routes = routes.filter((r) => r.sampleOutput !== null && r.sampleOutput !== undefined);
41
+
42
+ if (routes.length === 0) {
43
+ console.error(chalk.yellow("\n No sample data found."));
44
+ if (routeFilter) {
45
+ console.error(chalk.gray(` No routes matching "${routeFilter}" with sample data.\n`));
46
+ } else {
47
+ console.error(chalk.gray(" Instrument your app and make some requests first.\n"));
48
+ }
49
+ process.exit(0);
50
+ }
51
+
52
+ const format = (opts.format || "json").toLowerCase();
53
+ let output: string;
54
+
55
+ switch (format) {
56
+ case "json":
57
+ output = generateJson(routes);
58
+ break;
59
+ case "ts":
60
+ case "typescript":
61
+ output = generateTypeScript(routes);
62
+ break;
63
+ case "factory":
64
+ output = generateFactories(routes);
65
+ break;
66
+ default:
67
+ console.error(chalk.red(`\n Unknown format: ${format}`));
68
+ console.error(chalk.gray(" Supported: json, ts, factory\n"));
69
+ process.exit(1);
70
+ }
71
+
72
+ if (opts.out) {
73
+ const resolvedPath = path.resolve(opts.out);
74
+ const dir = path.dirname(resolvedPath);
75
+ if (!fs.existsSync(dir)) {
76
+ fs.mkdirSync(dir, { recursive: true });
77
+ }
78
+ fs.writeFileSync(resolvedPath, output, "utf-8");
79
+ console.log(chalk.green(`\n Fixtures written to ${chalk.bold(opts.out)}`));
80
+ console.log(chalk.gray(` ${routes.length} routes, format: ${format}\n`));
81
+ } else {
82
+ console.log(output);
83
+ }
84
+ }
85
+
86
+ function toPascalCase(name: string): string {
87
+ return name
88
+ .replace(/[^a-zA-Z0-9]+/g, " ")
89
+ .split(/\s+/)
90
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
91
+ .join("");
92
+ }
93
+
94
+ function toCamelCase(name: string): string {
95
+ const pascal = toPascalCase(name);
96
+ return pascal.charAt(0).toLowerCase() + pascal.slice(1);
97
+ }
98
+
99
+ // ── JSON format ──
100
+
101
+ function generateJson(routes: MockRoute[]): string {
102
+ const samples: Record<string, { request?: unknown; response: unknown }> = {};
103
+
104
+ for (const route of routes) {
105
+ const key = `${route.method} ${route.path}`;
106
+ const entry: { request?: unknown; response: unknown } = {
107
+ response: route.sampleOutput,
108
+ };
109
+
110
+ if (route.sampleInput) {
111
+ const input = route.sampleInput as Record<string, unknown>;
112
+ const body = input.body || input;
113
+ if (body && typeof body === "object" && Object.keys(body as object).length > 0) {
114
+ entry.request = body;
115
+ }
116
+ }
117
+
118
+ samples[key] = entry;
119
+ }
120
+
121
+ return JSON.stringify(samples, null, 2) + "\n";
122
+ }
123
+
124
+ // ── TypeScript constants ──
125
+
126
+ function generateTypeScript(routes: MockRoute[]): string {
127
+ const lines: string[] = [];
128
+ lines.push("// Auto-generated test fixtures by trickle");
129
+ lines.push(`// Generated at ${new Date().toISOString()}`);
130
+ lines.push("// Do not edit manually — re-run `trickle sample --format ts` to update");
131
+ lines.push("");
132
+
133
+ for (const route of routes) {
134
+ const varName = toCamelCase(route.functionName);
135
+
136
+ // Response sample
137
+ lines.push(`/** Sample response for ${route.method} ${route.path} */`);
138
+ lines.push(`export const ${varName}Response = ${formatValue(route.sampleOutput, 0)} as const;`);
139
+ lines.push("");
140
+
141
+ // Request body sample (for POST/PUT/PATCH)
142
+ if (["POST", "PUT", "PATCH"].includes(route.method) && route.sampleInput) {
143
+ const input = route.sampleInput as Record<string, unknown>;
144
+ const body = input.body || input;
145
+ if (body && typeof body === "object" && Object.keys(body as object).length > 0) {
146
+ lines.push(`/** Sample request body for ${route.method} ${route.path} */`);
147
+ lines.push(`export const ${varName}Request = ${formatValue(body, 0)} as const;`);
148
+ lines.push("");
149
+ }
150
+ }
151
+ }
152
+
153
+ return lines.join("\n").trimEnd() + "\n";
154
+ }
155
+
156
+ // ── Factory functions ──
157
+
158
+ function generateFactories(routes: MockRoute[]): string {
159
+ const lines: string[] = [];
160
+ lines.push("// Auto-generated test fixture factories by trickle");
161
+ lines.push(`// Generated at ${new Date().toISOString()}`);
162
+ lines.push("// Do not edit manually — re-run `trickle sample --format factory` to update");
163
+ lines.push("");
164
+
165
+ // First emit the base samples as constants
166
+ for (const route of routes) {
167
+ const varName = toCamelCase(route.functionName);
168
+
169
+ lines.push(`const _${varName}Response = ${formatValue(route.sampleOutput, 0)};`);
170
+ lines.push("");
171
+
172
+ if (["POST", "PUT", "PATCH"].includes(route.method) && route.sampleInput) {
173
+ const input = route.sampleInput as Record<string, unknown>;
174
+ const body = input.body || input;
175
+ if (body && typeof body === "object" && Object.keys(body as object).length > 0) {
176
+ lines.push(`const _${varName}Request = ${formatValue(body, 0)};`);
177
+ lines.push("");
178
+ }
179
+ }
180
+ }
181
+
182
+ // Then emit factory functions
183
+ for (const route of routes) {
184
+ const varName = toCamelCase(route.functionName);
185
+ const typeName = toPascalCase(route.functionName);
186
+
187
+ // Response factory
188
+ if (route.sampleOutput && typeof route.sampleOutput === "object" && !Array.isArray(route.sampleOutput)) {
189
+ lines.push(`/** Create a test fixture for ${route.method} ${route.path} response */`);
190
+ lines.push(`export function create${typeName}Response(overrides?: Partial<typeof _${varName}Response>): typeof _${varName}Response {`);
191
+ lines.push(` return { ..._${varName}Response, ...overrides };`);
192
+ lines.push(`}`);
193
+ lines.push("");
194
+ } else {
195
+ // For non-object responses (arrays, primitives), just export the constant
196
+ lines.push(`/** Sample response for ${route.method} ${route.path} */`);
197
+ lines.push(`export const ${varName}Response = _${varName}Response;`);
198
+ lines.push("");
199
+ }
200
+
201
+ // Request body factory
202
+ if (["POST", "PUT", "PATCH"].includes(route.method) && route.sampleInput) {
203
+ const input = route.sampleInput as Record<string, unknown>;
204
+ const body = input.body || input;
205
+ if (body && typeof body === "object" && !Array.isArray(body) && Object.keys(body as object).length > 0) {
206
+ lines.push(`/** Create a test fixture for ${route.method} ${route.path} request body */`);
207
+ lines.push(`export function create${typeName}Request(overrides?: Partial<typeof _${varName}Request>): typeof _${varName}Request {`);
208
+ lines.push(` return { ..._${varName}Request, ...overrides };`);
209
+ lines.push(`}`);
210
+ lines.push("");
211
+ }
212
+ }
213
+ }
214
+
215
+ return lines.join("\n").trimEnd() + "\n";
216
+ }
217
+
218
+ // ── Value formatting ──
219
+
220
+ function formatValue(value: unknown, indent: number): string {
221
+ const pad = " ".repeat(indent);
222
+ const innerPad = " ".repeat(indent + 1);
223
+
224
+ if (value === null) return "null";
225
+ if (value === undefined) return "undefined";
226
+
227
+ switch (typeof value) {
228
+ case "string":
229
+ return JSON.stringify(value);
230
+ case "number":
231
+ case "boolean":
232
+ return String(value);
233
+ }
234
+
235
+ if (Array.isArray(value)) {
236
+ if (value.length === 0) return "[]";
237
+ if (value.length === 1 && typeof value[0] !== "object") {
238
+ return `[${formatValue(value[0], 0)}]`;
239
+ }
240
+ const items = value.map((v) => `${innerPad}${formatValue(v, indent + 1)}`);
241
+ return `[\n${items.join(",\n")}\n${pad}]`;
242
+ }
243
+
244
+ if (typeof value === "object") {
245
+ const obj = value as Record<string, unknown>;
246
+ const keys = Object.keys(obj);
247
+ if (keys.length === 0) return "{}";
248
+
249
+ const entries = keys.map((key) => {
250
+ const formattedVal = formatValue(obj[key], indent + 1);
251
+ // Use identifier-safe keys without quotes, quoted otherwise
252
+ const safeKey = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key) ? key : JSON.stringify(key);
253
+ return `${innerPad}${safeKey}: ${formattedVal}`;
254
+ });
255
+ return `{\n${entries.join(",\n")}\n${pad}}`;
256
+ }
257
+
258
+ return JSON.stringify(value);
259
+ }
@@ -0,0 +1,107 @@
1
+ import chalk from "chalk";
2
+ import { getBackendUrl } from "../config";
3
+
4
+ export interface SearchOptions {
5
+ env?: string;
6
+ json?: boolean;
7
+ }
8
+
9
+ interface FieldMatch {
10
+ path: string;
11
+ kind: string;
12
+ typeName?: string;
13
+ }
14
+
15
+ interface SearchResult {
16
+ functionName: string;
17
+ module: string;
18
+ environment: string;
19
+ lastSeen: string;
20
+ matches: FieldMatch[];
21
+ }
22
+
23
+ interface SearchResponse {
24
+ query: string;
25
+ total: number;
26
+ results: SearchResult[];
27
+ }
28
+
29
+ export async function searchCommand(
30
+ query: string,
31
+ opts: SearchOptions,
32
+ ): Promise<void> {
33
+ const backendUrl = getBackendUrl();
34
+ const url = new URL("/api/search", backendUrl);
35
+ url.searchParams.set("q", query);
36
+ if (opts.env) {
37
+ url.searchParams.set("env", opts.env);
38
+ }
39
+
40
+ let data: SearchResponse;
41
+ try {
42
+ const res = await fetch(url.toString());
43
+ if (!res.ok) {
44
+ const body = await res.text();
45
+ throw new Error(`HTTP ${res.status}: ${body}`);
46
+ }
47
+ data = await res.json() as SearchResponse;
48
+ } catch (err: unknown) {
49
+ if (err instanceof Error && err.message.startsWith("HTTP ")) {
50
+ console.error(chalk.red(`\n Error: ${err.message}\n`));
51
+ } else {
52
+ console.error(chalk.red(`\n Cannot connect to trickle backend at ${chalk.bold(backendUrl)}.`));
53
+ console.error(chalk.red(" Is the backend running?\n"));
54
+ }
55
+ process.exit(1);
56
+ }
57
+
58
+ if (opts.json) {
59
+ console.log(JSON.stringify(data, null, 2));
60
+ return;
61
+ }
62
+
63
+ console.log("");
64
+ console.log(chalk.bold(` Search: "${query}"`));
65
+ console.log(chalk.gray(" " + "─".repeat(50)));
66
+
67
+ if (data.total === 0) {
68
+ console.log(chalk.gray(" No matches found.\n"));
69
+ return;
70
+ }
71
+
72
+ console.log(chalk.gray(` ${data.total} function${data.total === 1 ? "" : "s"} matched\n`));
73
+
74
+ for (const result of data.results) {
75
+ // Function name with method coloring
76
+ const fnName = result.functionName;
77
+ const methodMatch = fnName.match(/^(GET|POST|PUT|PATCH|DELETE)\s/);
78
+ if (methodMatch) {
79
+ const method = methodMatch[1];
80
+ const rest = fnName.slice(method.length);
81
+ const methodColors: Record<string, (s: string) => string> = {
82
+ GET: chalk.green,
83
+ POST: chalk.yellow,
84
+ PUT: chalk.blue,
85
+ PATCH: chalk.cyan,
86
+ DELETE: chalk.red,
87
+ };
88
+ const colorFn = methodColors[method] || chalk.white;
89
+ console.log(` ${colorFn(chalk.bold(method))}${chalk.white(rest)}`);
90
+ } else {
91
+ console.log(` ${chalk.white(chalk.bold(fnName))}`);
92
+ }
93
+
94
+ console.log(chalk.gray(` module: ${result.module} env: ${result.environment}`));
95
+
96
+ // Show matching fields
97
+ for (const match of result.matches) {
98
+ const typeStr = match.typeName ? chalk.cyan(match.typeName) : chalk.gray(match.kind);
99
+ if (match.kind === "name") {
100
+ console.log(chalk.gray(" → ") + chalk.yellow("function name match"));
101
+ } else {
102
+ console.log(chalk.gray(" → ") + chalk.white(match.path) + chalk.gray(": ") + typeStr);
103
+ }
104
+ }
105
+ console.log("");
106
+ }
107
+ }
@@ -0,0 +1,211 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import chalk from "chalk";
4
+ import { fetchStubs } from "../api-client";
5
+
6
+ export interface StubsOptions {
7
+ env?: string;
8
+ dryRun?: boolean;
9
+ silent?: boolean;
10
+ }
11
+
12
+ /**
13
+ * Map of file extensions to their stub extension.
14
+ */
15
+ const JS_EXTS = new Set([".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx"]);
16
+ const PY_EXTS = new Set([".py"]);
17
+
18
+ /**
19
+ * Normalize a module name for matching against file stems.
20
+ * Module names from trickle may use dashes or underscores.
21
+ * File stems use whatever the OS has.
22
+ */
23
+ function normalizeForMatch(name: string): string {
24
+ return name.replace(/[-_]/g, "").toLowerCase();
25
+ }
26
+
27
+ /**
28
+ * Recursively find all source files in a directory.
29
+ */
30
+ function findSourceFiles(dir: string): string[] {
31
+ const results: string[] = [];
32
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
33
+
34
+ for (const entry of entries) {
35
+ const fullPath = path.join(dir, entry.name);
36
+
37
+ if (entry.isDirectory()) {
38
+ // Skip node_modules, __pycache__, .git, etc.
39
+ if (
40
+ entry.name === "node_modules" ||
41
+ entry.name === "__pycache__" ||
42
+ entry.name === ".git" ||
43
+ entry.name === "dist" ||
44
+ entry.name === "build" ||
45
+ entry.name === ".trickle"
46
+ ) {
47
+ continue;
48
+ }
49
+ results.push(...findSourceFiles(fullPath));
50
+ } else if (entry.isFile()) {
51
+ const ext = path.extname(entry.name).toLowerCase();
52
+ if (JS_EXTS.has(ext) || PY_EXTS.has(ext)) {
53
+ results.push(fullPath);
54
+ }
55
+ }
56
+ }
57
+
58
+ return results;
59
+ }
60
+
61
+ export async function stubsCommand(
62
+ dir: string,
63
+ opts: StubsOptions,
64
+ ): Promise<void> {
65
+ const targetDir = path.resolve(dir);
66
+
67
+ if (!fs.existsSync(targetDir)) {
68
+ console.error(chalk.red(`\n Directory not found: ${targetDir}\n`));
69
+ process.exit(1);
70
+ }
71
+
72
+ if (!fs.statSync(targetDir).isDirectory()) {
73
+ console.error(chalk.red(`\n Not a directory: ${targetDir}\n`));
74
+ process.exit(1);
75
+ }
76
+
77
+ // Fetch per-module stubs from backend
78
+ const { stubs } = await fetchStubs({ env: opts.env });
79
+
80
+ if (!stubs || Object.keys(stubs).length === 0) {
81
+ if (!opts.silent) {
82
+ console.log(
83
+ chalk.yellow(
84
+ "\n No observed types found. Run your code with trickle first.\n",
85
+ ),
86
+ );
87
+ }
88
+ return;
89
+ }
90
+
91
+ // Find all source files in the target directory
92
+ const sourceFiles = findSourceFiles(targetDir);
93
+
94
+ if (sourceFiles.length === 0) {
95
+ if (!opts.silent) {
96
+ console.log(
97
+ chalk.yellow(`\n No source files found in ${targetDir}\n`),
98
+ );
99
+ }
100
+ return;
101
+ }
102
+
103
+ // Build a map: normalized stem → source file path
104
+ const fileMap: Map<string, string[]> = new Map();
105
+ for (const filePath of sourceFiles) {
106
+ const ext = path.extname(filePath);
107
+ const stem = path.basename(filePath, ext);
108
+ const key = normalizeForMatch(stem);
109
+ if (!fileMap.has(key)) fileMap.set(key, []);
110
+ fileMap.get(key)!.push(filePath);
111
+ }
112
+
113
+ const written: string[] = [];
114
+ const writtenPaths = new Set<string>();
115
+ const skipped: string[] = [];
116
+
117
+ for (const [moduleName, moduleStubs] of Object.entries(stubs)) {
118
+ const normalizedModule = normalizeForMatch(moduleName);
119
+
120
+ // Find matching source files
121
+ const matchingFiles = fileMap.get(normalizedModule);
122
+
123
+ if (!matchingFiles || matchingFiles.length === 0) {
124
+ skipped.push(moduleName);
125
+ continue;
126
+ }
127
+
128
+ for (const sourceFile of matchingFiles) {
129
+ const ext = path.extname(sourceFile).toLowerCase();
130
+ const isPython = PY_EXTS.has(ext);
131
+ const stubContent = isPython ? moduleStubs.python : moduleStubs.ts;
132
+ const stubExt = isPython ? ".pyi" : ".d.ts";
133
+
134
+ // Generate stub file path next to source file
135
+ const sourceDir = path.dirname(sourceFile);
136
+ const sourceStem = path.basename(sourceFile, ext);
137
+ const stubPath = path.join(sourceDir, `${sourceStem}${stubExt}`);
138
+
139
+ // Skip if already written (multiple modules may normalize to same name)
140
+ if (writtenPaths.has(stubPath)) continue;
141
+ writtenPaths.add(stubPath);
142
+
143
+ if (opts.dryRun) {
144
+ const relPath = path.relative(process.cwd(), stubPath);
145
+ console.log(chalk.cyan(` Would create: ${relPath}`));
146
+ console.log(
147
+ chalk.gray(
148
+ ` (from module "${moduleName}" → ${path.basename(sourceFile)})`,
149
+ ),
150
+ );
151
+ written.push(relPath);
152
+ continue;
153
+ }
154
+
155
+ // Write stub file
156
+ fs.writeFileSync(stubPath, stubContent, "utf-8");
157
+ const relPath = path.relative(process.cwd(), stubPath);
158
+ written.push(relPath);
159
+ }
160
+ }
161
+
162
+ // Output summary
163
+ if (opts.silent) return;
164
+ console.log();
165
+ if (opts.dryRun) {
166
+ console.log(chalk.cyan(" Dry run — no files written.\n"));
167
+ if (written.length > 0) {
168
+ console.log(chalk.gray(` ${written.length} stub file(s) would be created.\n`));
169
+ }
170
+ } else if (written.length > 0) {
171
+ console.log(chalk.green(` Generated ${written.length} type stub file(s):\n`));
172
+ for (const f of written) {
173
+ console.log(chalk.gray(` ${f}`));
174
+ }
175
+ console.log();
176
+ console.log(
177
+ chalk.gray(
178
+ " Your IDE should now pick up these types automatically.",
179
+ ),
180
+ );
181
+ console.log(
182
+ chalk.gray(
183
+ " Add *.d.ts / *.pyi to .gitignore if you don't want to commit them.",
184
+ ),
185
+ );
186
+ console.log();
187
+ } else {
188
+ console.log(
189
+ chalk.yellow(
190
+ " No matching source files found for observed modules.\n",
191
+ ),
192
+ );
193
+ if (skipped.length > 0) {
194
+ console.log(
195
+ chalk.gray(
196
+ ` Observed modules: ${skipped.join(", ")}`,
197
+ ),
198
+ );
199
+ console.log(
200
+ chalk.gray(
201
+ ` Source files in: ${path.relative(process.cwd(), targetDir) || "."}`,
202
+ ),
203
+ );
204
+ console.log(
205
+ chalk.gray(
206
+ " Make sure module names match file names (e.g., module 'helpers' → helpers.js)\n",
207
+ ),
208
+ );
209
+ }
210
+ }
211
+ }
@@ -0,0 +1,94 @@
1
+ import chalk from "chalk";
2
+ import { tailEvents, TailEvent } from "../api-client";
3
+ import { envBadge, timeBadge } from "../ui/badges";
4
+ import { getBackendUrl } from "../config";
5
+
6
+ export interface TailOptions {
7
+ filter?: string;
8
+ }
9
+
10
+ function eventBadge(event: string): string {
11
+ const lower = event.toLowerCase();
12
+ if (lower === "error" || lower.includes("error")) {
13
+ return chalk.bgRed.white.bold(" ERROR ");
14
+ }
15
+ if (lower === "new_type" || lower.includes("new")) {
16
+ return chalk.bgGreen.black.bold(" NEW_TYPE ");
17
+ }
18
+ if (lower === "type_changed" || lower.includes("changed")) {
19
+ return chalk.bgYellow.black.bold(" TYPE_CHANGED ");
20
+ }
21
+ if (lower === "ingest" || lower.includes("ingest")) {
22
+ return chalk.bgBlue.white.bold(" INGEST ");
23
+ }
24
+ return chalk.bgGray.white.bold(` ${event.toUpperCase()} `);
25
+ }
26
+
27
+ function formatEventDetail(event: TailEvent): string {
28
+ const data = event.data;
29
+ const parts: string[] = [];
30
+
31
+ if (data.functionName) {
32
+ parts.push(chalk.white.bold(String(data.functionName)));
33
+ }
34
+
35
+ if (data.module) {
36
+ parts.push(chalk.gray(`(${data.module})`));
37
+ }
38
+
39
+ if (data.env) {
40
+ parts.push(envBadge(String(data.env)));
41
+ }
42
+
43
+ if (data.error && typeof data.error === "object") {
44
+ const err = data.error as Record<string, unknown>;
45
+ if (err.message) {
46
+ parts.push(chalk.red(String(err.message)));
47
+ }
48
+ }
49
+
50
+ if (data.error_message) {
51
+ parts.push(chalk.red(String(data.error_message)));
52
+ }
53
+
54
+ return parts.join(" ");
55
+ }
56
+
57
+ export async function tailCommand(opts: TailOptions): Promise<void> {
58
+ const url = getBackendUrl();
59
+ console.log(chalk.gray(`\n Connecting to trickle at ${url}...`));
60
+
61
+ const cleanup = tailEvents(
62
+ (event: TailEvent) => {
63
+ const now = new Date().toISOString();
64
+ const timeStr = chalk.gray(
65
+ now.replace(/T/, " ").replace(/\..+/, "")
66
+ );
67
+ const badge = eventBadge(event.event);
68
+ const detail = formatEventDetail(event);
69
+
70
+ console.log(` ${timeStr} ${badge} ${detail}`);
71
+ },
72
+ opts.filter
73
+ );
74
+
75
+ // Give it a moment to connect, then show the listening message
76
+ setTimeout(() => {
77
+ console.log(chalk.green(" Listening for events...") + chalk.gray(" (Ctrl+C to stop)\n"));
78
+ }, 500);
79
+
80
+ // Keep the process alive and handle graceful shutdown
81
+ const onSignal = () => {
82
+ console.log(chalk.gray("\n Disconnected.\n"));
83
+ cleanup();
84
+ process.exit(0);
85
+ };
86
+
87
+ process.on("SIGINT", onSignal);
88
+ process.on("SIGTERM", onSignal);
89
+
90
+ // Keep the event loop alive
91
+ await new Promise<void>(() => {
92
+ // Never resolves — the process stays alive until killed
93
+ });
94
+ }