From af17a146bb47f61a0974ba13e6c895c7a969d12b Mon Sep 17 00:00:00 2001 From: axiosleo Date: Wed, 16 Sep 2026 14:09:18 +0800 Subject: [PATCH 1/2] chore: update package manager version and enhance pnpm detection in init command - Updated package manager version in package.json template from pnpm@11.10.0 to pnpm@10.33.4. - Improved pnpm detection and installation prompt in the init command, including better error handling and recovery instructions for users. --- assets/monorepo/package.json.tmpl | 4 +- commands/init.js | 109 +++++++++++++++++++++++++----- src/cli/pnpm.js | 77 +++++++++++++++++++++ tests/pnpm.tests.js | 103 ++++++++++++++++++++++++++++ 4 files changed, 273 insertions(+), 20 deletions(-) create mode 100644 src/cli/pnpm.js create mode 100644 tests/pnpm.tests.js diff --git a/assets/monorepo/package.json.tmpl b/assets/monorepo/package.json.tmpl index f097037..3a273ba 100644 --- a/assets/monorepo/package.json.tmpl +++ b/assets/monorepo/package.json.tmpl @@ -3,13 +3,13 @@ "version": "0.0.0", "private": true, "description": "${name} monorepo managed by pnpm workspaces", - "packageManager": "pnpm@11.10.0", + "packageManager": "pnpm@10.33.4", "bin": { "${name}": "./bin/${name}.js" }, "engines": { "node": ">=20", - "pnpm": ">=9" + "pnpm": ">=10" }, "scripts": { "dev": "pnpm --filter ${name}-services run dev", diff --git a/commands/init.js b/commands/init.js index e787efd..78566c0 100644 --- a/commands/init.js +++ b/commands/init.js @@ -1,19 +1,57 @@ -/* eslint-disable no-unused-vars */ 'use strict'; const path = require('path'); -const { spawnSync } = require('child_process'); -const { _search, _write, _mkdir, _exists, _move, _sync } = require('@axiosleo/cli-tool/src/helper/fs'); -const { _foreach, _exec } = require('@axiosleo/cli-tool/src/helper/cmd'); -const { Command, printer, debug } = require('@axiosleo/cli-tool'); +const { spawn, spawnSync } = require('child_process'); +const { _search, _write, _mkdir, _move, _sync } = require('@axiosleo/cli-tool/src/helper/fs'); +const { _foreach } = require('@axiosleo/cli-tool/src/helper/cmd'); +const { Command, printer } = require('@axiosleo/cli-tool'); const { _render_with_file } = require('@axiosleo/cli-tool/src/helper/str'); +const { + DEFAULT_PNPM_PIN, + readPnpmPin, + corepackEnv, + isCorepackLoadError, + globalInstallCommand +} = require('../src/cli/pnpm'); -function hasPnpm() { - const result = spawnSync('pnpm', ['--version'], { +function probePnpm(dir, env) { + return spawnSync('pnpm', ['--version'], { + cwd: dir, encoding: 'utf8', - shell: true + shell: true, + env }); - return result.status === 0; +} + +function probeOutput(result) { + const errMsg = result.error && result.error.message ? result.error.message : ''; + return `${result.stderr || ''}${result.stdout || ''}${errMsg}`; +} + +function runCommand(command, dir, env) { + return new Promise((resolve, reject) => { + const child = spawn(command, { + cwd: dir, + shell: true, + stdio: 'inherit', + env + }); + child.on('error', reject); + child.on('close', (code) => { + if (code === 0) { + resolve(); + } else { + reject(new Error(`The command "${command}" exited with code "${code}"`)); + } + }); + }); +} + +function printPnpmRecovery(dir) { + printer.info('The scaffold is already written. To run pnpm later:'); + printer.info(' npm install -g corepack@latest && corepack enable'); + printer.info(' # or switch to Node 22+ / 24 (see .nvmrc)'); + printer.info(` cd ${dir} && pnpm install`); } class InitCommand extends Command { @@ -27,8 +65,8 @@ class InitCommand extends Command { } /** - * @param {*} args - * @param {*} options + * @param {*} args + * @param {*} options */ async exec(args, options) { let name = args.name; @@ -56,23 +94,58 @@ class InitCommand extends Command { printer.success('Initialized successfully'); - if (!hasPnpm()) { + const pin = readPnpmPin(dir) || DEFAULT_PNPM_PIN; + const env = corepackEnv(process.env); + const installHint = globalInstallCommand(pin); + let probe = probePnpm(dir, env); + + if (probe.status !== 0) { + const output = probeOutput(probe); + if (isCorepackLoadError(output)) { + printer.error('pnpm failed to run (Corepack could not load the pinned version).'); + printPnpmRecovery(dir); + return; + } printer.warning('pnpm is required but not found'); if (await this.confirm('install pnpm?', true)) { - printer.info('running: npm install -g pnpm'); - await _exec('npm install -g pnpm', dir); + printer.info(`running: ${installHint}`); + try { + await runCommand(installHint, dir, env); + } catch (err) { + printer.error(err && err.message ? err.message : String(err)); + printPnpmRecovery(dir); + return; + } + probe = probePnpm(dir, env); + if (probe.status !== 0) { + printer.error('pnpm is still not usable after install.'); + printPnpmRecovery(dir); + return; + } } else { - printer.info('Please run: npm install -g pnpm'); - process.exit(0); + printer.info(`Please run: ${installHint}`); + printer.info(`Then: cd ${dir} && pnpm install`); + return; } } if (await this.confirm('install dependencies?', true)) { - await _exec('pnpm install', dir); + try { + await runCommand('pnpm install', dir, env); + } catch (err) { + printer.error(err && err.message ? err.message : String(err)); + printPnpmRecovery(dir); + return; + } } if (await this.confirm('start services right now?')) { - await _exec('pnpm dev', dir); + try { + await runCommand('pnpm dev', dir, env); + } catch (err) { + printer.error(err && err.message ? err.message : String(err)); + printPnpmRecovery(dir); + } } } } diff --git a/src/cli/pnpm.js b/src/cli/pnpm.js new file mode 100644 index 0000000..868e0e3 --- /dev/null +++ b/src/cli/pnpm.js @@ -0,0 +1,77 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const DEFAULT_PNPM_PIN = '10.33.4'; +const NPMMIRROR = 'https://registry.npmmirror.com'; + +/** + * Parse a Corepack `packageManager` field into a pnpm version pin. + * @param {unknown} packageManager + * @returns {string|null} + */ +function parsePnpmPin(packageManager) { + if (typeof packageManager !== 'string') { + return null; + } + const match = packageManager.trim().match(/^pnpm@(.+)$/); + return match ? match[1] : null; +} + +/** + * Read `packageManager` from `dir/package.json` and return the pnpm pin. + * @param {string} dir + * @returns {string|null} + */ +function readPnpmPin(dir) { + try { + const raw = fs.readFileSync(path.join(dir, 'package.json'), 'utf8'); + const pkg = JSON.parse(raw); + return parsePnpmPin(pkg && pkg.packageManager); + } catch (_err) { // eslint-disable-line no-unused-vars + return null; + } +} + +/** + * Env for Corepack-backed `pnpm` during `koapp init`. + * Skips the extra download prompt and uses the same registry as the template `.npmrc`. + * An existing `COREPACK_NPM_REGISTRY` on the caller is left intact. + * + * @param {NodeJS.ProcessEnv} [baseEnv] + * @returns {NodeJS.ProcessEnv} + */ +function corepackEnv(baseEnv = process.env) { + return { + ...baseEnv, + COREPACK_ENABLE_DOWNLOAD_PROMPT: '0', + COREPACK_NPM_REGISTRY: baseEnv.COREPACK_NPM_REGISTRY || NPMMIRROR + }; +} + +/** + * @param {unknown} output + * @returns {boolean} + */ +function isCorepackLoadError(output) { + return String(output || '').includes('ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING'); +} + +/** + * @param {string} pin + * @returns {string} + */ +function globalInstallCommand(pin) { + return `npm install -g pnpm@${pin}`; +} + +module.exports = { + DEFAULT_PNPM_PIN, + NPMMIRROR, + parsePnpmPin, + readPnpmPin, + corepackEnv, + isCorepackLoadError, + globalInstallCommand +}; diff --git a/tests/pnpm.tests.js b/tests/pnpm.tests.js new file mode 100644 index 0000000..a5eb2ee --- /dev/null +++ b/tests/pnpm.tests.js @@ -0,0 +1,103 @@ +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { expect } = require('chai'); +const { + DEFAULT_PNPM_PIN, + NPMMIRROR, + parsePnpmPin, + readPnpmPin, + corepackEnv, + isCorepackLoadError, + globalInstallCommand +} = require('../src/cli/pnpm'); + +function mkdtemp(prefix) { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} + +describe('cli/pnpm', () => { + describe('parsePnpmPin()', () => { + it('reads a pnpm@version field', () => { + expect(parsePnpmPin('pnpm@10.33.4')).to.equal('10.33.4'); + }); + + it('keeps a hash suffix after the version', () => { + expect(parsePnpmPin('pnpm@10.33.4+sha512.abc')).to.equal('10.33.4+sha512.abc'); + }); + + it('returns null for other package managers or empty values', () => { + expect(parsePnpmPin('yarn@4.1.0')).to.equal(null); + expect(parsePnpmPin('pnpm')).to.equal(null); + expect(parsePnpmPin('')).to.equal(null); + expect(parsePnpmPin(null)).to.equal(null); + expect(parsePnpmPin(undefined)).to.equal(null); + }); + }); + + describe('readPnpmPin()', () => { + it('matches the monorepo template pin', () => { + const tmpl = fs.readFileSync( + path.join(__dirname, '../assets/monorepo/package.json.tmpl'), + 'utf8' + ); + expect(tmpl).to.include(`"packageManager": "pnpm@${DEFAULT_PNPM_PIN}"`); + expect(tmpl).to.include('"pnpm": ">=10"'); + }); + + it('reads packageManager from package.json', () => { + const dir = mkdtemp('koapp-pnpm-pin-'); + fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ + name: 'demo', + packageManager: 'pnpm@10.33.4' + })); + expect(readPnpmPin(dir)).to.equal('10.33.4'); + }); + + it('returns null when package.json is missing', () => { + const dir = mkdtemp('koapp-pnpm-missing-'); + expect(readPnpmPin(dir)).to.equal(null); + }); + }); + + describe('corepackEnv()', () => { + it('skips the Corepack download prompt and uses npmmirror by default', () => { + const env = corepackEnv({ PATH: '/usr/bin', HOME: '/tmp' }); + expect(env.COREPACK_ENABLE_DOWNLOAD_PROMPT).to.equal('0'); + expect(env.COREPACK_NPM_REGISTRY).to.equal(NPMMIRROR); + expect(env.PATH).to.equal('/usr/bin'); + }); + + it('does not override an existing COREPACK_NPM_REGISTRY', () => { + const env = corepackEnv({ COREPACK_NPM_REGISTRY: 'https://registry.npmjs.org' }); + expect(env.COREPACK_NPM_REGISTRY).to.equal('https://registry.npmjs.org'); + expect(env.COREPACK_ENABLE_DOWNLOAD_PROMPT).to.equal('0'); + }); + }); + + describe('isCorepackLoadError()', () => { + it('detects the Node/Corepack VM import failure from the screenshot', () => { + const stderr = [ + 'node:internal/modules/esm/utils:231', + ' throw new ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING();', + "TypeError [ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING]: A dynamic import callback was not specified." + ].join('\n'); + expect(isCorepackLoadError(stderr)).to.equal(true); + }); + + it('returns false for unrelated output', () => { + expect(isCorepackLoadError('command not found: pnpm')).to.equal(false); + expect(isCorepackLoadError('')).to.equal(false); + expect(isCorepackLoadError(null)).to.equal(false); + }); + }); + + describe('globalInstallCommand()', () => { + it('pins the requested version instead of npm latest', () => { + expect(globalInstallCommand('10.33.4')).to.equal('npm install -g pnpm@10.33.4'); + expect(globalInstallCommand(DEFAULT_PNPM_PIN)).to.equal('npm install -g pnpm@10.33.4'); + }); + }); +}); From fa77727e52f0fcdd3336a82a55264b30907712cd Mon Sep 17 00:00:00 2001 From: axiosleo Date: Wed, 16 Sep 2026 14:14:07 +0800 Subject: [PATCH 2/2] chore: update Node.js versions in CI workflow - Modified the Node.js version matrix in the CI configuration to include versions 20, 22, 24, and 26, removing version 16. --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c7ba290..b795583 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: strategy: matrix: operating-system: [macos-latest, ubuntu-latest] - node-version: [16, 18, 20, 22, 24] + node-version: [20, 22, 24, 26] name: Node.js ${{ matrix.node-version }} Test on ${{ matrix.operating-system }} steps: