@sdkdrift/cli 0.2.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/cli.d.ts +2 -0
- package/dist/cli.js +132 -0
- package/dist/cli.js.map +1 -0
- package/package.json +25 -0
- package/src/cli.ts +164 -0
- package/src/js-yaml.d.ts +3 -0
- package/tsconfig.json +8 -0
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
4
|
+
import { renderReport, scanWithArtifacts } from "@sdkdrift/core";
|
|
5
|
+
import { scanPythonSdk, scanTypeScriptSdk } from "@sdkdrift/python-scanner";
|
|
6
|
+
import { load } from "js-yaml";
|
|
7
|
+
function isRecord(value) {
|
|
8
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
9
|
+
}
|
|
10
|
+
function validateConfig(config, configPath) {
|
|
11
|
+
if (!isRecord(config)) {
|
|
12
|
+
throw new Error(`Invalid config at ${configPath}: root must be a YAML object`);
|
|
13
|
+
}
|
|
14
|
+
const raw = config;
|
|
15
|
+
if (raw.match && !isRecord(raw.match)) {
|
|
16
|
+
throw new Error(`Invalid config at ${configPath}: match must be an object`);
|
|
17
|
+
}
|
|
18
|
+
if (typeof raw.match?.heuristicThreshold !== "undefined") {
|
|
19
|
+
const threshold = raw.match.heuristicThreshold;
|
|
20
|
+
if (typeof threshold !== "number" || Number.isNaN(threshold) || threshold < 0 || threshold > 1) {
|
|
21
|
+
throw new Error(`Invalid config at ${configPath}: match.heuristicThreshold must be a number between 0 and 1`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
if (raw.mapping && !isRecord(raw.mapping)) {
|
|
25
|
+
throw new Error(`Invalid config at ${configPath}: mapping must be an object`);
|
|
26
|
+
}
|
|
27
|
+
if (typeof raw.mapping?.overrides !== "undefined" && !Array.isArray(raw.mapping.overrides)) {
|
|
28
|
+
throw new Error(`Invalid config at ${configPath}: mapping.overrides must be an array`);
|
|
29
|
+
}
|
|
30
|
+
const seenOperationIds = new Set();
|
|
31
|
+
for (const [index, entry] of (raw.mapping?.overrides ?? []).entries()) {
|
|
32
|
+
if (!isRecord(entry)) {
|
|
33
|
+
throw new Error(`Invalid config at ${configPath}: mapping.overrides[${index}] must be an object`);
|
|
34
|
+
}
|
|
35
|
+
const operationId = entry.operationId;
|
|
36
|
+
const sdkMethod = entry.sdkMethod;
|
|
37
|
+
if (typeof operationId !== "string" || operationId.trim().length === 0) {
|
|
38
|
+
throw new Error(`Invalid config at ${configPath}: mapping.overrides[${index}].operationId must be a non-empty string`);
|
|
39
|
+
}
|
|
40
|
+
if (typeof sdkMethod !== "string" || sdkMethod.trim().length === 0) {
|
|
41
|
+
throw new Error(`Invalid config at ${configPath}: mapping.overrides[${index}].sdkMethod must be a non-empty string`);
|
|
42
|
+
}
|
|
43
|
+
if (seenOperationIds.has(operationId)) {
|
|
44
|
+
throw new Error(`Invalid config at ${configPath}: duplicate override for operationId '${operationId}'`);
|
|
45
|
+
}
|
|
46
|
+
seenOperationIds.add(operationId);
|
|
47
|
+
}
|
|
48
|
+
return raw;
|
|
49
|
+
}
|
|
50
|
+
async function loadMatchOptions(configPath) {
|
|
51
|
+
if (!configPath)
|
|
52
|
+
return undefined;
|
|
53
|
+
const content = await readFile(configPath, "utf8");
|
|
54
|
+
const parsed = load(content);
|
|
55
|
+
const config = validateConfig(parsed, configPath);
|
|
56
|
+
const overrides = {};
|
|
57
|
+
for (const entry of config.mapping?.overrides ?? []) {
|
|
58
|
+
if (typeof entry.operationId === "string" && typeof entry.sdkMethod === "string") {
|
|
59
|
+
overrides[entry.operationId] = entry.sdkMethod;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const options = {};
|
|
63
|
+
if (Object.keys(overrides).length > 0) {
|
|
64
|
+
options.overrides = overrides;
|
|
65
|
+
}
|
|
66
|
+
if (typeof config.match?.heuristicThreshold === "number") {
|
|
67
|
+
options.heuristicThreshold = config.match.heuristicThreshold;
|
|
68
|
+
}
|
|
69
|
+
return Object.keys(options).length > 0 ? options : undefined;
|
|
70
|
+
}
|
|
71
|
+
async function run() {
|
|
72
|
+
const program = new Command();
|
|
73
|
+
program
|
|
74
|
+
.name("sdkdrift")
|
|
75
|
+
.description("Detect drift between OpenAPI specs and SDK surfaces")
|
|
76
|
+
.version("0.1.0");
|
|
77
|
+
program
|
|
78
|
+
.command("scan")
|
|
79
|
+
.requiredOption("--spec <pathOrUrl>", "OpenAPI file path or URL")
|
|
80
|
+
.requiredOption("--sdk <path>", "SDK root directory")
|
|
81
|
+
.requiredOption("--lang <python|ts>", "SDK language")
|
|
82
|
+
.option("--format <terminal|json|markdown>", "Report output format", "terminal")
|
|
83
|
+
.option("--verbose", "Print matcher diagnostics to stderr", false)
|
|
84
|
+
.option("--config <path>", "Path to sdkdrift.config.yaml")
|
|
85
|
+
.option("--out <path>", "Write output to file path")
|
|
86
|
+
.option("--min-score <number>", "Fail if score is below threshold", (v) => Number(v))
|
|
87
|
+
.action(async (args) => {
|
|
88
|
+
const lang = args.lang;
|
|
89
|
+
const matchOptions = await loadMatchOptions(args.config);
|
|
90
|
+
const methods = lang === "python" ? await scanPythonSdk(args.sdk) : await scanTypeScriptSdk(args.sdk);
|
|
91
|
+
const artifacts = await scanWithArtifacts({
|
|
92
|
+
specPathOrUrl: args.spec,
|
|
93
|
+
sdkPath: args.sdk,
|
|
94
|
+
language: lang,
|
|
95
|
+
minScore: args.minScore,
|
|
96
|
+
match: matchOptions
|
|
97
|
+
}, methods);
|
|
98
|
+
const { report, matches } = artifacts;
|
|
99
|
+
const format = args.format ?? "terminal";
|
|
100
|
+
const output = renderReport(report, format);
|
|
101
|
+
if (args.out) {
|
|
102
|
+
await writeFile(args.out, output, "utf8");
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
process.stdout.write(`${output}\n`);
|
|
106
|
+
}
|
|
107
|
+
if (Boolean(args.verbose)) {
|
|
108
|
+
const strategyCounts = matches.reduce((acc, match) => {
|
|
109
|
+
acc[match.strategy] = (acc[match.strategy] ?? 0) + 1;
|
|
110
|
+
return acc;
|
|
111
|
+
}, {});
|
|
112
|
+
const unmatched = matches.filter((m) => !m.sdkMethodId).map((m) => m.operationId);
|
|
113
|
+
process.stderr.write([
|
|
114
|
+
"[sdkdrift:verbose]",
|
|
115
|
+
`operations=${report.summary.operationsTotal}`,
|
|
116
|
+
`methods=${methods.length}`,
|
|
117
|
+
`strategies=${JSON.stringify(strategyCounts)}`,
|
|
118
|
+
unmatched.length ? `unmatched=${unmatched.join(",")}` : "unmatched=none"
|
|
119
|
+
].join(" ") + "\n");
|
|
120
|
+
}
|
|
121
|
+
if (typeof args.minScore === "number" && report.score < args.minScore) {
|
|
122
|
+
process.exitCode = 2;
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
await program.parseAsync(process.argv);
|
|
126
|
+
return typeof process.exitCode === "number" ? process.exitCode : 0;
|
|
127
|
+
}
|
|
128
|
+
run().catch((error) => {
|
|
129
|
+
process.stderr.write(`SDKDrift CLI error: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
130
|
+
process.exit(1);
|
|
131
|
+
});
|
|
132
|
+
//# sourceMappingURL=cli.js.map
|
package/dist/cli.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cli.js","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@sdkdrift/cli",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"bin": {
|
|
6
|
+
"sdkdrift": "dist/cli.js"
|
|
7
|
+
},
|
|
8
|
+
"main": "dist/cli.js",
|
|
9
|
+
"types": "dist/cli.d.ts",
|
|
10
|
+
"publishConfig": {
|
|
11
|
+
"access": "public"
|
|
12
|
+
},
|
|
13
|
+
"scripts": {
|
|
14
|
+
"build": "tsc -p tsconfig.json",
|
|
15
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
16
|
+
"test": "node --test",
|
|
17
|
+
"lint": "echo 'lint not configured yet'"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@sdkdrift/core": "^0.2.0",
|
|
21
|
+
"@sdkdrift/python-scanner": "^0.2.0",
|
|
22
|
+
"commander": "^14.0.1",
|
|
23
|
+
"js-yaml": "^4.1.0"
|
|
24
|
+
}
|
|
25
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
4
|
+
import { renderReport, scanWithArtifacts } from "@sdkdrift/core";
|
|
5
|
+
import type { MatchOptions } from "@sdkdrift/core";
|
|
6
|
+
import { scanPythonSdk, scanTypeScriptSdk } from "@sdkdrift/python-scanner";
|
|
7
|
+
import { load } from "js-yaml";
|
|
8
|
+
|
|
9
|
+
type RawConfig = {
|
|
10
|
+
mapping?: {
|
|
11
|
+
overrides?: Array<{ operationId?: string; sdkMethod?: string }>;
|
|
12
|
+
};
|
|
13
|
+
match?: {
|
|
14
|
+
heuristicThreshold?: number;
|
|
15
|
+
};
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
19
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function validateConfig(config: unknown, configPath: string): RawConfig {
|
|
23
|
+
if (!isRecord(config)) {
|
|
24
|
+
throw new Error(`Invalid config at ${configPath}: root must be a YAML object`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const raw = config as RawConfig;
|
|
28
|
+
if (raw.match && !isRecord(raw.match)) {
|
|
29
|
+
throw new Error(`Invalid config at ${configPath}: match must be an object`);
|
|
30
|
+
}
|
|
31
|
+
if (typeof raw.match?.heuristicThreshold !== "undefined") {
|
|
32
|
+
const threshold = raw.match.heuristicThreshold;
|
|
33
|
+
if (typeof threshold !== "number" || Number.isNaN(threshold) || threshold < 0 || threshold > 1) {
|
|
34
|
+
throw new Error(`Invalid config at ${configPath}: match.heuristicThreshold must be a number between 0 and 1`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (raw.mapping && !isRecord(raw.mapping)) {
|
|
39
|
+
throw new Error(`Invalid config at ${configPath}: mapping must be an object`);
|
|
40
|
+
}
|
|
41
|
+
if (typeof raw.mapping?.overrides !== "undefined" && !Array.isArray(raw.mapping.overrides)) {
|
|
42
|
+
throw new Error(`Invalid config at ${configPath}: mapping.overrides must be an array`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const seenOperationIds = new Set<string>();
|
|
46
|
+
for (const [index, entry] of (raw.mapping?.overrides ?? []).entries()) {
|
|
47
|
+
if (!isRecord(entry)) {
|
|
48
|
+
throw new Error(`Invalid config at ${configPath}: mapping.overrides[${index}] must be an object`);
|
|
49
|
+
}
|
|
50
|
+
const operationId = entry.operationId;
|
|
51
|
+
const sdkMethod = entry.sdkMethod;
|
|
52
|
+
if (typeof operationId !== "string" || operationId.trim().length === 0) {
|
|
53
|
+
throw new Error(`Invalid config at ${configPath}: mapping.overrides[${index}].operationId must be a non-empty string`);
|
|
54
|
+
}
|
|
55
|
+
if (typeof sdkMethod !== "string" || sdkMethod.trim().length === 0) {
|
|
56
|
+
throw new Error(`Invalid config at ${configPath}: mapping.overrides[${index}].sdkMethod must be a non-empty string`);
|
|
57
|
+
}
|
|
58
|
+
if (seenOperationIds.has(operationId)) {
|
|
59
|
+
throw new Error(`Invalid config at ${configPath}: duplicate override for operationId '${operationId}'`);
|
|
60
|
+
}
|
|
61
|
+
seenOperationIds.add(operationId);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return raw;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function loadMatchOptions(configPath?: string): Promise<MatchOptions | undefined> {
|
|
68
|
+
if (!configPath) return undefined;
|
|
69
|
+
const content = await readFile(configPath, "utf8");
|
|
70
|
+
const parsed = load(content);
|
|
71
|
+
const config = validateConfig(parsed, configPath);
|
|
72
|
+
const overrides: Record<string, string> = {};
|
|
73
|
+
for (const entry of config.mapping?.overrides ?? []) {
|
|
74
|
+
if (typeof entry.operationId === "string" && typeof entry.sdkMethod === "string") {
|
|
75
|
+
overrides[entry.operationId] = entry.sdkMethod;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const options: MatchOptions = {};
|
|
80
|
+
if (Object.keys(overrides).length > 0) {
|
|
81
|
+
options.overrides = overrides;
|
|
82
|
+
}
|
|
83
|
+
if (typeof config.match?.heuristicThreshold === "number") {
|
|
84
|
+
options.heuristicThreshold = config.match.heuristicThreshold;
|
|
85
|
+
}
|
|
86
|
+
return Object.keys(options).length > 0 ? options : undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function run(): Promise<number> {
|
|
90
|
+
const program = new Command();
|
|
91
|
+
|
|
92
|
+
program
|
|
93
|
+
.name("sdkdrift")
|
|
94
|
+
.description("Detect drift between OpenAPI specs and SDK surfaces")
|
|
95
|
+
.version("0.1.0");
|
|
96
|
+
|
|
97
|
+
program
|
|
98
|
+
.command("scan")
|
|
99
|
+
.requiredOption("--spec <pathOrUrl>", "OpenAPI file path or URL")
|
|
100
|
+
.requiredOption("--sdk <path>", "SDK root directory")
|
|
101
|
+
.requiredOption("--lang <python|ts>", "SDK language")
|
|
102
|
+
.option("--format <terminal|json|markdown>", "Report output format", "terminal")
|
|
103
|
+
.option("--verbose", "Print matcher diagnostics to stderr", false)
|
|
104
|
+
.option("--config <path>", "Path to sdkdrift.config.yaml")
|
|
105
|
+
.option("--out <path>", "Write output to file path")
|
|
106
|
+
.option("--min-score <number>", "Fail if score is below threshold", (v) => Number(v))
|
|
107
|
+
.action(async (args) => {
|
|
108
|
+
const lang = args.lang as "python" | "ts";
|
|
109
|
+
const matchOptions = await loadMatchOptions(args.config as string | undefined);
|
|
110
|
+
const methods =
|
|
111
|
+
lang === "python" ? await scanPythonSdk(args.sdk as string) : await scanTypeScriptSdk(args.sdk as string);
|
|
112
|
+
|
|
113
|
+
const artifacts = await scanWithArtifacts(
|
|
114
|
+
{
|
|
115
|
+
specPathOrUrl: args.spec as string,
|
|
116
|
+
sdkPath: args.sdk as string,
|
|
117
|
+
language: lang,
|
|
118
|
+
minScore: args.minScore as number | undefined,
|
|
119
|
+
match: matchOptions
|
|
120
|
+
},
|
|
121
|
+
methods
|
|
122
|
+
);
|
|
123
|
+
const { report, matches } = artifacts;
|
|
124
|
+
|
|
125
|
+
const format = (args.format as "terminal" | "json" | "markdown") ?? "terminal";
|
|
126
|
+
const output = renderReport(report, format);
|
|
127
|
+
|
|
128
|
+
if (args.out) {
|
|
129
|
+
await writeFile(args.out as string, output, "utf8");
|
|
130
|
+
} else {
|
|
131
|
+
process.stdout.write(`${output}\n`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (Boolean(args.verbose)) {
|
|
135
|
+
const strategyCounts = matches.reduce<Record<string, number>>((acc, match) => {
|
|
136
|
+
acc[match.strategy] = (acc[match.strategy] ?? 0) + 1;
|
|
137
|
+
return acc;
|
|
138
|
+
}, {});
|
|
139
|
+
const unmatched = matches.filter((m) => !m.sdkMethodId).map((m) => m.operationId);
|
|
140
|
+
|
|
141
|
+
process.stderr.write(
|
|
142
|
+
[
|
|
143
|
+
"[sdkdrift:verbose]",
|
|
144
|
+
`operations=${report.summary.operationsTotal}`,
|
|
145
|
+
`methods=${methods.length}`,
|
|
146
|
+
`strategies=${JSON.stringify(strategyCounts)}`,
|
|
147
|
+
unmatched.length ? `unmatched=${unmatched.join(",")}` : "unmatched=none"
|
|
148
|
+
].join(" ") + "\n"
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (typeof args.minScore === "number" && report.score < args.minScore) {
|
|
153
|
+
process.exitCode = 2;
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
await program.parseAsync(process.argv);
|
|
158
|
+
return typeof process.exitCode === "number" ? process.exitCode : 0;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
run().catch((error) => {
|
|
162
|
+
process.stderr.write(`SDKDrift CLI error: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
163
|
+
process.exit(1);
|
|
164
|
+
});
|
package/src/js-yaml.d.ts
ADDED