Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/commander-15-esm.md
Original file line number Diff line number Diff line change
@@ -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
11 changes: 10 additions & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,4 +67,13 @@ jobs:
name: Restore build output
with:
name: build-output-node-${{ matrix.node }}
- run: pnpm test:unit
# 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
run: pnpm test:unit
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
20 changes: 10 additions & 10 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 6 additions & 3 deletions src/__tests__/e2e/test-utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { Command } from 'commander';
import path from 'node:path';

import { createCommandInstance } from '../../cli/commands/utils/commander-loader';

import type { Command } from 'commander';

/**
* Shared test utilities for e2e CLI tests.
*/
Expand Down Expand Up @@ -35,7 +38,7 @@ export async function withMockedCLI(fixtureName: string, testFn: TestCallback):
});

try {
const command = new Command();
const command = await createCommandInstance();
await testFn({ command, mockExit });
} finally {
process.cwd = originalCwd;
Expand Down Expand Up @@ -63,7 +66,7 @@ export async function ensureFixtureBuilt(fixtureName: string): Promise<void> {
*/
export async function invokeCLI(args: string[], command?: Command): Promise<Command> {
const { createCLI } = await import('../../index');
const cmd = command ?? new Command();
const cmd = command ?? (await createCommandInstance());
await createCLI(['node', 'strapi-plugin', ...args], cmd);
return cmd;
}
9 changes: 5 additions & 4 deletions src/cli/commands/plugin/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -49,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)
Expand Down
9 changes: 5 additions & 4 deletions src/cli/commands/plugin/watch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -38,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)
Expand Down
51 changes: 51 additions & 0 deletions src/cli/commands/utils/commander-loader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { importEsm } from './esm-interop';

import type * as Commander from 'commander';

type CommanderModule = typeof Commander;
type Command = Commander.Command;

let commanderModule: CommanderModule | undefined;
let commanderPromise: Promise<CommanderModule> | undefined;

const isCommanderModule = (value: unknown): value is CommanderModule =>
typeof (value as { createCommand?: unknown } | undefined)?.createCommand === 'function';

const resolveCommander = (mod: Record<string, unknown>): CommanderModule => {
if (isCommanderModule(mod)) {
return mod;
}

// Jest/@swc interop: named exports can be re-wrapped under `default`.
if (isCommanderModule(mod.default)) {
return mod.default;
}

throw new TypeError('Failed to load commander');
};

export const getCommander = async (): Promise<CommanderModule> => {
if (commanderModule) {
return commanderModule;
}

commanderPromise ??= importEsm('commander').then((mod) => {
commanderModule = resolveCommander(mod);

return commanderModule;
});

return commanderPromise;
};

export const createCommand = async (name?: string): Promise<Command> => {
const commander = await getCommander();

return commander.createCommand(name);
};

export const createCommandInstance = async (name?: string): Promise<Command> => {
const { Command: CommanderCommand } = await getCommander();

return new CommanderCommand(name);
};
15 changes: 11 additions & 4 deletions src/cli/commands/utils/esm-interop.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>> =>
export const importEsm = (specifier: string): Promise<Record<string, unknown>> => {
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.
Expand Down
28 changes: 15 additions & 13 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,25 @@
import { Command } from 'commander';

import { commands as strapiCommands } from './cli/commands';
import { loadChalk } from './cli/commands/utils/chalk-loader';
import { createCommandInstance } 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 loadChalk();

const program = command ?? (await 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',
Expand All @@ -44,23 +46,23 @@ const createCLI = async (argv: string[], command = new Command()) => {
} satisfies CLIContext;

// Load all commands
strapiCommands.forEach((commandFactory) => {
for (const commandFactory of strapiCommands) {
try {
const subCommand = commandFactory({ command, argv, ctx });
const subCommand = await 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);
};
Expand Down
2 changes: 1 addition & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,4 @@ export type StrapiCommand = (params: {
command: Command;
argv: string[];
ctx: CLIContext;
}) => void | Command;
}) => void | Command | Promise<void | Command>;