From a85dbd328ccfa2fbb1a93d23e29089acb7730c02 Mon Sep 17 00:00:00 2001 From: telegine Date: Wed, 29 Jul 2026 12:42:19 +0300 Subject: [PATCH 1/5] feat: generate Node version file --- README.md | 1 + scripts/readme/readme.hbs | 1 + src/generators/base.test.ts | 11 +++++++++++ src/generators/base.ts | 2 ++ src/generators/templates/.nvmrc.hbs | 1 + 5 files changed, 16 insertions(+) create mode 100644 src/generators/templates/.nvmrc.hbs diff --git a/README.md b/README.md index 990fc94..0e79dfa 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,7 @@ Three shapes: app-builder layout (frontend and/or backend selected), plain TS en ``` . |-- .gitignore +|-- .nvmrc |-- .prettierrc.js |-- .stylelintrc.json # only with styles |-- README.md diff --git a/scripts/readme/readme.hbs b/scripts/readme/readme.hbs index e0df393..75b4402 100644 --- a/scripts/readme/readme.hbs +++ b/scripts/readme/readme.hbs @@ -36,6 +36,7 @@ Three shapes: app-builder layout (frontend and/or backend selected), plain TS en ``` . |-- .gitignore +|-- .nvmrc |-- .prettierrc.js |-- .stylelintrc.json # only with styles |-- README.md diff --git a/src/generators/base.test.ts b/src/generators/base.test.ts index 7642a60..2bed6c7 100644 --- a/src/generators/base.test.ts +++ b/src/generators/base.test.ts @@ -4,6 +4,17 @@ import {setupGeneratorTest} from './__fixtures__/setupGeneratorTest.js'; import {generateBase} from './base.js'; test.describe('base generator', () => { + test('writes the supported Node.js version to .nvmrc', async (t: TestContext) => { + const {file} = await setupGeneratorTest(generateBase, { + destination: '/project', + projectName: 'my-app', + }); + + const nvmrc = file('.nvmrc'); + t.assert.ok(nvmrc); + t.assert.equal(nvmrc.content, 'v24\n'); + }); + test('no registry set: does not write .npmrc', async (t: TestContext) => { const {file} = await setupGeneratorTest(generateBase, { destination: '/project', diff --git a/src/generators/base.ts b/src/generators/base.ts index 31771f8..a226163 100644 --- a/src/generators/base.ts +++ b/src/generators/base.ts @@ -8,6 +8,7 @@ import type {FileSystem} from '../utils/types.js'; import renderGitignore from './templates/.gitignore.hbs.js'; import renderNpmrc from './templates/.npmrc.hbs.js'; +import renderNvmrc from './templates/.nvmrc.hbs.js'; import renderReadme from './templates/README.md.hbs.js'; function sortKeys(record: Record): Record { @@ -30,6 +31,7 @@ export async function generateBase(model: ProjectModel, fs: FileSystem): Promise await writeJson(fs, path.join(model.destination, 'package.json'), pkg); await fs.writeFile(path.join(model.destination, '.gitignore'), renderGitignore({})); + await fs.writeFile(path.join(model.destination, '.nvmrc'), renderNvmrc({})); await fs.writeFile( path.join(model.destination, 'README.md'), diff --git a/src/generators/templates/.nvmrc.hbs b/src/generators/templates/.nvmrc.hbs new file mode 100644 index 0000000..54c6511 --- /dev/null +++ b/src/generators/templates/.nvmrc.hbs @@ -0,0 +1 @@ +v24 From 737efb2644cb8aa7b88a24874f1648fca4ba6f44 Mon Sep 17 00:00:00 2001 From: telegine Date: Wed, 29 Jul 2026 13:04:20 +0300 Subject: [PATCH 2/5] feat: declare generated Node engine --- src/generators/base.test.ts | 6 +++++- src/generators/base.ts | 9 ++++++++- src/generators/templates/.nvmrc.hbs | 2 +- src/utils/constants.ts | 1 + 4 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/generators/base.test.ts b/src/generators/base.test.ts index 2bed6c7..a81f02f 100644 --- a/src/generators/base.test.ts +++ b/src/generators/base.test.ts @@ -4,7 +4,7 @@ import {setupGeneratorTest} from './__fixtures__/setupGeneratorTest.js'; import {generateBase} from './base.js'; test.describe('base generator', () => { - test('writes the supported Node.js version to .nvmrc', async (t: TestContext) => { + test('writes the supported Node.js version to .nvmrc and package.json', async (t: TestContext) => { const {file} = await setupGeneratorTest(generateBase, { destination: '/project', projectName: 'my-app', @@ -13,6 +13,10 @@ test.describe('base generator', () => { const nvmrc = file('.nvmrc'); t.assert.ok(nvmrc); t.assert.equal(nvmrc.content, 'v24\n'); + + const pkg = file('package.json'); + t.assert.ok(pkg); + t.assert.deepEqual(pkg.content.engines, {node: '24.x'}); }); test('no registry set: does not write .npmrc', async (t: TestContext) => { diff --git a/src/generators/base.ts b/src/generators/base.ts index a226163..063ef51 100644 --- a/src/generators/base.ts +++ b/src/generators/base.ts @@ -1,6 +1,7 @@ import path from 'node:path'; import type {ProjectModel} from '../model/index.js'; +import {NODE_VERSION_MAJOR} from '../utils/constants.js'; import {writeJson} from '../utils/fs.js'; import {isModulePackage} from '../utils/isModulePackage.js'; import {isDefaultRegistry} from '../utils/registry.js'; @@ -23,6 +24,9 @@ export async function generateBase(model: ProjectModel, fs: FileSystem): Promise version: '0.0.0', private: true, ...(isModule ? {type: 'module'} : {}), + engines: { + node: `${NODE_VERSION_MAJOR}.x`, + }, scripts: model.scripts, dependencies: sortKeys(model.packages.dependencies), devDependencies: sortKeys(model.packages.devDependencies), @@ -31,7 +35,10 @@ export async function generateBase(model: ProjectModel, fs: FileSystem): Promise await writeJson(fs, path.join(model.destination, 'package.json'), pkg); await fs.writeFile(path.join(model.destination, '.gitignore'), renderGitignore({})); - await fs.writeFile(path.join(model.destination, '.nvmrc'), renderNvmrc({})); + await fs.writeFile( + path.join(model.destination, '.nvmrc'), + renderNvmrc({nodeVersionMajor: NODE_VERSION_MAJOR}), + ); await fs.writeFile( path.join(model.destination, 'README.md'), diff --git a/src/generators/templates/.nvmrc.hbs b/src/generators/templates/.nvmrc.hbs index 54c6511..5acc5dd 100644 --- a/src/generators/templates/.nvmrc.hbs +++ b/src/generators/templates/.nvmrc.hbs @@ -1 +1 @@ -v24 +v{{nodeVersionMajor}} diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 0057de2..90d3dad 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -1 +1,2 @@ export const DEFAULT_NPM_REGISTRY = 'https://registry.npmjs.org/'; +export const NODE_VERSION_MAJOR = 24; From e71d98b7d2ac2f5a00e226ce115eea9aaa0bfc76 Mon Sep 17 00:00:00 2001 From: telegine Date: Wed, 29 Jul 2026 15:36:57 +0300 Subject: [PATCH 3/5] refactor: share generated Node version --- src/generators/base.test.ts | 4 ++-- src/generators/base.ts | 6 +++--- src/generators/templates/.nvmrc.hbs | 2 +- src/utils/constants.ts | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/generators/base.test.ts b/src/generators/base.test.ts index a81f02f..819a08d 100644 --- a/src/generators/base.test.ts +++ b/src/generators/base.test.ts @@ -12,11 +12,11 @@ test.describe('base generator', () => { const nvmrc = file('.nvmrc'); t.assert.ok(nvmrc); - t.assert.equal(nvmrc.content, 'v24\n'); + t.assert.equal(nvmrc.content, '24\n'); const pkg = file('package.json'); t.assert.ok(pkg); - t.assert.deepEqual(pkg.content.engines, {node: '24.x'}); + t.assert.deepEqual(pkg.content.engines, {node: '24'}); }); test('no registry set: does not write .npmrc', async (t: TestContext) => { diff --git a/src/generators/base.ts b/src/generators/base.ts index 063ef51..b374e03 100644 --- a/src/generators/base.ts +++ b/src/generators/base.ts @@ -1,7 +1,7 @@ import path from 'node:path'; import type {ProjectModel} from '../model/index.js'; -import {NODE_VERSION_MAJOR} from '../utils/constants.js'; +import {NODE_VERSION} from '../utils/constants.js'; import {writeJson} from '../utils/fs.js'; import {isModulePackage} from '../utils/isModulePackage.js'; import {isDefaultRegistry} from '../utils/registry.js'; @@ -25,7 +25,7 @@ export async function generateBase(model: ProjectModel, fs: FileSystem): Promise private: true, ...(isModule ? {type: 'module'} : {}), engines: { - node: `${NODE_VERSION_MAJOR}.x`, + node: NODE_VERSION, }, scripts: model.scripts, dependencies: sortKeys(model.packages.dependencies), @@ -37,7 +37,7 @@ export async function generateBase(model: ProjectModel, fs: FileSystem): Promise await fs.writeFile(path.join(model.destination, '.gitignore'), renderGitignore({})); await fs.writeFile( path.join(model.destination, '.nvmrc'), - renderNvmrc({nodeVersionMajor: NODE_VERSION_MAJOR}), + renderNvmrc({nodeVersion: NODE_VERSION}), ); await fs.writeFile( diff --git a/src/generators/templates/.nvmrc.hbs b/src/generators/templates/.nvmrc.hbs index 5acc5dd..c8ec7bc 100644 --- a/src/generators/templates/.nvmrc.hbs +++ b/src/generators/templates/.nvmrc.hbs @@ -1 +1 @@ -v{{nodeVersionMajor}} +{{nodeVersion}} diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 90d3dad..8d01869 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -1,2 +1,2 @@ export const DEFAULT_NPM_REGISTRY = 'https://registry.npmjs.org/'; -export const NODE_VERSION_MAJOR = 24; +export const NODE_VERSION = '24'; From d2ab5ca6403f1f7e95ce9598b615271081644b93 Mon Sep 17 00:00:00 2001 From: telegine Date: Wed, 29 Jul 2026 15:40:38 +0300 Subject: [PATCH 4/5] feat: prompt for generated Node version --- AGENTS.md | 2 +- README.md | 1 + src/cli/args.test.ts | 4 +++- src/cli/introspect.test.ts | 5 +++++ src/cli/schema.ts | 18 +++++++++++++++++- src/generators/base.test.ts | 11 +++++++++++ src/generators/base.ts | 5 ++--- src/model/createEmptyModel.ts | 3 +++ src/model/types.ts | 5 +++++ src/prompts/i18n.ts | 1 + src/prompts/questions.ts | 27 ++++++++++++++++++++++++++- src/prompts/runPromptFlow.test.ts | 14 +++++++++++++- src/prompts/runPromptFlow.ts | 11 +++++++++++ src/utils/constants.ts | 6 +++++- 14 files changed, 104 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9dc76c0..488b752 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,6 +52,6 @@ Plain-JS shape's entry stays at project root, not under `src/`: with no bundler `styles`/`react` are only settable when frontend is enabled — enforced twice: `CliSchema`'s `.refine()` checks (`schema.ts`) and structurally by `runPromptFlow.ts` (styles/react prompts only run `if (model.frontend)`). Keep both in sync if this changes. So the frontend axis collapses to 5 states — `false`, or enabled × `{none, styles-only, react-only, styles+react}` — not a raw 2×2 sub-cross. -Combined with `language` (2) and `hasBackend` (2): **20** distinct configs. `registry` (default vs. custom URL) is an independent axis, only toggling whether `generateBase` writes `.npmrc`. No package-manager (npm/yarn/pnpm) axis exists. +Combined with `language` (2), `hasBackend` (2), and `nodeVersion` (2): **40** distinct configs. `registry` (default vs. custom URL) is an independent axis, only toggling whether `generateBase` writes `.npmrc`. No package-manager (npm/yarn/pnpm) axis exists. What's actually observable at runtime per combo (browser vs stdout vs nothing), and current e2e coverage gaps: see `e2e/AGENTS.md`. diff --git a/README.md b/README.md index 0e79dfa..2322835 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ npm create @gravity-ui | --- | --- | | `--out ` | Destination folder for the new project | | `--language ` | Project language (ts\|js) | +| `--node-version <22\|24>` | Node.js version for the generated project (22\|24) | | `--frontend, --no-frontend` | Include frontend setup | | `--styles, --no-styles` | Include stylelint (requires --frontend) | | `--react, --no-react` | Include React + JSX transform (requires --frontend) | diff --git a/src/cli/args.test.ts b/src/cli/args.test.ts index 26de91d..cdd02c9 100644 --- a/src/cli/args.test.ts +++ b/src/cli/args.test.ts @@ -4,9 +4,10 @@ import {parseCli} from './args.js'; test.describe('parseCli', () => { test('parses string and enum flags', (t: TestContext) => { - const result = parseCli(['--out', './my-app', '--language', 'ts']); + const result = parseCli(['--out', './my-app', '--language', 'ts', '--node-version', '22']); t.assert.equal(result.out, './my-app'); t.assert.equal(result.language, 'ts'); + t.assert.equal(result['node-version'], '22'); }); test('parses boolean flags', (t: TestContext) => { @@ -52,6 +53,7 @@ test.describe('parseCli', () => { test('formats a zod error for a flag with --prefix', (t: TestContext) => { t.assert.throws(() => parseCli(['--language', 'cobol']), /^Error: --language: /); + t.assert.throws(() => parseCli(['--node-version', '20']), /^Error: --node-version: /); }); test('formats the out field error without a -- prefix', (t: TestContext) => { diff --git a/src/cli/introspect.test.ts b/src/cli/introspect.test.ts index 08bbf0e..54444e8 100644 --- a/src/cli/introspect.test.ts +++ b/src/cli/introspect.test.ts @@ -12,6 +12,7 @@ test.describe('listFields', () => { [ 'out', 'language', + 'node-version', 'frontend', 'styles', 'react', @@ -48,6 +49,10 @@ test.describe('listFields', () => { const language = fields.find((f) => f.name === 'language'); t.assert.equal(language?.kind, 'enum'); t.assert.deepEqual(language?.choices, ['ts', 'js']); + + const nodeVersion = fields.find((f) => f.name === 'node-version'); + t.assert.equal(nodeVersion?.kind, 'enum'); + t.assert.deepEqual(nodeVersion?.choices, ['22', '24']); }); test('classifies plain string fields as kind "string"', (t: TestContext) => { diff --git a/src/cli/schema.ts b/src/cli/schema.ts index d995300..e4a8980 100644 --- a/src/cli/schema.ts +++ b/src/cli/schema.ts @@ -1,5 +1,6 @@ import {z} from 'zod'; +import {DEFAULT_NODE_VERSION, SUPPORTED_NODE_VERSIONS} from '../utils/constants.js'; import {validateDestination} from '../utils/destination.js'; export type FlagGroup = 'project' | 'mode' | 'other'; @@ -56,6 +57,17 @@ export const CliSchema = z }), ), + 'node-version': z + .enum(SUPPORTED_NODE_VERSIONS) + .optional() + .meta( + flagMeta({ + description: 'Node.js version for the generated project', + group: 'project', + placeholder: '<22|24>', + }), + ), + frontend: z .boolean() .optional() @@ -180,8 +192,12 @@ export type ParsedCli = z.infer; */ export const YES_DEFAULTS = { language: 'ts', + 'node-version': DEFAULT_NODE_VERSION, frontend: true, styles: true, react: true, backend: false, -} satisfies Pick; +} satisfies Pick< + ParsedCli, + 'language' | 'node-version' | 'frontend' | 'styles' | 'react' | 'backend' +>; diff --git a/src/generators/base.test.ts b/src/generators/base.test.ts index 819a08d..4ba8222 100644 --- a/src/generators/base.test.ts +++ b/src/generators/base.test.ts @@ -19,6 +19,17 @@ test.describe('base generator', () => { t.assert.deepEqual(pkg.content.engines, {node: '24'}); }); + test('writes the selected minimum Node.js version', async (t: TestContext) => { + const {file} = await setupGeneratorTest(generateBase, { + destination: '/project', + projectName: 'my-app', + nodeVersion: '22', + }); + + t.assert.equal(file('.nvmrc')?.content, '22\n'); + t.assert.deepEqual(file('package.json')?.content.engines, {node: '22'}); + }); + test('no registry set: does not write .npmrc', async (t: TestContext) => { const {file} = await setupGeneratorTest(generateBase, { destination: '/project', diff --git a/src/generators/base.ts b/src/generators/base.ts index b374e03..bfeb077 100644 --- a/src/generators/base.ts +++ b/src/generators/base.ts @@ -1,7 +1,6 @@ import path from 'node:path'; import type {ProjectModel} from '../model/index.js'; -import {NODE_VERSION} from '../utils/constants.js'; import {writeJson} from '../utils/fs.js'; import {isModulePackage} from '../utils/isModulePackage.js'; import {isDefaultRegistry} from '../utils/registry.js'; @@ -25,7 +24,7 @@ export async function generateBase(model: ProjectModel, fs: FileSystem): Promise private: true, ...(isModule ? {type: 'module'} : {}), engines: { - node: NODE_VERSION, + node: model.nodeVersion, }, scripts: model.scripts, dependencies: sortKeys(model.packages.dependencies), @@ -37,7 +36,7 @@ export async function generateBase(model: ProjectModel, fs: FileSystem): Promise await fs.writeFile(path.join(model.destination, '.gitignore'), renderGitignore({})); await fs.writeFile( path.join(model.destination, '.nvmrc'), - renderNvmrc({nodeVersion: NODE_VERSION}), + renderNvmrc({nodeVersion: model.nodeVersion}), ); await fs.writeFile( diff --git a/src/model/createEmptyModel.ts b/src/model/createEmptyModel.ts index 3a88d6d..a9da38b 100644 --- a/src/model/createEmptyModel.ts +++ b/src/model/createEmptyModel.ts @@ -1,3 +1,5 @@ +import {DEFAULT_NODE_VERSION} from '../utils/constants.js'; + import type {ProjectModel} from './types.js'; export function createEmptyModel(): ProjectModel { @@ -5,6 +7,7 @@ export function createEmptyModel(): ProjectModel { destination: '', projectName: '', language: 'ts', + nodeVersion: DEFAULT_NODE_VERSION, frontend: false, hasBackend: false, packages: {dependencies: {}, devDependencies: {}}, diff --git a/src/model/types.ts b/src/model/types.ts index e1c126f..d074965 100644 --- a/src/model/types.ts +++ b/src/model/types.ts @@ -1,3 +1,5 @@ +import type {NodeVersion} from '../utils/constants.js'; + type Language = 'ts' | 'js'; export type FrontendFeature = 'styles' | 'react'; @@ -11,6 +13,9 @@ export interface ProjectModel { // Language language: Language; + // Node.js + nodeVersion: NodeVersion; + // Frontend: false = no frontend, array = frontend with these features frontend: false | FrontendFeature[]; diff --git a/src/prompts/i18n.ts b/src/prompts/i18n.ts index 7a83ae3..961f738 100644 --- a/src/prompts/i18n.ts +++ b/src/prompts/i18n.ts @@ -6,6 +6,7 @@ export const i18n = { label_language: 'Language', 'label_language-ts': 'TypeScript', 'label_language-js': 'JavaScript', + 'label_node-version': 'Node.js version', 'label_has-frontend': 'Does your project have a frontend?', 'label_has-styles': 'Will your project have styles?', 'label_has-react': 'Will your project use React?', diff --git a/src/prompts/questions.ts b/src/prompts/questions.ts index 742aaba..2a800d1 100644 --- a/src/prompts/questions.ts +++ b/src/prompts/questions.ts @@ -1,7 +1,11 @@ import path from 'node:path'; import type {ProjectModel} from '../model/index.js'; -import {DEFAULT_NPM_REGISTRY} from '../utils/constants.js'; +import { + DEFAULT_NODE_VERSION, + DEFAULT_NPM_REGISTRY, + MINIMUM_NODE_VERSION, +} from '../utils/constants.js'; import {validateDestination} from '../utils/destination.js'; import {i18n} from './i18n.js'; @@ -57,6 +61,27 @@ export async function askLanguage(prompter: Prompter, model: ProjectModel): Prom model.language = language; } +export async function askNodeVersion(prompter: Prompter, model: ProjectModel): Promise { + const nodeVersion = await prompter.select({ + message: i18n['label_node-version'], + options: [ + { + value: DEFAULT_NODE_VERSION, + label: `Node.js ${DEFAULT_NODE_VERSION}`, + hint: 'recommended', + }, + { + value: MINIMUM_NODE_VERSION, + label: `Node.js ${MINIMUM_NODE_VERSION}`, + hint: 'minimum supported', + }, + ], + initialValue: DEFAULT_NODE_VERSION, + }); + + model.nodeVersion = nodeVersion; +} + export async function askFrontend(prompter: Prompter): Promise { return prompter.select({ message: i18n['label_has-frontend'], diff --git a/src/prompts/runPromptFlow.test.ts b/src/prompts/runPromptFlow.test.ts index 2b70704..af71ab7 100644 --- a/src/prompts/runPromptFlow.test.ts +++ b/src/prompts/runPromptFlow.test.ts @@ -57,6 +57,7 @@ test.describe('runPromptFlow', () => { out: './my-app', registry: 'https://registry.example.com', language: 'ts', + 'node-version': '22', frontend: true, styles: true, react: true, @@ -69,6 +70,7 @@ test.describe('runPromptFlow', () => { t.assert.equal(model.projectName, path.basename(model.destination)); t.assert.equal(model.registry, 'https://registry.example.com'); t.assert.equal(model.language, 'ts'); + t.assert.equal(model.nodeVersion, '22'); t.assert.deepStrictEqual(model.frontend, ['styles', 'react']); t.assert.equal(model.hasBackend, true); }); @@ -80,6 +82,7 @@ test.describe('runPromptFlow', () => { await runPromptFlow(model, cli, createFakePrompter()); t.assert.equal(model.language, 'ts'); + t.assert.equal(model.nodeVersion, '24'); t.assert.deepStrictEqual(model.frontend, ['styles', 'react']); t.assert.equal(model.hasBackend, false); t.assert.equal(model.registry, undefined); @@ -117,6 +120,7 @@ test.describe('runPromptFlow', () => { }, select: { Language: 'js', + 'Node.js version': '22', 'Does your project have a frontend?': true, 'Will your project have styles?': false, 'Will your project use React?': true, @@ -130,13 +134,20 @@ test.describe('runPromptFlow', () => { t.assert.equal(model.projectName, path.basename(model.destination)); t.assert.equal(model.registry, 'https://custom.registry'); t.assert.equal(model.language, 'js'); + t.assert.equal(model.nodeVersion, '22'); t.assert.deepStrictEqual(model.frontend, ['react']); t.assert.equal(model.hasBackend, true); }); test('interactive registry declined leaves registry unset', async (t: TestContext) => { const model = createEmptyModel(); - const cli = baseCli({out: './my-app', language: 'ts', frontend: false, backend: false}); + const cli = baseCli({ + out: './my-app', + language: 'ts', + 'node-version': '24', + frontend: false, + backend: false, + }); const prompter = createFakePrompter({ confirm: { 'Use custom npm registry?': false, @@ -154,6 +165,7 @@ test.describe('runPromptFlow', () => { out: './my-app', registry: 'https://x.example.com', language: 'ts', + 'node-version': '24', backend: false, }); const prompter = createFakePrompter({ diff --git a/src/prompts/runPromptFlow.ts b/src/prompts/runPromptFlow.ts index 80727f2..933124e 100644 --- a/src/prompts/runPromptFlow.ts +++ b/src/prompts/runPromptFlow.ts @@ -11,6 +11,7 @@ import { askDestination, askFrontend, askLanguage, + askNodeVersion, askReact, askRegistry, askStyles, @@ -110,6 +111,16 @@ export async function runPromptFlow( ask: () => askLanguage(prompter, model), }); + // Node.js + await resolveStep(prompter, cli.yes, { + cli: cli['node-version'], + yesDefault: YES_DEFAULTS['node-version'], + label: i18n['label_node-version'], + format: String, + set: setModelValue(model, 'nodeVersion'), + ask: () => askNodeVersion(prompter, model), + }); + // Frontend await resolveStep(prompter, cli.yes, { cli: cli.frontend, diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 8d01869..e9d2acb 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -1,2 +1,6 @@ export const DEFAULT_NPM_REGISTRY = 'https://registry.npmjs.org/'; -export const NODE_VERSION = '24'; +export const MINIMUM_NODE_VERSION = '22'; +export const DEFAULT_NODE_VERSION = '24'; +export const SUPPORTED_NODE_VERSIONS = [MINIMUM_NODE_VERSION, DEFAULT_NODE_VERSION] as const; + +export type NodeVersion = (typeof SUPPORTED_NODE_VERSIONS)[number]; From 74b19fd87350b4d0705af7837143735c7477d18c Mon Sep 17 00:00:00 2001 From: telegine Date: Wed, 29 Jul 2026 16:42:56 +0300 Subject: [PATCH 5/5] feat: validate custom Node version --- AGENTS.md | 2 +- README.md | 2 +- package-lock.json | 146 ++++++++++++++++-------------- package.json | 2 + src/cli/args.test.ts | 14 ++- src/cli/introspect.test.ts | 6 +- src/cli/schema.ts | 16 +++- src/model/types.ts | 4 +- src/prompts/i18n.ts | 2 +- src/prompts/questions.ts | 25 ++--- src/prompts/runPromptFlow.test.ts | 4 +- src/utils/constants.ts | 3 - src/utils/nodeVersion.test.ts | 22 +++++ src/utils/nodeVersion.ts | 20 ++++ 14 files changed, 156 insertions(+), 112 deletions(-) create mode 100644 src/utils/nodeVersion.test.ts create mode 100644 src/utils/nodeVersion.ts diff --git a/AGENTS.md b/AGENTS.md index 488b752..d430f79 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,6 +52,6 @@ Plain-JS shape's entry stays at project root, not under `src/`: with no bundler `styles`/`react` are only settable when frontend is enabled — enforced twice: `CliSchema`'s `.refine()` checks (`schema.ts`) and structurally by `runPromptFlow.ts` (styles/react prompts only run `if (model.frontend)`). Keep both in sync if this changes. So the frontend axis collapses to 5 states — `false`, or enabled × `{none, styles-only, react-only, styles+react}` — not a raw 2×2 sub-cross. -Combined with `language` (2), `hasBackend` (2), and `nodeVersion` (2): **40** distinct configs. `registry` (default vs. custom URL) is an independent axis, only toggling whether `generateBase` writes `.npmrc`. No package-manager (npm/yarn/pnpm) axis exists. +Combined with `language` (2) and `hasBackend` (2): **20** distinct configs. `nodeVersion` is an independent string input that only changes `.nvmrc` and `package.json#engines`; `registry` (default vs. custom URL) is another independent axis that only toggles whether `generateBase` writes `.npmrc`. No package-manager (npm/yarn/pnpm) axis exists. What's actually observable at runtime per combo (browser vs stdout vs nothing), and current e2e coverage gaps: see `e2e/AGENTS.md`. diff --git a/README.md b/README.md index 2322835..a28eb98 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ npm create @gravity-ui | --- | --- | | `--out ` | Destination folder for the new project | | `--language ` | Project language (ts\|js) | -| `--node-version <22\|24>` | Node.js version for the generated project (22\|24) | +| `--node-version ` | Node.js version for the generated project in node-semver format (minimum 22) | | `--frontend, --no-frontend` | Include frontend setup | | `--styles, --no-styles` | Include stylelint (requires --frontend) | | `--react, --no-react` | Include React + JSX transform (requires --frontend) | diff --git a/package-lock.json b/package-lock.json index c7d6293..ac18cb0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@clack/prompts": "^1.6.0", "handlebars": "^4.7.9", + "semver": "^7.8.5", "zod": "^4.4.3" }, "bin": { @@ -22,6 +23,7 @@ "@gravity-ui/tsconfig": "^1.0.0", "@playwright/test": "^1.61.1", "@types/node": "^24.13.3", + "@types/semver": "^7.7.1", "eslint": "^9.39.4", "eslint-plugin-n": "^18.2.1", "knip": "^6.23.0", @@ -30,7 +32,7 @@ "typescript": "^6.0.3" }, "engines": { - "node": ">=22.4" + "node": "^22.13.0 || >=23.5.0" } }, "node_modules/@babel/code-frame": { @@ -89,6 +91,16 @@ "url": "https://opencollective.com/babel" } }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/@babel/eslint-parser": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/eslint-parser/-/eslint-parser-7.29.7.tgz", @@ -108,6 +120,16 @@ "eslint": "^7.5.0 || ^8.0.0 || ^9.0.0" } }, + "node_modules/@babel/eslint-parser/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/@babel/generator": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", @@ -155,6 +177,16 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/@babel/helper-globals": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", @@ -2162,6 +2194,13 @@ "undici-types": "~7.18.0" } }, + "node_modules/@types/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", + "dev": true, + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.62.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.62.1.tgz", @@ -2340,19 +2379,6 @@ "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@typescript-eslint/utils": { "version": "8.62.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.62.1.tgz", @@ -3720,19 +3746,6 @@ "eslint": ">=6.0.0" } }, - "node_modules/eslint-compat-utils/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/eslint-config-prettier": { "version": "10.1.8", "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", @@ -3956,6 +3969,16 @@ "node": "*" } }, + "node_modules/eslint-plugin-import/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/eslint-plugin-jsdoc": { "version": "50.8.0", "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-50.8.0.tgz", @@ -3981,19 +4004,6 @@ "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0" } }, - "node_modules/eslint-plugin-jsdoc/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/eslint-plugin-jsx-a11y": { "version": "6.10.2", "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", @@ -4114,19 +4124,6 @@ "node": ">= 4" } }, - "node_modules/eslint-plugin-n/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/eslint-plugin-prettier": { "version": "5.5.6", "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.6.tgz", @@ -4235,6 +4232,16 @@ "node": "*" } }, + "node_modules/eslint-plugin-react/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/eslint-plugin-security": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/eslint-plugin-security/-/eslint-plugin-security-3.0.1.tgz", @@ -5100,19 +5107,6 @@ "semver": "^7.7.1" } }, - "node_modules/is-bun-module/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/is-callable": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", @@ -5847,6 +5841,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/node-exports-info/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/node-releases": { "version": "2.0.50", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", @@ -6468,13 +6472,15 @@ } }, "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "license": "ISC", "bin": { "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" } }, "node_modules/set-function-length": { diff --git a/package.json b/package.json index d9371e5..909ccc9 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "dependencies": { "@clack/prompts": "^1.6.0", "handlebars": "^4.7.9", + "semver": "^7.8.5", "zod": "^4.4.3" }, "devDependencies": { @@ -37,6 +38,7 @@ "@gravity-ui/tsconfig": "^1.0.0", "@playwright/test": "^1.61.1", "@types/node": "^24.13.3", + "@types/semver": "^7.7.1", "eslint": "^9.39.4", "eslint-plugin-n": "^18.2.1", "knip": "^6.23.0", diff --git a/src/cli/args.test.ts b/src/cli/args.test.ts index cdd02c9..f635bcf 100644 --- a/src/cli/args.test.ts +++ b/src/cli/args.test.ts @@ -4,10 +4,17 @@ import {parseCli} from './args.js'; test.describe('parseCli', () => { test('parses string and enum flags', (t: TestContext) => { - const result = parseCli(['--out', './my-app', '--language', 'ts', '--node-version', '22']); + const result = parseCli([ + '--out', + './my-app', + '--language', + 'ts', + '--node-version', + '24.1.0', + ]); t.assert.equal(result.out, './my-app'); t.assert.equal(result.language, 'ts'); - t.assert.equal(result['node-version'], '22'); + t.assert.equal(result['node-version'], '24.1.0'); }); test('parses boolean flags', (t: TestContext) => { @@ -53,7 +60,8 @@ test.describe('parseCli', () => { test('formats a zod error for a flag with --prefix', (t: TestContext) => { t.assert.throws(() => parseCli(['--language', 'cobol']), /^Error: --language: /); - t.assert.throws(() => parseCli(['--node-version', '20']), /^Error: --node-version: /); + t.assert.throws(() => parseCli(['--node-version', 'latest']), /^Error: --node-version: /); + t.assert.throws(() => parseCli(['--node-version', '21']), /^Error: --node-version: /); }); test('formats the out field error without a -- prefix', (t: TestContext) => { diff --git a/src/cli/introspect.test.ts b/src/cli/introspect.test.ts index 54444e8..3211fd4 100644 --- a/src/cli/introspect.test.ts +++ b/src/cli/introspect.test.ts @@ -49,15 +49,11 @@ test.describe('listFields', () => { const language = fields.find((f) => f.name === 'language'); t.assert.equal(language?.kind, 'enum'); t.assert.deepEqual(language?.choices, ['ts', 'js']); - - const nodeVersion = fields.find((f) => f.name === 'node-version'); - t.assert.equal(nodeVersion?.kind, 'enum'); - t.assert.deepEqual(nodeVersion?.choices, ['22', '24']); }); test('classifies plain string fields as kind "string"', (t: TestContext) => { const fields = listFields(); - for (const name of ['out', 'registry']) { + for (const name of ['out', 'node-version', 'registry']) { const field = fields.find((f) => f.name === name); t.assert.equal(field?.kind, 'string'); } diff --git a/src/cli/schema.ts b/src/cli/schema.ts index e4a8980..b576674 100644 --- a/src/cli/schema.ts +++ b/src/cli/schema.ts @@ -1,7 +1,8 @@ import {z} from 'zod'; -import {DEFAULT_NODE_VERSION, SUPPORTED_NODE_VERSIONS} from '../utils/constants.js'; +import {DEFAULT_NODE_VERSION} from '../utils/constants.js'; import {validateDestination} from '../utils/destination.js'; +import {validateNodeVersion} from '../utils/nodeVersion.js'; export type FlagGroup = 'project' | 'mode' | 'other'; @@ -58,13 +59,20 @@ export const CliSchema = z ), 'node-version': z - .enum(SUPPORTED_NODE_VERSIONS) + .string() + .superRefine((value, ctx) => { + const message = validateNodeVersion(value); + if (message) { + ctx.addIssue({code: 'custom', message}); + } + }) .optional() .meta( flagMeta({ - description: 'Node.js version for the generated project', + description: + 'Node.js version for the generated project in node-semver format (minimum 22)', group: 'project', - placeholder: '<22|24>', + placeholder: '', }), ), diff --git a/src/model/types.ts b/src/model/types.ts index d074965..1d4a935 100644 --- a/src/model/types.ts +++ b/src/model/types.ts @@ -1,5 +1,3 @@ -import type {NodeVersion} from '../utils/constants.js'; - type Language = 'ts' | 'js'; export type FrontendFeature = 'styles' | 'react'; @@ -14,7 +12,7 @@ export interface ProjectModel { language: Language; // Node.js - nodeVersion: NodeVersion; + nodeVersion: string; // Frontend: false = no frontend, array = frontend with these features frontend: false | FrontendFeature[]; diff --git a/src/prompts/i18n.ts b/src/prompts/i18n.ts index 961f738..8cfcd68 100644 --- a/src/prompts/i18n.ts +++ b/src/prompts/i18n.ts @@ -6,7 +6,7 @@ export const i18n = { label_language: 'Language', 'label_language-ts': 'TypeScript', 'label_language-js': 'JavaScript', - 'label_node-version': 'Node.js version', + 'label_node-version': 'Node.js version (node-semver format, minimum 22)', 'label_has-frontend': 'Does your project have a frontend?', 'label_has-styles': 'Will your project have styles?', 'label_has-react': 'Will your project use React?', diff --git a/src/prompts/questions.ts b/src/prompts/questions.ts index 2a800d1..4990ba6 100644 --- a/src/prompts/questions.ts +++ b/src/prompts/questions.ts @@ -1,12 +1,9 @@ import path from 'node:path'; import type {ProjectModel} from '../model/index.js'; -import { - DEFAULT_NODE_VERSION, - DEFAULT_NPM_REGISTRY, - MINIMUM_NODE_VERSION, -} from '../utils/constants.js'; +import {DEFAULT_NODE_VERSION, DEFAULT_NPM_REGISTRY} from '../utils/constants.js'; import {validateDestination} from '../utils/destination.js'; +import {validateNodeVersion} from '../utils/nodeVersion.js'; import {i18n} from './i18n.js'; import type {Prompter} from './types.js'; @@ -62,21 +59,11 @@ export async function askLanguage(prompter: Prompter, model: ProjectModel): Prom } export async function askNodeVersion(prompter: Prompter, model: ProjectModel): Promise { - const nodeVersion = await prompter.select({ + const nodeVersion = await prompter.text({ message: i18n['label_node-version'], - options: [ - { - value: DEFAULT_NODE_VERSION, - label: `Node.js ${DEFAULT_NODE_VERSION}`, - hint: 'recommended', - }, - { - value: MINIMUM_NODE_VERSION, - label: `Node.js ${MINIMUM_NODE_VERSION}`, - hint: 'minimum supported', - }, - ], - initialValue: DEFAULT_NODE_VERSION, + placeholder: DEFAULT_NODE_VERSION, + defaultValue: DEFAULT_NODE_VERSION, + validate: validateNodeVersion, }); model.nodeVersion = nodeVersion; diff --git a/src/prompts/runPromptFlow.test.ts b/src/prompts/runPromptFlow.test.ts index af71ab7..5eca376 100644 --- a/src/prompts/runPromptFlow.test.ts +++ b/src/prompts/runPromptFlow.test.ts @@ -114,13 +114,13 @@ test.describe('runPromptFlow', () => { text: { 'Destination folder': './my-project', 'Registry URL': 'https://custom.registry', + 'Node.js version (node-semver format, minimum 22)': '24.1.0', }, confirm: { 'Use custom npm registry?': true, }, select: { Language: 'js', - 'Node.js version': '22', 'Does your project have a frontend?': true, 'Will your project have styles?': false, 'Will your project use React?': true, @@ -134,7 +134,7 @@ test.describe('runPromptFlow', () => { t.assert.equal(model.projectName, path.basename(model.destination)); t.assert.equal(model.registry, 'https://custom.registry'); t.assert.equal(model.language, 'js'); - t.assert.equal(model.nodeVersion, '22'); + t.assert.equal(model.nodeVersion, '24.1.0'); t.assert.deepStrictEqual(model.frontend, ['react']); t.assert.equal(model.hasBackend, true); }); diff --git a/src/utils/constants.ts b/src/utils/constants.ts index e9d2acb..f65dca9 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -1,6 +1,3 @@ export const DEFAULT_NPM_REGISTRY = 'https://registry.npmjs.org/'; export const MINIMUM_NODE_VERSION = '22'; export const DEFAULT_NODE_VERSION = '24'; -export const SUPPORTED_NODE_VERSIONS = [MINIMUM_NODE_VERSION, DEFAULT_NODE_VERSION] as const; - -export type NodeVersion = (typeof SUPPORTED_NODE_VERSIONS)[number]; diff --git a/src/utils/nodeVersion.test.ts b/src/utils/nodeVersion.test.ts new file mode 100644 index 0000000..fc0ab41 --- /dev/null +++ b/src/utils/nodeVersion.test.ts @@ -0,0 +1,22 @@ +import {type TestContext, test} from 'node:test'; + +import {validateNodeVersion} from './nodeVersion.js'; + +test.describe('validateNodeVersion', () => { + test('accepts semver versions at or above Node.js 22', (t: TestContext) => { + for (const version of ['22', '22.13', '22.13.0', '24', '24.1.0']) { + t.assert.equal(validateNodeVersion(version), undefined); + } + }); + + test('rejects versions below Node.js 22', (t: TestContext) => { + t.assert.match(validateNodeVersion('21') ?? '', /22 or newer/); + t.assert.match(validateNodeVersion('21.99.0') ?? '', /22 or newer/); + }); + + test('rejects invalid and range-only syntax', (t: TestContext) => { + for (const version of ['', 'latest', '^24', '>=22', '22.x', 'v24']) { + t.assert.match(validateNodeVersion(version) ?? '', /node-semver format/); + } + }); +}); diff --git a/src/utils/nodeVersion.ts b/src/utils/nodeVersion.ts new file mode 100644 index 0000000..6ffcefb --- /dev/null +++ b/src/utils/nodeVersion.ts @@ -0,0 +1,20 @@ +import semver from 'semver'; + +import {MINIMUM_NODE_VERSION} from './constants.js'; + +const VERSION_PATTERN = /^\d+(?:\.\d+){0,2}$/u; +const FORMAT_ERROR = + 'Node.js version must use node-semver format, for example 24, 24.1, or 24.1.0.'; + +export function validateNodeVersion(value: string | undefined): string | undefined { + if (!value || !VERSION_PATTERN.test(value) || !semver.validRange(value)) { + return FORMAT_ERROR; + } + + const version = semver.minVersion(value); + if (!version || semver.lt(version, `${MINIMUM_NODE_VERSION}.0.0`)) { + return `Node.js version must be ${MINIMUM_NODE_VERSION} or newer.`; + } + + return undefined; +}