relionhq 2.0.2 → 2.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.
Files changed (2) hide show
  1. package/dist/index.js +318 -30
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -217,14 +217,42 @@ 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
+ function boxLine(content, width) {
241
+ const stripped = stripAnsi(content);
242
+ const gap = Math.max(0, width - stripped.length);
243
+ return `\u2502 ${content}${" ".repeat(gap)} \u2502`;
244
+ }
245
+ function blankLine(width) {
246
+ return `\u2502${" ".repeat(width + 2)}\u2502`;
247
+ }
220
248
  function printPredeployReceipt(opts) {
221
249
  if (QUIET) return;
222
- const width = 60;
250
+ const width = 62;
223
251
  const line = "\u2500".repeat(width);
224
252
  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`;
253
+ const stripped = stripAnsi(value);
254
+ const gap = Math.max(1, width - label.length - stripped.length - 2);
255
+ return `\u2502 ${label}${" ".repeat(gap)}${value} \u2502`;
228
256
  };
229
257
  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
258
  console.log("");
@@ -232,7 +260,7 @@ function printPredeployReceipt(opts) {
232
260
  console.log(`\u2502 ${color.bold("Relion Pre-Deploy Check")}${" ".repeat(width - 22)} \u2502`);
233
261
  console.log(`\u251C${line}\u2524`);
234
262
  if (opts.branch || opts.commit) {
235
- const meta = [opts.branch, opts.commit ? opts.commit.slice(0, 7) : ""].filter(Boolean).join(" \u2192 ");
263
+ const meta = [opts.branch, opts.commit ? opts.commit.slice(0, 7) : ""].filter(Boolean).join(" \xB7 ");
236
264
  console.log(pad("Branch:", color.dim(meta)));
237
265
  }
238
266
  if (opts.baseBranch) console.log(pad("Comparing against:", color.dim(opts.baseBranch)));
@@ -245,23 +273,57 @@ function printPredeployReceipt(opts) {
245
273
  console.log(pad("Verdict:", verdictLabel));
246
274
  const nonSafe = opts.findings.filter((f) => f.riskLevel !== "safe" && f.riskLevel !== "info");
247
275
  if (nonSafe.length > 0) {
248
- console.log(`\u2502${" ".repeat(width + 2)}\u2502`);
249
- for (const finding of nonSafe.slice(0, 5)) {
276
+ console.log(`\u251C${line}\u2524`);
277
+ for (const finding of nonSafe) {
278
+ console.log(blankLine(width));
250
279
  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`);
280
+ const badge = finding.riskLevel === "blocked" ? color.red("[BLOCKED]") : finding.riskLevel === "high_risk" ? color.red("[HIGH RISK]") : color.yellow("[CAUTION]");
281
+ 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") : "";
282
+ const headerText = `${icon} ${badge} ${color.bold(finding.vendorName)}${sourceTag}`;
283
+ console.log(boxLine(headerText, width));
284
+ const title = finding.title ?? finding.description;
285
+ if (title) {
286
+ const titleLines = wordWrap(title, width - 4);
287
+ for (const tl of titleLines) {
288
+ console.log(boxLine(` ${color.dim(tl)}`, width));
289
+ }
290
+ }
291
+ if (finding.versionFrom || finding.versionTo) {
292
+ const vRange = [finding.versionFrom, finding.versionTo].filter(Boolean).join(color.dim(" \u2192 "));
293
+ console.log(boxLine(` ${color.dim("v")}${vRange}`, width));
294
+ }
295
+ const body = finding.summary ?? finding.description;
296
+ if (body) {
297
+ const bodyLines = wordWrap(body, width - 4);
298
+ console.log(blankLine(width));
299
+ for (const bl of bodyLines) {
300
+ console.log(boxLine(` ${bl}`, width));
301
+ }
302
+ }
303
+ if (finding.recommendation) {
304
+ const recLines = wordWrap(finding.recommendation, width - 7);
305
+ console.log(blankLine(width));
306
+ for (let i = 0; i < recLines.length; i++) {
307
+ const prefix = i === 0 ? ` ${color.cyan("\u2192")} ` : " ";
308
+ console.log(boxLine(`${prefix}${recLines[i]}`, width));
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(width));
313
+ if (nonSafe.length > 1) {
314
+ console.log(boxLine(color.dim(` ${nonSafe.length} finding${nonSafe.length === 1 ? "" : "s"} total`), width));
256
315
  }
257
316
  }
258
317
  if (opts.dashboardUrl) {
259
318
  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`);
319
+ console.log(boxLine(`${color.cyan("View:")} ${color.dim(opts.dashboardUrl)}`, width));
262
320
  }
263
321
  console.log(`\u2514${"\u2500".repeat(width + 2)}\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) {
@@ -493,8 +697,9 @@ async function scanCommand(targetPath, flags) {
493
697
  const startedAt = Date.now();
494
698
  const config = resolveConfig({ token: flags.token, apiUrl: flags.url, repoUrl: flags.repoUrl, commit: flags.commit, branch: flags.branch }, root);
495
699
  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);
700
+ printError("No API token found.", "Run: relion login");
701
+ process.exitCode = 1;
702
+ return;
498
703
  }
499
704
  const meta = gitMeta(root);
500
705
  const branch = flags.branch ?? config.branch ?? meta.branch ?? void 0;
@@ -524,7 +729,8 @@ Relion v2.0.0${repoUrl ? ` \xB7 ${repoUrl.replace("https://github.com/", "")}`
524
729
  } else {
525
730
  process.stdout.write(JSON.stringify({ vendors: detected, dryRun: true }, null, 2) + "\n");
526
731
  }
