@modelstatus/cli 0.1.34 β 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 +1 -1
- package/src/api.js +6 -0
- package/src/ci.js +2 -2
- package/src/index.js +106 -12
- package/src/integrations.js +121 -0
- package/src/sources/aws-lambda.js +95 -0
- package/src/sources/configscan.js +8 -2
- package/src/sources/filesystem.js +0 -0
- package/src/sources/github-actions.js +156 -0
- package/src/sources/index.js +70 -13
- package/src/sources/scan-runner.js +127 -0
- package/src/sources/supabase-edge.js +183 -0
- package/src/sources/supabase.js +5 -0
- package/src/sources/vercel.js +74 -0
- package/src/tui/app.js +5 -1
- package/src/tui/game/DkGame.js +187 -0
- package/src/tui/game/dk-core.js +413 -0
- package/src/tui/game/dk-render.js +114 -0
- package/src/tui/views/add.js +1 -1
- package/src/tui/views/integrations.js +224 -0
- package/src/tui/views/inventory.js +31 -2
- package/src/tui/views/scan.js +103 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@modelstatus/cli",
|
|
3
|
-
"version": "0.1.
|
|
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
|
-
/**
|
|
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")
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
};
|