@catalystiq/test-kit 0.2.0 → 0.3.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/README.md CHANGED
@@ -1,16 +1,17 @@
1
1
  # @catalystiq/test-kit
2
2
 
3
- Shared test helpers: one package, added to part by part. Pin an exact version (`"@catalystiq/test-kit": "0.2.0"`);
3
+ Shared test helpers: one package, added to part by part. Pin an exact version (`"@catalystiq/test-kit": "0.3.0"`);
4
4
  Dependabot raises it.
5
5
 
6
6
  | Part | Import | What |
7
7
  |---|---|---|
8
8
  | Setup | `@catalystiq/test-kit/setup/config`, `/setup`, `/setup/dom` | Vitest defaults and the safety setup: credentials stripped, network blocked, production and `.env*` databases refused, React warnings fail the test. (`dist/setup/README.md` in the package) |
9
+ | Environment guard | `tk-dev-guard`, `tk-check-env-example`, `@catalystiq/test-kit/env` | A `predev` guard that refuses a production database and lists unset required variables, and a check that `.env.example` lists what the code reads. (`dist/env/README.md` in the package) |
9
10
  | Neon test database | `tk-neon-test-db` (bin), `@catalystiq/test-kit/neon-test-db` | `npm run test:db`: a short-lived schema-only Neon branch for one command, deleted afterwards; moved from github-actions' vendored `scripts/neon-test-db.mjs`. (`dist/neon-test-db/README.md` in the package) |
10
11
  | MCP Apps host | `@catalystiq/test-kit/mcp-apps-host`, `/mcp-apps-host/playwright` | A fake ChatGPT or Claude host for an MCP Apps card, in jsdom and in Playwright, and `tk-check-bundle`. (`dist/mcp-apps-host/README.md` in the package) |
11
12
 
12
13
  Vitest 4.1 and Node 22.18 or later. Every peer dependency is optional; install what the parts you use need (`vitest`,
13
- `@playwright/test`, `@testing-library/react`, `@testing-library/jest-dom`).
14
+ `@playwright/test`, `@testing-library/react`, `@testing-library/jest-dom`; `typescript` for `tk-check-env-example`).
14
15
 
15
16
  ## Developing
