From 6852eb357ca17ae18316dd69ef9ba832c80abdc3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 5 Jun 2026 13:54:14 +0000 Subject: [PATCH 01/12] chore(deps): bump commander from 14.0.3 to 15.0.0 Bumps [commander](https://github.com/tj/commander.js) from 14.0.3 to 15.0.0. - [Release notes](https://github.com/tj/commander.js/releases) - [Changelog](https://github.com/tj/commander.js/blob/master/CHANGELOG.md) - [Commits](https://github.com/tj/commander.js/compare/v14.0.3...v15.0.0) --- updated-dependencies: - dependency-name: commander dependency-version: 15.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- package.json | 2 +- pnpm-lock.yaml | 20 ++++++++++---------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/package.json b/package.json index 9ba2ba7..ab2d7a8 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,7 @@ "@vitejs/plugin-react": "^5.2.0", "boxen": "8.0.1", "chalk": "5.6.2", - "commander": "14.0.3", + "commander": "15.0.0", "concurrently": "^10.0.0", "esbuild": "^0.28.0", "execa": "^9.6.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 319e6ba..b16aaf1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,8 +21,8 @@ importers: specifier: 5.6.2 version: 5.6.2 commander: - specifier: 14.0.3 - version: 14.0.3 + specifier: 15.0.0 + version: 15.0.0 concurrently: specifier: ^10.0.0 version: 10.0.0 @@ -1944,9 +1944,9 @@ packages: resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} engines: {node: '>=18'} - commander@14.0.3: - resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} - engines: {node: '>=20'} + commander@15.0.0: + resolution: {integrity: sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==} + engines: {node: '>=22.12.0'} common-tags@1.8.2: resolution: {integrity: sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==} @@ -3685,8 +3685,8 @@ packages: react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} - react-is@19.2.6: - resolution: {integrity: sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw==} + react-is@19.2.7: + resolution: {integrity: sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==} react-refresh@0.18.0: resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} @@ -6353,7 +6353,7 @@ snapshots: commander@13.1.0: {} - commander@14.0.3: {} + commander@15.0.0: {} common-tags@1.8.2: {} @@ -8438,7 +8438,7 @@ snapshots: '@jest/schemas': 30.4.1 ansi-styles: 5.2.0 react-is-18: react-is@18.3.1 - react-is-19: react-is@19.2.6 + react-is-19: react-is@19.2.7 pretty-ms@9.3.0: dependencies: @@ -8477,7 +8477,7 @@ snapshots: react-is@18.3.1: {} - react-is@19.2.6: {} + react-is@19.2.7: {} react-refresh@0.18.0: {} From c095018958e2366c67b24bebf1945a8e10e66250 Mon Sep 17 00:00:00 2001 From: Ben Irvin Date: Wed, 10 Jun 2026 15:33:20 +0200 Subject: [PATCH 02/12] fix(test): support commander 15 ESM-only in Jest on Node 22 commander 15 dropped CJS; Jest on Node 22 cannot require() it natively. Bundle commander to CJS for tests via esbuild moduleNameMapper, and inline it in the Vite CJS build so dist/cli.js stays require()-able. --- .eslintignore | 1 + .gitignore | 1 + jest.config.mjs | 21 +++++++++++++++++++++ vite.config.ts | 3 ++- 4 files changed, 25 insertions(+), 1 deletion(-) diff --git a/.eslintignore b/.eslintignore index a1dd9e3..913de97 100644 --- a/.eslintignore +++ b/.eslintignore @@ -3,3 +3,4 @@ coverage dist *.yaml .changeset +src/__tests__/support diff --git a/.gitignore b/.gitignore index 1f434d3..3bff54e 100644 --- a/.gitignore +++ b/.gitignore @@ -95,3 +95,4 @@ package-lock.json ############################ __tmp__/ +src/__tests__/support/ diff --git a/jest.config.mjs b/jest.config.mjs index 5895fbb..62bccb0 100644 --- a/jest.config.mjs +++ b/jest.config.mjs @@ -1,3 +1,21 @@ +import { buildSync } from 'esbuild'; +import { mkdirSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const rootDir = dirname(fileURLToPath(import.meta.url)); +const commanderCjs = join(rootDir, 'src/__tests__/support/commander.cjs'); + +// commander 15 is ESM-only; Jest on Node 22 cannot require() it natively. +mkdirSync(dirname(commanderCjs), { recursive: true }); +buildSync({ + entryPoints: [join(rootDir, 'node_modules/commander/index.js')], + bundle: true, + format: 'cjs', + platform: 'node', + outfile: commanderCjs, +}); + /** * @type {import('jest').Config} */ @@ -5,6 +23,9 @@ export default { modulePathIgnorePatterns: ['dist'], testMatch: ['**/__tests__/**/*.test.{js,ts}'], testPathIgnorePatterns: ['__tests__/fixtures/'], + moduleNameMapper: { + '^commander$': '/src/__tests__/support/commander.cjs', + }, transform: { '^.+\\.(t|j)sx?$': ['@swc/jest'], }, diff --git a/vite.config.ts b/vite.config.ts index 39eaf2f..092232c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -10,7 +10,8 @@ const pkg = JSON.parse( readFileSync(new URL('./package.json', import.meta.url), 'utf-8') ) as PackageJsonLike; -const externals = Object.keys(pkg.dependencies ?? {}); +// commander 15 is ESM-only; bundle it so dist/cli.js stays require()-able on Node 22. +const externals = Object.keys(pkg.dependencies ?? {}).filter((dep) => dep !== 'commander'); export default defineConfig({ build: { From 7e8dd1c43f1a8efb579db391814f231dc4fffde6 Mon Sep 17 00:00:00 2001 From: Ben Irvin Date: Wed, 10 Jun 2026 16:00:57 +0200 Subject: [PATCH 03/12] fix(cli): load ESM-only commander 15 via dynamic import Replace the static import of commander with a lazy loader (mirroring chalk-loader) so the published CJS bundle and Jest on Node 22 no longer require() commander 15, which is ESM-only and throws on Node < 24.9. The loader uses an indirect dynamic import so neither @swc/jest nor rollup rewrites it back to require(). This reverts the earlier esbuild shim / de-externalization workaround. --- .eslintignore | 1 - .gitignore | 1 - jest.config.mjs | 21 ------- src/__tests__/e2e/test-utils.ts | 11 +++- src/cli/commands/plugin/build.ts | 3 +- src/cli/commands/plugin/watch.ts | 3 +- src/cli/commands/utils/commander-loader.ts | 66 ++++++++++++++++++++++ src/index.ts | 25 ++++---- vite.config.ts | 3 +- 9 files changed, 91 insertions(+), 43 deletions(-) create mode 100644 src/cli/commands/utils/commander-loader.ts diff --git a/.eslintignore b/.eslintignore index 913de97..a1dd9e3 100644 --- a/.eslintignore +++ b/.eslintignore @@ -3,4 +3,3 @@ coverage dist *.yaml .changeset -src/__tests__/support diff --git a/.gitignore b/.gitignore index 3bff54e..1f434d3 100644 --- a/.gitignore +++ b/.gitignore @@ -95,4 +95,3 @@ package-lock.json ############################ __tmp__/ -src/__tests__/support/ diff --git a/jest.config.mjs b/jest.config.mjs index 62bccb0..5895fbb 100644 --- a/jest.config.mjs +++ b/jest.config.mjs @@ -1,21 +1,3 @@ -import { buildSync } from 'esbuild'; -import { mkdirSync } from 'node:fs'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const rootDir = dirname(fileURLToPath(import.meta.url)); -const commanderCjs = join(rootDir, 'src/__tests__/support/commander.cjs'); - -// commander 15 is ESM-only; Jest on Node 22 cannot require() it natively. -mkdirSync(dirname(commanderCjs), { recursive: true }); -buildSync({ - entryPoints: [join(rootDir, 'node_modules/commander/index.js')], - bundle: true, - format: 'cjs', - platform: 'node', - outfile: commanderCjs, -}); - /** * @type {import('jest').Config} */ @@ -23,9 +5,6 @@ export default { modulePathIgnorePatterns: ['dist'], testMatch: ['**/__tests__/**/*.test.{js,ts}'], testPathIgnorePatterns: ['__tests__/fixtures/'], - moduleNameMapper: { - '^commander$': '/src/__tests__/support/commander.cjs', - }, transform: { '^.+\\.(t|j)sx?$': ['@swc/jest'], }, diff --git a/src/__tests__/e2e/test-utils.ts b/src/__tests__/e2e/test-utils.ts index b8ea8b0..1169b3b 100644 --- a/src/__tests__/e2e/test-utils.ts +++ b/src/__tests__/e2e/test-utils.ts @@ -1,6 +1,9 @@ -import { Command } from 'commander'; import path from 'node:path'; +import { createCommandInstance, loadCommander } from '../../cli/commands/utils/commander-loader'; + +import type { Command } from 'commander'; + /** * Shared test utilities for e2e CLI tests. */ @@ -35,7 +38,8 @@ export async function withMockedCLI(fixtureName: string, testFn: TestCallback): }); try { - const command = new Command(); + await loadCommander(); + const command = createCommandInstance(); await testFn({ command, mockExit }); } finally { process.cwd = originalCwd; @@ -63,7 +67,8 @@ export async function ensureFixtureBuilt(fixtureName: string): Promise { */ export async function invokeCLI(args: string[], command?: Command): Promise { const { createCLI } = await import('../../index'); - const cmd = command ?? new Command(); + await loadCommander(); + const cmd = command ?? createCommandInstance(); await createCLI(['node', 'strapi-plugin', ...args], cmd); return cmd; } diff --git a/src/cli/commands/plugin/build.ts b/src/cli/commands/plugin/build.ts index cc0fc66..ebd43d9 100644 --- a/src/cli/commands/plugin/build.ts +++ b/src/cli/commands/plugin/build.ts @@ -4,8 +4,7 @@ * Bundles the plugin for npm publishing using Vite. * Produces dual CommonJS/ESM output with TypeScript declarations. */ -import { createCommand } from 'commander'; - +import { createCommand } from '../utils/commander-loader'; import { formatBoxedErrorStack, runAction } from '../utils/helpers'; import type { CLIContext, StrapiCommand } from '../../../types'; diff --git a/src/cli/commands/plugin/watch.ts b/src/cli/commands/plugin/watch.ts index 6f150d5..3496462 100644 --- a/src/cli/commands/plugin/watch.ts +++ b/src/cli/commands/plugin/watch.ts @@ -4,8 +4,7 @@ * Watches source files and rebuilds on changes using Vite. * Used during local plugin development. */ -import { createCommand } from 'commander'; - +import { createCommand } from '../utils/commander-loader'; import { formatBoxedErrorStack, runAction } from '../utils/helpers'; import type { StrapiCommand, CLIContext } from '../../../types'; diff --git a/src/cli/commands/utils/commander-loader.ts b/src/cli/commands/utils/commander-loader.ts new file mode 100644 index 0000000..8c0b5cf --- /dev/null +++ b/src/cli/commands/utils/commander-loader.ts @@ -0,0 +1,66 @@ +import type * as Commander from 'commander'; + +type CommanderModule = typeof Commander; +type Command = Commander.Command; + +/** + * Indirect dynamic import: keeps a native runtime `import()` that neither + * @swc/jest nor rollup rewrites to `require()`. commander 15 is ESM-only, and + * `require()`-ing it throws on Node < 24.9 (CJS bundle + Jest on Node 22). + */ +// eslint-disable-next-line @typescript-eslint/no-implied-eval +const importEsm = new Function('specifier', 'return import(specifier)') as ( + specifier: string +) => Promise; + +let commanderModule: CommanderModule | undefined; +let commanderPromise: Promise | undefined; + +const resolveCommander = ( + candidate: CommanderModule & { default?: CommanderModule } +): CommanderModule => { + if (typeof candidate?.createCommand === 'function') { + return candidate; + } + + // Jest/@swc interop: named exports can be re-wrapped under `default`. + if (typeof candidate?.default?.createCommand === 'function') { + return candidate.default; + } + + throw new TypeError('Failed to load commander'); +}; + +export const loadCommander = async (): Promise => { + if (commanderModule) { + return commanderModule; + } + + if (!commanderPromise) { + commanderPromise = importEsm('commander').then((mod) => { + commanderModule = resolveCommander(mod); + + return commanderModule; + }); + } + + return commanderPromise; +}; + +export const getCommander = (): CommanderModule => { + if (!commanderModule) { + throw new Error( + 'commander has not been loaded; call loadCommander() before using getCommander()' + ); + } + + return commanderModule; +}; + +export const createCommand = (name?: string): Command => getCommander().createCommand(name); + +export const createCommandInstance = (name?: string): Command => { + const { Command: CommanderCommand } = getCommander(); + + return new CommanderCommand(name); +}; diff --git a/src/index.ts b/src/index.ts index 458d341..0c219ce 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,23 +1,26 @@ -import { Command } from 'commander'; - import { commands as strapiCommands } from './cli/commands'; import { loadChalk } from './cli/commands/utils/chalk-loader'; +import { createCommandInstance, loadCommander } from './cli/commands/utils/commander-loader'; import { createLogger } from './cli/commands/utils/logger'; import { loadTsConfig } from './cli/commands/utils/tsconfig'; import type { CLIContext } from './types'; +import type { Command } from 'commander'; -const createCLI = async (argv: string[], command = new Command()) => { +const createCLI = async (argv: string[], command?: Command) => { + await loadCommander(); await loadChalk(); + const program = command ?? createCommandInstance(); + // Initial program setup - command.storeOptionsAsProperties(false).allowUnknownOption(true); + program.storeOptionsAsProperties(false).allowUnknownOption(true); // Help command - command.helpOption('-h, --help', 'Display help for command'); - command.helpCommand('help [command]', 'Display help for command'); + program.helpOption('-h, --help', 'Display help for command'); + program.helpCommand('help [command]', 'Display help for command'); - command.version( + program.version( // eslint-disable-next-line @typescript-eslint/no-var-requires require('../package.json').version, '-v, --version', @@ -46,21 +49,21 @@ const createCLI = async (argv: string[], command = new Command()) => { // Load all commands strapiCommands.forEach((commandFactory) => { try { - const subCommand = commandFactory({ command, argv, ctx }); + const subCommand = commandFactory({ command: program, argv, ctx }); // Add this command to the Commander command object if (subCommand) { - command.addCommand(subCommand); + program.addCommand(subCommand); } } catch (e) { logger.error('Failed to load command', e); } }); - return command; + return program; }; -const runCLI = async (argv = process.argv, command = new Command()) => { +const runCLI = async (argv = process.argv, command?: Command) => { const commands = await createCLI(argv, command); await commands.parseAsync(argv); }; diff --git a/vite.config.ts b/vite.config.ts index 092232c..39eaf2f 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -10,8 +10,7 @@ const pkg = JSON.parse( readFileSync(new URL('./package.json', import.meta.url), 'utf-8') ) as PackageJsonLike; -// commander 15 is ESM-only; bundle it so dist/cli.js stays require()-able on Node 22. -const externals = Object.keys(pkg.dependencies ?? {}).filter((dep) => dep !== 'commander'); +const externals = Object.keys(pkg.dependencies ?? {}); export default defineConfig({ build: { From ac4a00304a48eac379bf9300e37d516ce6d50a46 Mon Sep 17 00:00:00 2001 From: Ben Irvin Date: Thu, 11 Jun 2026 12:00:01 +0200 Subject: [PATCH 04/12] fix(test): load commander 15 under Jest without cross-file teardown Use a plain dynamic import() under Jest on Node >= 24.9 so commander loads within each test file's environment instead of a shared native-import realm that throws "import after teardown" once the first file finishes. Keep the native import() escape for the CJS bundle and for Jest on Node 22, and isolate test suites to separate workers on those versions so the escape cannot poison later files in the same process. --- jest.config.mjs | 6 +++ src/cli/commands/utils/commander-loader.ts | 26 ++++------- src/cli/commands/utils/esm-interop.ts | 50 +++++++++++++++++++--- 3 files changed, 60 insertions(+), 22 deletions(-) diff --git a/jest.config.mjs b/jest.config.mjs index 5895fbb..4096fdf 100644 --- a/jest.config.mjs +++ b/jest.config.mjs @@ -1,3 +1,6 @@ +const [nodeMajor = 0, nodeMinor = 0] = process.versions.node.split('.').map(Number); +const nodeHasSyncVmModules = nodeMajor > 24 || (nodeMajor === 24 && nodeMinor >= 9); + /** * @type {import('jest').Config} */ @@ -10,4 +13,7 @@ export default { }, displayName: 'Plugin CLI', collectCoverageFrom: ['src/**/*.ts'], + // ESM-only deps (commander 15) need a native `import()` escape on Node < 24.9; that + // escape poisons later test files in the same worker. One worker per suite avoids reuse. + ...(!nodeHasSyncVmModules ? { maxWorkers: 8 } : {}), }; diff --git a/src/cli/commands/utils/commander-loader.ts b/src/cli/commands/utils/commander-loader.ts index 8c0b5cf..81fd089 100644 --- a/src/cli/commands/utils/commander-loader.ts +++ b/src/cli/commands/utils/commander-loader.ts @@ -1,31 +1,23 @@ +import { importEsm } from './esm-interop'; + import type * as Commander from 'commander'; type CommanderModule = typeof Commander; type Command = Commander.Command; -/** - * Indirect dynamic import: keeps a native runtime `import()` that neither - * @swc/jest nor rollup rewrites to `require()`. commander 15 is ESM-only, and - * `require()`-ing it throws on Node < 24.9 (CJS bundle + Jest on Node 22). - */ -// eslint-disable-next-line @typescript-eslint/no-implied-eval -const importEsm = new Function('specifier', 'return import(specifier)') as ( - specifier: string -) => Promise; - let commanderModule: CommanderModule | undefined; let commanderPromise: Promise | undefined; -const resolveCommander = ( - candidate: CommanderModule & { default?: CommanderModule } -): CommanderModule => { - if (typeof candidate?.createCommand === 'function') { - return candidate; +const resolveCommander = (mod: Record): CommanderModule => { + const candidate = mod as Partial & { default?: Partial }; + + if (typeof candidate.createCommand === 'function') { + return candidate as CommanderModule; } // Jest/@swc interop: named exports can be re-wrapped under `default`. - if (typeof candidate?.default?.createCommand === 'function') { - return candidate.default; + if (typeof candidate.default?.createCommand === 'function') { + return candidate.default as CommanderModule; } throw new TypeError('Failed to load commander'); diff --git a/src/cli/commands/utils/esm-interop.ts b/src/cli/commands/utils/esm-interop.ts index 1afa636..537db08 100644 --- a/src/cli/commands/utils/esm-interop.ts +++ b/src/cli/commands/utils/esm-interop.ts @@ -1,10 +1,50 @@ +type EsmModule = Record; + +/** + * Whether the running Node exposes the synchronous vm-module APIs Jest needs to + * load a pure-ESM package through an intercepted `import()` (Node >= 24.9). + */ +const nodeHasSyncVmModules = (() => { + const [major = 0, minor = 0] = process.versions.node.split('.').map(Number); + + return major > 24 || (major === 24 && minor >= 9); +})(); + +const underJest = process.env.JEST_WORKER_ID !== undefined; + /** - * Dynamic import that bypasses Jest's `import()` hook on Node < 24.9. - * Jest 30 cannot load pure ESM packages via intercepted `import()` on those versions. + * Native `import()` that survives transpilation. Both `@swc/jest` and the rollup + * CJS bundle rewrite a literal `import()` to `require()`, and `require()`-ing a + * pure-ESM package throws on Node < 24.9. Routing through `new Function` keeps a + * real runtime `import()` the transpilers never see. */ -export const importEsm = (specifier: string): Promise> => - // eslint-disable-next-line @typescript-eslint/no-implied-eval -- use Node's native importer - new Function('specifier', 'return import(specifier)')(specifier); +// eslint-disable-next-line @typescript-eslint/no-implied-eval -- use Node's native importer +const nativeImport = new Function('specifier', 'return import(specifier)') as ( + specifier: string +) => Promise; + +/** + * Dynamic import for ESM-only deps (commander, ora, chalk) that works in the CJS + * CLI bundle and under Jest across the Node 22/24/26 CI matrix. + * + * Under Jest on Node >= 24.9 we use a plain `import()`: Jest intercepts it and + * loads the module within the current test file's environment, so it is torn + * down with that file. The `new Function` escape must NOT be used here — its + * `import()` is bound to a realm Jest shares across the worker's test files, so + * once the first file's environment is torn down every later file throws + * "import after teardown". + * + * Everywhere else — the production CJS bundle, and Jest on Node < 24.9 where an + * intercepted `import()` would fall back to an unsupported `require(ESM)` — we + * use the native `import()` escape. + */ +export const importEsm = (specifier: string): Promise => { + if (underJest && nodeHasSyncVmModules) { + return import(specifier); + } + + return nativeImport(specifier); +}; /** * Resolve the callable default export from a dynamic `import()` under Jest/@swc. From 1f41cd3899fc5aa4f437226bac8971db60951a80 Mon Sep 17 00:00:00 2001 From: Ben Irvin Date: Thu, 11 Jun 2026 12:24:34 +0200 Subject: [PATCH 05/12] chore: add changeset for commander 15 upgrade --- .changeset/commander-15-esm.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/commander-15-esm.md diff --git a/.changeset/commander-15-esm.md b/.changeset/commander-15-esm.md new file mode 100644 index 0000000..e518e93 --- /dev/null +++ b/.changeset/commander-15-esm.md @@ -0,0 +1,7 @@ +--- +"@strapi/sdk-plugin": patch +--- + +chore(deps): bump commander from 14.0.3 to 15.0.0 + +fix: load ESM-only commander 15 via a dynamic `import()` so the CJS CLI bundle and Jest tests work across the Node 22/24/26 matrix From 9c9b2e252d59279bb6f0025cc1d4fe20293af9f5 Mon Sep 17 00:00:00 2001 From: Ben Irvin Date: Thu, 11 Jun 2026 12:40:33 +0200 Subject: [PATCH 06/12] fix(cli): make commander loader async at call sites Expose async commander helpers so callers do not need to remember a separate preload step. Await command factories in the CLI setup flow. --- src/__tests__/e2e/test-utils.ts | 8 +++----- src/cli/commands/plugin/build.ts | 6 ++++-- src/cli/commands/plugin/watch.ts | 6 ++++-- src/cli/commands/utils/commander-loader.ts | 18 ++++++------------ src/index.ts | 11 +++++------ src/types.ts | 2 +- 6 files changed, 23 insertions(+), 28 deletions(-) diff --git a/src/__tests__/e2e/test-utils.ts b/src/__tests__/e2e/test-utils.ts index 1169b3b..4130192 100644 --- a/src/__tests__/e2e/test-utils.ts +++ b/src/__tests__/e2e/test-utils.ts @@ -1,6 +1,6 @@ import path from 'node:path'; -import { createCommandInstance, loadCommander } from '../../cli/commands/utils/commander-loader'; +import { createCommandInstance } from '../../cli/commands/utils/commander-loader'; import type { Command } from 'commander'; @@ -38,8 +38,7 @@ export async function withMockedCLI(fixtureName: string, testFn: TestCallback): }); try { - await loadCommander(); - const command = createCommandInstance(); + const command = await createCommandInstance(); await testFn({ command, mockExit }); } finally { process.cwd = originalCwd; @@ -67,8 +66,7 @@ export async function ensureFixtureBuilt(fixtureName: string): Promise { */ export async function invokeCLI(args: string[], command?: Command): Promise { const { createCLI } = await import('../../index'); - await loadCommander(); - const cmd = command ?? createCommandInstance(); + const cmd = command ?? (await createCommandInstance()); await createCLI(['node', 'strapi-plugin', ...args], cmd); return cmd; } diff --git a/src/cli/commands/plugin/build.ts b/src/cli/commands/plugin/build.ts index ebd43d9..fba82a5 100644 --- a/src/cli/commands/plugin/build.ts +++ b/src/cli/commands/plugin/build.ts @@ -48,8 +48,10 @@ const action = async (opts: BuildActionOptions, _cmd: unknown, { logger, cwd }: /** * `$ strapi-plugin build` */ -const command: StrapiCommand = ({ ctx }) => { - return createCommand('build') +const command: StrapiCommand = async ({ ctx }) => { + const buildCommand = await createCommand('build'); + + return buildCommand .description('Bundle your strapi plugin for publishing.') .option('-d, --debug', 'Enable debugging mode with verbose logs', false) .option('--silent', "Don't log anything", false) diff --git a/src/cli/commands/plugin/watch.ts b/src/cli/commands/plugin/watch.ts index 3496462..465aa1f 100644 --- a/src/cli/commands/plugin/watch.ts +++ b/src/cli/commands/plugin/watch.ts @@ -37,8 +37,10 @@ const action = async (opts: WatchActionOptions, _cmd: unknown, { cwd, logger }: /** * `$ strapi-plugin watch` */ -const command: StrapiCommand = ({ ctx }) => { - return createCommand('watch') +const command: StrapiCommand = async ({ ctx }) => { + const watchCommand = await createCommand('watch'); + + return watchCommand .description('Watch & compile your strapi plugin for local development.') .option('-d, --debug', 'Enable debugging mode with verbose logs', false) .option('--silent', "Don't log anything", false) diff --git a/src/cli/commands/utils/commander-loader.ts b/src/cli/commands/utils/commander-loader.ts index 81fd089..fb92552 100644 --- a/src/cli/commands/utils/commander-loader.ts +++ b/src/cli/commands/utils/commander-loader.ts @@ -23,7 +23,7 @@ const resolveCommander = (mod: Record): CommanderModule => { throw new TypeError('Failed to load commander'); }; -export const loadCommander = async (): Promise => { +export const getCommander = async (): Promise => { if (commanderModule) { return commanderModule; } @@ -39,20 +39,14 @@ export const loadCommander = async (): Promise => { return commanderPromise; }; -export const getCommander = (): CommanderModule => { - if (!commanderModule) { - throw new Error( - 'commander has not been loaded; call loadCommander() before using getCommander()' - ); - } +export const createCommand = async (name?: string): Promise => { + const commander = await getCommander(); - return commanderModule; + return commander.createCommand(name); }; -export const createCommand = (name?: string): Command => getCommander().createCommand(name); - -export const createCommandInstance = (name?: string): Command => { - const { Command: CommanderCommand } = getCommander(); +export const createCommandInstance = async (name?: string): Promise => { + const { Command: CommanderCommand } = await getCommander(); return new CommanderCommand(name); }; diff --git a/src/index.ts b/src/index.ts index 0c219ce..6ba2411 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,6 @@ import { commands as strapiCommands } from './cli/commands'; import { loadChalk } from './cli/commands/utils/chalk-loader'; -import { createCommandInstance, loadCommander } from './cli/commands/utils/commander-loader'; +import { createCommandInstance } from './cli/commands/utils/commander-loader'; import { createLogger } from './cli/commands/utils/logger'; import { loadTsConfig } from './cli/commands/utils/tsconfig'; @@ -8,10 +8,9 @@ import type { CLIContext } from './types'; import type { Command } from 'commander'; const createCLI = async (argv: string[], command?: Command) => { - await loadCommander(); await loadChalk(); - const program = command ?? createCommandInstance(); + const program = command ?? (await createCommandInstance()); // Initial program setup program.storeOptionsAsProperties(false).allowUnknownOption(true); @@ -47,9 +46,9 @@ const createCLI = async (argv: string[], command?: Command) => { } satisfies CLIContext; // Load all commands - strapiCommands.forEach((commandFactory) => { + for (const commandFactory of strapiCommands) { try { - const subCommand = commandFactory({ command: program, argv, ctx }); + const subCommand = await commandFactory({ command: program, argv, ctx }); // Add this command to the Commander command object if (subCommand) { @@ -58,7 +57,7 @@ const createCLI = async (argv: string[], command?: Command) => { } catch (e) { logger.error('Failed to load command', e); } - }); + } return program; }; diff --git a/src/types.ts b/src/types.ts index 389ca31..880e533 100644 --- a/src/types.ts +++ b/src/types.ts @@ -21,4 +21,4 @@ export type StrapiCommand = (params: { command: Command; argv: string[]; ctx: CLIContext; -}) => void | Command; +}) => void | Command | Promise; From f362dcabf7bc96e4c44d31ea9669f26c1c1e8b24 Mon Sep 17 00:00:00 2001 From: Ben Irvin Date: Thu, 11 Jun 2026 12:47:18 +0200 Subject: [PATCH 07/12] fix(test): keep ESM interop minimal for commander 15 Remove the Jest worker override and keep the shared ESM import helper aligned with the existing ora loader pattern. --- jest.config.mjs | 6 ---- src/cli/commands/utils/esm-interop.ts | 50 +++------------------------ 2 files changed, 5 insertions(+), 51 deletions(-) diff --git a/jest.config.mjs b/jest.config.mjs index 4096fdf..5895fbb 100644 --- a/jest.config.mjs +++ b/jest.config.mjs @@ -1,6 +1,3 @@ -const [nodeMajor = 0, nodeMinor = 0] = process.versions.node.split('.').map(Number); -const nodeHasSyncVmModules = nodeMajor > 24 || (nodeMajor === 24 && nodeMinor >= 9); - /** * @type {import('jest').Config} */ @@ -13,7 +10,4 @@ export default { }, displayName: 'Plugin CLI', collectCoverageFrom: ['src/**/*.ts'], - // ESM-only deps (commander 15) need a native `import()` escape on Node < 24.9; that - // escape poisons later test files in the same worker. One worker per suite avoids reuse. - ...(!nodeHasSyncVmModules ? { maxWorkers: 8 } : {}), }; diff --git a/src/cli/commands/utils/esm-interop.ts b/src/cli/commands/utils/esm-interop.ts index 537db08..1afa636 100644 --- a/src/cli/commands/utils/esm-interop.ts +++ b/src/cli/commands/utils/esm-interop.ts @@ -1,50 +1,10 @@ -type EsmModule = Record; - -/** - * Whether the running Node exposes the synchronous vm-module APIs Jest needs to - * load a pure-ESM package through an intercepted `import()` (Node >= 24.9). - */ -const nodeHasSyncVmModules = (() => { - const [major = 0, minor = 0] = process.versions.node.split('.').map(Number); - - return major > 24 || (major === 24 && minor >= 9); -})(); - -const underJest = process.env.JEST_WORKER_ID !== undefined; - /** - * Native `import()` that survives transpilation. Both `@swc/jest` and the rollup - * CJS bundle rewrite a literal `import()` to `require()`, and `require()`-ing a - * pure-ESM package throws on Node < 24.9. Routing through `new Function` keeps a - * real runtime `import()` the transpilers never see. + * Dynamic import that bypasses Jest's `import()` hook on Node < 24.9. + * Jest 30 cannot load pure ESM packages via intercepted `import()` on those versions. */ -// eslint-disable-next-line @typescript-eslint/no-implied-eval -- use Node's native importer -const nativeImport = new Function('specifier', 'return import(specifier)') as ( - specifier: string -) => Promise; - -/** - * Dynamic import for ESM-only deps (commander, ora, chalk) that works in the CJS - * CLI bundle and under Jest across the Node 22/24/26 CI matrix. - * - * Under Jest on Node >= 24.9 we use a plain `import()`: Jest intercepts it and - * loads the module within the current test file's environment, so it is torn - * down with that file. The `new Function` escape must NOT be used here — its - * `import()` is bound to a realm Jest shares across the worker's test files, so - * once the first file's environment is torn down every later file throws - * "import after teardown". - * - * Everywhere else — the production CJS bundle, and Jest on Node < 24.9 where an - * intercepted `import()` would fall back to an unsupported `require(ESM)` — we - * use the native `import()` escape. - */ -export const importEsm = (specifier: string): Promise => { - if (underJest && nodeHasSyncVmModules) { - return import(specifier); - } - - return nativeImport(specifier); -}; +export const importEsm = (specifier: string): Promise> => + // eslint-disable-next-line @typescript-eslint/no-implied-eval -- use Node's native importer + new Function('specifier', 'return import(specifier)')(specifier); /** * Resolve the callable default export from a dynamic `import()` under Jest/@swc. From e7031d70cca3ba20f24545bf107bc17a29e01fcf Mon Sep 17 00:00:00 2001 From: Ben Irvin Date: Thu, 11 Jun 2026 12:58:45 +0200 Subject: [PATCH 08/12] fix(cli): resolve SonarCloud issues in commander loader Use a type guard to drop redundant assertions and a nullish assignment for the cached commander promise. --- src/cli/commands/utils/commander-loader.ts | 23 +++++++++++----------- 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/src/cli/commands/utils/commander-loader.ts b/src/cli/commands/utils/commander-loader.ts index fb92552..063a8ee 100644 --- a/src/cli/commands/utils/commander-loader.ts +++ b/src/cli/commands/utils/commander-loader.ts @@ -8,16 +8,17 @@ type Command = Commander.Command; let commanderModule: CommanderModule | undefined; let commanderPromise: Promise | undefined; -const resolveCommander = (mod: Record): CommanderModule => { - const candidate = mod as Partial & { default?: Partial }; +const isCommanderModule = (value: unknown): value is CommanderModule => + typeof (value as { createCommand?: unknown } | undefined)?.createCommand === 'function'; - if (typeof candidate.createCommand === 'function') { - return candidate as CommanderModule; +const resolveCommander = (mod: Record): CommanderModule => { + if (isCommanderModule(mod)) { + return mod; } // Jest/@swc interop: named exports can be re-wrapped under `default`. - if (typeof candidate.default?.createCommand === 'function') { - return candidate.default as CommanderModule; + if (isCommanderModule(mod.default)) { + return mod.default; } throw new TypeError('Failed to load commander'); @@ -28,13 +29,11 @@ export const getCommander = async (): Promise => { return commanderModule; } - if (!commanderPromise) { - commanderPromise = importEsm('commander').then((mod) => { - commanderModule = resolveCommander(mod); + commanderPromise ??= importEsm('commander').then((mod) => { + commanderModule = resolveCommander(mod); - return commanderModule; - }); - } + return commanderModule; + }); return commanderPromise; }; From 543dff5141d568caae470db23c1b31cb6203cd50 Mon Sep 17 00:00:00 2001 From: Ben Irvin Date: Thu, 11 Jun 2026 13:22:23 +0200 Subject: [PATCH 09/12] fix(test): choose ESM import path by Node runtime --- src/cli/commands/utils/esm-interop.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/cli/commands/utils/esm-interop.ts b/src/cli/commands/utils/esm-interop.ts index 1afa636..7527e02 100644 --- a/src/cli/commands/utils/esm-interop.ts +++ b/src/cli/commands/utils/esm-interop.ts @@ -1,10 +1,17 @@ /** - * Dynamic import that bypasses Jest's `import()` hook on Node < 24.9. - * Jest 30 cannot load pure ESM packages via intercepted `import()` on those versions. + * Dynamic import for pure ESM dependencies used by the CommonJS CLI build. */ -export const importEsm = (specifier: string): Promise> => +export const importEsm = (specifier: string): Promise> => { + const [major = 0, minor = 0] = process.versions.node.split('.').map(Number); + const supportsJestEsmImport = major > 24 || (major === 24 && minor >= 9); + + if (process.env.JEST_WORKER_ID === undefined || supportsJestEsmImport) { + return import(specifier); + } + // eslint-disable-next-line @typescript-eslint/no-implied-eval -- use Node's native importer - new Function('specifier', 'return import(specifier)')(specifier); + return new Function('specifier', 'return import(specifier)')(specifier); +}; /** * Resolve the callable default export from a dynamic `import()` under Jest/@swc. From 66221427713e9c709e6b698ba7831b17d68b713c Mon Sep 17 00:00:00 2001 From: Ben Irvin Date: Thu, 11 Jun 2026 13:47:02 +0200 Subject: [PATCH 10/12] fix(ci): isolate Node 22 unit test workers Run Jest with enough workers on Node 22 so ESM-only dependencies loaded through native import do not cross Jest environment teardown boundaries. --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index cc18d67..65a2e13 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -67,4 +67,4 @@ jobs: name: Restore build output with: name: build-output-node-${{ matrix.node }} - - run: pnpm test:unit + - run: pnpm test:unit ${{ matrix.node == 22 && '--maxWorkers=8' || '' }} From 65fc551ef3ee9a59f4b3c5530c8e837af03323a2 Mon Sep 17 00:00:00 2001 From: Ben Irvin Date: Thu, 11 Jun 2026 13:50:23 +0200 Subject: [PATCH 11/12] fix(ci): run Node 22 unit tests with isolated workers Use an explicit Node 22 test step with enough Jest workers to avoid reusing a worker across ESM import environments. Keep Node 24 and 26 on the normal test command. --- .github/workflows/tests.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 65a2e13..da92c86 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -67,4 +67,7 @@ jobs: name: Restore build output with: name: build-output-node-${{ matrix.node }} - - run: pnpm test:unit ${{ matrix.node == 22 && '--maxWorkers=8' || '' }} + - if: matrix.node == 22 + run: pnpm test:unit --maxWorkers=8 + - if: matrix.node != 22 + run: pnpm test:unit From a80b3c5c6480f339ff52954a0308b2ab08c93543 Mon Sep 17 00:00:00 2001 From: Ben Irvin Date: Thu, 11 Jun 2026 13:56:16 +0200 Subject: [PATCH 12/12] docs(ci): explain Node 22 maxWorkers test setting --- .github/workflows/tests.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index da92c86..750eca4 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -67,6 +67,12 @@ jobs: name: Restore build output with: name: build-output-node-${{ matrix.node }} + # On Node < 24.9, Jest can't load pure-ESM deps (commander 15) via its normal + # import, so we fall back to a native dynamic import that escapes Jest's per-file + # environment. If a worker is reused across test files, a later file's import hits + # the previous file's torn-down environment ("import after teardown"). Forcing one + # worker per suite (we have 8) prevents reuse. Node 24/26 load ESM natively and + # don't need this. - if: matrix.node == 22 run: pnpm test:unit --maxWorkers=8 - if: matrix.node != 22