Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
35 changes: 17 additions & 18 deletions lib/config-generator.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { writeFile } from "node:fs/promises";
import enquirer from "enquirer";
import semverGreaterThanRange from "semver/ranges/gtr.js";
import semverLessThan from "semver/functions/lt.js";
import { isPackageTypeModule, installSyncSaveDev, fetchPeerDependencies, findPackageJson } from "./utils/npm-utils.js";
import { isPackageTypeModule, installSyncSaveDev, fetchPeerDependencies, findPackageJson, parsePackageName } from "./utils/npm-utils.js";
import { getShorthandName } from "./utils/naming.js";
import * as log from "./utils/logging.js";
import { langQuestions, jsQuestions, mdQuestions, installationQuestions, addJitiQuestion } from "./questions.js";
Expand Down Expand Up @@ -287,40 +287,39 @@ export class ConfigGenerator {
// passed `--config`
if (this.answers.config) {
const config = this.answers.config;
const { name: packageName } = parsePackageName(config.packageName);

this.result.devDependencies.push(config.packageName);

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

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

if (eslintIndex === -1) {
// eslint is not in the peer dependencies
if (eslintIndex === -1) {
// eslint is not in the peer dependencies

this.result.devDependencies.push(...peers);
} else {
const versionMatch = peers[eslintIndex].match(/eslint@(.+)/u);
const versionRequirement = versionMatch[1]; // Complete version requirement string
this.result.devDependencies.push(...peers);
} else {
const versionMatch = peers[eslintIndex].match(/eslint@(.+)/u);
const versionRequirement = versionMatch[1]; // Complete version requirement string

// Check if the version requirement allows for ESLint 9.22.0+
isDefineConfigExported = !semverGreaterThanRange("9.22.0", versionRequirement);
// Check if the version requirement allows for ESLint 9.22.0+
isDefineConfigExported = !semverGreaterThanRange("9.22.0", versionRequirement);

// eslint is in the peer dependencies => overwrite eslint version
this.result.devDependencies[0] = peers[eslintIndex];
peers.splice(eslintIndex, 1);
this.result.devDependencies.push(...peers);
}
// eslint is in the peer dependencies => overwrite eslint version
this.result.devDependencies[0] = peers[eslintIndex];
peers.splice(eslintIndex, 1);
this.result.devDependencies.push(...peers);
}

if (config.type === "flat" || config.type === void 0) {
importContent += `import config from "${config.packageName}";\n`;
importContent += `import config from "${packageName}";\n`;
exportContent += " config,\n";
} else if (config.type === "eslintrc") {
needCompatHelper = true;

const shorthandName = getShorthandName(config.packageName, "eslint-config");
const shorthandName = getShorthandName(packageName, "eslint-config");

exportContent += ` compat.extends("${shorthandName}"),\n`;
}
Expand Down
61 changes: 21 additions & 40 deletions lib/utils/npm-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import fs from "node:fs";
import spawn from "cross-spawn";
import path from "node:path";
import * as log from "./logging.js";
import semverMaxSatisfying from "semver/ranges/max-satisfying.js";

//------------------------------------------------------------------------------
// Helpers
Expand Down Expand Up @@ -86,52 +87,32 @@ function parsePackageName(packageName) {
* @returns {Object} Gotten peerDependencies. Returns null if npm was not found.
*/
async function fetchPeerDependencies(packageName) {
const npmProcess = spawn.sync(
"npm",
["show", "--json", packageName, "peerDependencies"],
{ encoding: "utf8" }
);
const { name, version } = parsePackageName(packageName);

const error = npmProcess.error;
try {
// eslint-disable-next-line n/no-unsupported-features/node-builtins -- Fallback using built-in fetch
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the comment needs an update. :)

const response = await fetch(`https://registry.npmjs.org/${name}`);
const data = await response.json();

if (error && error.code === "ENOENT") {
if (data.error) {
throw new Error(data.error);
}

// 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 {
const resolvedVersion = semverMaxSatisfying(Object.keys(data.versions), data["dist-tags"]?.[version] ?? version);
const packageVersion = data.versions[resolvedVersion];

// TODO: should throw an error instead of returning null
return null;
if (!packageVersion) {
throw new Error(
`Version "${version}" not found for package "${name}".`
);
}
return Object.entries(Object(packageVersion.peerDependencies)).map(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why Object() is used here? Is this to convert packageVersion.peerDependencies to {} when it is undefined?

([pkgName, pkgVersion]) => `${pkgName}@${pkgVersion}`
);
} catch (err) {
// eslint-disable-next-line preserve-caught-error -- Throw error again :)
throw new Error(`Cannot fetch "${name}@${version}" with error: ${err.message || err}`);
}
const fetchedText = npmProcess.stdout.trim();

const peers = JSON.parse(fetchedText || "{}");
const dependencies = [];

Object.keys(peers).forEach(pkgName => {
dependencies.push(`${pkgName}@${peers[pkgName]}`);
});

return dependencies;
}

/**
Expand Down
29 changes: 12 additions & 17 deletions tests/utils/npm-utils.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
parsePackageName
} from "../../lib/utils/npm-utils.js";
import { defineInMemoryFs } from "../_utils/in-memory-fs.js";
import { assert, describe, afterEach, it } from "vitest";
import { assert, describe, afterEach, it, expect } from "vitest";
import fs from "node:fs";
import process from "node:process";

Expand Down Expand Up @@ -241,20 +241,10 @@ describe("npmUtils", () => {
});

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

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();
});

// 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" } });
it.skipIf(process.version.startsWith("v21"))("should fetch peer dependencies from npm registry", async () => {
const fetchStub = sinon.stub(globalThis, "fetch");

const mockResponse = {
Expand All @@ -277,16 +267,21 @@ describe("npmUtils", () => {
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" } });
it("should throw if an error is thrown", async () => {
const stub = sinon.stub(globalThis, "fetch");

const mockResponse = {
json: sinon.stub().resolves({ error: "Not found" }),
ok: false,
status: 404
};

const peerDependencies = await fetchPeerDependencies("desired-package");
stub.resolves(mockResponse);

assert.isNull(peerDependencies);
expect(async () => await fetchPeerDependencies("desired-package")).rejects.toThrowError();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we use assert.throws() here? For consistency - the other tests are all assert-style.


stub.restore();
});
Expand Down