@gatedpath/preflight 0.1.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/LICENSE +21 -0
- package/README.md +189 -0
- package/dist/bip39-words.d.ts +1 -0
- package/dist/bip39-words.js +132 -0
- package/dist/checks/admin.d.ts +24 -0
- package/dist/checks/admin.js +81 -0
- package/dist/checks/balance.d.ts +3 -0
- package/dist/checks/balance.js +39 -0
- package/dist/checks/chain-id.d.ts +2 -0
- package/dist/checks/chain-id.js +11 -0
- package/dist/checks/compiler.d.ts +5 -0
- package/dist/checks/compiler.js +20 -0
- package/dist/checks/deployer.d.ts +14 -0
- package/dist/checks/deployer.js +53 -0
- package/dist/checks/git-secrets.d.ts +14 -0
- package/dist/checks/git-secrets.js +91 -0
- package/dist/checks/slither.d.ts +11 -0
- package/dist/checks/slither.js +119 -0
- package/dist/cli.d.ts +8 -0
- package/dist/cli.js +131 -0
- package/dist/config.d.ts +25 -0
- package/dist/config.js +197 -0
- package/dist/doctor-cli.d.ts +9 -0
- package/dist/doctor-cli.js +76 -0
- package/dist/doctor.d.ts +60 -0
- package/dist/doctor.js +251 -0
- package/dist/dotenv.d.ts +7 -0
- package/dist/dotenv.js +28 -0
- package/dist/gas-cli.d.ts +8 -0
- package/dist/gas-cli.js +100 -0
- package/dist/gas.d.ts +110 -0
- package/dist/gas.js +221 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.js +20 -0
- package/dist/proc.d.ts +14 -0
- package/dist/proc.js +28 -0
- package/dist/redbelly.d.ts +2 -0
- package/dist/redbelly.js +40 -0
- package/dist/run.d.ts +9 -0
- package/dist/run.js +106 -0
- package/dist/secrets.d.ts +18 -0
- package/dist/secrets.js +89 -0
- package/dist/ship-cli.d.ts +8 -0
- package/dist/ship-cli.js +128 -0
- package/dist/ship.d.ts +108 -0
- package/dist/ship.js +383 -0
- package/dist/suites.d.ts +26 -0
- package/dist/suites.js +53 -0
- package/dist/types.d.ts +58 -0
- package/dist/types.js +3 -0
- package/dist/well-known.d.ts +2 -0
- package/dist/well-known.js +21 -0
- package/package.json +50 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Who deploys, and may they? The address comes from `cast wallet address --account NAME`
|
|
2
|
+
// (cast decrypts the keystore locally; this process only sees the address) or from
|
|
3
|
+
// --address for a read-only run. Never from a key.
|
|
4
|
+
import { isAllowed } from '@gatedpath/chains';
|
|
5
|
+
import { ACCOUNT_NAME, run } from '../proc.js';
|
|
6
|
+
import { isWellKnownDevAccount } from '../well-known.js';
|
|
7
|
+
export async function resolveDeployer(src) {
|
|
8
|
+
if (src.address) {
|
|
9
|
+
if (!/^0x[0-9a-fA-F]{40}$/.test(src.address))
|
|
10
|
+
return { address: null, how: '--address', error: `"${src.address}" is not a 20-byte hex address.` };
|
|
11
|
+
return { address: src.address, how: '--address' };
|
|
12
|
+
}
|
|
13
|
+
if (src.account) {
|
|
14
|
+
if (!ACCOUNT_NAME.test(src.account))
|
|
15
|
+
return { address: null, how: '--account', error: 'Keystore names may only use letters, digits, dot, dash and underscore.' };
|
|
16
|
+
const args = ['wallet', 'address', '--account', src.account];
|
|
17
|
+
if (src.passwordFile)
|
|
18
|
+
args.push('--password-file', src.passwordFile);
|
|
19
|
+
const r = await run('cast', args, { cwd: src.cwd, env: src.env, timeoutMs: 60_000, stdin: '' });
|
|
20
|
+
if (r.spawnError)
|
|
21
|
+
return { address: null, how: '--account', error: `cast is not installed or not on PATH (${r.spawnError}); install Foundry or pass --address for a read-only run.` };
|
|
22
|
+
const m = /0x[0-9a-fA-F]{40}/.exec(r.stdout);
|
|
23
|
+
if (r.code !== 0 || !m)
|
|
24
|
+
return { address: null, how: '--account', error: `cast wallet address --account ${src.account} failed: ${(r.stderr || r.stdout).trim().split('\n').pop() ?? 'no output'}.` };
|
|
25
|
+
return { address: m[0], how: `cast wallet address --account ${src.account}` };
|
|
26
|
+
}
|
|
27
|
+
return { address: null, how: 'none' };
|
|
28
|
+
}
|
|
29
|
+
export async function checkDeployerVerified(address, how, error, rpc, chain) {
|
|
30
|
+
if (error)
|
|
31
|
+
return { id: 'deployer-verified', status: 'fail', reason: error };
|
|
32
|
+
if (!address)
|
|
33
|
+
return { id: 'deployer-verified', status: 'skip', reason: 'No deployer given; pass --account <keystore-name> or --address <0x…>.' };
|
|
34
|
+
if (chain !== 151 && chain !== 153)
|
|
35
|
+
return { id: 'deployer-verified', status: 'skip', reason: 'The RPC is not a Redbelly chain, so there is no permission contract to ask.', data: { address } };
|
|
36
|
+
try {
|
|
37
|
+
const allowed = await isAllowed(address, { rpc });
|
|
38
|
+
if (isWellKnownDevAccount(address)) {
|
|
39
|
+
return {
|
|
40
|
+
id: 'deployer-verified',
|
|
41
|
+
status: 'warn',
|
|
42
|
+
reason: `${address} (${how}) is one of the ten anvil/Hardhat default accounts whose private keys are public; it ${allowed ? 'passes' : 'fails'} isAllowed on chain ${chain}, but a deploy from it runs under an identity you do not control (RESEARCH.md question 30). Use a keystore of your own.`,
|
|
43
|
+
data: { address, allowed, how, wellKnownDevAccount: true },
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
if (allowed)
|
|
47
|
+
return { id: 'deployer-verified', status: 'pass', reason: `${address} (${how}) passes permission.isAllowed on chain ${chain}.`, data: { address, allowed, how } };
|
|
48
|
+
return { id: 'deployer-verified', status: 'fail', reason: `${address} (${how}) fails permission.isAllowed on chain ${chain}; verify it at https://access.redbelly.network before deploying.`, data: { address, allowed, how } };
|
|
49
|
+
}
|
|
50
|
+
catch (e) {
|
|
51
|
+
return { id: 'deployer-verified', status: 'fail', reason: `Could not read permission.isAllowed: ${e.message}.`, data: { address, how } };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { CheckResult } from '../types.js';
|
|
2
|
+
export interface SecretFinding {
|
|
3
|
+
commit: string;
|
|
4
|
+
file: string;
|
|
5
|
+
kind: string;
|
|
6
|
+
why: string;
|
|
7
|
+
}
|
|
8
|
+
export declare function scanGitHistory(project: string): Promise<{
|
|
9
|
+
findings: SecretFinding[];
|
|
10
|
+
commits: number;
|
|
11
|
+
isRepo: boolean;
|
|
12
|
+
trackedEnv: string[];
|
|
13
|
+
}>;
|
|
14
|
+
export declare function checkGitSecrets(project: string): Promise<CheckResult>;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Scans every added line in the whole git history for secret-shaped text. Streams
|
|
2
|
+
// `git log -p --all --unified=0` so nothing is held in memory and a large history still
|
|
3
|
+
// finishes in seconds. Only `+` lines count: a deleted secret was added earlier, and that
|
|
4
|
+
// addition is what gets flagged. Reports file, commit and kind; never the text.
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
6
|
+
import { createInterface } from 'node:readline';
|
|
7
|
+
import { scanLine } from '../secrets.js';
|
|
8
|
+
const IGNORED_PATHS = /(^|\/)(node_modules|lib|out|cache|artifacts|typechain(-types)?)\/|(^|\/)(package-lock\.json|yarn\.lock|pnpm-lock\.yaml|bun\.lockb?|\.gas-snapshot)$|\.(min\.js|map|svg|png|jpg|pdf)$/;
|
|
9
|
+
const MAX_FINDINGS = 25;
|
|
10
|
+
export async function scanGitHistory(project) {
|
|
11
|
+
const isRepo = await run('git', ['rev-parse', '--is-inside-work-tree'], project).then((r) => r.code === 0 && r.stdout.trim() === 'true');
|
|
12
|
+
if (!isRepo)
|
|
13
|
+
return { findings: [], commits: 0, isRepo: false, trackedEnv: [] };
|
|
14
|
+
const tracked = await run('git', ['ls-files', '--', '.env', '.env.*', '**/.env', '**/.env.*'], project);
|
|
15
|
+
const trackedEnv = tracked.stdout.split('\n').map((s) => s.trim()).filter((f) => f && !/\.env\.(example|sample|template)$/.test(f));
|
|
16
|
+
const findings = [];
|
|
17
|
+
const seen = new Set();
|
|
18
|
+
let commits = 0;
|
|
19
|
+
await new Promise((resolve, reject) => {
|
|
20
|
+
const child = spawn('git', ['log', '-p', '--all', '--no-color', '--unified=0', '--no-renames', '--format=commit %H', '--', '.'], { cwd: project, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
21
|
+
let commit = '';
|
|
22
|
+
let file = '';
|
|
23
|
+
let skipFile = false;
|
|
24
|
+
const rl = createInterface({ input: child.stdout });
|
|
25
|
+
rl.on('line', (line) => {
|
|
26
|
+
if (line.startsWith('commit ')) {
|
|
27
|
+
commit = line.slice(7, 47);
|
|
28
|
+
commits++;
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (line.startsWith('+++ ')) {
|
|
32
|
+
file = line.slice(4).replace(/^b\//, '');
|
|
33
|
+
skipFile = IGNORED_PATHS.test(file);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (line.startsWith('--- ') || line.startsWith('diff ') || line.startsWith('@@') || skipFile)
|
|
37
|
+
return;
|
|
38
|
+
if (line.startsWith('+')) {
|
|
39
|
+
for (const m of scanLine(line.slice(1))) {
|
|
40
|
+
const key = `${file}:${m.kind}`;
|
|
41
|
+
if (seen.has(key))
|
|
42
|
+
continue;
|
|
43
|
+
seen.add(key);
|
|
44
|
+
if (findings.length < MAX_FINDINGS)
|
|
45
|
+
findings.push({ commit, file, kind: m.kind, why: m.why });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
let stderr = '';
|
|
50
|
+
child.stderr.on('data', (d) => (stderr += d));
|
|
51
|
+
child.on('error', reject);
|
|
52
|
+
child.on('close', (code) => (code === 0 || code === null ? resolve() : reject(new Error(`git log exited ${code}: ${stderr.trim()}`))));
|
|
53
|
+
});
|
|
54
|
+
return { findings, commits, isRepo: true, trackedEnv };
|
|
55
|
+
}
|
|
56
|
+
export async function checkGitSecrets(project) {
|
|
57
|
+
let scan;
|
|
58
|
+
try {
|
|
59
|
+
scan = await scanGitHistory(project);
|
|
60
|
+
}
|
|
61
|
+
catch (e) {
|
|
62
|
+
return { id: 'git-secrets', status: 'fail', reason: `Could not scan git history: ${e.message}.` };
|
|
63
|
+
}
|
|
64
|
+
if (!scan.isRepo)
|
|
65
|
+
return { id: 'git-secrets', status: 'skip', reason: 'Not a git repository, so there is no history to scan.' };
|
|
66
|
+
const data = { commits: scan.commits, findings: scan.findings, trackedEnvFiles: scan.trackedEnv };
|
|
67
|
+
if (scan.findings.length > 0) {
|
|
68
|
+
const first = scan.findings[0];
|
|
69
|
+
return {
|
|
70
|
+
id: 'git-secrets',
|
|
71
|
+
status: 'fail',
|
|
72
|
+
reason: `${scan.findings.length} secret-shaped addition${scan.findings.length === 1 ? '' : 's'} in ${scan.commits} commits, first in ${first.file} at ${first.commit.slice(0, 10)} (${first.why}); rotate the key, then rewrite history or start a new repository.`,
|
|
73
|
+
data,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
if (scan.trackedEnv.length > 0) {
|
|
77
|
+
return { id: 'git-secrets', status: 'fail', reason: `${scan.trackedEnv.join(', ')} is tracked by git; a .env file belongs in .gitignore even when it holds no key today.`, data };
|
|
78
|
+
}
|
|
79
|
+
return { id: 'git-secrets', status: 'pass', reason: `No secret-shaped line added in ${scan.commits} commits, and no .env file is tracked.`, data };
|
|
80
|
+
}
|
|
81
|
+
function run(cmd, args, cwd) {
|
|
82
|
+
return new Promise((resolve) => {
|
|
83
|
+
const child = spawn(cmd, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
84
|
+
let stdout = '';
|
|
85
|
+
let stderr = '';
|
|
86
|
+
child.stdout.on('data', (d) => (stdout += d));
|
|
87
|
+
child.stderr.on('data', (d) => (stderr += d));
|
|
88
|
+
child.on('error', (e) => resolve({ code: null, stdout, stderr: e.message }));
|
|
89
|
+
child.on('close', (code) => resolve({ code, stdout, stderr }));
|
|
90
|
+
});
|
|
91
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { CheckResult } from '../types.js';
|
|
2
|
+
export declare const DEFAULT_REPORT_PATHS: string[];
|
|
3
|
+
/** Sidecar written next to the report: the hash of the sources Slither saw. */
|
|
4
|
+
export declare const SOURCES_HASH_FILE = "slither.sources.sha256";
|
|
5
|
+
/**
|
|
6
|
+
* SHA-256 over every `.sol` file under `dir`, in sorted order of its POSIX relative path:
|
|
7
|
+
* for each file, `<relative path>\n`, the file's bytes, then a NUL byte. The scaffold's
|
|
8
|
+
* `scripts/slither.mjs` computes the same value with the same recipe; keep them in step.
|
|
9
|
+
*/
|
|
10
|
+
export declare function hashSources(dir: string): string;
|
|
11
|
+
export declare function checkSlitherReport(project: string, sourceDir: string, reportPath?: string): Promise<CheckResult>;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Does a Slither report exist, and does it describe the sources as they are now? Two ways to
|
|
2
|
+
// answer. Preferred: a sidecar `slither.sources.sha256` next to the report holding the hash
|
|
3
|
+
// of every .sol file under the source directory at the time Slither ran (the scaffold's
|
|
4
|
+
// `npm run lint:slither` writes it); the check recomputes the hash and compares. Fallback,
|
|
5
|
+
// when there is no sidecar: times. Checked-out files all share one mtime, so inside a git
|
|
6
|
+
// repository "when" means the commit time of the last change, with the working-tree mtime
|
|
7
|
+
// taken instead for files that are modified or untracked; outside git, plain mtimes. The
|
|
8
|
+
// time rule cannot be satisfied by re-running Slither on a clean project, because a report
|
|
9
|
+
// with no findings is byte-identical and git records no new change for it (found on
|
|
10
|
+
// 2026-09-12 running the prompt cards), which is why the hash sidecar exists.
|
|
11
|
+
import { execFileSync } from 'node:child_process';
|
|
12
|
+
import { createHash } from 'node:crypto';
|
|
13
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
14
|
+
import { dirname, join, relative, sep } from 'node:path';
|
|
15
|
+
import { run } from '../proc.js';
|
|
16
|
+
export const DEFAULT_REPORT_PATHS = ['reports/slither.json', 'reports/slither.md', 'slither.json', 'slither-report.json', 'slither.md'];
|
|
17
|
+
/** Sidecar written next to the report: the hash of the sources Slither saw. */
|
|
18
|
+
export const SOURCES_HASH_FILE = 'slither.sources.sha256';
|
|
19
|
+
/**
|
|
20
|
+
* SHA-256 over every `.sol` file under `dir`, in sorted order of its POSIX relative path:
|
|
21
|
+
* for each file, `<relative path>\n`, the file's bytes, then a NUL byte. The scaffold's
|
|
22
|
+
* `scripts/slither.mjs` computes the same value with the same recipe; keep them in step.
|
|
23
|
+
*/
|
|
24
|
+
export function hashSources(dir) {
|
|
25
|
+
const files = listFiles(dir)
|
|
26
|
+
.filter((f) => f.endsWith('.sol'))
|
|
27
|
+
.map((f) => ({ rel: relative(dir, f).split(sep).join('/'), abs: f }))
|
|
28
|
+
.sort((a, b) => (a.rel < b.rel ? -1 : a.rel > b.rel ? 1 : 0));
|
|
29
|
+
const h = createHash('sha256');
|
|
30
|
+
for (const f of files) {
|
|
31
|
+
h.update(`${f.rel}\n`);
|
|
32
|
+
h.update(readFileSync(f.abs));
|
|
33
|
+
h.update('\0');
|
|
34
|
+
}
|
|
35
|
+
return h.digest('hex');
|
|
36
|
+
}
|
|
37
|
+
export async function checkSlitherReport(project, sourceDir, reportPath) {
|
|
38
|
+
const candidates = reportPath ? [reportPath] : DEFAULT_REPORT_PATHS;
|
|
39
|
+
const found = candidates.map((p) => join(project, p)).find((p) => existsSync(p));
|
|
40
|
+
if (!found) {
|
|
41
|
+
return { id: 'slither-report', status: 'fail', reason: `No Slither report at ${candidates.join(', ')}; run slither and commit the report before deploying.`, data: { looked: candidates } };
|
|
42
|
+
}
|
|
43
|
+
const srcAbs = join(project, sourceDir);
|
|
44
|
+
if (!existsSync(srcAbs))
|
|
45
|
+
return { id: 'slither-report', status: 'fail', reason: `Source directory ${sourceDir} does not exist under ${project}.`, data: { report: found } };
|
|
46
|
+
const sources = listFiles(srcAbs).filter((f) => f.endsWith('.sol'));
|
|
47
|
+
if (sources.length === 0)
|
|
48
|
+
return { id: 'slither-report', status: 'fail', reason: `No .sol files under ${sourceDir}.`, data: { report: found } };
|
|
49
|
+
const sidecar = join(dirname(found), SOURCES_HASH_FILE);
|
|
50
|
+
if (existsSync(sidecar)) {
|
|
51
|
+
const recorded = readFileSync(sidecar, 'utf8').trim().toLowerCase();
|
|
52
|
+
const current = hashSources(srcAbs);
|
|
53
|
+
const data = { report: relative(project, found), sidecar: relative(project, sidecar), recordedHash: recorded, currentHash: current, sources: sources.length };
|
|
54
|
+
if (recorded === current)
|
|
55
|
+
return { id: 'slither-report', status: 'pass', reason: `${data.report} describes the current sources: ${data.sidecar} matches the hash of ${sources.length} .sol file(s) under ${sourceDir}.`, data };
|
|
56
|
+
return { id: 'slither-report', status: 'fail', reason: `${data.report} is stale: the sources under ${sourceDir} no longer match ${data.sidecar}; re-run slither.`, data };
|
|
57
|
+
}
|
|
58
|
+
const times = await fileTimes(project, [found, ...sources]);
|
|
59
|
+
const reportTime = times.get(found) ?? 0;
|
|
60
|
+
let newestSource = '';
|
|
61
|
+
let newestTime = 0;
|
|
62
|
+
for (const s of sources) {
|
|
63
|
+
const t = times.get(s) ?? 0;
|
|
64
|
+
if (t > newestTime) {
|
|
65
|
+
newestTime = t;
|
|
66
|
+
newestSource = s;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const data = { report: relative(project, found), reportTime: iso(reportTime), newestSource: relative(project, newestSource), newestSourceTime: iso(newestTime), sources: sources.length };
|
|
70
|
+
if (reportTime >= newestTime)
|
|
71
|
+
return { id: 'slither-report', status: 'pass', reason: `${data.report} (${data.reportTime}) is newer than the newest source ${data.newestSource} (${data.newestSourceTime}).`, data };
|
|
72
|
+
return { id: 'slither-report', status: 'fail', reason: `${data.report} (${data.reportTime}) is older than ${data.newestSource} (${data.newestSourceTime}); re-run slither and write ${SOURCES_HASH_FILE} beside the report (the scaffold's npm run lint:slither does both).`, data };
|
|
73
|
+
}
|
|
74
|
+
function iso(t) {
|
|
75
|
+
return t ? new Date(t * 1000).toISOString() : 'unknown';
|
|
76
|
+
}
|
|
77
|
+
function listFiles(dir) {
|
|
78
|
+
const out = [];
|
|
79
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
80
|
+
const p = join(dir, entry.name);
|
|
81
|
+
if (entry.isDirectory())
|
|
82
|
+
out.push(...listFiles(p));
|
|
83
|
+
else
|
|
84
|
+
out.push(p);
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
/** Unix seconds per file: git commit time, or mtime for dirty, untracked or non-git files. */
|
|
89
|
+
async function fileTimes(project, files) {
|
|
90
|
+
const out = new Map();
|
|
91
|
+
const mtime = (f) => Math.floor(statSync(f).mtimeMs / 1000);
|
|
92
|
+
const inRepo = await run('git', ['rev-parse', '--is-inside-work-tree'], { cwd: project, timeoutMs: 10_000 });
|
|
93
|
+
if (inRepo.code !== 0 || inRepo.stdout.trim() !== 'true') {
|
|
94
|
+
for (const f of files)
|
|
95
|
+
out.set(f, mtime(f));
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
const status = await run('git', ['status', '--porcelain', '--untracked-files=all', '--', ...files], { cwd: project, timeoutMs: 30_000 });
|
|
99
|
+
const dirty = new Set(status.stdout.split('\n').filter(Boolean).map((l) => join(repoRoot(project), l.slice(3).trim())));
|
|
100
|
+
for (const f of files) {
|
|
101
|
+
if (dirty.has(f)) {
|
|
102
|
+
out.set(f, mtime(f));
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const log = await run('git', ['log', '-1', '--format=%ct', '--', f], { cwd: project, timeoutMs: 30_000 });
|
|
106
|
+
const t = Number(log.stdout.trim());
|
|
107
|
+
out.set(f, Number.isFinite(t) && t > 0 ? t : mtime(f));
|
|
108
|
+
}
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
let rootCache = null;
|
|
112
|
+
/** git status prints paths relative to the repository root, which may be above `project`. */
|
|
113
|
+
function repoRoot(project) {
|
|
114
|
+
if (rootCache && rootCache.project === project)
|
|
115
|
+
return rootCache.root;
|
|
116
|
+
const root = execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd: project, encoding: 'utf8' }).trim();
|
|
117
|
+
rootCache = { project, root };
|
|
118
|
+
return root;
|
|
119
|
+
}
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import type { RunOptions } from './run.js';
|
|
3
|
+
export declare function parseArgs(argv: readonly string[]): RunOptions & {
|
|
4
|
+
json: boolean;
|
|
5
|
+
help: boolean;
|
|
6
|
+
version: boolean;
|
|
7
|
+
};
|
|
8
|
+
export declare function main(argv?: readonly string[]): Promise<number>;
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// redbelly-preflight: run before any deployment. Exit 0 when no check fails, 1 when one does,
|
|
3
|
+
// 2 on bad arguments. Never takes a key: --account names a Foundry keystore that cast reads.
|
|
4
|
+
import { realpathSync } from 'node:fs';
|
|
5
|
+
import { pathToFileURL } from 'node:url';
|
|
6
|
+
import { CHECK_IDS, DEFAULT_GAS, VERSION, renderText, runPreflight } from './run.js';
|
|
7
|
+
const HELP = `redbelly-preflight [options]
|
|
8
|
+
|
|
9
|
+
Checks a project before it deploys to Redbelly Network. Reads foundry.toml or
|
|
10
|
+
hardhat.config.*, talks JSON-RPC to one RPC and nothing else, and never sees a key.
|
|
11
|
+
|
|
12
|
+
--project <dir> project directory (default: current directory)
|
|
13
|
+
--profile <name> Foundry profile or Hardhat network to read
|
|
14
|
+
--chain <151|153> expected chain; defaults to the config's chain id, then CHAIN_ID in .env
|
|
15
|
+
--rpc <url> JSON-RPC endpoint; defaults to the governors RPC for --chain
|
|
16
|
+
--account <name> Foundry keystore name; address resolved with cast wallet address
|
|
17
|
+
--password-file <path> passed through to cast for that keystore (or set ETH_PASSWORD)
|
|
18
|
+
--address <0x…> deployer address for a read-only run (or DEPLOYER in .env)
|
|
19
|
+
--admin <0x…> address that will own the contracts (or ADMIN_SAFE in .env)
|
|
20
|
+
--gas <units> expected deployment gas (default ${DEFAULT_GAS.toLocaleString('en-US')})
|
|
21
|
+
--slither-report <path> report file (default: reports/slither.json or reports/slither.md)
|
|
22
|
+
--skip <id,id> skip checks: ${CHECK_IDS.join(', ')}
|
|
23
|
+
--json print the report as JSON
|
|
24
|
+
--version, --help
|
|
25
|
+
`;
|
|
26
|
+
export function parseArgs(argv) {
|
|
27
|
+
const o = { json: false, help: false, version: false };
|
|
28
|
+
const skip = [];
|
|
29
|
+
for (let i = 0; i < argv.length; i++) {
|
|
30
|
+
const a = argv[i];
|
|
31
|
+
const [flag, inline] = a.includes('=') ? [a.slice(0, a.indexOf('=')), a.slice(a.indexOf('=') + 1)] : [a, undefined];
|
|
32
|
+
const next = () => {
|
|
33
|
+
if (inline !== undefined)
|
|
34
|
+
return inline;
|
|
35
|
+
const v = argv[++i];
|
|
36
|
+
if (v === undefined)
|
|
37
|
+
throw new Error(`${flag} needs a value`);
|
|
38
|
+
return v;
|
|
39
|
+
};
|
|
40
|
+
switch (flag) {
|
|
41
|
+
case '--project':
|
|
42
|
+
o.project = next();
|
|
43
|
+
break;
|
|
44
|
+
case '--profile':
|
|
45
|
+
o.profile = next();
|
|
46
|
+
break;
|
|
47
|
+
case '--chain': {
|
|
48
|
+
const c = Number(next());
|
|
49
|
+
if (c !== 151 && c !== 153)
|
|
50
|
+
throw new Error('--chain must be 151 or 153');
|
|
51
|
+
o.chain = c;
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
case '--rpc':
|
|
55
|
+
o.rpc = next();
|
|
56
|
+
break;
|
|
57
|
+
case '--account':
|
|
58
|
+
o.account = next();
|
|
59
|
+
break;
|
|
60
|
+
case '--password-file':
|
|
61
|
+
o.passwordFile = next();
|
|
62
|
+
break;
|
|
63
|
+
case '--address':
|
|
64
|
+
o.address = next();
|
|
65
|
+
break;
|
|
66
|
+
case '--admin':
|
|
67
|
+
o.admin = next();
|
|
68
|
+
break;
|
|
69
|
+
case '--gas': {
|
|
70
|
+
const g = next().replace(/[_,]/g, '');
|
|
71
|
+
if (!/^\d+$/.test(g))
|
|
72
|
+
throw new Error('--gas must be a whole number of gas units');
|
|
73
|
+
o.gas = BigInt(g);
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
case '--slither-report':
|
|
77
|
+
o.slitherReport = next();
|
|
78
|
+
break;
|
|
79
|
+
case '--skip':
|
|
80
|
+
for (const id of next().split(',').map((s) => s.trim()).filter(Boolean)) {
|
|
81
|
+
if (!CHECK_IDS.includes(id))
|
|
82
|
+
throw new Error(`unknown check "${id}"; known: ${CHECK_IDS.join(', ')}`);
|
|
83
|
+
skip.push(id);
|
|
84
|
+
}
|
|
85
|
+
break;
|
|
86
|
+
case '--json':
|
|
87
|
+
o.json = true;
|
|
88
|
+
break;
|
|
89
|
+
case '--help':
|
|
90
|
+
case '-h':
|
|
91
|
+
o.help = true;
|
|
92
|
+
break;
|
|
93
|
+
case '--version':
|
|
94
|
+
o.version = true;
|
|
95
|
+
break;
|
|
96
|
+
default:
|
|
97
|
+
if (/^(0x)?[0-9a-fA-F]{64}$/.test(a))
|
|
98
|
+
throw new Error('that argument looks like a private key; pre-flight never takes one, use --account <keystore-name>');
|
|
99
|
+
throw new Error(`unknown argument "${a}"\n\n${HELP}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (skip.length)
|
|
103
|
+
o.skip = skip;
|
|
104
|
+
return o;
|
|
105
|
+
}
|
|
106
|
+
export async function main(argv = process.argv.slice(2)) {
|
|
107
|
+
let args;
|
|
108
|
+
try {
|
|
109
|
+
args = parseArgs(argv);
|
|
110
|
+
}
|
|
111
|
+
catch (e) {
|
|
112
|
+
process.stderr.write(`${e.message}\n`);
|
|
113
|
+
return 2;
|
|
114
|
+
}
|
|
115
|
+
if (args.help) {
|
|
116
|
+
process.stdout.write(HELP);
|
|
117
|
+
return 0;
|
|
118
|
+
}
|
|
119
|
+
if (args.version) {
|
|
120
|
+
process.stdout.write(`${VERSION}\n`);
|
|
121
|
+
return 0;
|
|
122
|
+
}
|
|
123
|
+
const { json, help: _h, version: _v, ...opts } = args;
|
|
124
|
+
const report = await runPreflight(opts);
|
|
125
|
+
process.stdout.write(json ? JSON.stringify(report, null, 2) + '\n' : renderText(report));
|
|
126
|
+
return report.ok ? 0 : 1;
|
|
127
|
+
}
|
|
128
|
+
/** True when this file is the process entry point (a bin symlink resolves to it), not an import. */
|
|
129
|
+
const isEntry = Boolean(process.argv[1]) && pathToFileURL(realpathSync(process.argv[1])).href === import.meta.url;
|
|
130
|
+
if (isEntry)
|
|
131
|
+
main().then((code) => { process.exitCode = code; }, (e) => { process.stderr.write(`${e.stack ?? e}\n`); process.exitCode = 2; });
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export interface ProjectConfig {
|
|
2
|
+
kind: 'foundry' | 'hardhat' | 'none';
|
|
3
|
+
file: string | null;
|
|
4
|
+
profile: string | null;
|
|
5
|
+
solc: string | null;
|
|
6
|
+
evmVersion: string | null;
|
|
7
|
+
optimizerRuns: number | null;
|
|
8
|
+
/** foundry: `src`; hardhat: `contracts`. */
|
|
9
|
+
sourceDir: string;
|
|
10
|
+
/** foundry `chain_id` or the hardhat network's `chainId`. */
|
|
11
|
+
chainId: number | null;
|
|
12
|
+
/** foundry [rpc_endpoints] or hardhat networks' url values, by name. */
|
|
13
|
+
rpcEndpoints: Record<string, string>;
|
|
14
|
+
/** The RPC url of the chosen hardhat network, if any. */
|
|
15
|
+
rpcUrl: string | null;
|
|
16
|
+
}
|
|
17
|
+
export declare const HARDHAT_CONFIG_NAMES: string[];
|
|
18
|
+
export declare function readProjectConfig(project: string, profile?: string, env?: NodeJS.ProcessEnv): ProjectConfig;
|
|
19
|
+
type TomlValue = string | number | boolean | TomlValue[];
|
|
20
|
+
type TomlTable = Record<string, TomlValue>;
|
|
21
|
+
/** Enough TOML for foundry.toml: sections, dotted headers, scalars, one-line arrays, comments. */
|
|
22
|
+
export declare function parseToml(text: string): Record<string, TomlTable>;
|
|
23
|
+
export declare function readFoundryConfig(file: string, profile: string): ProjectConfig;
|
|
24
|
+
export declare function readHardhatConfig(file: string, network: string | null): ProjectConfig;
|
|
25
|
+
export {};
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// Reads the target profile from foundry.toml or hardhat.config.* without executing anything.
|
|
2
|
+
// Foundry's TOML is parsed by a small reader that understands the subset Foundry projects use:
|
|
3
|
+
// [section] headers, dotted section names, `key = value` with strings, numbers, booleans and
|
|
4
|
+
// one-line arrays. Hardhat configs are JavaScript, so they are scanned with regular expressions
|
|
5
|
+
// after brace matching narrows the text to the chosen network block. Both readers are tolerant:
|
|
6
|
+
// what they cannot find they report as null, and the checks say so.
|
|
7
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
export const HARDHAT_CONFIG_NAMES = ['hardhat.config.ts', 'hardhat.config.js', 'hardhat.config.cjs', 'hardhat.config.mjs', 'hardhat.config.cts', 'hardhat.config.mts'];
|
|
10
|
+
export function readProjectConfig(project, profile, env = process.env) {
|
|
11
|
+
const foundry = join(project, 'foundry.toml');
|
|
12
|
+
if (existsSync(foundry))
|
|
13
|
+
return readFoundryConfig(foundry, profile ?? env['FOUNDRY_PROFILE'] ?? 'default');
|
|
14
|
+
for (const name of HARDHAT_CONFIG_NAMES) {
|
|
15
|
+
const file = join(project, name);
|
|
16
|
+
if (existsSync(file))
|
|
17
|
+
return readHardhatConfig(file, profile ?? null);
|
|
18
|
+
}
|
|
19
|
+
return { kind: 'none', file: null, profile: null, solc: null, evmVersion: null, optimizerRuns: null, sourceDir: 'src', chainId: null, rpcEndpoints: {}, rpcUrl: null };
|
|
20
|
+
}
|
|
21
|
+
/** Enough TOML for foundry.toml: sections, dotted headers, scalars, one-line arrays, comments. */
|
|
22
|
+
export function parseToml(text) {
|
|
23
|
+
const out = {};
|
|
24
|
+
let section = '';
|
|
25
|
+
for (const rawLine of text.split(/\r?\n/)) {
|
|
26
|
+
const line = stripComment(rawLine).trim();
|
|
27
|
+
if (!line)
|
|
28
|
+
continue;
|
|
29
|
+
const header = /^\[\s*([^\]]+?)\s*\]$/.exec(line);
|
|
30
|
+
if (header) {
|
|
31
|
+
section = header[1].replace(/\s+/g, '');
|
|
32
|
+
out[section] ??= {};
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
const kv = /^([A-Za-z0-9_.-]+)\s*=\s*(.+)$/.exec(line);
|
|
36
|
+
if (!kv)
|
|
37
|
+
continue;
|
|
38
|
+
out[section] ??= {};
|
|
39
|
+
out[section][kv[1]] = parseTomlValue(kv[2].trim());
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
function stripComment(line) {
|
|
44
|
+
let inString = null;
|
|
45
|
+
for (let i = 0; i < line.length; i++) {
|
|
46
|
+
const c = line[i];
|
|
47
|
+
if (inString) {
|
|
48
|
+
if (c === inString)
|
|
49
|
+
inString = null;
|
|
50
|
+
}
|
|
51
|
+
else if (c === '"' || c === "'")
|
|
52
|
+
inString = c;
|
|
53
|
+
else if (c === '#')
|
|
54
|
+
return line.slice(0, i);
|
|
55
|
+
}
|
|
56
|
+
return line;
|
|
57
|
+
}
|
|
58
|
+
function parseTomlValue(raw) {
|
|
59
|
+
if (raw.startsWith('[') && raw.endsWith(']')) {
|
|
60
|
+
const inner = raw.slice(1, -1).trim();
|
|
61
|
+
if (!inner)
|
|
62
|
+
return [];
|
|
63
|
+
return splitTopLevel(inner).map((v) => parseTomlValue(v.trim()));
|
|
64
|
+
}
|
|
65
|
+
if ((raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith("'") && raw.endsWith("'")))
|
|
66
|
+
return raw.slice(1, -1);
|
|
67
|
+
if (raw === 'true')
|
|
68
|
+
return true;
|
|
69
|
+
if (raw === 'false')
|
|
70
|
+
return false;
|
|
71
|
+
if (/^-?\d[\d_]*$/.test(raw))
|
|
72
|
+
return Number(raw.replace(/_/g, ''));
|
|
73
|
+
if (/^-?\d[\d_]*\.\d+$/.test(raw))
|
|
74
|
+
return Number(raw.replace(/_/g, ''));
|
|
75
|
+
return raw;
|
|
76
|
+
}
|
|
77
|
+
function splitTopLevel(s) {
|
|
78
|
+
const parts = [];
|
|
79
|
+
let depth = 0;
|
|
80
|
+
let inString = null;
|
|
81
|
+
let cur = '';
|
|
82
|
+
for (const c of s) {
|
|
83
|
+
if (inString) {
|
|
84
|
+
cur += c;
|
|
85
|
+
if (c === inString)
|
|
86
|
+
inString = null;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (c === '"' || c === "'")
|
|
90
|
+
inString = c;
|
|
91
|
+
if (c === '[')
|
|
92
|
+
depth++;
|
|
93
|
+
if (c === ']')
|
|
94
|
+
depth--;
|
|
95
|
+
if (c === ',' && depth === 0) {
|
|
96
|
+
parts.push(cur);
|
|
97
|
+
cur = '';
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
cur += c;
|
|
101
|
+
}
|
|
102
|
+
if (cur.trim())
|
|
103
|
+
parts.push(cur);
|
|
104
|
+
return parts;
|
|
105
|
+
}
|
|
106
|
+
export function readFoundryConfig(file, profile) {
|
|
107
|
+
const toml = parseToml(readFileSync(file, 'utf8'));
|
|
108
|
+
const base = toml['profile.default'] ?? {};
|
|
109
|
+
const chosen = toml[`profile.${profile}`] ?? {};
|
|
110
|
+
const get = (key) => chosen[key] ?? base[key];
|
|
111
|
+
const str = (key) => {
|
|
112
|
+
const v = get(key);
|
|
113
|
+
return typeof v === 'string' ? v : null;
|
|
114
|
+
};
|
|
115
|
+
const num = (key) => {
|
|
116
|
+
const v = get(key);
|
|
117
|
+
return typeof v === 'number' ? v : null;
|
|
118
|
+
};
|
|
119
|
+
const rpcEndpoints = {};
|
|
120
|
+
for (const [k, v] of Object.entries(toml['rpc_endpoints'] ?? {}))
|
|
121
|
+
if (typeof v === 'string')
|
|
122
|
+
rpcEndpoints[k] = expandEnv(v);
|
|
123
|
+
return {
|
|
124
|
+
kind: 'foundry',
|
|
125
|
+
file,
|
|
126
|
+
profile,
|
|
127
|
+
solc: str('solc') ?? str('solc_version'),
|
|
128
|
+
evmVersion: str('evm_version'),
|
|
129
|
+
optimizerRuns: num('optimizer_runs'),
|
|
130
|
+
sourceDir: str('src') ?? 'src',
|
|
131
|
+
chainId: num('chain_id'),
|
|
132
|
+
rpcEndpoints,
|
|
133
|
+
rpcUrl: null,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
/** Foundry lets an endpoint be `${RPC_URL}`; expand from the environment when set. */
|
|
137
|
+
function expandEnv(v) {
|
|
138
|
+
return v.replace(/\$\{([A-Z0-9_]+)\}/g, (_, name) => process.env[name] ?? `\${${name}}`);
|
|
139
|
+
}
|
|
140
|
+
// ---------- Hardhat ----------
|
|
141
|
+
export function readHardhatConfig(file, network) {
|
|
142
|
+
const text = readFileSync(file, 'utf8');
|
|
143
|
+
const solidity = blockAfter(text, /\bsolidity\s*:/) ?? '';
|
|
144
|
+
const versions = [...(solidity || text).matchAll(/\bversion\s*:\s*["']([\d.]+)["']/g)].map((m) => m[1]);
|
|
145
|
+
const shorthand = /\bsolidity\s*:\s*["']([\d.]+)["']/.exec(text);
|
|
146
|
+
const solc = shorthand ? shorthand[1] : versions.length === 1 ? versions[0] : versions.length > 1 ? versions.join(',') : null;
|
|
147
|
+
const evm = /\bevmVersion\s*:\s*["']([A-Za-z]+)["']/.exec(solidity || text);
|
|
148
|
+
const runs = /\bruns\s*:\s*(\d+)/.exec(solidity || text);
|
|
149
|
+
const networks = blockAfter(text, /\bnetworks\s*:/) ?? '';
|
|
150
|
+
const rpcEndpoints = {};
|
|
151
|
+
const chainIds = {};
|
|
152
|
+
for (const m of networks.matchAll(/([A-Za-z0-9_]+)\s*:\s*\{/g)) {
|
|
153
|
+
const name = m[1];
|
|
154
|
+
const body = blockAfter(networks.slice(m.index), new RegExp(`\\b${name}\\s*:`)) ?? '';
|
|
155
|
+
const url = /\burl\s*:\s*["'`]([^"'`]+)["'`]/.exec(body);
|
|
156
|
+
const cid = /\bchainId\s*:\s*(\d+)/.exec(body);
|
|
157
|
+
if (url)
|
|
158
|
+
rpcEndpoints[name] = url[1];
|
|
159
|
+
if (cid)
|
|
160
|
+
chainIds[name] = Number(cid[1]);
|
|
161
|
+
}
|
|
162
|
+
const chosen = network ?? null;
|
|
163
|
+
const contractsDir = /\bsources\s*:\s*["']([^"']+)["']/.exec(text);
|
|
164
|
+
return {
|
|
165
|
+
kind: 'hardhat',
|
|
166
|
+
file,
|
|
167
|
+
profile: chosen,
|
|
168
|
+
solc,
|
|
169
|
+
evmVersion: evm ? evm[1] : null,
|
|
170
|
+
optimizerRuns: runs ? Number(runs[1]) : null,
|
|
171
|
+
sourceDir: contractsDir ? contractsDir[1].replace(/^\.\//, '') : 'contracts',
|
|
172
|
+
chainId: chosen ? (chainIds[chosen] ?? null) : null,
|
|
173
|
+
rpcEndpoints,
|
|
174
|
+
rpcUrl: chosen ? (rpcEndpoints[chosen] ?? null) : null,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
/** Text of the `{ ... }` block that follows the first match of `key`, with braces balanced. */
|
|
178
|
+
function blockAfter(text, key) {
|
|
179
|
+
const m = key.exec(text);
|
|
180
|
+
if (!m)
|
|
181
|
+
return null;
|
|
182
|
+
const start = text.indexOf('{', m.index + m[0].length);
|
|
183
|
+
if (start < 0)
|
|
184
|
+
return null;
|
|
185
|
+
let depth = 0;
|
|
186
|
+
for (let i = start; i < text.length; i++) {
|
|
187
|
+
const c = text[i];
|
|
188
|
+
if (c === '{')
|
|
189
|
+
depth++;
|
|
190
|
+
else if (c === '}') {
|
|
191
|
+
depth--;
|
|
192
|
+
if (depth === 0)
|
|
193
|
+
return text.slice(start, i + 1);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
export declare const DOCTOR_HELP = "redbelly-doctor [options]\n\nChecks the machine before the first command: Node 22 or later; forge, anvil and cast on\nPATH, one version, and the real binaries rather than the @foundry-rs npm shim that exits 0\nwhatever the binary returned; slither and aderyn (optional); git; no .env tracked by git;\nand, in a scaffold, vendor/ matching the versions the scaffolder recorded. One line per\ncheck with the fix on the same line. Touches no network and no key.\n\n --project <dir> scaffold root, its contracts/ folder, or any directory (default: cwd)\n --json print the report as JSON\n --version, --help\n\nExit 0 when every required check passes (optional checks may warn), 1 otherwise, 2 on bad arguments.\n";
|
|
3
|
+
export declare function parseDoctorArgs(argv: readonly string[]): {
|
|
4
|
+
project?: string;
|
|
5
|
+
json: boolean;
|
|
6
|
+
help: boolean;
|
|
7
|
+
version: boolean;
|
|
8
|
+
};
|
|
9
|
+
export declare function doctorMain(argv?: readonly string[]): Promise<number>;
|