npmguard-cli 1.1.2 → 1.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,7 +7,7 @@ engine before it touches your `node_modules`.
7
7
  npx npmguard-cli install express
8
8
  ```
9
9
 
10
- - **SAFE** → installs immediately
10
+ - **SAFE** → installs immediately from the configured source
11
11
  - **DANGEROUS** → warns, shows findings, asks before installing
12
12
  - **No audit yet** → offers to pay for one (Stripe or crypto), then streams
13
13
  the results in real time
@@ -55,12 +55,31 @@ lockfiles and runs the correct add command (`npm install`, `pnpm add`,
55
55
  (bypass with `--force`)
56
56
  5. **Not found** → asks how you want to pay for the audit:
57
57
  - Stripe (credit card) — browser checkout via QR
58
- - WalletConnectmobile wallet signs a tx on Base Sepolia (~$0.30)
58
+ - Browser wallet MetaMask/Rabby signs in Brave or Chrome
59
+ - WalletConnect — mobile wallet signs a tx on Base Sepolia
59
60
  - Install without audit (yolo)
60
61
  - Cancel
61
- 6. Streams audit events live (phases, findings, verdict)
62
+ 6. Streams audit events live for Stripe/WalletConnect, or waits for the
63
+ report when the browser-wallet page owns the live view
62
64
  7. Runs the install if the verdict is SAFE, or prompts otherwise
63
65
 
66
+ The install source is configurable:
67
+
68
+ ```bash
69
+ # default: audited verdict, then normal registry install
70
+ npmguard install express --install-source npm
71
+
72
+ # audited verdict, then install the tarball published by NpmGuard on Pinata
73
+ npmguard install express --install-source pinata
74
+
75
+ # audited verdict, then resolve ENS text records to find the Pinata tarball
76
+ npmguard install express --install-source ens
77
+ ```
78
+
79
+ The source choice never replaces server-side verification. NpmGuard always
80
+ checks the report store first; `pinata` and `ens` only decide where the SAFE
81
+ package bytes come from.
82
+
64
83
  ### `npmguard audit <package>[@version]`
65
84
 
66
85
  Run a standalone audit without installing. Returns the verdict and exits.
@@ -70,7 +89,9 @@ npmguard audit is-number
70
89
  npmguard audit express@5.2.1
71
90
  ```
72
91
 
73
- Same payment flow as `install` if the package hasn't been audited yet.
92
+ If the package hasn't been audited yet, the standalone audit command starts
93
+ the Stripe checkout flow. Use `install` for the interactive browser-wallet
94
+ and WalletConnect choices.
74
95
 
75
96
  ### `npmguard check [--path <dir>]`
76
97
 
@@ -87,7 +108,7 @@ npmguard check --path /path/to/other-project
87
108
  ## Payment options
88
109
 
89
110
  When a package hasn't been audited yet, an audit run costs real compute
90
- (LLM calls, sandbox execution). Two ways to pay:
111
+ (LLM calls, sandbox execution). Three ways to pay:
91
112
 
92
113
  ### Stripe (fiat)
93
114
 
@@ -95,11 +116,26 @@ Opens a Stripe checkout page in the browser. After payment, the engine
95
116
  triggers the audit automatically. Works from any machine, no wallet
96
117
  required.
97
118
 
119
+ ### Browser wallet (crypto)
120
+
121
+ The CLI prints a `https://npmguard.com/pay?...` URL and can open it in your
122
+ default browser. Open that page in Brave or Chrome with MetaMask/Rabby
123
+ enabled, connect the wallet, and confirm the Base Sepolia transaction. The
124
+ browser starts the server-side audit with the tx hash, while the CLI waits
125
+ for the persisted report before deciding whether to install.
126
+
127
+ This is the desktop-friendly flow for extension wallets.
128
+
98
129
  ### WalletConnect (crypto)
99
130
 
100
131
  The CLI generates a WalletConnect v2 QR code in the terminal. Scan it with
101
132
  any mobile wallet (MetaMask, Rainbow, Coinbase Wallet, etc.) and confirm
102
- the transaction.
133
+ the transaction. It also prints the raw WalletConnect URI for desktop
134
+ wallets that support a "connect by URI" flow.
135
+
136
+ Browser extension wallets do not automatically connect to a terminal
137
+ process. If your wallet only works as a Brave/Chrome extension, use the
138
+ browser wallet option instead.
103
139
 
