@modelstatus/cli 0.1.33 β†’ 0.1.35

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@modelstatus/cli",
3
- "version": "0.1.33",
3
+ "version": "0.1.35",
4
4
  "description": "Track which AI models you use, where, and never get surprised by a retirement. Free offline model-health for any repo (mm status), browser sign-in for cloud inventory + alerts.",
5
5
  "keywords": [
6
6
  "llm",
package/src/api.js CHANGED
@@ -80,6 +80,12 @@ export function createClient({ apiBase, apiKey }) {
80
80
  createChannel: (body) => req("POST", "/channels", { action: "connect", ...body }),
81
81
  testChannel: (body) => req("POST", "/channels", { action: "test", ...body }),
82
82
 
83
+ // integrations β€” the cross-device mirror of the local integrations.json toggle.
84
+ // The LOCAL file is authoritative for what `mm scan` runs; these sync the web
85
+ // card + carry plan-gating (402 on free). Best-effort from the TUI.
86
+ listIntegrations: () => req("GET", "/integrations"),
87
+ setIntegration: (body) => req("PATCH", "/integrations", body),
88
+
83
89
  // notifications feed
84
90
  listNotifications: (params) => req("GET", `/notifications${qs(params)}`),
85
91
  readNotification: (id) => req("POST", `/notifications/${id}/read`),
package/src/ci.js CHANGED
@@ -16,7 +16,7 @@ const BADGE = { ok: "🟒", deprecating: "🟑", retiring: "🟠", retired: "
16
16
 
17
17
  /** Evaluate `dir` and return { findings, failing, threshold, failOn, counts, snapshot }.
18
18
  * `findings` are per-(model, location) entries with health worse than ok. */
19
- export async function evaluateCi({ dir, sources = ["filesystem"], scanOpts = {}, failOn = "retired", offline = false, log = () => {} }) {
19
+ export async function evaluateCi({ dir, sources = ["filesystem"], explicit = new Set(), scanOpts = {}, failOn = "retired", offline = false, log = () => {} }) {
20
20
  // Online by default so CI checks against the LATEST registry; --offline (or the
21
21
  // env) falls back to the cached snapshot. cacheFile env keeps tests hermetic.
22
22
  const snapshot = await getRegistry({
@@ -24,7 +24,7 @@ export async function evaluateCi({ dir, sources = ["filesystem"], scanOpts = {},
24
24
  cacheFile: process.env.LLMSTATUS_REGISTRY_CACHE || undefined,
25
25
  log: (m) => log(`${m}\n`),
26
26
  });
27
- const candidates = await collectFrom(sources, { root: dir, ...scanOpts }, snapshot.detection);
27
+ const candidates = await collectFrom(sources, { root: dir, ...scanOpts }, snapshot.detection, explicit);
28
28
  const resolved = resolveLocal(snapshot, [...new Set(candidates.map((c) => c.model_string))]);
29
29
  const byStr = new Map(resolved.map((r) => [r.input.toLowerCase(), r]));
30
30
  const today = new Date();
package/src/index.js CHANGED
@@ -3,7 +3,11 @@ import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { resolveAuth, loadConfig, saveConfig, clearAuth, configFilePath } from "./config.js";
5
5
  import { createClient } from "./api.js";
6
- import { collectFrom, availability, ALL_SOURCE_IDS } from "./sources/index.js";
6
+ import { collectFrom, availability, ALL_SOURCE_IDS, getSource } from "./sources/index.js";
7
+ import {
8
+ INTEGRATION_IDS, INTEGRATION_META, readIntegrations, enabledIds,
9
+ getEnvTag, setEnabled, setEnvTag,
10
+ } from "./integrations.js";
7
11
  import { redactValue } from "./redact.js";
8
12
  import { assignProjects } from "./upload.js";
9
13
  import { loginViaBrowser } from "./auth.js";
@@ -17,6 +21,8 @@ function parseArgs(argv) {
17
21
  const valueFlags = new Set([
18
22
  "api", "key", "project", "dir", "fail-on", "diff", "json-out",
19
23
  "sources", "region", "namespace", "kube-context", "db", "sql-table", "env",
24
+ // Per-integration scope flags (non-secret): consumed by the 4 live sources.
25
+ "vercel-project", "vercel-team", "gh-repo", "supabase-ref",
20
26
  ]);
21
27
  for (let i = 0; i < argv.length; i++) {
22
28
  const a = argv[i];
@@ -41,15 +47,33 @@ const uuidish = (s) => /^[0-9a-f]{8}-[0-9a-f]{4}-/i.test(s || "");
41
47
  * from a git remote, since a local scan root may not be the repo root. */
42
48
  const ghRepoSlug = () => (process.env.GITHUB_REPOSITORY || "").trim();
43
49
 
44
- /** Resolve the requested sources: default filesystem, "all", or a comma list. */
50
+ /** The set of source ids the user named VERBATIM in --sources (empty for the
51
+ * default / "all"). Naming a live integration here overrides its enabled-gate. */
52
+ function explicitSources(flags) {
53
+ const raw = (flags.sources || "").trim();
54
+ if (!raw || raw === "all") return new Set();
55
+ return new Set(raw.split(",").map((s) => s.trim()).filter(Boolean));
56
+ }
57
+
58
+ /** Resolve the requested sources.
59
+ * - empty β†’ filesystem + every ENABLED live integration (toggled-on integrations
60
+ * scan by default; the rest are NOT in the default set, so no surprise
61
+ * network calls).
62
+ * - "all" β†’ ALL_SOURCE_IDS, but a live integration is included only when enabled
63
+ * (so `all` never silently fires a not-authorized integration); the
64
+ * existing non-integration sources stay in `all` unconditionally.
65
+ * - list β†’ honored verbatim (an explicit id overrides the enabled-gate). */
45
66
  function parseSources(flags) {
46
67
  const raw = (flags.sources || "").trim();
47
- if (!raw) return ["filesystem"];
48
- if (raw === "all") return ALL_SOURCE_IDS;
68
+ if (!raw) return ["filesystem", ...enabledIds()];
69
+ if (raw === "all") {
70
+ const enabled = enabledIds();
71
+ return ALL_SOURCE_IDS.filter((id) => !getSource(id)?.integration || enabled.has(id));
72
+ }
49
73
  return raw.split(",").map((s) => s.trim()).filter(Boolean);
50
74
  }
51
75
 
52
- /** Per-source options gathered from flags (region/namespace/db/…). */
76
+ /** Per-source options gathered from flags (region/namespace/db/integration scope). */
53
77
  function scanOpts(flags, dir) {
54
78
  return {
55
79
  root: dir,
@@ -59,6 +83,10 @@ function scanOpts(flags, dir) {
59
83
  db: flags.db,
60
84
  sqlTable: flags["sql-table"],
61
85
  env: flags.env,
86
+ vercelProject: flags["vercel-project"],
87
+ vercelTeam: flags["vercel-team"],
88
+ ghRepo: flags["gh-repo"],
89
+ supabaseProjectRef: flags["supabase-ref"],
62
90
  };
63
91
  }
64
92
 
@@ -136,10 +164,13 @@ async function cmdScan(positional, flags) {
136
164
  // Non-interactive (CI / --json / --yes): scan + bulk upload, no TUI.
137
165
  const client = createClient({ apiBase, apiKey });
138
166
  const sources = parseSources(flags);
167
+ const explicit = explicitSources(flags);
139
168
  const opts = scanOpts(flags, dir);
140
169
 
141
- // Report which sources will actually run (tool/creds/flags present).
142
- const avail = await availability(sources, opts);
170
+ // Report which sources will actually run (tool/creds/flags present). A live
171
+ // integration named explicitly in --sources counts as available without being
172
+ // toggled on (explicit intent overrides the enabled-gate).
173
+ const avail = await availability(sources, opts, explicit);
143
174
  for (const a of avail) {
144
175
  if (!a.known) process.stderr.write(`! unknown source "${a.id}" β€” skipped\n`);
145
176
  else if (!a.available) process.stderr.write(`! ${a.id} unavailable (tool, creds, or flags missing) β€” skipped\n`);
@@ -148,7 +179,7 @@ async function cmdScan(positional, flags) {
148
179
  process.stderr.write(`Scanning [${active.join(", ") || "none"}] …\n`);
149
180
 
150
181
  const patterns = await client.detectionPatterns();
151
- const candidates = await collectFrom(sources, opts, patterns);
182
+ const candidates = await collectFrom(sources, opts, patterns, explicit);
152
183
  if (candidates.length === 0) {
153
184
  console.log("No model usage found.");
154
185
  return;
@@ -305,6 +336,7 @@ async function cmdCi(positional, flags) {
305
336
  const res = await evaluateCi({
306
337
  dir,
307
338
  sources: parseSources(flags),
339
+ explicit: explicitSources(flags),
308
340
  scanOpts: scanOpts(flags, dir),
309
341
  failOn,
310
342
  offline: !!flags.offline,
@@ -397,19 +429,75 @@ async function cmdClear(_positional, flags) {
397
429
  console.log(`βœ“ Cleared ${res.usages ?? 0} usage(s)${res.projects ? ` + ${res.projects} project(s)` : ""}. Your inventory is clean β€” rescan to repopulate.`);
398
430
  }
399
431
 
400
- /** List detection sources and whether each can run right now. */
432
+ /** List detection sources and whether each can run right now. Live integrations
433
+ * also show their on/off toggle (the `int` column) so toggled state is visible
434
+ * here too. */
401
435
  async function cmdSources(_positional, flags) {
402
436
  const dir = path.resolve(flags.dir || ".");
403
437
  const report = await availability(ALL_SOURCE_IDS, scanOpts(flags, dir));
404
438
  console.log("Detection sources:");
405
439
  for (const a of report) {
406
- console.log(` ${a.available ? "βœ“" : "Β·"} ${a.id.padEnd(13)} ${a.label}${a.available ? "" : " (unavailable)"}`);
440
+ // For an integration, the toggle column shows on/off; "Β·" elsewhere is N/A.
441
+ const toggle = a.integration ? (a.enabled ? "on " : "off") : "β€” ";
442
+ console.log(` ${a.available ? "βœ“" : "Β·"} ${a.id.padEnd(15)} ${toggle} ${a.label}${a.available ? "" : " (unavailable)"}`);
407
443
  }
408
444
  console.log("\nScan with: mm scan --sources env,aws-secrets,k8s,helm,sql (or --sources all)");
445
+ console.log("Integrations (toggle with `mm integrations enable <id>`): " + INTEGRATION_IDS.join(", "));
409
446
  console.log("Options: --region <r> Β· --namespace <ns> Β· --kube-context <c> Β· --db <pg-dsn> --sql-table <t>");
447
+ console.log(" --vercel-project <p> Β· --vercel-team <t> Β· --gh-repo <owner/name> Β· --supabase-ref <ref>");
410
448
  console.log("Safety: secret VALUES never leave your machine β€” only model ids upload. Use --dry-run to preview.");
411
449
  }
412
450
 
451
+ /** `mm integrations [list|enable <id>|disable <id>|env <id> <tag>]` β€” manage the
452
+ * local enabled-state of the live integrations (the authoritative toggle for what
453
+ * `mm scan` runs by default). Non-secret only; authorization is your own CLI. */
454
+ function cmdIntegrations(positional, flags) {
455
+ const sub = positional[1];
456
+ const id = positional[2];
457
+ const known = (x) => INTEGRATION_IDS.includes(x);
458
+
459
+ if (sub === "enable" || sub === "disable") {
460
+ if (!known(id)) {
461
+ console.error(`Unknown integration "${id}". One of: ${INTEGRATION_IDS.join(", ")}`);
462
+ process.exit(1);
463
+ }
464
+ setEnabled(id, sub === "enable");
465
+ console.log(`βœ“ ${id} ${sub === "enable" ? "enabled" : "disabled"}. It ${sub === "enable" ? "now runs" : "no longer runs"} in the default scan.`);
466
+ return;
467
+ }
468
+ if (sub === "env") {
469
+ const tag = positional[3];
470
+ if (!known(id)) {
471
+ console.error(`Unknown integration "${id}". One of: ${INTEGRATION_IDS.join(", ")}`);
472
+ process.exit(1);
473
+ }
474
+ try {
475
+ setEnvTag(id, tag);
476
+ } catch (e) {
477
+ console.error(e.message);
478
+ process.exit(1);
479
+ }
480
+ console.log(`βœ“ ${id} env set to "${tag}".${tag === "unknown" ? " (override cleared β€” env is now guessed)" : ""}`);
481
+ return;
482
+ }
483
+
484
+ // Default / "list": show current state.
485
+ const cur = readIntegrations();
486
+ if (flags.json) {
487
+ console.log(JSON.stringify({ data: INTEGRATION_IDS.map((iid) => ({ id: iid, ...INTEGRATION_META[iid], enabled: !!cur[iid]?.enabled, env_tag: getEnvTag(iid) || "unknown", ref: cur[iid]?.ref })) }, null, 2));
488
+ return;
489
+ }
490
+ console.log("Live integrations:");
491
+ for (const iid of INTEGRATION_IDS) {
492
+ const meta = INTEGRATION_META[iid];
493
+ const on = cur[iid]?.enabled ? "on " : "off";
494
+ const tag = getEnvTag(iid) || "unknown";
495
+ console.log(` ${on} ${iid.padEnd(15)} env=${tag.padEnd(8)} (${meta.requiresCmd}) ${meta.label}`);
496
+ }
497
+ console.log("\nToggle: mm integrations enable|disable <id> Β· Env: mm integrations env <id> prod|staging|dev|unknown");
498
+ console.log("Authorization is your already-authenticated CLI (aws/gh/vercel/supabase) β€” we never store a token.");
499
+ }
500
+
413
501
  /** Offline, account-less health check: pull the signed registry snapshot, scan
414
502
  * here, resolve + score health entirely on-device. The free tier's core value. */
415
503
  async function cmdStatus(positional, flags) {
@@ -418,7 +506,7 @@ async function cmdStatus(positional, flags) {
418
506
  const { resolveLocal, computeHealth } = await import("./registry/local.js");
419
507
  const snapshot = await getRegistry({ offline: !!flags.offline, log: (m) => process.stderr.write(`! ${m}\n`) });
420
508
 
421
- const candidates = await collectFrom(parseSources(flags), scanOpts(flags, dir), snapshot.detection);
509
+ const candidates = await collectFrom(parseSources(flags), scanOpts(flags, dir), snapshot.detection, explicitSources(flags));
422
510
  const resolved = resolveLocal(snapshot, [...new Set(candidates.map((c) => c.model_string))]);
423
511
  const byStr = new Map(resolved.map((r) => [r.input.toLowerCase(), r]));
424
512
 
@@ -493,19 +581,24 @@ Usage:
493
581
  mm ci [dir] CI gate: fail the build on deprecated/retiring models (GitHub annotations)
494
582
  (--diff <base> limits findings to files changed vs base; auto on PRs via GITHUB_BASE_REF)
495
583
  mm sources List detection sources and whether each can run here
584
+ mm integrations Manage live integrations (list | enable <id> | disable <id> | env <id> <tag>)
496
585
  mm clear Delete all tracked usages from your inventory (--all also wipes projects/rules; --yes to skip the prompt)
497
586
  mm upgrade Open Stripe checkout and poll until Pro is active
498
587
  mm tui Force-launch the TUI (logs you in first if needed)
499
588
 
500
- Scan sources (--sources, default filesystem; "all" for everything):
589
+ Scan sources (--sources; default = filesystem + enabled integrations; "all" for everything):
501
590
  filesystem repo files aws-secrets AWS Secrets Manager + SSM
502
591
  env live process env k8s kubectl secrets + configmaps
503
592
  sql psql --db <dsn> helm helm release values
593
+ Live integrations (toggle on with \`mm integrations enable <id>\`, or name in --sources):
594
+ aws-lambda Lambda env + Bedrock vercel project env (names) supabase-edge edge fns + secrets
595
+ github-actions workflow YAML + secret names
504
596
  Secret sources shell out to your already-authenticated CLIs, run read-only,
505
597
  and only ever upload model ids β€” secret VALUES never leave your machine.
506
598
 
507
599
  Flags: --api <url> Β· --key <key> Β· --project <id|name> Β· --yes Β· --json Β· --ci Β· --dry-run
508
600
  --sources <list> Β· --region <r> Β· --namespace <ns> Β· --kube-context <c> Β· --db <dsn> Β· --sql-table <t>
601
+ --vercel-project <p> Β· --vercel-team <t> Β· --gh-repo <owner/name> Β· --supabase-ref <ref>
509
602
 
510
603
  Get started: \`mm login\` (opens your browser).`;
511
604
 
@@ -553,6 +646,7 @@ async function main() {
553
646
  else if (cmd === "ci") await cmdCi(positional, flags);
554
647
  else if (cmd === "status") await cmdStatus(positional, flags);
555
648
  else if (cmd === "sources") await cmdSources(positional, flags);
649
+ else if (cmd === "integrations") cmdIntegrations(positional, flags);
556
650
  else if (cmd === "clear") await cmdClear(positional, flags);
557
651
  else if (cmd === "upgrade") await cmdUpgrade(positional, flags);
558
652
  else if (cmd === "tui" || !cmd) await launchTui(positional[1], flags);
@@ -0,0 +1,121 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ /**
6
+ * On-disk toggle state for the LIVE integration sources (aws-lambda, vercel,
7
+ * supabase-edge, github-actions). This is the SINGLE source of truth read by
8
+ * parseSources (index.js), availability()/collectFrom() (sources/index.js),
9
+ * `mm integrations`, and the TUI Integrations view.
10
+ *
11
+ * It lives in its OWN file (NOT config.json) so the API-key file's blast radius
12
+ * stays small, and is written 0600/0700 with the exact recipe config.js uses.
13
+ *
14
+ * SECURITY: this file is NON-SECRET scope only. `enabled` is a boolean, `envTag`
15
+ * is a declared env label, and `ref` holds a non-secret scope hint (an AWS
16
+ * account id, a vercel team/project slug, a gh repo slug). It NEVER stores a
17
+ * provider key/token β€” authorization for a live source is always the user's
18
+ * already-authenticated local CLI (aws/gh/vercel/supabase), never our file.
19
+ *
20
+ * On-disk shape:
21
+ * {
22
+ * "aws-lambda": { "enabled": true, "envTag": "prod", "ref": "123456789012" },
23
+ * "vercel": { "enabled": false },
24
+ * "supabase-edge": { "enabled": false },
25
+ * "github-actions": { "enabled": true }
26
+ * }
27
+ */
28
+
29
+ const dir = path.join(os.homedir(), ".config", "llmstatus");
30
+
31
+ /** The integrations file path. LLMSTATUS_INTEGRATIONS_FILE overrides it (tests
32
+ * point this at a temp file so they never touch the dev's real toggles β€” mirrors
33
+ * filesystem.js's LLMSTATUS_IGNORE_FILE). */
34
+ export function integrationsFilePath() {
35
+ return process.env.LLMSTATUS_INTEGRATIONS_FILE || path.join(dir, "integrations.json");
36
+ }
37
+
38
+ /** The 4 known live-integration ids. enabledIds() intersects with this so a
39
+ * stale/hand-edited key can never inject an unknown source into a scan. */
40
+ export const INTEGRATION_IDS = ["aws-lambda", "vercel", "supabase-edge", "github-actions"];
41
+
42
+ /** Display + capability metadata. `requiresCmd` is the vendor CLI each one shells
43
+ * out to (the `available()` PATH check). Kept here so the command, the TUI view,
44
+ * and cmdSources all read one table. */
45
+ export const INTEGRATION_META = {
46
+ "aws-lambda": { label: "AWS Lambda env/config + Bedrock", requiresCmd: "aws" },
47
+ vercel: { label: "Vercel project env", requiresCmd: "vercel" },
48
+ "supabase-edge": { label: "Supabase Edge Functions + secrets", requiresCmd: "supabase" },
49
+ "github-actions": { label: "GitHub Actions secrets + workflows", requiresCmd: "gh" },
50
+ };
51
+
52
+ const ENV_TAGS = new Set(["prod", "staging", "dev", "unknown"]);
53
+
54
+ /** Parsed integrations.json, or {} (missing/corrupt file β†’ {}, like loadConfig). */
55
+ export function readIntegrations() {
56
+ try {
57
+ return JSON.parse(fs.readFileSync(integrationsFilePath(), "utf8"));
58
+ } catch {
59
+ return {};
60
+ }
61
+ }
62
+
63
+ /** Write the whole map. Owner-only (0600), mirroring config.js's saveConfig
64
+ * exactly β€” chmod after write also tightens a pre-existing loose-perm file
65
+ * (writeFileSync's mode only applies on create). Returns the file path. */
66
+ export function writeIntegrations(next) {
67
+ const file = integrationsFilePath();
68
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
69
+ fs.writeFileSync(file, JSON.stringify(next, null, 2), { mode: 0o600 });
70
+ try {
71
+ fs.chmodSync(file, 0o600);
72
+ } catch {
73
+ /* best-effort (e.g. Windows) */
74
+ }
75
+ return file;
76
+ }
77
+
78
+ /** Is this integration toggled on? */
79
+ export function getEnabled(id) {
80
+ return !!readIntegrations()[id]?.enabled;
81
+ }
82
+
83
+ /** The declared env override for an integration, or undefined if none is set
84
+ * (so the source falls back to guessEnvFrom). "unknown" is never returned β€” it's
85
+ * the "clear the override" sentinel (see setEnvTag). */
86
+ export function getEnvTag(id) {
87
+ const t = readIntegrations()[id]?.envTag;
88
+ return t && t !== "unknown" ? t : undefined;
89
+ }
90
+
91
+ /** The enabled live-integration ids, intersected with INTEGRATION_IDS so a stale
92
+ * key can't inject an unknown source. Returns a Set for cheap `.has()` in the gate. */
93
+ export function enabledIds() {
94
+ const cur = readIntegrations();
95
+ return new Set(INTEGRATION_IDS.filter((id) => !!cur[id]?.enabled));
96
+ }
97
+
98
+ /** Toggle an integration on/off (merges, preserving envTag/ref). Returns the path. */
99
+ export function setEnabled(id, enabled) {
100
+ const cur = readIntegrations();
101
+ return writeIntegrations({ ...cur, [id]: { ...cur[id], enabled: !!enabled } });
102
+ }
103
+
104
+ /** Set the declared env override. Validates the union; "unknown" CLEARS the
105
+ * override (so guessEnvFrom resumes) rather than force-stamping every finding as
106
+ * "unknown" and defeating positive-marker detection. Returns the path. */
107
+ export function setEnvTag(id, envTag) {
108
+ if (!ENV_TAGS.has(envTag)) throw new Error(`envTag must be one of ${[...ENV_TAGS].join(", ")}`);
109
+ const cur = readIntegrations();
110
+ const entry = { ...cur[id] };
111
+ if (envTag === "unknown") delete entry.envTag;
112
+ else entry.envTag = envTag;
113
+ return writeIntegrations({ ...cur, [id]: entry });
114
+ }
115
+
116
+ /** Set a non-secret scope hint (AWS account id / vercel slug / gh repo). Returns
117
+ * the path. NEVER pass a secret here β€” this is for display + locator context only. */
118
+ export function setRef(id, ref) {
119
+ const cur = readIntegrations();
120
+ return writeIntegrations({ ...cur, [id]: { ...cur[id], ref: ref || undefined } });
121
+ }
@@ -0,0 +1,95 @@
1
+ import { hasCmd, run } from "./shell.js";
2
+ import { scanConfigEntries, entriesFromKV } from "./configscan.js";
3
+
4
+ /* Pure parsers (unit-tested) β€” keep all JSON shape knowledge here, like aws.js.
5
+ * Each try/catch-es so malformed CLI output degrades to [] / {} rather than throws. */
6
+
7
+ /** `aws lambda list-functions` β†’ [FunctionName]. */
8
+ export function parseFunctionList(stdout) {
9
+ try {
10
+ return (JSON.parse(stdout).Functions || []).map((f) => f.FunctionName).filter(Boolean);
11
+ } catch {
12
+ return [];
13
+ }
14
+ }
15
+
16
+ /** `aws lambda get-function-configuration` β†’ the Environment.Variables map {K:V}. */
17
+ export function parseFunctionEnv(stdout) {
18
+ try {
19
+ return JSON.parse(stdout)?.Environment?.Variables || {};
20
+ } catch {
21
+ return {};
22
+ }
23
+ }
24
+
25
+ /** `aws bedrock list-foundation-models` β†’ [modelId] from .modelSummaries[].modelId.
26
+ * These are KNOWN-enabled foundation-model ids (not secrets) but still routed
27
+ * through the funnel for redaction + dedup parity. */
28
+ export function parseBedrockModels(stdout) {
29
+ try {
30
+ return (JSON.parse(stdout).modelSummaries || []).map((m) => m.modelId).filter(Boolean);
31
+ } catch {
32
+ return [];
33
+ }
34
+ }
35
+
36
+ /** AWS Lambda env/config + Bedrock model ids. LIVE integration: gated on the
37
+ * enabled toggle (integrations.json) AND the `aws` CLI being present. Shells out
38
+ * to your already-authenticated `aws` CLI, read-only. Lambda env VALUES are
39
+ * scanned locally for model ids and never uploaded β€” only model ids leave.
40
+ * opts: { region, awsBedrock? }. */
41
+ export const awsLambdaSource = {
42
+ id: "aws-lambda",
43
+ label: "AWS Lambda env/config + Bedrock",
44
+ kind: "cli",
45
+ integration: true,
46
+ envTag: "unknown",
47
+ async available() {
48
+ return hasCmd("aws");
49
+ },
50
+ /** Richer read-only identity probe (beyond the cheap PATH check). */
51
+ async authState(opts) {
52
+ const region = opts?.region ? ["--region", opts.region] : [];
53
+ const r = await run("aws", ["sts", "get-caller-identity", "--output", "json", ...region]);
54
+ if (!r.ok) return { connected: false, mode: "sts", reason: (r.stderr || "not authenticated").split("\n")[0] };
55
+ let account;
56
+ try {
57
+ account = JSON.parse(r.stdout).Account;
58
+ } catch {
59
+ /* ignore */
60
+ }
61
+ return { connected: true, mode: "sts", account };
62
+ },
63
+ async collect(opts, compiled) {
64
+ const region = opts?.region ? ["--region", opts.region] : [];
65
+ const tag = opts?.region || "default";
66
+ const out = [];
67
+
68
+ // (a) Lambda functions β†’ per-function env vars (Bedrock model ids show up here).
69
+ const list = await run("aws", ["lambda", "list-functions", "--output", "json", ...region]);
70
+ if (list.ok) {
71
+ for (const fn of parseFunctionList(list.stdout)) {
72
+ const cfg = await run("aws", ["lambda", "get-function-configuration", "--function-name", fn, "--output", "json", ...region]);
73
+ if (!cfg.ok) continue;
74
+ for (const [k, v] of Object.entries(parseFunctionEnv(cfg.stdout))) {
75
+ const entries = entriesFromKV(k, v, `aws-lambda://${tag}/${fn}#${k}`, fn);
76
+ out.push(...scanConfigEntries(entries, compiled, { sourceType: "aws-lambda", env: opts?.env }));
77
+ }
78
+ }
79
+ }
80
+
81
+ // (b) Bedrock enabled foundation models β€” emitted DIRECTLY but still through
82
+ // the funnel (the model id is the value) for redaction + dedup parity. Default
83
+ // on; opts.awsBedrock === false skips the extra call.
84
+ if (opts?.awsBedrock !== false) {
85
+ const bm = await run("aws", ["bedrock", "list-foundation-models", "--output", "json", ...region]);
86
+ if (bm.ok) {
87
+ for (const modelId of parseBedrockModels(bm.stdout)) {
88
+ const entries = entriesFromKV("bedrock-model", modelId, `aws-bedrock://${tag}/foundation-models#${modelId}`, opts?.region);
89
+ out.push(...scanConfigEntries(entries, compiled, { sourceType: "aws-bedrock", env: opts?.env }));
90
+ }
91
+ }
92
+ }
93
+ return out;
94
+ },
95
+ };
@@ -10,12 +10,18 @@ import { redactValue } from "../redact.js";
10
10
 
11
11
  const isPlainObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
12
12
 
13
- /** Guess an environment label from any contextual hints (namespace, key, path). */
13
+ /** Guess an environment label from any contextual hints (namespace, key, path).
14
+ * We claim an env only on a POSITIVE marker β€” including an explicit prod/live one
15
+ * β€” and never fabricate "prod" from the mere absence of a hint (a k8s "default"
16
+ * namespace, an unlabelled secret). No marker β†’ "unknown", so the user can tag it
17
+ * rather than trust a coin-flip. An explicit env passed to scanConfigEntries still
18
+ * overrides this entirely. */
14
19
  export function guessEnvFrom(...hints) {
15
20
  const s = hints.filter(Boolean).join(" ").toLowerCase();
16
21
  if (/(^|[^a-z])(dev|development|sandbox|local)([^a-z]|$)/.test(s)) return "dev";
17
22
  if (/(^|[^a-z])(stag|staging|stg|qa|uat|test)([^a-z]|$)/.test(s)) return "staging";
18
- return "prod";
23
+ if (/(^|[^a-z])(prod|production|prd|live)([^a-z]|$)/.test(s)) return "prod";
24
+ return "unknown";
19
25
  }
20
26
 
21
27
  /** Flatten a nested object/array into leaf entries with dotted locator paths. */
Binary file
@@ -0,0 +1,156 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { hasCmd, run } from "./shell.js";
4
+ import { detectInLine } from "../detect/core.js";
5
+ import { redactValue } from "../redact.js";
6
+ import { scanConfigEntries, entriesFromKV } from "./configscan.js";
7
+
8
+ /* Pure parsers (unit-tested). */
9
+
10
+ /** `gh variable list [--json name,value]` β†’ [{name, value}]. Actions VARIABLES are
11
+ * NON-secret config values (unlike secrets), so we scan the VALUE for model ids via
12
+ * entriesFromKV. Handles `--json name,value` ([{name,value}]) and the tab-separated
13
+ * table (NAME\tVALUE\tUPDATED). Pure β€” no JSON shape knowledge leaks out. */
14
+ export function parseVariableList(stdout) {
15
+ const s = String(stdout || "").trim();
16
+ if (!s) return [];
17
+ try {
18
+ const j = JSON.parse(s);
19
+ if (Array.isArray(j)) return j.map((x) => ({ name: x.name, value: x.value ?? "" })).filter((x) => x.name);
20
+ } catch {
21
+ /* fall through to table parse */
22
+ }
23
+ const out = [];
24
+ for (const raw of s.split(/\r?\n/)) {
25
+ const line = raw.trim();
26
+ if (!line) continue;
27
+ if (/^name\b/i.test(line)) continue; // header
28
+ if (/^[-\s|]+$/.test(line)) continue; // separator rule
29
+ // gh's table output is tab- or 2+-space-separated: NAME VALUE UPDATED.
30
+ const cols = line.split(/\t|\s{2,}/).map((c) => c.trim());
31
+ const name = cols[0];
32
+ if (name && /^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) out.push({ name, value: cols[1] ?? "" });
33
+ }
34
+ return out;
35
+ }
36
+
37
+ /** `gh secret list [--json name]` β†’ NAMES only. Handles `--json name` ([{name}])
38
+ * and the tab/space-separated table (NAME UPDATED). We NEVER `gh secret view`. */
39
+ export function parseGhSecretList(stdout) {
40
+ const s = String(stdout || "").trim();
41
+ try {
42
+ const j = JSON.parse(s);
43
+ if (Array.isArray(j)) return j.map((x) => x.name).filter(Boolean);
44
+ } catch {
45
+ /* fall through to table parse */
46
+ }
47
+ const names = [];
48
+ for (const raw of s.split(/\r?\n/)) {
49
+ const line = raw.trim();
50
+ if (!line) continue;
51
+ const name = line.split(/\s{2,}|\t/).map((c) => c.trim()).filter(Boolean)[0];
52
+ if (name && /^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) names.push(name);
53
+ }
54
+ return names;
55
+ }
56
+
57
+ /** Line-scan one workflow YAML body β†’ Candidates (model refs in workflow steps).
58
+ * Pure: takes text + relPath + compiled, returns #L<n>-located candidates with a
59
+ * redacted, 160-capped snippet. detectInLine returns a Set, iterated with for…of. */
60
+ export function scanWorkflowText(text, relPath, compiled, env) {
61
+ const out = [];
62
+ const seen = new Set();
63
+ String(text || "").split(/\r?\n/).forEach((line, i) => {
64
+ for (const model_string of detectInLine(line, compiled)) {
65
+ const locator = `github-actions://workflows/${relPath}#L${i + 1}`;
66
+ const key = `${model_string}|${locator}`;
67
+ if (seen.has(key)) continue;
68
+ seen.add(key);
69
+ out.push({
70
+ model_string,
71
+ source_type: "github-actions",
72
+ location_label: locator,
73
+ source_path: relPath,
74
+ source_line: i + 1,
75
+ environment: env || "unknown",
76
+ snippet: redactValue(line.trim()).slice(0, 160),
77
+ });
78
+ }
79
+ });
80
+ return out;
81
+ }
82
+
83
+ /** GitHub Actions VARIABLES + secrets + workflows. LIVE integration: gated on the
84
+ * enabled toggle AND the `gh` CLI. Surfaces:
85
+ * (a) `gh variable list` β†’ VALUES scanned via entriesFromKV (Actions variables are
86
+ * NON-secret config, the natural home for a `OPENAI_MODEL=gpt-4o` style value);
87
+ * (b) `gh secret list` β†’ NAME-only entries (NEVER a value β€” no value API exists);
88
+ * (c) a NARROW own-walk of <root>/.github/workflows/*.yml line-scanned for model
89
+ * refs (does NOT import filesystem.js β€” concurrency guardrail).
90
+ * When scoped to a GitHub Environment via opts.ghEnvironment, that environment is
91
+ * AUTHORITATIVE for variables (passed straight through), else the folded opts.env /
92
+ * guessEnvFrom applies. opts: { root, ghRepo, ghEnvironment }. */
93
+ export const githubActionsSource = {
94
+ id: "github-actions",
95
+ label: "GitHub Actions variables + secrets + workflows",
96
+ kind: "cli",
97
+ integration: true,
98
+ envTag: "unknown",
99
+ async available() {
100
+ return hasCmd("gh");
101
+ },
102
+ async authState() {
103
+ const r = await run("gh", ["auth", "status"]);
104
+ // `gh auth status` writes to stderr even on success; ok is the signal.
105
+ if (!r.ok) return { connected: false, mode: "auth-status", reason: (r.stderr || r.stdout || "not logged in").split("\n")[0] };
106
+ return { connected: true, mode: "auth-status" };
107
+ },
108
+ async collect(opts, compiled) {
109
+ const repoArg = opts?.ghRepo ? ["--repo", opts.ghRepo] : [];
110
+ const repoTag = opts?.ghRepo || "repo";
111
+ // A GitHub Environment is authoritative for env-scoped variables β€” pass it as
112
+ // the explicit env (overriding guessEnvFrom). Else fall back to the folded opts.env.
113
+ const ghEnv = opts?.ghEnvironment || "";
114
+ const envArg = ghEnv ? ["--env", ghEnv] : [];
115
+ const out = [];
116
+
117
+ // (a) VARIABLES β€” non-secret VALUES, scanned through the redaction funnel. We
118
+ // ask for JSON so the value column is unambiguous; a model id in a variable
119
+ // value (e.g. OPENAI_MODEL=gpt-4o) is exactly what we want to catch.
120
+ const vars = await run("gh", ["variable", "list", ...repoArg, ...envArg, "--json", "name,value"]);
121
+ if (vars.ok) {
122
+ for (const { name, value } of parseVariableList(vars.stdout)) {
123
+ const entries = entriesFromKV(name, value, `github-actions://${repoTag}/variables#${name}`, ghEnv || repoTag);
124
+ out.push(...scanConfigEntries(entries, compiled, { sourceType: "github-actions", env: ghEnv || opts?.env }));
125
+ }
126
+ }
127
+
128
+ // (b) Secret NAMES only (never a value β€” there is no value API anyway).
129
+ const secrets = await run("gh", ["secret", "list", ...repoArg, ...envArg]);
130
+ if (secrets.ok) {
131
+ for (const name of parseGhSecretList(secrets.stdout)) {
132
+ const entries = entriesFromKV(name, "", `github-actions://${repoTag}/secrets#${name}`, ghEnv || repoTag);
133
+ out.push(...scanConfigEntries(entries, compiled, { sourceType: "github-actions", env: ghEnv || opts?.env }));
134
+ }
135
+ }
136
+
137
+ // (c) Workflow YAML bodies β€” own narrow walk of <root>/.github/workflows.
138
+ const wfDir = path.join(opts?.root || ".", ".github", "workflows");
139
+ let files = [];
140
+ try {
141
+ files = fs.readdirSync(wfDir).filter((f) => /\.ya?ml$/.test(f));
142
+ } catch {
143
+ /* no workflows dir β€” secrets-only is fine */
144
+ }
145
+ for (const f of files) {
146
+ let text;
147
+ try {
148
+ text = fs.readFileSync(path.join(wfDir, f), "utf8");
149
+ } catch {
150
+ continue;
151
+ }
152
+ out.push(...scanWorkflowText(text, f, compiled, opts?.env));
153
+ }
154
+ return out;
155
+ },
156
+ };