@catalystiq/test-kit 0.3.2 → 0.4.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,12 +1,14 @@
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.3.2"`);
3
+ Shared test helpers: one package, added to part by part. Pin an exact version (`"@catalystiq/test-kit": "0.4.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
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) |
10
+ | Database endpoint reader | `@catalystiq/test-kit/neon-endpoint` | Which database a connection string addresses, read as the clients resolve its host (percent-decoded, IDNA-mapped): a dependency-free module an app also imports at build and boot, fed with its own production and allowed endpoint ids. The two guards above keep their own reading (`src/setup/database.ts`). (`dist/neon-endpoint/README.md` in the package) |
11
+ | Install hardening check | `tk-check-install-scripts`, `@catalystiq/test-kit/install` | package.json `allowScripts` against the lockfile, `.npmrc`'s `min-release-age` and `strict-allow-scripts`, the lockfile's pins and the npm floor, saying precisely what is wrong. (`dist/install/README.md` in the package) |
10
12
  | 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) |
11
13
  | 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) |
12
14
 
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+ // tk-check-install-scripts [--root <dir>] [--run <package>]... : fails, saying what is wrong, when the app's install
3
+ // hardening (allowScripts, .npmrc, the lockfile's pins, the npm floor) does not hold. See src/install/index.ts.
4
+ import { runInstallCheck } from "../install/index.js";
5
+ const { code, lines } = runInstallCheck(process.argv.slice(2), process.cwd());
6
+ for (const line of lines)
7
+ (code === 0 ? console.log : console.error)(line);
8
+ process.exitCode = code;
@@ -36,8 +36,14 @@ It reads the environment `next dev` would see (the shell over `.env.development.
36
36
  - refuses to start when any variable (but the two that hold its own command line, `npm_lifecycle_script` and
37
37
  `npm_package_scripts_predev`), or the `dev` script's own text, names a production database, file values expanded as
38
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
39
+ endpoint id on the list (`ep-…`, with or without `-pooler`, so `PGHOST`, `PGOPTIONS`, a keyword/value string and a
40
+ URL's `?host=` and `options=endpoint…` (encoded once or twice) count too), or a host on the list. A host or an id counts in any spelling a client still resolves to it:
41
+ percent-encoded (`e%70-…`, UTF-8 escapes too), in fullwidth letters, digits, hyphens or full stops (`ep-…`), or with
42
+ a character a URL parser drops inside it (a soft hyphen, a zero-width space, a tab); a listed address in another
43
+ numeric form (`0x0a.0.0.5` for `10.0.0.5`) where a host stands (after `//`, a URL's `@`, `host=` or `hostaddr=`, or another host of a list; a number
44
+ elsewhere is a version or a count, so `web@10.0.5` names nothing and `PGHOST=167772165` is not read); a name in its own script or its `xn--` form, whichever the list holds; a
45
+ keyword/value string's backslash escape (`host=e\p-…`). It names the variable and the list entry, never the value; a
46
+ `.env` line with no variable name (a pasted connection string) is reported as such and never echoed. `ALLOW_PROD_DB=1` in
41
47
  the shell (never a `.env` file) turns the refusal into a warning, for a deliberate production operation;
42
48
  - fails while a `REQUIRED` variable of `.env.example` is unset, naming each, and counts the optional ones unset.
43
49
 
@@ -1,3 +1,4 @@
1
+ import { productionEntryIn } from '../setup/database.ts';
1
2
  import { type ExampleEntry } from './example.ts';
2
3
  /** The files `next dev` loads, most precedent first (`NODE_ENV=test`: the test set, without `.env.local`). */
3
4
  export declare const DEV_ENV_FILES: readonly [".env.development.local", ".env.local", ".env.development", ".env"];
@@ -22,12 +23,7 @@ export interface DevEnv {
22
23
  * and expanded against the shell alone and against the shell and the files (`alsoChecked`): a guard errs on refusing.
23
24
  */
24
25
  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;
26
+ export { productionEntryIn };
31
27
  /**
32
28
  * 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
29
  * loses a port, a path and a leading dot. An entry that names no host throws, naming where it came from but not the
@@ -38,6 +34,11 @@ export interface ProductionHit {
38
34
  variable: string;
39
35
  entry: string;
40
36
  }
37
+ /**
38
+ * What a refusal prints for a variable: its name when it is one. A `.env` line with no `NAME=` parses with everything
39
+ * before its `=` as the "name" (a pasted connection string's credentials included), and that is never echoed.
40
+ */
41
+ export declare function printableVariableName(name: string): string;
41
42
  /**
42
43
  * Every variable that names a production database, once each, by name: the variables of `env`, and the extra
43
44
  * `[name, value]` pairs. The two variables that hold the guard's own command line are left out.
@@ -7,14 +7,16 @@
7
7
  * value is also checked as written. Every variable is checked (but npm's command-line ones), not
8
8
  * one named database variable: a value names production when any host-shaped token in it is on the production list (a
9
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.
10
+ * `PGHOST`, `PGOPTIONS endpoint=…` and a URL's `options=endpoint%3D…` all count, and so does a host spelled as a client
11
+ * still resolves it (percent-encoded, in fullwidth letters, with a soft hyphen inside). A refusal names the variable and the
12
+ * matched list entry, never the value; a line with no variable name is never echoed.
12
13
  */
13
14
  import { existsSync, readFileSync, statSync } from 'node:fs';
14
15
  import { createRequire } from 'node:module';
15
16
  import { resolve } from 'node:path';
17
+ import { domainToASCII } from 'node:url';
16
18
  import { parseEnv } from 'node:util';
17
- import { isProductionHost } from "../setup/database.js";
19
+ import { NON_ASCII, productionEntryIn } from "../setup/database.js";
18
20
  import { optionValues, refuseUnknownOptions } from "./args.js";
19
21
  import { parseEnvExample } from "./example.js";
20
22
  /** The files `next dev` loads, most precedent first (`NODE_ENV=test`: the test set, without `.env.local`). */
@@ -98,27 +100,7 @@ export function loadDevEnv(root, base = process.env) {
98
100
  env[name] = value;
99
101
  return { env, alsoChecked, files, fromNext: false };
100
102
  }
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
- }
103
+ export { productionEntryIn };
122
104
  /**
123
105
  * 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
106
  * loses a port, a path and a leading dot. An entry that names no host throws, naming where it came from but not the
@@ -135,6 +117,9 @@ export function productionHostEntry(entry, source = 'the production list') {
135
117
  }
136
118
  }
137
119
  host = host.replace(/[/?#].*$/, '').replace(/:\d*$/, '').replace(/^\.+/, '');
120
+ // A name listed in another script is the `xn--` form a client resolves.
121
+ if (NON_ASCII.test(host))
122
+ host = domainToASCII(host);
138
123
  if (!/^[a-z0-9-]+(?:\.[a-z0-9-]+)*$/.test(host)) {
139
124
  throw new Error(`an entry of ${source} names no host (an entry is a host or an ep-… endpoint id)`);
140
125
  }
@@ -145,6 +130,17 @@ export function productionHostEntry(entry, source = 'the production list') {
145
130
  * yarn's `npm_package_scripts_predev`. Every other `npm_*` variable is checked, and so is the `dev` script itself.
146
131
  */
147
132
  const GUARD_COMMAND_LINE = /^npm_(?:lifecycle_script|package_scripts_predev)$/i;
133
+ /** The name the `dev` script's own text is checked under. */
134
+ const DEV_SCRIPT = 'package.json scripts.dev';
135
+ /**
136
+ * What a refusal prints for a variable: its name when it is one. A `.env` line with no `NAME=` parses with everything
137
+ * before its `=` as the "name" (a pasted connection string's credentials included), and that is never echoed.
138
+ */
139
+ export function printableVariableName(name) {
140
+ if (/^[A-Za-z_][A-Za-z0-9_.-]*$/.test(name) || name === DEV_SCRIPT)
141
+ return name;
142
+ return '(a line with no variable name)';
143
+ }
148
144
  /**
149
145
  * Every variable that names a production database, once each, by name: the variables of `env`, and the extra
150
146
  * `[name, value]` pairs. The two variables that hold the guard's own command line are left out.
@@ -158,7 +154,13 @@ export function productionHits(env, productionHosts, allowVariables = [], alsoCh
158
154
  if (entry)
159
155
  hits.set(variable, entry);
160
156
  }
161
- return [...hits].sort(([a], [b]) => a.localeCompare(b)).map(([variable, entry]) => ({ variable, entry }));
157
+ const named = new Map();
158
+ for (const [variable, entry] of [...hits].sort(([a], [b]) => a.localeCompare(b))) {
159
+ const name = printableVariableName(variable);
160
+ if (!named.has(name))
161
+ named.set(name, entry);
162
+ }
163
+ return [...named].map(([variable, entry]) => ({ variable, entry }));
162
164
  }
163
165
  /** The variables of `.env.example` that are unset or blank, required and optional (documented ones, `# NAME=`, left out). */
164
166
  export function unsetEntries(entries, env) {
@@ -214,7 +216,7 @@ export function runDevGuard(args, root, base = process.env) {
214
216
  const source = files.length > 0 ? files.join(', ') : 'no .env file';
215
217
  let failed = false;
216
218
  const devScript = devScriptOf(root);
217
- const hits = productionHits(env, hosts, allow, devScript === null ? alsoChecked : [...alsoChecked, ['package.json scripts.dev', devScript]]);
219
+ const hits = productionHits(env, hosts, allow, devScript === null ? alsoChecked : [...alsoChecked, [DEV_SCRIPT, devScript]]);
218
220
  if (devScript !== null && PULLS_PRODUCTION.test(devScript) && !allowProduction) {
219
221
  failed = true;
220
222
  lines.push('tk-dev-guard: refusing to start: the dev script pulls the PRODUCTION environment (vercel env pull --environment=production).');
@@ -0,0 +1,55 @@
1
+ # The install hardening check: `tk-check-install-scripts`
2
+
3
+ One check, for an app's own repository, of the install hardening every getcatalystiq app keeps. It says precisely what
4
+ is wrong, one line each, and what it could not check; a check that did not run is never reported as passed.
5
+
6
+ ```sh
7
+ tk-check-install-scripts --run canvas --min-scripts 6
8
+ ```
9
+
10
+ or from a test (it imports no test runner, so any runner can call it):
11
+
12
+ ```ts
13
+ import { checkInstallHardening } from '@catalystiq/test-kit/install';
14
+
15
+ it('the install hardening holds', () => {
16
+ expect(checkInstallHardening(process.cwd(), { run: ['canvas'], minScripts: 6 }).problems).toEqual([]);
17
+ });
18
+ ```
19
+
20
+ A CommonJS test (Jest) runs the bin: `execFileSync('node_modules/.bin/tk-check-install-scripts', ['--run-none'])`.
21
+
22
+ ## What it holds
23
+
24
+ | Rule | Checked from |
25
+ |---|---|
26
+ | package.json `allowScripts` names exactly the locked packages with an install script (`hasInstallScript`, workspace links left out), each `true` or `false`; no entry is stale. | `package.json`, `package-lock.json` |
27
+ | The entries that run (`true`) are exactly the reviewed list. | `run` / `--run`, `--run-none` |
28
+ | `.npmrc` sets `min-release-age=1` and `strict-allow-scripts=true`, and no `legacy-peer-deps`; a commented line sets nothing. | `.npmrc` |
29
+ | The npm on PATH knows both keys and takes them from the project's file (asked with an empty user and global config, and without npm's own exported `npm_config_*`), reads `legacy-peer-deps` as off, and refuses with `ESTRICTALLOWSCRIPTS`, before running it, an install whose tree has a script `allowScripts` does not name. | `npm config ls -l`, `npm config get`, an offline `npm install` in a temporary directory |
30
+ | The lockfile pins every package it fetches by registry URL and sha512 (the root, links and bundled packages are never fetched). | `package-lock.json` |
31
+ | The app's floor script admits the floor, the next minor and the next major, and refuses the patch and minor below, the major below, a string that is no version and a prerelease of an admitted version; a crash is not a refusal. | `node scripts/check-npm-version.mjs` under a stub `npm` |
32
+ | `vercel.json`'s `installCommand` runs the floor script, then `npm ci`. | `vercel.json` |
33
+ | npm is the package manager: `lockfileVersion` 3, `package-lock.json` tracked, no other manager's lockfile tracked, no `pnpm` key. | `git ls-files`, `package.json` |
34
+ | `packageManager` is `npm@X.Y.Z` at or above the floor and, under `.nvmrc`'s own Node, the npm in use. | `package.json`, `.nvmrc`, `npm --version` |
35
+
36
+ The floor script stays in the app: Vercel runs it before `npm ci`, when no package is installed.
37
+
38
+ ## Options
39
+
40
+ | Option | Flag | Default | For |
41
+ |---|---|---|---|
42
+ | `run` | `--run <package>`…, `--run-none` | not checked | Which scripts run. |
43
+ | `minScripts` | `--min-scripts <n>` | 1 | A lockfile with fewer install scripts proves nothing. |
44
+ | `requireDevDependencies` | `--require-dev <package>`… | none | `tsx`, so `npx tsx` installs nothing. |
45
+ | `requireScripts` | `--require-script <name>=<text>`… | none | A pinned `npx --allow-scripts=…` line. |
46
+ | `minReleaseAge` | `--min-release-age <days>` | 1 | |
47
+ | `allowLegacyPeerDeps` | `--allow-legacy-peer-deps` | false | |
48
+ | `lockfilePins`, `registry` | `--no-lockfile-pins`, `--registry <url>` | on, `https://registry.npmjs.org/` | |
49
+ | `npmFloor` | `--npm-floor <X.Y.Z>` | `11.19.1` | |
50
+ | `floorScript` | `--floor-script <path>`, `--no-floor-script` | `scripts/check-npm-version.mjs` | |
51
+ | `prereleaseBelowRelease` | `--no-prerelease-cases` | true | A floor script that reads versions with `parseInt` admits `11.19.1-rc.0`. |
52
+ | `installCommand` | `--install-command <text>`, `--no-install-command` | `node <floorScript> && npm ci` | |
53
+ | `packageManager` | `--package-manager path\|bundled\|declared` | `path` | `bundled`: the npm Node ships beside its binary, not one installed over it. On another Node than `.nvmrc`'s it is reported not checked. |
54
+ | `npmProbes` | `--npm-probes required\|when-supported\|off` | `required` | Under an npm below the floor: fail, or report the npm checks not checked. |
55
+ | root | `--root <dir>` | the working directory | |
@@ -0,0 +1,97 @@
1
+ export interface LockEntry {
2
+ name?: string;
3
+ hasInstallScript?: boolean;
4
+ link?: boolean;
5
+ inBundle?: boolean;
6
+ resolved?: string;
7
+ integrity?: string;
8
+ }
9
+ export interface Lock {
10
+ lockfileVersion?: number;
11
+ packages?: Record<string, LockEntry>;
12
+ }
13
+ export interface InstallCheckOptions {
14
+ /**
15
+ * The `allowScripts` entries that are `true`, exactly: `[]` when every script is skipped. Omitted, which ones run is
16
+ * not checked (each must still be `true` or `false`).
17
+ */
18
+ run?: readonly string[];
19
+ /** The fewest locked packages with an install script: a lockfile with none proves nothing. Default 1. */
20
+ minScripts?: number;
21
+ /** The `min-release-age` `.npmrc` sets, in days. Default 1. */
22
+ minReleaseAge?: number;
23
+ /** Let `.npmrc` set `legacy-peer-deps`. Default false: a peer conflict is fixed by the versions. */
24
+ allowLegacyPeerDeps?: boolean;
25
+ /** Hold every fetched package of the lockfile to a registry URL and a sha512. Default true. */
26
+ lockfilePins?: boolean;
27
+ /** The registry every `resolved` URL starts with. Default `https://registry.npmjs.org/`. */
28
+ registry?: string;
29
+ /** The oldest npm that reads `allowScripts`, `strict-allow-scripts` and `min-release-age`. Default `11.19.1`. */
30
+ npmFloor?: string;
31
+ /** The app's floor script, run before `npm ci`; `null` when the app has none. Default `scripts/check-npm-version.mjs`. */
32
+ floorScript?: string | null;
33
+ /** Hold the floor script to refusing a prerelease of an admitted version. Default true. */
34
+ prereleaseBelowRelease?: boolean;
35
+ /**
36
+ * `vercel.json`'s `installCommand`; `null` when the app does not deploy through one. Default
37
+ * `node <floorScript> && npm ci`.
38
+ */
39
+ installCommand?: string | null;
40
+ /**
41
+ * How `packageManager` is held to the npm in use, under `.nvmrc`'s own Node (on another Node it is reported skipped):
42
+ * `'path'` the npm on PATH, `'bundled'` the npm that Node ships beside its binary, `'declared'` neither (the field
43
+ * still names an npm the floor admits). Default `'path'`.
44
+ */
45
+ packageManager?: 'path' | 'bundled' | 'declared';
46
+ /** devDependencies that must exist (`tsx`, so `npx tsx` installs nothing). */
47
+ requireDevDependencies?: readonly string[];
48
+ /** package.json scripts held to an exact text (a pinned `npx --allow-scripts=…` line). */
49
+ requireScripts?: Readonly<Record<string, string>>;
50
+ /**
51
+ * The checks that ask npm itself: `'required'` fails when the npm on PATH is below the floor, `'when-supported'`
52
+ * reports them skipped there, `'off'` never runs them (reported skipped). Default `'required'`.
53
+ */
54
+ npmProbes?: 'required' | 'when-supported' | 'off';
55
+ /** The environment `npm`, `node` and `git` run with. Default `process.env`. */
56
+ env?: NodeJS.ProcessEnv;
57
+ }
58
+ export interface InstallCheck {
59
+ /** What is wrong, one line each; empty when the hardening holds. */
60
+ problems: string[];
61
+ /** The checks that did not run, and why. Never a pass. */
62
+ skipped: string[];
63
+ /** The locked packages with an install script. */
64
+ scripts: string[];
65
+ }
66
+ /** The names of the locked packages that run an install script. */
67
+ export declare function withInstallScripts(lock: Lock): string[];
68
+ /** What is wrong between a lockfile and an `allowScripts`, one line each. */
69
+ export declare function scriptProblems(lock: Lock, allow: Record<string, unknown>): string[];
70
+ /** The lockfile paths npm would fetch that are not pinned to `registry` and a sha512. */
71
+ export declare function unpinnedPackages(lock: Lock, registry?: string): string[];
72
+ /** `X.Y.Z` as numbers, or null: a prerelease (`11.19.1-rc.0`) is below its release, and anything else is no version. */
73
+ export declare function releaseVersion(version: string): [number, number, number] | null;
74
+ /** Whether `version` is a release at or above `floor`. */
75
+ export declare function atLeast(version: string, floor: string): boolean;
76
+ /** The versions a floor script must admit and refuse, from the floor itself. */
77
+ export declare function floorCases(floor: string, prereleases?: boolean): {
78
+ admitted: string[];
79
+ refused: string[];
80
+ };
81
+ /** The settings of an `.npmrc` text, by key, as written (comments and blank lines left out; the last one wins). */
82
+ export declare function npmrcSettings(text: string): Record<string, string>;
83
+ /** Every check, over the app at `root`. See the module header and {@link InstallCheckOptions}. */
84
+ export declare function checkInstallHardening(root: string, options?: InstallCheckOptions): InstallCheck;
85
+ export declare const INSTALL_CHECK_USAGE: string;
86
+ /** The bin's arguments as a root and options. Throws, with the usage, on an unknown flag or a value that is not one. */
87
+ export declare function parseInstallCheckArgs(args: readonly string[], cwd: string): {
88
+ root: string;
89
+ options: InstallCheckOptions;
90
+ };
91
+ export interface InstallCheckRun {
92
+ code: 0 | 1;
93
+ /** What to print: to stderr when `code` is 1, to stdout otherwise. */
94
+ lines: string[];
95
+ }
96
+ /** The whole bin, as a function. */
97
+ export declare function runInstallCheck(args: readonly string[], cwd: string, env?: NodeJS.ProcessEnv): InstallCheckRun;