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,161 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { listFunctions, listTypes, getTypeDiff } from "../api-client";
|
|
3
|
+
import { formatType } from "../formatters/type-formatter";
|
|
4
|
+
import { formatDiffs } from "../formatters/diff-formatter";
|
|
5
|
+
import { envBadge, timeBadge } from "../ui/badges";
|
|
6
|
+
|
|
7
|
+
export interface TypesOptions {
|
|
8
|
+
env?: string;
|
|
9
|
+
diff?: boolean;
|
|
10
|
+
env1?: string;
|
|
11
|
+
env2?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function typesCommand(functionName: string, opts: TypesOptions): Promise<void> {
|
|
15
|
+
// Look up function by name (partial match)
|
|
16
|
+
const result = await listFunctions({ search: functionName });
|
|
17
|
+
const { functions } = result;
|
|
18
|
+
|
|
19
|
+
if (functions.length === 0) {
|
|
20
|
+
console.log(chalk.yellow(`\n No function found matching "${functionName}".\n`));
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Prefer exact match, then first partial match
|
|
25
|
+
const exactMatch = functions.find(
|
|
26
|
+
(f) => f.function_name === functionName || f.function_name.toLowerCase() === functionName.toLowerCase()
|
|
27
|
+
);
|
|
28
|
+
const fn = exactMatch || functions[0];
|
|
29
|
+
|
|
30
|
+
if (!exactMatch && functions.length > 1) {
|
|
31
|
+
console.log(
|
|
32
|
+
chalk.gray(`\n Multiple matches found, showing results for "${chalk.white(fn.function_name)}"`)
|
|
33
|
+
);
|
|
34
|
+
console.log(
|
|
35
|
+
chalk.gray(
|
|
36
|
+
` Other matches: ${functions
|
|
37
|
+
.slice(1, 5)
|
|
38
|
+
.map((f) => f.function_name)
|
|
39
|
+
.join(", ")}${functions.length > 5 ? "..." : ""}\n`
|
|
40
|
+
)
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Diff mode
|
|
45
|
+
if (opts.diff) {
|
|
46
|
+
await showDiff(fn.id, fn.function_name, opts);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Normal mode: show type snapshots
|
|
51
|
+
const typesResult = await listTypes(fn.id, { env: opts.env });
|
|
52
|
+
const { snapshots } = typesResult;
|
|
53
|
+
|
|
54
|
+
if (snapshots.length === 0) {
|
|
55
|
+
console.log(chalk.yellow(`\n No type snapshots found for "${fn.function_name}".\n`));
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
console.log("");
|
|
60
|
+
console.log(chalk.white.bold(` ${fn.function_name}`) + chalk.gray(` (${fn.module})`));
|
|
61
|
+
console.log(chalk.gray(" " + "─".repeat(50)));
|
|
62
|
+
|
|
63
|
+
for (const snapshot of snapshots) {
|
|
64
|
+
console.log("");
|
|
65
|
+
console.log(
|
|
66
|
+
` ${envBadge(snapshot.env)} ${timeBadge(snapshot.observed_at)}`
|
|
67
|
+
);
|
|
68
|
+
console.log("");
|
|
69
|
+
|
|
70
|
+
// Display function signature
|
|
71
|
+
console.log(chalk.gray(" args: ") + formatType(snapshot.args_type, 4));
|
|
72
|
+
console.log(chalk.gray(" returns: ") + formatType(snapshot.return_type, 4));
|
|
73
|
+
|
|
74
|
+
if (snapshot.sample_input !== undefined && snapshot.sample_input !== null) {
|
|
75
|
+
console.log("");
|
|
76
|
+
console.log(chalk.gray(" sample input:"));
|
|
77
|
+
console.log(chalk.gray(" ") + colorizeJson(snapshot.sample_input, 4));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (snapshot.sample_output !== undefined && snapshot.sample_output !== null) {
|
|
81
|
+
console.log(chalk.gray(" sample output:"));
|
|
82
|
+
console.log(chalk.gray(" ") + colorizeJson(snapshot.sample_output, 4));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
console.log("");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function showDiff(functionId: number, functionName: string, opts: TypesOptions): Promise<void> {
|
|
90
|
+
try {
|
|
91
|
+
let diffResult;
|
|
92
|
+
|
|
93
|
+
if (opts.env1 && opts.env2) {
|
|
94
|
+
diffResult = await getTypeDiff(functionId, {
|
|
95
|
+
fromEnv: opts.env1,
|
|
96
|
+
toEnv: opts.env2,
|
|
97
|
+
});
|
|
98
|
+
} else {
|
|
99
|
+
// Get latest two snapshots and diff by their IDs
|
|
100
|
+
const typesResult = await listTypes(functionId, { limit: 2 });
|
|
101
|
+
const { snapshots } = typesResult;
|
|
102
|
+
|
|
103
|
+
if (snapshots.length < 2) {
|
|
104
|
+
console.log(chalk.yellow(`\n Not enough snapshots to diff for "${functionName}".`));
|
|
105
|
+
console.log(chalk.gray(" Need at least 2 snapshots. Try specifying --env1 and --env2.\n"));
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
diffResult = await getTypeDiff(functionId, {
|
|
110
|
+
from: snapshots[1].id,
|
|
111
|
+
to: snapshots[0].id,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
console.log("");
|
|
116
|
+
console.log(chalk.white.bold(` Type diff for ${functionName}`));
|
|
117
|
+
console.log(chalk.gray(" " + "─".repeat(50)));
|
|
118
|
+
console.log("");
|
|
119
|
+
|
|
120
|
+
console.log(
|
|
121
|
+
chalk.gray(" from: ") +
|
|
122
|
+
envBadge(diffResult.from.env) +
|
|
123
|
+
chalk.gray(" " + diffResult.from.observed_at)
|
|
124
|
+
);
|
|
125
|
+
console.log(
|
|
126
|
+
chalk.gray(" to: ") +
|
|
127
|
+
envBadge(diffResult.to.env) +
|
|
128
|
+
chalk.gray(" " + diffResult.to.observed_at)
|
|
129
|
+
);
|
|
130
|
+
console.log("");
|
|
131
|
+
|
|
132
|
+
if (diffResult.diffs.length === 0) {
|
|
133
|
+
console.log(chalk.green(" No type differences found.\n"));
|
|
134
|
+
} else {
|
|
135
|
+
console.log(formatDiffs(diffResult.diffs));
|
|
136
|
+
console.log("");
|
|
137
|
+
}
|
|
138
|
+
} catch (err: unknown) {
|
|
139
|
+
if (err instanceof Error) {
|
|
140
|
+
console.error(chalk.red(`\n Error: ${err.message}\n`));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function colorizeJson(value: unknown, indent: number = 0): string {
|
|
146
|
+
const json = JSON.stringify(value, null, 2);
|
|
147
|
+
if (!json) return chalk.gray("null");
|
|
148
|
+
|
|
149
|
+
return json
|
|
150
|
+
.split("\n")
|
|
151
|
+
.map((line, i) => {
|
|
152
|
+
const padded = i === 0 ? line : " ".repeat(indent) + line;
|
|
153
|
+
return padded
|
|
154
|
+
.replace(/"([^"]+)":/g, (_, key) => `${chalk.white(`"${key}"`)}:`)
|
|
155
|
+
.replace(/: "([^"]*)"/g, (_, val) => `: ${chalk.green(`"${val}"`)}`)
|
|
156
|
+
.replace(/: (\d+\.?\d*)/g, (_, val) => `: ${chalk.yellow(val)}`)
|
|
157
|
+
.replace(/: (true|false)/g, (_, val) => `: ${chalk.blue(val)}`)
|
|
158
|
+
.replace(/: (null)/g, (_, val) => `: ${chalk.gray(val)}`);
|
|
159
|
+
})
|
|
160
|
+
.join("\n");
|
|
161
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as crypto from "crypto";
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
import { getBackendUrl } from "../config";
|
|
5
|
+
|
|
6
|
+
export interface UnpackOptions {
|
|
7
|
+
env?: string;
|
|
8
|
+
dryRun?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface PackedSnapshot {
|
|
12
|
+
typeHash: string;
|
|
13
|
+
env: string;
|
|
14
|
+
argsType: unknown;
|
|
15
|
+
returnType: unknown;
|
|
16
|
+
sampleInput?: unknown;
|
|
17
|
+
sampleOutput?: unknown;
|
|
18
|
+
observedAt: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface PackedFunction {
|
|
22
|
+
functionName: string;
|
|
23
|
+
module: string;
|
|
24
|
+
language: string;
|
|
25
|
+
environment: string;
|
|
26
|
+
snapshots: PackedSnapshot[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface PackBundle {
|
|
30
|
+
version: number;
|
|
31
|
+
createdAt: string;
|
|
32
|
+
source: string;
|
|
33
|
+
functions: PackedFunction[];
|
|
34
|
+
stats: {
|
|
35
|
+
totalFunctions: number;
|
|
36
|
+
totalSnapshots: number;
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* `trickle unpack <file>` — Import types from a packed bundle into the backend.
|
|
42
|
+
*
|
|
43
|
+
* Reads a .trickle.json bundle (created by `trickle pack`) and ingests
|
|
44
|
+
* all functions and their type snapshots into the backend.
|
|
45
|
+
*/
|
|
46
|
+
export async function unpackCommand(
|
|
47
|
+
file: string,
|
|
48
|
+
opts: UnpackOptions,
|
|
49
|
+
): Promise<void> {
|
|
50
|
+
const backendUrl = getBackendUrl();
|
|
51
|
+
|
|
52
|
+
// Read and parse the bundle
|
|
53
|
+
if (!fs.existsSync(file)) {
|
|
54
|
+
console.error(chalk.red(`\n File not found: ${file}\n`));
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let bundle: PackBundle;
|
|
59
|
+
try {
|
|
60
|
+
const content = fs.readFileSync(file, "utf-8");
|
|
61
|
+
bundle = JSON.parse(content) as PackBundle;
|
|
62
|
+
} catch {
|
|
63
|
+
console.error(chalk.red("\n Invalid bundle file — expected JSON.\n"));
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Validate bundle structure
|
|
68
|
+
if (!bundle.version || !bundle.functions || !Array.isArray(bundle.functions)) {
|
|
69
|
+
console.error(chalk.red("\n Invalid bundle format — missing required fields.\n"));
|
|
70
|
+
process.exit(1);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (bundle.version !== 1) {
|
|
74
|
+
console.error(chalk.red(`\n Unsupported bundle version: ${bundle.version}\n`));
|
|
75
|
+
process.exit(1);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
console.log("");
|
|
79
|
+
console.log(chalk.bold(" trickle unpack"));
|
|
80
|
+
console.log(chalk.gray(" " + "─".repeat(50)));
|
|
81
|
+
console.log(chalk.gray(` File: ${file}`));
|
|
82
|
+
console.log(chalk.gray(` Created: ${bundle.createdAt}`));
|
|
83
|
+
console.log(chalk.gray(` Source: ${bundle.source}`));
|
|
84
|
+
console.log(chalk.gray(` Contains: ${bundle.functions.length} functions, ${bundle.stats.totalSnapshots} snapshots`));
|
|
85
|
+
console.log(chalk.gray(" " + "─".repeat(50)));
|
|
86
|
+
|
|
87
|
+
if (opts.dryRun) {
|
|
88
|
+
console.log("");
|
|
89
|
+
console.log(chalk.yellow(" Dry run — listing contents without importing:"));
|
|
90
|
+
console.log("");
|
|
91
|
+
for (const fn of bundle.functions) {
|
|
92
|
+
console.log(
|
|
93
|
+
chalk.white(` ${fn.functionName}`) +
|
|
94
|
+
chalk.gray(` (${fn.module}, ${fn.language}, ${fn.snapshots.length} snapshot${fn.snapshots.length === 1 ? "" : "s"})`),
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
console.log("");
|
|
98
|
+
console.log(chalk.gray(` ${bundle.functions.length} functions would be imported.`));
|
|
99
|
+
console.log(chalk.gray(" Remove --dry-run to import.\n"));
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Check backend connectivity
|
|
104
|
+
try {
|
|
105
|
+
const res = await fetch(`${backendUrl}/api/health`, { signal: AbortSignal.timeout(3000) });
|
|
106
|
+
if (!res.ok) throw new Error("not ok");
|
|
107
|
+
} catch {
|
|
108
|
+
console.error(chalk.red(`\n Cannot reach trickle backend at ${chalk.bold(backendUrl)}\n`));
|
|
109
|
+
process.exit(1);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
console.log("");
|
|
113
|
+
|
|
114
|
+
// Ingest each function's snapshots
|
|
115
|
+
let imported = 0;
|
|
116
|
+
let skipped = 0;
|
|
117
|
+
let errors = 0;
|
|
118
|
+
|
|
119
|
+
for (const fn of bundle.functions) {
|
|
120
|
+
// Use the latest snapshot for ingest
|
|
121
|
+
const snapshot = fn.snapshots[0];
|
|
122
|
+
if (!snapshot) {
|
|
123
|
+
skipped++;
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const env = opts.env || snapshot.env || fn.environment;
|
|
128
|
+
const typeHash = snapshot.typeHash || computeTypeHash(snapshot.argsType, snapshot.returnType);
|
|
129
|
+
|
|
130
|
+
const payload = {
|
|
131
|
+
functionName: fn.functionName,
|
|
132
|
+
module: fn.module,
|
|
133
|
+
language: fn.language,
|
|
134
|
+
environment: env,
|
|
135
|
+
typeHash,
|
|
136
|
+
argsType: snapshot.argsType,
|
|
137
|
+
returnType: snapshot.returnType,
|
|
138
|
+
sampleInput: snapshot.sampleInput,
|
|
139
|
+
sampleOutput: snapshot.sampleOutput,
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
const res = await fetch(`${backendUrl}/api/ingest`, {
|
|
144
|
+
method: "POST",
|
|
145
|
+
headers: { "Content-Type": "application/json" },
|
|
146
|
+
body: JSON.stringify(payload),
|
|
147
|
+
signal: AbortSignal.timeout(5000),
|
|
148
|
+
});
|
|
149
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
150
|
+
imported++;
|
|
151
|
+
console.log(chalk.green(" ✓ ") + chalk.white(fn.functionName));
|
|
152
|
+
} catch {
|
|
153
|
+
errors++;
|
|
154
|
+
console.log(chalk.red(" ✗ ") + chalk.white(fn.functionName) + chalk.gray(" (failed)"));
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
console.log("");
|
|
159
|
+
console.log(chalk.gray(" " + "─".repeat(50)));
|
|
160
|
+
|
|
161
|
+
if (imported > 0) {
|
|
162
|
+
console.log(chalk.green(` ${imported} functions imported successfully`));
|
|
163
|
+
}
|
|
164
|
+
if (skipped > 0) {
|
|
165
|
+
console.log(chalk.yellow(` ${skipped} skipped (no snapshots)`));
|
|
166
|
+
}
|
|
167
|
+
if (errors > 0) {
|
|
168
|
+
console.log(chalk.red(` ${errors} failed`));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
console.log("");
|
|
172
|
+
console.log(chalk.gray(" Run ") + chalk.white("trickle overview") + chalk.gray(" to see imported types."));
|
|
173
|
+
console.log("");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function computeTypeHash(argsType: unknown, returnType: unknown): string {
|
|
177
|
+
const data = JSON.stringify({ a: argsType, r: returnType });
|
|
178
|
+
return crypto.createHash("sha256").update(data).digest("hex").slice(0, 16);
|
|
179
|
+
}
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { getBackendUrl } from "../config";
|
|
3
|
+
|
|
4
|
+
export interface ValidateOptions {
|
|
5
|
+
header?: string[];
|
|
6
|
+
body?: string;
|
|
7
|
+
env?: string;
|
|
8
|
+
strict?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface TypeNode {
|
|
12
|
+
kind: string;
|
|
13
|
+
[key: string]: unknown;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface Mismatch {
|
|
17
|
+
path: string;
|
|
18
|
+
issue: "missing" | "extra" | "type_mismatch" | "kind_mismatch";
|
|
19
|
+
expected?: string;
|
|
20
|
+
actual?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* `trickle validate <method> <url>` — Validate a live API response against observed types.
|
|
25
|
+
*
|
|
26
|
+
* Makes an HTTP request, infers types from the response, fetches the stored
|
|
27
|
+
* type for that route from the backend, and reports any mismatches.
|
|
28
|
+
*/
|
|
29
|
+
export async function validateCommand(
|
|
30
|
+
method: string,
|
|
31
|
+
url: string,
|
|
32
|
+
opts: ValidateOptions,
|
|
33
|
+
): Promise<void> {
|
|
34
|
+
const backendUrl = getBackendUrl();
|
|
35
|
+
|
|
36
|
+
const httpMethod = method.toUpperCase();
|
|
37
|
+
const validMethods = ["GET", "POST", "PUT", "PATCH", "DELETE"];
|
|
38
|
+
if (!validMethods.includes(httpMethod)) {
|
|
39
|
+
console.error(chalk.red(`\n Invalid HTTP method: ${method}\n`));
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let parsedUrl: URL;
|
|
44
|
+
try {
|
|
45
|
+
parsedUrl = new URL(url);
|
|
46
|
+
} catch {
|
|
47
|
+
console.error(chalk.red(`\n Invalid URL: ${url}\n`));
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Check backend
|
|
52
|
+
try {
|
|
53
|
+
const res = await fetch(`${backendUrl}/api/health`, { signal: AbortSignal.timeout(3000) });
|
|
54
|
+
if (!res.ok) throw new Error("not ok");
|
|
55
|
+
} catch {
|
|
56
|
+
console.error(chalk.red(`\n Cannot reach trickle backend at ${chalk.bold(backendUrl)}\n`));
|
|
57
|
+
process.exit(1);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Build request headers
|
|
61
|
+
const headers: Record<string, string> = { Accept: "application/json" };
|
|
62
|
+
if (opts.header) {
|
|
63
|
+
for (const h of opts.header) {
|
|
64
|
+
const colonIdx = h.indexOf(":");
|
|
65
|
+
if (colonIdx === -1) {
|
|
66
|
+
console.error(chalk.red(`\n Invalid header: ${h}\n`));
|
|
67
|
+
process.exit(1);
|
|
68
|
+
}
|
|
69
|
+
headers[h.slice(0, colonIdx).trim()] = h.slice(colonIdx + 1).trim();
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let reqBody: string | undefined;
|
|
74
|
+
if (opts.body) {
|
|
75
|
+
reqBody = opts.body;
|
|
76
|
+
headers["Content-Type"] = headers["Content-Type"] || "application/json";
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
console.log("");
|
|
80
|
+
console.log(chalk.bold(" trickle validate"));
|
|
81
|
+
console.log(chalk.gray(" " + "─".repeat(50)));
|
|
82
|
+
console.log(chalk.gray(` ${chalk.bold(httpMethod)} ${url}`));
|
|
83
|
+
|
|
84
|
+
// Make the request
|
|
85
|
+
let response: Response;
|
|
86
|
+
try {
|
|
87
|
+
response = await fetch(url, {
|
|
88
|
+
method: httpMethod,
|
|
89
|
+
headers,
|
|
90
|
+
body: reqBody,
|
|
91
|
+
signal: AbortSignal.timeout(30000),
|
|
92
|
+
});
|
|
93
|
+
} catch (err: unknown) {
|
|
94
|
+
const msg = err instanceof Error ? err.message : "Unknown error";
|
|
95
|
+
console.error(chalk.red(`\n Request failed: ${msg}\n`));
|
|
96
|
+
process.exit(1);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const status = response.status;
|
|
100
|
+
const statusColor = status < 400 ? chalk.green : chalk.red;
|
|
101
|
+
console.log(chalk.gray(` Status: `) + statusColor(`${status} ${response.statusText}`));
|
|
102
|
+
|
|
103
|
+
// Parse response
|
|
104
|
+
const resText = await response.text();
|
|
105
|
+
let resJson: unknown;
|
|
106
|
+
const contentType = response.headers.get("content-type") || "";
|
|
107
|
+
if (contentType.includes("json") && resText.length > 0) {
|
|
108
|
+
try {
|
|
109
|
+
resJson = JSON.parse(resText);
|
|
110
|
+
} catch {
|
|
111
|
+
console.error(chalk.red("\n Response is not valid JSON.\n"));
|
|
112
|
+
process.exit(1);
|
|
113
|
+
}
|
|
114
|
+
} else {
|
|
115
|
+
console.error(chalk.red("\n Response is not JSON.\n"));
|
|
116
|
+
process.exit(1);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Normalize path and find stored types
|
|
120
|
+
const routePath = normalizePath(parsedUrl.pathname);
|
|
121
|
+
const functionName = `${httpMethod} ${routePath}`;
|
|
122
|
+
console.log(chalk.gray(` Route: `) + chalk.white(functionName));
|
|
123
|
+
|
|
124
|
+
// Fetch stored function from backend
|
|
125
|
+
const funcsRes = await fetch(
|
|
126
|
+
`${backendUrl}/api/functions?q=${encodeURIComponent(functionName)}&limit=100`,
|
|
127
|
+
{ signal: AbortSignal.timeout(5000) },
|
|
128
|
+
);
|
|
129
|
+
const funcsData = await funcsRes.json() as { functions: Array<{ id: number; function_name: string }> };
|
|
130
|
+
|
|
131
|
+
const matchedFunc = funcsData.functions.find(
|
|
132
|
+
(f) => f.function_name === functionName,
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
if (!matchedFunc) {
|
|
136
|
+
console.log(chalk.gray(" " + "─".repeat(50)));
|
|
137
|
+
console.log(chalk.yellow(" No previously observed types for this route."));
|
|
138
|
+
console.log(chalk.gray(" Run ") + chalk.white("trickle capture") + chalk.gray(" first to establish a baseline.\n"));
|
|
139
|
+
process.exit(1);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Fetch latest type snapshot
|
|
143
|
+
const envQuery = opts.env ? `&env=${encodeURIComponent(opts.env)}` : "";
|
|
144
|
+
const typesRes = await fetch(
|
|
145
|
+
`${backendUrl}/api/types/${matchedFunc.id}?limit=1${envQuery}`,
|
|
146
|
+
{ signal: AbortSignal.timeout(5000) },
|
|
147
|
+
);
|
|
148
|
+
const typesData = await typesRes.json() as {
|
|
149
|
+
snapshots: Array<{ return_type: string; observed_at: string }>;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
if (!typesData.snapshots || typesData.snapshots.length === 0) {
|
|
153
|
+
console.log(chalk.yellow(" No type snapshots found for this route.\n"));
|
|
154
|
+
process.exit(1);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const snapshot = typesData.snapshots[0];
|
|
158
|
+
let expectedType: TypeNode;
|
|
159
|
+
try {
|
|
160
|
+
expectedType = (typeof snapshot.return_type === 'string'
|
|
161
|
+
? JSON.parse(snapshot.return_type)
|
|
162
|
+
: snapshot.return_type) as TypeNode;
|
|
163
|
+
} catch {
|
|
164
|
+
console.error(chalk.red(" Cannot parse stored type snapshot.\n"));
|
|
165
|
+
process.exit(1);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
console.log(chalk.gray(` Baseline: `) + chalk.gray(`observed ${snapshot.observed_at}`));
|
|
169
|
+
console.log(chalk.gray(" " + "─".repeat(50)));
|
|
170
|
+
|
|
171
|
+
// Infer type from actual response
|
|
172
|
+
const actualType = jsonToTypeNode(resJson);
|
|
173
|
+
|
|
174
|
+
// Compare
|
|
175
|
+
const mismatches: Mismatch[] = [];
|
|
176
|
+
compareTypes(expectedType, actualType, "response", mismatches, opts.strict || false);
|
|
177
|
+
|
|
178
|
+
if (mismatches.length === 0) {
|
|
179
|
+
console.log(chalk.green("\n ✓ Response matches observed type shape\n"));
|
|
180
|
+
console.log(chalk.gray(" All fields present with expected types.\n"));
|
|
181
|
+
} else {
|
|
182
|
+
console.log(chalk.red(`\n ✗ ${mismatches.length} mismatch${mismatches.length === 1 ? "" : "es"} found\n`));
|
|
183
|
+
|
|
184
|
+
for (const m of mismatches) {
|
|
185
|
+
switch (m.issue) {
|
|
186
|
+
case "missing":
|
|
187
|
+
console.log(
|
|
188
|
+
chalk.red(" MISSING ") +
|
|
189
|
+
chalk.white(m.path) +
|
|
190
|
+
chalk.gray(` (expected ${m.expected})`),
|
|
191
|
+
);
|
|
192
|
+
break;
|
|
193
|
+
case "extra":
|
|
194
|
+
console.log(
|
|
195
|
+
chalk.yellow(" EXTRA ") +
|
|
196
|
+
chalk.white(m.path) +
|
|
197
|
+
chalk.gray(` (${m.actual}, not in observed types)`),
|
|
198
|
+
);
|
|
199
|
+
break;
|
|
200
|
+
case "type_mismatch":
|
|
201
|
+
console.log(
|
|
202
|
+
chalk.red(" TYPE ") +
|
|
203
|
+
chalk.white(m.path) +
|
|
204
|
+
chalk.gray(` (expected ${m.expected}, got ${m.actual})`),
|
|
205
|
+
);
|
|
206
|
+
break;
|
|
207
|
+
case "kind_mismatch":
|
|
208
|
+
console.log(
|
|
209
|
+
chalk.red(" SHAPE ") +
|
|
210
|
+
chalk.white(m.path) +
|
|
211
|
+
chalk.gray(` (expected ${m.expected}, got ${m.actual})`),
|
|
212
|
+
);
|
|
213
|
+
break;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const errors = mismatches.filter((m) => m.issue !== "extra");
|
|
218
|
+
const warnings = mismatches.filter((m) => m.issue === "extra");
|
|
219
|
+
|
|
220
|
+
console.log("");
|
|
221
|
+
if (errors.length > 0) {
|
|
222
|
+
console.log(chalk.red(` ${errors.length} error${errors.length === 1 ? "" : "s"}`) +
|
|
223
|
+
(warnings.length > 0 ? chalk.yellow(`, ${warnings.length} warning${warnings.length === 1 ? "" : "s"}`) : ""));
|
|
224
|
+
} else {
|
|
225
|
+
console.log(chalk.yellow(` ${warnings.length} warning${warnings.length === 1 ? "" : "s"} (extra fields only)`));
|
|
226
|
+
}
|
|
227
|
+
console.log("");
|
|
228
|
+
|
|
229
|
+
if (errors.length > 0) {
|
|
230
|
+
process.exitCode = 1;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Recursively compare expected vs actual TypeNode.
|
|
237
|
+
*/
|
|
238
|
+
function compareTypes(
|
|
239
|
+
expected: TypeNode,
|
|
240
|
+
actual: TypeNode,
|
|
241
|
+
path: string,
|
|
242
|
+
mismatches: Mismatch[],
|
|
243
|
+
strict: boolean,
|
|
244
|
+
): void {
|
|
245
|
+
// Kind mismatch
|
|
246
|
+
if (expected.kind !== actual.kind) {
|
|
247
|
+
// Special case: unknown expected matches anything
|
|
248
|
+
if (expected.kind === "unknown") return;
|
|
249
|
+
// Special case: expected primitive null matches any actual (nullable)
|
|
250
|
+
if (expected.kind === "primitive" && (expected as unknown as { name: string }).name === "null") return;
|
|
251
|
+
|
|
252
|
+
mismatches.push({
|
|
253
|
+
path,
|
|
254
|
+
issue: "kind_mismatch",
|
|
255
|
+
expected: describeType(expected),
|
|
256
|
+
actual: describeType(actual),
|
|
257
|
+
});
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
switch (expected.kind) {
|
|
262
|
+
case "primitive": {
|
|
263
|
+
const expectedName = (expected as unknown as { name: string }).name;
|
|
264
|
+
const actualName = (actual as unknown as { name: string }).name;
|
|
265
|
+
if (expectedName !== actualName) {
|
|
266
|
+
mismatches.push({
|
|
267
|
+
path,
|
|
268
|
+
issue: "type_mismatch",
|
|
269
|
+
expected: expectedName,
|
|
270
|
+
actual: actualName,
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
break;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
case "object": {
|
|
277
|
+
const expectedProps = expected.properties as Record<string, TypeNode>;
|
|
278
|
+
const actualProps = actual.properties as Record<string, TypeNode>;
|
|
279
|
+
|
|
280
|
+
// Check for missing fields
|
|
281
|
+
for (const key of Object.keys(expectedProps)) {
|
|
282
|
+
if (!(key in actualProps)) {
|
|
283
|
+
mismatches.push({
|
|
284
|
+
path: `${path}.${key}`,
|
|
285
|
+
issue: "missing",
|
|
286
|
+
expected: describeType(expectedProps[key]),
|
|
287
|
+
});
|
|
288
|
+
} else {
|
|
289
|
+
compareTypes(expectedProps[key], actualProps[key], `${path}.${key}`, mismatches, strict);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Check for extra fields (warnings in non-strict, errors in strict)
|
|
294
|
+
if (strict) {
|
|
295
|
+
for (const key of Object.keys(actualProps)) {
|
|
296
|
+
if (!(key in expectedProps)) {
|
|
297
|
+
mismatches.push({
|
|
298
|
+
path: `${path}.${key}`,
|
|
299
|
+
issue: "extra",
|
|
300
|
+
actual: describeType(actualProps[key]),
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
break;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
case "array": {
|
|
309
|
+
const expectedElement = expected.element as TypeNode;
|
|
310
|
+
const actualElement = actual.element as TypeNode;
|
|
311
|
+
if (expectedElement.kind !== "unknown" && actualElement.kind !== "unknown") {
|
|
312
|
+
compareTypes(expectedElement, actualElement, `${path}[]`, mismatches, strict);
|
|
313
|
+
}
|
|
314
|
+
break;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
case "union": {
|
|
318
|
+
// For unions, check that actual matches at least one member
|
|
319
|
+
// Simplified: just skip deep comparison for unions
|
|
320
|
+
break;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function describeType(node: TypeNode): string {
|
|
326
|
+
switch (node.kind) {
|
|
327
|
+
case "primitive":
|
|
328
|
+
return (node as unknown as { name: string }).name;
|
|
329
|
+
case "object":
|
|
330
|
+
return "object";
|
|
331
|
+
case "array":
|
|
332
|
+
return `${describeType(node.element as TypeNode)}[]`;
|
|
333
|
+
case "union":
|
|
334
|
+
return (node.members as TypeNode[]).map(describeType).join(" | ");
|
|
335
|
+
default:
|
|
336
|
+
return node.kind;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function jsonToTypeNode(value: unknown): TypeNode {
|
|
341
|
+
if (value === null) return { kind: "primitive", name: "null" };
|
|
342
|
+
if (value === undefined) return { kind: "primitive", name: "undefined" };
|
|
343
|
+
switch (typeof value) {
|
|
344
|
+
case "string": return { kind: "primitive", name: "string" };
|
|
345
|
+
case "number": return { kind: "primitive", name: "number" };
|
|
346
|
+
case "boolean": return { kind: "primitive", name: "boolean" };
|
|
347
|
+
}
|
|
348
|
+
if (Array.isArray(value)) {
|
|
349
|
+
if (value.length === 0) return { kind: "array", element: { kind: "unknown" } };
|
|
350
|
+
return { kind: "array", element: jsonToTypeNode(value[0]) };
|
|
351
|
+
}
|
|
352
|
+
const obj = value as Record<string, unknown>;
|
|
353
|
+
const properties: Record<string, TypeNode> = {};
|
|
354
|
+
for (const [key, val] of Object.entries(obj)) {
|
|
355
|
+
properties[key] = jsonToTypeNode(val);
|
|
356
|
+
}
|
|
357
|
+
return { kind: "object", properties };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function normalizePath(urlPath: string): string {
|
|
361
|
+
return urlPath.split("/").map((part, i) => {
|
|
362
|
+
if (!part) return part;
|
|
363
|
+
if (/^\d+$/.test(part)) return ":id";
|
|
364
|
+
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(part)) return ":id";
|
|
365
|
+
if (/^[0-9a-f]{16,}$/i.test(part) && i > 1) return ":id";
|
|
366
|
+
return part;
|
|
367
|
+
}).join("/");
|
|
368
|
+
}
|