@tekyzinc/gsd-t 5.1.10 → 5.1.12

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.
@@ -1,1046 +0,0 @@
1
- "use strict";
2
-
3
- // bin/gsd-t-env-registry.cjs
4
- //
5
- // M102 — Environment Registry helper. Records every environment's connection
6
- // MAP (never a secret VALUE) into docs/infrastructure.md via the ONE shared
7
- // marker-block writer, and reads it back read-first (No-Re-Research) so a
8
- // missing entry HALTs-and-documents rather than being guessed (No-Fallback-Ever).
9
- //
10
- // Contract: .gsd-t/contracts/env-registry-contract.md
11
- //
12
- // Public surface:
13
- // recordEnvironment({ projectDir, scope, kind, host, port, name, authMethod,
14
- // secretVault, secretEnvVarName, fetchCommand,
15
- // connectCommand, gotchas, readOnlyDefault })
16
- // -> upsert row by (scope, kind) into the Environments table (idempotent).
17
- // lookupEnvironment(projectDir, scope, kind) -> row object | null.
18
- // detectEnvConfig(projectDir) -> { vault, fetchCommand, secretEnvVarNames, ... }
19
- // addPermissionEntry(projectDir, connectCommand) -> { added, tool, entry }
20
- // ensureEnvGitignored(projectDir) -> { added }
21
- //
22
- // Zero external npm runtime deps — fs/path only. Reuses the M100 detectStack
23
- // SHAPE and the ONE shared marker-block doc-writer (gsd-t-doc-marker.cjs).
24
-
25
- const fs = require("fs");
26
- const path = require("path");
27
- const { upsertMarkedDocBlock } = require("./gsd-t-doc-marker.cjs");
28
-
29
- // ─── Markers + schema ────────────────────────────────────────────────────────
30
-
31
- const ENV_MARKER_START = "<!-- gsd-t-env-registry:start -->";
32
- const ENV_MARKER_END = "<!-- gsd-t-env-registry:end -->";
33
-
34
- // Map-only columns — NO secret-value column exists (a row is structurally
35
- // incapable of holding a secret VALUE). `secret vault` + `secret env-var NAME`
36
- // + `fetch command` record WHERE the secret lives and HOW to fetch it, never
37
- // the value itself.
38
- const ENV_COLUMNS = [
39
- "id",
40
- "scope",
41
- "kind",
42
- "host",
43
- "port",
44
- "db/name",
45
- "auth method",
46
- "secret vault",
47
- "secret env-var NAME",
48
- "fetch command",
49
- "connect command",
50
- "access gotchas",
51
- "read-only default",
52
- "recorded",
53
- ];
54
-
55
- // ─── Secret-value BACKSTOP detector (known-prefix / JWT / embedded-cred) ─────
56
- //
57
- // THIS IS A BACKSTOP ONLY — never the primary guard. The PRIMARY guard for
58
- // every free-ish column is a TIGHT POSITIVE ALLOWLIST (see the per-column
59
- // validators below): a value is accepted ONLY if it matches the shape its
60
- // column is supposed to hold (a real hostname / an enum member / a $VAR / a
61
- // curated subcommand word). A value like `hunter2hunter2hunter` or
62
- // `correcthorsebattery` is rejected because it is NOT a valid hostname / NOT
63
- // the enum / NOT a $VAR — with ZERO entropy or character-class threshold.
64
- //
65
- // THREE Red Team passes proved the inverse ("reject if it looks like a secret",
66
- // a denylist with an entropy floor) leaks: every floor eventually admits a
67
- // secret that sits below it. The floor is the defect; it is GONE as a primary
68
- // guard. This backstop remains only to cheaply catch the well-known credential
69
- // SHAPES (GitHub / Stripe / AWS / Slack / Google prefixes, JWTs, embedded
70
- // proto://user:pw@ creds) — an EXTRA layer, never the sole thing between a
71
- // value and the doc.
72
-
73
- // Known secret prefixes. A token STARTING with one of these is a live secret.
74
- // Belt-and-suspenders only — the structural validators (command allowlist +
75
- // enumerated gotchas + gate high-entropy scan) are the real guard for an OPEN
76
- // category. This list catches the well-known SHAPES cheaply.
77
- const KNOWN_SECRET_PREFIXES = [
78
- "ghp_", "gho_", "ghs_", "ghu_", "ghr_", // GitHub PAT / OAuth / server / user / refresh
79
- "github_pat_", // GitHub fine-grained PAT
80
- "sk_live", "sk_test", "pk_live", "rk_live", // Stripe
81
- "AKIA", "ASIA", // AWS access-key ids
82
- "xoxb-", "xoxp-", "xoxa-", "xoxr-", "xoxs-", "xapp-", // Slack (incl. app-level)
83
- "AIza", // Google API key
84
- "glpat-", // GitLab personal access token
85
- "gitlab", // GitLab-prefixed tokens (belt-and-suspenders)
86
- "npm_", // npm automation token
87
- "dop_v1_", // DigitalOcean PAT
88
- "SG.", // SendGrid API key
89
- "shpat_", "shpss_", // Shopify access / shared-secret
90
- ];
91
-
92
- // proto://user:PASSWORD@host for ANY scheme (not a hardcoded scheme allowlist).
93
- // The password segment is a literal only when it is NOT a $-reference.
94
- const EMBEDDED_CRED = /[a-z][a-z0-9+.\-]*:\/\/[^\s:/@]+:(?!\$)[^\s:/@]+@/i;
95
-
96
- // JWT: three base64url segments joined by dots. A real JWT's header segment is
97
- // the base64url of a JSON object beginning `{"` → it ALWAYS starts with `eyJ`.
98
- // Anchoring on that avoids a false positive on a 3-part dotted hostname
99
- // (`ep-neon.neon.tech`), while still catching every genuine token.
100
- const JWT_SHAPE = /^eyJ[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{4,}$/;
101
-
102
- // A contiguous base64/hex run inside a larger string (e.g. `Bearer <token>`).
103
- const BASE64_RUN = /[A-Za-z0-9+/]{24,}={0,2}/;
104
- const HEX_RUN = /\b[0-9a-fA-F]{32,}\b/;
105
-
106
- // True when `s` contains (or is) a live secret value. Used BOTH as the reject
107
- // test inside the per-column allowlist checks AND as the verify gate's
108
- // independent detector.
109
- function looksLikeSecretValue(s) {
110
- if (typeof s !== "string" || !s) return false;
111
- // Known prefixes — anywhere a whitespace/quote-delimited token begins.
112
- for (const tok of s.split(/[\s"'`=:,()<>]+/)) {
113
- if (!tok) continue;
114
- for (const p of KNOWN_SECRET_PREFIXES) {
115
- if (tok.startsWith(p) && tok.length > p.length) return true;
116
- }
117
- if (JWT_SHAPE.test(tok)) return true;
118
- }
119
- if (EMBEDDED_CRED.test(s)) return true;
120
- // High-entropy runs — but ignore a $-reference and skip pure hostnames
121
- // (dotted, no long unbroken run) which the base64 run would otherwise catch.
122
- if (HEX_RUN.test(s)) return true;
123
- const b64 = s.match(BASE64_RUN);
124
- if (b64) {
125
- const run = b64[0];
126
- // A hostname segment is short and dotted; a real blob is one long run.
127
- if (run.length >= 24) return true;
128
- }
129
- return false;
130
- }
131
-
132
- // Back-compat alias — the OLD name is kept exported so nothing that imports it
133
- // breaks, but it now points at the strict entropy/prefix detector.
134
- function looksLikeInlineSecret(value) {
135
- return looksLikeSecretValue(value);
136
- }
137
-
138
- // ─── POSITIVE-ALLOWLIST shape primitives (shared: writer + verify gate) ──────
139
- //
140
- // These are the PRIMARY guard for every free-ish column. NOT an entropy floor —
141
- // a set of TIGHT POSITIVE shapes. A value is accepted ONLY if it IS a real
142
- // hostname / IP / $VAR / enum member / curated subcommand word. Everything else
143
- // is REJECTED, so `hunter2hunter2hunter` / `8675309…` / `correcthorsebattery` /
144
- // `Tr0ub4dor` fail because they are none of those shapes — with ZERO entropy or
145
- // character-class threshold anywhere.
146
-
147
- // A dotted hostname/domain: labels of [A-Za-z0-9-] joined by dots, at least one
148
- // dot, TLD-ish last label. Recognizes `ep-cool.us-east-2.aws.neon.tech`,
149
- // `bastion.example.com`, `api.example.com`.
150
- const DOTTED_HOSTNAME =
151
- /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?)+$/;
152
-
153
- // IPv4 dotted-quad (loose — 1-3 digits per octet; not validating 0-255 range,
154
- // only the SHAPE, which is enough to distinguish a real IP from a digit blob).
155
- const IPV4 = /^\d{1,3}(?:\.\d{1,3}){3}$/;
156
-
157
- // A bare single-label host: letters + hyphen ONLY (NO digits), ≤ 16 chars,
158
- // lowercase-led. Accepts docker/service names `db`, `postgres`, `db-primary`,
159
- // and the single-char test host `h`. REJECTS `hunter2hunter2hunter` (digits),
160
- // `8675309…` (digits), and a long single-label passphrase like
161
- // `correcthorsebattery` (19 > 16) — a bare blob is never a valid host here.
162
- // Real docker/service hostnames are short; a genuinely long endpoint is dotted
163
- // DNS or an IP, both of which have their own positive shapes.
164
- const BARE_HOST_LABEL = /^[a-z][a-z-]{0,15}$/;
165
-
166
- // A $VAR / ${VAR} reference, optionally single/double-quoted — the ONLY
167
- // sanctioned way a secret ever appears in a command.
168
- const VAR_REF = /^["']?\$\{?[A-Za-z_][A-Za-z0-9_]*\}?["']?$/;
169
-
170
- // A pure UPPER_SNAKE env-var NAME (e.g. DATABASE_URL_PROD, SECRET) — a NAME,
171
- // never a value.
172
- const UPPER_SNAKE = /^[A-Z][A-Z0-9_]*$/;
173
-
174
- // A short lowercase-led SNAKE identifier used for db / resource names. Positive
175
- // shape (NOT an entropy floor): lowercase-led, [a-z0-9_], ≤ 16 chars, and NO
176
- // digit-immediately-followed-by-a-letter (a `2h`-style alternation reads as a
177
- // blob, never a real db name). Accepts `binvoice_prod` (13), `analytics`,
178
- // `neondb_prod`, `appdb`; REJECTS `hunter2hunter2hunter` (`2h` transition),
179
- // `correcthorsebattery` (19 > 16), `Xk9…` (uppercase).
180
- const DB_NAME_MAX = 16;
181
- function isDbNameShape(s) {
182
- if (typeof s !== "string" || s.length === 0 || s.length > DB_NAME_MAX) return false;
183
- if (!/^[a-z][a-z0-9_]*$/.test(s)) return false;
184
- if (/[0-9][a-z]/.test(s)) return false; // digit→letter interior = blob-like
185
- return true;
186
- }
187
-
188
- // A dotted hostname OR IPv4 OR `localhost` — any real network endpoint shape.
189
- function isHostShape(s) {
190
- if (s === "localhost") return true;
191
- if (IPV4.test(s)) return true;
192
- if (DOTTED_HOSTNAME.test(s)) return true;
193
- if (BARE_HOST_LABEL.test(s)) return true;
194
- return false;
195
- }
196
-
197
- // ─── Per-column ALLOWLIST validators ─────────────────────────────────────────
198
- //
199
- // Each column may contain ONLY its expected shape. Anything else THROWS — the
200
- // row is REJECTED and never written. This is the inversion the Red Team
201
- // prescribed: a bare secret in ANY field fails its column's shape check.
202
-
203
- const SCOPES = new Set(["local", "staging", "prod"]);
204
- const VAULTS = new Set([
205
- "local", "local (.env)", ".env", "env",
206
- "vercel", "neon", "gcp-secret-manager", "google secret manager",
207
- "aws-secrets-manager", "aws secrets manager", "doppler", "1password",
208
- "hashicorp-vault", "vault", "azure-key-vault", "infisical",
209
- ]);
210
-
211
- // `auth method` is an ENUM (positive allowlist), exactly like `secret vault`
212
- // and `access gotchas`. A value NOT in this set is rejected — so
213
- // `hunter2hunter2hunter` fails because it is not an auth-method name, with NO
214
- // entropy check. Extend as real auth methods appear (this is the curated set,
215
- // not a floor).
216
- const AUTH_METHODS = new Set([
217
- "password", "iam", "oauth", "oauth2", "service-account", "ssh-key",
218
- "api-key", "none", "scram-sha-256", "md5", "trust", "token", "key",
219
- "cert", "mtls", "kerberos", "ldap",
220
- ]);
221
-
222
- // Curated CLI / DB subcommand words a command may carry as a BARE literal arg.
223
- // A positive allowlist — a bare arg is accepted only if it is one of these OR
224
- // the row's own db-name shape OR a hostname / $VAR / flag. Anything else must
225
- // become a $VAR (see classifyCommandToken). Extend as real tools appear.
226
- const CLI_WORDS = new Set([
227
- // DB / shell clients
228
- "psql", "mysql", "mysqldump", "mongo", "mongosh", "redis-cli", "sqlite3",
229
- "pg_dump", "pg_restore", "pg_dumpall", "cqlsh", "clickhouse-client",
230
- // vault / platform CLIs
231
- "neonctl", "vercel", "gcloud", "aws", "az", "doppler", "supabase",
232
- "flyctl", "fly", "heroku", "railway", "wrangler", "turso", "op", "infisical",
233
- "kubectl", "helm", "terraform", "vault",
234
- // network clients
235
- "ssh", "scp", "sftp", "curl", "wget", "ldapsearch", "nc", "openssl", "rsync",
236
- // common subcommands / operands
237
- "env", "pull", "push", "list", "get", "set", "secrets", "versions",
238
- "access", "version", "exec", "run", "connect", "login", "logout",
239
- "connection-string", "db-url", "database-url", "redis-url",
240
- "admin", "default", "latest", "read", "write", "describe", "show",
241
- // descriptive-fetch operands (the detectEnvConfig fallback: "read $VAR from .env")
242
- "from", "cat", "source", "printenv", "dotenv",
243
- ]);
244
-
245
- // A dotfile operand a command may reference (e.g. `.env`, `.env.local`,
246
- // `.gcloudignore`). REQUIRES a leading dot — that is what makes it a dotfile
247
- // and NOT a bare blob (`hunter2…` has no leading dot, so it never matches).
248
- // Short lowercase labels only; a leading-dot filename is a filesystem path,
249
- // never a secret VALUE.
250
- const DOTFILE_TOKEN = /^\.[a-z][a-z0-9]*(?:\.[a-z][a-z0-9-]*)*$/;
251
-
252
- // The doc-block markers a smuggled cell could use to corrupt upsert indexOf.
253
- const RESERVED_MARKER_SUBSTRINGS = [
254
- ENV_MARKER_START, ENV_MARKER_END,
255
- "gsd-t-env-registry:start", "gsd-t-env-registry:end",
256
- "gsd-t-logging-scaffold:start", "gsd-t-logging-scaffold:end",
257
- ];
258
-
259
- function rejectMarkerSmuggle(field, value) {
260
- if (typeof value !== "string" || !value) return;
261
- for (const m of RESERVED_MARKER_SUBSTRINGS) {
262
- if (value.includes(m)) {
263
- throw new Error(
264
- `recordEnvironment: field "${field}" contains a reserved doc-marker substring ` +
265
- `("${m}") — refusing to write a value that could corrupt the marked doc block.`
266
- );
267
- }
268
- }
269
- }
270
-
271
- function rejectSecret(field, value) {
272
- if (looksLikeSecretValue(value)) {
273
- throw new Error(
274
- `recordEnvironment: refusing to write an inline literal secret in field "${field}". ` +
275
- `Record the secret env-var NAME and reference it as $VAR (e.g. psql "$DATABASE_URL_PROD"), ` +
276
- `never a literal password/token/connection-string.`
277
- );
278
- }
279
- }
280
-
281
- // Optional-string helper: undefined/null/"" pass through as empty (—).
282
- function optStr(v) {
283
- return v === undefined || v === null ? "" : String(v);
284
- }
285
-
286
- function validateScope(v) {
287
- if (!SCOPES.has(v)) {
288
- throw new Error(`recordEnvironment: scope must be one of local|staging|prod, got "${v}"`);
289
- }
290
- }
291
-
292
- function validateKind(v) {
293
- if (!/^[a-z0-9][a-z0-9-]*$/.test(v)) {
294
- throw new Error(`recordEnvironment: kind must match [a-z0-9-]+ (e.g. postgres, http-api), got "${v}"`);
295
- }
296
- }
297
-
298
- function validateHost(field, v) {
299
- const s = optStr(v);
300
- if (s === "") return;
301
- // POSITIVE shape: `localhost` | IPv4 | dotted-DNS | a bare service label
302
- // (letters+hyphen, ≤24, no digits). Strip an optional trailing `:port`.
303
- const hostPart = s.replace(/:\d+$/, "");
304
- if (!isHostShape(hostPart)) {
305
- throw new Error(
306
- `recordEnvironment: ${field} "${s}" is not a valid host shape. ` +
307
- `A host must be "localhost", an IPv4 address, a dotted DNS hostname ` +
308
- `(e.g. ep-cool.us-east-2.aws.neon.tech), or a short lowercase service ` +
309
- `name (letters+hyphen, no digits — e.g. db, postgres, db-primary). ` +
310
- `A bare token like "${hostPart}" is rejected — if it is a secret, move ` +
311
- `it to an env var and reference it as $VAR.`
312
- );
313
- }
314
- // Backstop (extra layer, never the primary guard).
315
- rejectSecret(field, s);
316
- }
317
-
318
- function validatePort(v) {
319
- const s = optStr(v);
320
- if (s === "") return;
321
- if (!/^\d+$/.test(s)) {
322
- throw new Error(`recordEnvironment: port must be digits only, got "${s}"`);
323
- }
324
- }
325
-
326
- function validateName(v) {
327
- const s = optStr(v);
328
- if (s === "") return;
329
- // POSITIVE shape: a short lowercase-led snake db-name (≤16, no digit→letter
330
- // interior). Accepts `binvoice_prod`, `analytics`, `neondb_prod`, `appdb`;
331
- // REJECTS `hunter2hunter2hunter` (`2h` transition), `correcthorsebattery`
332
- // (too long), `Xk9…` (uppercase) — NO entropy check.
333
- if (!isDbNameShape(s)) {
334
- throw new Error(
335
- `recordEnvironment: db/name "${s}" is not a valid db-name shape. ` +
336
- `A db name must be a short lowercase identifier (≤${DB_NAME_MAX} chars, ` +
337
- `lowercase-led [a-z][a-z0-9_]*, no digit-then-letter run — e.g. ` +
338
- `binvoice_prod, analytics). A blob like "${s}" is rejected; if it is a ` +
339
- `secret, move it to an env var and reference it as $VAR.`
340
- );
341
- }
342
- // Backstop (extra layer).
343
- rejectSecret("db/name", s);
344
- }
345
-
346
- function validateAuthMethod(v) {
347
- const s = optStr(v);
348
- if (s === "") return;
349
- // POSITIVE ENUM (like `secret vault` / `access gotchas`). A value NOT in the
350
- // curated set is rejected — `hunter2hunter2hunter` fails because it is not an
351
- // auth-method name, with NO entropy check.
352
- if (!AUTH_METHODS.has(s.toLowerCase())) {
353
- throw new Error(
354
- `recordEnvironment: auth method "${s}" is not an enumerated auth method. ` +
355
- `Allowed: ${[...AUTH_METHODS].join(" | ")}. A secret is never an ` +
356
- `auth-method value — record the method name only.`
357
- );
358
- }
359
- // Backstop (extra layer).
360
- rejectSecret("auth method", s);
361
- }
362
-
363
- function validateVault(v) {
364
- const s = optStr(v);
365
- if (s === "") return;
366
- rejectSecret("secret vault", s);
367
- if (!VAULTS.has(s.toLowerCase())) {
368
- throw new Error(
369
- `recordEnvironment: secret vault must be an enumerated vault name ` +
370
- `(local|vercel|neon|gcp-secret-manager|aws-secrets-manager|env|…), got "${s}"`
371
- );
372
- }
373
- }
374
-
375
- function validateEnvVarName(v) {
376
- const s = optStr(v);
377
- if (s === "") return;
378
- // an env-var NAME only: UPPER_SNAKE. A real value has lowercase/entropy.
379
- // Backstop first: `AKIAIOSFODNN7EXAMPLE` is valid UPPER_SNAKE but is an AWS
380
- // key id — the known-prefix detector rejects it.
381
- rejectSecret("secret env-var NAME", s);
382
- if (!/^[A-Z][A-Z0-9_]*$/.test(s)) {
383
- throw new Error(
384
- `recordEnvironment: secret env-var NAME must be [A-Z][A-Z0-9_]* (a NAME, not a value), got "${s}"`
385
- );
386
- }
387
- }
388
-
389
- // ─── Command POSITIVE allowlist ──────────────────────────────────────────────
390
- //
391
- // A command token is accepted ONLY if it IS one of a small set of SAFE POSITIVE
392
- // shapes; ANYTHING else THROWS. This is the PRIMARY guard for the connect/fetch
393
- // columns. NO entropy threshold — THREE Red Team passes proved a floor always
394
- // admits a secret below it. The positive rule instead admits ONLY:
395
- //
396
- // 1. a $VAR / ${VAR} reference (optionally quoted) — the ONLY sanctioned way
397
- // a secret ever appears in a command;
398
- // 2. a flag: `-x` | `--flag` (bare) | `--flag=<value>` (value re-checked
399
- // by the SAME positive rule — a bare literal flag value must itself be a
400
- // $VAR / hostname / curated word);
401
- // 3. a dotted hostname / IPv4 / `localhost` (a network endpoint);
402
- // 4. a bare literal arg ONLY if it is a curated CLI/DB subcommand word OR the
403
- // row's own db-name shape (short lowercase snake, ≤16, no digit→letter).
404
- //
405
- // A bare literal that is NONE of these — `hunter2hunter2hunter`,
406
- // `correcthorsebattery`, `Xk9#mPq2vLzR8nQw`, a UUID, a platform token — is
407
- // REJECTED with "put this value in an env var and reference it as $VAR". We
408
- // ACCEPT the false positive that an unusual-but-legit bare arg must become a
409
- // $VAR — that is the correct trade for a secrets-adjacent file.
410
-
411
- // True if a BARE literal command arg is on the positive allowlist: a curated
412
- // subcommand word OR a db-name shape (row identifier). NO entropy check.
413
- function isSafeBareArg(bare) {
414
- if (CLI_WORDS.has(bare.toLowerCase())) return true;
415
- if (isDbNameShape(bare)) return true;
416
- return false;
417
- }
418
-
419
- // Split a command into whitespace-delimited tokens, but keep a quoted segment
420
- // (single or double quotes) as ONE token so `"$DATABASE_URL"` stays intact.
421
- function tokenizeCommand(s) {
422
- const tokens = [];
423
- const re = /"[^"]*"|'[^']*'|\S+/g;
424
- let m;
425
- while ((m = re.exec(s)) !== null) tokens.push(m[0]);
426
- return tokens;
427
- }
428
-
429
- // Classify ONE command token. Returns null if it is on the positive allowlist,
430
- // or a reason string if it must be rejected.
431
- function classifyCommandToken(tok) {
432
- // Strip a single layer of surrounding quotes for shape analysis.
433
- const bare = tok.replace(/^["']/, "").replace(/["']$/, "");
434
- if (bare === "") return null;
435
-
436
- // 1. a $VAR / ${VAR} reference (the ONLY sanctioned way a secret appears).
437
- if (VAR_REF.test(tok) || /^\$\{?[A-Za-z_][A-Za-z0-9_]*\}?$/.test(bare)) return null;
438
-
439
- // 2. a flag: -x | --flag | --flag=value. The value half is re-checked by the
440
- // SAME positive rule (a bare literal flag value must itself be a
441
- // $VAR / hostname / curated word — never a free literal).
442
- if (/^--?[A-Za-z][A-Za-z0-9-]*$/.test(bare)) {
443
- // bare flag with no value: -U, --headed, -W, -h, -d — always safe.
444
- return null;
445
- }
446
- if (/^--?[A-Za-z][A-Za-z0-9-]*=/.test(bare)) {
447
- const eq = bare.indexOf("=");
448
- const value = bare.slice(eq + 1);
449
- if (value === "") return null;
450
- const reason = classifyCommandToken(value);
451
- if (reason) {
452
- return `flag value in "${bare}" is not an allowed shape — ${reason}`;
453
- }
454
- return null;
455
- }
456
-
457
- // 3. a network endpoint: dotted hostname / IPv4 / localhost.
458
- if (isHostShape(bare)) return null;
459
-
460
- // 3b. a dotfile operand (`.env`, `.env.local`) — a filesystem path, not a secret.
461
- if (DOTFILE_TOKEN.test(bare)) return null;
462
-
463
- // 4. a bare literal arg — ONLY a curated subcommand word or a db-name shape.
464
- if (isSafeBareArg(bare)) return null;
465
-
466
- // 5. anything else → REJECT. It is neither $VAR, flag, host, nor a recognized
467
- // safe word — a probable inline literal secret.
468
- return `bare argument "${bare}" is not a $VAR reference, a flag, a hostname, ` +
469
- `nor a recognized CLI/db identifier — it is a probable inline literal secret. ` +
470
- `Move it to an env var and reference it as $VAR (e.g. psql "$DATABASE_URL_PROD")`;
471
- }
472
-
473
- function validateCommand(field, v) {
474
- const s = optStr(v);
475
- if (s === "") return;
476
- // Backstop first (known prefixes / JWT / base64 / hex / embedded cred).
477
- rejectSecret(field, s);
478
- // PRIMARY structural guard: every token must be an allowed shape.
479
- for (const tok of tokenizeCommand(s)) {
480
- const reason = classifyCommandToken(tok);
481
- if (reason) {
482
- throw new Error(
483
- `recordEnvironment: ${field} rejected — ${reason}. ` +
484
- `A command may contain only flags, $VAR/\${VAR} references, and simple ` +
485
- `identifier/subcommand/hostname tokens; a bare literal secret is forbidden. ` +
486
- `Put the value in an env var and reference it as $VAR (e.g. psql "$DATABASE_URL_PROD").`
487
- );
488
- }
489
- }
490
- }
491
-
492
- // ─── Gotchas ENUMERATED (no free prose) ──────────────────────────────────────
493
- //
494
- // Free prose gave a secret somewhere to hide. `gotchas` is now an enumerated /
495
- // short-token column: a comma/space-separated list drawn from a fixed enum,
496
- // plus an optional `via <hostname/identifier>` qualifier. Anything else THROWS.
497
-
498
- const GOTCHA_ENUM = new Set([
499
- "vpn", "ip-allowlist", "ssh-tunnel", "bastion", "none",
500
- ]);
501
-
502
- function validateGotchas(v) {
503
- const s = optStr(v);
504
- if (s === "") return;
505
- rejectSecret("access gotchas", s);
506
- // Tokenize on commas and whitespace.
507
- const tokens = s.split(/[,\s]+/).filter(Boolean);
508
- for (let i = 0; i < tokens.length; i++) {
509
- const t = tokens[i].toLowerCase();
510
- if (GOTCHA_ENUM.has(t)) continue;
511
- if (t === "via") {
512
- // the NEXT token must be a POSITIVE host shape (dotted DNS / IPv4 /
513
- // localhost / short service label) — never a free literal.
514
- const next = tokens[i + 1];
515
- if (!next) {
516
- throw new Error(`recordEnvironment: access gotchas — "via" must be followed by a hostname/identifier`);
517
- }
518
- if (!isHostShape(next)) {
519
- throw new Error(
520
- `recordEnvironment: access gotchas — "via ${next}" target must be a hostname/IP ` +
521
- `(dotted DNS, IPv4, localhost, or a short lowercase service name), got "${next}"`
522
- );
523
- }
524
- i++; // consume the hostname token
525
- continue;
526
- }
527
- // A standalone POSITIVE host shape is allowed (naming a bastion directly).
528
- if (isHostShape(tokens[i])) {
529
- continue;
530
- }
531
- throw new Error(
532
- `recordEnvironment: access gotchas must be an enumerated list ` +
533
- `(vpn | ip-allowlist | ssh-tunnel | bastion | none), optionally with ` +
534
- `"via <hostname>" — got "${tokens[i]}". Free prose is forbidden (nowhere for a secret to hide).`
535
- );
536
- }
537
- }
538
-
539
- // Run the full per-column allowlist + marker-smuggle + secret backstop over a
540
- // proposed row. Throws on the FIRST violation — the row is never written.
541
- function validateRow(opts) {
542
- // Marker-smuggling guard FIRST, on EVERY user-supplied field — a smuggled
543
- // doc-marker is rejected with its own specific error regardless of which
544
- // column it lands in (the column shape checks would otherwise reject it with
545
- // a less-specific "wrong shape" message).
546
- for (const [field, value] of Object.entries({
547
- scope: opts.scope,
548
- kind: opts.kind,
549
- host: opts.host,
550
- port: opts.port,
551
- name: opts.name,
552
- authMethod: opts.authMethod,
553
- secretVault: opts.secretVault,
554
- secretEnvVarName: opts.secretEnvVarName,
555
- fetchCommand: opts.fetchCommand,
556
- connectCommand: opts.connectCommand,
557
- gotchas: opts.gotchas,
558
- })) {
559
- rejectMarkerSmuggle(field, value);
560
- }
561
-
562
- validateScope(opts.scope);
563
- validateKind(opts.kind);
564
- validateHost("host", opts.host);
565
- validatePort(opts.port);
566
- validateName(opts.name);
567
- validateAuthMethod(opts.authMethod);
568
- validateVault(opts.secretVault);
569
- validateEnvVarName(opts.secretEnvVarName);
570
- validateCommand("fetch command", opts.fetchCommand);
571
- validateCommand("connect command", opts.connectCommand);
572
- validateGotchas(opts.gotchas);
573
- }
574
-
575
- // ─── Table serialization ─────────────────────────────────────────────────────
576
-
577
- function escapeCell(v) {
578
- if (v === undefined || v === null || v === "") return "—";
579
- return String(v).replace(/\|/g, "\\|").replace(/\n/g, " ").trim();
580
- }
581
-
582
- function rowToLine(row) {
583
- return "| " + ENV_COLUMNS.map((c) => escapeCell(row[c])).join(" | ") + " |";
584
- }
585
-
586
- function headerLines() {
587
- return [
588
- "| " + ENV_COLUMNS.join(" | ") + " |",
589
- "| " + ENV_COLUMNS.map(() => "---").join(" | ") + " |",
590
- ];
591
- }
592
-
593
- // Build the full marker-delimited block from a list of row objects.
594
- function buildTableBlock(rows) {
595
- const lines = [
596
- ENV_MARKER_START,
597
- "## Environments",
598
- "",
599
- "> **Map, not secrets.** This table records only WHERE an environment lives and",
600
- "> HOW to reach it — host/port/name/auth-method/which-vault-holds-the-secret/the",
601
- "> env-var NAME/the fetch + connect commands. It NEVER stores a secret VALUE.",
602
- "> The value lives in the vault (`.env` / Vercel / Neon / Google Secret Manager)",
603
- "> and is pulled at runtime via the env-var NAME. A missing row → HALT and",
604
- "> document (detect → ask → record → proceed); never guess a connection string,",
605
- "> never grep transcripts to rediscover.",
606
- "",
607
- ...headerLines(),
608
- ...rows.map(rowToLine),
609
- ENV_MARKER_END,
610
- ];
611
- return lines.join("\n");
612
- }
613
-
614
- // ─── Table parsing ───────────────────────────────────────────────────────────
615
-
616
- function infraDocPath(projectDir) {
617
- return path.join(projectDir, "docs", "infrastructure.md");
618
- }
619
-
620
- function extractBlock(content) {
621
- if (!content.includes(ENV_MARKER_START) || !content.includes(ENV_MARKER_END)) {
622
- return null;
623
- }
624
- const start = content.indexOf(ENV_MARKER_START);
625
- const end = content.indexOf(ENV_MARKER_END);
626
- return content.slice(start, end);
627
- }
628
-
629
- function splitRow(line) {
630
- // Strip the leading/trailing pipe, split on unescaped pipes.
631
- const trimmed = line.trim().replace(/^\|/, "").replace(/\|$/, "");
632
- const cells = [];
633
- let cur = "";
634
- for (let i = 0; i < trimmed.length; i++) {
635
- const ch = trimmed[i];
636
- if (ch === "\\" && trimmed[i + 1] === "|") {
637
- cur += "|";
638
- i++;
639
- } else if (ch === "|") {
640
- cells.push(cur.trim());
641
- cur = "";
642
- } else {
643
- cur += ch;
644
- }
645
- }
646
- cells.push(cur.trim());
647
- return cells;
648
- }
649
-
650
- // Parse the Environments table into an array of row objects keyed by column.
651
- function parseRows(projectDir) {
652
- const target = infraDocPath(projectDir);
653
- if (!fs.existsSync(target)) return [];
654
- const content = fs.readFileSync(target, "utf8");
655
- const block = extractBlock(content);
656
- if (!block) return [];
657
-
658
- const rows = [];
659
- const lines = block.split("\n");
660
- for (const line of lines) {
661
- if (!line.trim().startsWith("|")) continue;
662
- const cells = splitRow(line);
663
- // Skip the header row and the separator row.
664
- if (cells[0] === "id") continue;
665
- if (cells.every((c) => /^-{1,}$/.test(c) || c === "")) continue;
666
- const row = {};
667
- ENV_COLUMNS.forEach((col, i) => {
668
- const raw = cells[i] === undefined ? "" : cells[i];
669
- row[col] = raw === "—" ? "" : raw;
670
- });
671
- rows.push(row);
672
- }
673
- return rows;
674
- }
675
-
676
- // ─── recordEnvironment — upsert by (scope, kind) ─────────────────────────────
677
-
678
- function recordEnvironment(opts = {}) {
679
- const {
680
- projectDir,
681
- scope,
682
- kind,
683
- host,
684
- port,
685
- name,
686
- authMethod,
687
- secretVault,
688
- secretEnvVarName,
689
- fetchCommand,
690
- connectCommand,
691
- gotchas,
692
- } = opts;
693
-
694
- if (!projectDir) throw new Error("recordEnvironment: projectDir is required");
695
- if (!scope) throw new Error("recordEnvironment: scope is required");
696
- if (!kind) throw new Error("recordEnvironment: kind is required");
697
-
698
- // HARD CONSTRAINT: per-column allowlist + marker-smuggle + secret backstop.
699
- // Reject the ENTIRE row before any coercion or disk write.
700
- validateRow(opts);
701
-
702
- // Destructive-Action Guard: prod defaults read-only=YES unless explicitly set.
703
- // Coerce any non-string/boolean input to a YES/NO string (never literal
704
- // "false"/0/null leaking into the doc).
705
- let readOnlyDefault = opts.readOnlyDefault;
706
- if (readOnlyDefault === undefined || readOnlyDefault === null || readOnlyDefault === "") {
707
- readOnlyDefault = scope === "prod" ? "YES" : "NO";
708
- } else if (typeof readOnlyDefault === "boolean") {
709
- readOnlyDefault = readOnlyDefault ? "YES" : "NO";
710
- } else {
711
- const s = String(readOnlyDefault).trim().toLowerCase();
712
- readOnlyDefault = ["yes", "true", "1", "y"].includes(s) ? "YES" : "NO";
713
- }
714
-
715
- const recorded = new Date().toISOString();
716
- const id = `${scope}-${kind}`;
717
-
718
- const row = {
719
- id,
720
- scope,
721
- kind,
722
- host,
723
- port,
724
- "db/name": name,
725
- "auth method": authMethod,
726
- "secret vault": secretVault,
727
- "secret env-var NAME": secretEnvVarName,
728
- "fetch command": fetchCommand,
729
- "connect command": connectCommand,
730
- "access gotchas": gotchas,
731
- "read-only default": readOnlyDefault,
732
- recorded,
733
- };
734
-
735
- // (row is already fully validated above by validateRow — allowlist-per-column
736
- // + marker-smuggle guard + entropy/prefix secret backstop.)
737
- // Upsert by (scope, kind): replace the existing row with the same key, else
738
- // append. This is what self-heals staleness (Aiven → Neon replaces the row).
739
- const rows = parseRows(projectDir);
740
- const key = (r) => `${r.scope} ${r.kind}`;
741
- const thisKey = `${scope} ${kind}`;
742
-
743
- // Detect a DIFFERENT host replacing the same (scope,kind) — surface it so a
744
- // second real environment doesn't silently vanish into an upsert.
745
- const prior = rows.find((r) => key(r) === thisKey);
746
- const replaced = !!prior;
747
- const oldHost = prior ? prior.host : undefined;
748
- const newHost = optStr(host);
749
- const warnedHostChange = replaced && !!oldHost && !!newHost && oldHost !== newHost;
750
-
751
- const kept = rows.filter((r) => key(r) !== thisKey);
752
- kept.push(row);
753
-
754
- const block = buildTableBlock(kept);
755
- const target = infraDocPath(projectDir);
756
- upsertMarkedDocBlock(target, ENV_MARKER_START, ENV_MARKER_END, block);
757
-
758
- const result = Object.assign({}, row, { replaced });
759
- if (warnedHostChange) {
760
- result.warnedHostChange = true;
761
- result.oldHost = oldHost;
762
- result.newHost = newHost;
763
- }
764
- return result;
765
- }
766
-
767
- // ─── lookupEnvironment — read-first (No-Re-Research) ─────────────────────────
768
- //
769
- // Returns the row for (scope, kind) or null. On null the CALLER HALTs and
770
- // documents — this module intentionally emits NO guessed connection string and
771
- // has NO transcript-grep rediscovery path. There is no fallback here by design.
772
-
773
- function lookupEnvironment(projectDir, scope, kind) {
774
- const rows = parseRows(projectDir);
775
- const match = rows.find((r) => r.scope === scope && r.kind === kind);
776
- return match || null;
777
- }
778
-
779
- // ─── detectEnvConfig — reuse the detectStack SHAPE (map only, never a value) ──
780
- //
781
- // Reads .env.example / .env var NAMES, connection-string shapes, and
782
- // vercel/neon/gcloud presence to PROPOSE a vault + fetch command. Records the
783
- // map only — it NEVER reads or emits a secret value.
784
-
785
- function readEnvVarNames(projectDir) {
786
- const names = new Set();
787
- for (const f of [".env.example", ".env.sample", ".env.template"]) {
788
- const p = path.join(projectDir, f);
789
- if (!fs.existsSync(p)) continue;
790
- let content;
791
- try {
792
- content = fs.readFileSync(p, "utf8");
793
- } catch (_) {
794
- continue;
795
- }
796
- for (const line of content.split("\n")) {
797
- const m = line.match(/^\s*([A-Z][A-Z0-9_]*)\s*=/);
798
- if (m) names.add(m[1]);
799
- }
800
- }
801
- return Array.from(names);
802
- }
803
-
804
- function fileExists(projectDir, rel) {
805
- return fs.existsSync(path.join(projectDir, rel));
806
- }
807
-
808
- function readPkgDeps(projectDir) {
809
- const pkgPath = path.join(projectDir, "package.json");
810
- let pkg = {};
811
- try {
812
- pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
813
- } catch (_) {
814
- return [];
815
- }
816
- const deps = Object.assign({}, pkg.dependencies || {}, pkg.devDependencies || {});
817
- return Object.keys(deps).map((d) => d.toLowerCase());
818
- }
819
-
820
- function detectEnvConfig(projectDir) {
821
- if (!projectDir) throw new Error("detectEnvConfig: projectDir is required");
822
-
823
- const secretEnvVarNames = readEnvVarNames(projectDir);
824
- const depNames = readPkgDeps(projectDir);
825
-
826
- // Vault detection — convention-based, grounded in the 3 real inspected
827
- // projects. Precedence: an explicit platform marker wins over plain .env.
828
- const hasVercel = fileExists(projectDir, "vercel.json") || fileExists(projectDir, ".vercel");
829
- const hasGoogle =
830
- fileExists(projectDir, "cloudbuild.yaml") ||
831
- fileExists(projectDir, "cloudbuild.yml") ||
832
- fileExists(projectDir, ".gcloudignore");
833
- const hasNeon =
834
- depNames.some((d) => d === "@neondatabase/serverless" || d.includes("neon")) ||
835
- fileExists(projectDir, ".neon");
836
-
837
- let vault = "local (.env)";
838
- let fetchCommand = secretEnvVarNames[0]
839
- ? `read $${secretEnvVarNames[0]} from .env`
840
- : "read from .env";
841
-
842
- if (hasVercel) {
843
- vault = "Vercel";
844
- fetchCommand = "vercel env pull";
845
- } else if (hasGoogle) {
846
- vault = "Google Secret Manager";
847
- fetchCommand = "gcloud secrets versions access latest --secret=<name>";
848
- } else if (hasNeon) {
849
- vault = "Neon";
850
- fetchCommand = "neonctl connection-string";
851
- }
852
-
853
- return {
854
- vault,
855
- fetchCommand,
856
- secretEnvVarNames,
857
- signals: { hasVercel, hasGoogle, hasNeon },
858
- detectedFrom: path.join(projectDir, "package.json"),
859
- };
860
- }
861
-
862
- // ─── addPermissionEntry — broad-glob allow-entry per tool ────────────────────
863
- //
864
- // Reuses the guarded-write scaffold pattern from bin/gsd-t.js (invalid-JSON
865
- // guard + symlink guard + idempotent). Writes a BROAD GLOB per tool
866
- // (Bash(psql:*)), NOT the exact command string — the user-locked decision.
867
-
868
- function isSymlink(filePath) {
869
- try {
870
- return fs.lstatSync(filePath).isSymbolicLink();
871
- } catch (_) {
872
- return false;
873
- }
874
- }
875
-
876
- // Derive the tool from the connect command's first meaningful token, skipping
877
- // leading env-var assignments (FOO=bar psql ...) and a leading `sudo`.
878
- function deriveTool(connectCommand) {
879
- if (!connectCommand || typeof connectCommand !== "string") return null;
880
- const tokens = connectCommand.trim().split(/\s+/);
881
- let i = 0;
882
- while (i < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[i])) i++;
883
- if (tokens[i] === "sudo") i++;
884
- const tool = tokens[i];
885
- if (!tool) return null;
886
- // Base name only (strip any path).
887
- const base = tool.split("/").pop();
888
- if (!base) return null;
889
- // Reject a tool token carrying shell metacharacters — emitting Bash(psql;:*)
890
- // (or worse) into the permission allowlist would be broken junk / an injection
891
- // vector. A real tool name is plain `[A-Za-z0-9._-]`. Anything else → null
892
- // (no permission entry written).
893
- if (!/^[A-Za-z0-9._-]+$/.test(base)) return null;
894
- return base;
895
- }
896
-
897
- function addPermissionEntry(projectDir, connectCommand) {
898
- if (!projectDir) throw new Error("addPermissionEntry: projectDir is required");
899
- const tool = deriveTool(connectCommand);
900
- if (!tool) return { added: false, tool: null, entry: null, reason: "no tool derivable" };
901
-
902
- const entry = `Bash(${tool}:*)`;
903
- const settingsPath = path.join(projectDir, ".claude", "settings.json");
904
-
905
- let settings = {};
906
- if (fs.existsSync(settingsPath)) {
907
- try {
908
- settings = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
909
- if (!settings || typeof settings !== "object") settings = {};
910
- } catch (_) {
911
- // invalid-JSON guard — do not clobber, report noop
912
- return { added: false, tool, entry, reason: "settings.json has invalid JSON" };
913
- }
914
- }
915
-
916
- if (!settings.permissions || typeof settings.permissions !== "object") settings.permissions = {};
917
- if (!Array.isArray(settings.permissions.allow)) settings.permissions.allow = [];
918
-
919
- if (settings.permissions.allow.includes(entry)) {
920
- return { added: false, tool, entry, reason: "already present" };
921
- }
922
-
923
- if (isSymlink(settingsPath)) {
924
- return { added: false, tool, entry, reason: "settings.json is a symlink" };
925
- }
926
-
927
- settings.permissions.allow.push(entry);
928
-
929
- const dir = path.dirname(settingsPath);
930
- if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
931
- fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf8");
932
-
933
- return { added: true, tool, entry };
934
- }
935
-
936
- // ─── ensureEnvGitignored — auto-add + report (user-locked decision) ──────────
937
-
938
- function ensureEnvGitignored(projectDir) {
939
- if (!projectDir) throw new Error("ensureEnvGitignored: projectDir is required");
940
- const gitignorePath = path.join(projectDir, ".gitignore");
941
-
942
- if (isSymlink(gitignorePath)) {
943
- return { added: false, reason: ".gitignore is a symlink" };
944
- }
945
-
946
- let content = "";
947
- if (fs.existsSync(gitignorePath)) content = fs.readFileSync(gitignorePath, "utf8");
948
-
949
- const lines = content.split("\n").map((l) => l.trim());
950
- // A broad `.env*` rule (or `*.env`) already covers every variant.
951
- const hasBroadRule = lines.some((l) => l === ".env*" || l === "/.env*" || l === "*.env");
952
-
953
- // Collect the env files actually present in the project — `.env`, `.env.local`,
954
- // `.env.production`, etc. Each MUST be gitignored; a bare `.env` rule does NOT
955
- // cover `.env.production`, so leaving it tracked would leak secrets.
956
- let present = [];
957
- try {
958
- present = fs
959
- .readdirSync(projectDir)
960
- .filter((f) => f === ".env" || /^\.env\.[^/\\]+$/.test(f));
961
- } catch (_) {
962
- present = [];
963
- }
964
- // Always ensure at least a bare `.env` intent even if none is present yet.
965
- const required = new Set([".env", ...present]);
966
-
967
- const ignored = new Set(lines);
968
- const isCovered = (name) =>
969
- hasBroadRule || ignored.has(name) || ignored.has("/" + name);
970
-
971
- const missing = [...required].filter((name) => !isCovered(name));
972
- if (missing.length === 0) return { added: false, reason: "already gitignored" };
973
-
974
- // Prefer a single broad `.env*` rule when more than one variant is present or
975
- // missing — it future-proofs against new `.env.*` files.
976
- const additions =
977
- missing.length > 1 || present.some((f) => f !== ".env") ? [".env*"] : missing;
978
-
979
- const next =
980
- content.replace(/\s*$/, "") +
981
- (content ? "\n" : "") +
982
- additions.join("\n") +
983
- "\n";
984
- fs.writeFileSync(gitignorePath, next, "utf8");
985
- return { added: true, rules: additions };
986
- }
987
-
988
- module.exports = {
989
- recordEnvironment,
990
- lookupEnvironment,
991
- detectEnvConfig,
992
- addPermissionEntry,
993
- ensureEnvGitignored,
994
- deriveTool,
995
- looksLikeInlineSecret,
996
- looksLikeSecretValue,
997
- isHostShape,
998
- isDbNameShape,
999
- isSafeBareArg,
1000
- classifyCommandToken,
1001
- tokenizeCommand,
1002
- AUTH_METHODS,
1003
- CLI_WORDS,
1004
- ENV_COLUMNS,
1005
- ENV_MARKER_START,
1006
- ENV_MARKER_END,
1007
- };
1008
-
1009
- // ─── CLI arm (so sandboxed workflows can call via Bash) ──────────────────────
1010
-
1011
- if (require.main === module) {
1012
- const args = process.argv.slice(2);
1013
- const sub = args[0];
1014
- const out = (obj) => process.stdout.write(JSON.stringify(obj, null, 2) + "\n");
1015
-
1016
- try {
1017
- if (sub === "record") {
1018
- // node gsd-t-env-registry.cjs record '<json-opts>'
1019
- const opts = JSON.parse(args[1] || "{}");
1020
- out({ ok: true, row: recordEnvironment(opts) });
1021
- } else if (sub === "lookup") {
1022
- // node gsd-t-env-registry.cjs lookup <projectDir> <scope> <kind>
1023
- const row = lookupEnvironment(args[1], args[2], args[3]);
1024
- out({ ok: true, found: !!row, row });
1025
- } else if (sub === "detect") {
1026
- // node gsd-t-env-registry.cjs detect <projectDir>
1027
- out({ ok: true, detected: detectEnvConfig(args[1]) });
1028
- } else if (sub === "add-permission") {
1029
- // node gsd-t-env-registry.cjs add-permission <projectDir> '<connectCommand>'
1030
- out({ ok: true, result: addPermissionEntry(args[1], args[2]) });
1031
- } else if (sub === "ensure-gitignore") {
1032
- // node gsd-t-env-registry.cjs ensure-gitignore <projectDir>
1033
- out({ ok: true, result: ensureEnvGitignored(args[1]) });
1034
- } else {
1035
- out({
1036
- ok: false,
1037
- error:
1038
- "usage: env-registry <record|lookup|detect|add-permission|ensure-gitignore> ...",
1039
- });
1040
- process.exit(2);
1041
- }
1042
- } catch (e) {
1043
- out({ ok: false, error: e.message });
1044
- process.exit(1);
1045
- }
1046
- }