relionhq 2.0.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/index.js +1057 -0
- package/package.json +24 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1057 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
var __create = Object.create;
|
|
4
|
+
var __defProp = Object.defineProperty;
|
|
5
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
6
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
7
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
8
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
9
|
+
var __copyProps = (to, from, except, desc) => {
|
|
10
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
11
|
+
for (let key of __getOwnPropNames(from))
|
|
12
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
13
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
14
|
+
}
|
|
15
|
+
return to;
|
|
16
|
+
};
|
|
17
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
18
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
19
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
20
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
21
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
22
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
23
|
+
mod
|
|
24
|
+
));
|
|
25
|
+
|
|
26
|
+
// src/commands/scan.ts
|
|
27
|
+
var path3 = __toESM(require("path"));
|
|
28
|
+
|
|
29
|
+
// src/config.ts
|
|
30
|
+
var fs = __toESM(require("fs"));
|
|
31
|
+
var path = __toESM(require("path"));
|
|
32
|
+
var os = __toESM(require("os"));
|
|
33
|
+
var GLOBAL_CONFIG_DIR = path.join(os.homedir(), ".relion");
|
|
34
|
+
var GLOBAL_CONFIG_PATH = path.join(GLOBAL_CONFIG_DIR, "config.json");
|
|
35
|
+
var DEFAULT_API_URL = "https://relion.dev";
|
|
36
|
+
function readGlobalConfig() {
|
|
37
|
+
try {
|
|
38
|
+
const raw = fs.readFileSync(GLOBAL_CONFIG_PATH, "utf8");
|
|
39
|
+
return JSON.parse(raw);
|
|
40
|
+
} catch {
|
|
41
|
+
return {};
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function writeGlobalConfig(config) {
|
|
45
|
+
if (!fs.existsSync(GLOBAL_CONFIG_DIR)) {
|
|
46
|
+
fs.mkdirSync(GLOBAL_CONFIG_DIR, { recursive: true });
|
|
47
|
+
}
|
|
48
|
+
fs.writeFileSync(GLOBAL_CONFIG_PATH, JSON.stringify(config, null, 2), "utf8");
|
|
49
|
+
try {
|
|
50
|
+
fs.chmodSync(GLOBAL_CONFIG_PATH, 384);
|
|
51
|
+
} catch {
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function readProjectConfig(cwd) {
|
|
55
|
+
const candidates = [
|
|
56
|
+
path.join(cwd, ".relionrc"),
|
|
57
|
+
path.join(cwd, ".relion", "config")
|
|
58
|
+
];
|
|
59
|
+
for (const p of candidates) {
|
|
60
|
+
try {
|
|
61
|
+
const raw = fs.readFileSync(p, "utf8");
|
|
62
|
+
return JSON.parse(raw);
|
|
63
|
+
} catch {
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return {};
|
|
67
|
+
}
|
|
68
|
+
function resolveConfig(flags, cwd = process.cwd()) {
|
|
69
|
+
const global = readGlobalConfig();
|
|
70
|
+
const project = readProjectConfig(cwd);
|
|
71
|
+
const token = flags.token ?? process.env.RELION_TOKEN ?? project.token ?? global.token ?? null;
|
|
72
|
+
const apiUrl = flags.apiUrl ?? process.env.RELION_API_URL ?? project.apiUrl ?? global.apiUrl ?? DEFAULT_API_URL;
|
|
73
|
+
const repoUrl = flags.repoUrl ?? process.env.RELION_REPO_URL ?? project.repoUrl ?? autoDetectRepoUrl() ?? null;
|
|
74
|
+
const commit = flags.commit ?? process.env.RELION_COMMIT ?? process.env.GITHUB_SHA ?? autoDetectCommit() ?? null;
|
|
75
|
+
const branch = flags.branch ?? process.env.RELION_BRANCH ?? process.env.GITHUB_REF_NAME ?? autoDetectBranch() ?? null;
|
|
76
|
+
return {
|
|
77
|
+
token,
|
|
78
|
+
apiUrl,
|
|
79
|
+
repoUrl,
|
|
80
|
+
commit,
|
|
81
|
+
branch,
|
|
82
|
+
workspace: flags.workspace ?? process.env.RELION_WORKSPACE ?? project.workspace ?? global.workspace ?? null,
|
|
83
|
+
email: global.email ?? null,
|
|
84
|
+
lastScanId: global.lastScanId ?? null,
|
|
85
|
+
lastDashboardUrl: global.lastDashboardUrl ?? null
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
function autoDetectRepoUrl() {
|
|
89
|
+
try {
|
|
90
|
+
const { execSync: execSync2 } = require("child_process");
|
|
91
|
+
const remote = execSync2("git remote get-url origin", { stdio: ["pipe", "pipe", "ignore"] }).toString().trim();
|
|
92
|
+
return remote.replace(/^git@github\.com:/, "https://github.com/").replace(/\.git$/, "");
|
|
93
|
+
} catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
function autoDetectCommit() {
|
|
98
|
+
try {
|
|
99
|
+
const { execSync: execSync2 } = require("child_process");
|
|
100
|
+
return execSync2("git rev-parse HEAD", { stdio: ["pipe", "pipe", "ignore"] }).toString().trim();
|
|
101
|
+
} catch {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
function autoDetectBranch() {
|
|
106
|
+
try {
|
|
107
|
+
const { execSync: execSync2 } = require("child_process");
|
|
108
|
+
return execSync2("git rev-parse --abbrev-ref HEAD", { stdio: ["pipe", "pipe", "ignore"] }).toString().trim();
|
|
109
|
+
} catch {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// src/ui.ts
|
|
115
|
+
var NO_COLOR = !process.stdout.isTTY || process.env.NO_COLOR === "1" || process.env.RELION_NO_COLOR === "1";
|
|
116
|
+
var IS_CI = Boolean(process.env.CI);
|
|
117
|
+
var QUIET = process.env.RELION_QUIET === "1";
|
|
118
|
+
function c(code, text) {
|
|
119
|
+
if (NO_COLOR) return text;
|
|
120
|
+
return `\x1B[${code}m${text}\x1B[0m`;
|
|
121
|
+
}
|
|
122
|
+
var color = {
|
|
123
|
+
green: (t) => c("32", t),
|
|
124
|
+
red: (t) => c("31", t),
|
|
125
|
+
yellow: (t) => c("33", t),
|
|
126
|
+
cyan: (t) => c("36", t),
|
|
127
|
+
gray: (t) => c("90", t),
|
|
128
|
+
bold: (t) => c("1", t),
|
|
129
|
+
dim: (t) => c("2", t)
|
|
130
|
+
};
|
|
131
|
+
var FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
|
|
132
|
+
var Spinner = class {
|
|
133
|
+
constructor() {
|
|
134
|
+
this.frame = 0;
|
|
135
|
+
this.timer = null;
|
|
136
|
+
this.current = "";
|
|
137
|
+
}
|
|
138
|
+
start(msg) {
|
|
139
|
+
if (QUIET || IS_CI) return;
|
|
140
|
+
this.current = msg;
|
|
141
|
+
if (!NO_COLOR && process.stdout.isTTY) {
|
|
142
|
+
this.timer = setInterval(() => {
|
|
143
|
+
process.stdout.write(`\r${color.cyan(FRAMES[this.frame % FRAMES.length])} ${this.current} `);
|
|
144
|
+
this.frame++;
|
|
145
|
+
}, 80);
|
|
146
|
+
} else {
|
|
147
|
+
process.stdout.write(` ${msg}...
|
|
148
|
+
`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
update(msg) {
|
|
152
|
+
this.current = msg;
|
|
153
|
+
}
|
|
154
|
+
succeed(msg) {
|
|
155
|
+
this.stop();
|
|
156
|
+
if (!QUIET) console.log(`${color.green("\u2713")} ${msg}`);
|
|
157
|
+
}
|
|
158
|
+
fail(msg) {
|
|
159
|
+
this.stop();
|
|
160
|
+
console.error(`${color.red("\u2717")} ${msg}`);
|
|
161
|
+
}
|
|
162
|
+
warn(msg) {
|
|
163
|
+
this.stop();
|
|
164
|
+
if (!QUIET) console.log(`${color.yellow("\u26A0")} ${msg}`);
|
|
165
|
+
}
|
|
166
|
+
stop() {
|
|
167
|
+
if (this.timer) {
|
|
168
|
+
clearInterval(this.timer);
|
|
169
|
+
this.timer = null;
|
|
170
|
+
if (process.stdout.isTTY) process.stdout.write("\r\x1B[K");
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
function printReceipt(opts) {
|
|
175
|
+
if (QUIET) return;
|
|
176
|
+
const width = 60;
|
|
177
|
+
const line = "\u2500".repeat(width);
|
|
178
|
+
const pad = (label, value) => {
|
|
179
|
+
const gap = width - label.length - value.length - 2;
|
|
180
|
+
return `\u2502 ${label}${" ".repeat(Math.max(1, gap))}${value} \u2502`;
|
|
181
|
+
};
|
|
182
|
+
const gateLabel = opts.deployGateStatus === "clear" ? color.green("\u2713 CLEAR") : opts.deployGateStatus === "blocked" ? color.red("\u2717 BLOCKED") : color.yellow("\u26A0 PENDING REVIEW");
|
|
183
|
+
console.log("");
|
|
184
|
+
console.log(`\u250C${"\u2500".repeat(width + 2)}\u2510`);
|
|
185
|
+
console.log(`\u2502 ${color.bold(opts.dryRun ? "Relion Scan \u2014 Dry Run" : "Relion Scan Complete")}${" ".repeat(width - (opts.dryRun ? 20 : 19))} \u2502`);
|
|
186
|
+
console.log(`\u251C${line}\u2524`);
|
|
187
|
+
if (opts.scanId) console.log(pad("Scan ID:", color.dim(opts.scanId)));
|
|
188
|
+
if (opts.branch || opts.commit) {
|
|
189
|
+
const meta = [opts.branch, opts.commit ? opts.commit.slice(0, 7) : ""].filter(Boolean).join(" \xB7 Commit: ");
|
|
190
|
+
console.log(pad("Branch:", color.dim(meta)));
|
|
191
|
+
}
|
|
192
|
+
console.log(pad("Duration:", color.dim(`${(opts.durationMs / 1e3).toFixed(1)}s`)));
|
|
193
|
+
console.log(`\u251C${line}\u2524`);
|
|
194
|
+
console.log(pad("Files scanned:", String(opts.filesScanned)));
|
|
195
|
+
console.log(pad("Vendors detected:", String(opts.vendorsDetected)));
|
|
196
|
+
console.log(pad("API endpoints found:", String(opts.endpointsFound)));
|
|
197
|
+
console.log(pad("Data transmitted:", opts.dryRun ? "0 bytes (dry run)" : `${humanBytes(opts.bytesTransmitted)} (metadata only)`));
|
|
198
|
+
console.log(pad("Source code sent:", color.green("0 bytes (never)")));
|
|
199
|
+
if (opts.secretsRedacted > 0) {
|
|
200
|
+
console.log(pad("Secrets redacted:", color.yellow(String(opts.secretsRedacted))));
|
|
201
|
+
}
|
|
202
|
+
console.log(`\u251C${line}\u2524`);
|
|
203
|
+
console.log(pad("Deploy gate:", gateLabel));
|
|
204
|
+
if (opts.deployGateAlerts?.length) {
|
|
205
|
+
console.log(`\u2502${" ".repeat(width + 2)}\u2502`);
|
|
206
|
+
for (const alert of opts.deployGateAlerts.slice(0, 3)) {
|
|
207
|
+
const sev = alert.severity === "critical" ? color.red(`[${alert.severity.toUpperCase()}]`) : color.yellow(`[${alert.severity.toUpperCase()}]`);
|
|
208
|
+
const text = ` ${sev} ${alert.title}`.slice(0, width + 2);
|
|
209
|
+
console.log(`\u2502${text.padEnd(width + 2)}\u2502`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
if (opts.dashboardUrl && !opts.dryRun) {
|
|
213
|
+
console.log(`\u251C${line}\u2524`);
|
|
214
|
+
console.log(`\u2502 ${color.cyan("View on dashboard:")}${" ".repeat(width - 18)} \u2502`);
|
|
215
|
+
console.log(`\u2502 ${color.dim("\u2192")} ${opts.dashboardUrl.slice(0, width - 2).padEnd(width - 2)} \u2502`);
|
|
216
|
+
}
|
|
217
|
+
console.log(`\u2514${"\u2500".repeat(width + 2)}\u2518`);
|
|
218
|
+
console.log("");
|
|
219
|
+
}
|
|
220
|
+
function printPredeployReceipt(opts) {
|
|
221
|
+
if (QUIET) return;
|
|
222
|
+
const width = 60;
|
|
223
|
+
const line = "\u2500".repeat(width);
|
|
224
|
+
const pad = (label, value) => {
|
|
225
|
+
const stripped = value.replace(/\x1b\[[0-9;]*m/g, "");
|
|
226
|
+
const gap = width - label.length - stripped.length - 2;
|
|
227
|
+
return `\u2502 ${label}${" ".repeat(Math.max(1, gap))}${value} \u2502`;
|
|
228
|
+
};
|
|
229
|
+
const verdictLabel = opts.verdict === "safe" ? color.green("\u2713 SAFE") : opts.verdict === "caution" ? color.yellow("\u26A0 CAUTION") : opts.verdict === "high_risk" ? color.red("\u2717 HIGH RISK") : opts.verdict === "blocked" ? color.red("\u2717 BLOCKED") : color.dim("\u2014 OFFLINE");
|
|
230
|
+
console.log("");
|
|
231
|
+
console.log(`\u250C${"\u2500".repeat(width + 2)}\u2510`);
|
|
232
|
+
console.log(`\u2502 ${color.bold("Relion Pre-Deploy Check")}${" ".repeat(width - 22)} \u2502`);
|
|
233
|
+
console.log(`\u251C${line}\u2524`);
|
|
234
|
+
if (opts.branch || opts.commit) {
|
|
235
|
+
const meta = [opts.branch, opts.commit ? opts.commit.slice(0, 7) : ""].filter(Boolean).join(" \u2192 ");
|
|
236
|
+
console.log(pad("Branch:", color.dim(meta)));
|
|
237
|
+
}
|
|
238
|
+
if (opts.baseBranch) console.log(pad("Comparing against:", color.dim(opts.baseBranch)));
|
|
239
|
+
console.log(pad("Duration:", color.dim(`${(opts.durationMs / 1e3).toFixed(1)}s`)));
|
|
240
|
+
if (opts.offline) console.log(pad("Mode:", color.yellow("offline")));
|
|
241
|
+
console.log(`\u251C${line}\u2524`);
|
|
242
|
+
console.log(pad("Files in diff:", String(opts.filesChangedCount)));
|
|
243
|
+
console.log(pad("APIs involved:", String(opts.apisInvolvedCount)));
|
|
244
|
+
console.log(`\u251C${line}\u2524`);
|
|
245
|
+
console.log(pad("Verdict:", verdictLabel));
|
|
246
|
+
const nonSafe = opts.findings.filter((f) => f.riskLevel !== "safe" && f.riskLevel !== "info");
|
|
247
|
+
if (nonSafe.length > 0) {
|
|
248
|
+
console.log(`\u2502${" ".repeat(width + 2)}\u2502`);
|
|
249
|
+
for (const finding of nonSafe.slice(0, 5)) {
|
|
250
|
+
const icon = finding.riskLevel === "blocked" ? color.red("\u2717") : finding.riskLevel === "high_risk" ? color.red("!") : color.yellow("\u26A0");
|
|
251
|
+
const text = ` ${icon} ${finding.vendorName}: ${finding.description}`;
|
|
252
|
+
console.log(`\u2502${text.slice(0, width + 2).padEnd(width + 2)}\u2502`);
|
|
253
|
+
}
|
|
254
|
+
if (nonSafe.length > 5) {
|
|
255
|
+
console.log(`\u2502 ${color.dim(`...and ${nonSafe.length - 5} more findings`)}${" ".repeat(Math.max(0, width - 20 - String(nonSafe.length - 5).length))} \u2502`);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
if (opts.dashboardUrl) {
|
|
259
|
+
console.log(`\u251C${line}\u2524`);
|
|
260
|
+
console.log(`\u2502 ${color.cyan("View on dashboard:")}${" ".repeat(width - 18)} \u2502`);
|
|
261
|
+
console.log(`\u2502 ${color.dim("\u2192")} ${opts.dashboardUrl.slice(0, width - 2).padEnd(width - 2)} \u2502`);
|
|
262
|
+
}
|
|
263
|
+
console.log(`\u2514${"\u2500".repeat(width + 2)}\u2518`);
|
|
264
|
+
console.log("");
|
|
265
|
+
}
|
|
266
|
+
function printError(msg, hint) {
|
|
267
|
+
console.error(`
|
|
268
|
+
${color.red("\u2717")} ${color.bold(msg)}`);
|
|
269
|
+
if (hint) console.error(color.dim(` ${hint}`));
|
|
270
|
+
console.error("");
|
|
271
|
+
}
|
|
272
|
+
function printWarn(msg) {
|
|
273
|
+
if (!QUIET) console.warn(`${color.yellow("\u26A0")} ${msg}`);
|
|
274
|
+
}
|
|
275
|
+
function printInfo(msg) {
|
|
276
|
+
if (!QUIET) console.log(`${color.cyan("\u2139")} ${msg}`);
|
|
277
|
+
}
|
|
278
|
+
function humanBytes(bytes) {
|
|
279
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
280
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
281
|
+
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// src/engines/git.ts
|
|
285
|
+
var import_child_process = require("child_process");
|
|
286
|
+
function exec(cmd, cwd) {
|
|
287
|
+
try {
|
|
288
|
+
return (0, import_child_process.execSync)(cmd, { cwd, stdio: ["pipe", "pipe", "pipe"], timeout: 15e3 }).toString().trim();
|
|
289
|
+
} catch {
|
|
290
|
+
return "";
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
function getChangedFiles(root, mode, diffBase, commitSha) {
|
|
294
|
+
let out = "";
|
|
295
|
+
switch (mode) {
|
|
296
|
+
case "staged":
|
|
297
|
+
out = exec("git diff --cached --name-only", root);
|
|
298
|
+
break;
|
|
299
|
+
case "commit":
|
|
300
|
+
out = exec(`git diff-tree --no-commit-id -r --name-only ${commitSha ?? "HEAD"}`, root);
|
|
301
|
+
break;
|
|
302
|
+
case "diff":
|
|
303
|
+
out = exec(`git diff ${diffBase ?? "HEAD"} --name-only`, root);
|
|
304
|
+
break;
|
|
305
|
+
default:
|
|
306
|
+
out = exec("git diff HEAD --name-only", root);
|
|
307
|
+
if (!out) out = exec("git diff --cached --name-only", root);
|
|
308
|
+
}
|
|
309
|
+
return out ? out.split("\n").filter(Boolean) : [];
|
|
310
|
+
}
|
|
311
|
+
function gitMeta(root) {
|
|
312
|
+
const commit = exec("git rev-parse --short HEAD", root) || null;
|
|
313
|
+
const branch = exec("git rev-parse --abbrev-ref HEAD", root) || null;
|
|
314
|
+
const remote = exec("git remote get-url origin", root);
|
|
315
|
+
const repoUrl = remote ? remote.replace(/^git@github\.com:/, "https://github.com/").replace(/\.git$/, "") : null;
|
|
316
|
+
return { commit, branch, repoUrl };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// src/engines/detect.ts
|
|
320
|
+
var fs2 = __toESM(require("fs"));
|
|
321
|
+
var path2 = __toESM(require("path"));
|
|
322
|
+
|
|
323
|
+
// src/engines/vendors.ts
|
|
324
|
+
var VENDORS = [
|
|
325
|
+
{ key: "stripe", name: "Stripe", packages: ["stripe", "@stripe/stripe-js", "@stripe/react-stripe-js"], envPrefixes: ["STRIPE_"], domains: ["api.stripe.com"] },
|
|
326
|
+
{ key: "openai", name: "OpenAI", packages: ["openai", "@openai/openai"], envPrefixes: ["OPENAI_"], domains: ["api.openai.com"] },
|
|
327
|
+
{ key: "anthropic", name: "Anthropic", packages: ["@anthropic-ai/sdk", "anthropic"], envPrefixes: ["ANTHROPIC_", "CLAUDE_"], domains: ["api.anthropic.com"] },
|
|
328
|
+
{ key: "twilio", name: "Twilio", packages: ["twilio"], envPrefixes: ["TWILIO_"], domains: ["api.twilio.com"] },
|
|
329
|
+
{ key: "sendgrid", name: "SendGrid", packages: ["@sendgrid/mail", "@sendgrid/client"], envPrefixes: ["SENDGRID_"], domains: ["api.sendgrid.com"] },
|
|
330
|
+
{ key: "plaid", name: "Plaid", packages: ["plaid"], envPrefixes: ["PLAID_"], domains: ["production.plaid.com", "sandbox.plaid.com"] },
|
|
331
|
+
{ key: "slack", name: "Slack", packages: ["@slack/web-api", "@slack/bolt"], envPrefixes: ["SLACK_"], domains: ["api.slack.com", "hooks.slack.com"] },
|
|
332
|
+
{ key: "github", name: "GitHub", packages: ["@octokit/rest", "@octokit/core", "octokit", "@octokit/graphql"], envPrefixes: ["GITHUB_TOKEN", "GH_TOKEN"], domains: ["api.github.com"] },
|
|
333
|
+
{ key: "shopify", name: "Shopify", packages: ["@shopify/shopify-api", "@shopify/app-bridge"], envPrefixes: ["SHOPIFY_"], domains: ["myshopify.com", "admin.shopify.com"] },
|
|
334
|
+
{ key: "aws", name: "AWS", packages: ["aws-sdk", "@aws-sdk/client-s3", "@aws-sdk/client-dynamodb", "@aws-sdk/client-lambda", "@aws-sdk/client-sqs"], envPrefixes: ["AWS_"], domains: ["amazonaws.com"] },
|
|
335
|
+
{ key: "google_cloud", name: "Google Cloud", packages: ["@google-cloud/storage", "@google-cloud/bigquery", "googleapis"], envPrefixes: ["GOOGLE_", "GCP_"], domains: ["googleapis.com"] },
|
|
336
|
+
{ key: "datadog", name: "Datadog", packages: ["dd-trace", "datadog-lambda-js"], envPrefixes: ["DD_"], domains: ["api.datadoghq.com"] },
|
|
337
|
+
{ key: "segment", name: "Segment", packages: ["@segment/analytics-node", "analytics-node"], envPrefixes: ["SEGMENT_"], domains: ["api.segment.io"] },
|
|
338
|
+
{ key: "hubspot", name: "HubSpot", packages: ["@hubspot/api-client"], envPrefixes: ["HUBSPOT_", "HS_"], domains: ["api.hubapi.com"] },
|
|
339
|
+
{ key: "salesforce", name: "Salesforce", packages: ["jsforce"], envPrefixes: ["SALESFORCE_", "SF_"], domains: ["salesforce.com"] },
|
|
340
|
+
{ key: "pagerduty", name: "PagerDuty", packages: ["node-pagerduty", "@pagerduty/pdjs"], envPrefixes: ["PAGERDUTY_", "PD_"], domains: ["api.pagerduty.com"] },
|
|
341
|
+
{ key: "resend", name: "Resend", packages: ["resend"], envPrefixes: ["RESEND_"], domains: ["api.resend.com"] },
|
|
342
|
+
{ key: "clerk", name: "Clerk", packages: ["@clerk/nextjs", "@clerk/clerk-sdk-node", "@clerk/backend"], envPrefixes: ["CLERK_"], domains: ["api.clerk.dev"] },
|
|
343
|
+
{ key: "supabase", name: "Supabase", packages: ["@supabase/supabase-js"], envPrefixes: ["SUPABASE_"], domains: ["supabase.co"] },
|
|
344
|
+
{ key: "firebase", name: "Firebase", packages: ["firebase", "firebase-admin"], envPrefixes: ["FIREBASE_"], domains: ["firebaseapp.com"] },
|
|
345
|
+
{ key: "linear", name: "Linear", packages: ["@linear/sdk"], envPrefixes: ["LINEAR_"], domains: ["api.linear.app"] },
|
|
346
|
+
{ key: "notion", name: "Notion", packages: ["@notionhq/client"], envPrefixes: ["NOTION_"], domains: ["api.notion.com"] },
|
|
347
|
+
{ key: "airtable", name: "Airtable", packages: ["airtable"], envPrefixes: ["AIRTABLE_"], domains: ["api.airtable.com"] },
|
|
348
|
+
{ key: "vercel", name: "Vercel", packages: ["@vercel/edge", "@vercel/kv", "@vercel/blob"], envPrefixes: ["VERCEL_"], domains: ["api.vercel.com"] }
|
|
349
|
+
];
|
|
350
|
+
var PACKAGE_TO_VENDOR = /* @__PURE__ */ new Map();
|
|
351
|
+
var PREFIX_TO_VENDOR = /* @__PURE__ */ new Map();
|
|
352
|
+
var DOMAIN_TO_VENDOR = /* @__PURE__ */ new Map();
|
|
353
|
+
for (const v of VENDORS) {
|
|
354
|
+
for (const pkg of v.packages) PACKAGE_TO_VENDOR.set(pkg, v);
|
|
355
|
+
for (const pfx of v.envPrefixes) PREFIX_TO_VENDOR.set(pfx, v);
|
|
356
|
+
for (const dom of v.domains) DOMAIN_TO_VENDOR.set(dom, v);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// src/engines/detect.ts
|
|
360
|
+
var MAX_FILE_BYTES = 256e3;
|
|
361
|
+
var TEXT_EXTS = /* @__PURE__ */ new Set([
|
|
362
|
+
".ts",
|
|
363
|
+
".tsx",
|
|
364
|
+
".js",
|
|
365
|
+
".jsx",
|
|
366
|
+
".mjs",
|
|
367
|
+
".cjs",
|
|
368
|
+
".json",
|
|
369
|
+
".env",
|
|
370
|
+
".yaml",
|
|
371
|
+
".yml",
|
|
372
|
+
".toml",
|
|
373
|
+
".py",
|
|
374
|
+
".rb",
|
|
375
|
+
".go",
|
|
376
|
+
".java",
|
|
377
|
+
".cs",
|
|
378
|
+
".php",
|
|
379
|
+
".rs",
|
|
380
|
+
".swift",
|
|
381
|
+
".kt",
|
|
382
|
+
".scala"
|
|
383
|
+
]);
|
|
384
|
+
function readSafe(filePath) {
|
|
385
|
+
try {
|
|
386
|
+
const stat = fs2.statSync(filePath);
|
|
387
|
+
if (!stat.isFile() || stat.size > MAX_FILE_BYTES) return null;
|
|
388
|
+
return fs2.readFileSync(filePath, "utf8");
|
|
389
|
+
} catch {
|
|
390
|
+
return null;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
function matchPackageJson(content, detectedMap, filePath) {
|
|
394
|
+
let parsed;
|
|
395
|
+
try {
|
|
396
|
+
parsed = JSON.parse(content);
|
|
397
|
+
} catch {
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
const deps = {
|
|
401
|
+
...parsed.dependencies ?? {},
|
|
402
|
+
...parsed.devDependencies ?? {}
|
|
403
|
+
};
|
|
404
|
+
for (const pkgName of Object.keys(deps)) {
|
|
405
|
+
const vendor = PACKAGE_TO_VENDOR.get(pkgName);
|
|
406
|
+
if (!vendor) continue;
|
|
407
|
+
upsert(detectedMap, vendor, "strong", `package.json dependency: ${pkgName}`, filePath);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
function matchSourceFile(content, detectedMap, filePath) {
|
|
411
|
+
const importRe = /(?:from|require)\s*\(?['"`](@?[a-z0-9_\-./]+)['"`]\)?/g;
|
|
412
|
+
let m;
|
|
413
|
+
while ((m = importRe.exec(content)) !== null) {
|
|
414
|
+
const pkg = m[1];
|
|
415
|
+
const vendor = PACKAGE_TO_VENDOR.get(pkg) ?? PACKAGE_TO_VENDOR.get(pkg.split("/").slice(0, 2).join("/"));
|
|
416
|
+
if (vendor) upsert(detectedMap, vendor, "strong", `import: ${pkg}`, filePath);
|
|
417
|
+
}
|
|
418
|
+
const envRe = /\bprocess\.env\.([A-Z][A-Z0-9_]+)\b/g;
|
|
419
|
+
while ((m = envRe.exec(content)) !== null) {
|
|
420
|
+
const envKey = m[1];
|
|
421
|
+
for (const [prefix, vendor] of PREFIX_TO_VENDOR) {
|
|
422
|
+
if (envKey.startsWith(prefix)) {
|
|
423
|
+
upsert(detectedMap, vendor, "partial", `env var: ${envKey}`, filePath);
|
|
424
|
+
break;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
const urlRe = /['"`](https?:\/\/([a-z0-9.\-]+)[^\s'"`]*)/g;
|
|
429
|
+
while ((m = urlRe.exec(content)) !== null) {
|
|
430
|
+
const host = m[2];
|
|
431
|
+
for (const [domain, vendor] of DOMAIN_TO_VENDOR) {
|
|
432
|
+
if (host.includes(domain)) {
|
|
433
|
+
upsert(detectedMap, vendor, "partial", `URL reference: ${host}`, filePath);
|
|
434
|
+
break;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
function matchEnvFile(content, detectedMap, filePath) {
|
|
440
|
+
for (const line of content.split("\n")) {
|
|
441
|
+
const key = line.split("=")[0]?.trim().replace(/^export\s+/, "");
|
|
442
|
+
if (!key) continue;
|
|
443
|
+
for (const [prefix, vendor] of PREFIX_TO_VENDOR) {
|
|
444
|
+
if (key.startsWith(prefix)) {
|
|
445
|
+
upsert(detectedMap, vendor, "partial", `env key: ${key}`, filePath);
|
|
446
|
+
break;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
function upsert(map, vendor, confidence, signal, filePath) {
|
|
452
|
+
const existing = map.get(vendor.key);
|
|
453
|
+
if (existing) {
|
|
454
|
+
if (confidence === "strong") existing.confidence = "strong";
|
|
455
|
+
else if (confidence === "partial" && existing.confidence === "weak") existing.confidence = "partial";
|
|
456
|
+
if (!existing.signals.includes(signal)) existing.signals.push(signal);
|
|
457
|
+
if (!existing.files.includes(filePath)) existing.files.push(filePath);
|
|
458
|
+
} else {
|
|
459
|
+
map.set(vendor.key, { key: vendor.key, name: vendor.name, confidence, signals: [signal], files: [filePath] });
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
function detectVendorsInFiles(root, relPaths) {
|
|
463
|
+
const detectedMap = /* @__PURE__ */ new Map();
|
|
464
|
+
for (const rel of relPaths) {
|
|
465
|
+
const absPath = path2.join(root, rel);
|
|
466
|
+
const ext = path2.extname(rel).toLowerCase();
|
|
467
|
+
const base = path2.basename(rel).toLowerCase();
|
|
468
|
+
if (!TEXT_EXTS.has(ext) && !base.startsWith(".env")) continue;
|
|
469
|
+
const content = readSafe(absPath);
|
|
470
|
+
if (!content) continue;
|
|
471
|
+
if (base === "package.json") {
|
|
472
|
+
matchPackageJson(content, detectedMap, rel);
|
|
473
|
+
} else if (base.startsWith(".env") || ext === ".env") {
|
|
474
|
+
matchEnvFile(content, detectedMap, rel);
|
|
475
|
+
} else {
|
|
476
|
+
matchSourceFile(content, detectedMap, rel);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
return [...detectedMap.values()];
|
|
480
|
+
}
|
|
481
|
+
function detectVendorsInRoot(root) {
|
|
482
|
+
const pkgPath = path2.join(root, "package.json");
|
|
483
|
+
const content = readSafe(pkgPath);
|
|
484
|
+
if (!content) return [];
|
|
485
|
+
const map = /* @__PURE__ */ new Map();
|
|
486
|
+
matchPackageJson(content, map, "package.json");
|
|
487
|
+
return [...map.values()];
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// src/commands/scan.ts
|
|
491
|
+
async function scanCommand(targetPath, flags) {
|
|
492
|
+
const root = targetPath ? path3.resolve(targetPath) : process.cwd();
|
|
493
|
+
const startedAt = Date.now();
|
|
494
|
+
const config = resolveConfig({ token: flags.token, apiUrl: flags.url, repoUrl: flags.repoUrl, commit: flags.commit, branch: flags.branch }, root);
|
|
495
|
+
if (!config.token && !flags.dryRun) {
|
|
496
|
+
printError("No API token found.", "Set RELION_TOKEN env var or run: relion login --token <token>");
|
|
497
|
+
process.exit(1);
|
|
498
|
+
}
|
|
499
|
+
const meta = gitMeta(root);
|
|
500
|
+
const branch = flags.branch ?? config.branch ?? meta.branch ?? void 0;
|
|
501
|
+
const commit = flags.commit ?? config.commit ?? meta.commit ?? void 0;
|
|
502
|
+
const repoUrl = flags.repoUrl ?? config.repoUrl ?? meta.repoUrl ?? void 0;
|
|
503
|
+
if (flags.output !== "json") console.log(`
|
|
504
|
+
Relion v2.0.0${repoUrl ? ` \xB7 ${repoUrl.replace("https://github.com/", "")}` : ""}
|
|
505
|
+
`);
|
|
506
|
+
const spinner = new Spinner();
|
|
507
|
+
try {
|
|
508
|
+
spinner.start("Detecting API vendors");
|
|
509
|
+
if (!flags.dryRun && config.token) {
|
|
510
|
+
await verifyToken(config.token, config.apiUrl);
|
|
511
|
+
}
|
|
512
|
+
spinner.succeed("Authenticated");
|
|
513
|
+
spinner.start("Scanning for API dependencies");
|
|
514
|
+
const detected = detectVendorsInRoot(root);
|
|
515
|
+
spinner.succeed(`${detected.length} vendor API${detected.length === 1 ? "" : "s"} detected`);
|
|
516
|
+
const durationMs = Date.now() - startedAt;
|
|
517
|
+
if (flags.dryRun || !config.token) {
|
|
518
|
+
if (flags.output !== "json") {
|
|
519
|
+
console.log("\nDry run \u2014 results not uploaded:\n");
|
|
520
|
+
for (const v of detected) {
|
|
521
|
+
console.log(` ${v.name} (${v.confidence}) \u2014 ${v.signals[0] ?? ""}`);
|
|
522
|
+
}
|
|
523
|
+
console.log("");
|
|
524
|
+
} else {
|
|
525
|
+
process.stdout.write(JSON.stringify({ vendors: detected, dryRun: true }, null, 2) + "\n");
|
|
526
|
+
}
|
|
527
|
+
process.exit(detected.length === 0 ? 4 : 0);
|
|
528
|
+
}
|
|
529
|
+
spinner.start("Uploading API metadata");
|
|
530
|
+
const payload = {
|
|
531
|
+
schemaVersion: "2",
|
|
532
|
+
idempotencyKey: `${commit ?? "none"}-${Date.now()}`,
|
|
533
|
+
sessionId: `cli-${Date.now()}`,
|
|
534
|
+
agentVersion: "2.0.0",
|
|
535
|
+
cliVersion: "2.0.0",
|
|
536
|
+
repository: { repoUrl, commit, branch },
|
|
537
|
+
scanMeta: { startedAt: new Date(startedAt).toISOString(), completedAt: (/* @__PURE__ */ new Date()).toISOString(), durationMs, filesScanned: 0, bytesScanned: 0, triggerSource: "cli" },
|
|
538
|
+
stats: { filesScanned: 0, bytesScanned: 0, vendorsDetected: detected.length, endpointsFound: 0, specFilesFound: 0 },
|
|
539
|
+
integrations: detected.map((v) => ({
|
|
540
|
+
vendorKey: v.key,
|
|
541
|
+
vendorName: v.name,
|
|
542
|
+
integrationType: "api",
|
|
543
|
+
description: `Detected via ${v.signals[0] ?? "pattern matching"}`,
|
|
544
|
+
confidence: v.confidence === "strong" ? "strong" : v.confidence === "partial" ? "partial" : "weak",
|
|
545
|
+
confidenceScore: v.confidence === "strong" ? 0.9 : v.confidence === "partial" ? 0.6 : 0.3,
|
|
546
|
+
signals: v.signals.map((s) => ({ signalType: "package_dependency", filePath: v.files[0] ?? "unknown", lineStart: 0, signalValue: s, confidenceWeight: 0.8 })),
|
|
547
|
+
surfaces: []
|
|
548
|
+
}))
|
|
549
|
+
};
|
|
550
|
+
const res = await fetch(`${config.apiUrl.replace(/\/$/, "")}/api/ingest/scan/v2`, {
|
|
551
|
+
method: "POST",
|
|
552
|
+
headers: { Authorization: `Bearer ${config.token}`, "Content-Type": "application/json" },
|
|
553
|
+
body: JSON.stringify(payload)
|
|
554
|
+
});
|
|
555
|
+
if (!res.ok) {
|
|
556
|
+
const err = await res.json().catch(() => ({ error: "Upload failed" }));
|
|
557
|
+
throw new Error(err.error ?? `Upload failed (${res.status})`);
|
|
558
|
+
}
|
|
559
|
+
const receipt = await res.json();
|
|
560
|
+
spinner.succeed("Upload complete");
|
|
561
|
+
if (flags.output === "json") {
|
|
562
|
+
process.stdout.write(JSON.stringify(receipt, null, 2) + "\n");
|
|
563
|
+
} else {
|
|
564
|
+
printReceipt({
|
|
565
|
+
scanId: receipt.scanId,
|
|
566
|
+
branch,
|
|
567
|
+
commit,
|
|
568
|
+
durationMs,
|
|
569
|
+
filesScanned: 0,
|
|
570
|
+
vendorsDetected: detected.length,
|
|
571
|
+
endpointsFound: 0,
|
|
572
|
+
bytesTransmitted: JSON.stringify(payload).length,
|
|
573
|
+
secretsRedacted: 0,
|
|
574
|
+
deployGateStatus: receipt.deployGate?.status ?? "clear",
|
|
575
|
+
deployGateAlerts: receipt.deployGate?.alerts ?? [],
|
|
576
|
+
dashboardUrl: receipt.dashboardUrl,
|
|
577
|
+
dryRun: false
|
|
578
|
+
});
|
|
579
|
+
const global = readGlobalConfig();
|
|
580
|
+
writeGlobalConfig({ ...global, lastScanId: receipt.scanId, lastScanAt: (/* @__PURE__ */ new Date()).toISOString(), lastDashboardUrl: receipt.dashboardUrl });
|
|
581
|
+
}
|
|
582
|
+
if (receipt.deployGate?.status === "blocked") process.exit(2);
|
|
583
|
+
if (receipt.deployGate?.status === "pending") process.exit(3);
|
|
584
|
+
if (detected.length === 0) process.exit(4);
|
|
585
|
+
} catch (err) {
|
|
586
|
+
spinner.fail("Scan failed");
|
|
587
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
588
|
+
if (msg.includes("401") || msg.includes("Invalid or missing")) {
|
|
589
|
+
printError("Authentication failed.", "Set RELION_TOKEN env var.");
|
|
590
|
+
} else {
|
|
591
|
+
printError(msg);
|
|
592
|
+
}
|
|
593
|
+
if (flags.verbose) console.error(err);
|
|
594
|
+
process.exit(1);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
async function verifyToken(token, apiUrl) {
|
|
598
|
+
const res = await fetch(`${apiUrl.replace(/\/$/, "")}/api/cli/whoami`, { headers: { Authorization: `Bearer ${token}` } });
|
|
599
|
+
if (res.status === 401 || res.status === 403) throw new Error("401 Invalid or missing API token");
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// src/commands/login.ts
|
|
603
|
+
async function loginCommand(flags) {
|
|
604
|
+
const apiUrl = flags.url ?? process.env.RELION_API_URL ?? "https://relion.dev";
|
|
605
|
+
if (flags.token) {
|
|
606
|
+
await saveToken(flags.token, apiUrl);
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
const loginUrl = `${apiUrl}/settings/tokens`;
|
|
610
|
+
console.log(`
|
|
611
|
+
${color.bold("Relion Login")}
|
|
612
|
+
`);
|
|
613
|
+
console.log(`Create an API token at:
|
|
614
|
+
${color.cyan(loginUrl)}
|
|
615
|
+
`);
|
|
616
|
+
console.log(`Then run:
|
|
617
|
+
${color.dim("relion login --token <your-token>")}
|
|
618
|
+
`);
|
|
619
|
+
}
|
|
620
|
+
async function saveToken(token, apiUrl) {
|
|
621
|
+
console.log(`
|
|
622
|
+
${color.bold("Relion Login")}
|
|
623
|
+
`);
|
|
624
|
+
printInfo("Verifying token...");
|
|
625
|
+
let email;
|
|
626
|
+
try {
|
|
627
|
+
const res = await fetch(`${apiUrl.replace(/\/$/, "")}/api/cli/whoami`, {
|
|
628
|
+
headers: { Authorization: `Bearer ${token}` }
|
|
629
|
+
});
|
|
630
|
+
if (res.status === 401 || res.status === 403) {
|
|
631
|
+
printError("Token is invalid or revoked.", "Create a new token at " + apiUrl + "/settings/tokens");
|
|
632
|
+
process.exit(1);
|
|
633
|
+
}
|
|
634
|
+
if (res.ok) {
|
|
635
|
+
const data = await res.json();
|
|
636
|
+
email = data.email;
|
|
637
|
+
}
|
|
638
|
+
} catch {
|
|
639
|
+
console.warn(color.yellow(" Could not verify token (network issue). Saving anyway."));
|
|
640
|
+
}
|
|
641
|
+
const existing = readGlobalConfig();
|
|
642
|
+
writeGlobalConfig({ ...existing, token, apiUrl: apiUrl !== "https://relion.dev" ? apiUrl : void 0, email });
|
|
643
|
+
console.log(`${color.green("\u2713")} ${color.bold("Authenticated")}${email ? ` as ${email}` : ""}`);
|
|
644
|
+
console.log(`${color.dim(" Token saved to ~/.relion/config.json")}
|
|
645
|
+
`);
|
|
646
|
+
console.log(`Run: ${color.cyan("relion scan .")}
|
|
647
|
+
`);
|
|
648
|
+
}
|
|
649
|
+
async function logoutCommand() {
|
|
650
|
+
writeGlobalConfig({});
|
|
651
|
+
console.log(`${color.green("\u2713")} Logged out. Credentials removed.
|
|
652
|
+
`);
|
|
653
|
+
}
|
|
654
|
+
async function whoamiCommand(flags) {
|
|
655
|
+
const config = readGlobalConfig();
|
|
656
|
+
const token = process.env.RELION_TOKEN ?? config.token;
|
|
657
|
+
const apiUrl = flags.url ?? config.apiUrl ?? "https://relion.dev";
|
|
658
|
+
if (!token) {
|
|
659
|
+
printError("Not logged in.", "Run: relion login");
|
|
660
|
+
process.exit(1);
|
|
661
|
+
}
|
|
662
|
+
try {
|
|
663
|
+
const res = await fetch(`${apiUrl.replace(/\/$/, "")}/api/cli/whoami`, {
|
|
664
|
+
headers: { Authorization: `Bearer ${token}` }
|
|
665
|
+
});
|
|
666
|
+
if (!res.ok) {
|
|
667
|
+
printError("Token is invalid or expired.", "Run: relion login");
|
|
668
|
+
process.exit(1);
|
|
669
|
+
}
|
|
670
|
+
const data = await res.json();
|
|
671
|
+
console.log(`
|
|
672
|
+
${color.bold("Relion identity")}`);
|
|
673
|
+
if (data.email) console.log(` Email: ${data.email}`);
|
|
674
|
+
if (data.workspace) console.log(` Workspace: ${data.workspace}`);
|
|
675
|
+
console.log(` API: ${apiUrl}
|
|
676
|
+
`);
|
|
677
|
+
} catch {
|
|
678
|
+
printError("Could not reach Relion API.", `URL: ${apiUrl}`);
|
|
679
|
+
process.exit(1);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// src/commands/status.ts
|
|
684
|
+
async function statusCommand(flags) {
|
|
685
|
+
const config = readGlobalConfig();
|
|
686
|
+
const token = process.env.RELION_TOKEN ?? config.token;
|
|
687
|
+
const apiUrl = flags.url ?? config.apiUrl ?? "https://relion.dev";
|
|
688
|
+
if (!token) {
|
|
689
|
+
printError("Not logged in.", "Run: relion login");
|
|
690
|
+
process.exit(1);
|
|
691
|
+
}
|
|
692
|
+
if (!config.lastScanId) {
|
|
693
|
+
console.log(`
|
|
694
|
+
${color.dim("No scans recorded yet.")}
|
|
695
|
+
`);
|
|
696
|
+
console.log(`Run: ${color.cyan("relion scan .")}
|
|
697
|
+
`);
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
try {
|
|
701
|
+
const res = await fetch(
|
|
702
|
+
`${apiUrl.replace(/\/$/, "")}/api/cli/status/${config.lastScanId}`,
|
|
703
|
+
{ headers: { Authorization: `Bearer ${token}` } }
|
|
704
|
+
);
|
|
705
|
+
if (!res.ok) {
|
|
706
|
+
console.log(`
|
|
707
|
+
${color.dim(`Last scan: ${config.lastScanId}`)}`);
|
|
708
|
+
if (config.lastDashboardUrl) {
|
|
709
|
+
console.log(`Dashboard: ${color.cyan(config.lastDashboardUrl)}
|
|
710
|
+
`);
|
|
711
|
+
}
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
const data = await res.json();
|
|
715
|
+
if (flags.json) {
|
|
716
|
+
process.stdout.write(JSON.stringify(data, null, 2) + "\n");
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
const gateIcon = data.deployGate.status === "clear" ? color.green("\u2713 clear") : data.deployGate.status === "blocked" ? color.red("\u2717 blocked") : color.yellow("\u26A0 pending");
|
|
720
|
+
console.log(`
|
|
721
|
+
${color.bold("Last scan")}`);
|
|
722
|
+
console.log(` Scan ID: ${color.dim(data.scanId)}`);
|
|
723
|
+
if (data.scannedAt) console.log(` Scanned: ${new Date(data.scannedAt).toLocaleString()}`);
|
|
724
|
+
console.log(` Findings: ${data.findingsCount} vendor APIs`);
|
|
725
|
+
console.log(` Gate: ${gateIcon}`);
|
|
726
|
+
if (data.dashboardUrl) console.log(` Dashboard: ${color.cyan(data.dashboardUrl)}`);
|
|
727
|
+
console.log("");
|
|
728
|
+
} catch {
|
|
729
|
+
console.log(`
|
|
730
|
+
${color.bold("Last scan")} ${color.dim("(cached)")}`);
|
|
731
|
+
console.log(` Scan ID: ${color.dim(config.lastScanId)}`);
|
|
732
|
+
if (config.lastScanAt) console.log(` At: ${new Date(config.lastScanAt).toLocaleString()}`);
|
|
733
|
+
if (config.lastDashboardUrl) console.log(` Dashboard: ${color.cyan(config.lastDashboardUrl)}`);
|
|
734
|
+
console.log("");
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// src/commands/predeploy.ts
|
|
739
|
+
var path4 = __toESM(require("path"));
|
|
740
|
+
|
|
741
|
+
// src/engines/api.ts
|
|
742
|
+
async function apiFetch(url, token, method = "GET", body) {
|
|
743
|
+
const opts = {
|
|
744
|
+
method,
|
|
745
|
+
headers: {
|
|
746
|
+
Authorization: `Bearer ${token}`,
|
|
747
|
+
"Content-Type": "application/json"
|
|
748
|
+
}
|
|
749
|
+
};
|
|
750
|
+
if (body !== void 0) opts.body = JSON.stringify(body);
|
|
751
|
+
return fetch(url, opts);
|
|
752
|
+
}
|
|
753
|
+
async function lookupRisk(vendorKeys, token, apiUrl) {
|
|
754
|
+
if (vendorKeys.length === 0) return [];
|
|
755
|
+
const qs = vendorKeys.map((k) => `vendorKeys=${encodeURIComponent(k)}`).join("&");
|
|
756
|
+
const url = `${apiUrl.replace(/\/$/, "")}/api/predeploy/risk?${qs}`;
|
|
757
|
+
try {
|
|
758
|
+
const res = await apiFetch(url, token);
|
|
759
|
+
if (!res.ok) return [];
|
|
760
|
+
const data = await res.json();
|
|
761
|
+
return data.findings ?? [];
|
|
762
|
+
} catch {
|
|
763
|
+
return [];
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
async function submitCheck(payload, token, apiUrl) {
|
|
767
|
+
const url = `${apiUrl.replace(/\/$/, "")}/api/predeploy/check`;
|
|
768
|
+
try {
|
|
769
|
+
const res = await apiFetch(url, token, "POST", payload);
|
|
770
|
+
if (!res.ok) return null;
|
|
771
|
+
return await res.json();
|
|
772
|
+
} catch {
|
|
773
|
+
return null;
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
function scoreVerdict(findings) {
|
|
777
|
+
if (findings.some((f) => f.riskLevel === "blocked")) return { verdict: "blocked", exitCode: 2 };
|
|
778
|
+
if (findings.some((f) => f.riskLevel === "high_risk")) return { verdict: "high_risk", exitCode: 3 };
|
|
779
|
+
if (findings.some((f) => f.riskLevel === "caution")) return { verdict: "caution", exitCode: 3 };
|
|
780
|
+
return { verdict: "safe", exitCode: 0 };
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// src/commands/predeploy.ts
|
|
784
|
+
async function predeployCommand(targetPath, flags) {
|
|
785
|
+
const root = targetPath ? path4.resolve(targetPath) : process.cwd();
|
|
786
|
+
const startedAt = Date.now();
|
|
787
|
+
const config = resolveConfig({ token: flags.token, apiUrl: flags.url, repoUrl: flags.repoUrl, commit: flags.commit, branch: flags.branch }, root);
|
|
788
|
+
const offline = flags.offline ?? !config.token;
|
|
789
|
+
if (!config.token && !offline) {
|
|
790
|
+
printError("No API token found.", "Set RELION_TOKEN env var or run: relion login --token <token>\nUse --offline to run without cloud lookup.");
|
|
791
|
+
process.exit(1);
|
|
792
|
+
}
|
|
793
|
+
let scopeMode = "default";
|
|
794
|
+
let diffBase;
|
|
795
|
+
let commitSha;
|
|
796
|
+
if (flags.staged) {
|
|
797
|
+
scopeMode = "staged";
|
|
798
|
+
} else if (flags.diff) {
|
|
799
|
+
scopeMode = "diff";
|
|
800
|
+
diffBase = flags.diff;
|
|
801
|
+
} else if (flags.commitFlag) {
|
|
802
|
+
scopeMode = "commit";
|
|
803
|
+
commitSha = flags.commitFlag;
|
|
804
|
+
}
|
|
805
|
+
const scopeLabel = scopeMode === "staged" ? "staged changes" : scopeMode === "diff" ? `diff from ${diffBase ?? "HEAD"}` : scopeMode === "commit" ? `commit ${(commitSha ?? "HEAD").slice(0, 7)}` : "uncommitted changes";
|
|
806
|
+
if (!flags.json) console.log(`
|
|
807
|
+
Relion pre-deploy check \u2014 ${scopeLabel}
|
|
808
|
+
`);
|
|
809
|
+
const spinner = new Spinner();
|
|
810
|
+
try {
|
|
811
|
+
spinner.start("Resolving changed files");
|
|
812
|
+
const changedFiles = getChangedFiles(root, scopeMode, diffBase, commitSha);
|
|
813
|
+
if (changedFiles.length === 0) {
|
|
814
|
+
spinner.succeed("No changed files found");
|
|
815
|
+
if (!flags.json) console.log("\nNothing to check \u2014 no changed files detected.\n");
|
|
816
|
+
process.exit(0);
|
|
817
|
+
}
|
|
818
|
+
spinner.succeed(`${changedFiles.length} changed file${changedFiles.length === 1 ? "" : "s"}`);
|
|
819
|
+
spinner.start("Detecting API dependencies");
|
|
820
|
+
const detected = detectVendorsInFiles(root, changedFiles);
|
|
821
|
+
spinner.succeed(detected.length > 0 ? `${detected.length} API vendor${detected.length === 1 ? "" : "s"} detected` : "No vendor APIs detected in changed files");
|
|
822
|
+
let findings = [];
|
|
823
|
+
if (!offline && config.token && detected.length > 0) {
|
|
824
|
+
spinner.start("Checking risk against monitored APIs");
|
|
825
|
+
findings = await lookupRisk(detected.map((d) => d.key), config.token, config.apiUrl);
|
|
826
|
+
spinner.succeed(`Risk check complete \u2014 ${findings.filter((f) => f.riskLevel !== "safe").length} finding${findings.length === 1 ? "" : "s"}`);
|
|
827
|
+
} else if (offline) {
|
|
828
|
+
if (!flags.json) printWarn("Offline mode \u2014 cloud risk lookup skipped.");
|
|
829
|
+
}
|
|
830
|
+
const { verdict, exitCode } = offline ? { verdict: "offline", exitCode: 0 } : scoreVerdict(findings);
|
|
831
|
+
const durationMs = Date.now() - startedAt;
|
|
832
|
+
const meta = gitMeta(root);
|
|
833
|
+
const branch = flags.branch ?? config.branch ?? meta.branch ?? void 0;
|
|
834
|
+
const commit = flags.commit ?? config.commit ?? meta.commit ?? void 0;
|
|
835
|
+
const repoUrl = flags.repoUrl ?? config.repoUrl ?? meta.repoUrl ?? void 0;
|
|
836
|
+
let checkId;
|
|
837
|
+
let dashboardUrl;
|
|
838
|
+
if (!offline && config.token) {
|
|
839
|
+
spinner.start("Submitting check");
|
|
840
|
+
const receipt = await submitCheck(
|
|
841
|
+
{
|
|
842
|
+
verdict,
|
|
843
|
+
exitCode,
|
|
844
|
+
branch,
|
|
845
|
+
baseBranch: flags.baseBranch,
|
|
846
|
+
commit,
|
|
847
|
+
repoUrl,
|
|
848
|
+
scopeMode,
|
|
849
|
+
offline: false,
|
|
850
|
+
filesChangedCount: changedFiles.length,
|
|
851
|
+
apisInvolvedCount: detected.length,
|
|
852
|
+
changedFiles,
|
|
853
|
+
findings,
|
|
854
|
+
durationMs,
|
|
855
|
+
cliVersion: "2.0.0"
|
|
856
|
+
},
|
|
857
|
+
config.token,
|
|
858
|
+
config.apiUrl
|
|
859
|
+
);
|
|
860
|
+
if (receipt) {
|
|
861
|
+
checkId = receipt.checkId;
|
|
862
|
+
dashboardUrl = receipt.dashboardUrl;
|
|
863
|
+
spinner.succeed("Check recorded");
|
|
864
|
+
} else {
|
|
865
|
+
spinner.succeed("Check complete (could not reach dashboard)");
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
if (flags.json) {
|
|
869
|
+
process.stdout.write(JSON.stringify({ verdict, exitCode, branch, commit, filesChangedCount: changedFiles.length, apisInvolvedCount: detected.length, findings, durationMs, checkId, dashboardUrl }, null, 2) + "\n");
|
|
870
|
+
} else {
|
|
871
|
+
printPredeployReceipt({
|
|
872
|
+
verdict,
|
|
873
|
+
branch,
|
|
874
|
+
commit,
|
|
875
|
+
baseBranch: flags.baseBranch,
|
|
876
|
+
filesChangedCount: changedFiles.length,
|
|
877
|
+
apisInvolvedCount: detected.length,
|
|
878
|
+
durationMs,
|
|
879
|
+
offline,
|
|
880
|
+
findings,
|
|
881
|
+
dashboardUrl,
|
|
882
|
+
checkId
|
|
883
|
+
});
|
|
884
|
+
if (flags.verbose && findings.length > 0) {
|
|
885
|
+
console.log("Findings detail:");
|
|
886
|
+
for (const f of findings) {
|
|
887
|
+
if (f.riskLevel === "safe") continue;
|
|
888
|
+
const tag = f.riskLevel.toUpperCase().replace("_", " ");
|
|
889
|
+
console.log(` [${tag}] ${f.vendorName}: ${f.description}`);
|
|
890
|
+
if (f.recommendation) console.log(` \u2192 ${f.recommendation}`);
|
|
891
|
+
console.log("");
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
process.exit(exitCode);
|
|
896
|
+
} catch (err) {
|
|
897
|
+
spinner.fail("Pre-deploy check failed");
|
|
898
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
899
|
+
if (msg.includes("401") || msg.includes("Invalid or missing")) {
|
|
900
|
+
printError("Authentication failed.", "Set RELION_TOKEN env var.");
|
|
901
|
+
} else if (msg.includes("ECONNREFUSED") || msg.includes("fetch failed")) {
|
|
902
|
+
printError(`Could not reach ${config.apiUrl}`, "Use --offline to skip cloud lookup.");
|
|
903
|
+
} else {
|
|
904
|
+
printError(msg);
|
|
905
|
+
}
|
|
906
|
+
if (flags.verbose) console.error(err);
|
|
907
|
+
process.exit(1);
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
// src/index.ts
|
|
912
|
+
var VERSION = "2.0.0";
|
|
913
|
+
function parseArgs(argv) {
|
|
914
|
+
const args = argv.slice(2);
|
|
915
|
+
const positional = [];
|
|
916
|
+
const flags = {};
|
|
917
|
+
for (let i = 0; i < args.length; i++) {
|
|
918
|
+
const arg = args[i];
|
|
919
|
+
if (!arg.startsWith("-")) {
|
|
920
|
+
positional.push(arg);
|
|
921
|
+
continue;
|
|
922
|
+
}
|
|
923
|
+
const key = arg.replace(/^--?/, "");
|
|
924
|
+
const next = args[i + 1];
|
|
925
|
+
if (!next || next.startsWith("-")) {
|
|
926
|
+
flags[key] = true;
|
|
927
|
+
continue;
|
|
928
|
+
}
|
|
929
|
+
if (key === "ignore") {
|
|
930
|
+
const existing = flags[key];
|
|
931
|
+
flags[key] = Array.isArray(existing) ? [...existing, next] : [next];
|
|
932
|
+
i++;
|
|
933
|
+
continue;
|
|
934
|
+
}
|
|
935
|
+
flags[key] = next;
|
|
936
|
+
i++;
|
|
937
|
+
}
|
|
938
|
+
return { command: positional[0] ?? "help", positional: positional.slice(1), flags };
|
|
939
|
+
}
|
|
940
|
+
function printHelp() {
|
|
941
|
+
console.log(`
|
|
942
|
+
${color.bold("relion")} \u2014 MCP-powered API contract scanner and monitoring CLI
|
|
943
|
+
|
|
944
|
+
${color.bold("Usage:")}
|
|
945
|
+
relion <command> [flags]
|
|
946
|
+
|
|
947
|
+
${color.bold("Commands:")}
|
|
948
|
+
scan [path] Scan a directory for API vendor integrations
|
|
949
|
+
predeploy Check API risk before deploying (analyzes git diff)
|
|
950
|
+
login Authenticate with Relion
|
|
951
|
+
logout Remove stored credentials
|
|
952
|
+
whoami Show current identity
|
|
953
|
+
status Show last scan result for this directory
|
|
954
|
+
version Show CLI version
|
|
955
|
+
|
|
956
|
+
${color.bold("Scan flags:")}
|
|
957
|
+
--token <token> API token (or set RELION_TOKEN)
|
|
958
|
+
--url <url> API base URL (default: https://relion.dev)
|
|
959
|
+
--repo-url <url> Repository URL for cloud matching
|
|
960
|
+
--commit <sha> Git commit SHA (auto-detected from git)
|
|
961
|
+
--branch <name> Branch name (auto-detected from git)
|
|
962
|
+
--dry-run Run locally, print findings, skip upload
|
|
963
|
+
--verbose Show per-file detail
|
|
964
|
+
--ignore <glob> Additional patterns to ignore (repeatable)
|
|
965
|
+
--output json Machine-readable JSON output
|
|
966
|
+
|
|
967
|
+
${color.bold("Predeploy flags:")}
|
|
968
|
+
--staged Check staged changes (git diff --cached)
|
|
969
|
+
--diff <base> Check diff from a base branch/SHA
|
|
970
|
+
--commit <sha> Check files changed in a specific commit
|
|
971
|
+
--base-branch <b> Base branch label for display
|
|
972
|
+
--offline Skip cloud risk lookup (local analysis only)
|
|
973
|
+
--json Machine-readable JSON output
|
|
974
|
+
--verbose Show per-finding detail
|
|
975
|
+
|
|
976
|
+
${color.bold("Exit codes:")}
|
|
977
|
+
0 Clear 1 Error
|
|
978
|
+
2 Gate blocked 3 Gate pending 4 No findings
|
|
979
|
+
|
|
980
|
+
${color.bold("Environment variables:")}
|
|
981
|
+
RELION_TOKEN API token
|
|
982
|
+
RELION_API_URL API base URL
|
|
983
|
+
RELION_REPO_URL Repository URL
|
|
984
|
+
RELION_COMMIT Git commit SHA
|
|
985
|
+
RELION_BRANCH Branch name
|
|
986
|
+
|
|
987
|
+
${color.dim("https://relion.dev/docs/cli")}
|
|
988
|
+
`);
|
|
989
|
+
}
|
|
990
|
+
async function main() {
|
|
991
|
+
const { command, positional, flags } = parseArgs(process.argv);
|
|
992
|
+
const f = flags;
|
|
993
|
+
switch (command) {
|
|
994
|
+
case "scan":
|
|
995
|
+
await scanCommand(positional[0], {
|
|
996
|
+
token: f.token,
|
|
997
|
+
url: f.url,
|
|
998
|
+
repoUrl: f["repo-url"],
|
|
999
|
+
commit: f.commit,
|
|
1000
|
+
branch: f.branch,
|
|
1001
|
+
dryRun: Boolean(f["dry-run"]),
|
|
1002
|
+
verbose: Boolean(f.verbose),
|
|
1003
|
+
ignore: f.ignore,
|
|
1004
|
+
output: f.output || "text"
|
|
1005
|
+
});
|
|
1006
|
+
break;
|
|
1007
|
+
case "predeploy":
|
|
1008
|
+
await predeployCommand(positional[0], {
|
|
1009
|
+
token: f.token,
|
|
1010
|
+
url: f.url,
|
|
1011
|
+
repoUrl: f["repo-url"],
|
|
1012
|
+
commit: f.commit,
|
|
1013
|
+
branch: f.branch,
|
|
1014
|
+
baseBranch: f["base-branch"],
|
|
1015
|
+
staged: Boolean(f.staged),
|
|
1016
|
+
diff: f.diff,
|
|
1017
|
+
commitFlag: f.commit,
|
|
1018
|
+
offline: Boolean(f.offline),
|
|
1019
|
+
json: Boolean(f.json),
|
|
1020
|
+
verbose: Boolean(f.verbose)
|
|
1021
|
+
});
|
|
1022
|
+
break;
|
|
1023
|
+
case "login":
|
|
1024
|
+
await loginCommand({
|
|
1025
|
+
token: f.token,
|
|
1026
|
+
url: f.url
|
|
1027
|
+
});
|
|
1028
|
+
break;
|
|
1029
|
+
case "logout":
|
|
1030
|
+
await logoutCommand();
|
|
1031
|
+
break;
|
|
1032
|
+
case "whoami":
|
|
1033
|
+
await whoamiCommand({ url: f.url });
|
|
1034
|
+
break;
|
|
1035
|
+
case "status":
|
|
1036
|
+
await statusCommand({
|
|
1037
|
+
json: Boolean(f.json),
|
|
1038
|
+
url: f.url
|
|
1039
|
+
});
|
|
1040
|
+
break;
|
|
1041
|
+
case "version":
|
|
1042
|
+
case "--version":
|
|
1043
|
+
case "-v":
|
|
1044
|
+
console.log(`relion v${VERSION}`);
|
|
1045
|
+
break;
|
|
1046
|
+
case "help":
|
|
1047
|
+
case "--help":
|
|
1048
|
+
case "-h":
|
|
1049
|
+
default:
|
|
1050
|
+
printHelp();
|
|
1051
|
+
break;
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
main().catch((err) => {
|
|
1055
|
+
printError(err instanceof Error ? err.message : String(err));
|
|
1056
|
+
process.exit(1);
|
|
1057
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "relionhq",
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "Relion CLI — pre-deploy API risk detection and monitoring client.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"bin": {
|
|
7
|
+
"relion": "dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"main": "./dist/index.js",
|
|
10
|
+
"files": ["dist"],
|
|
11
|
+
"scripts": {
|
|
12
|
+
"build": "esbuild src/index.ts --bundle --platform=node --target=node18 --outfile=dist/index.js --external:fsevents",
|
|
13
|
+
"dev": "node --watch dist/index.js",
|
|
14
|
+
"prepublishOnly": "npm run build"
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@types/node": "^22.0.0",
|
|
19
|
+
"esbuild": "^0.25.0",
|
|
20
|
+
"typescript": "^5.8.0"
|
|
21
|
+
},
|
|
22
|
+
"engines": { "node": ">=18" },
|
|
23
|
+
"keywords": ["api", "monitoring", "relion", "predeploy", "cli", "deploy", "risk"]
|
|
24
|
+
}
|