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,1190 @@
1
+ import * as path from "path";
2
+ import * as fs from "fs";
3
+ import { spawn, ChildProcess } from "child_process";
4
+ import chalk from "chalk";
5
+ import { getBackendUrl } from "../config";
6
+ import {
7
+ listFunctions,
8
+ listErrors,
9
+ fetchAnnotations,
10
+ fetchStubs,
11
+ FunctionRow,
12
+ ErrorRow,
13
+ AnnotationEntry,
14
+ } from "../api-client";
15
+
16
+ export interface RunOptions {
17
+ module?: string;
18
+ include?: string;
19
+ exclude?: string;
20
+ stubs?: string;
21
+ annotate?: string;
22
+ watch?: boolean;
23
+ }
24
+
25
+ // ── .tricklerc.json config ──
26
+
27
+ interface TrickleConfig {
28
+ stubs?: string;
29
+ annotate?: string | string[];
30
+ include?: string | string[];
31
+ exclude?: string | string[];
32
+ }
33
+
34
+ function loadProjectConfig(): TrickleConfig | null {
35
+ const configNames = [".tricklerc.json", ".tricklerc", "trickle.config.json"];
36
+ for (const name of configNames) {
37
+ const p = path.resolve(name);
38
+ if (fs.existsSync(p)) {
39
+ try {
40
+ return JSON.parse(fs.readFileSync(p, "utf-8"));
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+ }
46
+ // Also check package.json "trickle" field
47
+ const pkgPath = path.resolve("package.json");
48
+ if (fs.existsSync(pkgPath)) {
49
+ try {
50
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
51
+ if (pkg.trickle && typeof pkg.trickle === "object") {
52
+ return pkg.trickle as TrickleConfig;
53
+ }
54
+ } catch {
55
+ // ignore
56
+ }
57
+ }
58
+ return null;
59
+ }
60
+
61
+ function mergeConfigWithOpts(opts: RunOptions, config: TrickleConfig | null): RunOptions {
62
+ if (!config) return opts;
63
+ const merged = { ...opts };
64
+
65
+ // CLI flags override config
66
+ if (!merged.stubs && config.stubs) {
67
+ merged.stubs = config.stubs;
68
+ }
69
+ if (!merged.annotate && config.annotate) {
70
+ // If array, join first item (run --annotate takes a single path)
71
+ merged.annotate = Array.isArray(config.annotate)
72
+ ? config.annotate[0]
73
+ : config.annotate;
74
+ }
75
+ if (!merged.include && config.include) {
76
+ merged.include = Array.isArray(config.include)
77
+ ? config.include.join(",")
78
+ : config.include;
79
+ }
80
+ if (!merged.exclude && config.exclude) {
81
+ merged.exclude = Array.isArray(config.exclude)
82
+ ? config.exclude.join(",")
83
+ : config.exclude;
84
+ }
85
+ return merged;
86
+ }
87
+
88
+ // ── Detect if command is a single source file ──
89
+
90
+ function detectSingleFile(command: string): string | null {
91
+ const trimmed = command.trim();
92
+ // Must be a single token (no spaces unless quoted)
93
+ if (/\s/.test(trimmed)) return null;
94
+
95
+ const ext = path.extname(trimmed).toLowerCase();
96
+ if (![".js", ".ts", ".tsx", ".jsx", ".mjs", ".cjs", ".mts", ".py"].includes(ext)) {
97
+ return null;
98
+ }
99
+
100
+ const resolved = path.resolve(trimmed);
101
+ if (!fs.existsSync(resolved)) return null;
102
+
103
+ return resolved;
104
+ }
105
+
106
+ // ── Auto-detect runtime from file extension ──
107
+
108
+ function autoDetectCommand(input: string): string {
109
+ // If it already starts with a known runtime, return as-is
110
+ if (/^(node|ts-node|tsx|nodemon|bun|deno|python3?|python3?\.\d+|vitest|jest|mocha|npx|bunx|pytest|uvicorn|gunicorn|flask|django-admin)\b/.test(input)) {
111
+ return input;
112
+ }
113
+
114
+ // Check if the first token is a file path
115
+ const parts = input.split(/\s+/);
116
+ const file = parts[0];
117
+ const rest = parts.slice(1).join(" ");
118
+ const ext = path.extname(file).toLowerCase();
119
+
120
+ // Resolve relative to cwd
121
+ const resolved = path.resolve(file);
122
+ const fileExists = fs.existsSync(resolved);
123
+
124
+ if (!fileExists) {
125
+ // Not a file — might be a custom command, return as-is
126
+ return input;
127
+ }
128
+
129
+ switch (ext) {
130
+ case ".js":
131
+ case ".cjs":
132
+ return rest ? `node ${file} ${rest}` : `node ${file}`;
133
+
134
+ case ".mjs":
135
+ return rest ? `node ${file} ${rest}` : `node ${file}`;
136
+
137
+ case ".ts":
138
+ case ".tsx":
139
+ case ".mts": {
140
+ // Find best available TS runtime
141
+ const tsRunner = findTsRunner();
142
+ return rest ? `${tsRunner} ${file} ${rest}` : `${tsRunner} ${file}`;
143
+ }
144
+
145
+ case ".py":
146
+ return rest ? `python ${file} ${rest}` : `python ${file}`;
147
+
148
+ default:
149
+ return input;
150
+ }
151
+ }
152
+
153
+ function findTsRunner(): string {
154
+ // Check for tsx (fastest, most compatible)
155
+ try {
156
+ const { execSync } = require("child_process");
157
+ execSync("tsx --version", { stdio: "ignore" });
158
+ return "tsx";
159
+ } catch {
160
+ // not available
161
+ }
162
+
163
+ // Check for ts-node
164
+ try {
165
+ const { execSync } = require("child_process");
166
+ execSync("ts-node --version", { stdio: "ignore" });
167
+ return "ts-node";
168
+ } catch {
169
+ // not available
170
+ }
171
+
172
+ // Check for bun (supports TS natively)
173
+ try {
174
+ const { execSync } = require("child_process");
175
+ execSync("bun --version", { stdio: "ignore" });
176
+ return "bun";
177
+ } catch {
178
+ // not available
179
+ }
180
+
181
+ // Fallback to npx tsx
182
+ return "npx tsx";
183
+ }
184
+
185
+ /**
186
+ * `trickle run <command>` — Run any command with universal type observation.
187
+ *
188
+ * Auto-detects JS or Python, injects the right instrumentation, starts the
189
+ * backend if needed, and shows a summary of captured types after exit.
190
+ * With --stubs or --annotate, also generates type files automatically.
191
+ * Reads .tricklerc.json for project defaults.
192
+ */
193
+ export async function runCommand(
194
+ command: string | undefined,
195
+ opts: RunOptions,
196
+ ): Promise<void> {
197
+ if (!command) {
198
+ console.error(chalk.red("\n Usage: trickle run <command>\n"));
199
+ console.error(chalk.gray(" Examples:"));
200
+ console.error(chalk.gray(' trickle run "node app.js"'));
201
+ console.error(chalk.gray(" trickle run app.ts # auto-detects TypeScript runtime"));
202
+ console.error(chalk.gray(" trickle run script.py # auto-detects Python"));
203
+ console.error(chalk.gray(' trickle run "node app.js" --stubs src/'));
204
+ console.error(chalk.gray(" trickle run app.js --watch # watch for changes and re-run"));
205
+ console.error("");
206
+ process.exit(1);
207
+ }
208
+
209
+ // Load project config
210
+ const config = loadProjectConfig();
211
+ opts = mergeConfigWithOpts(opts, config);
212
+
213
+ // Detect if command is a single file — if so, auto-generate sidecar types
214
+ const singleFile = detectSingleFile(command);
215
+
216
+ // Auto-detect runtime from file extension
217
+ const resolvedCommand = autoDetectCommand(command);
218
+
219
+ const backendUrl = getBackendUrl();
220
+
221
+ // Auto-start backend if not running — fall back to local mode
222
+ let backendProc: ChildProcess | null = null;
223
+ let localMode = false;
224
+ const backendRunning = await checkBackend(backendUrl);
225
+ if (!backendRunning) {
226
+ // Only try auto-start if using default URL (custom URL means user manages their own backend)
227
+ const isCustomUrl = !!process.env.TRICKLE_BACKEND_URL &&
228
+ process.env.TRICKLE_BACKEND_URL !== "http://localhost:4888";
229
+ if (!isCustomUrl) {
230
+ backendProc = await autoStartBackend();
231
+ }
232
+ if (!backendProc) {
233
+ // Fall back to local/offline mode instead of exiting
234
+ localMode = true;
235
+ console.log(
236
+ chalk.yellow(
237
+ `\n Backend not available — using local mode (offline)`,
238
+ ),
239
+ );
240
+ console.log(
241
+ chalk.gray(
242
+ " Observations will be saved to .trickle/observations.jsonl",
243
+ ),
244
+ );
245
+ }
246
+ }
247
+
248
+ // Detect language and inject instrumentation
249
+ const { instrumentedCommand, env: extraEnv } = injectObservation(
250
+ resolvedCommand,
251
+ backendUrl,
252
+ opts,
253
+ );
254
+
255
+ // Print header
256
+ console.log("");
257
+ console.log(chalk.bold(opts.watch ? " trickle run --watch" : " trickle run"));
258
+ console.log(chalk.gray(" " + "─".repeat(50)));
259
+ if (resolvedCommand !== command) {
260
+ console.log(chalk.gray(` File: ${command}`));
261
+ console.log(chalk.gray(` Resolved: ${resolvedCommand}`));
262
+ } else {
263
+ console.log(chalk.gray(` Command: ${command}`));
264
+ }
265
+ if (instrumentedCommand !== resolvedCommand) {
266
+ console.log(chalk.gray(` Injected: ${instrumentedCommand}`));
267
+ }
268
+ if (localMode) {
269
+ console.log(chalk.gray(` Mode: local (offline)`));
270
+ } else {
271
+ console.log(chalk.gray(` Backend: ${backendUrl}`));
272
+ }
273
+ if (config) {
274
+ console.log(chalk.gray(` Config: .tricklerc.json`));
275
+ }
276
+ if (opts.stubs) {
277
+ console.log(chalk.gray(` Stubs: ${opts.stubs}`));
278
+ }
279
+ if (opts.annotate) {
280
+ console.log(chalk.gray(` Annotate: ${opts.annotate}`));
281
+ }
282
+ if (opts.watch) {
283
+ console.log(chalk.gray(` Watch: enabled`));
284
+ }
285
+ console.log(chalk.gray(" " + "─".repeat(50)));
286
+ console.log("");
287
+
288
+ // Shared env for all runs
289
+ const runEnv: Record<string, string> = {
290
+ ...extraEnv,
291
+ TRICKLE_BACKEND_URL: backendUrl,
292
+ TRICKLE_DEBUG: process.env.TRICKLE_DEBUG || "",
293
+ };
294
+
295
+ // In local mode, set TRICKLE_LOCAL=1 so the client writes to JSONL
296
+ if (localMode) {
297
+ runEnv.TRICKLE_LOCAL = "1";
298
+ // Forward TRICKLE_LOCAL_DIR if set
299
+ if (process.env.TRICKLE_LOCAL_DIR) {
300
+ runEnv.TRICKLE_LOCAL_DIR = process.env.TRICKLE_LOCAL_DIR;
301
+ }
302
+ }
303
+
304
+ // Execute the single-run flow
305
+ const exitCode = await executeSingleRun(
306
+ instrumentedCommand,
307
+ runEnv,
308
+ opts,
309
+ singleFile,
310
+ localMode,
311
+ );
312
+
313
+ // If --watch, enter watch loop instead of exiting
314
+ if (opts.watch) {
315
+ await enterWatchLoop(command, instrumentedCommand, runEnv, opts, singleFile, backendProc, localMode);
316
+ // enterWatchLoop never returns (handles its own exit)
317
+ }
318
+
319
+ // Clean up
320
+ if (backendProc) {
321
+ backendProc.kill("SIGTERM");
322
+ await sleep(500);
323
+ }
324
+
325
+ process.exit(exitCode);
326
+ }
327
+
328
+ /**
329
+ * Execute a single observation run: run the command, wait for flush, show summary.
330
+ */
331
+ async function executeSingleRun(
332
+ instrumentedCommand: string,
333
+ env: Record<string, string>,
334
+ opts: RunOptions,
335
+ singleFile?: string | null,
336
+ localMode?: boolean,
337
+ ): Promise<number> {
338
+ if (!localMode) {
339
+ // Snapshot functions before run (to compute delta)
340
+ let functionsBefore: FunctionRow[] = [];
341
+ let errorsBefore: ErrorRow[] = [];
342
+ try {
343
+ const fb = await listFunctions();
344
+ functionsBefore = fb.functions;
345
+ const eb = await listErrors();
346
+ errorsBefore = eb.errors;
347
+ } catch {
348
+ // Backend might not have data yet
349
+ }
350
+
351
+ // Start live type generation for backend mode
352
+ let liveStop: (() => void) | null = null;
353
+ if (singleFile && !opts.stubs) {
354
+ liveStop = startLiveBackendTypes(singleFile);
355
+ }
356
+
357
+ // Run the instrumented command
358
+ const exitCode = await runProcess(instrumentedCommand, env);
359
+
360
+ // Stop live watcher
361
+ if (liveStop) liveStop();
362
+
363
+ // Wait for transport to flush
364
+ console.log(chalk.gray("\n Waiting for type data to flush..."));
365
+ await sleep(3000);
366
+
367
+ // Show summary with inline type signatures
368
+ await showSummary(functionsBefore, errorsBefore);
369
+
370
+ // Auto-generate stubs if --stubs was specified
371
+ if (opts.stubs) {
372
+ await autoGenerateStubs(opts.stubs);
373
+ }
374
+
375
+ // Auto-annotate if --annotate was specified
376
+ if (opts.annotate) {
377
+ await autoAnnotateFiles(opts.annotate);
378
+ }
379
+
380
+ // Auto-generate sidecar type file when invoked with a single file
381
+ // (unless --stubs was explicitly specified, which overrides this)
382
+ if (singleFile && !opts.stubs) {
383
+ await autoGenerateSidecar(singleFile);
384
+ }
385
+
386
+ return exitCode;
387
+ }
388
+
389
+ // ── Local/offline mode ──
390
+
391
+ const localDir = env.TRICKLE_LOCAL_DIR || process.env.TRICKLE_LOCAL_DIR || path.join(process.cwd(), ".trickle");
392
+ const jsonlPath = path.join(localDir, "observations.jsonl");
393
+
394
+ const { generateLocalStubs, generateFromJsonl } = await import("../local-codegen");
395
+
396
+ // Start live type generation — types update while the process runs
397
+ let liveTypesStop: (() => void) | null = null;
398
+ if (singleFile) {
399
+ liveTypesStop = startLiveLocalTypes(singleFile, jsonlPath, generateLocalStubs);
400
+ }
401
+
402
+ // Run the instrumented command
403
+ const exitCode = await runProcess(instrumentedCommand, env);
404
+
405
+ // Stop live watcher
406
+ if (liveTypesStop) liveTypesStop();
407
+
408
+ // Brief pause for any async file writes to complete
409
+ await sleep(500);
410
+
411
+ if (!fs.existsSync(jsonlPath)) {
412
+ console.log(chalk.gray("\n No observations captured."));
413
+ return exitCode;
414
+ }
415
+
416
+ // Final type generation (catches any remaining observations)
417
+ if (singleFile) {
418
+ generateLocalStubs(singleFile, jsonlPath);
419
+ }
420
+
421
+ // Show local summary
422
+ const stubs = generateFromJsonl(jsonlPath);
423
+ const allModules = Object.keys(stubs);
424
+ let totalFunctions = 0;
425
+ for (const mod of allModules) {
426
+ const lines = stubs[mod].ts.split("\n");
427
+ totalFunctions += lines.filter((l) => l.startsWith("export declare function")).length;
428
+ }
429
+
430
+ console.log("");
431
+ console.log(chalk.bold(" Summary (local mode)"));
432
+ console.log(chalk.gray(" " + "─".repeat(50)));
433
+ console.log(` Functions observed: ${chalk.bold(String(totalFunctions))}`);
434
+ console.log(` Data saved to: ${chalk.gray(jsonlPath)}`);
435
+
436
+ if (singleFile) {
437
+ const ext = path.extname(singleFile).toLowerCase();
438
+ const isPython = ext === ".py";
439
+ const baseName = path.basename(singleFile, ext);
440
+ const stubExt = isPython ? ".pyi" : ".d.ts";
441
+ const stubFile = path.join(path.dirname(singleFile), `${baseName}${stubExt}`);
442
+ if (fs.existsSync(stubFile)) {
443
+ const relPath = path.relative(process.cwd(), stubFile);
444
+ console.log(chalk.green(` Types written to ${chalk.bold(relPath)}`));
445
+ }
446
+ }
447
+
448
+ console.log(chalk.gray(" " + "─".repeat(50)));
449
+ console.log("");
450
+
451
+ return exitCode;
452
+ }
453
+
454
+ // ── Live type generation ──
455
+
456
+ /**
457
+ * Start a background watcher that regenerates type stubs whenever the
458
+ * JSONL file changes. Returns a stop function.
459
+ *
460
+ * Uses polling (fs.watchFile) because the file is being appended to by
461
+ * the child process and fs.watch can be unreliable with rapid appends.
462
+ */
463
+ function startLiveLocalTypes(
464
+ sourceFile: string,
465
+ jsonlPath: string,
466
+ generateLocalStubs: (sourceFile: string, jsonlPath: string) => { written: string[]; functionCount: number },
467
+ ): () => void {
468
+ let lastSize = 0;
469
+ let lastFunctionCount = 0;
470
+ let debounceTimer: ReturnType<typeof setTimeout> | null = null;
471
+ let stopped = false;
472
+
473
+ const regenerate = () => {
474
+ if (stopped) return;
475
+ try {
476
+ if (!fs.existsSync(jsonlPath)) return;
477
+
478
+ const stat = fs.statSync(jsonlPath);
479
+ if (stat.size === lastSize) return; // no new data
480
+ lastSize = stat.size;
481
+
482
+ const { written, functionCount } = generateLocalStubs(sourceFile, jsonlPath);
483
+ if (written.length > 0 && functionCount > lastFunctionCount) {
484
+ const newCount = functionCount - lastFunctionCount;
485
+ const ts = new Date().toLocaleTimeString("en-US", { hour12: false });
486
+ const relPath = path.relative(process.cwd(), written[0]);
487
+ console.log(
488
+ chalk.gray(` [${ts}]`) +
489
+ chalk.green(` +${newCount} type(s)`) +
490
+ chalk.gray(` → ${relPath}`) +
491
+ chalk.gray(` (${functionCount} total)`),
492
+ );
493
+ lastFunctionCount = functionCount;
494
+ }
495
+ } catch {
496
+ // Never crash — this is a background helper
497
+ }
498
+ };
499
+
500
+ // Do an initial check after a short delay (catch fast-running scripts)
501
+ const initialTimer = setTimeout(regenerate, 800);
502
+
503
+ // Poll every 2 seconds
504
+ const interval = setInterval(regenerate, 2000);
505
+
506
+ // Also try fs.watchFile for faster response on changes
507
+ try {
508
+ fs.watchFile(jsonlPath, { interval: 1000 }, () => {
509
+ if (debounceTimer) clearTimeout(debounceTimer);
510
+ debounceTimer = setTimeout(regenerate, 200);
511
+ });
512
+ } catch {
513
+ // watchFile may fail if file doesn't exist yet — polling handles it
514
+ }
515
+
516
+ return () => {
517
+ stopped = true;
518
+ clearTimeout(initialTimer);
519
+ clearInterval(interval);
520
+ if (debounceTimer) clearTimeout(debounceTimer);
521
+ try { fs.unwatchFile(jsonlPath); } catch {}
522
+ };
523
+ }
524
+
525
+ /**
526
+ * Start a background poller that fetches stubs from the backend and
527
+ * writes sidecar type files while the process runs. Returns a stop function.
528
+ */
529
+ function startLiveBackendTypes(sourceFile: string): () => void {
530
+ let lastFunctionCount = 0;
531
+ let stopped = false;
532
+
533
+ const ext = path.extname(sourceFile).toLowerCase();
534
+ const isPython = ext === ".py";
535
+ const dir = path.dirname(sourceFile);
536
+ const baseName = path.basename(sourceFile, ext);
537
+ const sidecarName = isPython ? `${baseName}.pyi` : `${baseName}.d.ts`;
538
+ const sidecarPath = path.join(dir, sidecarName);
539
+
540
+ const poll = async () => {
541
+ if (stopped) return;
542
+ try {
543
+ const { stubsCommand } = await import("./stubs");
544
+ await stubsCommand(dir, { silent: true });
545
+
546
+ if (fs.existsSync(sidecarPath)) {
547
+ const content = fs.readFileSync(sidecarPath, "utf-8");
548
+ const funcCount = (content.match(/export declare function/g) || []).length;
549
+
550
+ if (funcCount > lastFunctionCount) {
551
+ const newCount = funcCount - lastFunctionCount;
552
+ const ts = new Date().toLocaleTimeString("en-US", { hour12: false });
553
+ console.log(
554
+ chalk.gray(` [${ts}]`) +
555
+ chalk.green(` +${newCount} type(s)`) +
556
+ chalk.gray(` → ${sidecarName}`) +
557
+ chalk.gray(` (${funcCount} total)`),
558
+ );
559
+ lastFunctionCount = funcCount;
560
+ }
561
+ }
562
+ } catch {
563
+ // Never crash — background helper
564
+ }
565
+ };
566
+
567
+ // Poll every 3 seconds (backend mode has higher overhead)
568
+ const interval = setInterval(poll, 3000);
569
+
570
+ return () => {
571
+ stopped = true;
572
+ clearInterval(interval);
573
+ };
574
+ }
575
+
576
+ // ── Auto-generate sidecar type file ──
577
+
578
+ async function autoGenerateSidecar(filePath: string): Promise<void> {
579
+ try {
580
+ const ext = path.extname(filePath).toLowerCase();
581
+ const isPython = ext === ".py";
582
+ const dir = path.dirname(filePath);
583
+ const baseName = path.basename(filePath, ext);
584
+
585
+ // Determine sidecar filename
586
+ const sidecarName = isPython ? `${baseName}.pyi` : `${baseName}.d.ts`;
587
+ const sidecarPath = path.join(dir, sidecarName);
588
+
589
+ // Use the stubs command to generate stubs for the file's directory
590
+ const { stubsCommand } = await import("./stubs");
591
+ await stubsCommand(dir, { silent: true });
592
+
593
+ // Check if the sidecar was generated
594
+ if (fs.existsSync(sidecarPath)) {
595
+ const stats = fs.statSync(sidecarPath);
596
+ if (stats.size > 0) {
597
+ console.log(
598
+ chalk.green(`\n Types written to ${chalk.bold(sidecarName)}`),
599
+ );
600
+ }
601
+ }
602
+ } catch {
603
+ // Don't fail the run if sidecar generation fails
604
+ }
605
+ }
606
+
607
+ // ── Watch mode ──
608
+
609
+ /**
610
+ * Find source files to watch based on the command.
611
+ * Returns the directory to watch and specific file paths.
612
+ */
613
+ function findWatchTargets(command: string): { dir: string; file: string | null } {
614
+ const parts = command.split(/\s+/);
615
+
616
+ // Find the first token that looks like a file path
617
+ for (const part of parts) {
618
+ const ext = path.extname(part).toLowerCase();
619
+ if ([".js", ".ts", ".tsx", ".mjs", ".cjs", ".mts", ".py", ".jsx"].includes(ext)) {
620
+ const resolved = path.resolve(part);
621
+ if (fs.existsSync(resolved)) {
622
+ return {
623
+ dir: path.dirname(resolved),
624
+ file: resolved,
625
+ };
626
+ }
627
+ }
628
+ }
629
+
630
+ return { dir: process.cwd(), file: null };
631
+ }
632
+
633
+ /**
634
+ * Enter watch mode — watch source files and re-run on changes.
635
+ */
636
+ async function enterWatchLoop(
637
+ originalCommand: string,
638
+ instrumentedCommand: string,
639
+ env: Record<string, string>,
640
+ opts: RunOptions,
641
+ singleFile: string | null,
642
+ backendProc: ChildProcess | null,
643
+ localMode?: boolean,
644
+ ): Promise<void> {
645
+ const { dir: watchDir, file: watchFile } = findWatchTargets(originalCommand);
646
+
647
+ const watchExts = new Set([".js", ".ts", ".tsx", ".mjs", ".cjs", ".mts", ".py", ".jsx"]);
648
+ const ignoreDirs = new Set(["node_modules", ".git", "dist", "build", "__pycache__", ".trickle"]);
649
+
650
+ console.log("");
651
+ console.log(chalk.gray(" " + "─".repeat(50)));
652
+ console.log(chalk.cyan(" Watching for changes...") + chalk.gray(` (${watchDir})`));
653
+ console.log(chalk.gray(" Press Ctrl+C to stop."));
654
+ console.log(chalk.gray(" " + "─".repeat(50)));
655
+ console.log("");
656
+
657
+ let debounceTimer: ReturnType<typeof setTimeout> | null = null;
658
+ let runCount = 1;
659
+
660
+ const triggerRerun = () => {
661
+ if (debounceTimer) clearTimeout(debounceTimer);
662
+ debounceTimer = setTimeout(async () => {
663
+ runCount++;
664
+ const ts = new Date().toLocaleTimeString("en-US", { hour12: false });
665
+ console.log("");
666
+ console.log(chalk.cyan(` [${ts}]`) + chalk.bold(` Re-running (#${runCount})...`));
667
+ console.log(chalk.gray(" " + "─".repeat(50)));
668
+
669
+ try {
670
+ await executeSingleRun(instrumentedCommand, env, opts, singleFile, localMode);
671
+ } catch {
672
+ console.log(chalk.red(" Run failed. Waiting for next change..."));
673
+ }
674
+
675
+ console.log("");
676
+ console.log(chalk.gray(" Watching for changes..."));
677
+ }, 300); // 300ms debounce
678
+ };
679
+
680
+ // Use fs.watch with recursive option (supported on macOS and Windows)
681
+ try {
682
+ const watcher = fs.watch(watchDir, { recursive: true }, (_eventType, filename) => {
683
+ if (!filename) return;
684
+
685
+ // Check file extension
686
+ const ext = path.extname(filename).toLowerCase();
687
+ if (!watchExts.has(ext)) return;
688
+
689
+ // Skip ignored directories
690
+ const parts = filename.split(path.sep);
691
+ if (parts.some(p => ignoreDirs.has(p))) return;
692
+
693
+ const ts = new Date().toLocaleTimeString("en-US", { hour12: false });
694
+ console.log(chalk.gray(` [${ts}] Changed: ${filename}`));
695
+ triggerRerun();
696
+ });
697
+
698
+ // Handle graceful shutdown
699
+ const cleanup = () => {
700
+ watcher.close();
701
+ if (debounceTimer) clearTimeout(debounceTimer);
702
+ if (backendProc) {
703
+ backendProc.kill("SIGTERM");
704
+ }
705
+ console.log(chalk.gray("\n Watch stopped.\n"));
706
+ process.exit(0);
707
+ };
708
+
709
+ process.on("SIGINT", cleanup);
710
+ process.on("SIGTERM", cleanup);
711
+
712
+ // Keep the process alive
713
+ await new Promise<never>(() => {});
714
+ } catch (err: unknown) {
715
+ // Fallback: if recursive watch isn't supported, watch just the target file
716
+ if (watchFile) {
717
+ console.log(chalk.gray(" (Watching single file: " + path.basename(watchFile) + ")"));
718
+
719
+ const watcher = fs.watch(watchFile, () => {
720
+ const ts = new Date().toLocaleTimeString("en-US", { hour12: false });
721
+ console.log(chalk.gray(` [${ts}] Changed: ${path.basename(watchFile)}`));
722
+ triggerRerun();
723
+ });
724
+
725
+ const cleanup = () => {
726
+ watcher.close();
727
+ if (debounceTimer) clearTimeout(debounceTimer);
728
+ if (backendProc) {
729
+ backendProc.kill("SIGTERM");
730
+ }
731
+ console.log(chalk.gray("\n Watch stopped.\n"));
732
+ process.exit(0);
733
+ };
734
+
735
+ process.on("SIGINT", cleanup);
736
+ process.on("SIGTERM", cleanup);
737
+
738
+ await new Promise<never>(() => {});
739
+ }
740
+
741
+ // Can't watch anything
742
+ console.error(chalk.red(" Could not set up file watcher."));
743
+ if (backendProc) backendProc.kill("SIGTERM");
744
+ process.exit(1);
745
+ }
746
+ }
747
+
748
+ // ── Auto-generate stubs ──
749
+
750
+ async function autoGenerateStubs(dir: string): Promise<void> {
751
+ try {
752
+ const { stubsCommand } = await import("./stubs");
753
+ await stubsCommand(dir, {});
754
+ } catch (err: unknown) {
755
+ if (err instanceof Error) {
756
+ console.error(chalk.yellow(`\n Stubs generation warning: ${err.message}`));
757
+ }
758
+ }
759
+ }
760
+
761
+ // ── Auto-annotate files ──
762
+
763
+ async function autoAnnotateFiles(fileOrDir: string): Promise<void> {
764
+ try {
765
+ const { annotateCommand } = await import("./annotate");
766
+ const resolved = path.resolve(fileOrDir);
767
+
768
+ if (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory()) {
769
+ // Annotate all JS/TS/Python files in the directory
770
+ const files = findAnnotatableFiles(resolved);
771
+ if (files.length === 0) {
772
+ console.log(chalk.gray(`\n No annotatable files found in ${fileOrDir}`));
773
+ return;
774
+ }
775
+ for (const file of files) {
776
+ await annotateCommand(file, {});
777
+ }
778
+ } else {
779
+ // Annotate a single file
780
+ await annotateCommand(fileOrDir, {});
781
+ }
782
+ } catch (err: unknown) {
783
+ if (err instanceof Error) {
784
+ console.error(chalk.yellow(`\n Annotation warning: ${err.message}`));
785
+ }
786
+ }
787
+ }
788
+
789
+ function findAnnotatableFiles(dir: string): string[] {
790
+ const results: string[] = [];
791
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
792
+
793
+ for (const entry of entries) {
794
+ const fullPath = path.join(dir, entry.name);
795
+ if (entry.isDirectory()) {
796
+ if (["node_modules", "__pycache__", ".git", "dist", "build", ".trickle"].includes(entry.name)) continue;
797
+ results.push(...findAnnotatableFiles(fullPath));
798
+ } else if (entry.isFile()) {
799
+ const ext = path.extname(entry.name).toLowerCase();
800
+ if ([".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".py"].includes(ext)) {
801
+ results.push(fullPath);
802
+ }
803
+ }
804
+ }
805
+ return results;
806
+ }
807
+
808
+ // ── Inline type signatures in summary ──
809
+
810
+ async function fetchTypeSignatures(
811
+ newFunctions: FunctionRow[],
812
+ ): Promise<Record<string, AnnotationEntry>> {
813
+ try {
814
+ const { annotations } = await fetchAnnotations({});
815
+ return annotations || {};
816
+ } catch {
817
+ return {};
818
+ }
819
+ }
820
+
821
+ function formatSignature(
822
+ fnName: string,
823
+ annotation: AnnotationEntry,
824
+ maxLen: number = 90,
825
+ ): string {
826
+ const params = annotation.params
827
+ .map((p) => `${p.name}: ${p.type}`)
828
+ .join(", ");
829
+ const sig = `${fnName}(${params}) → ${annotation.returnType}`;
830
+ if (sig.length > maxLen) {
831
+ return sig.substring(0, maxLen - 1) + "…";
832
+ }
833
+ return sig;
834
+ }
835
+
836
+ /**
837
+ * Detect if a script file uses ES modules.
838
+ */
839
+ function isEsmFile(command: string): boolean {
840
+ const parts = command.split(/\s+/);
841
+ for (const part of parts) {
842
+ if (part.endsWith(".mjs") || part.endsWith(".mts")) return true;
843
+
844
+ if (
845
+ part.endsWith(".js") ||
846
+ part.endsWith(".ts") ||
847
+ part.endsWith(".tsx") ||
848
+ part.endsWith(".jsx")
849
+ ) {
850
+ const filePath = path.resolve(part);
851
+ try {
852
+ const content = fs.readFileSync(filePath, "utf-8");
853
+ if (/^\s*(import|export)\s/m.test(content)) return true;
854
+ } catch {
855
+ // File might not exist at this path
856
+ }
857
+
858
+ try {
859
+ let dir = path.dirname(filePath);
860
+ for (let i = 0; i < 10; i++) {
861
+ const pkgPath = path.join(dir, "package.json");
862
+ if (fs.existsSync(pkgPath)) {
863
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
864
+ if (pkg.type === "module") return true;
865
+ break;
866
+ }
867
+ const parent = path.dirname(dir);
868
+ if (parent === dir) break;
869
+ dir = parent;
870
+ }
871
+ } catch {
872
+ // Ignore
873
+ }
874
+ }
875
+ }
876
+ return false;
877
+ }
878
+
879
+ /**
880
+ * Detect the language and inject the appropriate auto-observation mechanism.
881
+ */
882
+ function injectObservation(
883
+ command: string,
884
+ backendUrl: string,
885
+ opts: RunOptions,
886
+ ): { instrumentedCommand: string; env: Record<string, string> } {
887
+ const env: Record<string, string> = {};
888
+
889
+ if (
890
+ command.includes("trickle-observe/observe") ||
891
+ command.includes("trickle-observe/register") ||
892
+ command.includes("trickle/observe") ||
893
+ command.includes("trickle/register") ||
894
+ command.includes("-m trickle")
895
+ ) {
896
+ return { instrumentedCommand: command, env };
897
+ }
898
+
899
+ const observePath = resolveObservePath();
900
+ const observeEsmPath = resolveObserveEsmPath();
901
+
902
+ if (opts.include) env.TRICKLE_OBSERVE_INCLUDE = opts.include;
903
+ if (opts.exclude) env.TRICKLE_OBSERVE_EXCLUDE = opts.exclude;
904
+
905
+ const nodeMatch = command.match(/^(node|ts-node|tsx|nodemon)\s/);
906
+ if (nodeMatch) {
907
+ const runner = nodeMatch[1];
908
+ const useEsm = isEsmFile(command) && observeEsmPath;
909
+
910
+ if (useEsm) {
911
+ const modified = command.replace(
912
+ new RegExp(`^${runner}\\s`),
913
+ `${runner} --import ${observeEsmPath} `,
914
+ );
915
+ return { instrumentedCommand: modified, env };
916
+ } else {
917
+ const modified = command.replace(
918
+ new RegExp(`^${runner}\\s`),
919
+ `${runner} -r ${observePath} `,
920
+ );
921
+ return { instrumentedCommand: modified, env };
922
+ }
923
+ }
924
+
925
+ if (/^(vitest|jest|mocha|npx|bunx|bun)\b/.test(command)) {
926
+ const existing = process.env.NODE_OPTIONS || "";
927
+ if (observeEsmPath) {
928
+ env.NODE_OPTIONS =
929
+ `${existing} -r ${observePath} --import ${observeEsmPath}`.trim();
930
+ } else {
931
+ env.NODE_OPTIONS = `${existing} -r ${observePath}`.trim();
932
+ }
933
+ return { instrumentedCommand: command, env };
934
+ }
935
+
936
+ const pyMatch = command.match(/^(python3?|python3?\.\d+)\s/);
937
+ if (pyMatch) {
938
+ const python = pyMatch[1];
939
+ const rest = command.slice(pyMatch[0].length);
940
+ if (opts.include) env.TRICKLE_OBSERVE_INCLUDE = opts.include;
941
+ if (opts.exclude) env.TRICKLE_OBSERVE_EXCLUDE = opts.exclude;
942
+ return {
943
+ instrumentedCommand: `${python} -c "from trickle.observe_runner import main; main()" ${rest}`,
944
+ env,
945
+ };
946
+ }
947
+
948
+ if (/^(pytest|uvicorn|gunicorn|flask|django-admin)\b/.test(command)) {
949
+ if (opts.include) env.TRICKLE_OBSERVE_INCLUDE = opts.include;
950
+ if (opts.exclude) env.TRICKLE_OBSERVE_EXCLUDE = opts.exclude;
951
+ return {
952
+ instrumentedCommand: `python -c "from trickle.observe_runner import main; main()" -m ${command}`,
953
+ env,
954
+ };
955
+ }
956
+
957
+ console.log(
958
+ chalk.yellow(
959
+ " Could not detect language. Trying Node.js instrumentation...",
960
+ ),
961
+ );
962
+ const existing = process.env.NODE_OPTIONS || "";
963
+ env.NODE_OPTIONS = `${existing} -r ${observePath}`.trim();
964
+ return { instrumentedCommand: command, env };
965
+ }
966
+
967
+ function resolveObservePath(): string {
968
+ try {
969
+ return require.resolve("trickle-observe/observe");
970
+ } catch {
971
+ // Not in node_modules
972
+ }
973
+
974
+ try {
975
+ return require.resolve("trickle/observe");
976
+ } catch {
977
+ // Not in node_modules
978
+ }
979
+
980
+ const monorepoPath = path.resolve(
981
+ __dirname,
982
+ "..",
983
+ "..",
984
+ "..",
985
+ "client-js",
986
+ "observe.js",
987
+ );
988
+ if (fs.existsSync(monorepoPath)) return monorepoPath;
989
+
990
+ return "trickle-observe/observe";
991
+ }
992
+
993
+ function resolveObserveEsmPath(): string | null {
994
+ try {
995
+ return require.resolve("trickle-observe/observe-esm");
996
+ } catch {
997
+ // Not in node_modules
998
+ }
999
+
1000
+ const monorepoPath = path.resolve(
1001
+ __dirname,
1002
+ "..",
1003
+ "..",
1004
+ "..",
1005
+ "client-js",
1006
+ "observe-esm.mjs",
1007
+ );
1008
+ if (fs.existsSync(monorepoPath)) return monorepoPath;
1009
+
1010
+ return null;
1011
+ }
1012
+
1013
+ function runProcess(
1014
+ command: string,
1015
+ env: Record<string, string>,
1016
+ ): Promise<number> {
1017
+ return new Promise((resolve) => {
1018
+ const proc = spawn(command, [], {
1019
+ stdio: "inherit",
1020
+ shell: true,
1021
+ env: { ...process.env, ...env },
1022
+ });
1023
+
1024
+ proc.on("error", (err) => {
1025
+ console.error(chalk.red(`\n Failed to start: ${err.message}\n`));
1026
+ resolve(1);
1027
+ });
1028
+
1029
+ proc.on("exit", (code) => {
1030
+ resolve(code ?? 1);
1031
+ });
1032
+ });
1033
+ }
1034
+
1035
+ /**
1036
+ * Show a summary of what was captured during the run, with inline type signatures.
1037
+ */
1038
+ async function showSummary(
1039
+ functionsBefore: FunctionRow[],
1040
+ errorsBefore: ErrorRow[],
1041
+ ): Promise<void> {
1042
+ try {
1043
+ const { functions } = await listFunctions();
1044
+ const { errors } = await listErrors();
1045
+
1046
+ const beforeIds = new Set(functionsBefore.map((f) => f.id));
1047
+ const newFunctions = functions.filter((f) => !beforeIds.has(f.id));
1048
+
1049
+ const beforeErrorIds = new Set(errorsBefore.map((e) => e.id));
1050
+ const newErrors = errors.filter((e) => !beforeErrorIds.has(e.id));
1051
+
1052
+ // Fetch inline type signatures for the new functions
1053
+ const annotations = await fetchTypeSignatures(newFunctions);
1054
+
1055
+ console.log("");
1056
+ console.log(chalk.bold(" Summary"));
1057
+ console.log(chalk.gray(" " + "─".repeat(50)));
1058
+
1059
+ if (functions.length === 0) {
1060
+ console.log(
1061
+ chalk.yellow(" No functions captured. The command may not have"),
1062
+ );
1063
+ console.log(
1064
+ chalk.yellow(" loaded any modules that could be instrumented."),
1065
+ );
1066
+ } else {
1067
+ console.log(
1068
+ ` Functions observed: ${chalk.bold(String(functions.length))} total, ${chalk.green(String(newFunctions.length) + " new")}`,
1069
+ );
1070
+
1071
+ if (newFunctions.length > 0) {
1072
+ console.log("");
1073
+ const shown = newFunctions.slice(0, 15);
1074
+ for (const fn of shown) {
1075
+ const annotation = annotations[fn.function_name];
1076
+ if (annotation) {
1077
+ // Show full type signature
1078
+ const sig = formatSignature(fn.function_name, annotation);
1079
+ console.log(` ${chalk.green("+")} ${sig}`);
1080
+ console.log(chalk.gray(` ${fn.module} module`));
1081
+ } else {
1082
+ const moduleBadge = chalk.gray(`[${fn.module}]`);
1083
+ console.log(
1084
+ ` ${chalk.green("+")} ${fn.function_name} ${moduleBadge}`,
1085
+ );
1086
+ }
1087
+ }
1088
+ if (newFunctions.length > 15) {
1089
+ console.log(
1090
+ chalk.gray(` ... and ${newFunctions.length - 15} more`),
1091
+ );
1092
+ }
1093
+ }
1094
+
1095
+ if (newErrors.length > 0) {
1096
+ console.log("");
1097
+ console.log(
1098
+ ` Errors captured: ${chalk.red(String(newErrors.length))}`,
1099
+ );
1100
+ const shownErrors = newErrors.slice(0, 5);
1101
+ for (const err of shownErrors) {
1102
+ const fn = functions.find((f) => f.id === err.function_id);
1103
+ const fnName = fn ? fn.function_name : "unknown";
1104
+ console.log(
1105
+ ` ${chalk.red("!")} ${fnName}: ${chalk.gray(err.error_message.substring(0, 80))}`,
1106
+ );
1107
+ }
1108
+ }
1109
+
1110
+ console.log("");
1111
+ console.log(chalk.gray(" Explore results:"));
1112
+ console.log(
1113
+ chalk.gray(
1114
+ " trickle functions # list all captured functions",
1115
+ ),
1116
+ );
1117
+ if (newFunctions.length > 0) {
1118
+ const example = newFunctions[0].function_name;
1119
+ console.log(
1120
+ chalk.gray(
1121
+ ` trickle types ${example} # see types + sample data`,
1122
+ ),
1123
+ );
1124
+ }
1125
+ if (newErrors.length > 0) {
1126
+ console.log(
1127
+ chalk.gray(
1128
+ " trickle errors # see captured errors",
1129
+ ),
1130
+ );
1131
+ }
1132
+ }
1133
+
1134
+ console.log(chalk.gray(" " + "─".repeat(50)));
1135
+ console.log("");
1136
+ } catch {
1137
+ console.log(chalk.gray("\n Could not fetch summary from backend.\n"));
1138
+ }
1139
+ }
1140
+
1141
+ async function checkBackend(url: string): Promise<boolean> {
1142
+ try {
1143
+ const res = await fetch(`${url}/api/health`, {
1144
+ signal: AbortSignal.timeout(2000),
1145
+ });
1146
+ return res.ok;
1147
+ } catch {
1148
+ return false;
1149
+ }
1150
+ }
1151
+
1152
+ async function autoStartBackend(): Promise<ChildProcess | null> {
1153
+ const backendPaths = [
1154
+ path.resolve("packages/backend/dist/index.js"),
1155
+ path.resolve("node_modules/trickle-backend/dist/index.js"),
1156
+ ];
1157
+
1158
+ for (const p of backendPaths) {
1159
+ if (fs.existsSync(p)) {
1160
+ console.log(chalk.gray(" Auto-starting trickle backend..."));
1161
+ const proc = spawn("node", [p], {
1162
+ stdio: ["ignore", "pipe", "pipe"],
1163
+ env: { ...process.env },
1164
+ detached: false,
1165
+ });
1166
+
1167
+ proc.stdout?.on("data", () => {});
1168
+ proc.stderr?.on("data", () => {});
1169
+ proc.unref();
1170
+
1171
+ for (let i = 0; i < 20; i++) {
1172
+ await sleep(500);
1173
+ const ready = await checkBackend(getBackendUrl());
1174
+ if (ready) {
1175
+ console.log(chalk.gray(" Backend started ✓\n"));
1176
+ return proc;
1177
+ }
1178
+ }
1179
+
1180
+ proc.kill("SIGTERM");
1181
+ return null;
1182
+ }
1183
+ }
1184
+
1185
+ return null;
1186
+ }
1187
+
1188
+ function sleep(ms: number): Promise<void> {
1189
+ return new Promise((resolve) => setTimeout(resolve, ms));
1190
+ }