Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add fallback to npm registry API in fetchPeerDependencies #155

Merged
merged 3 commits into from
Apr 9, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions bin/create-config.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ if (sharedConfigIndex === -1) {
const generator = new ConfigGenerator({ cwd, packageJsonPath });

await generator.prompt();
generator.calc();
await generator.calc();
await generator.output();
} else {

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

generator.calc();
await generator.calc();
await generator.output();
}
4 changes: 2 additions & 2 deletions lib/config-generator.js
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ export class ConfigGenerator {
* Calculate the configuration based on the user's answers.
* @returns {void}
*/
calc() {
async calc() {
const isESMModule = isPackageTypeModule(this.packageJsonPath);

this.result.configFilename = isESMModule ? "eslint.config.js" : "eslint.config.mjs";
Expand Down Expand Up @@ -190,7 +190,7 @@ const compat = new FlatCompat({baseDirectory: __dirname, recommendedConfig: plug
this.result.devDependencies.push(config.packageName);

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

if (peers !== null) {
const eslintIndex = peers.findIndex(dep => (dep.startsWith("eslint@")));
Expand Down
49 changes: 45 additions & 4 deletions lib/utils/npm-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@

import fs from "node:fs";
import spawn from "cross-spawn";

import path from "node:path";
import * as log from "./logging.js";

Expand Down Expand Up @@ -63,12 +62,30 @@ function installSyncSaveDev(packages, packageManager = "npm", installFlags = ["-
}
}

/**
* Parses a package name string into its name and version components.
* @param {string} packageName The package name to parse.
* @returns {Object} An object with 'name' and 'version' properties.
*/
function parsePackageName(packageName) {
const atIndex = packageName.lastIndexOf("@");

if (atIndex > 0) {
const name = packageName.slice(0, atIndex);
const version = packageName.slice(atIndex + 1) || "latest";

return { name, version };
}
return { name: packageName, version: "latest" };

}

/**
* Fetch `peerDependencies` of the given package by `npm show` command.
* @param {string} packageName The package name to fetch peerDependencies.
* @returns {Object} Gotten peerDependencies. Returns null if npm was not found.
*/
function fetchPeerDependencies(packageName) {
async function fetchPeerDependencies(packageName) {
const npmProcess = spawn.sync(
"npm",
["show", "--json", packageName, "peerDependencies"],
Expand All @@ -79,8 +96,31 @@ function fetchPeerDependencies(packageName) {

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

// TODO: should throw an error instead of returning null
return null;
// Fallback to using the npm registry API directly when npm is not available.
const { name, version } = parsePackageName(packageName);

try {
// eslint-disable-next-line n/no-unsupported-features/node-builtins -- Fallback using built-in fetch
const response = await fetch(`https://registry.npmjs.org/${name}`);
const data = await response.json();

const resolvedVersion =
version === "latest" ? data["dist-tags"]?.latest : version;
const packageVersion = data.versions[resolvedVersion];

if (!packageVersion) {
throw new Error(
`Version "${version}" not found for package "${name}".`
);
}
return Object.entries(packageVersion.peerDependencies).map(
([pkgName, pkgVersion]) => `${pkgName}@${pkgVersion}`
);
} catch {

// TODO: should throw an error instead of returning null
return null;
}
}
const fetchedText = npmProcess.stdout.trim();

Expand Down Expand Up @@ -188,6 +228,7 @@ function isPackageTypeModule(pkgJSONPath) {

export {
installSyncSaveDev,
parsePackageName,
fetchPeerDependencies,
findPackageJson,
checkDeps,
Expand Down
12 changes: 6 additions & 6 deletions tests/config-snapshots.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,22 +58,22 @@ describe("generate config for esm projects", () => {
});

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

generator.calc();
await generator.calc();

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

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

generator.calc();
await generator.calc();

expect(generator.result.configFilename).toBe("eslint.config.js");
expect(generator.packageJsonPath).toBe(join(esmProjectDir, "./package.json"));
Expand Down Expand Up @@ -117,10 +117,10 @@ describe("generate config for cjs projects", () => {
}];

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

generator.calc();
await generator.calc();

expect(generator.result.configFilename).toBe("eslint.config.mjs");
expect(generator.packageJsonPath).toBe(join(cjsProjectDir, "./package.json"));
Expand Down
77 changes: 72 additions & 5 deletions tests/utils/npm-utils.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,13 @@ import {
fetchPeerDependencies,
checkDeps,
checkDevDeps,
checkPackageJson
checkPackageJson,
parsePackageName
} from "../../lib/utils/npm-utils.js";
import { defineInMemoryFs } from "../_utils/in-memory-fs.js";
import { assert, describe, afterEach, it } from "vitest";
import fs from "node:fs";
import process from "node:process";

//------------------------------------------------------------------------------
// Helpers
Expand Down Expand Up @@ -177,6 +179,15 @@ describe("npmUtils", () => {
stub.restore();
});

it("should invoke bun to install a single desired package", () => {
const stub = sinon.stub(spawn, "sync").returns({ stdout: "" });

installSyncSaveDev("desired-package", "bun");
assert(stub.calledOnce);
assert.strictEqual(stub.firstCall.args[0], "bun");
assert.deepStrictEqual(stub.firstCall.args[1], ["install", "-D", "desired-package"]);
stub.restore();
});

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

describe("parsePackageName()", () => {
it("should parse package name with version", () => {
const result = parsePackageName("[email protected]");

assert.deepStrictEqual(result, { name: "eslint", version: "9.0.0" });
});

it("should parse package name without version", () => {
const result = parsePackageName("eslint");

assert.deepStrictEqual(result, { name: "eslint", version: "latest" });
});

it("should handle scoped packages with version", () => {
const result = parsePackageName("@typescript-eslint/[email protected]");

assert.deepStrictEqual(result, { name: "@typescript-eslint/eslint-plugin", version: "5.0.0" });
});

it("should handle scoped packages without version", () => {
const result = parsePackageName("@typescript-eslint/eslint-plugin");

assert.deepStrictEqual(result, { name: "@typescript-eslint/eslint-plugin", version: "latest" });
});
});

describe("fetchPeerDependencies()", () => {
it("should execute 'npm show --json <packageName> peerDependencies' command", () => {
it("should execute 'npm show --json <packageName> peerDependencies' command", async () => {
const stub = sinon.stub(spawn, "sync").returns({ stdout: "" });

fetchPeerDependencies("desired-package");
await fetchPeerDependencies("desired-package");
assert(stub.calledOnce);
assert.strictEqual(stub.firstCall.args[0], "npm");
assert.deepStrictEqual(stub.firstCall.args[1], ["show", "--json", "desired-package", "peerDependencies"]);
stub.restore();
});

it("should return null if npm throws ENOENT error", () => {
// Skip on Node.js v21 due to a bug where fetch cannot be stubbed
// See: https://github.com/sinonjs/sinon/issues/2590
it.skipIf(process.version.startsWith("v21"))("should fetch peer dependencies from npm registry when npm is not available", async () => {
const npmStub = sinon.stub(spawn, "sync").returns({ error: { code: "ENOENT" } });
const fetchStub = sinon.stub(globalThis, "fetch");

const mockResponse = {
json: sinon.stub().resolves({
"dist-tags": { latest: "9.0.0" },
versions: {
"9.0.0": {
peerDependencies: { eslint: "9.0.0" }
}
}
}),
ok: true,
status: 200
};

fetchStub.resolves(mockResponse);

const result = await fetchPeerDependencies("desired-package");

assert(fetchStub.calledOnceWith("https://registry.npmjs.org/desired-package"));
assert.deepStrictEqual(result, ["[email protected]"]);

npmStub.restore();
fetchStub.restore();
});

it("should return null if an error is thrown", async () => {
const stub = sinon.stub(spawn, "sync").returns({ error: { code: "ENOENT" } });

const peerDependencies = fetchPeerDependencies("desired-package");
const peerDependencies = await fetchPeerDependencies("desired-package");

assert.isNull(peerDependencies);

Expand Down