@catalyst-cloud/cli 0.9.5 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/README.md +4 -2
- package/dist/args.js +3 -1
- package/dist/cli.js +7 -2
- package/dist/env/declaration-rules.js +163 -0
- package/dist/env/group.js +51 -0
- package/dist/env/inventory.js +102 -0
- package/dist/env/render.js +39 -0
- package/dist/env/scan-env-example.js +62 -0
- package/dist/env/scan-source.js +43 -0
- package/dist/env/scan-workflow.js +123 -0
- package/dist/env/scan-wrangler.js +120 -0
- package/dist/env/types.js +1 -0
- package/dist/env/walk.js +87 -0
- package/dist/env.js +76 -0
- package/package.json +4 -4
- package/skills/catalyst-github/SKILL.md +1 -1
- package/skills/catalyst-linear/SKILL.md +1 -1
- package/skills/catalyst-onboard/SKILL.md +1 -1
- package/skills/catalyst-setup/SKILL.md +1 -1
- package/skills/connect-me/SKILL.md +3 -3
- package/skills/how-catalyst-works/SKILL.md +4 -2
- package/skills/how-catalyst-works/references/settings-and-where-they-live.md +20 -0
- package/skills/how-catalyst-works/references/what-runs-next.md +8 -1
- package/skills/run-this-project/SKILL.md +1 -1
- package/skills/unstick/SKILL.md +1 -1
- package/skills/what-needs-me/SKILL.md +1 -1
- package/skills/what-needs-me/references/reading-the-inbox.md +4 -0
- package/skills/what-this-repo-needs/SKILL.md +40 -0
- package/skills/what-this-repo-needs/agents/openai.yaml +6 -0
- package/skills/what-this-repo-needs/agents/portability.yaml +4 -0
- package/skills/what-this-repo-needs/references/what-each-group-means.md +68 -0
- package/skills/what-this-repo-needs/scripts/check.mjs +64 -0
- package/skills/what-this-repo-needs/scripts/inventory.mjs +61 -0
- package/skills/what-this-repo-needs/scripts/lib/cli.mjs +114 -0
- package/skills/what-this-repo-needs/scripts/lib/credential.mjs +29 -0
- package/skills/whats-happening/SKILL.md +1 -1
- package/skills/whats-happening/references/reprioritising.md +1 -1
- package/skills/whats-happening/references/status-reply.md +2 -0
- package/skills/whats-happening/references/why-is-it-stuck.md +41 -34
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.13.0
|
|
4
|
+
|
|
5
|
+
Catalyst's released pieces now share one version line, 0.13: `@catalyst-cloud/cli` and the deprecated `@catalyst-cloud/catalyst-skills` name, these skills, `@catalyst-cloud/sdk`, the installer, and the schema, replicate and read-model libraries. A CLI on 0.13.x belongs with a cloud whose tenant contract names `releaseLine` 0.13. The CLI moves from 0.9 straight to 0.13 and runs on `@catalyst-cloud/sdk` 0.13. The tenant contract keeps its own version, which tracks the document's shape.
|
|
6
|
+
|
|
7
|
+
Ask your agent what a setting does and where it is set, and it names the screen, the route and the rule instead of guessing: connecting Linear, installing the GitHub App, registering a repository, the merge policy and its reviewers, secrets versus environment variables, and why routing has no write route yet all have a home in `how-catalyst-works`. `whats-happening` now maps every reason a ticket can be held to a next action and who takes it — the person with their own login, a tenant owner or admin in settings, an operator, or "no one acts" when it is genuinely just waiting — and three reasons that were missing from the stuck-state table (a live container holding a later phase, too many claims in an hour, and a repository at capacity) are documented for the first time. Connecting your own Linear account and being matched as a Linear identity are now explained as two different, sometimes disagreeing, facts, so "I connected Linear, why am I still not getting asks?" has a real answer. And the README says plainly what it still does not know: flow metrics — cycle time, throughput, how long a pull request has been open — are not computed yet, rather than counting something else and calling it that.
|
|
8
|
+
|
|
3
9
|
## 0.9.5
|
|
4
10
|
|
|
5
11
|
The CLI calls itself `catalyst` everywhere: usage lines, every verb's `--help`, hints, remedies and errors. `catalyst-skills` still works as a deprecated alias. The npx form is `npx -p @catalyst-cloud/cli catalyst …`, because bare `npx @catalyst-cloud/cli` cannot pick a binary. A replica read on the `node:sqlite` fallback no longer prints a notice every time; `CATALYST_DEBUG=1` still shows it.
|
|
@@ -60,6 +66,22 @@ That one command is `catalyst-skills runtime install`. It downloads a pinned Nod
|
|
|
60
66
|
|
|
61
67
|
## 0.7.0
|
|
62
68
|
|
|
69
|
+
`catalyst-skills env inventory [path]` scans a repository offline — no login, no network — and lists
|
|
70
|
+
the environment variable NAMES it needs, never a value. Names are grouped build/test (needed to
|
|
71
|
+
install, build and run the tests), deploy-only (a CI secret only a deploy job uses) and bindings (a
|
|
72
|
+
Cloudflare or other platform binding, which is not an environment value). Each name shows where it
|
|
73
|
+
was found (file:line), what uses it, and where a local value would come from — a `.env` file, your
|
|
74
|
+
shell, a CI secret, or a Cloudflare binding. `--json` gives the same data in machine form. `env check
|
|
75
|
+
<file>` validates a `catalyst.env.json` offline, against this bundle's own dated, cited copy of the
|
|
76
|
+
cloud's validation rules (it does not compute the cloud's canonical hash — only a `valid` / `invalid`
|
|
77
|
+
verdict and the refusal reasons). The new `what-this-repo-needs` skill explains why a cloud container
|
|
78
|
+
needs these names before it scans, then asks you to review the list: keep, drop or move each one.
|
|
79
|
+
|
|
80
|
+
Reading a GitHub Actions workflow now goes through a real YAML parser (`yaml`, now a runtime
|
|
81
|
+
dependency of this package — it carries no transitive dependencies of its own) rather than a
|
|
82
|
+
hand-written line scanner, so a workflow that declares its `env:` in flow style (`{ A: 1, B: 2 }`) is
|
|
83
|
+
read correctly instead of silently missed.
|
|
84
|
+
|
|
63
85
|
`explain` and `ready` now name a team that cannot start work even when the live read is not available. Your tenant's contract carries each team's dispatch gate, and this machine already keeps a copy of that contract on disk; until now only the live eligibility read could name the gate, so a network hiccup, or a cloud older than that read, left `explain` printing nothing at all. `explain` now leads with the gate and the fix from the cached contract when the live read is unavailable or sends no gate, and says which of the two it read; when both answer and disagree, the live read wins and the paragraph says the cached one disagreed. A refusal from the cloud — a credential that is not accepted, for instance — is still a refusal, never quietly replaced by a cached answer. `ready` now prints one dispatch-gate line per team: open teams read `ok`, and a team whose stages are not saved reads `FAIL` with the remedy your tenant sent and turns the verdict to NOT READY, because nothing in that team can start. A cloud that does not send the gate changes nothing.
|
|
64
86
|
|
|
65
87
|
`catalyst-skills ready` now tells you when the skills on this machine, or the CLI itself, are behind the newest published release — it names the version you have, the version that is out, and the one command that updates each, as a note that never changes the verdict. Every skill file now records the bundle version it was vendored at, so a copy installed months ago is visible as such instead of only by diffing files. The check reads the npm registry's `dist-tags.latest`, capped at 1500ms and cached for six hours, and never blocks: an unreachable registry is one note that says so, and `--offline` (or `CATALYST_SKILLS_OFFLINE=1`) skips the lookup entirely.
|
package/README.md
CHANGED
|
@@ -162,14 +162,16 @@ The replica is a Node process, not a service: the supported path is the plain co
|
|
|
162
162
|
| `how-catalyst-works` | "How does this work? Why did it do that? How does it prioritise?" | The execution model as references loaded on demand: the eight-phase ladder, the eleven board slots and this team's live stage map, what happens when a phase fails, how the queue is ordered and routed, every exclusion reason, and the coding-account model. Scripts explain one ticket's eligibility in plain English. | [`SKILL.md`](skills/how-catalyst-works/SKILL.md) |
|
|
163
163
|
| `unstick` | "Why is this parked? Unpark it. Get things flowing again." | Reads why a ticket is not running and every park or hold on it, decides whether the recorded cause is fixed, previews the release and releases it the right way from your own login — or one failure class across a team — and raises an ask only for what a person has to do. | [`SKILL.md`](skills/unstick/SKILL.md) |
|
|
164
164
|
| `connect-me` | "Connect this machine to my tenant." | Connects the machine to the tenant with your own personal key, caches the tenant contract, verifies, and offers to start the replica. | [`SKILL.md`](skills/connect-me/SKILL.md) |
|
|
165
|
+
| `what-this-repo-needs` | "What env vars does this repo need? What does catalyst.env.json mean?" | Scans a repository offline — no login, no network — and lists the environment variable names it needs, grouped build/test, deploy-only and bindings, each with where it was found, what uses it, and where a local value would come from. Never reads or prints a value. Also validates a `catalyst.env.json` file with the same rules the cloud applies. | [`SKILL.md`](skills/what-this-repo-needs/SKILL.md) |
|
|
165
166
|
|
|
166
167
|
Some of these only read; the ones that write anything — or that drive you through writes, as `catalyst-onboard` does — are marked so an agent cannot invoke them on its own, and you ask for them by name. Each skill's own `SKILL.md` says which it is, in its frontmatter; no count lives in this sentence, because a count in a sentence goes wrong the first time the table above gains a row. Every skill declares `allowed-tools` scoped to this package's own binary, so none of them needs a blanket shell grant.
|
|
167
168
|
|
|
168
169
|
## What a key cannot see yet
|
|
169
170
|
|
|
170
|
-
Your personal key reads everything the skills need — tickets, pull requests, the eligibility explainer, the dispatch queue, fleet activity, per-ticket execution history (`catalyst explain --history <ticket>`: phase attempts, remediation rounds, park state) and coding-account status (`catalyst accounts`: provider, declared and observed state, window usage, walls, quarantine — never a credential; enrolling or pausing one is `<your cloud>/settings/coding-accounts`). It also releases a parked or held ticket once its cause is fixed: `catalyst release <ticket> --because <what changed>` (the `unstick` skill runs it), recorded against your name and shown in the ticket's history. And it declares what your containers need: `catalyst environment` reads the tenant-wide declaration, `environment propose --file <path> --approve` proposes and approves it in one compare-and-set, and the values behind the names stay in the cloud — reading needs any active seat, proposing and approving need an admin or owner one. An admin or owner can put a repository's values in from the terminal. `catalyst secret import .env --repo owner/name` stores every name in the file and lists the declared names that still have no value. `catalyst secret set NAME --repo owner/name --command 'op read op://Vault/item/field'` runs the command on your machine and stores its output; with no `--command` it reads the value from stdin, or asks for it without echoing. No value is ever printed, and the cloud's audit records the command, not its output. That is the account's own declaration — a repository's own `.catalyst/catalyst.toml`, which `catalyst-setup` already reports on, is a separate thing.
|
|
171
|
+
Your personal key reads everything the skills need — tickets, pull requests, the eligibility explainer, the dispatch queue, fleet activity, per-ticket execution history (`catalyst explain --history <ticket>`: phase attempts, remediation rounds, park state) and coding-account status (`catalyst accounts`: provider, declared and observed state, window usage, walls, quarantine — never a credential; enrolling or pausing one is `<your cloud>/settings/coding-accounts`). It also releases a parked or held ticket once its cause is fixed: `catalyst release <ticket> --because <what changed>` (the `unstick` skill runs it), recorded against your name and shown in the ticket's history. And it declares what your containers need: `catalyst environment` reads the tenant-wide declaration, `environment propose --file <path> --approve` proposes and approves it in one compare-and-set, and the values behind the names stay in the cloud — reading needs any active seat, proposing and approving need an admin or owner one. An admin or owner can put a repository's values in from the terminal. `catalyst secret import .env --repo owner/name` stores every name in the file and lists the declared names that still have no value. `catalyst secret set NAME --repo owner/name --command 'op read op://Vault/item/field'` runs the command on your machine and stores its output; with no `--command` it reads the value from stdin, or asks for it without echoing. No value is ever printed, and the cloud's audit records the command, not its output. That is the account's own declaration — a repository's own `.catalyst/catalyst.toml`, which `catalyst-setup` already reports on, is a separate thing. `catalyst env inventory` lists the environment variable names a repository needs — never a value — and `env check` validates a `catalyst.env.json` file offline; neither needs a login (the `what-this-repo-needs` skill walks through both). Two things it cannot do, and the skills say so by name rather than guess:
|
|
171
172
|
|
|
172
173
|
- Read pull-request labels or the reviewer's reaction. The mirror does not carry them; GitHub's own page does.
|
|
174
|
+
- Compute a flow number — cycle time, throughput, or how long pull requests have been open. Nothing serves those yet, so say they are not computed rather than counting something else and calling it that.
|
|
173
175
|
|
|
174
176
|
## Versions and origins
|
|
175
177
|
|
|
@@ -198,7 +200,7 @@ A `customer.json` written by an older bundle is still read unchanged; it gains t
|
|
|
198
200
|
|
|
199
201
|
## Uninstalling
|
|
200
202
|
|
|
201
|
-
Remove the skills the way you installed them: `/plugin uninstall catalyst@catalyst-cloud` in Claude Code, or delete the skill directories (`catalyst-github`, `catalyst-linear`, `catalyst-onboard`, `catalyst-setup`, `connect-me`, `how-catalyst-works`, `run-this-project`, `unstick`, `what-needs-me`, `whats-happening`) from wherever `npx skills add` wrote them. Then remove what the CLI wrote:
|
|
203
|
+
Remove the skills the way you installed them: `/plugin uninstall catalyst@catalyst-cloud` in Claude Code, or delete the skill directories (`catalyst-github`, `catalyst-linear`, `catalyst-onboard`, `catalyst-setup`, `connect-me`, `how-catalyst-works`, `run-this-project`, `unstick`, `what-needs-me`, `what-this-repo-needs`, `whats-happening`) from wherever `npx skills add` wrote them. Then remove what the CLI wrote:
|
|
202
204
|
|
|
203
205
|
```sh
|
|
204
206
|
catalyst replica stop
|
package/dist/args.js
CHANGED
|
@@ -111,6 +111,7 @@ export const FLAG_TABLES = {
|
|
|
111
111
|
offline: { value: false, help: "skip the published-release check (no network)" },
|
|
112
112
|
},
|
|
113
113
|
accounts: {},
|
|
114
|
+
env: {},
|
|
114
115
|
team: {
|
|
115
116
|
all: { value: false, help: "check every team, one request at a time" },
|
|
116
117
|
stage: { value: true, repeat: true, help: "map a role to a live Linear state name (role=StateName)" },
|
|
@@ -169,7 +170,8 @@ export const VERB_USAGE = {
|
|
|
169
170
|
accounts: "accounts [--json]",
|
|
170
171
|
mcp: "mcp <add <name> --url URL <--auth none|--bearer SECRET_NAME|--header NAME=SECRET_NAME...>|list|remove <name>> [--json]",
|
|
171
172
|
team: "team <list|check <KEY>|check --all|map <KEY> [--stage role=StateName]... [--yes --plan-hash H]|adopt <KEY> [--undo] [--yes --plan-hash H]|migrate <KEY> [--choice sourceId=destinationId]... [--retire] [--yes --plan-hash H]|checklist <KEY>> [--json]",
|
|
172
|
-
|
|
173
|
+
env: "env <inventory [path] | check <file>> [--json] (THIS repository, offline — no login, no network)",
|
|
174
|
+
environment: "environment [read] [--json] | environment propose --file <path>|--stdin [--expect-revision N] [--approve] [--json] | environment approve [--revision N --hash H] [--json] (your ACCOUNT's declaration; needs login)",
|
|
173
175
|
secret: "secret set <NAME> --repo <owner/name> [--command '<cmd>'] [--json] (value from --command, stdin, or a hidden prompt) | secret import <file> --repo <owner/name> [--rotate NAME]... [--json]",
|
|
174
176
|
identity: "identity linear <status|options|set> [<linearUserId>] [--json]",
|
|
175
177
|
capabilities: "capabilities [--json]",
|
package/dist/cli.js
CHANGED
|
@@ -23,6 +23,7 @@ import { cmdAsk } from "./ask.js";
|
|
|
23
23
|
import { cmdMcp } from "./mcp.js";
|
|
24
24
|
import { cmdRelease } from "./release.js";
|
|
25
25
|
import { cmdEnvironment } from "./environment.js";
|
|
26
|
+
import { cmdEnv } from "./env.js";
|
|
26
27
|
import { cmdSecret } from "./secret.js";
|
|
27
28
|
import { cmdTeam } from "./team.js";
|
|
28
29
|
import { cmdCapabilities } from "./capabilities.js";
|
|
@@ -39,6 +40,7 @@ export const CUSTOMER_SKILLS = [
|
|
|
39
40
|
"run-this-project",
|
|
40
41
|
"unstick",
|
|
41
42
|
"what-needs-me",
|
|
43
|
+
"what-this-repo-needs",
|
|
42
44
|
"whats-happening",
|
|
43
45
|
];
|
|
44
46
|
/** `login` is the verb; `join` is the 0.1 name, kept as a deprecated alias for one minor version. */
|
|
@@ -67,7 +69,8 @@ export function usageText() {
|
|
|
67
69
|
" catalyst write <comment|state|label|create|reaction|attachment|session> ...",
|
|
68
70
|
" catalyst ask <raise|accept|list> ...",
|
|
69
71
|
" catalyst release <ticket> --because <what changed> [--retry-unchanged] [--dry-run] | release --class <c> --team <K> ...",
|
|
70
|
-
" catalyst environment [read] | environment propose --file <path>|--stdin [--approve] | environment approve",
|
|
72
|
+
" catalyst environment [read] | environment propose --file <path>|--stdin [--approve] | environment approve (your account; needs login)",
|
|
73
|
+
" catalyst env <inventory [path] | check <file>> (this repository, offline — no login, no network)",
|
|
71
74
|
" catalyst secret set <NAME> --repo <owner/name> [--command '<cmd>'] | secret import <file> --repo <owner/name>",
|
|
72
75
|
" catalyst team <list|check|map|adopt|migrate|checklist> ...",
|
|
73
76
|
" catalyst capabilities [--json] (what this CLI can do, the role each verb needs, and whether this cloud serves it)",
|
|
@@ -245,6 +248,8 @@ export async function main(argv, ctx = defaultCtx(), deps = {}) {
|
|
|
245
248
|
return await cmdRelease(args, ctx);
|
|
246
249
|
case "environment":
|
|
247
250
|
return await cmdEnvironment(args, ctx, deps.environment ?? {});
|
|
251
|
+
case "env":
|
|
252
|
+
return await cmdEnv(args, ctx, deps.env ?? {});
|
|
248
253
|
case "secret":
|
|
249
254
|
return await cmdSecret(args, ctx, deps.secret ?? {});
|
|
250
255
|
case "team":
|
|
@@ -278,7 +283,7 @@ export async function main(argv, ctx = defaultCtx(), deps = {}) {
|
|
|
278
283
|
throw err;
|
|
279
284
|
}
|
|
280
285
|
}
|
|
281
|
-
const VERB_HELP_KNOWN = Object.fromEntries(["login", "join", "install", "status", "notice", "me", "contract", "query", "replica", "runtime", "events", "explain", "running", "queue", "watch", "write", "ask", "ready", "accounts", "release", "secret", "environment", "connections", "identity", "mcp", "team", "capabilities"].map((v) => [v, true]));
|
|
286
|
+
const VERB_HELP_KNOWN = Object.fromEntries(["login", "join", "install", "status", "notice", "me", "contract", "query", "replica", "runtime", "events", "explain", "running", "queue", "watch", "write", "ask", "ready", "accounts", "release", "secret", "env", "environment", "connections", "identity", "mcp", "team", "capabilities"].map((v) => [v, true]));
|
|
282
287
|
async function cmdLogin(args, ctx, deps) {
|
|
283
288
|
const manifest = readManifest();
|
|
284
289
|
const key = (args.key ?? ctx.env.CATALYST_CLOUD_TOKEN ?? "").trim();
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// declaration-rules.ts — a vendored, dated copy of catalyst-cloud's rules for a repo-scope
|
|
2
|
+
// `catalyst.env.json`, applied by `validateRepoEnvironmentDeclaration`
|
|
3
|
+
// (packages/environment/src/contract.ts:416-493 on catalyst-cloud `main`; the ticket cites line 488,
|
|
4
|
+
// which falls inside that range). That file lives in the PRIVATE `coalesce-labs/catalyst-cloud`
|
|
5
|
+
// monorepo — no sibling checkout, no npm publication (its one customer, `apps/cli`, is itself
|
|
6
|
+
// "private": true) and no network reachability from this session. Everything below is vendored
|
|
7
|
+
// secondhand from two independent, dated thoughts-pool research passes (2026-09-10 and 2026-09-17)
|
|
8
|
+
// that agreed with each other, following the same idiom this repository's own
|
|
9
|
+
// `test/skills-content.test.ts` already uses to vendor catalyst-cloud's `READINESS_CHECK_IDS` (see
|
|
10
|
+
// that file's `TEAM_CHECK_IDS` and its provenance comment): dated, cited, paired with a test, never
|
|
11
|
+
// hand-copied and trusted on its own.
|
|
12
|
+
//
|
|
13
|
+
// What a parity test built against THIS copy can, and cannot, catch: it can catch this copy DRIFTING
|
|
14
|
+
// FROM ITSELF — a rule dropped, loosened or tightened by a future edit here. It CANNOT notice a new
|
|
15
|
+
// rule landing upstream, because nothing in this repository imports the real function — there is no
|
|
16
|
+
// publish channel for it. The one fixture pinned by a real, run command transcript rather than by
|
|
17
|
+
// prose (`valid-empty-provenance.json`, in `test/fixtures/env-declaration/`) is the strongest anchor
|
|
18
|
+
// this vendored copy has; the rest are pinned by the two agreeing research passes.
|
|
19
|
+
const MAX_ENV_REFS = 128;
|
|
20
|
+
const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
21
|
+
// ⛔ A PREFIX ONLY COUNTS AT A TOKEN BOUNDARY (validate attempt 29, CR-6). These were matched with
|
|
22
|
+
// `text.includes(p)`, and "sk-" is a SUBSTRING of "task-", "risk-" and "disk-" — so a declaration
|
|
23
|
+
// whose setup step was `["npm","run","task-build"]` was refused as containing secret material. Every
|
|
24
|
+
// prefix here is identifier-shaped, so a match is real only at the start of the text or after a
|
|
25
|
+
// character that cannot be part of a token; PEM armour, which is not identifier-shaped and carries
|
|
26
|
+
// its own unmistakable delimiter, keeps a plain substring match of its own.
|
|
27
|
+
// `gho_`, `sk_live_` and `AIza` are in the 2026-09-10 pass's list of upstream's prefixes
|
|
28
|
+
// (thoughts: repos/catalyst/shared/research/2026-09-10-CTC-2020-laptop-environment-inventory.md:65),
|
|
29
|
+
// and `ghs_` is pinned as an upstream entry by the 2026-09-27 CTC-3378 plan v2 (:238). Validate
|
|
30
|
+
// attempt 31 found the first three missing here (and noted `ghs_`), so `env check` said `valid`
|
|
31
|
+
// where upstream's validator refuses.
|
|
32
|
+
// That upstream list ends "and others": this copy still cannot claim to hold every entry.
|
|
33
|
+
const KNOWN_SECRET_PREFIXES = ["ghp_", "gho_", "ghs_", "github_pat_", "sk-", "sk_live_", "AKIA", "AIza", "xoxb-", "xoxp-"];
|
|
34
|
+
const PEM_ARMOUR = "-----BEGIN";
|
|
35
|
+
const KNOWN_SECRET_PREFIX_RE = new RegExp(`(?:^|[^A-Za-z0-9_])(?:${KNOWN_SECRET_PREFIXES.map(escapeForRegExp).join("|")})`);
|
|
36
|
+
function escapeForRegExp(literal) {
|
|
37
|
+
return literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
38
|
+
}
|
|
39
|
+
const SECRET_ASSIGNMENT_RE = /\b([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(\S{4,})/g;
|
|
40
|
+
const SECRET_NAME_RE = /(_TOKEN|_SECRET|_KEY|_PASSWORD|_CREDENTIAL)$|^(TOKEN|SECRET|PASSWORD)$/i;
|
|
41
|
+
const REQUIRED_ARRAY_FIELDS = ["toolchains", "systemPackages", "setup", "verify", "services", "environment", "agentAssets", "provenance"];
|
|
42
|
+
const TOP_LEVEL_FIELDS = new Set(["version", ...REQUIRED_ARRAY_FIELDS]);
|
|
43
|
+
/** Empty array = valid. Never reads, and never returns, a value — only field names and paths. */
|
|
44
|
+
export function validateDeclaration(doc) {
|
|
45
|
+
if (typeof doc !== "object" || doc === null || Array.isArray(doc)) {
|
|
46
|
+
return ["the declaration must be a JSON object"];
|
|
47
|
+
}
|
|
48
|
+
const rec = doc;
|
|
49
|
+
const errors = [];
|
|
50
|
+
for (const key of Object.keys(rec))
|
|
51
|
+
if (!TOP_LEVEL_FIELDS.has(key))
|
|
52
|
+
errors.push(`unknown key "${key}"`);
|
|
53
|
+
if (rec.version !== 1)
|
|
54
|
+
errors.push("version must be 1");
|
|
55
|
+
for (const field of REQUIRED_ARRAY_FIELDS)
|
|
56
|
+
if (!Array.isArray(rec[field]))
|
|
57
|
+
errors.push(`${field} must be an array`);
|
|
58
|
+
// The rules below assume the shape above holds; a missing/wrong-typed field is reported on its own.
|
|
59
|
+
if (errors.length > 0)
|
|
60
|
+
return errors;
|
|
61
|
+
const environment = rec.environment;
|
|
62
|
+
if (environment.length > MAX_ENV_REFS)
|
|
63
|
+
errors.push(`environment has ${environment.length} entries; at most ${MAX_ENV_REFS}`);
|
|
64
|
+
for (const [i, e] of environment.entries()) {
|
|
65
|
+
if (typeof e !== "object" || e === null) {
|
|
66
|
+
errors.push(`environment[${i}] must be an object`);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
const rec2 = e;
|
|
70
|
+
if (typeof rec2.name !== "string" || !ENV_NAME_RE.test(rec2.name))
|
|
71
|
+
errors.push(`environment[${i}].name is not a valid name`);
|
|
72
|
+
if (typeof rec2.required !== "boolean")
|
|
73
|
+
errors.push(`environment[${i}].required must be a boolean`);
|
|
74
|
+
if (!Array.isArray(rec2.provenanceIds))
|
|
75
|
+
errors.push(`environment[${i}].provenanceIds must be an array`);
|
|
76
|
+
}
|
|
77
|
+
for (const field of ["setup", "verify"]) {
|
|
78
|
+
for (const [i, s] of rec[field].entries()) {
|
|
79
|
+
if (typeof s !== "object" || s === null) {
|
|
80
|
+
errors.push(`${field}[${i}] must be an object`);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const rec2 = s;
|
|
84
|
+
const command = rec2.command;
|
|
85
|
+
if (!Array.isArray(command) || command.length === 0 || !command.every((c) => typeof c === "string")) {
|
|
86
|
+
errors.push(`${field}[${i}].command must be an argv array (a bare shell string is refused; ["sh","-lc", …] is the explicit escape)`);
|
|
87
|
+
}
|
|
88
|
+
if (typeof rec2.timeoutSeconds !== "number")
|
|
89
|
+
errors.push(`${field}[${i}].timeoutSeconds must be a number`);
|
|
90
|
+
if (!Array.isArray(rec2.provenanceIds))
|
|
91
|
+
errors.push(`${field}[${i}].provenanceIds must be an array`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// C-2: services / provenance / agentAssets had no element check at all, so a non-object element
|
|
95
|
+
// reached the free-text sweep below and threw instead of being reported. The rule is the same one
|
|
96
|
+
// `environment` and `setup`/`verify` already apply: an element that is not an object is an error.
|
|
97
|
+
for (const field of ["services", "provenance", "agentAssets"]) {
|
|
98
|
+
for (const [i, e] of rec[field].entries()) {
|
|
99
|
+
if (typeof e !== "object" || e === null || Array.isArray(e))
|
|
100
|
+
errors.push(`${field}[${i}] must be an object`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
// assertNoSecretMaterial, LAST — a value-SHAPE refusal over every free-text field (argv strings,
|
|
104
|
+
// service image names, provenance explanations, agent-asset references), never a classifier over
|
|
105
|
+
// environment[].name: there is nothing to classify there. The refusal names the FIELD, never the
|
|
106
|
+
// matched text, so this function never prints what it found.
|
|
107
|
+
for (const { path, text } of collectFreeText(rec)) {
|
|
108
|
+
if (text.includes(PEM_ARMOUR) || KNOWN_SECRET_PREFIX_RE.test(text)) {
|
|
109
|
+
errors.push(`${path} appears to contain secret material (matches a known credential prefix)`);
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
SECRET_ASSIGNMENT_RE.lastIndex = 0;
|
|
113
|
+
let m;
|
|
114
|
+
while ((m = SECRET_ASSIGNMENT_RE.exec(text))) {
|
|
115
|
+
if (SECRET_NAME_RE.test(m[1])) {
|
|
116
|
+
errors.push(`${path} appears to contain secret material (an assignment shaped like a secret name)`);
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return errors;
|
|
122
|
+
}
|
|
123
|
+
function collectFreeText(rec) {
|
|
124
|
+
const out = [];
|
|
125
|
+
for (const field of ["setup", "verify"]) {
|
|
126
|
+
for (const [i, s] of rec[field].entries()) {
|
|
127
|
+
const rec2 = asRecord(s);
|
|
128
|
+
if (rec2 === null)
|
|
129
|
+
continue;
|
|
130
|
+
if (Array.isArray(rec2.command)) {
|
|
131
|
+
for (const c of rec2.command)
|
|
132
|
+
if (typeof c === "string")
|
|
133
|
+
out.push({ path: `${field}[${i}].command`, text: c });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
for (const [i, s] of rec.services.entries()) {
|
|
138
|
+
const rec2 = asRecord(s);
|
|
139
|
+
if (rec2 === null)
|
|
140
|
+
continue;
|
|
141
|
+
if (typeof rec2.image === "string")
|
|
142
|
+
out.push({ path: `services[${i}].image`, text: rec2.image });
|
|
143
|
+
}
|
|
144
|
+
for (const [i, p] of rec.provenance.entries()) {
|
|
145
|
+
const rec2 = asRecord(p);
|
|
146
|
+
if (rec2 === null)
|
|
147
|
+
continue;
|
|
148
|
+
if (typeof rec2.explanation === "string")
|
|
149
|
+
out.push({ path: `provenance[${i}].explanation`, text: rec2.explanation });
|
|
150
|
+
}
|
|
151
|
+
for (const [i, a] of rec.agentAssets.entries()) {
|
|
152
|
+
const rec2 = asRecord(a);
|
|
153
|
+
if (rec2 === null)
|
|
154
|
+
continue;
|
|
155
|
+
if (typeof rec2.reference === "string")
|
|
156
|
+
out.push({ path: `agentAssets[${i}].reference`, text: rec2.reference });
|
|
157
|
+
}
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
/** An element the caller may safely read properties off, or null — a non-object is reported above. */
|
|
161
|
+
function asRecord(value) {
|
|
162
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
|
|
163
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
const GROUP_ORDER = ["build/test", "deploy-only", "bindings"];
|
|
2
|
+
export function groupSightings(sightings) {
|
|
3
|
+
const byName = new Map();
|
|
4
|
+
for (const s of sightings) {
|
|
5
|
+
if (!byName.has(s.name))
|
|
6
|
+
byName.set(s.name, []);
|
|
7
|
+
byName.get(s.name).push(s);
|
|
8
|
+
}
|
|
9
|
+
const entries = [];
|
|
10
|
+
for (const [name, list] of byName) {
|
|
11
|
+
const binding = list.find((s) => s.isBinding);
|
|
12
|
+
const wranglerVar = list.find((s) => s.isWranglerVar);
|
|
13
|
+
let group;
|
|
14
|
+
let needsDeclaration = true;
|
|
15
|
+
let wranglerEnvironment;
|
|
16
|
+
if (binding) {
|
|
17
|
+
group = "bindings";
|
|
18
|
+
wranglerEnvironment = binding.wranglerEnvironment;
|
|
19
|
+
}
|
|
20
|
+
else if (wranglerVar) {
|
|
21
|
+
group = "build/test";
|
|
22
|
+
needsDeclaration = false;
|
|
23
|
+
wranglerEnvironment = wranglerVar.wranglerEnvironment;
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
const allJobScoped = list.every((s) => s.jobId !== undefined);
|
|
27
|
+
const allDeploy = list.every((s) => s.jobDeploy === true);
|
|
28
|
+
group = allJobScoped && allDeploy ? "deploy-only" : "build/test";
|
|
29
|
+
}
|
|
30
|
+
const localSource = resolveLocalSource(group, list);
|
|
31
|
+
const found = list.map((s) => s.finding).sort((a, b) => (a.file === b.file ? a.line - b.line : a.file.localeCompare(b.file)));
|
|
32
|
+
entries.push({ name, group, localSource, needsDeclaration, wranglerEnvironment, found });
|
|
33
|
+
}
|
|
34
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
35
|
+
const groups = GROUP_ORDER.map((id) => ({
|
|
36
|
+
id,
|
|
37
|
+
names: entries.filter((e) => e.group === id).map((e) => e.name).sort(),
|
|
38
|
+
}));
|
|
39
|
+
return { entries, groups };
|
|
40
|
+
}
|
|
41
|
+
function resolveLocalSource(group, list) {
|
|
42
|
+
if (group === "bindings")
|
|
43
|
+
return "Cloudflare binding";
|
|
44
|
+
if (list.some((s) => s.isWranglerVar))
|
|
45
|
+
return "value committed in wrangler.toml — nothing to declare";
|
|
46
|
+
const hasEnvShell = list.some((s) => s.localSource === ".env file or your shell");
|
|
47
|
+
const hasCiSecret = list.some((s) => s.localSource === "CI secret");
|
|
48
|
+
if (group === "deploy-only")
|
|
49
|
+
return hasCiSecret ? "CI secret" : ".env file or your shell";
|
|
50
|
+
return hasEnvShell ? ".env file or your shell" : hasCiSecret ? "CI secret" : list[0].localSource;
|
|
51
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// inventory.ts — walks a repository once and dispatches each file to the scanner that understands it.
|
|
2
|
+
// D-5: `.env` and `.dev.vars` are never opened — not filtered out after reading, never asked for at
|
|
3
|
+
// all. Nothing in the dispatch table below names them, so the structural guarantee holds without a
|
|
4
|
+
// separate exclusion list that could itself drift.
|
|
5
|
+
//
|
|
6
|
+
// ⛔ D-5 IS KEYED ON THE REAL PATH, NOT ON THE WALKED ONE (validate attempt 29, M-2). A basename is
|
|
7
|
+
// exactly what a symlink controls: a committed `.env.example -> .env` used to route the REAL `.env`
|
|
8
|
+
// into scanEnvExample, because the dispatch read the link's name rather than the file it opens.
|
|
9
|
+
// Chained with the unquoted-PEM defect in scan-env-example.ts, that printed verbatim bytes of an
|
|
10
|
+
// uncommitted private key as a variable NAME. So each file is resolved with `realpathSync` first,
|
|
11
|
+
// the dispatch keys on the resolved basename, and NEVER_READ_BASENAMES refuses the two names D-5
|
|
12
|
+
// promises are never opened — under either name, the link's or the target's.
|
|
13
|
+
import { readFileSync, realpathSync, statSync } from "node:fs";
|
|
14
|
+
import { basename, join } from "node:path";
|
|
15
|
+
import { groupSightings } from "./group.js";
|
|
16
|
+
import { ENV_EXAMPLE_FILES, scanEnvExample } from "./scan-env-example.js";
|
|
17
|
+
import { MAX_SOURCE_FILE_BYTES, SOURCE_EXTENSIONS, scanSource } from "./scan-source.js";
|
|
18
|
+
import { scanWorkflow } from "./scan-workflow.js";
|
|
19
|
+
import { scanWrangler } from "./scan-wrangler.js";
|
|
20
|
+
import { walkRepo } from "./walk.js";
|
|
21
|
+
/** D-5's promise, as a list: neither name is ever opened, by either route. */
|
|
22
|
+
const NEVER_READ_BASENAMES = new Set([".env", ".dev.vars"]);
|
|
23
|
+
/** Above this a file is not read at all, whatever it is. CR-7: a drop here is always noted. */
|
|
24
|
+
export const HARD_READ_CAP_BYTES = MAX_SOURCE_FILE_BYTES * 4;
|
|
25
|
+
function defaultReadFile(path) {
|
|
26
|
+
try {
|
|
27
|
+
if (statSync(path).size > HARD_READ_CAP_BYTES)
|
|
28
|
+
return undefined;
|
|
29
|
+
return readFileSync(path, "utf8");
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** The basename of the file `abs` really opens, or the walked basename when it cannot be resolved. */
|
|
36
|
+
function resolvedBasename(abs, walked) {
|
|
37
|
+
try {
|
|
38
|
+
return basename(realpathSync(abs));
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return walked;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
export function inventoryRepo(root, deps = {}) {
|
|
45
|
+
const readFile = deps.readFile ?? defaultReadFile;
|
|
46
|
+
const listFiles = deps.listFiles ?? walkRepo;
|
|
47
|
+
const files = listFiles(root);
|
|
48
|
+
const sightings = [];
|
|
49
|
+
const jobs = [];
|
|
50
|
+
const notes = [];
|
|
51
|
+
for (const rel of files) {
|
|
52
|
+
const walkedBase = rel.split("/").pop() ?? rel;
|
|
53
|
+
const abs = join(root, rel);
|
|
54
|
+
const base = resolvedBasename(abs, walkedBase);
|
|
55
|
+
// M-2: refused under either name — the link's or the one it really opens.
|
|
56
|
+
if (NEVER_READ_BASENAMES.has(base) || NEVER_READ_BASENAMES.has(walkedBase))
|
|
57
|
+
continue;
|
|
58
|
+
if (ENV_EXAMPLE_FILES.includes(base)) {
|
|
59
|
+
const text = readFile(abs);
|
|
60
|
+
if (text !== undefined)
|
|
61
|
+
sightings.push(...scanEnvExample(rel, text));
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (rel.startsWith(".github/workflows/") && (rel.endsWith(".yml") || rel.endsWith(".yaml"))) {
|
|
65
|
+
const text = readFile(abs);
|
|
66
|
+
if (text !== undefined) {
|
|
67
|
+
const res = scanWorkflow(rel, text);
|
|
68
|
+
sightings.push(...res.sightings);
|
|
69
|
+
jobs.push(...res.jobs);
|
|
70
|
+
}
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (base === "wrangler.toml") {
|
|
74
|
+
const text = readFile(abs);
|
|
75
|
+
if (text !== undefined)
|
|
76
|
+
sightings.push(...scanWrangler(rel, text));
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (base === "wrangler.json" || base === "wrangler.jsonc") {
|
|
80
|
+
notes.push(`${rel} is a Cloudflare config this scan does not read yet (JSON/JSONC) — its vars and bindings are not reflected below.`);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (SOURCE_EXTENSIONS.some((ext) => rel.endsWith(ext))) {
|
|
84
|
+
const text = readFile(abs);
|
|
85
|
+
// CR-7: the 512 KB band below emitted a note while the hard cap above it emitted NOTHING, so a
|
|
86
|
+
// file over 2 MB vanished from the inventory with no trace at all. Every drop is now named.
|
|
87
|
+
if (text === undefined) {
|
|
88
|
+
notes.push(`${rel} could not be read (unreadable, or over the ${HARD_READ_CAP_BYTES} byte hard cap) and was skipped`);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
// CR-7: measured in BYTES. `text.length` counts UTF-16 code units, so a file of multi-byte
|
|
92
|
+
// characters was compared against the wrong number.
|
|
93
|
+
if (Buffer.byteLength(text, "utf8") > MAX_SOURCE_FILE_BYTES) {
|
|
94
|
+
notes.push(`${rel} is over 512 KB and was skipped`);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
sightings.push(...scanSource(rel, text));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const { entries, groups } = groupSightings(sightings);
|
|
101
|
+
return { root, entries, groups, jobs, notes };
|
|
102
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
const GROUP_BLURB = {
|
|
2
|
+
"build/test": "needed to install, build and run the tests in a container",
|
|
3
|
+
"deploy-only": "a CI secret used only by a deploy job",
|
|
4
|
+
bindings: "a Cloudflare (or other platform) binding — not an environment value",
|
|
5
|
+
};
|
|
6
|
+
export function renderInventory(inv) {
|
|
7
|
+
const lines = [];
|
|
8
|
+
for (const group of inv.groups) {
|
|
9
|
+
lines.push(`${group.id} — ${GROUP_BLURB[group.id]}`);
|
|
10
|
+
if (group.names.length === 0) {
|
|
11
|
+
lines.push(" (none found)");
|
|
12
|
+
}
|
|
13
|
+
else {
|
|
14
|
+
for (const name of group.names) {
|
|
15
|
+
const entry = inv.entries.find((e) => e.name === name);
|
|
16
|
+
if (!entry)
|
|
17
|
+
continue;
|
|
18
|
+
const where = entry.found.map((f) => `${f.file}:${f.line}`).join(", ");
|
|
19
|
+
const consumers = [...new Set(entry.found.map((f) => f.consumer))].join(", ");
|
|
20
|
+
lines.push(` ${name} — found ${where} · used by ${consumers} · value comes from ${entry.localSource}`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
lines.push("");
|
|
24
|
+
}
|
|
25
|
+
if (inv.notes.length > 0) {
|
|
26
|
+
lines.push("Notes:");
|
|
27
|
+
for (const n of inv.notes)
|
|
28
|
+
lines.push(` ${n}`);
|
|
29
|
+
}
|
|
30
|
+
return lines;
|
|
31
|
+
}
|
|
32
|
+
export function inventoryToJson(inv) {
|
|
33
|
+
return { root: inv.root, groups: inv.groups, entries: inv.entries, jobs: inv.jobs, notes: inv.notes };
|
|
34
|
+
}
|
|
35
|
+
export function renderCheck(result, file) {
|
|
36
|
+
if (result.state === "valid")
|
|
37
|
+
return [`valid — ${file} passes this bundle's offline check`];
|
|
38
|
+
return [`invalid — ${file} failed this bundle's offline check:`, ...result.errors.map((e) => ` - ${e}`)];
|
|
39
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
export const ENV_EXAMPLE_FILES = [".env.example", ".env.sample", ".env.template", ".env.defaults", ".dev.vars.example"];
|
|
2
|
+
const LINE_RE = /^\s*(#\s*)?(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/;
|
|
3
|
+
const PEM_BEGIN = "-----BEGIN";
|
|
4
|
+
const PEM_END = "-----END";
|
|
5
|
+
export function scanEnvExample(file, text) {
|
|
6
|
+
const out = [];
|
|
7
|
+
const lines = text.split("\n");
|
|
8
|
+
// Non-null while a quoted value is still open. Lines inside one are skipped entirely: the name
|
|
9
|
+
// that opened the value was already recorded, and everything after it is value material.
|
|
10
|
+
let openQuote = null;
|
|
11
|
+
// True when the open quote was opened by a COMMENTED assignment — see shape 2 above.
|
|
12
|
+
let openQuoteCommented = false;
|
|
13
|
+
// True while a PEM armour block opened by a value half is still unterminated — see shape 1 above.
|
|
14
|
+
let openPem = false;
|
|
15
|
+
for (let i = 0; i < lines.length; i++) {
|
|
16
|
+
const line = lines[i];
|
|
17
|
+
if (openPem) {
|
|
18
|
+
if (line.includes(PEM_END))
|
|
19
|
+
openPem = false;
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
if (openQuote) {
|
|
23
|
+
if (openQuoteCommented && !line.trimStart().startsWith("#")) {
|
|
24
|
+
// The comment block ended, so the "value" ended with it. Fall through and read this line.
|
|
25
|
+
openQuote = null;
|
|
26
|
+
openQuoteCommented = false;
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
if (line.includes(openQuote)) {
|
|
30
|
+
openQuote = null;
|
|
31
|
+
openQuoteCommented = false;
|
|
32
|
+
}
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const m = LINE_RE.exec(line);
|
|
37
|
+
if (!m)
|
|
38
|
+
continue;
|
|
39
|
+
const value = m[3];
|
|
40
|
+
if (value.includes(PEM_BEGIN) && !value.includes(PEM_END)) {
|
|
41
|
+
openPem = true;
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
openQuote = unclosedQuote(value);
|
|
45
|
+
openQuoteCommented = openQuote !== null && m[1] !== undefined;
|
|
46
|
+
}
|
|
47
|
+
out.push({
|
|
48
|
+
name: m[2],
|
|
49
|
+
finding: { file, line: i + 1, consumer: `a documented name in ${file}`, consumerKind: "env-example" },
|
|
50
|
+
localSource: ".env file or your shell",
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
/** The quote character this value half opens and does not close on its own line, or null. */
|
|
56
|
+
function unclosedQuote(value) {
|
|
57
|
+
const v = value.trim();
|
|
58
|
+
const q = v[0];
|
|
59
|
+
if (q !== '"' && q !== "'")
|
|
60
|
+
return null;
|
|
61
|
+
return v.slice(1).includes(q) ? null : q;
|
|
62
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export const SOURCE_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"];
|
|
2
|
+
export const MAX_SOURCE_FILE_BYTES = 512 * 1024;
|
|
3
|
+
const PATTERNS = [
|
|
4
|
+
/\bprocess\.env\.([A-Za-z_][A-Za-z0-9_]*)/g,
|
|
5
|
+
/\bprocess\.env\[["']([A-Za-z_][A-Za-z0-9_]*)["']\]/g,
|
|
6
|
+
/\bimport\.meta\.env\.([A-Za-z_][A-Za-z0-9_]*)/g,
|
|
7
|
+
/\bDeno\.env\.get\(["']([A-Za-z_][A-Za-z0-9_]*)["']\)/g,
|
|
8
|
+
];
|
|
9
|
+
export function scanSource(file, text) {
|
|
10
|
+
const out = [];
|
|
11
|
+
const lineStarts = computeLineStarts(text);
|
|
12
|
+
for (const re of PATTERNS) {
|
|
13
|
+
re.lastIndex = 0;
|
|
14
|
+
let m;
|
|
15
|
+
while ((m = re.exec(text))) {
|
|
16
|
+
out.push({
|
|
17
|
+
name: m[1],
|
|
18
|
+
finding: { file, line: lineOf(lineStarts, m.index), consumer: `read by ${file}`, consumerKind: "source" },
|
|
19
|
+
localSource: ".env file or your shell",
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return out;
|
|
24
|
+
}
|
|
25
|
+
function computeLineStarts(text) {
|
|
26
|
+
const starts = [0];
|
|
27
|
+
for (let i = 0; i < text.length; i++)
|
|
28
|
+
if (text[i] === "\n")
|
|
29
|
+
starts.push(i + 1);
|
|
30
|
+
return starts;
|
|
31
|
+
}
|
|
32
|
+
function lineOf(starts, offset) {
|
|
33
|
+
let lo = 0;
|
|
34
|
+
let hi = starts.length - 1;
|
|
35
|
+
while (lo < hi) {
|
|
36
|
+
const mid = Math.ceil((lo + hi) / 2);
|
|
37
|
+
if (starts[mid] <= offset)
|
|
38
|
+
lo = mid;
|
|
39
|
+
else
|
|
40
|
+
hi = mid - 1;
|
|
41
|
+
}
|
|
42
|
+
return lo + 1;
|
|
43
|
+
}
|