16
17
 
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env node
2
+ // tk-check-env-example [--example .env.example] [--exclude <path>]...: fails when .env.example and the code's
3
+ // environment reads disagree. See src/env/scan.ts.
4
+ import { optionValues, refuseUnknownOptions } from "../env/args.js";
5
+ import { checkEnvExample } from "../env/scan.js";
6
+ const USAGE = 'usage: tk-check-env-example [--example .env.example] [--exclude <path>]...';
7
+ const args = process.argv.slice(2);
8
+ try {
9
+ refuseUnknownOptions(args, ['--example', '--exclude'], USAGE);
10
+ const example = optionValues(args, '--example', USAGE).at(-1) ?? '.env.example';
11
+ const result = checkEnvExample(process.cwd(), example, optionValues(args, '--exclude', USAGE));
12
+ if (result.problems.length > 0) {
13
+ console.error(`tk-check-env-example: ${example} and the code disagree:\n ${result.problems.join('\n ')}`);
14
+ process.exitCode = 1;
15
+ }
16
+ else {
17
+ console.log(`ok ${example} lists the ${result.read} variables ${result.files} code files read (${result.listed} listed)`);
18
+ }
19
+ }
20
+ catch (error) {
21
+ console.error(`tk-check-env-example: ${error.message}`);
22
+ process.exitCode = 1;
23
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ // tk-dev-guard --hosts <file> [--example .env.example]: run it as `predev`. See src/env/dev-guard.ts.
3
+ import { runDevGuard } from "../env/dev-guard.js";
4
+ const { code, lines } = runDevGuard(process.argv.slice(2), process.cwd());
5
+ for (const line of lines)
6
+ (code === 0 ? console.log : console.error)(line);
7
+ process.exitCode = code;
@@ -0,0 +1,66 @@
1
+ # The local environment guard: `tk-dev-guard` and `tk-check-env-example`
2
+
3
+ Two CLIs for an app's own repository: a `predev` guard that refuses to start against production, and a check that keeps
4
+ `.env.example` equal to the variables the code reads.
5
+
6
+ ## `.env.example`
7
+
8
+ Every variable the app reads, one line each, no real values. The comment block above a variable (or a run of variables)
9
+ says what it is for. Two words, in capitals, in that block mark it:
10
+
11
+ - `REQUIRED`, opening a comment line (`# REQUIRED — …`): the app does not start without it; the guard fails while it
12
+ is unset.
13
+ - `UNSCANNED`: the code reads it in a way the check cannot see (a name held in a list, a tool reading it itself).
14
+
15
+ A line `# NAME=` lists a variable that is documented but not set by hand, such as `VERCEL_ENV`.
16
+
17
+ ```sh
18
+ # REQUIRED. The app's Postgres string: a development branch of your own, never production.
19
+ DATABASE_URL=
20
+
21
+ # Set by Vercel on a deployment.
22
+ # VERCEL_ENV=
23
+ ```
24
+
25
+ Keep it out of `.gitignore`'s `.env*` with `!.env.example`.
26
+
27
+ ## `tk-dev-guard`
28
+
29
+ ```json
30
+ "predev": "tk-dev-guard --hosts db/production-hosts.json"
31
+ ```
32
+
33
+ It reads the environment `next dev` would see (the shell over `.env.development.local`, `.env.local`,
34
+ `.env.development` and `.env`) and:
35
+
36
+ - refuses to start when any variable (but the two that hold its own command line, `npm_lifecycle_script` and
37
+ `npm_package_scripts_predev`), or the `dev` script's own text, names a production database, file values expanded as
38
+ `next dev` expands them (`$NAME`, `${NAME:-default}`): a Postgres URL whose host is on the list, a compute
39
+ endpoint id on the list (`ep-…`, with or without `-pooler`, so `PGHOST`, `PGOPTIONS` and a URL's `options=endpoint…`
40
+ count too), or a host on the list. It names the variable and the list entry, never the value. `ALLOW_PROD_DB=1` in
41
+ the shell (never a `.env` file) turns the refusal into a warning, for a deliberate production operation;
42
+ - fails while a `REQUIRED` variable of `.env.example` is unset, naming each, and counts the optional ones unset.
43
+
44
+ The list is `--hosts <file>` (a JSON array, or `{ "hosts": [...] }`: test-kit's setup reads the same file), `--host
45
+ <entry>` and `PRODUCTION_DATABASE_HOSTS` (comma-separated). An entry is a host or an endpoint id (a scheme, credentials, a port or a
46
+ path are dropped); with no entry at all it fails: a guard with nothing to refuse is not one. `--example` names another file; `--allow-var NAME` skips a variable that holds a production id on purpose.
47
+
48
+ ## `tk-check-env-example`
49
+
50
+ ```sh
51
+ tk-check-env-example [--example .env.example] [--exclude scripts/one-off]
52
+ ```
53
+
54
+ Parses the tracked code (`git ls-files`, less tests, fixtures, `e2e/`, type declarations and each `--exclude` path) with
55
+ TypeScript's parser (`typescript` is a peer dependency) for `process.env.NAME`, `process.env['NAME']`, `'NAME' in
56
+ process.env`, `const { NAME } = process.env`, the same on `import.meta.env` (less Vite's own `DEV`, `MODE`, …) and on an
57
+ object named `env` (capitals only), and fails
58
+ when the code reads a name `.env.example` does not list, when `.env.example` lists one no code reads (unless `UNSCANNED`),
59
+ on a name listed twice, and on a scan that found no code or no read. Run it from a test, so it cannot drift:
60
+
61
+ ```ts
62
+ import { execFileSync } from 'node:child_process';
63
+ it('.env.example lists what the code reads', () => {
64
+ execFileSync('node_modules/.bin/tk-check-env-example', [], { stdio: 'pipe' });
65
+ });
66
+ ```
@@ -0,0 +1,5 @@
1
+ /** The two bins' flag parsing: `--flag value`, repeatable, nothing else. */
2
+ /** Every value given for `flag`. Throws on a flag with no value after it. */
3
+ export declare function optionValues(args: readonly string[], flag: string, usage: string): string[];
4
+ /** Throws on an argument that is neither one of `flags` nor the value after one. */
5
+ export declare function refuseUnknownOptions(args: readonly string[], flags: readonly string[], usage: string): void;
@@ -0,0 +1,20 @@
1
+ /** The two bins' flag parsing: `--flag value`, repeatable, nothing else. */
2
+ /** Every value given for `flag`. Throws on a flag with no value after it. */
3
+ export function optionValues(args, flag, usage) {
4
+ const found = [];
5
+ args.forEach((arg, i) => {
6
+ if (arg !== flag)
7
+ return;
8
+ const next = args[i + 1];
9
+ if (next === undefined || next.startsWith('--'))
10
+ throw new Error(`${flag} needs a value\n${usage}`);
11
+ found.push(next);
12
+ });
13
+ return found;
14
+ }
15
+ /** Throws on an argument that is neither one of `flags` nor the value after one. */
16
+ export function refuseUnknownOptions(args, flags, usage) {
17
+ const unknown = args.filter((a, i) => !flags.includes(a) && !flags.includes(args[i - 1] ?? ''));
18
+ if (unknown.length > 0)
19
+ throw new Error(`unknown argument ${unknown.join(', ')}\n${usage}`);
20
+ }
@@ -0,0 +1,61 @@
1
+ import { type ExampleEntry } from './example.ts';
2
+ /** The files `next dev` loads, most precedent first (`NODE_ENV=test`: the test set, without `.env.local`). */
3
+ export declare const DEV_ENV_FILES: readonly [".env.development.local", ".env.local", ".env.development", ".env"];
4
+ export declare const TEST_ENV_FILES: readonly [".env.test.local", ".env.test", ".env"];
5
+ export interface DevEnv {
6
+ /** The environment the app would run with. */
7
+ env: Record<string, string>;
8
+ /**
9
+ * Every value the guard checks, beyond `env`: each file's own values as written and expanded two ways, so a
10
+ * production string the expansion rules would hide (or a file `env` overrode) is still seen.
11
+ */
12
+ alsoChecked: Array<[string, string]>;
13
+ /** The files that existed and were read, most precedent first. */
14
+ files: string[];
15
+ /** Whether `env` came from Next's own loader (`@next/env`, resolved from the root). */
16
+ fromNext: boolean;
17
+ }
18
+ /**
19
+ * The environment `next dev` would run with at `root`. In the real process (`base` is `process.env`) with `next`
20
+ * installed, that is Next's own loader, so its file set, `KEY: value` lines and expansion rules are exactly the app's.
21
+ * Otherwise the shell over the files, file values expanded. Either way every file's values are also checked as written
22
+ * and expanded against the shell alone and against the shell and the files (`alsoChecked`): a guard errs on refusing.
23
+ */
24
+ export declare function loadDevEnv(root: string, base?: NodeJS.ProcessEnv): DevEnv;
25
+ /**
26
+ * The production list entry a value names, or null. One pass over every host-shaped token of the value, as written and
27
+ * percent-decoded, so every connection-string shape counts: a URL (one host or several), a keyword/value string
28
+ * (`host=… dbname=…`), `PGHOST` (with a port), `PGOPTIONS endpoint=…`, a URL's `options=endpoint%3D…`.
29
+ */
30
+ export declare function productionEntryIn(value: string, productionHosts: readonly string[]): string | null;
31
+ /**
32
+ * A list entry as the host it names: a URL (`postgres://u:p@db.prod.example:5432/app`) is read as one, and a host
33
+ * loses a port, a path and a leading dot. An entry that names no host throws, naming where it came from but not the
34
+ * entry itself (it may be a whole connection string).
35
+ */
36
+ export declare function productionHostEntry(entry: string, source?: string): string;
37
+ export interface ProductionHit {
38
+ variable: string;
39
+ entry: string;
40
+ }
41
+ /**
42
+ * Every variable that names a production database, once each, by name: the variables of `env`, and the extra
43
+ * `[name, value]` pairs. The two variables that hold the guard's own command line are left out.
44
+ */
45
+ export declare function productionHits(env: Record<string, string | undefined>, productionHosts: readonly string[], allowVariables?: readonly string[], alsoChecked?: ReadonlyArray<readonly [string, string]>): ProductionHit[];
46
+ /** The variables of `.env.example` that are unset or blank, required and optional (documented ones, `# NAME=`, left out). */
47
+ export declare function unsetEntries(entries: readonly ExampleEntry[], env: Record<string, string | undefined>): {
48
+ required: string[];
49
+ optional: string[];
50
+ };
51
+ /** A production list file: a JSON array of hosts, or `{ "hosts": [...] }` (other keys, such as `_comment`, ignored). */
52
+ export declare function readProductionHostsFile(path: string): string[];
53
+ export interface DevGuardResult {
54
+ code: 0 | 1;
55
+ lines: string[];
56
+ }
57
+ /**
58
+ * The whole guard, as a function: `tk-dev-guard` prints its lines and exits with its code. `ALLOW_PROD_DB=1` is read
59
+ * from the shell alone (`base`): a line in a `.env` file would make the override permanent.
60
+ */
61
+ export declare function runDevGuard(args: readonly string[], root: string, base?: NodeJS.ProcessEnv): DevGuardResult;
@@ -0,0 +1,254 @@
1
+ /**
2
+ * The local guard `npm run dev` runs first (`tk-dev-guard`): it refuses an environment that names a production database
3
+ * and lists the required variables (`.env.example`, `REQUIRED`) that are unset.
4
+ *
5
+ * The environment is the one `next dev` would see: the process's own variables, over `.env.development.local`,
6
+ * `.env.local`, `.env.development` and `.env` at the root (Next's own loader when `next` is installed), and every file
7
+ * value is also checked as written. Every variable is checked (but npm's command-line ones), not
8
+ * one named database variable: a value names production when any host-shaped token in it is on the production list (a
9
+ * host or a subdomain of it, or a compute endpoint id `ep-…`, pooled or not), so a URL, a keyword/value string,
10
+ * `PGHOST`, `PGOPTIONS endpoint=…` and a URL's `options=endpoint%3D…` all count. A refusal names the variable and the
11
+ * matched list entry, never the value.
12
+ */
13
+ import { existsSync, readFileSync, statSync } from 'node:fs';
14
+ import { createRequire } from 'node:module';
15
+ import { resolve } from 'node:path';
16
+ import { parseEnv } from 'node:util';
17
+ import { isProductionHost } from "../setup/database.js";
18
+ import { optionValues, refuseUnknownOptions } from "./args.js";
19
+ import { parseEnvExample } from "./example.js";
20
+ /** The files `next dev` loads, most precedent first (`NODE_ENV=test`: the test set, without `.env.local`). */
21
+ export const DEV_ENV_FILES = ['.env.development.local', '.env.local', '.env.development', '.env'];
22
+ export const TEST_ENV_FILES = ['.env.test.local', '.env.test', '.env'];
23
+ const MAX_EXPANDED_LENGTH = 64 * 1024;
24
+ const REFERENCE = /(\\)?\$(?:\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}|([A-Za-z_][A-Za-z0-9_]*))/g;
25
+ /** `$NAME`, `${NAME}` and `${NAME:-default}` against `lookup`; `\$` stays a dollar sign. */
26
+ function expand(value, lookup, expanding = new Set()) {
27
+ const out = value.replace(REFERENCE, (match, escaped, braced, fallback, bare) => {
28
+ if (escaped)
29
+ return match.slice(1);
30
+ const name = (braced ?? bare);
31
+ // A name already being expanded (`A=$A`) expands to nothing, as a cycle.
32
+ if (expanding.has(name))
33
+ return '';
34
+ const found = lookup(name);
35
+ return expand(found ? found : (fallback ?? ''), lookup, new Set([...expanding, name]));
36
+ });
37
+ return out.length > MAX_EXPANDED_LENGTH ? out.slice(0, MAX_EXPANDED_LENGTH) : out;
38
+ }
39
+ /** A `.env` file's variables; dotenv's `KEY: value` lines, which `next dev` reads too, as `KEY=value`. */
40
+ function parseEnvFile(text) {
41
+ return parseEnv(text.replace(/^(\s*(?:export\s+)?[A-Za-z_][A-Za-z0-9_.-]*):[ \t]+/gm, '$1='));
42
+ }
43
+ /** Next's own loader, from the root's dependencies, or null. */
44
+ function nextEnvAt(root) {
45
+ try {
46
+ const loaded = createRequire(resolve(root, 'package.json'))('@next/env');
47
+ return typeof loaded.loadEnvConfig === 'function' ? loaded : null;
48
+ }
49
+ catch {
50
+ return null;
51
+ }
52
+ }
53
+ /**
54
+ * The environment `next dev` would run with at `root`. In the real process (`base` is `process.env`) with `next`
55
+ * installed, that is Next's own loader, so its file set, `KEY: value` lines and expansion rules are exactly the app's.
56
+ * Otherwise the shell over the files, file values expanded. Either way every file's values are also checked as written
57
+ * and expanded against the shell alone and against the shell and the files (`alsoChecked`): a guard errs on refusing.
58
+ */
59
+ export function loadDevEnv(root, base = process.env) {
60
+ const fileSet = base.NODE_ENV === 'test' ? TEST_ENV_FILES : DEV_ENV_FILES;
61
+ const fromFiles = {};
62
+ const files = [];
63
+ const perFile = [];
64
+ for (const file of [...fileSet].reverse()) {
65
+ const path = resolve(root, file);
66
+ // A file (or a pipe), as Next reads: a `.env/` directory is a Python virtualenv, not variables.
67
+ const kind = existsSync(path) ? statSync(path) : null;
68
+ if (!kind || !(kind.isFile() || kind.isFIFO()))
69
+ continue;
70
+ const parsed = parseEnvFile(readFileSync(path, 'utf8'));
71
+ perFile.push(parsed);
72
+ Object.assign(fromFiles, parsed);
73
+ files.unshift(file);
74
+ }
75
+ const alsoChecked = [];
76
+ const shellOnly = (name) => base[name] || undefined;
77
+ const shellThenFiles = (name) => base[name] || fromFiles[name];
78
+ for (const parsed of perFile) {
79
+ for (const [name, value] of Object.entries(parsed)) {
80
+ alsoChecked.push([name, value], [name, expand(value, shellOnly)], [name, expand(value, shellThenFiles)]);
81
+ }
82
+ }
83
+ const next = base === process.env ? nextEnvAt(root) : null;
84
+ if (next) {
85
+ const quiet = { info: () => undefined, error: () => undefined };
86
+ const loaded = next.loadEnvConfig(root, true, quiet, true);
87
+ const env = {};
88
+ for (const [name, value] of Object.entries(loaded.combinedEnv))
89
+ if (value !== undefined)
90
+ env[name] = value;
91
+ return { env, alsoChecked, files: loaded.loadedEnvFiles.map((f) => f.path), fromNext: true };
92
+ }
93
+ const env = {};
94
+ for (const [name, value] of Object.entries(fromFiles))
95
+ env[name] = expand(value, shellThenFiles);
96
+ for (const [name, value] of Object.entries(base))
97
+ if (value !== undefined)
98
+ env[name] = value;
99
+ return { env, alsoChecked, files, fromNext: false };
100
+ }
101
+ /** A host-shaped run of a value: labels of letters, digits and `-`, joined by dots (`ep-…-pooler.c-1.example`, `ep-…`). */
102
+ const HOST_TOKEN = /[a-z0-9-]+(?:\.[a-z0-9-]+)*/gi;
103
+ /** The value with each `%XX` escape decoded on its own, so one malformed `%` elsewhere cannot hide the rest. */
104
+ function decoded(value) {
105
+ return value.replace(/%([0-9a-f]{2})/gi, (_, hex) => String.fromCharCode(Number.parseInt(hex, 16)));
106
+ }
107
+ /**
108
+ * The production list entry a value names, or null. One pass over every host-shaped token of the value, as written and
109
+ * percent-decoded, so every connection-string shape counts: a URL (one host or several), a keyword/value string
110
+ * (`host=… dbname=…`), `PGHOST` (with a port), `PGOPTIONS endpoint=…`, a URL's `options=endpoint%3D…`.
111
+ */
112
+ export function productionEntryIn(value, productionHosts) {
113
+ for (const text of new Set([value, decoded(value)])) {
114
+ for (const token of text.matchAll(HOST_TOKEN)) {
115
+ const entry = productionHosts.find((e) => isProductionHost(token[0], [e]));
116
+ if (entry)
117
+ return entry.trim();
118
+ }
119
+ }
120
+ return null;
121
+ }
122
+ /**
123
+ * A list entry as the host it names: a URL (`postgres://u:p@db.prod.example:5432/app`) is read as one, and a host
124
+ * loses a port, a path and a leading dot. An entry that names no host throws, naming where it came from but not the
125
+ * entry itself (it may be a whole connection string).
126
+ */
127
+ export function productionHostEntry(entry, source = 'the production list') {
128
+ let host = entry.trim().toLowerCase();
129
+ if (host.includes('://')) {
130
+ try {
131
+ host = new URL(host).hostname;
132
+ }
133
+ catch {
134
+ host = '';
135
+ }
136
+ }
137
+ host = host.replace(/[/?#].*$/, '').replace(/:\d*$/, '').replace(/^\.+/, '');
138
+ if (!/^[a-z0-9-]+(?:\.[a-z0-9-]+)*$/.test(host)) {
139
+ throw new Error(`an entry of ${source} names no host (an entry is a host or an ep-… endpoint id)`);
140
+ }
141
+ return host;
142
+ }
143
+ /**
144
+ * The two variables that hold the guard's own command line (its `--host ep-…`): npm's `npm_lifecycle_script` and
145
+ * yarn's `npm_package_scripts_predev`. Every other `npm_*` variable is checked, and so is the `dev` script itself.
146
+ */
147
+ const GUARD_COMMAND_LINE = /^npm_(?:lifecycle_script|package_scripts_predev)$/i;
148
+ /**
149
+ * Every variable that names a production database, once each, by name: the variables of `env`, and the extra
150
+ * `[name, value]` pairs. The two variables that hold the guard's own command line are left out.
151
+ */
152
+ export function productionHits(env, productionHosts, allowVariables = [], alsoChecked = []) {
153
+ const hits = new Map();
154
+ for (const [variable, value] of [...Object.entries(env), ...alsoChecked]) {
155
+ if (!value || hits.has(variable) || allowVariables.includes(variable) || GUARD_COMMAND_LINE.test(variable))
156
+ continue;
157
+ const entry = productionEntryIn(value, productionHosts);
158
+ if (entry)
159
+ hits.set(variable, entry);
160
+ }
161
+ return [...hits].sort(([a], [b]) => a.localeCompare(b)).map(([variable, entry]) => ({ variable, entry }));
162
+ }
163
+ /** The variables of `.env.example` that are unset or blank, required and optional (documented ones, `# NAME=`, left out). */
164
+ export function unsetEntries(entries, env) {
165
+ const unset = entries.filter((e) => !e.commented && !env[e.name]?.trim());
166
+ const names = (list) => [...new Set(list.map((e) => e.name))].sort();
167
+ return { required: names(unset.filter((e) => e.required)), optional: names(unset.filter((e) => !e.required)) };
168
+ }
169
+ /** A production list file: a JSON array of hosts, or `{ "hosts": [...] }` (other keys, such as `_comment`, ignored). */
170
+ export function readProductionHostsFile(path) {
171
+ const value = JSON.parse(readFileSync(path, 'utf8'));
172
+ const list = Array.isArray(value) ? value : value?.hosts;
173
+ if (!Array.isArray(list) || !list.every((h) => typeof h === 'string')) {
174
+ throw new Error(`${path} must be a JSON array of hosts, or { "hosts": [...] }`);
175
+ }
176
+ return list;
177
+ }
178
+ /** `vercel env pull` of the production environment. */
179
+ const PULLS_PRODUCTION = /\bvercel\s+env\s+pull\b[^&|;]*(?:--environment[= ]+prod(?:uction)?\b|--prod\b)/i;
180
+ /** The `dev` script of the root's package.json, which runs after the guard and may set variables itself, or null. */
181
+ function devScriptOf(root) {
182
+ try {
183
+ const scripts = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')).scripts;
184
+ return typeof scripts?.dev === 'string' ? scripts.dev : null;
185
+ }
186
+ catch {
187
+ return null;
188
+ }
189
+ }
190
+ const USAGE = 'usage: tk-dev-guard --hosts <production-hosts.json> [--host <host>] [--example .env.example] [--allow-var NAME]';
191
+ const FLAGS = ['--hosts', '--host', '--example', '--allow-var'];
192
+ /**
193
+ * The whole guard, as a function: `tk-dev-guard` prints its lines and exits with its code. `ALLOW_PROD_DB=1` is read
194
+ * from the shell alone (`base`): a line in a `.env` file would make the override permanent.
195
+ */
196
+ export function runDevGuard(args, root, base = process.env) {
197
+ const lines = [];
198
+ // Read before the files are loaded: Next's loader writes them into process.env, which may be `base`.
199
+ const allowProduction = base.ALLOW_PROD_DB === '1';
200
+ try {
201
+ refuseUnknownOptions(args, FLAGS, USAGE);
202
+ const examplePath = optionValues(args, '--example', USAGE).at(-1) ?? '.env.example';
203
+ const { env, alsoChecked, files } = loadDevEnv(root, base);
204
+ const fromEnv = (env.PRODUCTION_DATABASE_HOSTS ?? '').split(',');
205
+ const listed = [
206
+ ...optionValues(args, '--hosts', USAGE).flatMap((f) => readProductionHostsFile(resolve(root, f)).map((h) => [h, `--hosts ${f}`])),
207
+ ...optionValues(args, '--host', USAGE).map((h) => [h, '--host']),
208
+ ...fromEnv.map((h) => [h, 'PRODUCTION_DATABASE_HOSTS']),
209
+ ];
210
+ const hosts = listed.filter(([h]) => h.trim() !== '').map(([h, source]) => productionHostEntry(h, source));
211
+ if (hosts.length === 0)
212
+ throw new Error(`no production database host to refuse: pass --hosts or --host\n${USAGE}`);
213
+ const allow = ['PRODUCTION_DATABASE_HOSTS', ...optionValues(args, '--allow-var', USAGE)];
214
+ const source = files.length > 0 ? files.join(', ') : 'no .env file';
215
+ let failed = false;
216
+ const devScript = devScriptOf(root);
217
+ const hits = productionHits(env, hosts, allow, devScript === null ? alsoChecked : [...alsoChecked, ['package.json scripts.dev', devScript]]);
218
+ if (devScript !== null && PULLS_PRODUCTION.test(devScript) && !allowProduction) {
219
+ failed = true;
220
+ lines.push('tk-dev-guard: refusing to start: the dev script pulls the PRODUCTION environment (vercel env pull --environment=production).');
221
+ }
222
+ if (hits.length > 0 && allowProduction) {
223
+ lines.push(`tk-dev-guard: WARNING ALLOW_PROD_DB=1: running against PRODUCTION (${hits.map((h) => h.variable).join(', ')}).`);
224
+ }
225
+ else if (hits.length > 0) {
226
+ failed = true;
227
+ lines.push('tk-dev-guard: refusing to start: the environment names a PRODUCTION database.', ...hits.map((h) => ` ${h.variable} names ${h.entry}`), ` Read from: the shell and ${source}.`, ' Point it at a development or test branch of your own. Never pull production variables onto a laptop.', ' Override, only for a deliberate production operation, in the shell: ALLOW_PROD_DB=1 npm run dev.');
228
+ }
229
+ let exampleText = null;
230
+ try {
231
+ exampleText = readFileSync(resolve(root, examplePath), 'utf8');
232
+ }
233
+ catch {
234
+ failed = true;
235
+ lines.push(`tk-dev-guard: ${examplePath} could not be read, so the required variables are unknown.`);
236
+ }
237
+ if (exampleText !== null) {
238
+ const entries = parseEnvExample(exampleText);
239
+ const { required: missing, optional } = unsetEntries(entries, env);
240
+ if (missing.length > 0) {
241
+ failed = true;
242
+ lines.push(`tk-dev-guard: required variables unset (see ${examplePath}):`, ...missing.map((name) => ` ${name}`));
243
+ }
244
+ if (optional.length > 0)
245
+ lines.push(`tk-dev-guard: ${optional.length} optional variables unset; the features that need them are off (${examplePath}).`);
246
+ }
247
+ if (!failed)
248
+ lines.push(`tk-dev-guard: ok: no production database (${hosts.length} refused), required variables set. Read from: the shell and ${source}.`);
249
+ return { code: failed ? 1 : 0, lines };
250
+ }
251
+ catch (error) {
252
+ return { code: 1, lines: [...lines, `tk-dev-guard: ${error.message}`] };
253
+ }
254
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Reading a repository's `.env.example`: which variables it names, which the app needs to start, and which the code
3
+ * reads in a way a scan cannot see.
4
+ *
5
+ * A variable is a line `NAME=…` (or `export NAME=…`). A line `# NAME=…` (one space at most after the `#`) names one
6
+ * too, documented but not to be set by hand (one the platform sets, such as `VERCEL_ENV`). Consecutive variable lines
7
+ * share the comment block above the first of them; a blank line ends a block. Two words in a block, in capitals, mark
8
+ * its variables:
9
+ *
10
+ * - `REQUIRED`, opening a comment line (`# REQUIRED — …`): the app does not start without it. `tk-dev-guard` lists a required variable that is unset and fails.
11
+ * - `UNSCANNED`: the code reads it in a way `tk-check-env-example` cannot see (a name held in a list, a tool reading it
12
+ * on its own), so the check does not call it unread.
13
+ */
14
+ export interface ExampleEntry {
15
+ name: string;
16
+ /** 1-based line number in the file. */
17
+ line: number;
18
+ /** `# NAME=`: documented, not set by hand. */
19
+ commented: boolean;
20
+ required: boolean;
21
+ unscanned: boolean;
22
+ }
23
+ export declare function parseEnvExample(text: string): ExampleEntry[];
24
+ /** The names the file lists more than once. */
25
+ export declare function duplicateNames(entries: readonly ExampleEntry[]): string[];
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Reading a repository's `.env.example`: which variables it names, which the app needs to start, and which the code
3
+ * reads in a way a scan cannot see.
4
+ *
5
+ * A variable is a line `NAME=…` (or `export NAME=…`). A line `# NAME=…` (one space at most after the `#`) names one
6
+ * too, documented but not to be set by hand (one the platform sets, such as `VERCEL_ENV`). Consecutive variable lines
7
+ * share the comment block above the first of them; a blank line ends a block. Two words in a block, in capitals, mark
8
+ * its variables:
9
+ *
10
+ * - `REQUIRED`, opening a comment line (`# REQUIRED — …`): the app does not start without it. `tk-dev-guard` lists a required variable that is unset and fails.
11
+ * - `UNSCANNED`: the code reads it in a way `tk-check-env-example` cannot see (a name held in a list, a tool reading it
12
+ * on its own), so the check does not call it unread.
13
+ */
14
+ /**
15
+ * `NAME=` / `export NAME=`, or, commented, `# NAME=` with at most one space after the `#` and none before the `=`: a
16
+ * prose line (`# REQUIRED = …`) or an indented command in a comment (`# NAME=dev/you`) is not a variable. A set
17
+ * variable may be in any case (`Api_Key=`), as the code may read one; a commented one is in capitals.
18
+ */
19
+ const VARIABLE = /^\s*(?:(#)[ \t]?(?:export\s+)?([A-Z][A-Z0-9_]*)=|(?:export\s+)?([A-Za-z][A-Za-z0-9_]*)\s*=)/;
20
+ export function parseEnvExample(text) {
21
+ const entries = [];
22
+ let block = [];
23
+ let afterVariable = false;
24
+ /** The quote a multi-line value opened and has not closed yet: its lines are the value, not variables. */
25
+ let openQuote = null;
26
+ text.split(/\r?\n/).forEach((raw, index) => {
27
+ if (openQuote !== null) {
28
+ if (raw.includes(openQuote))
29
+ openQuote = null;
30
+ return;
31
+ }
32
+ const line = raw.trim();
33
+ if (line === '') {
34
+ block = [];
35
+ afterVariable = false;
36
+ return;
37
+ }
38
+ const variable = VARIABLE.exec(raw);
39
+ if (variable) {
40
+ const text = block.join('\n');
41
+ entries.push({
42
+ name: (variable[2] ?? variable[3]),
43
+ line: index + 1,
44
+ commented: variable[1] !== undefined,
45
+ required: /^#\s*REQUIRED\b/m.test(text),
46
+ unscanned: /\bUNSCANNED\b/.test(text),
47
+ });
48
+ afterVariable = true;
49
+ if (variable[1] === undefined) {
50
+ const value = raw.slice(raw.indexOf('=') + 1).trim();
51
+ const quote = value[0];
52
+ if ((quote === '"' || quote === "'" || quote === '`') && !value.slice(1).includes(quote))
53
+ openQuote = quote;
54
+ }
55
+ return;
56
+ }
57
+ if (line.startsWith('#')) {
58
+ if (afterVariable)
59
+ block = [];
60
+ afterVariable = false;
61
+ block.push(line);
62
+ return;
63
+ }
64
+ // Any other line (a stray value) ends the block.
65
+ block = [];
66
+ afterVariable = false;
67
+ });
68
+ return entries;
69
+ }
70
+ /** The names the file lists more than once. */
71
+ export function duplicateNames(entries) {
72
+ const seen = new Set();
73
+ const twice = new Set();
74
+ for (const e of entries)
75
+ (seen.has(e.name) ? twice : seen).add(e.name);
76
+ return [...twice].sort();
77
+ }
@@ -0,0 +1,3 @@
1
+ export { parseEnvExample, duplicateNames, type ExampleEntry } from './example.ts';
2
+ export { envNamesInText, stringConstants, isScannedPath, scannedFiles, envReads, checkEnvExample, type EnvExampleCheck } from './scan.ts';
3
+ export { DEV_ENV_FILES, loadDevEnv, productionEntryIn, productionHits, unsetEntries, productionHostEntry, readProductionHostsFile, runDevGuard, type DevEnv, type ProductionHit, type DevGuardResult, } from './dev-guard.ts';
@@ -0,0 +1,3 @@
1
+ export { parseEnvExample, duplicateNames } from "./example.js";
2
+ export { envNamesInText, stringConstants, isScannedPath, scannedFiles, envReads, checkEnvExample } from "./scan.js";
3
+ export { DEV_ENV_FILES, loadDevEnv, productionEntryIn, productionHits, unsetEntries, productionHostEntry, readProductionHostsFile, runDevGuard, } from "./dev-guard.js";
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The variable names a source text reads, from its syntax tree (TypeScript's parser): comments, strings and regular
3
+ * expressions are never mistaken for code.
4
+ *
5
+ * A key may be a literal or a top-level string constant (`env[GOOGLE_CLIENT_ID_ENV]`), this file's or one in
6
+ * `constants` (the repository's, {@link stringConstants}).
7
+ *
8
+ * The environment is `process.env` (also `globalThis.process.env`, `process['env']`), `import.meta.env` (less Vite's
9
+ * own `DEV`, `MODE`, …), a name the file binds to one of those (`const e = process.env`), and any object named `env`
10
+ * (capitals only: `env.local` is not a variable; a file whose `env` is something else goes in `--exclude`). A read off
11
+ * it is a property (`.NAME`, `?.NAME`), an element with a literal key (`['NAME']`), an `in` test (`'NAME' in …`), or a
12
+ * destructuring (`const { NAME, OTHER: x }: T = …`, a parameter default, an assignment `({ NAME } = …)`). A method call
13
+ * (`process.env.toString()`) and a write (`process.env.TZ = …`, `delete process.env.X`) are not reads; a presence check
14
+ * (`Object.hasOwn(process.env, 'X')`, `Reflect.has(…)`, `.hasOwnProperty('X')`) is. `const { env: e } = process` makes
15
+ * `e` an alias.
16
+ */
17
+ export declare function envNamesInText(source: string, fileName?: string, constants?: ReadonlyMap<string, string>): string[];
18
+ /**
19
+ * The repository's top-level string constants, by name, so a read through a constant another file exports
20
+ * (`process.env[SECRET_ENV]`) is seen. A name two files define with different text resolves to nothing.
21
+ */
22
+ export declare function stringConstants(texts: ReadonlyArray<readonly [file: string, text: string]>): Map<string, string>;
23
+ /** Whether a repository-relative path is code the scan reads. */
24
+ export declare function isScannedPath(path: string, exclude?: readonly string[]): boolean;
25
+ /** The code files of the repository at `root`: tracked, or new and not ignored. */
26
+ export declare function scannedFiles(root: string, exclude?: readonly string[]): string[];
27
+ /** Each name read, with the first file that reads it. */
28
+ export declare function envReads(root: string, files: readonly string[]): Map<string, string>;
29
+ export interface EnvExampleCheck {
30
+ problems: string[];
31
+ files: number;
32
+ read: number;
33
+ listed: number;
34
+ }
35
+ /** Compares `.env.example` with what the code reads. Every problem names variables, never a value. */
36
+ export declare function checkEnvExample(root: string, example: string, exclude?: readonly string[]): EnvExampleCheck;
@@ -0,0 +1,327 @@
1
+ /**
2
+ * Which environment variables a repository's code reads, by name, and whether `.env.example` lists exactly those.
3
+ *
4
+ * The reads come from each file's syntax tree (see {@link envNamesInText}). A name built at run time
5
+ * (`process.env[name]`) is not seen: list it in `.env.example` under a comment saying `UNSCANNED`.
6
+ *
7
+ * The files are the repository's tracked code (`git ls-files`), less tests, fixtures, type declarations and the paths
8
+ * the caller excludes. Needs `typescript` installed (a peer dependency).
9
+ */
10
+ import { execFileSync } from 'node:child_process';
11
+ import { readFileSync } from 'node:fs';
12
+ import { createRequire } from 'node:module';
13
+ import { join } from 'node:path';
14
+ import { duplicateNames, parseEnvExample } from "./example.js";
15
+ const CAPITALS = /^[A-Z][A-Z0-9_]*$/;
16
+ const CODE = /\.(?:ts|tsx|js|jsx|mjs|cjs|mts|cts)$/;
17
+ const SKIP_SEGMENTS = new Set(['node_modules', '__tests__', '__mocks__', 'fixtures', '.next', 'dist', 'coverage']);
18
+ /** Test trees at the top of the repository only: `src/app/test/page.tsx` is a route. */
19
+ const SKIP_TOP = new Set(['test', 'tests', 'e2e']);
20
+ const SKIP_FILE = /\.(?:test|spec)\.[^/]+$|\.pw\.ts$|\.d\.ts$/;
21
+ /** npm's own variables and a runtime's internals (`__NEXT_…`) are not the app's to list. */
22
+ const NOT_THE_APPS = /^(?:npm_|_)/;
23
+ /** What Vite itself puts on `import.meta.env`. */
24
+ const VITE_BUILT_INS = new Set(['BASE_URL', 'DEV', 'MODE', 'PROD', 'SSR']);
25
+ /** TypeScript, loaded on first use, so `/env` imports (the guard) work where it is not installed. */
26
+ let tsModule = null;
27
+ function typescript() {
28
+ if (tsModule === null) {
29
+ try {
30
+ tsModule = createRequire(import.meta.url)('typescript');
31
+ }
32
+ catch {
33
+ throw new Error('tk-check-env-example needs typescript installed (a peer dependency of @catalystiq/test-kit)');
34
+ }
35
+ }
36
+ return tsModule;
37
+ }
38
+ /** Whether `name`, read off that object, is an environment variable. A bare `env` object counts in capitals only. */
39
+ function isVariable(kind, name) {
40
+ if (NOT_THE_APPS.test(name))
41
+ return false;
42
+ if (kind === 'bare')
43
+ return CAPITALS.test(name);
44
+ if (kind === 'meta')
45
+ return !VITE_BUILT_INS.has(name);
46
+ return true;
47
+ }
48
+ function scriptKind(ts, fileName) {
49
+ if (/\.tsx$/.test(fileName))
50
+ return ts.ScriptKind.TSX;
51
+ if (/\.[mc]?ts$/.test(fileName))
52
+ return ts.ScriptKind.TS;
53
+ return ts.ScriptKind.JSX;
54
+ }
55
+ /**
56
+ * The variable names a source text reads, from its syntax tree (TypeScript's parser): comments, strings and regular
57
+ * expressions are never mistaken for code.
58
+ *
59
+ * A key may be a literal or a top-level string constant (`env[GOOGLE_CLIENT_ID_ENV]`), this file's or one in
60
+ * `constants` (the repository's, {@link stringConstants}).
61
+ *
62
+ * The environment is `process.env` (also `globalThis.process.env`, `process['env']`), `import.meta.env` (less Vite's
63
+ * own `DEV`, `MODE`, …), a name the file binds to one of those (`const e = process.env`), and any object named `env`
64
+ * (capitals only: `env.local` is not a variable; a file whose `env` is something else goes in `--exclude`). A read off
65
+ * it is a property (`.NAME`, `?.NAME`), an element with a literal key (`['NAME']`), an `in` test (`'NAME' in …`), or a
66
+ * destructuring (`const { NAME, OTHER: x }: T = …`, a parameter default, an assignment `({ NAME } = …)`). A method call
67
+ * (`process.env.toString()`) and a write (`process.env.TZ = …`, `delete process.env.X`) are not reads; a presence check
68
+ * (`Object.hasOwn(process.env, 'X')`, `Reflect.has(…)`, `.hasOwnProperty('X')`) is. `const { env: e } = process` makes
69
+ * `e` an alias.
70
+ */
71
+ export function envNamesInText(source, fileName = 'file.ts', constants = new Map()) {
72
+ const ts = typescript();
73
+ const file = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, true, scriptKind(ts, fileName));
74
+ const own = topLevelStrings(ts, file);
75
+ const aliases = new Map();
76
+ const unwrapped = (node) => {
77
+ let n = node;
78
+ while (ts.isParenthesizedExpression(n) || ts.isNonNullExpression(n) || ts.isAsExpression(n) || ts.isSatisfiesExpression(n))
79
+ n = n.expression;
80
+ return n;
81
+ };
82
+ const literal = (node) => node && (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) ? node.text : undefined;
83
+ /** `process`, or `globalThis.process`. */
84
+ const isProcess = (node) => {
85
+ const n = unwrapped(node);
86
+ if (ts.isIdentifier(n))
87
+ return n.text === 'process';
88
+ return ts.isPropertyAccessExpression(n) && n.name.text === 'process' && ts.isIdentifier(n.expression) && n.expression.text === 'globalThis';
89
+ };
90
+ const envObject = (node) => {
91
+ const n = unwrapped(node);
92
+ if (ts.isIdentifier(n))
93
+ return aliases.get(n.text) ?? (n.text === 'env' ? 'bare' : null);
94
+ const key = ts.isPropertyAccessExpression(n) ? n.name.text : ts.isElementAccessExpression(n) ? literal(n.argumentExpression) : undefined;
95
+ if (key !== 'env' || !(ts.isPropertyAccessExpression(n) || ts.isElementAccessExpression(n)))
96
+ return null;
97
+ const owner = n.expression;
98
+ if (isProcess(owner))
99
+ return 'process';
100
+ if (ts.isMetaProperty(owner) && owner.keywordToken === ts.SyntaxKind.ImportKeyword && owner.name.text === 'meta')
101
+ return 'meta';
102
+ return null;
103
+ };
104
+ /** A method call on the object, an assignment to it, or a `delete` of it. */
105
+ const isNotARead = (node) => {
106
+ const parent = node.parent;
107
+ if (ts.isCallExpression(parent) && parent.expression === node)
108
+ return true;
109
+ if (ts.isDeleteExpression(parent))
110
+ return true;
111
+ // Only a plain `=` writes without reading: `??=`, `||=` and `+=` read the variable first.
112
+ return ts.isBinaryExpression(parent) && parent.left === node && parent.operatorToken.kind === ts.SyntaxKind.EqualsToken;
113
+ };
114
+ // Aliases first: `const e = process.env` makes `e.NAME` a read, for a name the file declares only once (there is no
115
+ // scope analysis, so a name declared twice, once as the environment and once as something else, is no alias).
116
+ const declared = new Map();
117
+ const candidates = new Map();
118
+ const collectAliases = (node) => {
119
+ if ((ts.isVariableDeclaration(node) || ts.isParameter(node)) && ts.isIdentifier(node.name)) {
120
+ declared.set(node.name.text, (declared.get(node.name.text) ?? 0) + 1);
121
+ const kind = ts.isVariableDeclaration(node) && node.initializer ? envObject(node.initializer) : null;
122
+ if (kind === 'process' || kind === 'meta')
123
+ candidates.set(node.name.text, kind);
124
+ }
125
+ if (ts.isBindingElement(node) && ts.isIdentifier(node.name)) {
126
+ declared.set(node.name.text, (declared.get(node.name.text) ?? 0) + 1);
127
+ // `const { env: e } = process` (or `globalThis.process`).
128
+ const declaration = node.parent.parent;
129
+ const key = node.propertyName ?? node.name;
130
+ if (ts.isIdentifier(key) && key.text === 'env' && ts.isVariableDeclaration(declaration) && declaration.initializer && isProcess(declaration.initializer)) {
131
+ candidates.set(node.name.text, 'process');
132
+ }
133
+ }
134
+ ts.forEachChild(node, collectAliases);
135
+ };
136
+ collectAliases(file);
137
+ for (const [name, kind] of candidates)
138
+ if (declared.get(name) === 1)
139
+ aliases.set(name, kind);
140
+ /** A literal key, or a named constant holding one (`env[GOOGLE_CLIENT_ID_ENV]`): this file's, else the repository's. */
141
+ const keyOf = (node) => {
142
+ const text = literal(node);
143
+ if (text !== undefined || !ts.isIdentifier(node))
144
+ return text;
145
+ // A name the file declares again (a parameter `KEY`, a local `const KEY`) is not the constant.
146
+ const declarations = declared.get(node.text) ?? 0;
147
+ if (own.has(node.text))
148
+ return declarations === 1 ? own.get(node.text) : undefined;
149
+ return declarations === 0 ? constants.get(node.text) : undefined;
150
+ };
151
+ const names = new Set();
152
+ const add = (kind, name) => {
153
+ if (kind !== null && name !== undefined && isVariable(kind, name))
154
+ names.add(name);
155
+ };
156
+ const keyText = (key) => {
157
+ if (key === undefined)
158
+ return undefined;
159
+ if (ts.isIdentifier(key) || ts.isStringLiteral(key))
160
+ return key.text;
161
+ if (ts.isComputedPropertyName(key))
162
+ return literal(key.expression);
163
+ return undefined;
164
+ };
165
+ const addPattern = (pattern, from) => {
166
+ if (!from || !ts.isObjectBindingPattern(pattern))
167
+ return;
168
+ const kind = envObject(from);
169
+ for (const element of pattern.elements)
170
+ if (!element.dotDotDotToken)
171
+ add(kind, keyText(element.propertyName ?? element.name));
172
+ };
173
+ /** `Object.hasOwn(env, 'X')`, `Reflect.has(env, 'X')`, `env.hasOwnProperty('X')`: a presence check is a read. */
174
+ const presenceCheck = (node) => {
175
+ const callee = unwrapped(node.expression);
176
+ if (!ts.isPropertyAccessExpression(callee))
177
+ return;
178
+ const method = callee.name.text;
179
+ const owner = unwrapped(callee.expression);
180
+ if (method === 'hasOwnProperty' || method === 'propertyIsEnumerable')
181
+ add(envObject(owner), literal(node.arguments[0]));
182
+ else if (ts.isIdentifier(owner) && ((owner.text === 'Object' && method === 'hasOwn') || (owner.text === 'Reflect' && method === 'has'))) {
183
+ const target = node.arguments[0];
184
+ if (target)
185
+ add(envObject(target), literal(node.arguments[1]));
186
+ }
187
+ };
188
+ const visit = (node) => {
189
+ if (ts.isCallExpression(node))
190
+ presenceCheck(node);
191
+ if (ts.isPropertyAccessExpression(node)) {
192
+ if (!isNotARead(node))
193
+ add(envObject(node.expression), node.name.text);
194
+ }
195
+ else if (ts.isElementAccessExpression(node)) {
196
+ if (!isNotARead(node))
197
+ add(envObject(node.expression), keyOf(node.argumentExpression));
198
+ }
199
+ else if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.InKeyword) {
200
+ add(envObject(node.right), keyOf(node.left));
201
+ }
202
+ else if (ts.isVariableDeclaration(node) || ts.isParameter(node)) {
203
+ addPattern(node.name, node.initializer);
204
+ }
205
+ else if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken && ts.isObjectLiteralExpression(unwrapped(node.left))) {
206
+ const kind = envObject(node.right);
207
+ for (const property of unwrapped(node.left).properties) {
208
+ if (ts.isShorthandPropertyAssignment(property) || ts.isPropertyAssignment(property))
209
+ add(kind, keyText(property.name));
210
+ }
211
+ }
212
+ ts.forEachChild(node, visit);
213
+ };
214
+ visit(file);
215
+ return [...names].sort();
216
+ }
217
+ /** A file's top-level `const NAME = 'text'` (exported or not, `as const` or not), by name. */
218
+ function topLevelStrings(ts, file) {
219
+ const found = new Map();
220
+ for (const statement of file.statements) {
221
+ if (!ts.isVariableStatement(statement) || !(statement.declarationList.flags & ts.NodeFlags.Const))
222
+ continue;
223
+ for (const declaration of statement.declarationList.declarations) {
224
+ let init = declaration.initializer;
225
+ while (init && (ts.isAsExpression(init) || ts.isSatisfiesExpression(init) || ts.isParenthesizedExpression(init)))
226
+ init = init.expression;
227
+ if (ts.isIdentifier(declaration.name) && init && (ts.isStringLiteral(init) || ts.isNoSubstitutionTemplateLiteral(init))) {
228
+ found.set(declaration.name.text, init.text);
229
+ }
230
+ }
231
+ }
232
+ return found;
233
+ }
234
+ /**
235
+ * The repository's top-level string constants, by name, so a read through a constant another file exports
236
+ * (`process.env[SECRET_ENV]`) is seen. A name two files define with different text resolves to nothing.
237
+ */
238
+ export function stringConstants(texts) {
239
+ const ts = typescript();
240
+ const found = new Map();
241
+ for (const [fileName, text] of texts) {
242
+ const file = ts.createSourceFile(fileName, text, ts.ScriptTarget.Latest, false, scriptKind(ts, fileName));
243
+ for (const [name, value] of topLevelStrings(ts, file))
244
+ found.set(name, found.has(name) && found.get(name) !== value ? null : value);
245
+ }
246
+ return new Map([...found].filter((entry) => entry[1] !== null));
247
+ }
248
+ /** Whether a repository-relative path is code the scan reads. */
249
+ export function isScannedPath(path, exclude = []) {
250
+ if (!CODE.test(path) || SKIP_FILE.test(path))
251
+ return false;
252
+ const segments = path.split('/');
253
+ if (SKIP_TOP.has(segments[0]) || segments.some((segment) => SKIP_SEGMENTS.has(segment)))
254
+ return false;
255
+ return !exclude.map(normalisedPrefix).some((prefix) => path === prefix || path.startsWith(`${prefix}/`));
256
+ }
257
+ /** `./scripts/one-off/` as git spells it: `scripts/one-off`. */
258
+ function normalisedPrefix(prefix) {
259
+ return prefix.replace(/^(?:\.\/)+/, '').replace(/\/+$/, '');
260
+ }
261
+ /** The code files of the repository at `root`: tracked, or new and not ignored. */
262
+ export function scannedFiles(root, exclude = []) {
263
+ // Tracked files and new ones git does not ignore, so a file not yet added is scanned too.
264
+ const listed = execFileSync('git', ['ls-files', '-z', '--cached', '--others', '--exclude-standard'], { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
265
+ const paths = listed
266
+ .split('\0')
267
+ .filter(Boolean)
268
+ .filter((path) => isScannedPath(path, exclude));
269
+ return [...new Set(paths)].sort();
270
+ }
271
+ /** Each name read, with the first file that reads it. */
272
+ export function envReads(root, files) {
273
+ const texts = [];
274
+ for (const file of files) {
275
+ try {
276
+ texts.push([file, readFileSync(join(root, file), 'utf8')]);
277
+ }
278
+ catch {
279
+ // listed by git but deleted in the working tree
280
+ }
281
+ }
282
+ const constants = stringConstants(texts);
283
+ const reads = new Map();
284
+ for (const [file, text] of texts)
285
+ for (const name of envNamesInText(text, file, constants))
286
+ if (!reads.has(name))
287
+ reads.set(name, file);
288
+ return reads;
289
+ }
290
+ /** Compares `.env.example` with what the code reads. Every problem names variables, never a value. */
291
+ export function checkEnvExample(root, example, exclude = []) {
292
+ let text;
293
+ try {
294
+ text = readFileSync(join(root, example), 'utf8');
295
+ }
296
+ catch {
297
+ return { problems: [`${example} does not exist`], files: 0, read: 0, listed: 0 };
298
+ }
299
+ const entries = parseEnvExample(text);
300
+ const all = scannedFiles(root);
301
+ const files = all.filter((path) => isScannedPath(path, exclude));
302
+ const reads = envReads(root, files);
303
+ const problems = [];
304
+ if (files.length === 0)
305
+ problems.push('the scan found no code files: the check would pass on nothing');
306
+ else if (reads.size === 0)
307
+ problems.push(`the scan found no environment read in ${files.length} files: the check would pass on nothing`);
308
+ if (entries.length === 0)
309
+ problems.push(`${example} names no variable`);
310
+ for (const prefix of exclude) {
311
+ if (all.every((path) => isScannedPath(path, [prefix])))
312
+ problems.push(`--exclude ${prefix} matches no scanned file`);
313
+ }
314
+ for (const name of duplicateNames(entries))
315
+ problems.push(`${name} is listed more than once in ${example}`);
316
+ const listed = new Map(entries.map((e) => [e.name, e]));
317
+ for (const [name, file] of [...reads].sort(([a], [b]) => a.localeCompare(b))) {
318
+ if (!listed.has(name))
319
+ problems.push(`${name} is read (${file}) but not listed in ${example}`);
320
+ }
321
+ for (const e of entries) {
322
+ if (!e.unscanned && !reads.has(e.name) && listed.get(e.name) === e) {
323
+ problems.push(`${e.name} is listed in ${example} (line ${e.line}) but no code reads it: remove it, or say UNSCANNED in its comment`);
324
+ }
325
+ }
326
+ return { problems, files: files.length, read: reads.size, listed: listed.size };
327
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@catalystiq/test-kit",
3
- "version": "0.2.0",
4
- "description": "Shared test setup for Vitest, a fake MCP Apps host for jsdom and Playwright, and the local Neon test-database run.",
3
+ "version": "0.3.0",
4
+ "description": "Shared test setup for Vitest, a fake MCP Apps host for jsdom and Playwright, the local Neon test-database run, and the local environment guard.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "sideEffects": true,
@@ -38,6 +38,10 @@
38
38
  "types": "./dist/mcp-apps-host/playwright.d.ts",
39
39
  "import": "./dist/mcp-apps-host/playwright.js"
40
40
  },
41
+ "./env": {
42
+ "types": "./dist/env/index.d.ts",
43
+ "import": "./dist/env/index.js"
44
+ },
41
45
  "./neon-test-db": {
42
46
  "types": "./dist/neon-test-db/neon-test-db.d.mts",
43
47
  "import": "./dist/neon-test-db/neon-test-db.mjs"
@@ -47,6 +51,8 @@
47
51
  "bin": {
48
52
  "tk-check-bundle": "dist/bin/check-bundle.js",
49
53
  "tk-check-allowlist": "dist/bin/check-allowlist.js",
54
+ "tk-dev-guard": "dist/bin/dev-guard.js",
55
+ "tk-check-env-example": "dist/bin/check-env-example.js",
50
56
  "tk-neon-test-db": "dist/neon-test-db/neon-test-db.mjs"
51
57
  },
52
58
  "files": [
@@ -67,7 +73,8 @@
67
73
  "@playwright/test": ">=1.50",
68
74
  "@testing-library/jest-dom": ">=6",
69
75
  "@testing-library/react": ">=16",
70
- "vitest": "^4.1.0"
76
+ "vitest": "^4.1.0",
77
+ "typescript": ">=5"
71
78
  },
72
79
  "peerDependenciesMeta": {
73
80
  "@playwright/test": {
@@ -81,6 +88,9 @@
81
88
  },
82
89
  "vitest": {
83
90
  "optional": true
91
+ },
92
+ "typescript": {
93
+ "optional": true
84
94
  }
85
95
  },
86
96
  "devDependencies": {