postgres-mcp-hardened 0.1.1
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 +57 -0
- package/bin/cli.js +101 -0
- package/checksums.json +7 -0
- package/lib/install.js +136 -0
- package/package.json +54 -0
- package/scripts/postinstall.js +18 -0
- package/scripts/run-tests.js +55 -0
package/README.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# postgres-mcp-hardened
|
|
2
|
+
|
|
3
|
+
A maintained, read-only PostgreSQL MCP server — the drop-in replacement for
|
|
4
|
+
[`@modelcontextprotocol/server-postgres`](https://www.npmjs.com/package/@modelcontextprotocol/server-postgres),
|
|
5
|
+
which was deprecated by its authors and last released in December 2024.
|
|
6
|
+
|
|
7
|
+
Writes are refused by walking the parsed SQL, not by matching strings. Comments, dollar-quoting and
|
|
8
|
+
Unicode tricks do not survive the parse, so they cannot smuggle a statement past the check.
|
|
9
|
+
|
|
10
|
+
## Replace the deprecated server
|
|
11
|
+
|
|
12
|
+
```diff
|
|
13
|
+
{
|
|
14
|
+
"mcpServers": {
|
|
15
|
+
"postgres": {
|
|
16
|
+
- "command": "npx",
|
|
17
|
+
- "args": ["-y", "@modelcontextprotocol/server-postgres", "postgresql://localhost/mydb"]
|
|
18
|
+
+ "command": "npx",
|
|
19
|
+
+ "args": ["-y", "postgres-mcp-hardened", "--stdio"],
|
|
20
|
+
+ "env": { "DATABASE_URL": "postgres://readonly_user:PASSWORD@localhost:5432/mydb" }
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
The connection string moves from an argument to `DATABASE_URL` on purpose: arguments show up in
|
|
27
|
+
`ps` output and in shell history on a shared machine, and a database password does not belong there.
|
|
28
|
+
|
|
29
|
+
## What it does differently
|
|
30
|
+
|
|
31
|
+
- **Read-only is enforced twice.** The validator rejects anything that is not a read, and the
|
|
32
|
+
session runs in a `READ ONLY` transaction — so a gap in the first layer is not a breach.
|
|
33
|
+
- **Statement timeout and row caps** are set server-side, so a careless question cannot pin your
|
|
34
|
+
database or drag a million rows into a model's context.
|
|
35
|
+
- **Every query is written to an audit log** chained by hash, which survives a restart and makes
|
|
36
|
+
a deleted or truncated tail detectable.
|
|
37
|
+
- **Optional OAuth (RS256)** with audience and issuer enforced, for running it as a shared HTTP
|
|
38
|
+
endpoint rather than a local process.
|
|
39
|
+
- **Signed releases** — every artefact carries a Sigstore signature and a SHA-256, and this package
|
|
40
|
+
refuses to install a binary whose checksum does not match the one recorded at publish time.
|
|
41
|
+
|
|
42
|
+
## Install
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
npx -y postgres-mcp-hardened --stdio # no install
|
|
46
|
+
npm install -g postgres-mcp-hardened # or keep it around
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The package fetches a prebuilt binary for your platform from the matching GitHub release:
|
|
50
|
+
Linux (x64, arm64), macOS (Intel, Apple Silicon) and Windows (x64). Alpine/musl is not among them —
|
|
51
|
+
the Linux builds link against glibc; use the container image `ghcr.io/eszetael/postgres-mcp-hardened`
|
|
52
|
+
or build from source with `cargo build --release` in a clone.
|
|
53
|
+
|
|
54
|
+
Full documentation, configuration reference and the security model:
|
|
55
|
+
**https://github.com/Eszetael/postgres-mcp-hardened**
|
|
56
|
+
|
|
57
|
+
MIT licensed.
|
package/bin/cli.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// The launcher an MCP client actually spawns. It hands over to the native binary with execve-like
|
|
5
|
+
// semantics: same stdio, same exit code, no wrapper process interpreting the protocol.
|
|
6
|
+
//
|
|
7
|
+
// It also downloads the binary if it is missing. That is deliberate belt-and-braces: `postinstall`
|
|
8
|
+
// normally does it at install time, but plenty of environments run `npm install --ignore-scripts`
|
|
9
|
+
// (corporate policy, most CI defaults), and there the postinstall never fires. Without this branch
|
|
10
|
+
// those users would get "command not found" from a package that installed successfully — the kind
|
|
11
|
+
// of failure people do not report, they just leave.
|
|
12
|
+
|
|
13
|
+
const { spawn } = require('node:child_process');
|
|
14
|
+
const fs = require('node:fs');
|
|
15
|
+
const { ensureBinary, binaryPath } = require('../lib/install.js');
|
|
16
|
+
|
|
17
|
+
// Every flag the server understands, top-level and inside `--print-setup-sql`. Kept here because
|
|
18
|
+
// the server itself scans for the flags it knows and ignores the rest — so `--stdi` does not fail,
|
|
19
|
+
// it starts an HTTP listener instead of a stdio server. For someone wiring this into an MCP client
|
|
20
|
+
// that is a silent hang; on a shared machine it is an unintended open port. A test asserts this
|
|
21
|
+
// list against the Rust sources, so a new flag upstream breaks the build rather than a user's day.
|
|
22
|
+
const KNOWN = new Set([
|
|
23
|
+
'--stdio', '--validate', '--canon', '--fuzz', '--verify-audit', '--expect-last',
|
|
24
|
+
'--print-setup-sql', '--role', '--schemas', '--tables', '--redact', '--database', '--owner',
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
const USAGE = `postgres-mcp-hardened — read-only PostgreSQL MCP server
|
|
28
|
+
|
|
29
|
+
postgres-mcp-hardened --stdio speak MCP over stdin/stdout (what an MCP client spawns)
|
|
30
|
+
postgres-mcp-hardened serve Streamable HTTP on MCP_ADDR (default 127.0.0.1:8080)
|
|
31
|
+
|
|
32
|
+
--validate <sql> print the validator's verdict for one statement
|
|
33
|
+
--canon <sql> print the text that would actually reach the database
|
|
34
|
+
--verify-audit <path> [--expect-last <hash>] check the audit log's hash chain
|
|
35
|
+
--print-setup-sql [--role R] [--schemas S] [--tables T] [--redact C] [--database D] [--owner O]
|
|
36
|
+
print the SQL that creates a least-privilege role
|
|
37
|
+
--fuzz [iterations] [seed] deterministic validator fuzz; exits 1 on a violation
|
|
38
|
+
|
|
39
|
+
-h, --help this text
|
|
40
|
+
-V, --version package version
|
|
41
|
+
|
|
42
|
+
Configuration is environment-driven; DATABASE_URL is required to serve.
|
|
43
|
+
Full reference: https://github.com/Eszetael/postgres-mcp-hardened`;
|
|
44
|
+
|
|
45
|
+
function guard(argv) {
|
|
46
|
+
if (argv.includes('-h') || argv.includes('--help')) {
|
|
47
|
+
process.stdout.write(USAGE + '\n');
|
|
48
|
+
return { exit: 0 };
|
|
49
|
+
}
|
|
50
|
+
if (argv.includes('-V') || argv.includes('--version')) {
|
|
51
|
+
process.stdout.write(require('../package.json').version + '\n');
|
|
52
|
+
return { exit: 0 };
|
|
53
|
+
}
|
|
54
|
+
const unknown = argv.filter((a) => a.startsWith('--') && !KNOWN.has(a));
|
|
55
|
+
if (unknown.length) {
|
|
56
|
+
process.stderr.write(
|
|
57
|
+
`postgres-mcp-hardened: unknown option ${unknown.join(', ')}\n` +
|
|
58
|
+
`The server ignores options it does not recognise, so a typo in --stdio would have started ` +
|
|
59
|
+
`a network listener instead of a stdio server. Refusing.\n\n${USAGE}\n`);
|
|
60
|
+
return { exit: 2 };
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function main() {
|
|
66
|
+
const stop = guard(process.argv.slice(2));
|
|
67
|
+
if (stop) process.exit(stop.exit);
|
|
68
|
+
|
|
69
|
+
let bin = binaryPath();
|
|
70
|
+
if (!fs.existsSync(bin)) {
|
|
71
|
+
// stdio is the MCP transport: one stray byte on stdout and the client sees a malformed frame.
|
|
72
|
+
// Progress goes to stderr, which clients log and ignore.
|
|
73
|
+
bin = await ensureBinary();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const child = spawn(bin, process.argv.slice(2), { stdio: 'inherit' });
|
|
77
|
+
|
|
78
|
+
// Signals are forwarded rather than left to kill the launcher: an MCP client shutting a server
|
|
79
|
+
// down sends SIGTERM to the process it spawned, and if that is us, the native process would be
|
|
80
|
+
// orphaned and keep its database connections open.
|
|
81
|
+
for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
|
|
82
|
+
process.on(sig, () => { try { child.kill(sig); } catch { /* already gone */ } });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
child.on('error', (e) => {
|
|
86
|
+
process.stderr.write(`postgres-mcp-hardened: cannot start ${bin}\n${e.message}\n`);
|
|
87
|
+
process.exit(127);
|
|
88
|
+
});
|
|
89
|
+
// A process killed by a signal has no exit code; report it the way a shell does, so a supervisor
|
|
90
|
+
// can tell "crashed" from "exited non-zero".
|
|
91
|
+
child.on('exit', (code, signal) => process.exit(signal ? 128 + osSignalNumber(signal) : code ?? 0));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function osSignalNumber(sig) {
|
|
95
|
+
return { SIGHUP: 1, SIGINT: 2, SIGQUIT: 3, SIGKILL: 9, SIGTERM: 15 }[sig] ?? 0;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
main().catch((e) => {
|
|
99
|
+
process.stderr.write(`postgres-mcp-hardened: ${e.message}\n`);
|
|
100
|
+
process.exit(1);
|
|
101
|
+
});
|
package/checksums.json
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"postgres-mcp-hardened-aarch64-apple-darwin.tar.gz": "7d1dedc7f46a34f3863a8b3ab7dd34d6eba75e086f567e0874e2dae657d925e8",
|
|
3
|
+
"postgres-mcp-hardened-aarch64-unknown-linux-gnu.tar.gz": "1ce5543a4feaaedd13536a75895f29cf9fa9b498704e1c1a5b157f0b85ce570c",
|
|
4
|
+
"postgres-mcp-hardened-x86_64-apple-darwin.tar.gz": "5747fa27c728f35de77467bd9cd91c3f7f8598983bc3dfd8365bd222fd20d823",
|
|
5
|
+
"postgres-mcp-hardened-x86_64-unknown-linux-gnu.tar.gz": "5853be99322d5bdcbcc35d7a5cd6f5a4f687f336c1db68daea27a4eb638fcf2e",
|
|
6
|
+
"postgres-mcp-hardened-x86_64-pc-windows-msvc.zip": "f8ccaf6d08a6e16b4bdefb2b6fd81d1255b3fac1cdb5587921d593eca93ed6ee"
|
|
7
|
+
}
|
package/lib/install.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Fetches the prebuilt binary for this platform from the GitHub Release that matches this package
|
|
4
|
+
// version, verifies it against a checksum baked in at publish time, and unpacks it next to this file.
|
|
5
|
+
//
|
|
6
|
+
// Why the checksum is IN the package rather than fetched alongside the download: a checksum served
|
|
7
|
+
// from the same place as the artefact proves the transfer was not corrupted and nothing else. The
|
|
8
|
+
// list in `checksums.json` is written by the release workflow from the artefacts it just built, so
|
|
9
|
+
// it travels with npm's own integrity guarantee. If the two disagree, the download is wrong — and
|
|
10
|
+
// for a tool whose whole selling point is refusing unsafe input, "run it anyway" is not an option.
|
|
11
|
+
|
|
12
|
+
const fs = require('node:fs');
|
|
13
|
+
const os = require('node:os');
|
|
14
|
+
const path = require('node:path');
|
|
15
|
+
const crypto = require('node:crypto');
|
|
16
|
+
const { execFileSync } = require('node:child_process');
|
|
17
|
+
|
|
18
|
+
const REPO = 'Eszetael/postgres-mcp-hardened';
|
|
19
|
+
const BIN = 'postgres-mcp-hardened';
|
|
20
|
+
|
|
21
|
+
// npm's platform names on the left, Rust target triples on the right. Kept explicit rather than
|
|
22
|
+
// assembled from parts: a wrong triple would download an archive that unpacks fine and then dies
|
|
23
|
+
// with "Exec format error", which is a far worse message than "unsupported platform".
|
|
24
|
+
const TARGETS = {
|
|
25
|
+
'linux-x64': 'x86_64-unknown-linux-gnu',
|
|
26
|
+
'linux-arm64': 'aarch64-unknown-linux-gnu',
|
|
27
|
+
'darwin-x64': 'x86_64-apple-darwin',
|
|
28
|
+
'darwin-arm64': 'aarch64-apple-darwin',
|
|
29
|
+
'win32-x64': 'x86_64-pc-windows-msvc',
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const key = () => `${process.platform}-${process.arch}`;
|
|
33
|
+
|
|
34
|
+
function binaryPath() {
|
|
35
|
+
const exe = process.platform === 'win32' ? `${BIN}.exe` : BIN;
|
|
36
|
+
return path.join(__dirname, '..', 'vendor', exe);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function targetOrExplain() {
|
|
40
|
+
const t = TARGETS[key()];
|
|
41
|
+
if (t) return t;
|
|
42
|
+
const supported = Object.keys(TARGETS).join(', ');
|
|
43
|
+
throw new Error(
|
|
44
|
+
`No prebuilt binary for ${key()}. Supported: ${supported}.\n` +
|
|
45
|
+
`Build from source instead: cargo install --git https://github.com/${REPO}\n` +
|
|
46
|
+
`(Alpine/musl is not in that list either — the Linux builds are glibc. Use the container image: ` +
|
|
47
|
+
`ghcr.io/${REPO.toLowerCase()})`
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function expectedChecksum(archive) {
|
|
52
|
+
let sums;
|
|
53
|
+
try {
|
|
54
|
+
sums = require('../checksums.json');
|
|
55
|
+
} catch {
|
|
56
|
+
sums = null;
|
|
57
|
+
}
|
|
58
|
+
const sum = sums && sums[archive];
|
|
59
|
+
if (!sum) {
|
|
60
|
+
// Fail closed. An empty checksum list means the package was published without the release
|
|
61
|
+
// workflow filling it in — that is a broken publish, and silently trusting the download would
|
|
62
|
+
// turn one mistake into a supply-chain hole that nobody would ever notice.
|
|
63
|
+
throw new Error(
|
|
64
|
+
`No checksum recorded for ${archive}. This package was published incorrectly; ` +
|
|
65
|
+
`refusing to install an unverified binary. Please open an issue at https://github.com/${REPO}/issues`
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
return sum;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function download(url) {
|
|
72
|
+
const res = await fetch(url, { redirect: 'follow' });
|
|
73
|
+
if (!res.ok) {
|
|
74
|
+
throw new Error(`Download failed: ${res.status} ${res.statusText}\n ${url}`);
|
|
75
|
+
}
|
|
76
|
+
return Buffer.from(await res.arrayBuffer());
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function unpack(archivePath, into) {
|
|
80
|
+
fs.mkdirSync(into, { recursive: true });
|
|
81
|
+
try {
|
|
82
|
+
// bsdtar ships with macOS, every Linux, and Windows 10+ — and it reads .zip as well as .tar.gz,
|
|
83
|
+
// so one command covers all five targets.
|
|
84
|
+
execFileSync('tar', ['-xf', archivePath, '-C', into], { stdio: 'ignore' });
|
|
85
|
+
} catch (e) {
|
|
86
|
+
if (process.platform !== 'win32') throw e;
|
|
87
|
+
execFileSync('powershell', ['-NoProfile', '-Command',
|
|
88
|
+
`Expand-Archive -LiteralPath '${archivePath}' -DestinationPath '${into}' -Force`],
|
|
89
|
+
{ stdio: 'ignore' });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function ensureBinary({ quiet = false } = {}) {
|
|
94
|
+
const dest = binaryPath();
|
|
95
|
+
if (fs.existsSync(dest)) return dest;
|
|
96
|
+
|
|
97
|
+
const target = targetOrExplain();
|
|
98
|
+
const ext = process.platform === 'win32' ? 'zip' : 'tar.gz';
|
|
99
|
+
const archive = `${BIN}-${target}.${ext}`;
|
|
100
|
+
const version = require('../package.json').version;
|
|
101
|
+
const url = `https://github.com/${REPO}/releases/download/v${version}/${archive}`;
|
|
102
|
+
|
|
103
|
+
// Ask for the checksum BEFORE spending the bandwidth. If the list is missing an entry we are
|
|
104
|
+
// going to refuse anyway, and refusing after a 10 MB download only makes the failure slower.
|
|
105
|
+
const want = expectedChecksum(archive);
|
|
106
|
+
|
|
107
|
+
if (!quiet) process.stderr.write(`postgres-mcp-hardened: fetching ${archive} (v${version})\n`);
|
|
108
|
+
const buf = await download(url);
|
|
109
|
+
|
|
110
|
+
const got = crypto.createHash('sha256').update(buf).digest('hex');
|
|
111
|
+
if (got !== want) {
|
|
112
|
+
throw new Error(
|
|
113
|
+
`Checksum mismatch for ${archive}\n expected ${want}\n got ${got}\n` +
|
|
114
|
+
`Refusing to install. Report this at https://github.com/${REPO}/issues`
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pmh-'));
|
|
119
|
+
const archivePath = path.join(tmp, archive);
|
|
120
|
+
fs.writeFileSync(archivePath, buf);
|
|
121
|
+
unpack(archivePath, path.dirname(dest));
|
|
122
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
123
|
+
|
|
124
|
+
if (!fs.existsSync(dest)) {
|
|
125
|
+
throw new Error(`Archive ${archive} did not contain ${path.basename(dest)}`);
|
|
126
|
+
}
|
|
127
|
+
if (process.platform !== 'win32') fs.chmodSync(dest, 0o755);
|
|
128
|
+
return dest;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
module.exports = {
|
|
132
|
+
ensureBinary, binaryPath, TARGETS, REPO, BIN,
|
|
133
|
+
// Exported for the tests: both are refusal paths, and a refusal that is never exercised is a
|
|
134
|
+
// refusal nobody knows still works.
|
|
135
|
+
targetOrExplain, expectedChecksum, key,
|
|
136
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "postgres-mcp-hardened",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Secure read-only PostgreSQL MCP server in Rust — a maintained alternative to the deprecated @modelcontextprotocol/server-postgres. Blocks writes at the AST, not with regexes.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"mcp",
|
|
7
|
+
"model-context-protocol",
|
|
8
|
+
"postgres",
|
|
9
|
+
"postgresql",
|
|
10
|
+
"mcp-server",
|
|
11
|
+
"read-only",
|
|
12
|
+
"sql",
|
|
13
|
+
"ai",
|
|
14
|
+
"llm",
|
|
15
|
+
"claude",
|
|
16
|
+
"cursor",
|
|
17
|
+
"rust"
|
|
18
|
+
],
|
|
19
|
+
"homepage": "https://github.com/Eszetael/postgres-mcp-hardened#readme",
|
|
20
|
+
"bugs": "https://github.com/Eszetael/postgres-mcp-hardened/issues",
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/Eszetael/postgres-mcp-hardened.git",
|
|
24
|
+
"directory": "npm"
|
|
25
|
+
},
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"type": "commonjs",
|
|
28
|
+
"bin": {
|
|
29
|
+
"postgres-mcp-hardened": "bin/cli.js"
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"bin/",
|
|
33
|
+
"lib/",
|
|
34
|
+
"scripts/",
|
|
35
|
+
"checksums.json",
|
|
36
|
+
"README.md"
|
|
37
|
+
],
|
|
38
|
+
"scripts": {
|
|
39
|
+
"postinstall": "node scripts/postinstall.js",
|
|
40
|
+
"test": "node scripts/run-tests.js"
|
|
41
|
+
},
|
|
42
|
+
"engines": {
|
|
43
|
+
"node": ">=18.17"
|
|
44
|
+
},
|
|
45
|
+
"os": [
|
|
46
|
+
"linux",
|
|
47
|
+
"darwin",
|
|
48
|
+
"win32"
|
|
49
|
+
],
|
|
50
|
+
"cpu": [
|
|
51
|
+
"x64",
|
|
52
|
+
"arm64"
|
|
53
|
+
]
|
|
54
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Fetches the binary at install time so the first `npx` run is instant — an MCP client spawns the
|
|
4
|
+
// server and waits for a handshake, and a 10 MB download inside that window looks like a hang.
|
|
5
|
+
//
|
|
6
|
+
// It must never fail the install. Offline machines, proxies and `--ignore-scripts` all exist, and
|
|
7
|
+
// the launcher downloads on demand anyway; turning a recoverable situation into a failed
|
|
8
|
+
// `npm install` would be worse than the delay it avoids. So: explain, and exit 0.
|
|
9
|
+
|
|
10
|
+
const { ensureBinary } = require('../lib/install.js');
|
|
11
|
+
|
|
12
|
+
ensureBinary().catch((e) => {
|
|
13
|
+
process.stderr.write(
|
|
14
|
+
`postgres-mcp-hardened: could not fetch the binary now (${e.message.split('\n')[0]}).\n` +
|
|
15
|
+
`It will be fetched on first run instead.\n`
|
|
16
|
+
);
|
|
17
|
+
process.exit(0);
|
|
18
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// Uruchamia testy opakowania i ODMAWIA, gdy nie ma czego uruchomić.
|
|
5
|
+
//
|
|
6
|
+
// Dwa błędy naraz, oba znalezione 7.08 i oba tego samego rodzaju — ścieżka, której nikt nie wykonał.
|
|
7
|
+
//
|
|
8
|
+
// 1. `npm test` było ustawione na `node --test test/`. Na Node 22 ta forma nie uruchamia katalogu,
|
|
9
|
+
// tylko próbuje wczytać go jako moduł i pada z MODULE_NOT_FOUND. Czyli udokumentowana komenda
|
|
10
|
+
// projektu nie działała, podczas gdy same testy przechodziły 9/9 uruchomione na pliku. CI
|
|
11
|
+
// wywoływało dokładnie tę zepsutą formę i nigdy się o tym nie dowiedziało, bo zadanie `npm`
|
|
12
|
+
// zależy od `build`, a `build` padał od 28.07 na uprawnieniach.
|
|
13
|
+
//
|
|
14
|
+
// 2. Oczywista poprawka — samo `node --test` — ma gorszą wadę: w katalogu BEZ testów kończy się
|
|
15
|
+
// kodem 0. Zero testów wygląda wtedy identycznie jak komplet zdanych. Sprawdzone wprost.
|
|
16
|
+
//
|
|
17
|
+
// Stąd ten plik: wyszukaj pliki testów, odmów przy zerze, a dopiero potem uruchom. Wyszukiwanie
|
|
18
|
+
// jest rekurencyjne, więc nowy podkatalog nie wypadnie po cichu z pakietu.
|
|
19
|
+
|
|
20
|
+
const { readdirSync, statSync } = require('node:fs');
|
|
21
|
+
const { join, relative } = require('node:path');
|
|
22
|
+
const { spawnSync } = require('node:child_process');
|
|
23
|
+
|
|
24
|
+
const ROOT = join(__dirname, '..');
|
|
25
|
+
const TEST_DIR = join(ROOT, 'test');
|
|
26
|
+
|
|
27
|
+
function collect(dir) {
|
|
28
|
+
let out = [];
|
|
29
|
+
for (const name of readdirSync(dir)) {
|
|
30
|
+
const p = join(dir, name);
|
|
31
|
+
if (statSync(p).isDirectory()) out = out.concat(collect(p));
|
|
32
|
+
else if (/\.test\.(c|m)?js$/.test(name)) out.push(p);
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let files = [];
|
|
38
|
+
try {
|
|
39
|
+
files = collect(TEST_DIR);
|
|
40
|
+
} catch (err) {
|
|
41
|
+
console.error(`ODMOWA: nie da się odczytać ${relative(ROOT, TEST_DIR)} — ${err.message}`);
|
|
42
|
+
process.exit(3);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (files.length === 0) {
|
|
46
|
+
console.error('ODMOWA: nie znaleziono ani jednego pliku *.test.js.');
|
|
47
|
+
console.error('Zero testów nie jest sukcesem, a `node --test` w pustym katalogu kończy się zerem.');
|
|
48
|
+
process.exit(3);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
console.log(`uruchamiam ${files.length} plik(ów) testów:`);
|
|
52
|
+
for (const f of files) console.log(` ${relative(ROOT, f)}`);
|
|
53
|
+
|
|
54
|
+
const r = spawnSync(process.execPath, ['--test', ...files], { stdio: 'inherit', cwd: ROOT });
|
|
55
|
+
process.exit(r.status === null ? 1 : r.status);
|