527
- process.exit(detected.length === 0 ? 4 : 0);
732
+ process.exitCode = detected.length === 0 ? 4 : 0;
733
+ return;
528
734
  }
529
735
  spinner.start("Uploading API metadata");
530
736
  const payload = {
@@ -579,9 +785,9 @@ Relion v2.0.0${repoUrl ? ` \xB7 ${repoUrl.replace("https://github.com/", "")}`
579
785
  const global = readGlobalConfig();
580
786
  writeGlobalConfig({ ...global, lastScanId: receipt.scanId, lastScanAt: (/* @__PURE__ */ new Date()).toISOString(), lastDashboardUrl: receipt.dashboardUrl });
581
787
  }
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);
788
+ if (receipt.deployGate?.status === "blocked") process.exitCode = 2;
789
+ else if (receipt.deployGate?.status === "pending") process.exitCode = 3;
790
+ else if (detected.length === 0) process.exitCode = 4;
585
791
  } catch (err) {
586
792
  spinner.fail("Scan failed");
587
793
  const msg = err instanceof Error ? err.message : String(err);
@@ -591,7 +797,7 @@ Relion v2.0.0${repoUrl ? ` \xB7 ${repoUrl.replace("https://github.com/", "")}`
591
797
  printError(msg);
592
798
  }
593
799
  if (flags.verbose) console.error(err);
594
- process.exit(1);
800
+ process.exitCode = 1;
595
801
  }
596
802
  }
597
803
  async function verifyToken(token, apiUrl) {
@@ -797,6 +1003,18 @@ ${color.bold("Last scan")} ${color.dim("(cached)")}`);
797
1003
  var path4 = __toESM(require("path"));
798
1004
 
799
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
+ }
800
1018
  async function apiFetch(url, token, method = "GET", body) {
801
1019
  const opts = {
802
1020
  method,
@@ -810,16 +1028,72 @@ async function apiFetch(url, token, method = "GET", body) {
810
1028
  }
811
1029
  async function lookupRisk(vendorKeys, token, apiUrl) {
812
1030
  if (vendorKeys.length === 0) return [];
813
- const qs = vendorKeys.map((k) => `vendorKeys=${encodeURIComponent(k)}`).join("&");
1031
+ const qs = `vendors=${vendorKeys.map(encodeURIComponent).join(",")}`;
814
1032
  const url = `${apiUrl.replace(/\/$/, "")}/api/predeploy/risk?${qs}`;
1033
+ let data;
815
1034
  try {
816
1035
  const res = await apiFetch(url, token);
817
1036
  if (!res.ok) return [];
818
- const data = await res.json();
819
- return data.findings ?? [];
1037
+ data = await res.json();
820
1038
  } catch {
821
1039
  return [];
822
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;
823
1097
  }
824
1098
  async function submitCheck(payload, token, apiUrl) {
825
1099
  const url = `${apiUrl.replace(/\/$/, "")}/api/predeploy/check`;
@@ -845,8 +1119,9 @@ async function predeployCommand(targetPath, flags) {
845
1119
  const config = resolveConfig({ token: flags.token, apiUrl: flags.url, repoUrl: flags.repoUrl, commit: flags.commit, branch: flags.branch }, root);
846
1120
  const offline = flags.offline ?? !config.token;
847
1121
  if (!config.token && !offline) {
848
- printError("No API token found.", "Set RELION_TOKEN env var or run: relion login --token <token>\nUse --offline to run without cloud lookup.");
849
- process.exit(1);
1122
+ printError("No API token found.", "Run: relion login\nOr set RELION_TOKEN env var. Use --offline to skip cloud lookup.");
1123
+ process.exitCode = 1;
1124
+ return;
850
1125
  }
851
1126
  let scopeMode = "default";
852
1127
  let diffBase;
@@ -871,12 +1146,25 @@ Relion pre-deploy check \u2014 ${scopeLabel}
871
1146
  if (changedFiles.length === 0) {
872
1147
  spinner.succeed("No changed files found");
873
1148
  if (!flags.json) console.log("\nNothing to check \u2014 no changed files detected.\n");
874
- process.exit(0);
1149
+ return;
875
1150
  }
876
1151
  spinner.succeed(`${changedFiles.length} changed file${changedFiles.length === 1 ? "" : "s"}`);
877
1152
  spinner.start("Detecting API dependencies");
878
- const detected = detectVendorsInFiles(root, changedFiles);
879
- 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");
880
1168
  let findings = [];
881
1169
  if (!offline && config.token && detected.length > 0) {
882
1170
  spinner.start("Checking risk against monitored APIs");
@@ -950,7 +1238,7 @@ Relion pre-deploy check \u2014 ${scopeLabel}
950
1238
  }
951
1239
  }
952
1240
  }
953
- process.exit(exitCode);
1241
+ process.exitCode = exitCode;
954
1242
  } catch (err) {
955
1243
  spinner.fail("Pre-deploy check failed");
956
1244
  const msg = err instanceof Error ? err.message : String(err);
@@ -962,7 +1250,7 @@ Relion pre-deploy check \u2014 ${scopeLabel}
962
1250
  printError(msg);
963
1251
  }
964
1252
  if (flags.verbose) console.error(err);
965
- process.exit(1);
1253
+ process.exitCode = 1;
966
1254
  }
967
1255
  }
968
1256
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relionhq",
3
- "version": "2.0.2",
3
+ "version": "2.1.0",
4
4
  "description": "Relion CLI — pre-deploy API risk detection and monitoring client.",
5
5
  "license": "MIT",
6
6
  "bin": {