trickle-cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api-client.d.ts +208 -0
- package/dist/api-client.js +237 -0
- package/dist/commands/annotate.d.ts +6 -0
- package/dist/commands/annotate.js +433 -0
- package/dist/commands/audit.d.ts +7 -0
- package/dist/commands/audit.js +82 -0
- package/dist/commands/auto.d.ts +8 -0
- package/dist/commands/auto.js +268 -0
- package/dist/commands/capture.d.ts +14 -0
- package/dist/commands/capture.js +271 -0
- package/dist/commands/check.d.ts +6 -0
- package/dist/commands/check.js +408 -0
- package/dist/commands/codegen.d.ts +21 -0
- package/dist/commands/codegen.js +129 -0
- package/dist/commands/coverage.d.ts +13 -0
- package/dist/commands/coverage.js +126 -0
- package/dist/commands/dashboard.d.ts +1 -0
- package/dist/commands/dashboard.js +83 -0
- package/dist/commands/dev.d.ts +14 -0
- package/dist/commands/dev.js +319 -0
- package/dist/commands/diff.d.ts +7 -0
- package/dist/commands/diff.js +79 -0
- package/dist/commands/docs.d.ts +13 -0
- package/dist/commands/docs.js +383 -0
- package/dist/commands/errors.d.ts +7 -0
- package/dist/commands/errors.js +180 -0
- package/dist/commands/export.d.ts +18 -0
- package/dist/commands/export.js +238 -0
- package/dist/commands/functions.d.ts +6 -0
- package/dist/commands/functions.js +71 -0
- package/dist/commands/infer.d.ts +14 -0
- package/dist/commands/infer.js +275 -0
- package/dist/commands/init.d.ts +5 -0
- package/dist/commands/init.js +395 -0
- package/dist/commands/mock.d.ts +5 -0
- package/dist/commands/mock.js +232 -0
- package/dist/commands/openapi.d.ts +8 -0
- package/dist/commands/openapi.js +82 -0
- package/dist/commands/overview.d.ts +11 -0
- package/dist/commands/overview.js +266 -0
- package/dist/commands/pack.d.ts +11 -0
- package/dist/commands/pack.js +133 -0
- package/dist/commands/proxy.d.ts +13 -0
- package/dist/commands/proxy.js +312 -0
- package/dist/commands/replay.d.ts +14 -0
- package/dist/commands/replay.js +289 -0
- package/dist/commands/run.d.ts +17 -0
- package/dist/commands/run.js +997 -0
- package/dist/commands/sample.d.ts +13 -0
- package/dist/commands/sample.js +260 -0
- package/dist/commands/search.d.ts +5 -0
- package/dist/commands/search.js +80 -0
- package/dist/commands/stubs.d.ts +6 -0
- package/dist/commands/stubs.js +187 -0
- package/dist/commands/tail.d.ts +4 -0
- package/dist/commands/tail.js +76 -0
- package/dist/commands/test-gen.d.ts +13 -0
- package/dist/commands/test-gen.js +237 -0
- package/dist/commands/trace.d.ts +14 -0
- package/dist/commands/trace.js +417 -0
- package/dist/commands/types.d.ts +7 -0
- package/dist/commands/types.js +128 -0
- package/dist/commands/unpack.d.ts +11 -0
- package/dist/commands/unpack.js +166 -0
- package/dist/commands/validate.d.ts +13 -0
- package/dist/commands/validate.js +310 -0
- package/dist/commands/watch.d.ts +9 -0
- package/dist/commands/watch.js +267 -0
- package/dist/config.d.ts +1 -0
- package/dist/config.js +66 -0
- package/dist/formatters/diff-formatter.d.ts +5 -0
- package/dist/formatters/diff-formatter.js +43 -0
- package/dist/formatters/type-formatter.d.ts +22 -0
- package/dist/formatters/type-formatter.js +135 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +419 -0
- package/dist/local-codegen.d.ts +22 -0
- package/dist/local-codegen.js +762 -0
- package/dist/ui/badges.d.ts +16 -0
- package/dist/ui/badges.js +71 -0
- package/dist/ui/helpers.d.ts +13 -0
- package/dist/ui/helpers.js +85 -0
- package/package.json +23 -0
- package/src/api-client.ts +407 -0
- package/src/commands/annotate.ts +450 -0
- package/src/commands/audit.ts +103 -0
- package/src/commands/auto.ts +268 -0
- package/src/commands/capture.ts +257 -0
- package/src/commands/check.ts +437 -0
- package/src/commands/codegen.ts +128 -0
- package/src/commands/coverage.ts +170 -0
- package/src/commands/dashboard.ts +46 -0
- package/src/commands/dev.ts +323 -0
- package/src/commands/diff.ts +99 -0
- package/src/commands/docs.ts +392 -0
- package/src/commands/errors.ts +205 -0
- package/src/commands/export.ts +287 -0
- package/src/commands/functions.ts +81 -0
- package/src/commands/infer.ts +260 -0
- package/src/commands/init.ts +419 -0
- package/src/commands/mock.ts +220 -0
- package/src/commands/openapi.ts +53 -0
- package/src/commands/overview.ts +310 -0
- package/src/commands/pack.ts +139 -0
- package/src/commands/proxy.ts +314 -0
- package/src/commands/replay.ts +356 -0
- package/src/commands/run.ts +1190 -0
- package/src/commands/sample.ts +259 -0
- package/src/commands/search.ts +107 -0
- package/src/commands/stubs.ts +211 -0
- package/src/commands/tail.ts +94 -0
- package/src/commands/test-gen.ts +236 -0
- package/src/commands/trace.ts +440 -0
- package/src/commands/types.ts +161 -0
- package/src/commands/unpack.ts +179 -0
- package/src/commands/validate.ts +368 -0
- package/src/commands/watch.ts +277 -0
- package/src/config.ts +38 -0
- package/src/formatters/diff-formatter.ts +51 -0
- package/src/formatters/type-formatter.ts +161 -0
- package/src/index.ts +454 -0
- package/src/local-codegen.ts +859 -0
- package/src/ui/badges.ts +66 -0
- package/src/ui/helpers.ts +80 -0
- package/tsconfig.json +8 -0
|
@@ -0,0 +1,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
|
+
}
|