104
140
  - **Chain**: Base Sepolia (testnet — free ETH from
105
141
  [Alchemy faucet](https://www.alchemy.com/faucets/base-sepolia))
@@ -108,7 +144,7 @@ the transaction.
108
144
 
109
145
  Flow:
110
146
 
111
- 1. CLI reads the fee from the contract
147
+ 1. CLI asks the engine for public crypto config (contract + fee), with a direct contract-read fallback
112
148
  2. You approve the tx in your wallet
113
149
  3. Engine verifies the receipt on Base Sepolia via Alchemy
114
150
  4. Audit starts, CLI streams events
@@ -120,20 +156,44 @@ against your request before launching the audit.
120
156
  ## Configuration
121
157
 
122
158
  The CLI talks to `https://npmguard.com` by default. You can override the
123
- API URL for local development:
159
+ API URL and the web app URL for local development:
124
160
 
125
161
  ```bash
126
162
  # via flag
127
- npmguard --api http://localhost:8000 install lodash
163
+ npmguard --api http://localhost:8000 --web http://localhost:3000 install lodash
128
164
 
129
165
  # via env
130
166
  export NPMGUARD_API_URL=http://localhost:8000
167
+ export NPMGUARD_WEB_URL=http://localhost:3000
131
168
  npmguard install lodash
132
169
  ```
133
170
 
134
- No blockchain config is required from the user — the CLI reads the
135
- contract address + chain from its own code. Your wallet's RPC handles the
136
- broadcast.
171
+ Install source:
172
+
173
+ ```bash
174
+ # via flag
175
+ npmguard install lodash --install-source ens
176
+
177
+ # via env
178
+ export NPMGUARD_INSTALL_SOURCE=pinata
179
+ export NPMGUARD_ENS_ROOT_DOMAIN=npmguard-demo.eth
180
+ export NPMGUARD_ENS_RPC_URL=https://ethereum-sepolia-rpc.publicnode.com
181
+ npmguard install lodash
182
+ ```
183
+
184
+ Available values:
185
+
186
+ | Source | Behavior |
187
+ |---|---|
188
+ | `npm` | Installs `<package>@<version>` from the normal npm registry |
189
+ | `pinata` | Reads `/package/<name>/storage?version=<version>` from NpmGuard and installs the pinned tarball URL |
190
+ | `ens` | Resolves Sepolia ENS `npmguard.*` text records and installs the announced Pinata tarball |
191
+
192
+ No private key or paid RPC config is required from the user. For the
193
+ WalletConnect path, the CLI uses `viem` with a public Base Sepolia RPC to
194
+ read/confirm the transaction for local UX; your wallet broadcasts the tx,
195
+ and the engine re-verifies the receipt with its own RPC before starting the
196
+ audit.
137
197
 
138
198
  ## Exit codes
139
199
 
package/dist/api.js CHANGED
@@ -10,6 +10,9 @@ async function request(url, options) {
10
10
  }
11
11
  return res.json();
12
12
  }
13
+ export async function getPublicConfig(apiUrl) {
14
+ return request(`${apiUrl}/config/public`);
15
+ }
13
16
  export async function checkout(apiUrl, packageName, version) {
14
17
  const body = { packageName };
15
18
  if (version)
@@ -75,3 +78,16 @@ export async function getPackageReport(apiUrl, packageName, version) {
75
78
  throw err;
76
79
  }
77
80
  }
81
+ export async function getPackageStorage(apiUrl, packageName, version) {
82
+ const query = `?version=${encodeURIComponent(version)}`;
83
+ const url = `${apiUrl}/package/${encodeURIComponent(packageName)}/storage${query}`;
84
+ try {
85
+ return await request(url);
86
+ }
87
+ catch (err) {
88
+ if (err instanceof Error && err.message.startsWith("HTTP 404")) {
89
+ return null;
90
+ }
91
+ throw err;
92
+ }
93
+ }
@@ -3,22 +3,51 @@ import ora from "ora";
3
3
  import { spawnSync } from "node:child_process";
4
4
  import { formatEther } from "viem";
5
5
  import * as api from "../api.js";
6
- import { parsePackageArg, prompt, resolveLatestVersion, detectPackageManager, } from "../utils.js";
6
+ import { parsePackageArg, prompt, resolveLatestVersion, detectPackageManager, openExternalUrl, } from "../utils.js";
7
7
  import { auditCommand } from "./audit.js";
8
8
  import { payViaWalletConnect, readAuditFee } from "../wallet/walletconnect.js";
9
9
  import { streamAuditEvents } from "../stream.js";
10
+ import { defaultEnsRootDomain, defaultEnsRpcUrl, normalizeInstallSource, resolveEnsInstallSpec, resolvePinataInstallSpec, } from "../install-source.js";
10
11
  /**
11
12
  * Run the correct "add this package" command for the detected package manager.
12
13
  * `npm install <pkg>` adds the package. `pnpm add <pkg>` / `yarn add <pkg>` do
13
14
  * the same. Crucially, `yarn install <pkg>` would ignore <pkg> in yarn classic.
14
15
  */
15
- function runInstall(packageSpec) {
16
+ function runInstall(packageSpec, label) {
16
17
  const pm = detectPackageManager();
17
18
  const verb = pm === "npm" ? "install" : "add";
18
- console.log(chalk.gray(`\n Running: ${pm} ${verb} ${packageSpec}\n`));
19
+ console.log(chalk.gray(`\n Running: ${pm} ${verb} ${packageSpec}${label ? `\n Source: ${label}` : ""}\n`));
19
20
  const res = spawnSync(pm, [verb, packageSpec], { stdio: "inherit" });
20
21
  return res.status ?? 1;
21
22
  }
23
+ async function resolveInstallTarget(fullSpec, packageName, version, opts) {
24
+ const source = normalizeInstallSource(opts.installSource);
25
+ if (source === "npm") {
26
+ return { spec: fullSpec, detail: "npm registry" };
27
+ }
28
+ if (source === "pinata") {
29
+ return resolvePinataInstallSpec(opts.api, packageName, version);
30
+ }
31
+ return resolveEnsInstallSpec({
32
+ packageName,
33
+ version,
34
+ rootDomain: opts.ensRoot ?? defaultEnsRootDomain(),
35
+ rpcUrl: opts.ensRpc ?? defaultEnsRpcUrl(),
36
+ });
37
+ }
38
+ async function installSafePackage(fullSpec, packageName, version, opts) {
39
+ let target;
40
+ try {
41
+ target = await resolveInstallTarget(fullSpec, packageName, version, opts);
42
+ }
43
+ catch (err) {
44
+ console.error(chalk.red("Could not resolve install source: " +
45
+ (err instanceof Error ? err.message : String(err))));
46
+ console.log(chalk.gray(" Retry with --install-source npm to install from the npm registry."));
47
+ process.exit(1);
48
+ }
49
+ process.exit(runInstall(target.spec, target.detail));
50
+ }
22
51
  function extractVerdict(report) {
23
52
  const nested = report.report?.verdict;
24
53
  return (nested ?? report.verdict ?? "UNKNOWN").toUpperCase();
@@ -30,6 +59,7 @@ function extractCapabilities(report) {
30
59
  }
31
60
  export async function installCommand(packageSpec, opts) {
32
61
  const apiUrl = opts.api;
62
+ const webUrl = opts.web;
33
63
  let parsed;
34
64
  try {
35
65
  parsed = parsePackageArg(packageSpec);
@@ -65,7 +95,7 @@ export async function installCommand(packageSpec, opts) {
65
95
  }
66
96
  spinner.stop();
67
97
  if (report) {
68
- handleExistingReport(report, name, fullSpec, apiUrl, opts);
98
+ await handleExistingReport(report, name, version, fullSpec, apiUrl, opts);
69
99
  return;
70
100
  }
71
101
  // No audit found — ask how to pay
@@ -73,27 +103,32 @@ export async function installCommand(packageSpec, opts) {
73
103
  console.log();
74
104
  console.log(chalk.bold(" How do you want to pay for the audit?"));
75
105
  console.log(" 1) Stripe (credit card)");
76
- console.log(" 2) WalletConnectETH on Base Sepolia");
77
- console.log(" 3) Install without audit (at your own risk)");
78
- console.log(" 4) Cancel");
106
+ console.log(" 2) Browser wallet MetaMask/Rabby in Brave or Chrome");
107
+ console.log(" 3) WalletConnect QR/mobile wallet");
108
+ console.log(" 4) Install without audit (at your own risk)");
109
+ console.log(" 5) Cancel");
79
110
  console.log();
80
- const choice = await prompt(" Choice [1/2/3/4]: ");
111
+ const choice = await prompt(" Choice [1/2/3/4/5]: ");
81
112
  if (choice === "1") {
82
- await runStripeAuditAndInstall(fullSpec, name, version, apiUrl);
113
+ await runStripeAuditAndInstall(fullSpec, name, version, apiUrl, opts);
83
114
  return;
84
115
  }
85
116
  if (choice === "2") {
86
- await runCryptoAuditAndInstall(fullSpec, name, version, apiUrl);
117
+ await runBrowserWalletAuditAndInstall(fullSpec, name, version, apiUrl, webUrl, opts);
87
118
  return;
88
119
  }
89
120
  if (choice === "3") {
121
+ await runCryptoAuditAndInstall(fullSpec, name, version, apiUrl, opts);
122
+ return;
123
+ }
124
+ if (choice === "4") {
90
125
  console.log(chalk.yellow(" Installing without audit. Proceed at your own risk."));
91
- process.exit(runInstall(fullSpec));
126
+ process.exit(runInstall(fullSpec, "npm registry (audit skipped)"));
92
127
  }
93
128
  console.log(chalk.gray(" Cancelled."));
94
129
  process.exit(0);
95
130
  }
96
- function handleExistingReport(report, name, fullSpec, apiUrl, opts) {
131
+ async function handleExistingReport(report, name, version, fullSpec, apiUrl, opts) {
97
132
  const verdict = extractVerdict(report);
98
133
  const capabilities = extractCapabilities(report);
99
134
  if (verdict === "SAFE") {
@@ -101,7 +136,8 @@ function handleExistingReport(report, name, fullSpec, apiUrl, opts) {
101
136
  if (capabilities.length > 0) {
102
137
  console.log(chalk.gray(` Capabilities: ${capabilities.join(", ")}`));
103
138
  }
104
- process.exit(runInstall(fullSpec));
139
+ await installSafePackage(fullSpec, name, version, opts);
140
+ return;
105
141
  }
106
142
  if (verdict === "DANGEROUS" || verdict === "CRITICAL" || verdict === "WARNING") {
107
143
  console.log(chalk.bgRed.white.bold(` ${verdict} `));
@@ -113,23 +149,24 @@ function handleExistingReport(report, name, fullSpec, apiUrl, opts) {
113
149
  console.log();
114
150
  if (opts.force) {
115
151
  console.log(chalk.yellow(" --force passed, installing anyway..."));
116
- process.exit(runInstall(fullSpec));
152
+ await installSafePackage(fullSpec, name, version, opts);
153
+ return;
117
154
  }
118
- promptAndInstallIfAccepted(fullSpec, " Install anyway? This package is flagged. (y/N) ");
155
+ await promptAndInstallIfAccepted(fullSpec, name, version, opts, " Install anyway? This package is flagged. (y/N) ");
119
156
  return;
120
157
  }
121
158
  console.log(chalk.yellow(` Verdict: ${verdict}`));
122
- promptAndInstallIfAccepted(fullSpec, " Proceed with install? (y/N) ");
159
+ await promptAndInstallIfAccepted(fullSpec, name, version, opts, " Proceed with install? (y/N) ");
123
160
  }
124
- async function promptAndInstallIfAccepted(fullSpec, question) {
161
+ async function promptAndInstallIfAccepted(fullSpec, name, version, opts, question) {
125
162
  const answer = await prompt(chalk.red.bold(question));
126
163
  if (answer === "y" || answer === "yes") {
127
- process.exit(runInstall(fullSpec));
164
+ await installSafePackage(fullSpec, name, version, opts);
128
165
  }
129
166
  console.log(chalk.gray(" Aborted."));
130
167
  process.exit(1);
131
168
  }
132
- async function runStripeAuditAndInstall(fullSpec, name, version, apiUrl) {
169
+ async function runStripeAuditAndInstall(fullSpec, name, version, apiUrl, opts) {
133
170
  try {
134
171
  await auditCommand(fullSpec, { api: apiUrl, exit: false });
135
172
  }
@@ -137,19 +174,89 @@ async function runStripeAuditAndInstall(fullSpec, name, version, apiUrl) {
137
174
  console.error(chalk.red("Audit failed: " + (err instanceof Error ? err.message : String(err))));
138
175
  process.exit(1);
139
176
  }
140
- await finalizeAfterAudit(fullSpec, name, version, apiUrl);
177
+ await finalizeAfterAudit(fullSpec, name, version, apiUrl, opts);
141
178
  }
142
- async function runCryptoAuditAndInstall(fullSpec, name, version, apiUrl) {
143
- // 1. Read current fee from contract
144
- let feeWei;
179
+ function buildBrowserWalletUrl(webUrl, packageName, version) {
180
+ const url = new URL("/pay", webUrl.replace(/\/+$/, ""));
181
+ url.searchParams.set("packageName", packageName);
182
+ url.searchParams.set("version", version);
183
+ url.searchParams.set("source", "cli");
184
+ return url.toString();
185
+ }
186
+ function sleep(ms) {
187
+ return new Promise((resolve) => setTimeout(resolve, ms));
188
+ }
189
+ async function waitForPackageReport(apiUrl, packageName, version, timeoutMs) {
190
+ const deadline = Date.now() + timeoutMs;
191
+ while (Date.now() < deadline) {
192
+ const report = await api.getPackageReport(apiUrl, packageName, version);
193
+ if (report)
194
+ return report;
195
+ await sleep(3000);
196
+ }
197
+ return null;
198
+ }
199
+ async function runBrowserWalletAuditAndInstall(fullSpec, name, version, apiUrl, webUrl, opts) {
200
+ const paymentUrl = buildBrowserWalletUrl(webUrl, name, version);
201
+ console.log();
202
+ console.log(chalk.bold(" Open this URL in Brave or Chrome with MetaMask/Rabby:"));
203
+ console.log(chalk.blue.underline(` ${paymentUrl}`));
204
+ console.log();
205
+ console.log(chalk.gray(" The browser page will connect your wallet, sign the Base Sepolia tx, and start the audit."));
206
+ console.log(chalk.gray(" Keep this terminal open; it will continue when the report is ready."));
207
+ console.log();
208
+ const openAnswer = await prompt(" Open it now in your default browser? (Y/n) ");
209
+ if (openAnswer !== "n" && openAnswer !== "no") {
210
+ const opened = openExternalUrl(paymentUrl);
211
+ if (!opened) {
212
+ console.log(chalk.yellow(" Could not open automatically. Copy the URL above."));
213
+ }
214
+ }
215
+ const spinner = ora(" Waiting for browser payment and audit report...").start();
216
+ let report = null;
145
217
  try {
146
- feeWei = await readAuditFee();
218
+ report = await waitForPackageReport(apiUrl, name, version, 30 * 60 * 1000);
147
219
  }
148
220
  catch (err) {
149
- console.error(chalk.red("Could not read fee from contract: " +
150
- (err instanceof Error ? err.message : String(err))));
221
+ spinner.fail("Could not read report: " +
222
+ (err instanceof Error ? err.message : String(err)));
151
223
  process.exit(1);
152
224
  }
225
+ if (!report) {
226
+ spinner.fail("Timed out waiting for the browser audit report.");
227
+ console.log(chalk.gray(` Check ${webUrl.replace(/\/+$/, "")}/package/${encodeURIComponent(name)}`));
228
+ process.exit(1);
229
+ }
230
+ spinner.succeed("Audit report received");
231
+ await finalizeWithReport(fullSpec, name, version, report, opts);
232
+ }
233
+ async function runCryptoAuditAndInstall(fullSpec, name, version, apiUrl, opts) {
234
+ // 1. Read current fee from the engine's public config, then fall back to
235
+ // direct contract read if the engine is older or config is unavailable.
236
+ let feeWei;
237
+ let contractAddress;
238
+ try {
239
+ const publicConfig = await api.getPublicConfig(apiUrl);
240
+ const contract = publicConfig.crypto?.contract;
241
+ const auditFeeWei = publicConfig.crypto?.auditFeeWei;
242
+ if (contract && /^0x[0-9a-fA-F]{40}$/.test(contract) && auditFeeWei) {
243
+ contractAddress = contract;
244
+ feeWei = BigInt(auditFeeWei);
245
+ }
246
+ else {
247
+ feeWei = await readAuditFee();
248
+ }
249
+ }
250
+ catch (err) {
251
+ try {
252
+ feeWei = await readAuditFee();
253
+ }
254
+ catch {
255
+ console.error(chalk.red("Could not read fee from engine or contract: " +
256
+ (err instanceof Error ? err.message : String(err))));
257
+ process.exit(1);
258
+ }
259
+ }
153
260
  const feeDisplay = `${formatEther(feeWei)} ETH`;
154
261
  const confirm = await prompt(chalk.yellow(` Pay ${feeDisplay} on Base Sepolia? (y/N) `));
155
262
  if (confirm !== "y" && confirm !== "yes") {
@@ -157,7 +264,7 @@ async function runCryptoAuditAndInstall(fullSpec, name, version, apiUrl) {
157
264
  process.exit(0);
158
265
  }
159
266
  // 2. WalletConnect → user signs → we get txHash
160
- const result = await payViaWalletConnect(name, version, feeWei, feeDisplay);
267
+ const result = await payViaWalletConnect(name, version, feeWei, feeDisplay, contractAddress);
161
268
  if (!result.paid || !result.txHash) {
162
269
  console.log(chalk.red(" Payment failed, aborting."));
163
270
  process.exit(1);
@@ -181,23 +288,26 @@ async function runCryptoAuditAndInstall(fullSpec, name, version, apiUrl) {
181
288
  // would trigger a second, unpaid audit via /checkout).
182
289
  await streamAuditEvents(apiUrl, auditId);
183
290
  // 5. Fetch the persisted report and decide whether to install
184
- await finalizeAfterAudit(fullSpec, name, version, apiUrl);
291
+ await finalizeAfterAudit(fullSpec, name, version, apiUrl, opts);
185
292
  }
186
- async function finalizeAfterAudit(fullSpec, name, version, apiUrl) {
293
+ async function finalizeAfterAudit(fullSpec, name, version, apiUrl, opts) {
187
294
  const freshReport = await api.getPackageReport(apiUrl, name, version);
188
295
  if (!freshReport) {
189
296
  console.log(chalk.red(" Audit finished but report not found."));
190
297
  process.exit(1);
191
298
  }
192
- const verdict = extractVerdict(freshReport);
299
+ await finalizeWithReport(fullSpec, name, version, freshReport, opts);
300
+ }
301
+ async function finalizeWithReport(fullSpec, name, version, report, opts) {
302
+ const verdict = extractVerdict(report);
193
303
  if (verdict === "SAFE") {
194
304
  console.log(chalk.green("\n ✓ SAFE — proceeding with install"));
195
- process.exit(runInstall(fullSpec));
305
+ await installSafePackage(fullSpec, name, version, opts);
196
306
  }
197
307
  console.log(chalk.red(`\n Audit verdict: ${verdict}`));
198
308
  const confirm = await prompt(chalk.red.bold(" Install anyway? (y/N) "));
199
309
  if (confirm === "y" || confirm === "yes") {
200
- process.exit(runInstall(fullSpec));
310
+ await installSafePackage(fullSpec, name, version, opts);
201
311
  }
202
312
  process.exit(1);
203
313
  }
package/dist/index.js CHANGED
@@ -4,6 +4,7 @@ import { readFileSync } from "node:fs";
4
4
  import { auditCommand } from "./commands/audit.js";
5
5
  import { checkCommand } from "./commands/check.js";
6
6
  import { installCommand } from "./commands/install.js";
7
+ import { defaultEnsRootDomain, defaultEnsRpcUrl, normalizeInstallSource } from "./install-source.js";
7
8
  function readPackageVersion() {
8
9
  try {
9
10
  const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf-8"));
@@ -18,7 +19,8 @@ program
18
19
  .name("npmguard")
19
20
  .description("NpmGuard CLI — audit npm packages for security issues")
20
21
  .version(readPackageVersion())
21
- .option("--api <url>", "NpmGuard engine API URL", process.env.NPMGUARD_API_URL ?? "https://npmguard.com");
22
+ .option("--api <url>", "NpmGuard engine API URL", process.env.NPMGUARD_API_URL ?? "https://npmguard.com")
23
+ .option("--web <url>", "NpmGuard web app URL for browser wallet payments", process.env.NPMGUARD_WEB_URL ?? "https://npmguard.com");
22
24
  program
23
25
  .command("audit")
24
26
  .description("Pay for and run a security audit on an npm package")
@@ -32,9 +34,19 @@ program
32
34
  .description("Install an npm package with NpmGuard security check")
33
35
  .argument("<package>", "Package name, optionally with version (e.g. express@4.18.0)")
34
36
  .option("-f, --force", "Install even if the package is flagged as dangerous")
37
+ .option("--install-source <source>", "Install SAFE packages from npm, pinata, or ens", process.env.NPMGUARD_INSTALL_SOURCE ?? "npm")
38
+ .option("--ens-root <name>", "ENS root domain for --install-source ens", defaultEnsRootDomain())
39
+ .option("--ens-rpc <url>", "Sepolia RPC URL for --install-source ens", defaultEnsRpcUrl())
35
40
  .action(async (pkg, cmdOpts) => {
36
- const apiUrl = program.opts().api;
37
- await installCommand(pkg, { api: apiUrl, force: cmdOpts.force });
41
+ const opts = program.opts();
42
+ await installCommand(pkg, {
43
+ api: opts.api,
44
+ web: opts.web,
45
+ force: cmdOpts.force,
46
+ installSource: normalizeInstallSource(cmdOpts.installSource),
47
+ ensRoot: cmdOpts.ensRoot,
48
+ ensRpc: cmdOpts.ensRpc,
49
+ });
38
50
  });
39
51
  program
40
52
  .command("check")
@@ -0,0 +1,131 @@
1
+ import { createPublicClient, http, parseAbi, toHex, zeroAddress } from "viem";
2
+ import { sepolia } from "viem/chains";
3
+ import { namehash, packetToBytes } from "viem/ens";
4
+ import * as api from "./api.js";
5
+ const DEFAULT_ENS_RPC_URL = "https://ethereum-sepolia-rpc.publicnode.com";
6
+ const DEFAULT_ENS_ROOT_DOMAIN = "npmguard-demo.eth";
7
+ const UNIVERSAL_RESOLVER = "0xeEeEEEeE14D718C2B47D9923Deab1335E144EeEe";
8
+ const universalResolverAbi = parseAbi([
9
+ "function findResolver(bytes name) view returns (address resolver, bytes32 node, uint256 offset)",
10
+ ]);
11
+ const resolverAbi = parseAbi([
12
+ "function text(bytes32 node, string key) view returns (string)",
13
+ ]);
14
+ export function normalizeInstallSource(value) {
15
+ const source = (value ?? "npm").toLowerCase();
16
+ if (source === "npm" || source === "pinata" || source === "ens")
17
+ return source;
18
+ throw new Error(`Invalid install source "${value}". Expected npm, pinata, or ens.`);
19
+ }
20
+ export function defaultEnsRootDomain() {
21
+ return process.env.NPMGUARD_ENS_ROOT_DOMAIN ?? DEFAULT_ENS_ROOT_DOMAIN;
22
+ }
23
+ export function defaultEnsRpcUrl() {
24
+ return process.env.NPMGUARD_ENS_RPC_URL ?? process.env.SEPOLIA_RPC_URL ?? DEFAULT_ENS_RPC_URL;
25
+ }
26
+ function ensSafeLabel(value) {
27
+ const label = value
28
+ .replace(/^@/, "")
29
+ .replace(/[^a-z0-9-]+/gi, "-")
30
+ .replace(/^-+|-+$/g, "")
31
+ .toLowerCase();
32
+ if (!label)
33
+ throw new Error(`Cannot derive an ENS label from "${value}"`);
34
+ return label.slice(0, 63);
35
+ }
36
+ function tarballFromStorage(storage) {
37
+ return (storage.storage.tarball?.gatewayUrl ??
38
+ storage.storage.manifest?.value?.tarball?.gatewayUrl ??
39
+ null);
40
+ }
41
+ export async function resolvePinataInstallSpec(apiUrl, packageName, version) {
42
+ const publication = await api.getPackageStorage(apiUrl, packageName, version);
43
+ if (!publication) {
44
+ throw new Error(`No Pinata publication found for ${packageName}@${version}`);
45
+ }
46
+ const tarballUrl = tarballFromStorage(publication);
47
+ if (!tarballUrl) {
48
+ throw new Error(`Pinata publication for ${packageName}@${version} has no installable tarball URL`);
49
+ }
50
+ return {
51
+ spec: tarballUrl,
52
+ detail: publication.storage.ens?.recordName
53
+ ? `Pinata tarball announced by ${publication.storage.ens.recordName}`
54
+ : "Pinata tarball from NpmGuard storage API",
55
+ };
56
+ }
57
+ async function readTextRecords(name, keys, rpcUrl) {
58
+ const client = createPublicClient({ chain: sepolia, transport: http(rpcUrl) });
59
+ const found = await client.readContract({
60
+ address: UNIVERSAL_RESOLVER,
61
+ abi: universalResolverAbi,
62
+ functionName: "findResolver",
63
+ args: [toHex(packetToBytes(name))],
64
+ });
65
+ const resolver = found.resolver ?? found[0] ?? zeroAddress;
66
+ if (!resolver || resolver === zeroAddress)
67
+ return {};
68
+ const node = namehash(name);
69
+ const records = {};
70
+ for (const key of keys) {
71
+ try {
72
+ const value = await client.readContract({
73
+ address: resolver,
74
+ abi: resolverAbi,
75
+ functionName: "text",
76
+ args: [node, key],
77
+ });
78
+ if (value)
79
+ records[key] = value;
80
+ }
81
+ catch {
82
+ // Some resolvers throw for missing keys; treat that as an empty record.
83
+ }
84
+ }
85
+ return records;
86
+ }
87
+ async function tarballFromManifest(manifestUrl) {
88
+ const res = await fetch(manifestUrl);
89
+ if (!res.ok)
90
+ return null;
91
+ const manifest = await res.json();
92
+ return manifest.tarball?.gatewayUrl ?? null;
93
+ }
94
+ export async function resolveEnsInstallSpec(options) {
95
+ const packageLabel = ensSafeLabel(options.packageName);
96
+ const versionLabel = ensSafeLabel(options.version || "latest");
97
+ const root = options.rootDomain.replace(/\.+$/, "");
98
+ const candidates = [
99
+ `${versionLabel}.${packageLabel}.${root}`,
100
+ `${packageLabel}.${root}`,
101
+ root,
102
+ ];
103
+ const keys = [
104
+ "npmguard.tarball_uri",
105
+ "npmguard.latest_tarball_uri",
106
+ "npmguard.manifest_uri",
107
+ "npmguard.latest_manifest_uri",
108
+ "npmguard.package",
109
+ "npmguard.version",
110
+ "npmguard.latest_version",
111
+ ];
112
+ for (const name of candidates) {
113
+ const records = await readTextRecords(name, keys, options.rpcUrl);
114
+ const recordPackage = records["npmguard.package"];
115
+ const recordVersion = records["npmguard.version"] ?? records["npmguard.latest_version"];
116
+ const packageMatches = !recordPackage || recordPackage === options.packageName;
117
+ const versionMatches = !recordVersion || recordVersion === options.version;
118
+ if (!packageMatches || !versionMatches)
119
+ continue;
120
+ const directTarball = records["npmguard.tarball_uri"] ?? records["npmguard.latest_tarball_uri"];
121
+ if (directTarball)
122
+ return { spec: directTarball, detail: `ENS ${name} → Pinata tarball` };
123
+ const manifestUrl = records["npmguard.manifest_uri"] ?? records["npmguard.latest_manifest_uri"];
124
+ if (manifestUrl) {
125
+ const tarballUrl = await tarballFromManifest(manifestUrl);
126
+ if (tarballUrl)
127
+ return { spec: tarballUrl, detail: `ENS ${name} → manifest → Pinata tarball` };
128
+ }
129
+ }
130
+ throw new Error(`No installable ENS tarball record found for ${options.packageName}@${options.version}`);
131
+ }
package/dist/utils.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { createInterface } from "node:readline";
2
2
  import { existsSync } from "node:fs";
3
3
  import { join } from "node:path";
4
+ import { spawn } from "node:child_process";
4
5
  export function parsePackageArg(pkg) {
5
6
  if (pkg.startsWith("@")) {
6
7
  const slashIndex = pkg.indexOf("/");
@@ -54,3 +55,27 @@ export function detectPackageManager(cwd = process.cwd()) {
54
55
  return "yarn";
55
56
  return "npm";
56
57
  }
58
+ export function openExternalUrl(url) {
59
+ try {
60
+ const platform = process.platform;
61
+ const command = platform === "darwin"
62
+ ? "open"
63
+ : platform === "win32"
64
+ ? "cmd"
65
+ : "xdg-open";
66
+ const args = platform === "win32"
67
+ ? ["/c", "start", "", url]
68
+ : [url];
69
+ const child = spawn(command, args, {
70
+ detached: true,
71
+ stdio: "ignore",
72
+ windowsHide: true,
73
+ });
74
+ child.on("error", () => { });
75
+ child.unref();
76
+ return true;
77
+ }
78
+ catch {
79
+ return false;
80
+ }
81
+ }
@@ -14,7 +14,7 @@ function generateQrCode(text) {
14
14
  });
15
15
  });
16
16
  }
17
- export async function payViaWalletConnect(packageName, version, feeWei, feeDisplay) {
17
+ export async function payViaWalletConnect(packageName, version, feeWei, feeDisplay, contractAddress = AUDIT_REQUEST_ADDRESS_BASE_SEPOLIA) {
18
18
  const calldata = encodeFunctionData({
19
19
  abi: AUDIT_REQUEST_ABI,
20
20
  functionName: "requestAudit",
@@ -63,6 +63,10 @@ export async function payViaWalletConnect(packageName, version, feeWei, feeDispl
63
63
  console.log();
64
64
  await generateQrCode(uri);
65
65
  console.log();
66
+ console.log(chalk.cyan(" Desktop wallet? Paste this WalletConnect URI into your wallet:"));
67
+ console.log(chalk.gray(` ${uri}`));
68
+ console.log(chalk.gray(" Keep it private; it is only for this pairing session."));
69
+ console.log();
66
70
  const pairSpinner = ora(" Waiting for wallet connection...").start();
67
71
  const session = await approval();
68
72
  const accounts = session.namespaces.eip155?.accounts ?? [];
@@ -84,7 +88,7 @@ export async function payViaWalletConnect(packageName, version, feeWei, feeDispl
84
88
  params: [
85
89
  {
86
90
  from: sender,
87
- to: AUDIT_REQUEST_ADDRESS_BASE_SEPOLIA,
91
+ to: contractAddress,
88
92
  data: calldata,
89
93
  value: "0x" + feeWei.toString(16),
90
94
  },
@@ -120,13 +124,13 @@ export async function payViaWalletConnect(packageName, version, feeWei, feeDispl
120
124
  setTimeout(() => process.off("uncaughtException", wcErrorHandler), 5000);
121
125
  }
122
126
  }
123
- export async function readAuditFee() {
127
+ export async function readAuditFee(contractAddress = AUDIT_REQUEST_ADDRESS_BASE_SEPOLIA) {
124
128
  const publicClient = createPublicClient({
125
129
  chain: baseSepolia,
126
130
  transport: http(),
127
131
  });
128
132
  return (await publicClient.readContract({
129
- address: AUDIT_REQUEST_ADDRESS_BASE_SEPOLIA,
133
+ address: contractAddress,
130
134
  abi: AUDIT_REQUEST_ABI,
131
135
  functionName: "auditFee",
132
136
  }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "npmguard-cli",
3
- "version": "1.1.2",
3
+ "version": "1.1.4",
4
4
  "type": "module",
5
5
  "description": "NpmGuard CLI — check npm packages against NpmGuard security audits",
6
6
  "main": "dist/index.js",