Skip to content

Commit d08a7a4

Browse files
authored
feat: add fallback to npm registry API in fetchPeerDependencies (#155)
1 parent 1d35087 commit d08a7a4

File tree

5 files changed

+127
-19
lines changed

5 files changed

+127
-19
lines changed

bin/create-config.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ if (sharedConfigIndex === -1) {
2929
const generator = new ConfigGenerator({ cwd, packageJsonPath });
3030

3131
await generator.prompt();
32-
generator.calc();
32+
await generator.calc();
3333
await generator.output();
3434
} else {
3535

@@ -39,6 +39,6 @@ if (sharedConfigIndex === -1) {
3939
const answers = { config: { packageName, type } };
4040
const generator = new ConfigGenerator({ cwd, packageJsonPath, answers });
4141

42-
generator.calc();
42+
await generator.calc();
4343
await generator.output();
4444
}

lib/config-generator.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ export class ConfigGenerator {
107107
* Calculate the configuration based on the user's answers.
108108
* @returns {void}
109109
*/
110-
calc() {
110+
async calc() {
111111
const isESMModule = isPackageTypeModule(this.packageJsonPath);
112112

113113
this.result.configFilename = isESMModule ? "eslint.config.js" : "eslint.config.mjs";
@@ -235,7 +235,7 @@ export class ConfigGenerator {
235235
this.result.devDependencies.push(config.packageName);
236236

237237
// install peer dependencies - it's needed for most eslintrc-style shared configs.
238-
const peers = fetchPeerDependencies(config.packageName);
238+
const peers = await fetchPeerDependencies(config.packageName);
239239

240240
if (peers !== null) {
241241
const eslintIndex = peers.findIndex(dep => (dep.startsWith("eslint@")));

lib/utils/npm-utils.js

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010

1111
import fs from "node:fs";
1212
import spawn from "cross-spawn";
13-
1413
import path from "node:path";
1514
import * as log from "./logging.js";
1615

@@ -63,12 +62,30 @@ function installSyncSaveDev(packages, packageManager = "npm", installFlags = ["-
6362
}
6463
}
6564

65+
/**
66+
* Parses a package name string into its name and version components.
67+
* @param {string} packageName The package name to parse.
68+
* @returns {Object} An object with 'name' and 'version' properties.
69+
*/
70+
function parsePackageName(packageName) {
71+
const atIndex = packageName.lastIndexOf("@");
72+
73+
if (atIndex > 0) {
74+
const name = packageName.slice(0, atIndex);
75+
const version = packageName.slice(atIndex + 1) || "latest";
76+
77+
return { name, version };
78+
}
79+
return { name: packageName, version: "latest" };
80+
81+
}
82+
6683
/**
6784
* Fetch `peerDependencies` of the given package by `npm show` command.
6885
* @param {string} packageName The package name to fetch peerDependencies.
6986
* @returns {Object} Gotten peerDependencies. Returns null if npm was not found.
7087
*/
71-
function fetchPeerDependencies(packageName) {
88+
async function fetchPeerDependencies(packageName) {
7289
const npmProcess = spawn.sync(
7390
"npm",
7491
["show", "--json", packageName, "peerDependencies"],
@@ -79,8 +96,31 @@ function fetchPeerDependencies(packageName) {
7996

8097
if (error && error.code === "ENOENT") {
8198

82-
// TODO: should throw an error instead of returning null
83-
return null;
99+
// Fallback to using the npm registry API directly when npm is not available.
100+
const { name, version } = parsePackageName(packageName);
101+
102+
try {
103+
// eslint-disable-next-line n/no-unsupported-features/node-builtins -- Fallback using built-in fetch
104+
const response = await fetch(`https://registry.npmjs.org/${name}`);
105+
const data = await response.json();
106+
107+
const resolvedVersion =
108+
version === "latest" ? data["dist-tags"]?.latest : version;
109+
const packageVersion = data.versions[resolvedVersion];
110+
111+
if (!packageVersion) {
112+
throw new Error(
113+
`Version "${version}" not found for package "${name}".`
114+
);
115+
}
116+
return Object.entries(packageVersion.peerDependencies).map(
117+
([pkgName, pkgVersion]) => `${pkgName}@${pkgVersion}`
118+
);
119+
} catch {
120+
121+
// TODO: should throw an error instead of returning null
122+
return null;
123+
}
84124
}
85125
const fetchedText = npmProcess.stdout.trim();
86126

@@ -188,6 +228,7 @@ function isPackageTypeModule(pkgJSONPath) {
188228

189229
export {
190230
installSyncSaveDev,
231+
parsePackageName,
191232
fetchPeerDependencies,
192233
findPackageJson,
193234
checkDeps,

tests/config-snapshots.spec.js

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -72,22 +72,22 @@ describe("generate config for esm projects", () => {
7272
});
7373

7474
inputs.forEach(item => {
75-
test(`${item.name}`, () => {
75+
test(`${item.name}`, async () => {
7676
const generator = new ConfigGenerator({ cwd: esmProjectDir, answers: item.answers });
7777

78-
generator.calc();
78+
await generator.calc();
7979

8080
expect(generator.result.configFilename).toBe("eslint.config.js");
8181
expect(generator.packageJsonPath).toBe(join(esmProjectDir, "./package.json"));
8282
expect(generator.result).toMatchFileSnapshot(`./__snapshots__/${item.name}`);
8383
});
8484
});
8585

86-
test("sub dir", () => {
86+
test("sub dir", async () => {
8787
const sub = join(__filename, "../fixtures/esm-project/sub");
8888
const generator = new ConfigGenerator({ cwd: sub, answers: { purpose: "problems", moduleType: "esm", framework: "none", useTs: false, env: ["node"] } });
8989

90-
generator.calc();
90+
await generator.calc();
9191

9292
expect(generator.result.configFilename).toBe("eslint.config.js");
9393
expect(generator.packageJsonPath).toBe(join(esmProjectDir, "./package.json"));
@@ -131,10 +131,10 @@ describe("generate config for cjs projects", () => {
131131
}];
132132

133133
inputs.forEach(item => {
134-
test(`${item.name}`, () => {
134+
test(`${item.name}`, async () => {
135135
const generator = new ConfigGenerator({ cwd: cjsProjectDir, answers: item.answers });
136136

137-
generator.calc();
137+
await generator.calc();
138138

139139
expect(generator.result.configFilename).toBe("eslint.config.mjs");
140140
expect(generator.packageJsonPath).toBe(join(cjsProjectDir, "./package.json"));

tests/utils/npm-utils.spec.js

Lines changed: 72 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,13 @@ import {
1414
fetchPeerDependencies,
1515
checkDeps,
1616
checkDevDeps,
17-
checkPackageJson
17+
checkPackageJson,
18+
parsePackageName
1819
} from "../../lib/utils/npm-utils.js";
1920
import { defineInMemoryFs } from "../_utils/in-memory-fs.js";
2021
import { assert, describe, afterEach, it } from "vitest";
2122
import fs from "node:fs";
23+
import process from "node:process";
2224

2325
//------------------------------------------------------------------------------
2426
// Helpers
@@ -177,6 +179,15 @@ describe("npmUtils", () => {
177179
stub.restore();
178180
});
179181

182+
it("should invoke bun to install a single desired package", () => {
183+
const stub = sinon.stub(spawn, "sync").returns({ stdout: "" });
184+
185+
installSyncSaveDev("desired-package", "bun");
186+
assert(stub.calledOnce);
187+
assert.strictEqual(stub.firstCall.args[0], "bun");
188+
assert.deepStrictEqual(stub.firstCall.args[1], ["install", "-D", "desired-package"]);
189+
stub.restore();
190+
});
180191

181192
it("should accept an array of packages to install", () => {
182193
const stub = sinon.stub(spawn, "sync").returns({ stdout: "" });
@@ -203,21 +214,77 @@ describe("npmUtils", () => {
203214
});
204215
});
205216

217+
describe("parsePackageName()", () => {
218+
it("should parse package name with version", () => {
219+
const result = parsePackageName("[email protected]");
220+
221+
assert.deepStrictEqual(result, { name: "eslint", version: "9.0.0" });
222+
});
223+
224+
it("should parse package name without version", () => {
225+
const result = parsePackageName("eslint");
226+
227+
assert.deepStrictEqual(result, { name: "eslint", version: "latest" });
228+
});
229+
230+
it("should handle scoped packages with version", () => {
231+
const result = parsePackageName("@typescript-eslint/[email protected]");
232+
233+
assert.deepStrictEqual(result, { name: "@typescript-eslint/eslint-plugin", version: "5.0.0" });
234+
});
235+
236+
it("should handle scoped packages without version", () => {
237+
const result = parsePackageName("@typescript-eslint/eslint-plugin");
238+
239+
assert.deepStrictEqual(result, { name: "@typescript-eslint/eslint-plugin", version: "latest" });
240+
});
241+
});
242+
206243
describe("fetchPeerDependencies()", () => {
207-
it("should execute 'npm show --json <packageName> peerDependencies' command", () => {
244+
it("should execute 'npm show --json <packageName> peerDependencies' command", async () => {
208245
const stub = sinon.stub(spawn, "sync").returns({ stdout: "" });
209246

210-
fetchPeerDependencies("desired-package");
247+
await fetchPeerDependencies("desired-package");
211248
assert(stub.calledOnce);
212249
assert.strictEqual(stub.firstCall.args[0], "npm");
213250
assert.deepStrictEqual(stub.firstCall.args[1], ["show", "--json", "desired-package", "peerDependencies"]);
214251
stub.restore();
215252
});
216253

217-
it("should return null if npm throws ENOENT error", () => {
254+
// Skip on Node.js v21 due to a bug where fetch cannot be stubbed
255+
// See: https://github.com/sinonjs/sinon/issues/2590
256+
it.skipIf(process.version.startsWith("v21"))("should fetch peer dependencies from npm registry when npm is not available", async () => {
257+
const npmStub = sinon.stub(spawn, "sync").returns({ error: { code: "ENOENT" } });
258+
const fetchStub = sinon.stub(globalThis, "fetch");
259+
260+
const mockResponse = {
261+
json: sinon.stub().resolves({
262+
"dist-tags": { latest: "9.0.0" },
263+
versions: {
264+
"9.0.0": {
265+
peerDependencies: { eslint: "9.0.0" }
266+
}
267+
}
268+
}),
269+
ok: true,
270+
status: 200
271+
};
272+
273+
fetchStub.resolves(mockResponse);
274+
275+
const result = await fetchPeerDependencies("desired-package");
276+
277+
assert(fetchStub.calledOnceWith("https://registry.npmjs.org/desired-package"));
278+
assert.deepStrictEqual(result, ["[email protected]"]);
279+
280+
npmStub.restore();
281+
fetchStub.restore();
282+
});
283+
284+
it("should return null if an error is thrown", async () => {
218285
const stub = sinon.stub(spawn, "sync").returns({ error: { code: "ENOENT" } });
219286

220-
const peerDependencies = fetchPeerDependencies("desired-package");
287+
const peerDependencies = await fetchPeerDependencies("desired-package");
221288

222289
assert.isNull(peerDependencies);
223290

0 commit comments

Comments
 (0)