npmguard-cli 1.1.3 → 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 +39 -1
- package/dist/api.js +13 -0
- package/dist/commands/install.js +56 -25
- package/dist/index.js +12 -1
- package/dist/install-source.js +131 -0
- package/package.json +1 -1
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
|
|
@@ -63,6 +63,23 @@ lockfiles and runs the correct add command (`npm install`, `pnpm add`,
|
|
|
63
63
|
report when the browser-wallet page owns the live view
|
|
64
64
|
7. Runs the install if the verdict is SAFE, or prompts otherwise
|
|
65
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
|
+
|
|
66
83
|
### `npmguard audit <package>[@version]`
|
|
67
84
|
|
|
68
85
|
Run a standalone audit without installing. Returns the verdict and exits.
|
|
@@ -151,6 +168,27 @@ export NPMGUARD_WEB_URL=http://localhost:3000
|
|
|
151
168
|
npmguard install lodash
|
|
152
169
|
```
|
|
153
170
|
|
|
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
|
+
|
|
154
192
|
No private key or paid RPC config is required from the user. For the
|
|
155
193
|
WalletConnect path, the CLI uses `viem` with a public Base Sepolia RPC to
|
|
156
194
|
read/confirm the transaction for local UX; your wallet broadcasts the tx,
|
package/dist/api.js
CHANGED
|
@@ -78,3 +78,16 @@ export async function getPackageReport(apiUrl, packageName, version) {
|
|
|
78
78
|
throw err;
|
|
79
79
|
}
|
|
80
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
|
+
}
|
package/dist/commands/install.js
CHANGED
|
@@ -7,18 +7,47 @@ import { parsePackageArg, prompt, resolveLatestVersion, detectPackageManager, op
|
|
|
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();
|
|
@@ -66,7 +95,7 @@ export async function installCommand(packageSpec, opts) {
|
|
|
66
95
|
}
|
|
67
96
|
spinner.stop();
|
|
68
97
|
if (report) {
|
|
69
|
-
handleExistingReport(report, name, fullSpec, apiUrl, opts);
|
|
98
|
+
await handleExistingReport(report, name, version, fullSpec, apiUrl, opts);
|
|
70
99
|
return;
|
|
71
100
|
}
|
|
72
101
|
// No audit found — ask how to pay
|
|
@@ -81,25 +110,25 @@ export async function installCommand(packageSpec, opts) {
|
|
|
81
110
|
console.log();
|
|
82
111
|
const choice = await prompt(" Choice [1/2/3/4/5]: ");
|
|
83
112
|
if (choice === "1") {
|
|
84
|
-
await runStripeAuditAndInstall(fullSpec, name, version, apiUrl);
|
|
113
|
+
await runStripeAuditAndInstall(fullSpec, name, version, apiUrl, opts);
|
|
85
114
|
return;
|
|
86
115
|
}
|
|
87
116
|
if (choice === "2") {
|
|
88
|
-
await runBrowserWalletAuditAndInstall(fullSpec, name, version, apiUrl, webUrl);
|
|
117
|
+
await runBrowserWalletAuditAndInstall(fullSpec, name, version, apiUrl, webUrl, opts);
|
|
89
118
|
return;
|
|
90
119
|
}
|
|
91
120
|
if (choice === "3") {
|
|
92
|
-
await runCryptoAuditAndInstall(fullSpec, name, version, apiUrl);
|
|
121
|
+
await runCryptoAuditAndInstall(fullSpec, name, version, apiUrl, opts);
|
|
93
122
|
return;
|
|
94
123
|
}
|
|
95
124
|
if (choice === "4") {
|
|
96
125
|
console.log(chalk.yellow(" Installing without audit. Proceed at your own risk."));
|
|
97
|
-
process.exit(runInstall(fullSpec));
|
|
126
|
+
process.exit(runInstall(fullSpec, "npm registry (audit skipped)"));
|
|
98
127
|
}
|
|
99
128
|
console.log(chalk.gray(" Cancelled."));
|
|
100
129
|
process.exit(0);
|
|
101
130
|
}
|
|
102
|
-
function handleExistingReport(report, name, fullSpec, apiUrl, opts) {
|
|
131
|
+
async function handleExistingReport(report, name, version, fullSpec, apiUrl, opts) {
|
|
103
132
|
const verdict = extractVerdict(report);
|
|
104
133
|
const capabilities = extractCapabilities(report);
|
|
105
134
|
if (verdict === "SAFE") {
|
|
@@ -107,7 +136,8 @@ function handleExistingReport(report, name, fullSpec, apiUrl, opts) {
|
|
|
107
136
|
if (capabilities.length > 0) {
|
|
108
137
|
console.log(chalk.gray(` Capabilities: ${capabilities.join(", ")}`));
|
|
109
138
|
}
|
|
110
|
-
|
|
139
|
+
await installSafePackage(fullSpec, name, version, opts);
|
|
140
|
+
return;
|
|
111
141
|
}
|
|
112
142
|
if (verdict === "DANGEROUS" || verdict === "CRITICAL" || verdict === "WARNING") {
|
|
113
143
|
console.log(chalk.bgRed.white.bold(` ${verdict} `));
|
|
@@ -119,23 +149,24 @@ function handleExistingReport(report, name, fullSpec, apiUrl, opts) {
|
|
|
119
149
|
console.log();
|
|
120
150
|
if (opts.force) {
|
|
121
151
|
console.log(chalk.yellow(" --force passed, installing anyway..."));
|
|
122
|
-
|
|
152
|
+
await installSafePackage(fullSpec, name, version, opts);
|
|
153
|
+
return;
|
|
123
154
|
}
|
|
124
|
-
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) ");
|
|
125
156
|
return;
|
|
126
157
|
}
|
|
127
158
|
console.log(chalk.yellow(` Verdict: ${verdict}`));
|
|
128
|
-
promptAndInstallIfAccepted(fullSpec, " Proceed with install? (y/N) ");
|
|
159
|
+
await promptAndInstallIfAccepted(fullSpec, name, version, opts, " Proceed with install? (y/N) ");
|
|
129
160
|
}
|
|
130
|
-
async function promptAndInstallIfAccepted(fullSpec, question) {
|
|
161
|
+
async function promptAndInstallIfAccepted(fullSpec, name, version, opts, question) {
|
|
131
162
|
const answer = await prompt(chalk.red.bold(question));
|
|
132
163
|
if (answer === "y" || answer === "yes") {
|
|
133
|
-
|
|
164
|
+
await installSafePackage(fullSpec, name, version, opts);
|
|
134
165
|
}
|
|
135
166
|
console.log(chalk.gray(" Aborted."));
|
|
136
167
|
process.exit(1);
|
|
137
168
|
}
|
|
138
|
-
async function runStripeAuditAndInstall(fullSpec, name, version, apiUrl) {
|
|
169
|
+
async function runStripeAuditAndInstall(fullSpec, name, version, apiUrl, opts) {
|
|
139
170
|
try {
|
|
140
171
|
await auditCommand(fullSpec, { api: apiUrl, exit: false });
|
|
141
172
|
}
|
|
@@ -143,7 +174,7 @@ async function runStripeAuditAndInstall(fullSpec, name, version, apiUrl) {
|
|
|
143
174
|
console.error(chalk.red("Audit failed: " + (err instanceof Error ? err.message : String(err))));
|
|
144
175
|
process.exit(1);
|
|
145
176
|
}
|
|
146
|
-
await finalizeAfterAudit(fullSpec, name, version, apiUrl);
|
|
177
|
+
await finalizeAfterAudit(fullSpec, name, version, apiUrl, opts);
|
|
147
178
|
}
|
|
148
179
|
function buildBrowserWalletUrl(webUrl, packageName, version) {
|
|
149
180
|
const url = new URL("/pay", webUrl.replace(/\/+$/, ""));
|
|
@@ -165,7 +196,7 @@ async function waitForPackageReport(apiUrl, packageName, version, timeoutMs) {
|
|
|
165
196
|
}
|
|
166
197
|
return null;
|
|
167
198
|
}
|
|
168
|
-
async function runBrowserWalletAuditAndInstall(fullSpec, name, version, apiUrl, webUrl) {
|
|
199
|
+
async function runBrowserWalletAuditAndInstall(fullSpec, name, version, apiUrl, webUrl, opts) {
|
|
169
200
|
const paymentUrl = buildBrowserWalletUrl(webUrl, name, version);
|
|
170
201
|
console.log();
|
|
171
202
|
console.log(chalk.bold(" Open this URL in Brave or Chrome with MetaMask/Rabby:"));
|
|
@@ -197,9 +228,9 @@ async function runBrowserWalletAuditAndInstall(fullSpec, name, version, apiUrl,
|
|
|
197
228
|
process.exit(1);
|
|
198
229
|
}
|
|
199
230
|
spinner.succeed("Audit report received");
|
|
200
|
-
await finalizeWithReport(fullSpec, report);
|
|
231
|
+
await finalizeWithReport(fullSpec, name, version, report, opts);
|
|
201
232
|
}
|
|
202
|
-
async function runCryptoAuditAndInstall(fullSpec, name, version, apiUrl) {
|
|
233
|
+
async function runCryptoAuditAndInstall(fullSpec, name, version, apiUrl, opts) {
|
|
203
234
|
// 1. Read current fee from the engine's public config, then fall back to
|
|
204
235
|
// direct contract read if the engine is older or config is unavailable.
|
|
205
236
|
let feeWei;
|
|
@@ -257,26 +288,26 @@ async function runCryptoAuditAndInstall(fullSpec, name, version, apiUrl) {
|
|
|
257
288
|
// would trigger a second, unpaid audit via /checkout).
|
|
258
289
|
await streamAuditEvents(apiUrl, auditId);
|
|
259
290
|
// 5. Fetch the persisted report and decide whether to install
|
|
260
|
-
await finalizeAfterAudit(fullSpec, name, version, apiUrl);
|
|
291
|
+
await finalizeAfterAudit(fullSpec, name, version, apiUrl, opts);
|
|
261
292
|
}
|
|
262
|
-
async function finalizeAfterAudit(fullSpec, name, version, apiUrl) {
|
|
293
|
+
async function finalizeAfterAudit(fullSpec, name, version, apiUrl, opts) {
|
|
263
294
|
const freshReport = await api.getPackageReport(apiUrl, name, version);
|
|
264
295
|
if (!freshReport) {
|
|
265
296
|
console.log(chalk.red(" Audit finished but report not found."));
|
|
266
297
|
process.exit(1);
|
|
267
298
|
}
|
|
268
|
-
await finalizeWithReport(fullSpec, freshReport);
|
|
299
|
+
await finalizeWithReport(fullSpec, name, version, freshReport, opts);
|
|
269
300
|
}
|
|
270
|
-
async function finalizeWithReport(fullSpec, report) {
|
|
301
|
+
async function finalizeWithReport(fullSpec, name, version, report, opts) {
|
|
271
302
|
const verdict = extractVerdict(report);
|
|
272
303
|
if (verdict === "SAFE") {
|
|
273
304
|
console.log(chalk.green("\n ✓ SAFE — proceeding with install"));
|
|
274
|
-
|
|
305
|
+
await installSafePackage(fullSpec, name, version, opts);
|
|
275
306
|
}
|
|
276
307
|
console.log(chalk.red(`\n Audit verdict: ${verdict}`));
|
|
277
308
|
const confirm = await prompt(chalk.red.bold(" Install anyway? (y/N) "));
|
|
278
309
|
if (confirm === "y" || confirm === "yes") {
|
|
279
|
-
|
|
310
|
+
await installSafePackage(fullSpec, name, version, opts);
|
|
280
311
|
}
|
|
281
312
|
process.exit(1);
|
|
282
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"));
|
|
@@ -33,9 +34,19 @@ program
|
|
|
33
34
|
.description("Install an npm package with NpmGuard security check")
|
|
34
35
|
.argument("<package>", "Package name, optionally with version (e.g. express@4.18.0)")
|
|
35
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())
|
|
36
40
|
.action(async (pkg, cmdOpts) => {
|
|
37
41
|
const opts = program.opts();
|
|
38
|
-
await installCommand(pkg, {
|
|
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
|
+
});
|
|
39
50
|
});
|
|
40
51
|
program
|
|
41
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
|
+
}
|