@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.
Files changed (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +189 -0
  3. package/dist/bip39-words.d.ts +1 -0
  4. package/dist/bip39-words.js +132 -0
  5. package/dist/checks/admin.d.ts +24 -0
  6. package/dist/checks/admin.js +81 -0
  7. package/dist/checks/balance.d.ts +3 -0
  8. package/dist/checks/balance.js +39 -0
  9. package/dist/checks/chain-id.d.ts +2 -0
  10. package/dist/checks/chain-id.js +11 -0
  11. package/dist/checks/compiler.d.ts +5 -0
  12. package/dist/checks/compiler.js +20 -0
  13. package/dist/checks/deployer.d.ts +14 -0
  14. package/dist/checks/deployer.js +53 -0
  15. package/dist/checks/git-secrets.d.ts +14 -0
  16. package/dist/checks/git-secrets.js +91 -0
  17. package/dist/checks/slither.d.ts +11 -0
  18. package/dist/checks/slither.js +119 -0
  19. package/dist/cli.d.ts +8 -0
  20. package/dist/cli.js +131 -0
  21. package/dist/config.d.ts +25 -0
  22. package/dist/config.js +197 -0
  23. package/dist/doctor-cli.d.ts +9 -0
  24. package/dist/doctor-cli.js +76 -0
  25. package/dist/doctor.d.ts +60 -0
  26. package/dist/doctor.js +251 -0
  27. package/dist/dotenv.d.ts +7 -0
  28. package/dist/dotenv.js +28 -0
  29. package/dist/gas-cli.d.ts +8 -0
  30. package/dist/gas-cli.js +100 -0
  31. package/dist/gas.d.ts +110 -0
  32. package/dist/gas.js +221 -0
  33. package/dist/index.d.ts +28 -0
  34. package/dist/index.js +20 -0
  35. package/dist/proc.d.ts +14 -0
  36. package/dist/proc.js +28 -0
  37. package/dist/redbelly.d.ts +2 -0
  38. package/dist/redbelly.js +40 -0
  39. package/dist/run.d.ts +9 -0
  40. package/dist/run.js +106 -0
  41. package/dist/secrets.d.ts +18 -0
  42. package/dist/secrets.js +89 -0
  43. package/dist/ship-cli.d.ts +8 -0
  44. package/dist/ship-cli.js +128 -0
  45. package/dist/ship.d.ts +108 -0
  46. package/dist/ship.js +383 -0
  47. package/dist/suites.d.ts +26 -0
  48. package/dist/suites.js +53 -0
  49. package/dist/types.d.ts +58 -0
  50. package/dist/types.js +3 -0
  51. package/dist/well-known.d.ts +2 -0
  52. package/dist/well-known.js +21 -0
  53. package/package.json +50 -0
@@ -0,0 +1,76 @@
1
+ #!/usr/bin/env node
2
+ // redbelly-doctor: one line per check, pass, warn or fail, with the fix on the same line.
3
+ // Exit 0 only when every required check passes; 1 otherwise; 2 on bad arguments. --json for agents.
4
+ import { realpathSync } from 'node:fs';
5
+ import { pathToFileURL } from 'node:url';
6
+ import { DOCTOR_VERSION, renderDoctorText, runDoctor } from './doctor.js';
7
+ export const DOCTOR_HELP = `redbelly-doctor [options]
8
+
9
+ Checks the machine before the first command: Node 22 or later; forge, anvil and cast on
10
+ PATH, one version, and the real binaries rather than the @foundry-rs npm shim that exits 0
11
+ whatever the binary returned; slither and aderyn (optional); git; no .env tracked by git;
12
+ and, in a scaffold, vendor/ matching the versions the scaffolder recorded. One line per
13
+ check with the fix on the same line. Touches no network and no key.
14
+
15
+ --project <dir> scaffold root, its contracts/ folder, or any directory (default: cwd)
16
+ --json print the report as JSON
17
+ --version, --help
18
+
19
+ Exit 0 when every required check passes (optional checks may warn), 1 otherwise, 2 on bad arguments.
20
+ `;
21
+ export function parseDoctorArgs(argv) {
22
+ const o = { json: false, help: false, version: false };
23
+ for (let i = 0; i < argv.length; i++) {
24
+ const a = argv[i];
25
+ const [flag, inline] = a.includes('=') ? [a.slice(0, a.indexOf('=')), a.slice(a.indexOf('=') + 1)] : [a, undefined];
26
+ switch (flag) {
27
+ case '--project': {
28
+ const v = inline ?? argv[++i];
29
+ if (v === undefined)
30
+ throw new Error('--project needs a value');
31
+ o.project = v;
32
+ break;
33
+ }
34
+ case '--json':
35
+ o.json = true;
36
+ break;
37
+ case '--help':
38
+ case '-h':
39
+ o.help = true;
40
+ break;
41
+ case '--version':
42
+ o.version = true;
43
+ break;
44
+ default:
45
+ if (/^(0x)?[0-9a-fA-F]{64}$/.test(a))
46
+ throw new Error('that argument looks like a private key; doctor never takes one and needs no address');
47
+ throw new Error(`unknown argument "${a}"\n\n${DOCTOR_HELP}`);
48
+ }
49
+ }
50
+ return o;
51
+ }
52
+ export async function doctorMain(argv = process.argv.slice(2)) {
53
+ let args;
54
+ try {
55
+ args = parseDoctorArgs(argv);
56
+ }
57
+ catch (e) {
58
+ process.stderr.write(`${e.message}\n`);
59
+ return 2;
60
+ }
61
+ if (args.help) {
62
+ process.stdout.write(DOCTOR_HELP);
63
+ return 0;
64
+ }
65
+ if (args.version) {
66
+ process.stdout.write(`${DOCTOR_VERSION}\n`);
67
+ return 0;
68
+ }
69
+ const report = await runDoctor(args.project !== undefined ? { project: args.project } : {});
70
+ process.stdout.write(args.json ? JSON.stringify(report, null, 2) + '\n' : renderDoctorText(report));
71
+ return report.ok ? 0 : 1;
72
+ }
73
+ /** True when this file is the process entry point (a bin symlink resolves to it), not an import. */
74
+ const isEntry = Boolean(process.argv[1]) && pathToFileURL(realpathSync(process.argv[1])).href === import.meta.url;
75
+ if (isEntry)
76
+ doctorMain().then((code) => { process.exitCode = code; }, (e) => { process.stderr.write(`${e.stack ?? e}\n`); process.exitCode = 2; });
@@ -0,0 +1,60 @@
1
+ export declare const DOCTOR_VERSION: string;
2
+ export type DoctorCheckId = 'node' | 'git' | 'forge' | 'anvil' | 'cast' | 'foundry-version' | 'foundry-exit-code' | 'slither' | 'aderyn' | 'env-tracked' | 'vendor';
3
+ export declare const DOCTOR_CHECK_IDS: readonly DoctorCheckId[];
4
+ export type DoctorStatus = 'pass' | 'warn' | 'fail';
5
+ export interface DoctorCheck {
6
+ readonly id: DoctorCheckId;
7
+ /** A required check that fails makes the exit code 1. Optional checks warn at worst. */
8
+ readonly required: boolean;
9
+ readonly status: DoctorStatus;
10
+ /** One sentence, printed after the status and the id. */
11
+ readonly reason: string;
12
+ /** The next command, printed on the same line. From the failure table where the table has the entry. */
13
+ readonly fix: string | null;
14
+ /** The failure-table kind the fix comes from, so explain_failure says the same thing. */
15
+ readonly kind: string | null;
16
+ readonly data?: Record<string, unknown>;
17
+ }
18
+ export interface DoctorOptions {
19
+ /** Project directory: a scaffold root, its contracts/ folder, or any directory. Default: cwd. */
20
+ project?: string;
21
+ /** Environment to read PATH (and PATHEXT on Windows) from. Default: process.env. */
22
+ env?: NodeJS.ProcessEnv;
23
+ /** Node version to judge. Default: process.version. Tests hand in an older one. */
24
+ nodeVersion?: string;
25
+ platform?: NodeJS.Platform;
26
+ arch?: string;
27
+ }
28
+ export interface DoctorReport {
29
+ readonly tool: 'redbelly-doctor';
30
+ readonly version: string;
31
+ readonly generatedAt: string;
32
+ readonly project: string;
33
+ readonly scaffold: boolean;
34
+ readonly checks: readonly DoctorCheck[];
35
+ /** True when every required check passes. Warnings never clear or cause a fail. */
36
+ readonly ok: boolean;
37
+ }
38
+ export declare const NODE_MIN_MAJOR = 22;
39
+ export declare const SLITHER_PIN = "0.11.6";
40
+ export declare const ADERYN_PIN = "0.6.8";
41
+ declare const FOUNDRY_TOOLS: readonly ["forge", "anvil", "cast"];
42
+ type FoundryTool = (typeof FOUNDRY_TOOLS)[number];
43
+ /** First match for `name` on PATH, the way a shell finds it. Windows adds PATHEXT. */
44
+ export declare function whichOnPath(name: string, env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): string | null;
45
+ /** The semantic version a tool prints for --version, or null. */
46
+ export declare function toolVersion(path: string): Promise<{
47
+ version: string | null;
48
+ output: string;
49
+ }>;
50
+ /** Does the binary hand its exit code back? The npm shim spawns the real tool and always exits 0. */
51
+ export declare function exitCodePropagates(path: string): Promise<boolean | null>;
52
+ /** A Node script where a binary should be: a .mjs/.js/.cjs, a `#!…node` shebang, or a Windows .cmd calling node. */
53
+ export declare function looksLikeNodeShim(path: string): boolean;
54
+ /** The platform binary the @foundry-rs shim would spawn, when it sits beside the shim. */
55
+ export declare function realBinaryNear(shimPath: string, tool: FoundryTool, platform?: NodeJS.Platform, arch?: string): string | null;
56
+ /** Scaffold root for a directory: itself, or its parent when the directory is a scaffold's contracts/. */
57
+ export declare function scaffoldRoot(project: string): string | null;
58
+ export declare function runDoctor(opts?: DoctorOptions): Promise<DoctorReport>;
59
+ export declare function renderDoctorText(report: DoctorReport): string;
60
+ export {};
package/dist/doctor.js ADDED
@@ -0,0 +1,251 @@
1
+ // redbelly-doctor: the machine before the first command. Node 22 or later; forge, anvil and cast
2
+ // present, one version, and the real binaries rather than the @foundry-rs npm shim that exits 0
3
+ // whatever the binary returned; slither and aderyn optional; git; no .env tracked; and, in a
4
+ // scaffold, vendor/ matching the versions the scaffolder recorded. One line per check with the fix
5
+ // on the same line. The fix text comes from the failure table in @gatedpath/agent-rules, so
6
+ // explain_failure and doctor use the same words. Nothing here touches a network or a key.
7
+ import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs';
8
+ import { createRequire } from 'node:module';
9
+ import { basename, delimiter, dirname, join, resolve } from 'node:path';
10
+ import { failureByKind } from '@gatedpath/agent-rules/failures';
11
+ import { run } from './proc.js';
12
+ const require = createRequire(import.meta.url);
13
+ export const DOCTOR_VERSION = require('../package.json').version;
14
+ export const DOCTOR_CHECK_IDS = ['node', 'git', 'forge', 'anvil', 'cast', 'foundry-version', 'foundry-exit-code', 'slither', 'aderyn', 'env-tracked', 'vendor'];
15
+ export const NODE_MIN_MAJOR = 22;
16
+ export const SLITHER_PIN = '0.11.6';
17
+ export const ADERYN_PIN = '0.6.8';
18
+ const FOUNDRY_TOOLS = ['forge', 'anvil', 'cast'];
19
+ /** The flag no Foundry binary knows. clap exits 2 on it; the npm shim exits 0. */
20
+ const BOGUS_FLAG = '--this-flag-does-not-exist';
21
+ const fixFor = (kind) => failureByKind(kind)?.fix.command ?? null;
22
+ /** First match for `name` on PATH, the way a shell finds it. Windows adds PATHEXT. */
23
+ export function whichOnPath(name, env = process.env, platform = process.platform) {
24
+ const dirs = (env.PATH ?? env.Path ?? '').split(delimiter).filter(Boolean);
25
+ const exts = platform === 'win32' ? ['', ...(env.PATHEXT ?? '.EXE;.CMD;.BAT').split(';').map((e) => e.toLowerCase())] : [''];
26
+ for (const dir of dirs) {
27
+ for (const ext of exts) {
28
+ const candidate = join(dir, name + ext);
29
+ try {
30
+ if (statSync(candidate).isFile())
31
+ return candidate;
32
+ }
33
+ catch {
34
+ /* not here */
35
+ }
36
+ }
37
+ }
38
+ return null;
39
+ }
40
+ /** The semantic version a tool prints for --version, or null. */
41
+ export async function toolVersion(path) {
42
+ const r = await run(path, ['--version'], { timeoutMs: 20_000 });
43
+ const output = (r.stdout + r.stderr).trim();
44
+ if (r.spawnError)
45
+ return { version: null, output: r.spawnError };
46
+ const m = /(\d+\.\d+\.\d+)/.exec(output);
47
+ return { version: m ? m[1] : null, output };
48
+ }
49
+ /** Does the binary hand its exit code back? The npm shim spawns the real tool and always exits 0. */
50
+ export async function exitCodePropagates(path) {
51
+ const r = await run(path, [BOGUS_FLAG], { timeoutMs: 20_000 });
52
+ if (r.spawnError || r.code === null)
53
+ return null;
54
+ return r.code !== 0;
55
+ }
56
+ /** A Node script where a binary should be: a .mjs/.js/.cjs, a `#!…node` shebang, or a Windows .cmd calling node. */
57
+ export function looksLikeNodeShim(path) {
58
+ try {
59
+ const real = realpathSync(path);
60
+ if (/\.(mjs|cjs|js)$/i.test(real))
61
+ return true;
62
+ const head = readFileSync(real).subarray(0, 512).toString('utf8');
63
+ if (head.startsWith('#!') && /node/.test(head.split('\n')[0]))
64
+ return true;
65
+ if (/\.cmd$/i.test(real) && /node/.test(head))
66
+ return true;
67
+ return false;
68
+ }
69
+ catch {
70
+ return false;
71
+ }
72
+ }
73
+ /** The platform binary the @foundry-rs shim would spawn, when it sits beside the shim. */
74
+ export function realBinaryNear(shimPath, tool, platform = process.platform, arch = process.arch) {
75
+ let dir;
76
+ try {
77
+ dir = dirname(realpathSync(shimPath));
78
+ }
79
+ catch {
80
+ return null;
81
+ }
82
+ const archName = arch === 'x64' ? 'amd64' : arch;
83
+ const pkg = `${tool}-${platform}-${archName}`;
84
+ const exe = platform === 'win32' ? '.exe' : '';
85
+ const candidates = [join(dir, 'node_modules', '@foundry-rs', pkg, 'bin', tool + exe), join(dir, '..', pkg, 'bin', tool + exe), join(dir, '..', '..', '@foundry-rs', pkg, 'bin', tool + exe)];
86
+ return candidates.find((c) => existsSync(c)) ?? null;
87
+ }
88
+ /** Scaffold root for a directory: itself, or its parent when the directory is a scaffold's contracts/. */
89
+ export function scaffoldRoot(project) {
90
+ const isScaffold = (dir) => existsSync(join(dir, 'package.json')) && existsSync(join(dir, 'vendor')) && readdirSync(join(dir, 'vendor')).some((d) => d.startsWith('redbelly-'));
91
+ if (isScaffold(project))
92
+ return project;
93
+ const parent = resolve(project, '..');
94
+ if (basename(project) === 'contracts' && isScaffold(parent))
95
+ return parent;
96
+ return null;
97
+ }
98
+ const majorOf = (v) => Number(/^v?(\d+)/.exec(v)?.[1] ?? 0);
99
+ export async function runDoctor(opts = {}) {
100
+ const env = opts.env ?? process.env;
101
+ const platform = opts.platform ?? process.platform;
102
+ const arch = opts.arch ?? process.arch;
103
+ const project = resolve(opts.project ?? process.cwd());
104
+ const root = scaffoldRoot(project);
105
+ const checks = [];
106
+ // node
107
+ const nodeVersion = opts.nodeVersion ?? process.version;
108
+ const major = majorOf(nodeVersion);
109
+ checks.push(major >= NODE_MIN_MAJOR
110
+ ? { id: 'node', required: true, status: 'pass', reason: `${nodeVersion} (${NODE_MIN_MAJOR} or later)`, fix: null, kind: null, data: { version: nodeVersion } }
111
+ : { id: 'node', required: true, status: 'fail', reason: `${nodeVersion} is older than ${NODE_MIN_MAJOR}; the scaffold, the MCP server and the web app are built and tested on ${NODE_MIN_MAJOR}.`, fix: fixFor('node-too-old'), kind: 'node-too-old', data: { version: nodeVersion } });
112
+ // git
113
+ const gitPath = whichOnPath('git', env, platform);
114
+ const git = gitPath ? await toolVersion(gitPath) : null;
115
+ checks.push(gitPath && git?.version
116
+ ? { id: 'git', required: true, status: 'pass', reason: `${git.version} at ${gitPath}`, fix: null, kind: null, data: { path: gitPath, version: git.version } }
117
+ : { id: 'git', required: true, status: 'fail', reason: 'not found on PATH.', fix: fixFor('git-not-found'), kind: 'git-not-found' });
118
+ // forge, anvil, cast
119
+ const found = {};
120
+ for (const tool of FOUNDRY_TOOLS) {
121
+ const path = whichOnPath(tool, env, platform);
122
+ const kind = tool === 'anvil' ? 'anvil-not-found' : 'forge-not-found';
123
+ if (!path) {
124
+ checks.push({ id: tool, required: true, status: 'fail', reason: 'not found on PATH.', fix: fixFor(kind), kind });
125
+ continue;
126
+ }
127
+ const v = await toolVersion(path);
128
+ const propagates = await exitCodePropagates(path);
129
+ const shim = propagates === false || looksLikeNodeShim(path);
130
+ const real = shim ? realBinaryNear(path, tool, platform, arch) : null;
131
+ found[tool] = { path, version: v.version, propagates, shim, real };
132
+ if (!v.version) {
133
+ checks.push({ id: tool, required: true, status: 'fail', reason: `${path} did not answer --version (${v.output.slice(0, 120) || 'no output'}).`, fix: fixFor(kind), kind, data: { path } });
134
+ continue;
135
+ }
136
+ checks.push({ id: tool, required: true, status: 'pass', reason: `${v.version} at ${path}${shim ? ' (the npm shim; see foundry-exit-code)' : ''}`, fix: null, kind: null, data: { path, version: v.version, shim } });
137
+ }
138
+ // one version across the three
139
+ const versions = FOUNDRY_TOOLS.map((t) => found[t]?.version ?? null);
140
+ if (versions.some((v) => v === null)) {
141
+ checks.push({ id: 'foundry-version', required: true, status: 'fail', reason: `cannot compare: ${FOUNDRY_TOOLS.filter((t) => !found[t]?.version).join(', ')} missing (above).`, fix: fixFor('foundry-version-mismatch'), kind: 'foundry-version-mismatch', data: { versions } });
142
+ }
143
+ else if (new Set(versions).size === 1) {
144
+ checks.push({ id: 'foundry-version', required: true, status: 'pass', reason: `forge, anvil and cast are all ${versions[0]}.`, fix: null, kind: null, data: { version: versions[0] } });
145
+ }
146
+ else {
147
+ checks.push({ id: 'foundry-version', required: true, status: 'fail', reason: FOUNDRY_TOOLS.map((t) => `${t} ${found[t].version}`).join(', ') + '; Foundry ships and tests the three together.', fix: fixFor('foundry-version-mismatch'), kind: 'foundry-version-mismatch', data: { versions } });
148
+ }
149
+ // the shim that drops exit codes
150
+ const present = FOUNDRY_TOOLS.filter((t) => found[t]);
151
+ const shims = present.filter((t) => found[t].shim);
152
+ if (present.length === 0) {
153
+ checks.push({ id: 'foundry-exit-code', required: true, status: 'fail', reason: 'nothing to probe: forge, anvil and cast are missing (above).', fix: null, kind: 'foundry-exit-code-shim' });
154
+ }
155
+ else if (shims.length === 0) {
156
+ checks.push({ id: 'foundry-exit-code', required: true, status: 'pass', reason: `${present.join(', ')} hand back their exit codes (${BOGUS_FLAG} exits ${present.map((t) => found[t].propagates === null ? '?' : 'non-zero').join(', ')}).`, fix: null, kind: null });
157
+ }
158
+ else {
159
+ const detail = shims.map((t) => `${t} at ${found[t].path} is the @foundry-rs npm shim (${BOGUS_FLAG} exited 0; the real binary exits 2)${found[t].real ? `, real binary ${found[t].real}` : ''}`).join('; ');
160
+ const concrete = shims.map((t) => (found[t].real ? `ln -sf ${found[t].real} ${found[t].path}` : null)).filter(Boolean).join(' && ');
161
+ checks.push({ id: 'foundry-exit-code', required: true, status: 'fail', reason: `${detail}. A failed forge build or test reports success through it.`, fix: concrete || fixFor('foundry-exit-code-shim'), kind: 'foundry-exit-code-shim', data: { shims: shims.map((t) => ({ tool: t, path: found[t].path, real: found[t].real })) } });
162
+ }
163
+ // slither, aderyn (optional)
164
+ for (const [tool, pin, kind] of [['slither', SLITHER_PIN, 'slither-not-found'], ['aderyn', ADERYN_PIN, 'aderyn-not-found']]) {
165
+ const path = whichOnPath(tool, env, platform);
166
+ if (!path) {
167
+ checks.push({ id: tool, required: false, status: 'warn', reason: `not found on PATH (optional${tool === 'slither' ? '; pre-flight wants its report before a deploy' : ''}).`, fix: fixFor(kind), kind });
168
+ continue;
169
+ }
170
+ const v = await toolVersion(path);
171
+ if (!v.version)
172
+ checks.push({ id: tool, required: false, status: 'warn', reason: `${path} did not answer --version (optional).`, fix: fixFor(kind), kind, data: { path } });
173
+ else if (v.version !== pin)
174
+ checks.push({ id: tool, required: false, status: 'warn', reason: `${v.version} at ${path}; CI pins ${pin}, so findings may differ.`, fix: null, kind: null, data: { path, version: v.version, pin } });
175
+ else
176
+ checks.push({ id: tool, required: false, status: 'pass', reason: `${v.version} at ${path}`, fix: null, kind: null, data: { path, version: v.version } });
177
+ }
178
+ // no .env tracked
179
+ if (!gitPath) {
180
+ checks.push({ id: 'env-tracked', required: true, status: 'fail', reason: 'cannot check without git (above).', fix: null, kind: 'git-not-found' });
181
+ }
182
+ else {
183
+ const inRepo = await run(gitPath, ['rev-parse', '--is-inside-work-tree'], { cwd: root ?? project, timeoutMs: 10_000 });
184
+ if (inRepo.code !== 0 || inRepo.stdout.trim() !== 'true') {
185
+ checks.push({ id: 'env-tracked', required: true, status: 'pass', reason: 'not a git repository, so nothing is tracked.', fix: null, kind: null, data: { repository: false } });
186
+ }
187
+ else {
188
+ const ls = await run(gitPath, ['ls-files'], { cwd: root ?? project, timeoutMs: 30_000 });
189
+ const files = ls.stdout.split('\n').filter(Boolean);
190
+ const tracked = files.filter((f) => /^\.env(\..+)?$/.test(basename(f)) && !/\.(example|sample)$/.test(f));
191
+ if (tracked.length === 0)
192
+ checks.push({ id: 'env-tracked', required: true, status: 'pass', reason: `no .env among ${files.length} tracked file${files.length === 1 ? '' : 's'}.`, fix: null, kind: null, data: { repository: true, tracked: files.length } });
193
+ else
194
+ checks.push({ id: 'env-tracked', required: true, status: 'fail', reason: `${tracked.join(', ')} ${tracked.length === 1 ? 'is' : 'are'} tracked by git.`, fix: fixFor('env-tracked'), kind: 'env-tracked', data: { tracked } });
195
+ }
196
+ }
197
+ // vendor/ against the scaffolder's record
198
+ if (!root) {
199
+ checks.push({ id: 'vendor', required: true, status: 'pass', reason: 'not a scaffold (no vendor/redbelly-*), so nothing to compare.', fix: null, kind: null, data: { scaffold: false } });
200
+ }
201
+ else {
202
+ const pkg = readJson(join(root, 'package.json'));
203
+ const record = pkg?.redbelly?.vendor;
204
+ const vendored = {};
205
+ for (const dir of readdirSync(join(root, 'vendor')).filter((d) => d.startsWith('redbelly-')).sort()) {
206
+ const v = readJson(join(root, 'vendor', dir, 'package.json'));
207
+ vendored[dir] = v?.version ?? null;
208
+ }
209
+ if (!record) {
210
+ checks.push({ id: 'vendor', required: true, status: 'warn', reason: `${Object.keys(vendored).length} vendored package${Object.keys(vendored).length === 1 ? '' : 's'} (${Object.entries(vendored).map(([d, v]) => `${d} ${v ?? '?'}`).join(', ')}); this scaffold predates the version record in package.json, so nothing to compare against.`, fix: null, kind: null, data: { vendored, record: null } });
211
+ }
212
+ else {
213
+ const drift = Object.entries(record).filter(([dir, want]) => vendored[dir] !== want).map(([dir, want]) => `vendor/${dir} is ${vendored[dir] ?? 'missing'}, the record says ${want}`);
214
+ if (drift.length === 0)
215
+ checks.push({ id: 'vendor', required: true, status: 'pass', reason: `vendor/ matches the record: ${Object.entries(record).map(([d, v]) => `${d} ${v}`).join(', ')}.`, fix: null, kind: null, data: { vendored, record, scaffolder: pkg?.redbelly?.scaffolder ?? null } });
216
+ else
217
+ checks.push({ id: 'vendor', required: true, status: 'fail', reason: `${drift.join('; ')}.`, fix: fixFor('vendor-drift'), kind: 'vendor-drift', data: { vendored, record, scaffolder: pkg?.redbelly?.scaffolder ?? null } });
218
+ }
219
+ }
220
+ return {
221
+ tool: 'redbelly-doctor',
222
+ version: DOCTOR_VERSION,
223
+ generatedAt: new Date().toISOString(),
224
+ project,
225
+ scaffold: root !== null,
226
+ checks,
227
+ ok: checks.every((c) => !(c.required && c.status === 'fail')),
228
+ };
229
+ }
230
+ function readJson(path) {
231
+ try {
232
+ return JSON.parse(readFileSync(path, 'utf8'));
233
+ }
234
+ catch {
235
+ return null;
236
+ }
237
+ }
238
+ export function renderDoctorText(report) {
239
+ const lines = [];
240
+ lines.push(`redbelly-doctor ${report.version} project ${report.project}${report.scaffold ? ' (scaffold)' : ''}`);
241
+ lines.push('');
242
+ for (const c of report.checks)
243
+ lines.push(`${c.status.padEnd(5)} ${c.id.padEnd(18)} ${c.reason}${c.fix && c.status !== 'pass' ? ` Fix: ${c.fix}` : ''}`);
244
+ lines.push('');
245
+ const fails = report.checks.filter((c) => c.required && c.status === 'fail').length;
246
+ const warns = report.checks.filter((c) => c.status === 'warn').length;
247
+ lines.push(report.ok
248
+ ? `ok: every required check passed${warns ? ` (${warns} warning${warns === 1 ? '' : 's'}, optional)` : ''}. Next: npm run dev in a scaffold, or create-redbelly-dapp my-app --yes to make one.`
249
+ : `not ok: ${fails} required check${fails === 1 ? '' : 's'} failed. Run each Fix above, then redbelly-doctor again.`);
250
+ return lines.join('\n') + '\n';
251
+ }
@@ -0,0 +1,7 @@
1
+ export declare const ENV_NAMES: {
2
+ readonly deployer: "DEPLOYER";
3
+ readonly admin: "ADMIN_SAFE";
4
+ readonly chain: "CHAIN_ID";
5
+ readonly legacyAdmin: "PREFLIGHT_ADMIN";
6
+ };
7
+ export declare function readDotEnv(project: string, env: NodeJS.ProcessEnv): Record<string, string>;
package/dist/dotenv.js ADDED
@@ -0,0 +1,28 @@
1
+ // Reads KEY=VALUE lines from a project's .env without executing it, for the names the
2
+ // scaffolder's templates use (DEPLOYER, ADMIN_SAFE, CHAIN_ID). Values already in the
3
+ // process environment win. Nothing here is written back and secrets are not looked for; the
4
+ // git-secrets check covers what should never be in that file.
5
+ import { existsSync, readFileSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+ export const ENV_NAMES = { deployer: 'DEPLOYER', admin: 'ADMIN_SAFE', chain: 'CHAIN_ID', legacyAdmin: 'PREFLIGHT_ADMIN' };
8
+ export function readDotEnv(project, env) {
9
+ const out = {};
10
+ const file = join(project, '.env');
11
+ if (existsSync(file)) {
12
+ for (const raw of readFileSync(file, 'utf8').split(/\r?\n/)) {
13
+ const line = raw.trim();
14
+ if (!line || line.startsWith('#'))
15
+ continue;
16
+ const m = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
17
+ if (!m)
18
+ continue;
19
+ out[m[1]] = m[2].replace(/^(["'])(.*)\1$/, '$2').trim();
20
+ }
21
+ }
22
+ for (const name of Object.values(ENV_NAMES)) {
23
+ const v = env[name];
24
+ if (v !== undefined && v !== '')
25
+ out[name] = v;
26
+ }
27
+ return out;
28
+ }
@@ -0,0 +1,8 @@
1
+ import type { GasOptions } from './gas.js';
2
+ export declare const GAS_HELP = "redbelly gas [options]\n\nPrices a gas snapshot or a forge gas report at the chain's latest base fee and on-chain feed\nprice, one row per test or function, in RBNT and US cents. Reads .gas-snapshot when the project\nhas one, else runs forge test --gas-report --json. Read-only: two RPC reads, no key.\n\n --project <dir> Foundry project or scaffold root (default: current directory)\n --chain <151|153> chain to price on (default 153); the RPC must report it\n --rpc <url> JSON-RPC endpoint (default: the governors RPC for --chain; a fork works)\n --snapshot <file> price this .gas-snapshot or gas-report JSON instead of the project's\n --report run forge test --gas-report --json even when .gas-snapshot exists\n --diff <file> a previous .gas-snapshot or gas-report JSON to compare against\n --json print the report as JSON\n --version, --help\n";
3
+ export declare function parseGasArgs(argv: readonly string[]): GasOptions & {
4
+ json: boolean;
5
+ help: boolean;
6
+ version: boolean;
7
+ };
8
+ export declare function gasMain(argv?: readonly string[]): Promise<number>;
@@ -0,0 +1,100 @@
1
+ // `redbelly gas`: the gas report in RBNT and US cents. Exit 0 when priced, 1 when the source could
2
+ // not be read or the RPC did not answer, 2 on bad arguments. --json for agents.
3
+ import { GAS_VERSION, gasReportJson, renderGasText, runGas } from './gas.js';
4
+ export const GAS_HELP = `redbelly gas [options]
5
+
6
+ Prices a gas snapshot or a forge gas report at the chain's latest base fee and on-chain feed
7
+ price, one row per test or function, in RBNT and US cents. Reads .gas-snapshot when the project
8
+ has one, else runs forge test --gas-report --json. Read-only: two RPC reads, no key.
9
+
10
+ --project <dir> Foundry project or scaffold root (default: current directory)
11
+ --chain <151|153> chain to price on (default 153); the RPC must report it
12
+ --rpc <url> JSON-RPC endpoint (default: the governors RPC for --chain; a fork works)
13
+ --snapshot <file> price this .gas-snapshot or gas-report JSON instead of the project's
14
+ --report run forge test --gas-report --json even when .gas-snapshot exists
15
+ --diff <file> a previous .gas-snapshot or gas-report JSON to compare against
16
+ --json print the report as JSON
17
+ --version, --help
18
+ `;
19
+ export function parseGasArgs(argv) {
20
+ const o = { json: false, help: false, version: false };
21
+ for (let i = 0; i < argv.length; i++) {
22
+ const a = argv[i];
23
+ const [flag, inline] = a.includes('=') ? [a.slice(0, a.indexOf('=')), a.slice(a.indexOf('=') + 1)] : [a, undefined];
24
+ const next = () => {
25
+ if (inline !== undefined)
26
+ return inline;
27
+ const v = argv[++i];
28
+ if (v === undefined)
29
+ throw new Error(`${flag} needs a value`);
30
+ return v;
31
+ };
32
+ switch (flag) {
33
+ case '--project':
34
+ o.project = next();
35
+ break;
36
+ case '--chain': {
37
+ const c = Number(next());
38
+ if (c !== 151 && c !== 153)
39
+ throw new Error('--chain must be 151 or 153');
40
+ o.chain = c;
41
+ break;
42
+ }
43
+ case '--rpc':
44
+ o.rpc = next();
45
+ break;
46
+ case '--snapshot':
47
+ o.snapshot = next();
48
+ break;
49
+ case '--report':
50
+ o.report = true;
51
+ break;
52
+ case '--diff':
53
+ o.diff = next();
54
+ break;
55
+ case '--json':
56
+ o.json = true;
57
+ break;
58
+ case '--help':
59
+ case '-h':
60
+ o.help = true;
61
+ break;
62
+ case '--version':
63
+ o.version = true;
64
+ break;
65
+ default:
66
+ if (/^(0x)?[0-9a-fA-F]{64}$/.test(a))
67
+ throw new Error('that argument looks like a private key; redbelly gas reads the chain and never takes one');
68
+ throw new Error(`unknown argument "${a}"\n\n${GAS_HELP}`);
69
+ }
70
+ }
71
+ return o;
72
+ }
73
+ export async function gasMain(argv = process.argv.slice(2)) {
74
+ let args;
75
+ try {
76
+ args = parseGasArgs(argv);
77
+ }
78
+ catch (e) {
79
+ process.stderr.write(`${e.message}\n`);
80
+ return 2;
81
+ }
82
+ if (args.help) {
83
+ process.stdout.write(GAS_HELP);
84
+ return 0;
85
+ }
86
+ if (args.version) {
87
+ process.stdout.write(`${GAS_VERSION}\n`);
88
+ return 0;
89
+ }
90
+ const { json, help: _h, version: _v, ...opts } = args;
91
+ try {
92
+ const report = await runGas(opts);
93
+ process.stdout.write(json ? gasReportJson(report) : renderGasText(report));
94
+ return 0;
95
+ }
96
+ catch (e) {
97
+ process.stderr.write(`redbelly gas: ${e.message}\n`);
98
+ return 1;
99
+ }
100
+ }
package/dist/gas.d.ts ADDED
@@ -0,0 +1,110 @@
1
+ export declare const GAS_VERSION: string;
2
+ export interface GasRow {
3
+ /** `Contract:item`, the key rows are matched on across two files. */
4
+ readonly name: string;
5
+ readonly contract: string;
6
+ /** A test name from a snapshot, a function signature from a gas report, or `deployment`. */
7
+ readonly item: string;
8
+ readonly gas: bigint;
9
+ /** From a gas report: how many calls the mean is over. From a fuzz line: the runs. */
10
+ readonly calls?: number;
11
+ readonly min?: bigint;
12
+ readonly max?: bigint;
13
+ }
14
+ export type GasSource = {
15
+ readonly kind: 'snapshot';
16
+ readonly file: string;
17
+ } | {
18
+ readonly kind: 'gas-report';
19
+ readonly command: string;
20
+ } | {
21
+ readonly kind: 'gas-report-file';
22
+ readonly file: string;
23
+ };
24
+ export interface GasQuote {
25
+ readonly chain: 151 | 153;
26
+ readonly network: 'mainnet' | 'testnet';
27
+ readonly rpc: string;
28
+ readonly block: number;
29
+ readonly baseFeeWei: bigint;
30
+ /** The feed's raw integer (six decimals) and the derived USD per RBNT. */
31
+ readonly priceRaw: bigint;
32
+ readonly usdPerRbnt: number;
33
+ readonly priceTimestamp: string;
34
+ readonly priceFeed: string;
35
+ }
36
+ export interface PricedRow extends GasRow {
37
+ readonly wei: bigint;
38
+ readonly rbnt: string;
39
+ readonly usd: number;
40
+ /** US cents to three decimal places, as a number. */
41
+ readonly cents: number;
42
+ readonly before?: {
43
+ readonly gas: bigint;
44
+ readonly cents: number;
45
+ };
46
+ readonly delta?: {
47
+ readonly gas: bigint;
48
+ readonly cents: number;
49
+ };
50
+ }
51
+ export interface GasReport {
52
+ readonly tool: 'redbelly-gas';
53
+ readonly version: string;
54
+ readonly generatedAt: string;
55
+ readonly project: string;
56
+ readonly source: GasSource;
57
+ readonly quote: GasQuote;
58
+ readonly rows: readonly PricedRow[];
59
+ readonly diff: {
60
+ readonly file: string;
61
+ readonly rows: number;
62
+ readonly added: readonly string[];
63
+ readonly removed: readonly string[];
64
+ } | null;
65
+ }
66
+ export interface GasOptions {
67
+ /** A Foundry project, or a scaffold root (its contracts/ is used). Default: cwd. */
68
+ project?: string;
69
+ chain?: 151 | 153;
70
+ rpc?: string;
71
+ /** A snapshot or gas-report JSON file to price instead of the project's .gas-snapshot. */
72
+ snapshot?: string;
73
+ /** Run `forge test --gas-report --json` even when a .gas-snapshot exists. */
74
+ report?: boolean;
75
+ /** A previous snapshot or gas-report JSON to compare against. */
76
+ diff?: string;
77
+ env?: NodeJS.ProcessEnv;
78
+ }
79
+ /** `.gas-snapshot`: `Contract:test() (gas: N)`, fuzz lines by their mean (μ), invariant lines (no gas) skipped. */
80
+ export declare function parseGasSnapshot(text: string): GasRow[];
81
+ /** `forge test --gas-report --json`: the deployment row, then each function by its mean. Contract names lose their path. */
82
+ export declare function parseGasReportJson(json: unknown): GasRow[];
83
+ /** Either format, by content: JSON that parses as an array is a gas report; anything else is a snapshot. */
84
+ export declare function parseGasFile(text: string): {
85
+ kind: 'snapshot' | 'gas-report-file';
86
+ rows: GasRow[];
87
+ };
88
+ /** wei = gas * baseFee; thousandths of a US cent = wei * raw / 10^(18 + 6 - 5). Only the last step leaves BigInt. */
89
+ export declare function priceGas(gas: bigint, quote: Pick<GasQuote, 'baseFeeWei' | 'priceRaw'>): {
90
+ wei: bigint;
91
+ rbnt: string;
92
+ usd: number;
93
+ cents: number;
94
+ };
95
+ export declare function priceRows(rows: readonly GasRow[], quote: Pick<GasQuote, 'baseFeeWei' | 'priceRaw'>, before?: readonly GasRow[]): {
96
+ rows: PricedRow[];
97
+ added: string[];
98
+ removed: string[];
99
+ };
100
+ export declare function quoteGas(rpc: string, expected?: 151 | 153): Promise<GasQuote>;
101
+ /** A scaffold root's contracts/ when that is where foundry.toml is; otherwise the directory itself. */
102
+ export declare function foundryDir(project: string): string;
103
+ export declare function readGasSource(dir: string, opts: Pick<GasOptions, 'snapshot' | 'report' | 'env'>): Promise<{
104
+ source: GasSource;
105
+ rows: GasRow[];
106
+ }>;
107
+ export declare function runGas(opts?: GasOptions): Promise<GasReport>;
108
+ export declare function renderGasText(report: GasReport): string;
109
+ /** JSON with BigInt as decimal strings. */
110
+ export declare function gasReportJson(report: GasReport): string;