Skip to content
Open
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
36 changes: 33 additions & 3 deletions src/checks/node-version.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,23 @@ import path from 'node:path';
import semver from 'semver';
import type { CheckDefinition, CheckResult, CheckContext } from '../types/index.js';

const ENGINE_STRICT_HINT =
"Add 'engine-strict=true' to .npmrc to prevent accidental installs on mismatched Node versions.";

/** Returns true if .npmrc enables engine-strict=true (non-comment line). */
async function hasEngineStrict(cwd: string): Promise<boolean> {
try {
const npmrc = await fs.readFile(path.join(cwd, '.npmrc'), 'utf8');
return npmrc.split(/\r?\n/).some((line) => {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) return false;
return /^engine-strict\s*=\s*true\b/i.test(trimmed);
});
} catch {
return false;
}
}

export const nodeVersionCheck: CheckDefinition = {
id: 'node-version',
name: 'Node.js Version',
Expand All @@ -15,10 +32,11 @@ export const nodeVersionCheck: CheckDefinition = {

let targetRange = context.config?.minNodeVersion || '>=18.0.0';
let source = 'default';
const hasEnginesNode = Boolean(context.pkg?.engines?.node);

// 1. Check package.json engines.node
if (context.pkg?.engines?.node) {
targetRange = context.pkg.engines.node;
if (hasEnginesNode) {
targetRange = context.pkg!.engines!.node as string;
source = 'package.json engines';
} else {
// 2. Check .nvmrc
Expand Down Expand Up @@ -52,6 +70,15 @@ export const nodeVersionCheck: CheckDefinition = {

const satisfies = semver.satisfies(cleanCurrent, targetRange, { loose: true });

// When engines.node is set, suggest engine-strict if not already enabled
let engineStrictHint: string | undefined;
if (hasEnginesNode) {
const strictEnabled = await hasEngineStrict(context.cwd);
if (!strictEnabled) {
engineStrictHint = ENGINE_STRICT_HINT;
}
}

if (satisfies) {
return {
id: 'node-version',
Expand All @@ -60,6 +87,7 @@ export const nodeVersionCheck: CheckDefinition = {
status: 'success',
message: `Node.js ${currentVersion} (satisfies ${targetRange} from ${source})`,
fixable: false,
...(engineStrictHint ? { hint: engineStrictHint } : {}),
};
}

Expand All @@ -75,7 +103,9 @@ export const nodeVersionCheck: CheckDefinition = {
`Consider switching version with 'nvm use' or 'fnm use'`,
],
fixable: false,
hint: `Install the supported Node.js version using your version manager (nvm, fnm, or volta).`,
hint:
engineStrictHint ??
`Install the supported Node.js version using your version manager (nvm, fnm, or volta).`,
};
},
};
30 changes: 30 additions & 0 deletions tests/checks/node-version.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,34 @@ describe('checks/node-version', () => {

expect(result.message).toContain('.nvmrc');
});

it('hints engine-strict when engines.node is set and .npmrc lacks engine-strict', async () => {
const result = await nodeVersionCheck.run({
cwd: testDir.path,
pkg: { engines: { node: '>=18.0.0' } },
});

expect(result.hint).toContain('engine-strict=true');
});

it('does not hint engine-strict when .npmrc already enables it', async () => {
await testDir.writeFile('.npmrc', 'engine-strict=true\n');

const result = await nodeVersionCheck.run({
cwd: testDir.path,
pkg: { engines: { node: '>=18.0.0' } },
});

expect(result.status).toBe('success');
expect(result.hint).toBeUndefined();
});

it('does not hint engine-strict when engines.node is absent', async () => {
const result = await nodeVersionCheck.run({
cwd: testDir.path,
pkg: {},
});

expect(result.hint).toBeUndefined();
});
});