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,170 @@
1
+ import chalk from "chalk";
2
+ import { getBackendUrl } from "../config";
3
+
4
+ export interface CoverageOptions {
5
+ env?: string;
6
+ json?: boolean;
7
+ failUnder?: string;
8
+ staleHours?: string;
9
+ }
10
+
11
+ interface CoverageEntry {
12
+ functionName: string;
13
+ module: string;
14
+ language: string;
15
+ environment: string;
16
+ firstSeen: string;
17
+ lastObserved: string;
18
+ snapshots: number;
19
+ variants: number;
20
+ errors: number;
21
+ isStale: boolean;
22
+ hasTypes: boolean;
23
+ health: number;
24
+ }
25
+
26
+ interface CoverageSummary {
27
+ total: number;
28
+ withTypes: number;
29
+ withoutTypes: number;
30
+ fresh: number;
31
+ stale: number;
32
+ withErrors: number;
33
+ withMultipleVariants: number;
34
+ health: number;
35
+ staleThresholdHours: number;
36
+ }
37
+
38
+ interface CoverageResponse {
39
+ summary: CoverageSummary;
40
+ entries: CoverageEntry[];
41
+ }
42
+
43
+ /**
44
+ * `trickle coverage` — Type observation health report.
45
+ *
46
+ * Shows per-function type coverage, staleness, variant counts,
47
+ * error counts, and an overall health score. Useful for CI gates.
48
+ */
49
+ export async function coverageCommand(opts: CoverageOptions): Promise<void> {
50
+ const backendUrl = getBackendUrl();
51
+
52
+ // Fetch coverage data
53
+ const url = new URL("/api/coverage", backendUrl);
54
+ if (opts.env) url.searchParams.set("env", opts.env);
55
+ if (opts.staleHours) url.searchParams.set("stale_hours", opts.staleHours);
56
+
57
+ let data: CoverageResponse;
58
+ try {
59
+ const res = await fetch(url.toString());
60
+ if (!res.ok) {
61
+ const body = await res.text();
62
+ throw new Error(`HTTP ${res.status}: ${body}`);
63
+ }
64
+ data = (await res.json()) as CoverageResponse;
65
+ } catch (err: unknown) {
66
+ if (err instanceof Error && err.message.startsWith("HTTP ")) {
67
+ console.error(chalk.red(`\n Error: ${err.message}\n`));
68
+ } else {
69
+ console.error(chalk.red(`\n Cannot connect to trickle backend at ${chalk.bold(backendUrl)}.`));
70
+ console.error(chalk.red(" Is the backend running?\n"));
71
+ }
72
+ process.exit(1);
73
+ }
74
+
75
+ // JSON output mode
76
+ if (opts.json) {
77
+ console.log(JSON.stringify(data, null, 2));
78
+ checkThreshold(data.summary.health, opts.failUnder);
79
+ return;
80
+ }
81
+
82
+ const { summary, entries } = data;
83
+
84
+ // Header
85
+ console.log("");
86
+ console.log(chalk.bold(" trickle coverage"));
87
+ console.log(chalk.gray(" " + "─".repeat(60)));
88
+ if (opts.env) {
89
+ console.log(chalk.gray(` Environment: ${opts.env}`));
90
+ }
91
+ console.log(chalk.gray(` Stale threshold: ${summary.staleThresholdHours}h`));
92
+ console.log(chalk.gray(" " + "─".repeat(60)));
93
+ console.log("");
94
+
95
+ if (entries.length === 0) {
96
+ console.log(chalk.yellow(" No functions observed yet."));
97
+ console.log(chalk.gray(" Instrument your app and make some requests first.\n"));
98
+ checkThreshold(0, opts.failUnder);
99
+ return;
100
+ }
101
+
102
+ // Health score bar
103
+ const healthColor = summary.health >= 80 ? chalk.green : summary.health >= 50 ? chalk.yellow : chalk.red;
104
+ const barFilled = Math.round(summary.health / 5);
105
+ const barEmpty = 20 - barFilled;
106
+ const healthBar = healthColor("█".repeat(barFilled)) + chalk.gray("░".repeat(barEmpty));
107
+ console.log(` Health: ${healthBar} ${healthColor(chalk.bold(`${summary.health}%`))}`);
108
+ console.log("");
109
+
110
+ // Summary stats
111
+ console.log(chalk.bold(" Summary"));
112
+ console.log(` ${chalk.cyan(String(summary.total))} functions observed`);
113
+ console.log(` ${chalk.green(String(summary.withTypes))} with types ${chalk.gray(`${summary.withoutTypes} without`)}`);
114
+ console.log(` ${chalk.green(String(summary.fresh))} fresh ${summary.stale > 0 ? chalk.yellow(`${summary.stale} stale`) : chalk.gray("0 stale")}`);
115
+ if (summary.withErrors > 0) {
116
+ console.log(` ${chalk.red(String(summary.withErrors))} with errors`);
117
+ }
118
+ if (summary.withMultipleVariants > 0) {
119
+ console.log(` ${chalk.yellow(String(summary.withMultipleVariants))} with multiple type variants`);
120
+ }
121
+ console.log("");
122
+
123
+ // Per-function table
124
+ console.log(chalk.bold(" Functions"));
125
+ console.log(chalk.gray(" " + "─".repeat(60)));
126
+
127
+ for (const entry of entries) {
128
+ const statusIcon = !entry.hasTypes
129
+ ? chalk.red("✗")
130
+ : entry.isStale
131
+ ? chalk.yellow("◦")
132
+ : chalk.green("✓");
133
+
134
+ const nameStr = chalk.bold(entry.functionName);
135
+ const moduleStr = entry.module !== "api" && entry.module !== "default" ? chalk.gray(` [${entry.module}]`) : "";
136
+
137
+ const badges: string[] = [];
138
+ if (entry.snapshots > 0) badges.push(chalk.gray(`${entry.snapshots} snap`));
139
+ if (entry.variants > 1) badges.push(chalk.yellow(`${entry.variants} variants`));
140
+ if (entry.errors > 0) badges.push(chalk.red(`${entry.errors} err`));
141
+ if (entry.isStale) badges.push(chalk.yellow("stale"));
142
+
143
+ const healthStr = entry.health >= 80
144
+ ? chalk.green(`${entry.health}%`)
145
+ : entry.health >= 50
146
+ ? chalk.yellow(`${entry.health}%`)
147
+ : chalk.red(`${entry.health}%`);
148
+
149
+ console.log(` ${statusIcon} ${nameStr}${moduleStr} ${badges.join(" ")} ${healthStr}`);
150
+ }
151
+
152
+ console.log(chalk.gray(" " + "─".repeat(60)));
153
+ console.log("");
154
+
155
+ checkThreshold(summary.health, opts.failUnder);
156
+ }
157
+
158
+ function checkThreshold(health: number, failUnder?: string): void {
159
+ if (!failUnder) return;
160
+ const threshold = parseInt(failUnder, 10);
161
+ if (isNaN(threshold)) return;
162
+
163
+ if (health < threshold) {
164
+ console.error(
165
+ chalk.red(` Coverage health ${health}% is below threshold ${threshold}%`),
166
+ );
167
+ console.error("");
168
+ process.exit(1);
169
+ }
170
+ }
@@ -0,0 +1,46 @@
1
+ import chalk from "chalk";
2
+ import { getBackendUrl } from "../config";
3
+
4
+ export async function dashboardCommand(): Promise<void> {
5
+ const backendUrl = getBackendUrl();
6
+ const dashboardUrl = `${backendUrl}/dashboard`;
7
+
8
+ // Check backend is reachable
9
+ try {
10
+ const res = await fetch(`${backendUrl}/api/health`, { signal: AbortSignal.timeout(3000) });
11
+ if (!res.ok) throw new Error("not ok");
12
+ } catch {
13
+ console.error(chalk.red(`\n Cannot reach trickle backend at ${chalk.bold(backendUrl)}`));
14
+ console.error(chalk.gray(" Start the backend first: cd packages/backend && npm start\n"));
15
+ process.exit(1);
16
+ }
17
+
18
+ console.log("");
19
+ console.log(chalk.bold(" trickle dashboard"));
20
+ console.log(chalk.gray(" " + "─".repeat(50)));
21
+ console.log(chalk.gray(` Opening ${chalk.bold(dashboardUrl)}`));
22
+ console.log(chalk.gray(" " + "─".repeat(50)));
23
+ console.log("");
24
+
25
+ // Open in browser
26
+ const { exec } = await import("child_process");
27
+ const platform = process.platform;
28
+ let cmd: string;
29
+ if (platform === "darwin") {
30
+ cmd = `open "${dashboardUrl}"`;
31
+ } else if (platform === "win32") {
32
+ cmd = `start "" "${dashboardUrl}"`;
33
+ } else {
34
+ cmd = `xdg-open "${dashboardUrl}"`;
35
+ }
36
+
37
+ exec(cmd, (err) => {
38
+ if (err) {
39
+ console.log(chalk.yellow(` Could not open browser automatically.`));
40
+ console.log(chalk.yellow(` Open this URL manually: ${dashboardUrl}\n`));
41
+ }
42
+ });
43
+
44
+ // Keep the process alive briefly so the user sees the message
45
+ await new Promise((resolve) => setTimeout(resolve, 1000));
46
+ }
@@ -0,0 +1,323 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { spawn, ChildProcess } from "child_process";
4
+ import chalk from "chalk";
5
+ import { getBackendUrl } from "../config";
6
+ import { fetchCodegen } from "../api-client";
7
+
8
+ export interface DevOptions {
9
+ port?: string;
10
+ out?: string;
11
+ client?: boolean;
12
+ python?: boolean;
13
+ }
14
+
15
+ /**
16
+ * `trickle dev` — All-in-one development command.
17
+ *
18
+ * Starts your app with auto-instrumentation and watches for type changes,
19
+ * regenerating type files as requests flow through. One command replaces
20
+ * the 2-terminal setup of `trickle:start` + `trickle:dev`.
21
+ */
22
+ export async function devCommand(command: string | undefined, opts: DevOptions): Promise<void> {
23
+ const backendUrl = getBackendUrl();
24
+
25
+ // Resolve the app command to run
26
+ const appCommand = resolveAppCommand(command);
27
+ if (!appCommand) {
28
+ console.error(chalk.red("\n Could not determine app command to run."));
29
+ console.error(chalk.gray(" Provide a command: trickle dev \"node app.js\""));
30
+ console.error(chalk.gray(" Or ensure package.json has a start or dev script.\n"));
31
+ process.exit(1);
32
+ }
33
+
34
+ // Determine output paths
35
+ const isPython = opts.python === true;
36
+ const typesOut = opts.out || (isPython ? ".trickle/types.pyi" : ".trickle/types.d.ts");
37
+ const clientOut = opts.client ? ".trickle/api-client.ts" : undefined;
38
+
39
+ // Ensure .trickle directory exists
40
+ const trickleDir = path.resolve(".trickle");
41
+ if (!fs.existsSync(trickleDir)) {
42
+ fs.mkdirSync(trickleDir, { recursive: true });
43
+ }
44
+
45
+ // Check backend connectivity
46
+ const backendReachable = await checkBackend(backendUrl);
47
+ if (!backendReachable) {
48
+ console.error(chalk.red(`\n Cannot reach trickle backend at ${chalk.bold(backendUrl)}`));
49
+ console.error(chalk.gray(" Start the backend first: cd packages/backend && npm start"));
50
+ console.error(chalk.gray(" Or set TRICKLE_BACKEND_URL to point to a running backend.\n"));
51
+ process.exit(1);
52
+ }
53
+
54
+ // Print header
55
+ console.log("");
56
+ console.log(chalk.bold(" trickle dev"));
57
+ console.log(chalk.gray(" " + "─".repeat(50)));
58
+ console.log(chalk.gray(` App command: ${appCommand}`));
59
+ console.log(chalk.gray(` Backend: ${backendUrl}`));
60
+ console.log(chalk.gray(` Types output: ${typesOut}`));
61
+ if (clientOut) {
62
+ console.log(chalk.gray(` Client output: ${clientOut}`));
63
+ }
64
+ console.log(chalk.gray(" " + "─".repeat(50)));
65
+ console.log("");
66
+
67
+ // Inject -r trickle-observe/register into the command
68
+ const instrumentedCommand = injectRegister(appCommand);
69
+
70
+ // Start the app process
71
+ const appProc = startApp(instrumentedCommand, backendUrl);
72
+
73
+ // Start the codegen watcher
74
+ const stopWatcher = startCodegenWatcher(typesOut, clientOut, isPython);
75
+
76
+ // Handle cleanup
77
+ let shuttingDown = false;
78
+ function cleanup() {
79
+ if (shuttingDown) return;
80
+ shuttingDown = true;
81
+ console.log(chalk.gray("\n Shutting down..."));
82
+ stopWatcher();
83
+ if (!appProc.killed) {
84
+ appProc.kill("SIGTERM");
85
+ }
86
+ // Give processes time to clean up
87
+ setTimeout(() => {
88
+ process.exit(0);
89
+ }, 500);
90
+ }
91
+
92
+ process.on("SIGINT", cleanup);
93
+ process.on("SIGTERM", cleanup);
94
+
95
+ appProc.on("exit", (code) => {
96
+ if (!shuttingDown) {
97
+ if (code !== null && code !== 0) {
98
+ console.log(chalk.red(`\n App exited with code ${code}`));
99
+ } else {
100
+ console.log(chalk.gray("\n App exited."));
101
+ }
102
+ cleanup();
103
+ }
104
+ });
105
+ }
106
+
107
+ /**
108
+ * Resolve the command to run. Checks:
109
+ * 1. Explicit command argument
110
+ * 2. package.json scripts.start
111
+ * 3. package.json scripts.dev
112
+ */
113
+ function resolveAppCommand(explicitCommand: string | undefined): string | null {
114
+ if (explicitCommand) {
115
+ return explicitCommand;
116
+ }
117
+
118
+ // Try reading package.json
119
+ const pkgPath = path.resolve("package.json");
120
+ if (fs.existsSync(pkgPath)) {
121
+ try {
122
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
123
+ const scripts = pkg.scripts || {};
124
+ // Prefer trickle:start if it exists (already instrumented)
125
+ if (scripts["trickle:start"]) {
126
+ return scripts["trickle:start"];
127
+ }
128
+ if (scripts.start) {
129
+ return scripts.start;
130
+ }
131
+ if (scripts.dev) {
132
+ return scripts.dev;
133
+ }
134
+ } catch {
135
+ // ignore
136
+ }
137
+ }
138
+
139
+ return null;
140
+ }
141
+
142
+ /**
143
+ * Inject `-r trickle-observe/register` into a node/ts-node/nodemon command.
144
+ * If the command already has it, return as-is.
145
+ */
146
+ function injectRegister(command: string): string {
147
+ // Already instrumented
148
+ if (command.includes("trickle-observe/register") || command.includes("trickle\\register")) {
149
+ return command;
150
+ }
151
+
152
+ // Inject -r flag
153
+ if (/\bnode\s/.test(command)) {
154
+ return command.replace(/\bnode\s/, "node -r trickle-observe/register ");
155
+ }
156
+ if (/\bts-node\s/.test(command)) {
157
+ return command.replace(/\bts-node\s/, "ts-node -r trickle-observe/register ");
158
+ }
159
+ if (/\bnodemon\s/.test(command)) {
160
+ return command.replace(/\bnodemon\s/, "nodemon -r trickle-observe/register ");
161
+ }
162
+
163
+ // Can't inject — run as-is with a warning
164
+ console.log(chalk.yellow(" Warning: Could not inject -r trickle-observe/register into command."));
165
+ console.log(chalk.yellow(" Auto-instrumentation may not work. Consider using:"));
166
+ console.log(chalk.yellow(` node -r trickle-observe/register ${command}\n`));
167
+ return command;
168
+ }
169
+
170
+ /**
171
+ * Start the app as a child process with environment variables set.
172
+ */
173
+ function startApp(command: string, backendUrl: string): ChildProcess {
174
+ const parts = command.split(/\s+/);
175
+ const cmd = parts[0];
176
+ const args = parts.slice(1);
177
+
178
+ const prefix = chalk.cyan("[app]");
179
+
180
+ const proc = spawn(cmd, args, {
181
+ stdio: ["inherit", "pipe", "pipe"],
182
+ env: {
183
+ ...process.env,
184
+ TRICKLE_BACKEND_URL: backendUrl,
185
+ },
186
+ shell: true,
187
+ });
188
+
189
+ proc.stdout?.on("data", (data: Buffer) => {
190
+ const lines = data.toString().split("\n");
191
+ for (const line of lines) {
192
+ if (line.trim()) {
193
+ console.log(`${prefix} ${line}`);
194
+ }
195
+ }
196
+ });
197
+
198
+ proc.stderr?.on("data", (data: Buffer) => {
199
+ const lines = data.toString().split("\n");
200
+ for (const line of lines) {
201
+ if (line.trim()) {
202
+ console.error(`${prefix} ${chalk.red(line)}`);
203
+ }
204
+ }
205
+ });
206
+
207
+ return proc;
208
+ }
209
+
210
+ /**
211
+ * Start a codegen watcher that polls for type changes.
212
+ * Returns a stop function.
213
+ */
214
+ function startCodegenWatcher(
215
+ typesOut: string,
216
+ clientOut: string | undefined,
217
+ isPython: boolean,
218
+ ): () => void {
219
+ const prefix = chalk.magenta("[types]");
220
+ const language = isPython ? "python" : undefined;
221
+ let lastTypesContent = "";
222
+ let lastClientContent = "";
223
+ let stopped = false;
224
+ let firstRun = true;
225
+
226
+ // Wait a bit before first poll to let the app start
227
+ let initialDelay = true;
228
+
229
+ const poll = async () => {
230
+ if (stopped) return;
231
+
232
+ try {
233
+ // Generate types
234
+ const result = await fetchCodegen({ language });
235
+ const types = result.types;
236
+
237
+ if (types !== lastTypesContent) {
238
+ lastTypesContent = types;
239
+ const resolvedPath = path.resolve(typesOut);
240
+ const dir = path.dirname(resolvedPath);
241
+ if (!fs.existsSync(dir)) {
242
+ fs.mkdirSync(dir, { recursive: true });
243
+ }
244
+ fs.writeFileSync(resolvedPath, types, "utf-8");
245
+
246
+ if (!firstRun) {
247
+ const count = countTypes(types);
248
+ console.log(`${prefix} ${chalk.green("Updated")} ${chalk.bold(typesOut)} ${chalk.gray(`(${count} types)`)}`);
249
+ } else {
250
+ firstRun = false;
251
+ }
252
+ }
253
+
254
+ // Generate client if requested
255
+ if (clientOut) {
256
+ const clientResult = await fetchCodegen({ format: "client" });
257
+ if (clientResult.types !== lastClientContent) {
258
+ lastClientContent = clientResult.types;
259
+ const resolvedPath = path.resolve(clientOut);
260
+ const dir = path.dirname(resolvedPath);
261
+ if (!fs.existsSync(dir)) {
262
+ fs.mkdirSync(dir, { recursive: true });
263
+ }
264
+ fs.writeFileSync(resolvedPath, clientResult.types, "utf-8");
265
+
266
+ if (!firstRun) {
267
+ console.log(`${prefix} ${chalk.green("Updated")} ${chalk.bold(clientOut)}`);
268
+ }
269
+ }
270
+ }
271
+ } catch {
272
+ // Backend might not be ready yet or app hasn't served requests — silently retry
273
+ if (!initialDelay) {
274
+ // Only log after initial startup period
275
+ }
276
+ }
277
+
278
+ initialDelay = false;
279
+ };
280
+
281
+ // First poll after 3s (give app time to start), then every 3s
282
+ const startTimeout = setTimeout(() => {
283
+ poll();
284
+ const interval = setInterval(poll, 3000);
285
+
286
+ // Store interval for cleanup
287
+ (startTimeout as unknown as Record<string, unknown>).__interval = interval;
288
+ }, 3000);
289
+
290
+ let intervalRef: ReturnType<typeof setInterval> | null = null;
291
+
292
+ // Use a different approach: start polling immediately with setInterval
293
+ const interval = setInterval(async () => {
294
+ if (stopped) return;
295
+ await poll();
296
+ }, 3000);
297
+ intervalRef = interval;
298
+
299
+ // Initial poll after 2s delay
300
+ const initTimer = setTimeout(poll, 2000);
301
+
302
+ return () => {
303
+ stopped = true;
304
+ clearTimeout(startTimeout);
305
+ clearTimeout(initTimer);
306
+ if (intervalRef) clearInterval(intervalRef);
307
+ };
308
+ }
309
+
310
+ function countTypes(code: string): number {
311
+ const tsMatches = code.match(/export (interface|type) /g);
312
+ const pyMatches = code.match(/class \w+\(TypedDict\)/g);
313
+ return (tsMatches?.length ?? 0) + (pyMatches?.length ?? 0);
314
+ }
315
+
316
+ async function checkBackend(url: string): Promise<boolean> {
317
+ try {
318
+ const res = await fetch(`${url}/api/health`, { signal: AbortSignal.timeout(3000) });
319
+ return res.ok;
320
+ } catch {
321
+ return false;
322
+ }
323
+ }
@@ -0,0 +1,99 @@
1
+ import chalk from "chalk";
2
+ import { fetchDiffReport, DiffReportEntry } from "../api-client";
3
+ import { formatDiffs } from "../formatters/diff-formatter";
4
+ import { envBadge, timeBadge } from "../ui/badges";
5
+ import { parseSince } from "../ui/helpers";
6
+
7
+ export interface DiffOptions {
8
+ since?: string;
9
+ env?: string;
10
+ env1?: string;
11
+ env2?: string;
12
+ }
13
+
14
+ export async function diffCommand(opts: DiffOptions): Promise<void> {
15
+ try {
16
+ // Parse --since into a datetime string for the backend
17
+ let sinceStr: string | undefined;
18
+ if (opts.since) {
19
+ sinceStr = parseSince(opts.since);
20
+ }
21
+
22
+ const result = await fetchDiffReport({
23
+ since: sinceStr,
24
+ env: opts.env,
25
+ env1: opts.env1,
26
+ env2: opts.env2,
27
+ });
28
+
29
+ console.log("");
30
+
31
+ if (result.mode === "cross-env") {
32
+ console.log(
33
+ chalk.white.bold(" Type drift: ") +
34
+ envBadge(result.env1!) +
35
+ chalk.gray(" → ") +
36
+ envBadge(result.env2!)
37
+ );
38
+ } else {
39
+ const label = opts.since
40
+ ? `changes in the last ${opts.since}`
41
+ : "all type changes";
42
+ console.log(chalk.white.bold(` Type drift: ${label}`));
43
+ if (opts.env) {
44
+ console.log(chalk.gray(` Environment: ${opts.env}`));
45
+ }
46
+ }
47
+
48
+ console.log(chalk.gray(" " + "─".repeat(50)));
49
+
50
+ if (result.total === 0) {
51
+ console.log("");
52
+ console.log(chalk.green(" No type drift detected."));
53
+ if (opts.since) {
54
+ console.log(chalk.gray(` No functions had type changes in the last ${opts.since}.`));
55
+ }
56
+ console.log("");
57
+ return;
58
+ }
59
+
60
+ console.log(
61
+ chalk.gray(` ${result.total} function${result.total === 1 ? "" : "s"} with type changes`)
62
+ );
63
+
64
+ for (const entry of result.entries) {
65
+ console.log("");
66
+ console.log(
67
+ chalk.white.bold(` ${entry.functionName}`) +
68
+ chalk.gray(` (${entry.module})`)
69
+ );
70
+
71
+ // Show from/to metadata
72
+ console.log(
73
+ chalk.gray(" from: ") +
74
+ envBadge(entry.from.env) +
75
+ chalk.gray(" " + timeBadge(entry.from.observed_at))
76
+ );
77
+ console.log(
78
+ chalk.gray(" to: ") +
79
+ envBadge(entry.to.env) +
80
+ chalk.gray(" " + timeBadge(entry.to.observed_at))
81
+ );
82
+ console.log("");
83
+
84
+ // Indent diffs by 2 extra spaces
85
+ const formattedDiffs = formatDiffs(entry.diffs);
86
+ const indented = formattedDiffs
87
+ .split("\n")
88
+ .map((line) => " " + line)
89
+ .join("\n");
90
+ console.log(indented);
91
+ }
92
+
93
+ console.log("");
94
+ } catch (err: unknown) {
95
+ if (err instanceof Error) {
96
+ console.error(chalk.red(`\n Error: ${err.message}\n`));
97
+ }
98
+ }
99
+ }