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,419 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import chalk from "chalk";
4
+
5
+ export interface InitOptions {
6
+ dir?: string;
7
+ python?: boolean;
8
+ }
9
+
10
+ interface ProjectInfo {
11
+ dir: string;
12
+ hasPackageJson: boolean;
13
+ hasTsConfig: boolean;
14
+ isPython: boolean;
15
+ framework: "express" | "fastapi" | "flask" | "django" | null;
16
+ entryFile: string | null;
17
+ packageJson: Record<string, unknown> | null;
18
+ tsConfig: Record<string, unknown> | null;
19
+ }
20
+
21
+ function detectProject(dir: string, forcePython: boolean): ProjectInfo {
22
+ const info: ProjectInfo = {
23
+ dir,
24
+ hasPackageJson: false,
25
+ hasTsConfig: false,
26
+ isPython: forcePython,
27
+ framework: null,
28
+ entryFile: null,
29
+ packageJson: null,
30
+ tsConfig: null,
31
+ };
32
+
33
+ // Check for package.json
34
+ const pkgPath = path.join(dir, "package.json");
35
+ if (fs.existsSync(pkgPath)) {
36
+ info.hasPackageJson = true;
37
+ try {
38
+ info.packageJson = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
39
+ } catch {
40
+ // ignore parse errors
41
+ }
42
+ }
43
+
44
+ // Check for tsconfig.json
45
+ const tsPath = path.join(dir, "tsconfig.json");
46
+ if (fs.existsSync(tsPath)) {
47
+ info.hasTsConfig = true;
48
+ try {
49
+ // Strip comments (simple approach: remove // and /* */ comments)
50
+ const raw = fs.readFileSync(tsPath, "utf-8");
51
+ const cleaned = raw
52
+ .replace(/\/\/.*$/gm, "")
53
+ .replace(/\/\*[\s\S]*?\*\//g, "");
54
+ info.tsConfig = JSON.parse(cleaned);
55
+ } catch {
56
+ // ignore parse errors
57
+ }
58
+ }
59
+
60
+ // Check for Python project
61
+ if (
62
+ fs.existsSync(path.join(dir, "pyproject.toml")) ||
63
+ fs.existsSync(path.join(dir, "setup.py")) ||
64
+ fs.existsSync(path.join(dir, "requirements.txt"))
65
+ ) {
66
+ info.isPython = true;
67
+ }
68
+
69
+ // Detect framework from dependencies
70
+ if (info.packageJson) {
71
+ const deps = {
72
+ ...(info.packageJson.dependencies as Record<string, string> | undefined),
73
+ ...(info.packageJson.devDependencies as Record<string, string> | undefined),
74
+ };
75
+ if (deps.express) info.framework = "express";
76
+ }
77
+
78
+ if (info.isPython) {
79
+ // Check requirements.txt or pyproject.toml for framework
80
+ const reqPath = path.join(dir, "requirements.txt");
81
+ if (fs.existsSync(reqPath)) {
82
+ const reqs = fs.readFileSync(reqPath, "utf-8").toLowerCase();
83
+ if (reqs.includes("fastapi")) info.framework = "fastapi";
84
+ else if (reqs.includes("flask")) info.framework = "flask";
85
+ else if (reqs.includes("django")) info.framework = "django";
86
+ }
87
+ }
88
+
89
+ // Detect entry file
90
+ if (info.packageJson) {
91
+ const main = info.packageJson.main as string | undefined;
92
+ if (main && fs.existsSync(path.join(dir, main))) {
93
+ info.entryFile = main;
94
+ } else {
95
+ // Common entry points
96
+ for (const candidate of ["src/index.ts", "src/index.js", "index.ts", "index.js", "app.ts", "app.js", "server.ts", "server.js"]) {
97
+ if (fs.existsSync(path.join(dir, candidate))) {
98
+ info.entryFile = candidate;
99
+ break;
100
+ }
101
+ }
102
+ }
103
+ }
104
+
105
+ if (info.isPython && !info.entryFile) {
106
+ for (const candidate of ["app.py", "main.py", "server.py", "wsgi.py"]) {
107
+ if (fs.existsSync(path.join(dir, candidate))) {
108
+ info.entryFile = candidate;
109
+ break;
110
+ }
111
+ }
112
+ }
113
+
114
+ return info;
115
+ }
116
+
117
+ function createTrickleConfig(dir: string, info: ProjectInfo): boolean {
118
+ const configPath = path.join(dir, ".tricklerc.json");
119
+ if (fs.existsSync(configPath)) return false;
120
+
121
+ // Also check for package.json "trickle" field
122
+ if (info.packageJson && (info.packageJson as Record<string, unknown>).trickle) {
123
+ return false;
124
+ }
125
+
126
+ const config: Record<string, unknown> = {};
127
+
128
+ // Suggest stubs directory based on project structure
129
+ if (fs.existsSync(path.join(dir, "src"))) {
130
+ config.stubs = "src/";
131
+ } else {
132
+ config.stubs = ".";
133
+ }
134
+
135
+ // Default exclude patterns
136
+ config.exclude = ["node_modules", "dist", "build", "__pycache__"];
137
+
138
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
139
+ return true;
140
+ }
141
+
142
+ function ensureTrickleDir(dir: string): string {
143
+ const trickleDir = path.join(dir, ".trickle");
144
+ if (!fs.existsSync(trickleDir)) {
145
+ fs.mkdirSync(trickleDir, { recursive: true });
146
+ }
147
+ return trickleDir;
148
+ }
149
+
150
+ function writeInitialTypes(trickleDir: string, isPython: boolean): void {
151
+ if (isPython) {
152
+ const pyPath = path.join(trickleDir, "types.pyi");
153
+ if (!fs.existsSync(pyPath)) {
154
+ fs.writeFileSync(
155
+ pyPath,
156
+ [
157
+ "# Auto-generated by trickle from runtime type observations",
158
+ "# Run your app to populate types, then: trickle codegen --python --out .trickle/types.pyi",
159
+ "#",
160
+ "# This file will be updated automatically when using: trickle codegen --python --watch --out .trickle/types.pyi",
161
+ "",
162
+ "from typing import TypedDict",
163
+ "",
164
+ "# Types will appear here after your app processes its first requests.",
165
+ "",
166
+ ].join("\n"),
167
+ "utf-8",
168
+ );
169
+ }
170
+ } else {
171
+ const tsPath = path.join(trickleDir, "types.d.ts");
172
+ if (!fs.existsSync(tsPath)) {
173
+ fs.writeFileSync(
174
+ tsPath,
175
+ [
176
+ "// Auto-generated by trickle from runtime type observations",
177
+ "// Run your app to populate types, then: trickle codegen --out .trickle/types.d.ts",
178
+ "//",
179
+ "// This file will be updated automatically when using: trickle codegen --watch --out .trickle/types.d.ts",
180
+ "",
181
+ "// Types will appear here after your app processes its first requests.",
182
+ "",
183
+ ].join("\n"),
184
+ "utf-8",
185
+ );
186
+ }
187
+
188
+ // Also create an api-client placeholder
189
+ const clientPath = path.join(trickleDir, "api-client.ts");
190
+ if (!fs.existsSync(clientPath)) {
191
+ fs.writeFileSync(
192
+ clientPath,
193
+ [
194
+ "// Auto-generated typed API client by trickle",
195
+ "// Run your app to populate types, then: trickle codegen --client --out .trickle/api-client.ts",
196
+ "",
197
+ "// A fully-typed fetch client will appear here after your app serves its first API requests.",
198
+ "",
199
+ ].join("\n"),
200
+ "utf-8",
201
+ );
202
+ }
203
+ }
204
+ }
205
+
206
+ function updateTsConfig(dir: string, info: ProjectInfo): boolean {
207
+ const tsConfigPath = path.join(dir, "tsconfig.json");
208
+
209
+ if (!info.hasTsConfig || !info.tsConfig) {
210
+ // No tsconfig — create a minimal one that includes .trickle
211
+ if (!info.isPython) {
212
+ const newConfig = {
213
+ compilerOptions: {
214
+ target: "ES2022",
215
+ module: "commonjs",
216
+ strict: true,
217
+ esModuleInterop: true,
218
+ skipLibCheck: true,
219
+ outDir: "./dist",
220
+ rootDir: "./src",
221
+ },
222
+ include: ["src", ".trickle"],
223
+ };
224
+ fs.writeFileSync(tsConfigPath, JSON.stringify(newConfig, null, 2) + "\n", "utf-8");
225
+ return true;
226
+ }
227
+ return false;
228
+ }
229
+
230
+ // Read the raw file to preserve formatting as much as possible
231
+ const raw = fs.readFileSync(tsConfigPath, "utf-8");
232
+ const config = info.tsConfig;
233
+
234
+ // Check if .trickle is already included
235
+ const include = config.include as string[] | undefined;
236
+ if (include && include.some((p: string) => p === ".trickle" || p.startsWith(".trickle/"))) {
237
+ return false; // Already configured
238
+ }
239
+
240
+ // Add .trickle to include array
241
+ if (include) {
242
+ include.push(".trickle");
243
+ } else {
244
+ (config as Record<string, unknown>).include = ["src", ".trickle"];
245
+ }
246
+
247
+ fs.writeFileSync(tsConfigPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
248
+ return true;
249
+ }
250
+
251
+ function updatePackageJson(dir: string, info: ProjectInfo): { scriptsAdded: string[] } {
252
+ if (!info.hasPackageJson || !info.packageJson) {
253
+ return { scriptsAdded: [] };
254
+ }
255
+
256
+ const pkgPath = path.join(dir, "package.json");
257
+ const pkg = info.packageJson;
258
+ const scripts = (pkg.scripts as Record<string, string>) || {};
259
+ const added: string[] = [];
260
+
261
+ // Add trickle:dev script for watch mode
262
+ if (!scripts["trickle:dev"]) {
263
+ const outFile = info.isPython ? ".trickle/types.pyi" : ".trickle/types.d.ts";
264
+ const langFlag = info.isPython ? " --python" : "";
265
+ scripts["trickle:dev"] = `trickle codegen${langFlag} --watch --out ${outFile}`;
266
+ added.push("trickle:dev");
267
+ }
268
+
269
+ // Add trickle:client script for API client generation
270
+ if (!info.isPython && !scripts["trickle:client"]) {
271
+ scripts["trickle:client"] = "trickle codegen --client --out .trickle/api-client.ts";
272
+ added.push("trickle:client");
273
+ }
274
+
275
+ // Add trickle:mock script
276
+ if (!scripts["trickle:mock"]) {
277
+ scripts["trickle:mock"] = "trickle mock";
278
+ added.push("trickle:mock");
279
+ }
280
+
281
+ // Detect current start script and create a trickle-wrapped version
282
+ const startScript = scripts.start || scripts.dev;
283
+ if (startScript && !scripts["trickle:start"]) {
284
+ // Check if it's a node command we can add -r to
285
+ if (startScript.match(/\bnode\s/)) {
286
+ scripts["trickle:start"] = startScript.replace(/\bnode\s/, "node -r trickle-observe/register ");
287
+ added.push("trickle:start");
288
+ } else if (startScript.match(/\bts-node\s/)) {
289
+ scripts["trickle:start"] = startScript.replace(/\bts-node\s/, "ts-node -r trickle-observe/register ");
290
+ added.push("trickle:start");
291
+ } else if (startScript.match(/\bnodemon\s/)) {
292
+ scripts["trickle:start"] = startScript.replace(/\bnodemon\s/, "nodemon -r trickle-observe/register ");
293
+ added.push("trickle:start");
294
+ }
295
+ }
296
+
297
+ if (added.length > 0) {
298
+ pkg.scripts = scripts;
299
+ fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n", "utf-8");
300
+ }
301
+
302
+ return { scriptsAdded: added };
303
+ }
304
+
305
+ function updateGitignore(dir: string): boolean {
306
+ const giPath = path.join(dir, ".gitignore");
307
+ let content = "";
308
+
309
+ if (fs.existsSync(giPath)) {
310
+ content = fs.readFileSync(giPath, "utf-8");
311
+ if (content.includes(".trickle")) {
312
+ return false; // Already has it
313
+ }
314
+ }
315
+
316
+ const addition = content.endsWith("\n") || content === ""
317
+ ? ".trickle/\n"
318
+ : "\n.trickle/\n";
319
+
320
+ fs.writeFileSync(giPath, content + addition, "utf-8");
321
+ return true;
322
+ }
323
+
324
+ export async function initCommand(opts: InitOptions): Promise<void> {
325
+ const dir = path.resolve(opts.dir || ".");
326
+
327
+ console.log("");
328
+ console.log(chalk.bold(" trickle init"));
329
+ console.log("");
330
+
331
+ // Step 1: Detect project
332
+ const info = detectProject(dir, opts.python === true);
333
+
334
+ if (!info.hasPackageJson && !info.isPython) {
335
+ console.log(chalk.yellow(" No package.json or Python project detected."));
336
+ console.log(chalk.gray(" Run this command from your project root.\n"));
337
+ process.exit(1);
338
+ }
339
+
340
+ const lang = info.isPython ? "Python" : "Node.js";
341
+ console.log(chalk.gray(` Detected: ${lang} project${info.framework ? ` (${info.framework})` : ""}`));
342
+ if (info.entryFile) {
343
+ console.log(chalk.gray(` Entry: ${info.entryFile}`));
344
+ }
345
+ console.log("");
346
+
347
+ // Step 2: Create .tricklerc.json
348
+ const configCreated = createTrickleConfig(dir, info);
349
+ if (configCreated) {
350
+ console.log(` ${chalk.green("+")} Created ${chalk.bold(".tricklerc.json")} — project config`);
351
+ } else {
352
+ console.log(` ${chalk.gray("-")} .tricklerc.json already exists`);
353
+ }
354
+
355
+ // Step 3: Create .trickle directory
356
+ const trickleDir = ensureTrickleDir(dir);
357
+ console.log(` ${chalk.green("+")} Created ${chalk.bold(".trickle/")} directory`);
358
+
359
+ // Step 3: Write initial type files
360
+ writeInitialTypes(trickleDir, info.isPython);
361
+ if (info.isPython) {
362
+ console.log(` ${chalk.green("+")} Created ${chalk.bold(".trickle/types.pyi")} (type stubs)`);
363
+ } else {
364
+ console.log(` ${chalk.green("+")} Created ${chalk.bold(".trickle/types.d.ts")} (type declarations)`);
365
+ console.log(` ${chalk.green("+")} Created ${chalk.bold(".trickle/api-client.ts")} (typed API client)`);
366
+ }
367
+
368
+ // Step 4: Update tsconfig.json
369
+ if (!info.isPython) {
370
+ const tsUpdated = updateTsConfig(dir, info);
371
+ if (tsUpdated) {
372
+ if (info.hasTsConfig) {
373
+ console.log(` ${chalk.green("~")} Updated ${chalk.bold("tsconfig.json")} — added .trickle to include`);
374
+ } else {
375
+ console.log(` ${chalk.green("+")} Created ${chalk.bold("tsconfig.json")} with .trickle included`);
376
+ }
377
+ } else {
378
+ console.log(` ${chalk.gray("-")} tsconfig.json already includes .trickle`);
379
+ }
380
+ }
381
+
382
+ // Step 5: Update package.json scripts
383
+ if (info.hasPackageJson) {
384
+ const { scriptsAdded } = updatePackageJson(dir, info);
385
+ if (scriptsAdded.length > 0) {
386
+ for (const name of scriptsAdded) {
387
+ console.log(` ${chalk.green("+")} Added npm script: ${chalk.bold(name)}`);
388
+ }
389
+ }
390
+ }
391
+
392
+ // Step 6: Update .gitignore
393
+ const giUpdated = updateGitignore(dir);
394
+ if (giUpdated) {
395
+ console.log(` ${chalk.green("~")} Updated ${chalk.bold(".gitignore")} — added .trickle/`);
396
+ }
397
+
398
+ // Step 7: Print next steps
399
+ console.log("");
400
+ console.log(chalk.bold(" Next steps:"));
401
+ console.log("");
402
+
403
+ const entryFile = info.entryFile || (info.isPython ? "app.py" : "app.js");
404
+ console.log(chalk.white(" 1. Run your app with trickle (one command does everything):"));
405
+ console.log(chalk.cyan(` trickle run ${entryFile}`));
406
+ console.log("");
407
+ console.log(chalk.white(" 2. That's it! trickle auto-detects the runtime, observes types,"));
408
+ console.log(chalk.white(" and generates stubs from .tricklerc.json settings."));
409
+ console.log("");
410
+ console.log(chalk.gray(" Customize .tricklerc.json:"));
411
+ console.log(chalk.gray(' { "stubs": "src/", "annotate": "src/", "exclude": ["test"] }'));
412
+ console.log("");
413
+ console.log(chalk.gray(" Other commands:"));
414
+ console.log(chalk.gray(" trickle functions — list observed functions"));
415
+ console.log(chalk.gray(" trickle types <name> — see types + sample data"));
416
+ console.log(chalk.gray(" trickle annotate src/ — add type annotations to source files"));
417
+
418
+ console.log("");
419
+ }
@@ -0,0 +1,220 @@
1
+ import * as http from "http";
2
+ import chalk from "chalk";
3
+ import { fetchMockConfig, MockRoute } from "../api-client";
4
+
5
+ export interface MockOptions {
6
+ port?: string;
7
+ cors?: boolean;
8
+ }
9
+
10
+ /**
11
+ * Convert an Express-style path like `/api/users/:id` to a regex
12
+ * that captures named path params.
13
+ */
14
+ function pathToRegex(routePath: string): { regex: RegExp; paramNames: string[] } {
15
+ const paramNames: string[] = [];
16
+ const pattern = routePath.replace(/:(\w+)/g, (_match, paramName) => {
17
+ paramNames.push(paramName);
18
+ return "([^/]+)";
19
+ });
20
+ return { regex: new RegExp(`^${pattern}$`), paramNames };
21
+ }
22
+
23
+ /**
24
+ * Build a description of the mock server routes for the startup banner.
25
+ */
26
+ function formatRouteTable(routes: MockRoute[]): string {
27
+ const lines: string[] = [];
28
+ const methodColors: Record<string, (s: string) => string> = {
29
+ GET: chalk.green,
30
+ POST: chalk.yellow,
31
+ PUT: chalk.blue,
32
+ DELETE: chalk.red,
33
+ PATCH: chalk.magenta,
34
+ };
35
+
36
+ for (const route of routes) {
37
+ const color = methodColors[route.method] || chalk.white;
38
+ const method = color(route.method.padEnd(7));
39
+ const path = chalk.white(route.path);
40
+ const age = formatTimeAgo(route.observedAt);
41
+ lines.push(` ${method} ${path} ${chalk.gray(`(sample from ${age})`)}`);
42
+ }
43
+
44
+ return lines.join("\n");
45
+ }
46
+
47
+ function formatTimeAgo(isoDate: string): string {
48
+ try {
49
+ const date = new Date(isoDate.replace(" ", "T") + (isoDate.includes("Z") ? "" : "Z"));
50
+ const now = Date.now();
51
+ const diffSec = Math.floor((now - date.getTime()) / 1000);
52
+ if (diffSec < 60) return `${diffSec}s ago`;
53
+ const diffMin = Math.floor(diffSec / 60);
54
+ if (diffMin < 60) return `${diffMin}m ago`;
55
+ const diffHr = Math.floor(diffMin / 60);
56
+ if (diffHr < 24) return `${diffHr}h ago`;
57
+ const diffDay = Math.floor(diffHr / 24);
58
+ return `${diffDay}d ago`;
59
+ } catch {
60
+ return isoDate;
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Substitute path params in sample output to match the requested values.
66
+ * For example, if the sample output has { id: 1 } but the request has :id = "42",
67
+ * replace numeric id fields with the requested value.
68
+ */
69
+ function substituteSampleOutput(
70
+ sample: unknown,
71
+ paramValues: Record<string, string>,
72
+ ): unknown {
73
+ if (sample === null || sample === undefined) return sample;
74
+ if (typeof sample !== "object") return sample;
75
+
76
+ if (Array.isArray(sample)) {
77
+ return sample.map((item) => substituteSampleOutput(item, paramValues));
78
+ }
79
+
80
+ const result: Record<string, unknown> = {};
81
+ for (const [key, value] of Object.entries(sample as Record<string, unknown>)) {
82
+ // If this key matches a path param name, substitute the value
83
+ if (key in paramValues) {
84
+ const paramVal = paramValues[key];
85
+ // Try to preserve the original type (number vs string)
86
+ if (typeof value === "number") {
87
+ const num = Number(paramVal);
88
+ result[key] = isNaN(num) ? paramVal : num;
89
+ } else {
90
+ result[key] = paramVal;
91
+ }
92
+ } else if (typeof value === "object" && value !== null) {
93
+ result[key] = substituteSampleOutput(value, paramValues);
94
+ } else {
95
+ result[key] = value;
96
+ }
97
+ }
98
+ return result;
99
+ }
100
+
101
+ export async function mockCommand(opts: MockOptions): Promise<void> {
102
+ const port = parseInt(opts.port || "3000", 10);
103
+ const enableCors = opts.cors !== false;
104
+
105
+ // Fetch mock configuration from the backend
106
+ let routes: MockRoute[];
107
+ try {
108
+ const config = await fetchMockConfig();
109
+ routes = config.routes;
110
+ } catch (err: unknown) {
111
+ if (err instanceof Error) {
112
+ console.error(chalk.red(`\n Error fetching mock config: ${err.message}\n`));
113
+ }
114
+ process.exit(1);
115
+ }
116
+
117
+ if (routes.length === 0) {
118
+ console.log("");
119
+ console.log(chalk.yellow(" No API routes found."));
120
+ console.log(chalk.gray(" Instrument your app and make some requests first."));
121
+ console.log("");
122
+ process.exit(0);
123
+ }
124
+
125
+ // Build route matchers
126
+ const matchers = routes.map((route) => {
127
+ const { regex, paramNames } = pathToRegex(route.path);
128
+ return { route, regex, paramNames };
129
+ });
130
+
131
+ // Create the mock HTTP server
132
+ const server = http.createServer((req, res) => {
133
+ const reqUrl = new URL(req.url || "/", `http://localhost:${port}`);
134
+ const reqMethod = (req.method || "GET").toUpperCase();
135
+ const reqPath = reqUrl.pathname;
136
+
137
+ // CORS headers
138
+ if (enableCors) {
139
+ res.setHeader("Access-Control-Allow-Origin", "*");
140
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, PATCH, OPTIONS");
141
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
142
+ }
143
+
144
+ // Handle preflight
145
+ if (reqMethod === "OPTIONS") {
146
+ res.writeHead(204);
147
+ res.end();
148
+ return;
149
+ }
150
+
151
+ // Find matching route
152
+ for (const { route, regex, paramNames } of matchers) {
153
+ if (route.method !== reqMethod) continue;
154
+
155
+ const match = reqPath.match(regex);
156
+ if (!match) continue;
157
+
158
+ // Extract path params
159
+ const paramValues: Record<string, string> = {};
160
+ for (let i = 0; i < paramNames.length; i++) {
161
+ paramValues[paramNames[i]] = match[i + 1];
162
+ }
163
+
164
+ // Get sample output, substituting path param values
165
+ let output = route.sampleOutput;
166
+ if (output && Object.keys(paramValues).length > 0) {
167
+ output = substituteSampleOutput(output, paramValues);
168
+ }
169
+
170
+ // Log the request
171
+ const methodColor =
172
+ reqMethod === "GET" ? chalk.green :
173
+ reqMethod === "POST" ? chalk.yellow :
174
+ reqMethod === "PUT" ? chalk.blue :
175
+ reqMethod === "DELETE" ? chalk.red :
176
+ chalk.white;
177
+ console.log(
178
+ ` ${chalk.gray(new Date().toLocaleTimeString())} ${methodColor(reqMethod.padEnd(7))} ${reqPath} ${chalk.gray("→ 200")}`,
179
+ );
180
+
181
+ res.writeHead(200, { "Content-Type": "application/json" });
182
+ res.end(JSON.stringify(output ?? {}));
183
+ return;
184
+ }
185
+
186
+ // No route matched
187
+ console.log(
188
+ ` ${chalk.gray(new Date().toLocaleTimeString())} ${chalk.red(reqMethod.padEnd(7))} ${reqPath} ${chalk.red("→ 404")}`,
189
+ );
190
+ res.writeHead(404, { "Content-Type": "application/json" });
191
+ res.end(JSON.stringify({ error: "Not found", path: reqPath, method: reqMethod }));
192
+ });
193
+
194
+ server.listen(port, () => {
195
+ console.log("");
196
+ console.log(chalk.bold(" Trickle Mock Server"));
197
+ console.log("");
198
+ console.log(chalk.gray(" Routes (from runtime observations):"));
199
+ console.log(formatRouteTable(routes));
200
+ console.log("");
201
+ console.log(` Listening on ${chalk.cyan(`http://localhost:${port}`)}`);
202
+ if (enableCors) {
203
+ console.log(chalk.gray(" CORS enabled (Access-Control-Allow-Origin: *)"));
204
+ }
205
+ console.log(chalk.gray(" Press Ctrl+C to stop.\n"));
206
+ });
207
+
208
+ // Handle graceful shutdown
209
+ process.on("SIGINT", () => {
210
+ console.log(chalk.gray("\n Stopping mock server...\n"));
211
+ server.close(() => process.exit(0));
212
+ });
213
+
214
+ process.on("SIGTERM", () => {
215
+ server.close(() => process.exit(0));
216
+ });
217
+
218
+ // Keep process alive
219
+ await new Promise(() => {});
220
+ }
@@ -0,0 +1,53 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import chalk from "chalk";
4
+ import { fetchOpenApiSpec } from "../api-client";
5
+
6
+ export interface OpenApiOptions {
7
+ out?: string;
8
+ env?: string;
9
+ title?: string;
10
+ apiVersion?: string;
11
+ server?: string;
12
+ }
13
+
14
+ export async function openapiCommand(opts: OpenApiOptions): Promise<void> {
15
+ try {
16
+ const spec = await fetchOpenApiSpec({
17
+ env: opts.env,
18
+ title: opts.title,
19
+ version: opts.apiVersion,
20
+ serverUrl: opts.server,
21
+ });
22
+
23
+ const json = JSON.stringify(spec, null, 2) + "\n";
24
+
25
+ if (opts.out) {
26
+ const outPath = path.resolve(opts.out);
27
+ const dir = path.dirname(outPath);
28
+ if (!fs.existsSync(dir)) {
29
+ fs.mkdirSync(dir, { recursive: true });
30
+ }
31
+ fs.writeFileSync(outPath, json, "utf-8");
32
+ console.log("");
33
+ console.log(chalk.green(` OpenAPI spec written to ${chalk.bold(opts.out)}`));
34
+
35
+ // Show summary
36
+ const specObj = spec as Record<string, unknown>;
37
+ const paths = specObj.paths as Record<string, unknown> || {};
38
+ const pathCount = Object.keys(paths).length;
39
+ let operationCount = 0;
40
+ for (const methods of Object.values(paths)) {
41
+ operationCount += Object.keys(methods as Record<string, unknown>).length;
42
+ }
43
+ console.log(chalk.gray(` ${pathCount} path${pathCount !== 1 ? "s" : ""}, ${operationCount} operation${operationCount !== 1 ? "s" : ""}`));
44
+ console.log("");
45
+ } else {
46
+ process.stdout.write(json);
47
+ }
48
+ } catch (err: unknown) {
49
+ if (err instanceof Error) {
50
+ console.error(chalk.red(`\n Error: ${err.message}\n`));
51
+ }
52
+ }
53
+ }