relionhq 2.0.3 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.js +311 -26
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -217,51 +217,113 @@ function printReceipt(opts) {
217
217
  console.log(`\u2514${"\u2500".repeat(width + 2)}\u2518`);
218
218
  console.log("");
219
219
  }
220
+ function stripAnsi(s) {
221
+ return s.replace(/\x1b\[[0-9;]*m/g, "");
222
+ }
223
+ function wordWrap(text, maxWidth) {
224
+ const words = text.split(" ");
225
+ const lines = [];
226
+ let current = "";
227
+ for (const word of words) {
228
+ if (current.length === 0) {
229
+ current = word;
230
+ } else if (current.length + 1 + word.length <= maxWidth) {
231
+ current += " " + word;
232
+ } else {
233
+ lines.push(current);
234
+ current = word;
235
+ }
236
+ }
237
+ if (current) lines.push(current);
238
+ return lines.length ? lines : [""];
239
+ }
240
+ var INNER = 64;
241
+ var TEXT = INNER - 4;
242
+ function boxLine(content) {
243
+ const stripped = stripAnsi(content);
244
+ const gap = Math.max(0, TEXT - stripped.length);
245
+ return `\u2502 ${content}${" ".repeat(gap)} \u2502`;
246
+ }
247
+ function blankLine() {
248
+ return `\u2502${" ".repeat(INNER)}\u2502`;
249
+ }
250
+ function divider() {
251
+ return `\u251C${"\u2500".repeat(INNER)}\u2524`;
252
+ }
220
253
  function printPredeployReceipt(opts) {
221
254
  if (QUIET) return;
222
- const width = 60;
223
- const line = "\u2500".repeat(width);
224
255
  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`;
256
+ const stripped = stripAnsi(value);
257
+ const gap = Math.max(1, TEXT - label.length - stripped.length);
258
+ return `\u2502 ${label}${" ".repeat(gap)}${value} \u2502`;
228
259
  };
229
260
  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
261
  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`);
262
+ console.log(`\u250C${"\u2500".repeat(INNER)}\u2510`);
263
+ console.log(`\u2502 ${color.bold("Relion Pre-Deploy Check")}${" ".repeat(TEXT - 22)} \u2502`);
264
+ console.log(divider());
234
265
  if (opts.branch || opts.commit) {
235
- const meta = [opts.branch, opts.commit ? opts.commit.slice(0, 7) : ""].filter(Boolean).join(" \u2192 ");
266
+ const meta = [opts.branch, opts.commit ? opts.commit.slice(0, 7) : ""].filter(Boolean).join(" \xB7 ");
236
267
  console.log(pad("Branch:", color.dim(meta)));
237
268
  }
238
269
  if (opts.baseBranch) console.log(pad("Comparing against:", color.dim(opts.baseBranch)));
239
270
  console.log(pad("Duration:", color.dim(`${(opts.durationMs / 1e3).toFixed(1)}s`)));
240
271
  if (opts.offline) console.log(pad("Mode:", color.yellow("offline")));
241
- console.log(`\u251C${line}\u2524`);
272
+ console.log(divider());
242
273
  console.log(pad("Files in diff:", String(opts.filesChangedCount)));
243
274
  console.log(pad("APIs involved:", String(opts.apisInvolvedCount)));
244
- console.log(`\u251C${line}\u2524`);
275
+ console.log(divider());
245
276
  console.log(pad("Verdict:", verdictLabel));
246
277
  const nonSafe = opts.findings.filter((f) => f.riskLevel !== "safe" && f.riskLevel !== "info");
247
278
  if (nonSafe.length > 0) {
248
- console.log(`\u2502${" ".repeat(width + 2)}\u2502`);
249
- for (const finding of nonSafe.slice(0, 5)) {
279
+ console.log(divider());
280
+ for (const finding of nonSafe) {
281
+ console.log(blankLine());
250
282
  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`);
283
+ const badge = finding.riskLevel === "blocked" ? color.red("[BLOCKED]") : finding.riskLevel === "high_risk" ? color.red("[HIGH RISK]") : color.yellow("[CAUTION]");
284
+ const sourceTag = finding.source === "probe_failure" ? color.dim(" \xB7 live probe") : finding.source === "vendor_change" ? color.dim(" \xB7 spec change") : finding.source === "alert" ? color.dim(" \xB7 alert") : "";
285
+ console.log(boxLine(`${icon} ${badge} ${color.bold(finding.vendorName)}${sourceTag}`));
286
+ const title = finding.title ?? finding.description;
287
+ if (title) {
288
+ for (const tl of wordWrap(title, TEXT - 4)) {
289
+ console.log(boxLine(` ${color.dim(tl)}`));
290
+ }
291
+ }
292
+ if (finding.versionFrom || finding.versionTo) {
293
+ const vRange = [finding.versionFrom, finding.versionTo].filter(Boolean).join(" \u2192 ");
294
+ console.log(boxLine(` v${vRange}`));
295
+ }
296
+ const body = finding.summary ?? finding.description;
297
+ if (body) {
298
+ console.log(blankLine());
299
+ for (const bl of wordWrap(body, TEXT - 4)) {
300
+ console.log(boxLine(` ${bl}`));
301
+ }
302
+ }
303
+ if (finding.recommendation) {
304
+ console.log(blankLine());
305
+ const recLines = wordWrap(finding.recommendation, TEXT - 7);
306
+ for (let i = 0; i < recLines.length; i++) {
307
+ const prefix = i === 0 ? ` ${color.cyan("\u2192")} ` : " ";
308
+ console.log(boxLine(`${prefix}${recLines[i]}`));
309
+ }
310
+ }
253
311
  }
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`);
312
+ console.log(blankLine());
313
+ if (nonSafe.length > 1) {
314
+ console.log(boxLine(color.dim(` ${nonSafe.length} findings total`)));
256
315
  }
257
316
  }
258
317
  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`);
318
+ console.log(divider());
319
+ console.log(boxLine(`${color.cyan("View:")} ${color.dim(opts.dashboardUrl)}`));
262
320
  }
263
- console.log(`\u2514${"\u2500".repeat(width + 2)}\u2518`);
321
+ console.log(`\u2514${"\u2500".repeat(INNER)}\u2518`);
264
322
  console.log("");
323
+ if (opts.verdict === "safe" && !QUIET) {
324
+ console.log(`${color.green("\u2713")} No API risk detected. Safe to deploy.
325
+ `);
326
+ }
265
327
  }
266
328
  function printError(msg, hint) {
267
329
  console.error(`
@@ -355,6 +417,81 @@ for (const v of VENDORS) {
355
417
  for (const pfx of v.envPrefixes) PREFIX_TO_VENDOR.set(pfx, v);
356
418
  for (const dom of v.domains) DOMAIN_TO_VENDOR.set(dom, v);
357
419
  }
420
+ var PYTHON_PACKAGE_TO_VENDOR = /* @__PURE__ */ new Map([
421
+ ["stripe", VENDORS.find((v) => v.key === "stripe")],
422
+ ["openai", VENDORS.find((v) => v.key === "openai")],
423
+ ["anthropic", VENDORS.find((v) => v.key === "anthropic")],
424
+ ["twilio", VENDORS.find((v) => v.key === "twilio")],
425
+ ["sendgrid", VENDORS.find((v) => v.key === "sendgrid")],
426
+ ["sendgrid-python", VENDORS.find((v) => v.key === "sendgrid")],
427
+ ["plaid-python", VENDORS.find((v) => v.key === "plaid")],
428
+ ["slack-sdk", VENDORS.find((v) => v.key === "slack")],
429
+ ["slack-bolt", VENDORS.find((v) => v.key === "slack")],
430
+ ["PyGithub", VENDORS.find((v) => v.key === "github")],
431
+ ["shopifyapi", VENDORS.find((v) => v.key === "shopify")],
432
+ ["boto3", VENDORS.find((v) => v.key === "aws")],
433
+ ["botocore", VENDORS.find((v) => v.key === "aws")],
434
+ ["google-cloud-storage", VENDORS.find((v) => v.key === "google_cloud")],
435
+ ["google-cloud-bigquery", VENDORS.find((v) => v.key === "google_cloud")],
436
+ ["google-api-python-client", VENDORS.find((v) => v.key === "google_cloud")],
437
+ ["datadog", VENDORS.find((v) => v.key === "datadog")],
438
+ ["analytics-python", VENDORS.find((v) => v.key === "segment")],
439
+ ["hubspot", VENDORS.find((v) => v.key === "hubspot")],
440
+ ["simple-salesforce", VENDORS.find((v) => v.key === "salesforce")],
441
+ ["pdpyras", VENDORS.find((v) => v.key === "pagerduty")],
442
+ ["resend", VENDORS.find((v) => v.key === "resend")],
443
+ ["supabase", VENDORS.find((v) => v.key === "supabase")],
444
+ ["firebase-admin", VENDORS.find((v) => v.key === "firebase")],
445
+ ["notion-client", VENDORS.find((v) => v.key === "notion")],
446
+ ["airtable", VENDORS.find((v) => v.key === "airtable")]
447
+ ]);
448
+ var GO_MODULE_TO_VENDOR = /* @__PURE__ */ new Map([
449
+ ["github.com/stripe/stripe-go", VENDORS.find((v) => v.key === "stripe")],
450
+ ["github.com/sashabaranov/go-openai", VENDORS.find((v) => v.key === "openai")],
451
+ ["github.com/anthropics/anthropic-sdk-go", VENDORS.find((v) => v.key === "anthropic")],
452
+ ["github.com/twilio/twilio-go", VENDORS.find((v) => v.key === "twilio")],
453
+ ["github.com/sendgrid/sendgrid-go", VENDORS.find((v) => v.key === "sendgrid")],
454
+ ["github.com/plaid/plaid-go", VENDORS.find((v) => v.key === "plaid")],
455
+ ["github.com/slack-go/slack", VENDORS.find((v) => v.key === "slack")],
456
+ ["github.com/google/go-github", VENDORS.find((v) => v.key === "github")],
457
+ ["github.com/aws/aws-sdk-go", VENDORS.find((v) => v.key === "aws")],
458
+ ["github.com/aws/aws-sdk-go-v2", VENDORS.find((v) => v.key === "aws")],
459
+ ["google.golang.org/api", VENDORS.find((v) => v.key === "google_cloud")],
460
+ ["cloud.google.com/go", VENDORS.find((v) => v.key === "google_cloud")],
461
+ ["github.com/DataDog/datadog-go", VENDORS.find((v) => v.key === "datadog")],
462
+ ["github.com/PagerDuty/go-pagerduty", VENDORS.find((v) => v.key === "pagerduty")],
463
+ ["github.com/supabase-community/supabase-go", VENDORS.find((v) => v.key === "supabase")],
464
+ ["firebase.google.com/go", VENDORS.find((v) => v.key === "firebase")],
465
+ ["github.com/jomei/notionapi", VENDORS.find((v) => v.key === "notion")]
466
+ ]);
467
+ var RUBY_GEM_TO_VENDOR = /* @__PURE__ */ new Map([
468
+ ["stripe", VENDORS.find((v) => v.key === "stripe")],
469
+ ["ruby-openai", VENDORS.find((v) => v.key === "openai")],
470
+ ["anthropic-rb", VENDORS.find((v) => v.key === "anthropic")],
471
+ ["twilio-ruby", VENDORS.find((v) => v.key === "twilio")],
472
+ ["sendgrid-ruby", VENDORS.find((v) => v.key === "sendgrid")],
473
+ ["plaid", VENDORS.find((v) => v.key === "plaid")],
474
+ ["slack-ruby-client", VENDORS.find((v) => v.key === "slack")],
475
+ ["octokit", VENDORS.find((v) => v.key === "github")],
476
+ ["shopify_api", VENDORS.find((v) => v.key === "shopify")],
477
+ ["aws-sdk", VENDORS.find((v) => v.key === "aws")],
478
+ ["google-apis-core", VENDORS.find((v) => v.key === "google_cloud")],
479
+ ["dogapi", VENDORS.find((v) => v.key === "datadog")],
480
+ ["hubspot-api-client", VENDORS.find((v) => v.key === "hubspot")],
481
+ ["restforce", VENDORS.find((v) => v.key === "salesforce")],
482
+ ["supabase", VENDORS.find((v) => v.key === "supabase")]
483
+ ]);
484
+ var RUST_CRATE_TO_VENDOR = /* @__PURE__ */ new Map([
485
+ ["stripe", VENDORS.find((v) => v.key === "stripe")],
486
+ ["async-openai", VENDORS.find((v) => v.key === "openai")],
487
+ ["twilio", VENDORS.find((v) => v.key === "twilio")],
488
+ ["aws-sdk-s3", VENDORS.find((v) => v.key === "aws")],
489
+ ["aws-sdk-dynamodb", VENDORS.find((v) => v.key === "aws")],
490
+ ["aws-config", VENDORS.find((v) => v.key === "aws")],
491
+ ["google-cloud-storage", VENDORS.find((v) => v.key === "google_cloud")],
492
+ ["octocrab", VENDORS.find((v) => v.key === "github")],
493
+ ["slack-morphism", VENDORS.find((v) => v.key === "slack")]
494
+ ]);
358
495
 
359
496
  // src/engines/detect.ts
360
497
  var MAX_FILE_BYTES = 256e3;
@@ -486,6 +623,73 @@ function detectVendorsInRoot(root) {
486
623
  matchPackageJson(content, map, "package.json");
487
624
  return [...map.values()];
488
625
  }
626
+ function matchRequirementsTxt(content, map, filePath) {
627
+ for (const rawLine of content.split("\n")) {
628
+ const line = rawLine.trim();
629
+ if (!line || line.startsWith("#") || line.startsWith("-")) continue;
630
+ const pkgName = line.split(/[=<>!~\[;]/)[0].trim().toLowerCase();
631
+ const vendor = PYTHON_PACKAGE_TO_VENDOR.get(pkgName) ?? PYTHON_PACKAGE_TO_VENDOR.get(line.split(/[=<>!~\[;]/)[0].trim());
632
+ if (vendor) upsert(map, vendor, "strong", `requirements.txt: ${pkgName}`, filePath);
633
+ }
634
+ }
635
+ function matchPyprojectToml(content, map, filePath) {
636
+ const depRe = /^([a-zA-Z0-9_\-]+)\s*=/gm;
637
+ let m;
638
+ while ((m = depRe.exec(content)) !== null) {
639
+ const pkgName = m[1].toLowerCase().replace(/_/g, "-");
640
+ const vendor = PYTHON_PACKAGE_TO_VENDOR.get(pkgName) ?? PYTHON_PACKAGE_TO_VENDOR.get(m[1].toLowerCase());
641
+ if (vendor) upsert(map, vendor, "strong", `pyproject.toml: ${pkgName}`, filePath);
642
+ }
643
+ }
644
+ function matchGoMod(content, map, filePath) {
645
+ const reqRe = /^\s+([a-zA-Z0-9.\-/]+)\s+v[\d.]+/gm;
646
+ let m;
647
+ while ((m = reqRe.exec(content)) !== null) {
648
+ const modPath = m[1];
649
+ for (const [key, vendor] of GO_MODULE_TO_VENDOR) {
650
+ if (modPath === key || modPath.startsWith(key + "/") || key.startsWith(modPath)) {
651
+ upsert(map, vendor, "strong", `go.mod: ${modPath}`, filePath);
652
+ break;
653
+ }
654
+ }
655
+ }
656
+ }
657
+ function matchGemfile(content, map, filePath) {
658
+ const gemRe = /^\s*gem\s+['"]([a-zA-Z0-9_\-]+)['"]/gm;
659
+ let m;
660
+ while ((m = gemRe.exec(content)) !== null) {
661
+ const gemName = m[1];
662
+ const vendor = RUBY_GEM_TO_VENDOR.get(gemName);
663
+ if (vendor) upsert(map, vendor, "strong", `Gemfile: ${gemName}`, filePath);
664
+ }
665
+ }
666
+ function matchCargoToml(content, map, filePath) {
667
+ const depRe = /^\s*([a-zA-Z0-9_\-]+)\s*=/gm;
668
+ let m;
669
+ while ((m = depRe.exec(content)) !== null) {
670
+ const crateName = m[1].replace(/_/g, "-");
671
+ const vendor = RUST_CRATE_TO_VENDOR.get(crateName) ?? RUST_CRATE_TO_VENDOR.get(m[1]);
672
+ if (vendor) upsert(map, vendor, "strong", `Cargo.toml: ${crateName}`, filePath);
673
+ }
674
+ }
675
+ function scanProjectManifests(root) {
676
+ const map = /* @__PURE__ */ new Map();
677
+ const manifests = [
678
+ { file: "package.json", handler: matchPackageJson },
679
+ { file: "requirements.txt", handler: matchRequirementsTxt },
680
+ { file: "requirements.in", handler: matchRequirementsTxt },
681
+ { file: "pyproject.toml", handler: matchPyprojectToml },
682
+ { file: "go.mod", handler: matchGoMod },
683
+ { file: "Gemfile", handler: matchGemfile },
684
+ { file: "Cargo.toml", handler: matchCargoToml }
685
+ ];
686
+ for (const { file, handler } of manifests) {
687
+ const absPath = path2.join(root, file);
688
+ const content = readSafe(absPath);
689
+ if (content) handler(content, map, file);
690
+ }
691
+ return [...map.values()];
692
+ }
489
693
 
490
694
  // src/commands/scan.ts
491
695
  async function scanCommand(targetPath, flags) {
@@ -799,6 +1003,18 @@ ${color.bold("Last scan")} ${color.dim("(cached)")}`);
799
1003
  var path4 = __toESM(require("path"));
800
1004
 
801
1005
  // src/engines/api.ts
1006
+ function severityToRiskLevel(severity) {
1007
+ switch (severity?.toLowerCase()) {
1008
+ case "critical":
1009
+ return "blocked";
1010
+ case "high":
1011
+ return "high_risk";
1012
+ case "medium":
1013
+ return "caution";
1014
+ default:
1015
+ return "info";
1016
+ }
1017
+ }
802
1018
  async function apiFetch(url, token, method = "GET", body) {
803
1019
  const opts = {
804
1020
  method,
@@ -812,16 +1028,72 @@ async function apiFetch(url, token, method = "GET", body) {
812
1028
  }
813
1029
  async function lookupRisk(vendorKeys, token, apiUrl) {
814
1030
  if (vendorKeys.length === 0) return [];
815
- const qs = vendorKeys.map((k) => `vendorKeys=${encodeURIComponent(k)}`).join("&");
1031
+ const qs = `vendors=${vendorKeys.map(encodeURIComponent).join(",")}`;
816
1032
  const url = `${apiUrl.replace(/\/$/, "")}/api/predeploy/risk?${qs}`;
1033
+ let data;
817
1034
  try {
818
1035
  const res = await apiFetch(url, token);
819
1036
  if (!res.ok) return [];
820
- const data = await res.json();
821
- return data.findings ?? [];
1037
+ data = await res.json();
822
1038
  } catch {
823
1039
  return [];
824
1040
  }
1041
+ const findings = [];
1042
+ const seen = /* @__PURE__ */ new Set();
1043
+ for (const a of data.alerts ?? []) {
1044
+ const key = `alert:${a.id}`;
1045
+ if (seen.has(key)) continue;
1046
+ seen.add(key);
1047
+ findings.push({
1048
+ vendorKey: a.vendorKey ?? "",
1049
+ vendorName: a.vendorName ?? a.title,
1050
+ riskLevel: severityToRiskLevel(a.severity),
1051
+ findingType: "alert",
1052
+ source: "alert",
1053
+ title: a.title,
1054
+ description: a.vendorChange?.summary ?? a.summary,
1055
+ summary: a.summary,
1056
+ versionFrom: void 0,
1057
+ versionTo: void 0,
1058
+ detectedAt: a.createdAt
1059
+ });
1060
+ }
1061
+ for (const vc of data.vendorChanges ?? []) {
1062
+ const key = `vc:${vc.id}`;
1063
+ if (seen.has(key)) continue;
1064
+ seen.add(key);
1065
+ findings.push({
1066
+ vendorKey: vc.vendorKey,
1067
+ vendorName: vc.vendorKey,
1068
+ riskLevel: severityToRiskLevel(vc.severity),
1069
+ findingType: "vendor_change",
1070
+ source: "vendor_change",
1071
+ title: vc.title,
1072
+ description: vc.summary,
1073
+ summary: vc.summary,
1074
+ versionFrom: vc.versionFrom ?? void 0,
1075
+ versionTo: vc.versionTo ?? void 0,
1076
+ detectedAt: vc.detectedAt
1077
+ });
1078
+ }
1079
+ for (const p of data.probeFailures ?? []) {
1080
+ const key = `probe:${p.id}`;
1081
+ if (seen.has(key)) continue;
1082
+ seen.add(key);
1083
+ findings.push({
1084
+ vendorKey: p.vendorKey ?? "",
1085
+ vendorName: p.vendorName ?? p.name,
1086
+ riskLevel: p.consecutiveFailures >= 3 ? "high_risk" : "caution",
1087
+ findingType: "probe_failure",
1088
+ source: "probe_failure",
1089
+ title: `${p.name} endpoint is failing`,
1090
+ description: p.lastError ?? `${p.consecutiveFailures} consecutive failures on ${p.targetUrl}`,
1091
+ summary: `Live probe ${p.name} has failed ${p.consecutiveFailures} times in a row. Last error: ${p.lastError ?? "unknown"}`,
1092
+ recommendation: "Check the endpoint status before deploying changes that depend on it.",
1093
+ detectedAt: p.lastRunAt ?? void 0
1094
+ });
1095
+ }
1096
+ return findings;
825
1097
  }
826
1098
  async function submitCheck(payload, token, apiUrl) {
827
1099
  const url = `${apiUrl.replace(/\/$/, "")}/api/predeploy/check`;
@@ -878,8 +1150,21 @@ Relion pre-deploy check \u2014 ${scopeLabel}
878
1150
  }
879
1151
  spinner.succeed(`${changedFiles.length} changed file${changedFiles.length === 1 ? "" : "s"}`);
880
1152
  spinner.start("Detecting API dependencies");
881
- const detected = detectVendorsInFiles(root, changedFiles);
882
- spinner.succeed(detected.length > 0 ? `${detected.length} API vendor${detected.length === 1 ? "" : "s"} detected` : "No vendor APIs detected in changed files");
1153
+ const fromDiff = detectVendorsInFiles(root, changedFiles);
1154
+ const fromManifests = scanProjectManifests(root);
1155
+ const vendorMap = new Map(fromDiff.map((v) => [v.key, v]));
1156
+ for (const v of fromManifests) {
1157
+ if (!vendorMap.has(v.key)) {
1158
+ vendorMap.set(v.key, { ...v, signals: v.signals.map((s) => `[project] ${s}`) });
1159
+ } else {
1160
+ const existing = vendorMap.get(v.key);
1161
+ for (const s of v.signals) {
1162
+ if (!existing.signals.includes(s)) existing.signals.push(`[project] ${s}`);
1163
+ }
1164
+ }
1165
+ }
1166
+ const detected = [...vendorMap.values()];
1167
+ spinner.succeed(detected.length > 0 ? `${detected.length} API vendor${detected.length === 1 ? "" : "s"} detected` : "No vendor APIs detected");
883
1168
  let findings = [];
884
1169
  if (!offline && config.token && detected.length > 0) {
885
1170
  spinner.start("Checking risk against monitored APIs");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relionhq",
3
- "version": "2.0.3",
3
+ "version": "2.1.1",
4
4
  "description": "Relion CLI — pre-deploy API risk detection and monitoring client.",
5
5
  "license": "MIT",
6
6
  "bin": {