From 017f5eda6f3855a1d1bfa0152fd00d8bf66b3050 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 12 Aug 2026 22:54:02 +0000 Subject: [PATCH 1/9] feat(rstack): define plugin SPI --- packages/rstack/src/config.ts | 34 +++++++++- packages/rstack/src/index.ts | 11 ++++ packages/rstack/src/plugin.ts | 65 +++++++++++++++++++ .../rstack/tests/cli/staged/index.test.ts | 1 + .../load-config/duplicate-plugins.config.ts | 4 ++ .../tests/config/load-config/index.test.ts | 28 +++++++- .../load-config/parallel-first.config.ts | 1 + .../load-config/parallel-second.config.ts | 1 + .../config/load-config/plugins.config.ts | 7 ++ .../tests/config/load-config/rstack.config.ts | 1 + .../tests/types/resolution-bundler/index.ts | 26 +++++++- .../tests/types/resolution-nodenext/index.ts | 26 +++++++- 12 files changed, 200 insertions(+), 5 deletions(-) create mode 100644 packages/rstack/src/plugin.ts create mode 100644 packages/rstack/tests/config/load-config/duplicate-plugins.config.ts create mode 100644 packages/rstack/tests/config/load-config/plugins.config.ts diff --git a/packages/rstack/src/config.ts b/packages/rstack/src/config.ts index f977f6fc..f52841f1 100644 --- a/packages/rstack/src/config.ts +++ b/packages/rstack/src/config.ts @@ -6,6 +6,7 @@ import type { RslintConfig } from '@rslint/core'; import type { UserConfig, UserConfigAsyncFn } from '@rspress/core'; import type { RstestConfigExport } from '@rstest/core'; import type { FmtConfigDefinition } from './fmt/types.ts'; +import type { RstackPlugins } from './plugin.ts'; import type { StagedConfig } from './staged.ts'; export type RslintConfigDefinition = RslintConfig | (() => Promise); @@ -23,6 +24,7 @@ export type Configs = { export type LoadedRstackConfig = { configs: Configs; + plugins: RstackPlugins; filePath: string | null; dependencies: string[]; }; @@ -44,6 +46,8 @@ export type LoadRstackConfigOptions = { type ConfigSession = { configs: Configs; + plugins: RstackPlugins; + pluginsDefined: boolean; active: boolean; }; @@ -85,6 +89,12 @@ export const getConfigState = (): ConfigState => { }; type Define = { + /** + * Registers plugins that extend the Rstack CLI. + * + * @see {@link https://rstack.rs/plugins | Rstack plugin guide} + */ + plugins: (plugins: RstackPlugins) => void; /** * Defines the Rsbuild config for the app. * @@ -147,20 +157,37 @@ type Define = { staged: (config: StagedConfig) => void; }; -const setConfig = (type: T, config: Configs[T]): void => { +const getActiveConfigSession = (type: string): ConfigSession => { const session = getConfigSessionStorage().getStore(); if (!session?.active) { throw new Error(`The "${type}" config must be defined while loading an Rstack config.`); } + return session; +}; + +const setConfig = (type: T, config: Configs[T]): void => { + const session = getActiveConfigSession(type); + if (type in session.configs) { throw new Error(`The "${type}" config has already been defined.`); } session.configs[type] = config; }; +const setPlugins = (plugins: RstackPlugins): void => { + const session = getActiveConfigSession('plugins'); + + if (session.pluginsDefined) { + throw new Error('The "plugins" config has already been defined.'); + } + session.plugins = plugins; + session.pluginsDefined = true; +}; + export const define: Define = { + plugins: setPlugins, app: (config) => setConfig('app', config), lib: (config) => setConfig('lib', config), doc: (config) => setConfig('doc', config), @@ -178,6 +205,8 @@ export const loadRstackConfig = async ({ const configPath = configFilePath ?? state.configPath; const session: ConfigSession = { configs: {}, + plugins: [], + pluginsDefined: false, active: true, }; @@ -202,12 +231,15 @@ export const loadRstackConfig = async ({ return { configs: session.configs, + plugins: session.plugins, filePath, dependencies, }; } finally { session.active = false; session.configs = {}; + session.plugins = []; + session.pluginsDefined = false; } }); }; diff --git a/packages/rstack/src/index.ts b/packages/rstack/src/index.ts index 85410493..56cbc566 100644 --- a/packages/rstack/src/index.ts +++ b/packages/rstack/src/index.ts @@ -1,2 +1,13 @@ export { define } from './config.ts'; export { runCLI } from './cli/index.ts'; +export type { FmtConfig } from './fmt/types.ts'; +export type { + RstackCommand, + RstackConfigMap, + RstackLogger, + RstackPlugin, + RstackPluginAPI, + RstackPluginContext, + RstackPlugins, +} from './plugin.ts'; +export type { StagedConfig } from './staged.ts'; diff --git a/packages/rstack/src/plugin.ts b/packages/rstack/src/plugin.ts new file mode 100644 index 00000000..23b50a1b --- /dev/null +++ b/packages/rstack/src/plugin.ts @@ -0,0 +1,65 @@ +import type { RsbuildConfig } from '@rsbuild/core'; +import type { RslibConfig } from '@rslib/core'; +import type { RslintConfig } from '@rslint/core'; +import type { UserConfig as RspressConfig } from '@rspress/core'; +import type { RstestConfig } from '@rstest/core'; +import type { FmtConfig } from './fmt/types.ts'; +import type { StagedConfig } from './staged.ts'; + +export type RstackConfigMap = { + app: RsbuildConfig; + lib: RslibConfig; + doc: RspressConfig; + test: RstestConfig; + lint: RslintConfig; + fmt: FmtConfig; + staged: StagedConfig; +}; + +export type RstackPluginContext = Readonly<{ + cwd: string; + command: string; + args: readonly string[]; + configFilePath: string | null; +}>; + +export interface RstackLogger { + debug(message?: unknown, ...args: unknown[]): void; + info(message?: unknown, ...args: unknown[]): void; + warn(message?: unknown, ...args: unknown[]): void; + error(message?: unknown, ...args: unknown[]): void; + success(message?: unknown, ...args: unknown[]): void; +} + +export type RstackCommand = { + name: string; + handler(args: readonly string[]): void | Promise; +}; + +export type RstackPluginAPI = { + readonly context: RstackPluginContext; + readonly logger: RstackLogger; + + addCommand(command: RstackCommand): void; + + modifyConfig( + kind: K, + handler: ( + config: RstackConfigMap[K], + ) => void | RstackConfigMap[K] | Promise, + ): void; +}; + +export type RstackPlugin = { + name: string; + setup(api: RstackPluginAPI): void | Promise; +}; + +export type RstackPlugins = Array< + | RstackPlugin + | false + | null + | undefined + | Promise + | RstackPlugins +>; diff --git a/packages/rstack/tests/cli/staged/index.test.ts b/packages/rstack/tests/cli/staged/index.test.ts index 5fb293f1..6028b773 100644 --- a/packages/rstack/tests/cli/staged/index.test.ts +++ b/packages/rstack/tests/cli/staged/index.test.ts @@ -22,6 +22,7 @@ beforeEach(() => { mocks.lintStaged.mockResolvedValue(true); mocks.loadRstackConfig.mockResolvedValue({ configs: { staged: stagedConfig }, + plugins: [], filePath: null, dependencies: [], }); diff --git a/packages/rstack/tests/config/load-config/duplicate-plugins.config.ts b/packages/rstack/tests/config/load-config/duplicate-plugins.config.ts new file mode 100644 index 00000000..bd3d289c --- /dev/null +++ b/packages/rstack/tests/config/load-config/duplicate-plugins.config.ts @@ -0,0 +1,4 @@ +import { define } from 'rstack'; + +define.plugins([]); +define.plugins([]); diff --git a/packages/rstack/tests/config/load-config/index.test.ts b/packages/rstack/tests/config/load-config/index.test.ts index a738f80e..23bca411 100644 --- a/packages/rstack/tests/config/load-config/index.test.ts +++ b/packages/rstack/tests/config/load-config/index.test.ts @@ -1,6 +1,6 @@ import path from 'node:path'; import { afterEach, expect, test } from 'rstack/test'; -import { getConfigState, loadRstackConfig } from '../../../src/config.ts'; +import { define, getConfigState, loadRstackConfig } from '../../../src/config.ts'; type Deferred = ReturnType>; @@ -35,8 +35,9 @@ test('should discard a config session after loading fails', async () => { await expect(loadRstackConfig()).rejects.toThrow('test config error'); - const { configs } = await loadConfigFile('explicit.config.ts'); + const { configs, plugins } = await loadConfigFile('explicit.config.ts'); expect(configs).toEqual({ app: {} }); + expect(plugins).toEqual([]); }); test('should prefer an explicit config path over the state config path', async () => { @@ -81,10 +82,12 @@ test('should isolate parallel config sessions across top-level await', async () app: { root: 'first' }, test: {}, }); + expect(firstResult.plugins).toMatchObject([{ name: 'first' }]); expect(secondResult.configs).toEqual({ app: { root: 'second' }, lib: {}, }); + expect(secondResult.plugins).toMatchObject([{ name: 'second' }]); }); test('should keep a running session intact when another config fails', async () => { @@ -108,3 +111,24 @@ test('should reject duplicate definitions within the same session', async () => 'The "app" config has already been defined.', ); }); + +test('should capture nested and asynchronous plugin definitions', async () => { + const { plugins } = await loadConfigFile('plugins.config.ts'); + + expect(plugins).toHaveLength(3); + expect(plugins[0]).toMatchObject({ name: 'first' }); + await expect(plugins[1]).resolves.toMatchObject({ name: 'second' }); + await expect(plugins[2]).resolves.toEqual([false, expect.objectContaining({ name: 'third' })]); +}); + +test('should reject duplicate plugin definitions within the same session', async () => { + await expect(loadConfigFile('duplicate-plugins.config.ts')).rejects.toThrow( + 'The "plugins" config has already been defined.', + ); +}); + +test('should reject plugin definitions outside config loading', () => { + expect(() => define.plugins([])).toThrow( + 'The "plugins" config must be defined while loading an Rstack config.', + ); +}); diff --git a/packages/rstack/tests/config/load-config/parallel-first.config.ts b/packages/rstack/tests/config/load-config/parallel-first.config.ts index 4d68eed0..fc7ccf91 100644 --- a/packages/rstack/tests/config/load-config/parallel-first.config.ts +++ b/packages/rstack/tests/config/load-config/parallel-first.config.ts @@ -3,6 +3,7 @@ import { define } from 'rstack'; const hooks = globalThis.__rstackConfigTestHooks!; define.app({ root: 'first' }); +define.plugins([{ name: 'first', setup() {} }]); hooks.ready.resolve(); await hooks.release.promise; diff --git a/packages/rstack/tests/config/load-config/parallel-second.config.ts b/packages/rstack/tests/config/load-config/parallel-second.config.ts index f5d6bcb7..4d66a4c4 100644 --- a/packages/rstack/tests/config/load-config/parallel-second.config.ts +++ b/packages/rstack/tests/config/load-config/parallel-second.config.ts @@ -2,3 +2,4 @@ import { define } from 'rstack'; define.app({ root: 'second' }); define.lib({}); +define.plugins([{ name: 'second', setup() {} }]); diff --git a/packages/rstack/tests/config/load-config/plugins.config.ts b/packages/rstack/tests/config/load-config/plugins.config.ts new file mode 100644 index 00000000..dfee7b49 --- /dev/null +++ b/packages/rstack/tests/config/load-config/plugins.config.ts @@ -0,0 +1,7 @@ +import { define } from 'rstack'; + +define.plugins([ + { name: 'first', setup() {} }, + Promise.resolve({ name: 'second', setup() {} }), + Promise.resolve([false, { name: 'third', setup() {} }]), +]); diff --git a/packages/rstack/tests/config/load-config/rstack.config.ts b/packages/rstack/tests/config/load-config/rstack.config.ts index 7720867d..b77da116 100644 --- a/packages/rstack/tests/config/load-config/rstack.config.ts +++ b/packages/rstack/tests/config/load-config/rstack.config.ts @@ -1,4 +1,5 @@ import { define } from 'rstack'; define.app({}); +define.plugins([{ name: 'discarded', setup() {} }]); throw new Error('test config error'); diff --git a/packages/rstack/tests/types/resolution-bundler/index.ts b/packages/rstack/tests/types/resolution-bundler/index.ts index 44477318..77cec385 100644 --- a/packages/rstack/tests/types/resolution-bundler/index.ts +++ b/packages/rstack/tests/types/resolution-bundler/index.ts @@ -2,7 +2,14 @@ import 'rstack/test/globals'; import 'rstack/test/importMeta'; import 'rstack/types'; -import { define } from 'rstack'; +import { + define, + type FmtConfig, + type RstackConfigMap, + type RstackPlugin, + type RstackPlugins, + type StagedConfig, +} from 'rstack'; import { createRsbuild, defineConfig as defineAppConfig } from 'rstack/app'; import { loadRstackConfig, @@ -19,9 +26,25 @@ const libConfig = defineLibConfig({}); const loadOptions: LoadRstackConfigOptions = { configFilePath: 'rstack.config.ts' }; const loadedConfig: Promise = loadRstackConfig(loadOptions); const configs: Configs = {}; +const plugin: RstackPlugin = { + name: 'example', + setup(api) { + api.addCommand({ name: 'example', handler: async () => {} }); + api.modifyConfig('app', (config) => config); + api.modifyConfig('test', async (config) => config); + api.logger.info(api.context.command); + }, +}; +const plugins: RstackPlugins = [plugin, false, Promise.resolve([undefined, plugin])]; +const fmtConfig: FmtConfig = {}; +const stagedConfig: StagedConfig = {}; +const appConfigFromMap: RstackConfigMap['app'] = appConfig; void loadedConfig; void configs; +void fmtConfig; +void stagedConfig; +void appConfigFromMap; createRsbuild({ config: appConfig }); define.app(appConfig); @@ -30,6 +53,7 @@ define.doc({}); define.test({}); define.lint([js.configs.recommended, ts.configs.recommended]); define.staged({}); +define.plugins(plugins); importedTest('exposes the Rstest APIs', () => { importedExpect(true).toBe(true); diff --git a/packages/rstack/tests/types/resolution-nodenext/index.ts b/packages/rstack/tests/types/resolution-nodenext/index.ts index 95bf176a..b3e665a8 100644 --- a/packages/rstack/tests/types/resolution-nodenext/index.ts +++ b/packages/rstack/tests/types/resolution-nodenext/index.ts @@ -2,7 +2,14 @@ import 'rstack/test/globals'; import 'rstack/test/importMeta'; import 'rstack/types'; -import { define } from 'rstack'; +import { + define, + type FmtConfig, + type RstackConfigMap, + type RstackPlugin, + type RstackPlugins, + type StagedConfig, +} from 'rstack'; import { createRsbuild, defineConfig as defineAppConfig } from 'rstack/app'; import { loadRstackConfig, @@ -19,9 +26,25 @@ const libConfig = defineLibConfig({}); const loadOptions: LoadRstackConfigOptions = { configFilePath: 'rstack.config.ts' }; const loadedConfig: Promise = loadRstackConfig(loadOptions); const configs: Configs = {}; +const plugin: RstackPlugin = { + name: 'example', + setup(api) { + api.addCommand({ name: 'example', handler: async () => {} }); + api.modifyConfig('app', (config) => config); + api.modifyConfig('test', async (config) => config); + api.logger.info(api.context.command); + }, +}; +const plugins: RstackPlugins = [plugin, false, Promise.resolve([undefined, plugin])]; +const fmtConfig: FmtConfig = {}; +const stagedConfig: StagedConfig = {}; +const appConfigFromMap: RstackConfigMap['app'] = appConfig; void loadedConfig; void configs; +void fmtConfig; +void stagedConfig; +void appConfigFromMap; createRsbuild({ config: appConfig }); define.app(appConfig); @@ -30,6 +53,7 @@ define.doc({}); define.test({}); define.lint([js.configs.recommended, ts.configs.recommended]); define.staged({}); +define.plugins(plugins); importedTest('exposes the Rstest APIs', () => { importedExpect(true).toBe(true); From a7b901e9ad9798eda16b4c26223249aea34994bd Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 12 Aug 2026 23:07:22 +0000 Subject: [PATCH 2/9] feat(rstack): support plugin commands --- packages/rstack/src/cli/commands.ts | 25 ++- packages/rstack/src/config.ts | 14 +- packages/rstack/src/pluginRuntime.ts | 196 +++++++++++++++++ .../cli/plugin-commands/broken.config.ts | 1 + .../tests/cli/plugin-commands/index.test.ts | 47 +++++ .../cli/plugin-commands/rstack.config.ts | 28 +++ packages/rstack/tests/pluginRuntime.test.ts | 197 ++++++++++++++++++ 7 files changed, 505 insertions(+), 3 deletions(-) create mode 100644 packages/rstack/src/pluginRuntime.ts create mode 100644 packages/rstack/tests/cli/plugin-commands/broken.config.ts create mode 100644 packages/rstack/tests/cli/plugin-commands/index.test.ts create mode 100644 packages/rstack/tests/cli/plugin-commands/rstack.config.ts create mode 100644 packages/rstack/tests/pluginRuntime.test.ts diff --git a/packages/rstack/src/cli/commands.ts b/packages/rstack/src/cli/commands.ts index 07ed732e..9038bba2 100644 --- a/packages/rstack/src/cli/commands.ts +++ b/packages/rstack/src/cli/commands.ts @@ -1,5 +1,7 @@ import { join, resolve } from 'node:path'; -import { getConfigState } from '../config.ts'; +import { logger } from 'rslog'; +import { getConfigState, loadRstackConfig } from '../config.ts'; +import { createPluginRuntime } from '../pluginRuntime.ts'; import { insertConfigArg, parseArgs, parseCliArgs } from './args.ts'; import { hasHelpFlag, printCommandHelp } from './help.ts'; @@ -165,13 +167,14 @@ async function runCheckCLI(args: string[]): Promise { export async function setupCommands(): Promise { const { args, configPath } = parseCliArgs(process.argv.slice(2)); const command = args[0]; + const state = getConfigState(); // Resolved for every command so that a relative `--config` path always means // the same file: it is anchored to the directory the CLI was invoked in, even // when the config is later loaded from another directory. The motivating case // is `rs fmt --lsp`, which loads the config from the LSP workspace root the // client reports, and that root need not be the process working directory. - getConfigState().configPath = configPath === undefined ? undefined : resolve(configPath); + state.configPath = configPath === undefined ? undefined : resolve(configPath); if (!command || command === '-h' || command === '--help') { return printCommandHelp('root'); @@ -182,6 +185,13 @@ export async function setupCommands(): Promise { return; } + state.invocation = { + cwd: process.cwd(), + command, + args: args.slice(1), + configFilePath: null, + }; + if (command === 'lib') { await runRslibCLI(args.slice(1)); return; @@ -239,5 +249,16 @@ export async function setupCommands(): Promise { return; } + const { plugins } = await loadRstackConfig(); + const runtime = await createPluginRuntime({ + plugins, + context: state.invocation, + logger, + }); + + if (await runtime.runCommand(command, state.invocation.args)) { + return; + } + throw new Error(`Unknown command: ${command}`); } diff --git a/packages/rstack/src/config.ts b/packages/rstack/src/config.ts index f52841f1..865e162d 100644 --- a/packages/rstack/src/config.ts +++ b/packages/rstack/src/config.ts @@ -51,6 +51,13 @@ type ConfigSession = { active: boolean; }; +export type RstackInvocation = { + cwd: string; + command: string; + args: string[]; + configFilePath: string | null; +}; + type ConfigState = { /** * Config file path from the global `--config` flag. Always absolute: the CLI @@ -58,6 +65,7 @@ type ConfigState = { * (`loadRstackConfig` may be called with an LSP workspace root as `cwd`). */ configPath?: string; + invocation?: RstackInvocation; }; declare global { @@ -80,7 +88,7 @@ const getConfigSessionStorage = (): AsyncLocalStorage => { export const getConfigState = (): ConfigState => { // The CLI and its internal tool config can also be loaded as separate module - // instances. Keep only the CLI config path in its own global state. + // instances. Keep CLI invocation state in its own global state. if (!globalThis.__rstackCliState) { globalThis.__rstackCliState = {}; } @@ -229,6 +237,10 @@ export const loadRstackConfig = async ({ }), }); + if (state.invocation) { + state.invocation.configFilePath = filePath; + } + return { configs: session.configs, plugins: session.plugins, diff --git a/packages/rstack/src/pluginRuntime.ts b/packages/rstack/src/pluginRuntime.ts new file mode 100644 index 00000000..b3d49e19 --- /dev/null +++ b/packages/rstack/src/pluginRuntime.ts @@ -0,0 +1,196 @@ +import type { + RstackCommand, + RstackConfigMap, + RstackLogger, + RstackPlugin, + RstackPluginContext, + RstackPlugins, +} from './plugin.ts'; + +export type RstackConfigModifier = ( + config: RstackConfigMap[K], +) => void | RstackConfigMap[K] | Promise; + +export type RstackConfigModifierRegistry = { + [K in keyof RstackConfigMap]: RstackConfigModifier[]; +}; + +export type RstackPluginRuntime = { + readonly context: RstackPluginContext; + readonly configModifiers: RstackConfigModifierRegistry; + runCommand(name: string, args: readonly string[]): Promise; + applyConfigModifiers( + kind: K, + config: RstackConfigMap[K], + ): Promise; +}; + +export type CreatePluginRuntimeOptions = { + plugins: RstackPlugins; + context: RstackPluginContext; + logger: RstackLogger; + reservedCommands?: Iterable; +}; + +const validCommandName = /^[a-z][a-z0-9-]*$/u; + +const builtInCommandNames = [ + 'dev', + 'build', + 'preview', + 'lib', + 'doc', + 'test', + 'lint', + 'check', + 'fmt', + 'format', + 'staged', + 'setup', +]; + +const hasValidName = (name: unknown): name is string => + typeof name === 'string' && name.length > 0 && name.trim() === name && !/\s/u.test(name); + +const flattenPlugins = async (plugins: RstackPlugins): Promise => { + const result: RstackPlugin[] = []; + + const visit = async (value: RstackPlugins[number]): Promise => { + const resolved = await value; + + if (resolved === false || resolved === null || resolved === undefined) { + return; + } + + if (Array.isArray(resolved)) { + for (const plugin of resolved) { + await visit(plugin); + } + return; + } + + result.push(resolved); + }; + + for (const plugin of plugins) { + await visit(plugin); + } + + return result; +}; + +const assertPlugin = (plugin: RstackPlugin): void => { + if (!plugin || typeof plugin !== 'object' || Array.isArray(plugin)) { + throw new Error('Invalid Rstack plugin. Expected a plugin object.'); + } + + if (!hasValidName(plugin.name)) { + throw new Error( + 'Invalid Rstack plugin name. Plugin names must be non-empty strings without whitespace.', + ); + } + + if (typeof plugin.setup !== 'function') { + throw new Error(`Invalid Rstack plugin "${plugin.name}". Expected a setup function.`); + } +}; + +const createModifierRegistry = (): RstackConfigModifierRegistry => ({ + app: [], + lib: [], + doc: [], + test: [], + lint: [], + fmt: [], + staged: [], +}); + +export const createPluginRuntime = async ({ + plugins, + context, + logger, + reservedCommands = builtInCommandNames, +}: CreatePluginRuntimeOptions): Promise => { + const commands = new Map(); + const configModifiers = createModifierRegistry(); + const pluginNames = new Set(); + const reservedCommandNames = new Set(reservedCommands); + let setupComplete = false; + + const addCommand = (command: RstackCommand): void => { + if (!command || typeof command !== 'object' || Array.isArray(command)) { + throw new Error('Invalid Rstack command. Expected a command object.'); + } + + if (!validCommandName.test(command.name)) { + throw new Error( + 'Invalid Rstack command name. Command names must start with a lowercase letter and contain only lowercase letters, numbers, and hyphens.', + ); + } + + if (typeof command.handler !== 'function') { + throw new Error(`Invalid Rstack command "${command.name}". Expected a handler function.`); + } + + if (reservedCommandNames.has(command.name)) { + throw new Error(`Rstack command "${command.name}" conflicts with a built-in command.`); + } + + if (commands.has(command.name)) { + throw new Error(`Duplicate Rstack command: "${command.name}".`); + } + + commands.set(command.name, command.handler); + }; + + const runtime: RstackPluginRuntime = { + context, + configModifiers, + async runCommand(name, args) { + const handler = commands.get(name); + if (!handler) { + return false; + } + await handler(args); + return true; + }, + async applyConfigModifiers(kind, config) { + if (!setupComplete) { + throw new Error('Rstack plugin setup must complete before config modifiers run.'); + } + + let current = config; + for (const modifier of configModifiers[kind] as RstackConfigModifier[]) { + const result = await modifier(current); + if (result !== undefined) { + current = result; + } + } + return current; + }, + }; + + const resolvedPlugins = await flattenPlugins(plugins); + for (const plugin of resolvedPlugins) { + assertPlugin(plugin); + + if (pluginNames.has(plugin.name)) { + throw new Error(`Duplicate Rstack plugin: "${plugin.name}".`); + } + pluginNames.add(plugin.name); + } + + for (const plugin of resolvedPlugins) { + await plugin.setup({ + context, + logger, + addCommand, + modifyConfig(kind, handler) { + (configModifiers[kind] as RstackConfigModifier[]).push(handler); + }, + }); + } + + setupComplete = true; + return runtime; +}; diff --git a/packages/rstack/tests/cli/plugin-commands/broken.config.ts b/packages/rstack/tests/cli/plugin-commands/broken.config.ts new file mode 100644 index 00000000..6564093d --- /dev/null +++ b/packages/rstack/tests/cli/plugin-commands/broken.config.ts @@ -0,0 +1 @@ +throw new Error('root help and version must not load configuration'); diff --git a/packages/rstack/tests/cli/plugin-commands/index.test.ts b/packages/rstack/tests/cli/plugin-commands/index.test.ts new file mode 100644 index 00000000..90b0aec0 --- /dev/null +++ b/packages/rstack/tests/cli/plugin-commands/index.test.ts @@ -0,0 +1,47 @@ +import path from 'node:path'; +import { test } from '#test-helpers'; + +for (const configArg of [ + '--config ./rstack.config.ts', + '--config=./rstack.config.ts', + '-c ./rstack.config.ts', + '-c./rstack.config.ts', +]) { + test(`dispatches a plugin command after removing ${configArg} from its raw arguments`, ({ + cwd, + execCli, + expect, + }) => { + const output = execCli(`plugin-command first ${configArg} second`); + + expect(JSON.parse(output)).toEqual({ + args: ['first', 'second'], + context: { + cwd, + command: 'plugin-command', + args: ['first', 'second'], + configFilePath: path.join(cwd, 'rstack.config.ts'), + }, + }); + }); +} + +test('awaits asynchronous plugin command handlers', ({ execCli, expect }) => { + expect(execCli('async-command')).toBe('async handler completed\n'); +}); + +test('preserves plugin command failures', ({ execCli, expect }) => { + try { + execCli('throws-command'); + } catch (error) { + expect((error as { stderr?: Buffer }).stderr?.toString()).toContain('plugin command failure'); + return; + } + + throw new Error('Expected the plugin command to fail.'); +}); + +test('keeps root help and version config-free', ({ execCli, expect }) => { + expect(execCli('--config ./broken.config.ts --help')).toContain('$ rs'); + expect(execCli('-c./broken.config.ts --version')).toMatch(/^Rstack v/u); +}); diff --git a/packages/rstack/tests/cli/plugin-commands/rstack.config.ts b/packages/rstack/tests/cli/plugin-commands/rstack.config.ts new file mode 100644 index 00000000..64b90a9e --- /dev/null +++ b/packages/rstack/tests/cli/plugin-commands/rstack.config.ts @@ -0,0 +1,28 @@ +import { define } from 'rstack'; + +define.plugins([ + { + name: 'plugin-command-fixture', + setup({ addCommand, context }) { + addCommand({ + name: 'plugin-command', + handler(args) { + console.log(JSON.stringify({ args, context })); + }, + }); + addCommand({ + name: 'async-command', + async handler() { + await Promise.resolve(); + console.log('async handler completed'); + }, + }); + addCommand({ + name: 'throws-command', + handler() { + throw new Error('plugin command failure'); + }, + }); + }, + }, +]); diff --git a/packages/rstack/tests/pluginRuntime.test.ts b/packages/rstack/tests/pluginRuntime.test.ts new file mode 100644 index 00000000..73a59c29 --- /dev/null +++ b/packages/rstack/tests/pluginRuntime.test.ts @@ -0,0 +1,197 @@ +import { expect, test } from 'rstack/test'; +import type { RstackPlugins } from '../src/plugin.ts'; +import { createPluginRuntime } from '../src/pluginRuntime.ts'; + +const logger = { + debug() {}, + info() {}, + warn() {}, + error() {}, + success() {}, +}; + +test('flattens nested asynchronous plugins in declaration order and initializes them sequentially', async () => { + const events: string[] = []; + const runtime = await createPluginRuntime({ + plugins: [ + { + name: 'first', + async setup() { + events.push('first:start'); + await Promise.resolve(); + events.push('first:end'); + }, + }, + false, + Promise.resolve([ + { + name: 'second', + setup() { + events.push('second'); + }, + }, + [ + undefined, + { + name: 'third', + setup() { + events.push('third'); + }, + }, + ], + ]), + ], + context: { + cwd: '/project', + command: 'plugin-command', + args: ['--raw'], + configFilePath: '/project/rstack.config.ts', + }, + logger, + }); + + expect(events).toEqual(['first:start', 'first:end', 'second', 'third']); + expect(runtime.context).toEqual({ + cwd: '/project', + command: 'plugin-command', + args: ['--raw'], + configFilePath: '/project/rstack.config.ts', + }); +}); + +test('rejects invalid plugin objects and duplicate plugin names', async () => { + const options = { + context: { cwd: '/project', command: 'command', args: [], configFilePath: null }, + logger, + }; + + await expect( + createPluginRuntime({ ...options, plugins: [true] as unknown as RstackPlugins }), + ).rejects.toThrow('Invalid Rstack plugin'); + await expect( + createPluginRuntime({ + ...options, + plugins: [{ name: 'not valid', setup() {} }], + }), + ).rejects.toThrow('Invalid Rstack plugin name'); + await expect( + createPluginRuntime({ + ...options, + plugins: [ + { name: 'duplicate', setup() {} }, + { name: 'duplicate', setup() {} }, + ], + }), + ).rejects.toThrow('Duplicate Rstack plugin'); +}); + +test('validates every plugin before setup begins', async () => { + let setupRan = false; + + await expect( + createPluginRuntime({ + plugins: [ + { + name: 'valid', + setup() { + setupRan = true; + }, + }, + { name: 'invalid' } as never, + ], + context: { cwd: '/project', command: 'command', args: [], configFilePath: null }, + logger, + }), + ).rejects.toThrow('Expected a setup function'); + expect(setupRan).toBe(false); +}); + +test('rejects invalid, duplicate, and built-in command names', async () => { + const options = { + context: { cwd: '/project', command: 'command', args: [], configFilePath: null }, + logger, + }; + + await expect( + createPluginRuntime({ + ...options, + plugins: [ + { + name: 'invalid-command', + setup({ addCommand }) { + addCommand({ name: 'not valid', handler() {} }); + }, + }, + ], + }), + ).rejects.toThrow('Invalid Rstack command name'); + await expect( + createPluginRuntime({ + ...options, + plugins: [ + { + name: 'duplicate-command', + setup({ addCommand }) { + addCommand({ name: 'plugin-command', handler() {} }); + addCommand({ name: 'plugin-command', handler() {} }); + }, + }, + ], + }), + ).rejects.toThrow('Duplicate Rstack command'); + for (const name of [ + 'dev', + 'build', + 'preview', + 'lib', + 'doc', + 'test', + 'lint', + 'check', + 'fmt', + 'format', + 'staged', + 'setup', + ]) { + await expect( + createPluginRuntime({ + ...options, + plugins: [ + { + name: `built-in-${name}`, + setup({ addCommand }) { + addCommand({ name, handler() {} }); + }, + }, + ], + }), + ).rejects.toThrow('conflicts with a built-in command'); + } +}); + +test('runs config modifiers after setup and keeps each modifier result', async () => { + const runtime = await createPluginRuntime({ + plugins: [ + { + name: 'modifier', + setup({ modifyConfig }) { + modifyConfig('app', (config) => { + config.root = '/first'; + }); + modifyConfig('app', (config) => ({ ...config, root: `${config.root}/second` })); + modifyConfig('app', async (config) => ({ + ...config, + root: `${config.root}/third`, + })); + }, + }, + ], + context: { cwd: '/project', command: 'command', args: [], configFilePath: null }, + logger, + }); + + expect(runtime.configModifiers.app).toHaveLength(3); + await expect(runtime.applyConfigModifiers('app', {})).resolves.toMatchObject({ + root: '/first/second/third', + }); +}); From 884d2af9e8a16dfdea7817fcf087a91733d46b1b Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 12 Aug 2026 23:30:19 +0000 Subject: [PATCH 3/9] feat(rstack): apply plugin config modifiers --- packages/rstack/src/config.ts | 36 +++++++- packages/rstack/src/fmt/cli.ts | 15 ++-- packages/rstack/src/fmt/config.ts | 8 +- packages/rstack/src/rsbuildConfig.ts | 11 ++- packages/rstack/src/rslibConfig.ts | 10 ++- packages/rstack/src/rslintConfig.ts | 13 ++- packages/rstack/src/rspressConfig.ts | 6 +- packages/rstack/src/rstestConfig.ts | 59 +++++++++---- packages/rstack/src/staged.ts | 9 +- .../rstack/tests/cli/staged/index.test.ts | 42 +++++++++- .../config/plugin-modifiers/adapters.test.ts | 82 ++++++++++++++++++ .../explicit-rstack.config.ts | 25 ++++++ .../tests/config/plugin-modifiers/fmt.test.ts | 29 +++++++ .../config/plugin-modifiers/index.test.ts | 84 +++++++++++++++++++ .../projects-explicit-rstack.config.ts | 25 ++++++ .../config/plugin-modifiers/rstack.config.ts | 47 +++++++++++ .../test-modifier-extends-rstack.config.ts | 25 ++++++ 17 files changed, 486 insertions(+), 40 deletions(-) create mode 100644 packages/rstack/tests/config/plugin-modifiers/adapters.test.ts create mode 100644 packages/rstack/tests/config/plugin-modifiers/explicit-rstack.config.ts create mode 100644 packages/rstack/tests/config/plugin-modifiers/fmt.test.ts create mode 100644 packages/rstack/tests/config/plugin-modifiers/index.test.ts create mode 100644 packages/rstack/tests/config/plugin-modifiers/projects-explicit-rstack.config.ts create mode 100644 packages/rstack/tests/config/plugin-modifiers/rstack.config.ts create mode 100644 packages/rstack/tests/config/plugin-modifiers/test-modifier-extends-rstack.config.ts diff --git a/packages/rstack/src/config.ts b/packages/rstack/src/config.ts index 865e162d..0860e4d1 100644 --- a/packages/rstack/src/config.ts +++ b/packages/rstack/src/config.ts @@ -1,12 +1,14 @@ import { AsyncLocalStorage } from 'node:async_hooks'; import { loadConfig } from '@rstackjs/load-config'; +import { logger } from 'rslog'; import type { RsbuildConfigDefinition } from '@rsbuild/core'; import type { RslibConfigDefinition } from '@rslib/core'; import type { RslintConfig } from '@rslint/core'; import type { UserConfig, UserConfigAsyncFn } from '@rspress/core'; import type { RstestConfigExport } from '@rstest/core'; import type { FmtConfigDefinition } from './fmt/types.ts'; -import type { RstackPlugins } from './plugin.ts'; +import type { RstackConfigMap, RstackPlugins } from './plugin.ts'; +import { createPluginRuntime, type RstackPluginRuntime } from './pluginRuntime.ts'; import type { StagedConfig } from './staged.ts'; export type RslintConfigDefinition = RslintConfig | (() => Promise); @@ -29,6 +31,8 @@ export type LoadedRstackConfig = { dependencies: string[]; }; +const loadedPluginRuntimes = new WeakMap>(); + export type LoadRstackConfigOptions = { /** * The path to the Rstack config file, can be a relative or absolute path. @@ -96,6 +100,36 @@ export const getConfigState = (): ConfigState => { return globalThis.__rstackCliState; }; +export const getRstackPluginRuntime = ( + config: LoadedRstackConfig, +): Promise => { + const existingRuntime = loadedPluginRuntimes.get(config); + if (existingRuntime) { + return existingRuntime; + } + + const invocation = getConfigState().invocation; + const runtime = createPluginRuntime({ + plugins: config.plugins, + context: { + cwd: invocation?.cwd ?? process.cwd(), + command: invocation?.command ?? 'programmatic', + args: invocation?.args ?? [], + configFilePath: config.filePath, + }, + logger, + }); + loadedPluginRuntimes.set(config, runtime); + return runtime; +}; + +export const applyRstackConfigModifiers = async ( + loaded: LoadedRstackConfig, + kind: K, + config: RstackConfigMap[K], +): Promise => + (await getRstackPluginRuntime(loaded)).applyConfigModifiers(kind, config); + type Define = { /** * Registers plugins that extend the Rstack CLI. diff --git a/packages/rstack/src/fmt/cli.ts b/packages/rstack/src/fmt/cli.ts index 6bd314f2..c0e604e5 100644 --- a/packages/rstack/src/fmt/cli.ts +++ b/packages/rstack/src/fmt/cli.ts @@ -3,10 +3,10 @@ import { performance } from 'node:perf_hooks'; import { color, logger } from 'rslog'; import { parseArgs } from '../cli/args.ts'; import { printCommandHelp } from '../cli/help.ts'; -import { loadRstackConfig } from '../config.ts'; +import { applyRstackConfigModifiers, loadRstackConfig } from '../config.ts'; import { ensureProjectCacheDir } from '../projectCache.ts'; import { fmtCacheFileName } from './cacheStore.ts'; -import { resolveFmtConfig } from './config.ts'; +import { resolveFmtConfig, resolveFmtConfigDefinition } from './config.ts'; import { discoverFmtFiles } from './discovery.ts'; import { createRelativePathResolver, toPosixPath } from './pathHelpers.ts'; import { runFmtFiles } from './runner.ts'; @@ -236,11 +236,16 @@ const logFmtResult = ( }; const loadFmtConfig = async (cwd: string): Promise => { - const { configs, filePath } = await loadRstackConfig({ cwd }); + const loaded = await loadRstackConfig({ cwd }); + const config = await applyRstackConfigModifiers( + loaded, + 'fmt', + await resolveFmtConfigDefinition(loaded.configs.fmt), + ); return resolveFmtConfig({ - definition: configs.fmt, - configFilePath: filePath, + definition: config, + configFilePath: loaded.filePath, cwd, }); }; diff --git a/packages/rstack/src/fmt/config.ts b/packages/rstack/src/fmt/config.ts index 362a4527..6145a7fc 100644 --- a/packages/rstack/src/fmt/config.ts +++ b/packages/rstack/src/fmt/config.ts @@ -116,16 +116,20 @@ const createOptionsResolver = (config: ResolvedFmtConfig): FmtOptionsResolver => }; /** Resolves a formatter config definition and its project root. */ +const resolveFmtConfigDefinition = async ( + definition: FmtConfigDefinition | undefined, +): Promise => (typeof definition === 'function' ? await definition() : definition) ?? {}; + const resolveFmtConfig = async ({ definition, configFilePath, cwd, }: ResolveFmtConfigOptions): Promise => { - const config = typeof definition === 'function' ? await definition() : definition; + const config = await resolveFmtConfigDefinition(definition); const rootPath = configFilePath ? dirname(configFilePath) : cwd; return normalizeFmtConfig(config, rootPath); }; -export { createOptionsResolver, normalizeFmtConfig, resolveFmtConfig }; +export { createOptionsResolver, normalizeFmtConfig, resolveFmtConfig, resolveFmtConfigDefinition }; export type { FmtOptionsResolver }; diff --git a/packages/rstack/src/rsbuildConfig.ts b/packages/rstack/src/rsbuildConfig.ts index 01385c44..569f6ccb 100644 --- a/packages/rstack/src/rsbuildConfig.ts +++ b/packages/rstack/src/rsbuildConfig.ts @@ -1,5 +1,5 @@ import type { ConfigParams, RsbuildConfigDefinition, WatchFiles } from '@rsbuild/core'; -import { loadRstackConfig, type Configs } from './config.ts'; +import { applyRstackConfigModifiers, loadRstackConfig, type Configs } from './config.ts'; const resolveRsbuildConfig = async (configs: Configs, params: ConfigParams) => { const appConfig = configs.app; @@ -13,8 +13,13 @@ const resolveRsbuildConfig = async (configs: Configs, params: ConfigParams) => { }; const loadRsbuildConfig: RsbuildConfigDefinition = async (params) => { - const { configs, filePath, dependencies } = await loadRstackConfig(); - const config = await resolveRsbuildConfig(configs, params); + const loaded = await loadRstackConfig(); + const { configs, filePath, dependencies } = loaded; + const config = await applyRstackConfigModifiers( + loaded, + 'app', + await resolveRsbuildConfig(configs, params), + ); if (!filePath) { return config; diff --git a/packages/rstack/src/rslibConfig.ts b/packages/rstack/src/rslibConfig.ts index 6f0011ed..6c2a093b 100644 --- a/packages/rstack/src/rslibConfig.ts +++ b/packages/rstack/src/rslibConfig.ts @@ -1,5 +1,5 @@ import type { ConfigParams, RslibConfig, RslibConfigDefinition } from '@rslib/core'; -import { loadRstackConfig, type Configs } from './config.ts'; +import { applyRstackConfigModifiers, loadRstackConfig, type Configs } from './config.ts'; const resolveRslibConfig = async (configs: Configs, params: ConfigParams): Promise => { const libConfig = configs.lib; @@ -13,8 +13,12 @@ const resolveRslibConfig = async (configs: Configs, params: ConfigParams): Promi }; const loadRslibConfig = (async (params: ConfigParams) => { - const { configs } = await loadRstackConfig(); - return resolveRslibConfig(configs, params); + const loaded = await loadRstackConfig(); + return applyRstackConfigModifiers( + loaded, + 'lib', + await resolveRslibConfig(loaded.configs, params), + ); }) as RslibConfigDefinition; export default loadRslibConfig; diff --git a/packages/rstack/src/rslintConfig.ts b/packages/rstack/src/rslintConfig.ts index c0e953c3..a0e7e42d 100644 --- a/packages/rstack/src/rslintConfig.ts +++ b/packages/rstack/src/rslintConfig.ts @@ -1,7 +1,8 @@ -import { loadRstackConfig } from './config.ts'; +import { applyRstackConfigModifiers, loadRstackConfig } from './config.ts'; import type { RslintConfig } from '@rslint/core'; -const { configs } = await loadRstackConfig(); +const loaded = await loadRstackConfig(); +const { configs } = loaded; const lintExports = configs.lint ?? []; let lintConfig: RslintConfig; @@ -13,4 +14,10 @@ if (typeof lintExports === 'function') { lintConfig = lintExports; } -export default lintConfig; +const modifiedLintConfig: RslintConfig = await applyRstackConfigModifiers( + loaded, + 'lint', + lintConfig, +); + +export default modifiedLintConfig; diff --git a/packages/rstack/src/rspressConfig.ts b/packages/rstack/src/rspressConfig.ts index ed5efd59..2b98761d 100644 --- a/packages/rstack/src/rspressConfig.ts +++ b/packages/rstack/src/rspressConfig.ts @@ -1,5 +1,5 @@ import type { UserConfig } from '@rspress/core'; -import { loadRstackConfig, type Configs } from './config.ts'; +import { applyRstackConfigModifiers, loadRstackConfig, type Configs } from './config.ts'; const resolveRspressConfig = async (configs: Configs): Promise => { const docConfig = configs.doc; @@ -13,6 +13,6 @@ const resolveRspressConfig = async (configs: Configs): Promise => { }; export default async (): Promise => { - const { configs } = await loadRstackConfig(); - return resolveRspressConfig(configs); + const loaded = await loadRstackConfig(); + return applyRstackConfigModifiers(loaded, 'doc', await resolveRspressConfig(loaded.configs)); }; diff --git a/packages/rstack/src/rstestConfig.ts b/packages/rstack/src/rstestConfig.ts index b28d61d2..998d95a7 100644 --- a/packages/rstack/src/rstestConfig.ts +++ b/packages/rstack/src/rstestConfig.ts @@ -1,33 +1,56 @@ import type { ConfigParams } from '@rsbuild/core'; import type { RstestConfig, RstestConfigExport } from '@rstest/core'; -import { loadRstackConfig, type Configs } from './config.ts'; +import { + applyRstackConfigModifiers, + getRstackPluginRuntime, + loadRstackConfig, + type Configs, + type LoadedRstackConfig, +} from './config.ts'; const resolveAutomaticExtends = async ( - configs: Configs, + loaded: LoadedRstackConfig, params: ConfigParams, ): Promise => { // Prefer the app when both app and lib are defined. Merging both adapters can // introduce conflicting runtime, resolve, and source transform settings. - const appConfig = configs.app; - if (appConfig) { + const appConfig = loaded.configs.app; + const runtime = await getRstackPluginRuntime(loaded); + if (appConfig || runtime.configModifiers.app.length > 0) { const { withRsbuildConfig } = await import( /* rspackChunkName: 'adapterRsbuild' */ '@rstest/adapter-rsbuild' ); - const config = typeof appConfig === 'function' ? await appConfig(params) : appConfig; + const config = await applyRstackConfigModifiers( + loaded, + 'app', + appConfig === undefined + ? {} + : typeof appConfig === 'function' + ? await appConfig(params) + : appConfig, + ); return withRsbuildConfig({ config, }); } - const libConfig = configs.lib; - if (libConfig) { + const libConfig = loaded.configs.lib; + if (libConfig || runtime.configModifiers.lib.length > 0) { const { withRslibConfig } = await import( /* rspackChunkName: 'adapterRslib' */ '@rstest/adapter-rslib' ); - const config = typeof libConfig === 'function' ? await libConfig(params) : libConfig; + const config = await applyRstackConfigModifiers( + loaded, + 'lib', + libConfig === undefined + ? {} + : typeof libConfig === 'function' + ? await libConfig(params) + : libConfig, + ); return withRslibConfig({ config, @@ -51,13 +74,17 @@ const injectExtends = ( }; }; -const extendsConfig = async (configs: Configs, testConfig: RstestConfig, params: ConfigParams) => { +const extendsConfig = async ( + loaded: LoadedRstackConfig, + testConfig: RstestConfig, + params: ConfigParams, +) => { if ('extends' in testConfig) { return testConfig; } if (testConfig.projects === undefined) { - const automaticExtends = await resolveAutomaticExtends(configs, params); + const automaticExtends = await resolveAutomaticExtends(loaded, params); return injectExtends(testConfig, automaticExtends); } @@ -68,7 +95,7 @@ const extendsConfig = async (configs: Configs, testConfig: RstestConfig, params: return testConfig; } - const automaticExtends = await resolveAutomaticExtends(configs, params); + const automaticExtends = await resolveAutomaticExtends(loaded, params); return { ...testConfig, @@ -90,9 +117,13 @@ const resolveRstestConfig = async (configs: Configs) => { }; const loadRstestConfig = (async (params: ConfigParams) => { - const { configs } = await loadRstackConfig(); - const testConfig = await resolveRstestConfig(configs); - return extendsConfig(configs, testConfig, params); + const loaded = await loadRstackConfig(); + const configWithAutomaticExtends = await extendsConfig( + loaded, + await resolveRstestConfig(loaded.configs), + params, + ); + return applyRstackConfigModifiers(loaded, 'test', configWithAutomaticExtends); }) as RstestConfigExport; export default loadRstestConfig; diff --git a/packages/rstack/src/staged.ts b/packages/rstack/src/staged.ts index b1a8e6dc..5f474298 100644 --- a/packages/rstack/src/staged.ts +++ b/packages/rstack/src/staged.ts @@ -1,7 +1,7 @@ import lintStaged from 'lint-staged'; import { parseArgs } from './cli/args.ts'; import { printCommandHelp } from './cli/help.ts'; -import { loadRstackConfig } from './config.ts'; +import { getRstackPluginRuntime, loadRstackConfig } from './config.ts'; export type StagedSyncTaskGenerator = (stagedFileNames: readonly string[]) => string | string[]; @@ -44,13 +44,14 @@ export async function runStagedCLI(args: string[]): Promise { return; } - const { configs } = await loadRstackConfig(); - const stagedConfig = configs.staged; - if (!stagedConfig) { + const loaded = await loadRstackConfig(); + const runtime = await getRstackPluginRuntime(loaded); + if (!loaded.configs.staged && runtime.configModifiers.staged.length === 0) { throw new Error( 'No define.staged config found. Add define.staged({ "*": "your-command" }) to rstack config file', ); } + const stagedConfig = await runtime.applyConfigModifiers('staged', loaded.configs.staged ?? {}); // Let child commands detect that they are running through `rs staged`. process.env.RSTACK_STAGED = '1'; diff --git a/packages/rstack/tests/cli/staged/index.test.ts b/packages/rstack/tests/cli/staged/index.test.ts index 6028b773..418b8c5e 100644 --- a/packages/rstack/tests/cli/staged/index.test.ts +++ b/packages/rstack/tests/cli/staged/index.test.ts @@ -1,7 +1,7 @@ import lintStaged from 'lint-staged'; -import { afterEach, beforeEach, rs } from 'rstack/test'; +import { afterEach, beforeEach, expect, rs } from 'rstack/test'; import { normalizeHelpOutput, test } from '#test-helpers'; -import { loadRstackConfig } from '../../../src/config.ts'; +import { getRstackPluginRuntime, loadRstackConfig } from '../../../src/config.ts'; import { runStagedCLI, type StagedConfig } from '../../../src/staged.ts'; rs.mock('lint-staged'); @@ -9,6 +9,7 @@ rs.mock('../../../src/config.ts'); const mocks = { lintStaged: rs.mocked(lintStaged), + getRstackPluginRuntime: rs.mocked(getRstackPluginRuntime), loadRstackConfig: rs.mocked(loadRstackConfig), }; @@ -16,10 +17,16 @@ const stagedConfig: StagedConfig = { '*.txt': 'echo test', }; +const noStagedModifiers = { + configModifiers: { staged: [] }, + applyConfigModifiers: async (_kind: 'staged', config: StagedConfig) => config, +}; + beforeEach(() => { delete process.env.RSTACK_STAGED; rs.resetAllMocks(); mocks.lintStaged.mockResolvedValue(true); + mocks.getRstackPluginRuntime.mockResolvedValue(noStagedModifiers as never); mocks.loadRstackConfig.mockResolvedValue({ configs: { staged: stagedConfig }, plugins: [], @@ -28,6 +35,37 @@ beforeEach(() => { }); }); +test('should preserve the missing staged config error when no plugin contributes one', async () => { + mocks.loadRstackConfig.mockResolvedValue({ + configs: {}, + plugins: [], + filePath: null, + dependencies: [], + }); + + await expect(runStagedCLI([])).rejects.toThrow('No define.staged config found'); +}); + +test('should accept a staged config supplied only by a plugin modifier', async ({ expect }) => { + const modifierConfig: StagedConfig = { '*.ts': 'echo plugin' }; + mocks.loadRstackConfig.mockResolvedValue({ + configs: {}, + plugins: [], + filePath: null, + dependencies: [], + }); + mocks.getRstackPluginRuntime.mockResolvedValue({ + configModifiers: { staged: [() => modifierConfig] }, + applyConfigModifiers: async () => modifierConfig, + } as never); + + await runStagedCLI([]); + + expect(mocks.lintStaged).toHaveBeenCalledWith( + expect.objectContaining({ config: modifierConfig }), + ); +}); + afterEach(() => { delete process.env.RSTACK_STAGED; }); diff --git a/packages/rstack/tests/config/plugin-modifiers/adapters.test.ts b/packages/rstack/tests/config/plugin-modifiers/adapters.test.ts new file mode 100644 index 00000000..4ac88284 --- /dev/null +++ b/packages/rstack/tests/config/plugin-modifiers/adapters.test.ts @@ -0,0 +1,82 @@ +import path from 'node:path'; +import { afterEach, expect, test } from 'rstack/test'; +import { getConfigState } from '../../../src/config.ts'; +import loadRsbuildConfig from '../../../src/rsbuildConfig.ts'; +import loadRslibConfig from '../../../src/rslibConfig.ts'; +import loadRspressConfig from '../../../src/rspressConfig.ts'; +import loadRstestConfig from '../../../src/rstestConfig.ts'; + +declare global { + // rslint-disable-next-line no-var + var __rstackPluginModifierSetups: number | undefined; +} + +const state = getConfigState(); +const loadAppConfig = loadRsbuildConfig as (params: never) => Promise; +const loadLibConfig = loadRslibConfig as (params: never) => Promise; +const loadTestConfig = loadRstestConfig as (params: never) => Promise; +const configPath = path.join(import.meta.dirname, 'rstack.config.ts'); +const explicitConfigPath = path.join(import.meta.dirname, 'explicit-rstack.config.ts'); +const projectsExplicitConfigPath = path.join( + import.meta.dirname, + 'projects-explicit-rstack.config.ts', +); +const testModifierExtendsConfigPath = path.join( + import.meta.dirname, + 'test-modifier-extends-rstack.config.ts', +); + +afterEach(() => { + delete state.configPath; + delete globalThis.__rstackPluginModifierSetups; + delete globalThis.__rstackExplicitAppModifierCalls; + delete globalThis.__rstackAllProjectsExplicitAppModifierCalls; + delete globalThis.__rstackTestModifierExtendsAppCalls; +}); + +test('constructs automatic Rstest extends before applying test modifiers', async () => { + state.configPath = testModifierExtendsConfigPath; + + await expect(loadTestConfig({} as never)).resolves.toMatchObject({ + extends: { root: 'test-modifier' }, + }); + expect(globalThis.__rstackTestModifierExtendsAppCalls).toBe(1); +}); + +test('does not apply app modifiers when all Rstest projects explicitly extend configs', async () => { + state.configPath = projectsExplicitConfigPath; + + await expect(loadTestConfig({} as never)).resolves.toMatchObject({ + reporters: ['dot'], + projects: [{ name: 'explicit', extends: {} }], + }); + expect(globalThis.__rstackAllProjectsExplicitAppModifierCalls).toBeUndefined(); +}); + +test('does not apply app modifiers for explicit Rstest extends', async () => { + state.configPath = explicitConfigPath; + + await expect(loadTestConfig({} as never)).resolves.toMatchObject({ + extends: {}, + reporters: ['dot'], + }); + expect(globalThis.__rstackExplicitAppModifierCalls).toBeUndefined(); +}); + +test('uses app, lib, and doc modifiers when their user configs are absent', async () => { + state.configPath = configPath; + + await expect(loadAppConfig({} as never)).resolves.toMatchObject({ root: 'app-1' }); + await expect(loadLibConfig({} as never)).resolves.toMatchObject({ root: 'lib-2' }); + await expect(loadRspressConfig()).resolves.toMatchObject({ root: 'doc-3' }); +}); + +test('uses plugin-provided app config for automatic Rstest extends and keeps test config native', async () => { + state.configPath = configPath; + + await expect(loadTestConfig({} as never)).resolves.toMatchObject({ + name: 'test-1', + reporters: ['dot'], + extends: expect.anything(), + }); +}); diff --git a/packages/rstack/tests/config/plugin-modifiers/explicit-rstack.config.ts b/packages/rstack/tests/config/plugin-modifiers/explicit-rstack.config.ts new file mode 100644 index 00000000..2beea713 --- /dev/null +++ b/packages/rstack/tests/config/plugin-modifiers/explicit-rstack.config.ts @@ -0,0 +1,25 @@ +import { define } from 'rstack'; + +declare global { + // rslint-disable-next-line no-var + var __rstackExplicitAppModifierCalls: number | undefined; +} + +define.plugins([ + { + name: 'explicit-extends', + setup({ modifyConfig }) { + modifyConfig('app', (config) => { + globalThis.__rstackExplicitAppModifierCalls = + (globalThis.__rstackExplicitAppModifierCalls ?? 0) + 1; + return config; + }); + modifyConfig('test', (config) => ({ ...config, reporters: ['dot'] })); + }, + }, +]); + +define.app({}); +define.test({ + extends: {}, +}); diff --git a/packages/rstack/tests/config/plugin-modifiers/fmt.test.ts b/packages/rstack/tests/config/plugin-modifiers/fmt.test.ts new file mode 100644 index 00000000..55f03ae5 --- /dev/null +++ b/packages/rstack/tests/config/plugin-modifiers/fmt.test.ts @@ -0,0 +1,29 @@ +import { expect, test } from 'rstack/test'; +import { setupFmtTest } from '../../cli/fmt/helpers.ts'; + +const { readProjectFile, runFmt, writeProjectFile } = setupFmtTest(); + +test('applies fmt modifiers after resolving the native config definition', () => { + writeProjectFile( + 'rstack.config.ts', + `import { define } from 'rstack'; + +define.plugins([ + { + name: 'fmt-modifier', + setup({ modifyConfig }) { + modifyConfig('fmt', async (config) => ({ ...config, singleQuote: true })); + }, + }, +]); + +define.fmt(() => ({})); +`, + ); + writeProjectFile('index.ts', 'const message="hello"'); + + const result = runFmt(['index.ts']); + + expect(result.status).toBe(0); + expect(readProjectFile('index.ts')).toBe("const message = 'hello';\n"); +}); diff --git a/packages/rstack/tests/config/plugin-modifiers/index.test.ts b/packages/rstack/tests/config/plugin-modifiers/index.test.ts new file mode 100644 index 00000000..4925f9f1 --- /dev/null +++ b/packages/rstack/tests/config/plugin-modifiers/index.test.ts @@ -0,0 +1,84 @@ +import path from 'node:path'; +import { afterEach, expect, test } from 'rstack/test'; +import { + applyRstackConfigModifiers, + getConfigState, + loadRstackConfig, +} from '../../../src/config.ts'; + +declare global { + // rslint-disable-next-line no-var + var __rstackPluginModifierSetups: number | undefined; +} + +const state = getConfigState(); +const configFilePath = path.join(import.meta.dirname, 'rstack.config.ts'); + +afterEach(() => { + delete state.invocation; + delete globalThis.__rstackPluginModifierSetups; + delete globalThis.__rstackPluginModifierContext; + delete globalThis.__rstackPluginModifierError; +}); + +test('propagates modifier errors', async () => { + const loaded = await loadRstackConfig({ configFilePath }); + globalThis.__rstackPluginModifierError = true; + + await expect(applyRstackConfigModifiers(loaded, 'app', {})).rejects.toThrow( + 'plugin modifier error', + ); +}); + +test('applies typed modifiers to native defaults in registration order', async () => { + const loaded = await loadRstackConfig({ configFilePath }); + + await expect(applyRstackConfigModifiers(loaded, 'app', {})).resolves.toEqual({ + setup: 1, + root: 'app-1', + }); + await expect(applyRstackConfigModifiers(loaded, 'lib', {})).resolves.toEqual({ + root: 'lib-1', + }); + await expect(applyRstackConfigModifiers(loaded, 'doc', {})).resolves.toEqual({ + root: 'doc-1', + }); + await expect(applyRstackConfigModifiers(loaded, 'test', {})).resolves.toEqual({ + name: 'test-1', + reporters: ['dot'], + }); + await expect(applyRstackConfigModifiers(loaded, 'lint', [])).resolves.toEqual([ + { name: 'lint-1' }, + ]); + await expect(applyRstackConfigModifiers(loaded, 'fmt', {})).resolves.toEqual({ + singleQuote: true, + }); + await expect(applyRstackConfigModifiers(loaded, 'staged', {})).resolves.toEqual({ + '*.ts': 'echo staged-1', + }); +}); + +test('initializes plugins again for each fresh config load', async () => { + state.invocation = { + cwd: '/invocation', + command: 'build', + args: ['--watch'], + configFilePath: null, + }; + + const first = await loadRstackConfig({ configFilePath }); + const second = await loadRstackConfig({ configFilePath }); + + await expect(applyRstackConfigModifiers(first, 'app', {})).resolves.toMatchObject({ + root: 'app-1', + }); + await expect(applyRstackConfigModifiers(second, 'app', {})).resolves.toMatchObject({ + root: 'app-2', + }); + expect(globalThis.__rstackPluginModifierContext).toEqual({ + cwd: '/invocation', + command: 'build', + args: ['--watch'], + configFilePath, + }); +}); diff --git a/packages/rstack/tests/config/plugin-modifiers/projects-explicit-rstack.config.ts b/packages/rstack/tests/config/plugin-modifiers/projects-explicit-rstack.config.ts new file mode 100644 index 00000000..9cbdcf1c --- /dev/null +++ b/packages/rstack/tests/config/plugin-modifiers/projects-explicit-rstack.config.ts @@ -0,0 +1,25 @@ +import { define } from 'rstack'; + +declare global { + // rslint-disable-next-line no-var + var __rstackAllProjectsExplicitAppModifierCalls: number | undefined; +} + +define.plugins([ + { + name: 'all-projects-explicit', + setup({ modifyConfig }) { + modifyConfig('app', (config) => { + globalThis.__rstackAllProjectsExplicitAppModifierCalls = + (globalThis.__rstackAllProjectsExplicitAppModifierCalls ?? 0) + 1; + return config; + }); + modifyConfig('test', (config) => ({ ...config, reporters: ['dot'] })); + }, + }, +]); + +define.app({}); +define.test({ + projects: [{ name: 'explicit', extends: {} }], +}); diff --git a/packages/rstack/tests/config/plugin-modifiers/rstack.config.ts b/packages/rstack/tests/config/plugin-modifiers/rstack.config.ts new file mode 100644 index 00000000..7d03afb7 --- /dev/null +++ b/packages/rstack/tests/config/plugin-modifiers/rstack.config.ts @@ -0,0 +1,47 @@ +import { define } from 'rstack'; + +declare global { + // rslint-disable-next-line no-var + var __rstackPluginModifierSetups: number | undefined; + // rslint-disable-next-line no-var + var __rstackPluginModifierContext: + | { cwd: string; command: string; args: readonly string[]; configFilePath: string | null } + | undefined; + // rslint-disable-next-line no-var + var __rstackPluginModifierError: boolean | undefined; +} + +define.plugins([ + { + name: 'config-modifiers', + setup({ context, modifyConfig }) { + globalThis.__rstackPluginModifierSetups = (globalThis.__rstackPluginModifierSetups ?? 0) + 1; + globalThis.__rstackPluginModifierContext = context; + const setup = globalThis.__rstackPluginModifierSetups; + + modifyConfig('app', (config) => { + (config as { setup?: number }).setup = setup; + }); + modifyConfig('app', async (config) => ({ + ...config, + root: `app-${(config as { setup?: number }).setup}`, + })); + modifyConfig('app', () => { + if (globalThis.__rstackPluginModifierError) { + throw new Error('plugin modifier error'); + } + }); + + modifyConfig('lib', (config) => ({ ...config, root: `lib-${setup}` })); + modifyConfig('doc', (config) => ({ ...config, root: `doc-${setup}` })); + modifyConfig('test', (config) => ({ + ...config, + name: `test-${setup}`, + reporters: ['dot'], + })); + modifyConfig('lint', () => [{ name: `lint-${setup}` }] as never); + modifyConfig('fmt', (config) => ({ ...config, singleQuote: true })); + modifyConfig('staged', async () => ({ '*.ts': `echo staged-${setup}` })); + }, + }, +]); diff --git a/packages/rstack/tests/config/plugin-modifiers/test-modifier-extends-rstack.config.ts b/packages/rstack/tests/config/plugin-modifiers/test-modifier-extends-rstack.config.ts new file mode 100644 index 00000000..9d515911 --- /dev/null +++ b/packages/rstack/tests/config/plugin-modifiers/test-modifier-extends-rstack.config.ts @@ -0,0 +1,25 @@ +import { define } from 'rstack'; + +declare global { + // rslint-disable-next-line no-var + var __rstackTestModifierExtendsAppCalls: number | undefined; +} + +define.plugins([ + { + name: 'test-modifier-extends', + setup({ modifyConfig }) { + modifyConfig('app', (config) => { + globalThis.__rstackTestModifierExtendsAppCalls = + (globalThis.__rstackTestModifierExtendsAppCalls ?? 0) + 1; + return { ...config, root: 'automatic' }; + }); + modifyConfig('test', (config) => ({ + ...config, + extends: { root: 'test-modifier' }, + })); + }, + }, +]); + +define.test({}); From 4563930bbb3f17b8c40f76aac708ff931b81e3e8 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 12 Aug 2026 23:48:56 +0000 Subject: [PATCH 4/9] fix(rstack): harden plugin runtime --- packages/rstack/src/cli/commands.ts | 16 ++-- packages/rstack/src/config.ts | 8 +- packages/rstack/src/pluginRuntime.ts | 24 ++++-- packages/rstack/src/rstestConfig.ts | 4 +- packages/rstack/src/staged.ts | 2 +- .../tests/cli/plugin-commands/state.test.ts | 29 +++++++ .../rstack/tests/cli/staged/index.test.ts | 4 +- .../config/plugin-modifiers/index.test.ts | 16 ++++ packages/rstack/tests/pluginRuntime.test.ts | 83 +++++++++++++++++-- 9 files changed, 155 insertions(+), 31 deletions(-) create mode 100644 packages/rstack/tests/cli/plugin-commands/state.test.ts diff --git a/packages/rstack/src/cli/commands.ts b/packages/rstack/src/cli/commands.ts index 9038bba2..d877183f 100644 --- a/packages/rstack/src/cli/commands.ts +++ b/packages/rstack/src/cli/commands.ts @@ -165,16 +165,12 @@ async function runCheckCLI(args: string[]): Promise { } export async function setupCommands(): Promise { - const { args, configPath } = parseCliArgs(process.argv.slice(2)); - const command = args[0]; const state = getConfigState(); + delete state.configPath; + delete state.invocation; - // Resolved for every command so that a relative `--config` path always means - // the same file: it is anchored to the directory the CLI was invoked in, even - // when the config is later loaded from another directory. The motivating case - // is `rs fmt --lsp`, which loads the config from the LSP workspace root the - // client reports, and that root need not be the process working directory. - state.configPath = configPath === undefined ? undefined : resolve(configPath); + const { args, configPath } = parseCliArgs(process.argv.slice(2)); + const command = args[0]; if (!command || command === '-h' || command === '--help') { return printCommandHelp('root'); @@ -185,6 +181,10 @@ export async function setupCommands(): Promise { return; } + // Anchor a relative `--config` to the directory the CLI was invoked in, + // even when a command later loads it from another directory (for example, + // an LSP workspace root). + state.configPath = configPath === undefined ? undefined : resolve(configPath); state.invocation = { cwd: process.cwd(), command, diff --git a/packages/rstack/src/config.ts b/packages/rstack/src/config.ts index 0860e4d1..b6dea6d5 100644 --- a/packages/rstack/src/config.ts +++ b/packages/rstack/src/config.ts @@ -1,4 +1,5 @@ import { AsyncLocalStorage } from 'node:async_hooks'; +import { resolve } from 'node:path'; import { loadConfig } from '@rstackjs/load-config'; import { logger } from 'rslog'; import type { RsbuildConfigDefinition } from '@rsbuild/core'; @@ -32,6 +33,7 @@ export type LoadedRstackConfig = { }; const loadedPluginRuntimes = new WeakMap>(); +const loadedConfigDirectories = new WeakMap(); export type LoadRstackConfigOptions = { /** @@ -112,7 +114,7 @@ export const getRstackPluginRuntime = ( const runtime = createPluginRuntime({ plugins: config.plugins, context: { - cwd: invocation?.cwd ?? process.cwd(), + cwd: invocation?.cwd ?? loadedConfigDirectories.get(config) ?? process.cwd(), command: invocation?.command ?? 'programmatic', args: invocation?.args ?? [], configFilePath: config.filePath, @@ -275,12 +277,14 @@ export const loadRstackConfig = async ({ state.invocation.configFilePath = filePath; } - return { + const loaded = { configs: session.configs, plugins: session.plugins, filePath, dependencies, }; + loadedConfigDirectories.set(loaded, resolve(cwd ?? process.cwd())); + return loaded; } finally { session.active = false; session.configs = {}; diff --git a/packages/rstack/src/pluginRuntime.ts b/packages/rstack/src/pluginRuntime.ts index b3d49e19..eb4a173a 100644 --- a/packages/rstack/src/pluginRuntime.ts +++ b/packages/rstack/src/pluginRuntime.ts @@ -11,13 +11,13 @@ export type RstackConfigModifier = ( config: RstackConfigMap[K], ) => void | RstackConfigMap[K] | Promise; -export type RstackConfigModifierRegistry = { +type RstackConfigModifierRegistry = { [K in keyof RstackConfigMap]: RstackConfigModifier[]; }; export type RstackPluginRuntime = { readonly context: RstackPluginContext; - readonly configModifiers: RstackConfigModifierRegistry; + hasConfigModifier(kind: keyof RstackConfigMap): boolean; runCommand(name: string, args: readonly string[]): Promise; applyConfigModifiers( kind: K, @@ -53,6 +53,10 @@ const hasValidName = (name: unknown): name is string => typeof name === 'string' && name.length > 0 && name.trim() === name && !/\s/u.test(name); const flattenPlugins = async (plugins: RstackPlugins): Promise => { + if (!Array.isArray(plugins)) { + throw new Error('Invalid Rstack plugins. Expected an array.'); + } + const result: RstackPlugin[] = []; const visit = async (value: RstackPlugins[number]): Promise => { @@ -115,7 +119,6 @@ export const createPluginRuntime = async ({ const configModifiers = createModifierRegistry(); const pluginNames = new Set(); const reservedCommandNames = new Set(reservedCommands); - let setupComplete = false; const addCommand = (command: RstackCommand): void => { if (!command || typeof command !== 'object' || Array.isArray(command)) { @@ -145,7 +148,9 @@ export const createPluginRuntime = async ({ const runtime: RstackPluginRuntime = { context, - configModifiers, + hasConfigModifier(kind) { + return configModifiers[kind].length > 0; + }, async runCommand(name, args) { const handler = commands.get(name); if (!handler) { @@ -155,10 +160,6 @@ export const createPluginRuntime = async ({ return true; }, async applyConfigModifiers(kind, config) { - if (!setupComplete) { - throw new Error('Rstack plugin setup must complete before config modifiers run.'); - } - let current = config; for (const modifier of configModifiers[kind] as RstackConfigModifier[]) { const result = await modifier(current); @@ -186,11 +187,16 @@ export const createPluginRuntime = async ({ logger, addCommand, modifyConfig(kind, handler) { + if (!Object.hasOwn(configModifiers, kind)) { + throw new Error(`Invalid Rstack config kind: "${String(kind)}".`); + } + if (typeof handler !== 'function') { + throw new Error(`Invalid Rstack ${kind} config modifier. Expected a function.`); + } (configModifiers[kind] as RstackConfigModifier[]).push(handler); }, }); } - setupComplete = true; return runtime; }; diff --git a/packages/rstack/src/rstestConfig.ts b/packages/rstack/src/rstestConfig.ts index 998d95a7..ec25209e 100644 --- a/packages/rstack/src/rstestConfig.ts +++ b/packages/rstack/src/rstestConfig.ts @@ -16,7 +16,7 @@ const resolveAutomaticExtends = async ( // introduce conflicting runtime, resolve, and source transform settings. const appConfig = loaded.configs.app; const runtime = await getRstackPluginRuntime(loaded); - if (appConfig || runtime.configModifiers.app.length > 0) { + if (appConfig || runtime.hasConfigModifier('app')) { const { withRsbuildConfig } = await import( /* rspackChunkName: 'adapterRsbuild' */ '@rstest/adapter-rsbuild' @@ -37,7 +37,7 @@ const resolveAutomaticExtends = async ( } const libConfig = loaded.configs.lib; - if (libConfig || runtime.configModifiers.lib.length > 0) { + if (libConfig || runtime.hasConfigModifier('lib')) { const { withRslibConfig } = await import( /* rspackChunkName: 'adapterRslib' */ '@rstest/adapter-rslib' diff --git a/packages/rstack/src/staged.ts b/packages/rstack/src/staged.ts index 5f474298..64967661 100644 --- a/packages/rstack/src/staged.ts +++ b/packages/rstack/src/staged.ts @@ -46,7 +46,7 @@ export async function runStagedCLI(args: string[]): Promise { const loaded = await loadRstackConfig(); const runtime = await getRstackPluginRuntime(loaded); - if (!loaded.configs.staged && runtime.configModifiers.staged.length === 0) { + if (!loaded.configs.staged && !runtime.hasConfigModifier('staged')) { throw new Error( 'No define.staged config found. Add define.staged({ "*": "your-command" }) to rstack config file', ); diff --git a/packages/rstack/tests/cli/plugin-commands/state.test.ts b/packages/rstack/tests/cli/plugin-commands/state.test.ts new file mode 100644 index 00000000..535a2b22 --- /dev/null +++ b/packages/rstack/tests/cli/plugin-commands/state.test.ts @@ -0,0 +1,29 @@ +import { afterEach, expect, test } from 'rstack/test'; +import { setupCommands } from '../../../src/cli/commands.ts'; +import { getConfigState } from '../../../src/config.ts'; + +const state = getConfigState(); +const originalArgv = process.argv; + +afterEach(() => { + process.argv = originalArgv; + delete (globalThis as { RSTACK_VERSION?: string }).RSTACK_VERSION; + delete state.configPath; + delete state.invocation; +}); + +test('clears prior invocation state for config-free root help', async () => { + state.configPath = '/prior/rstack.config.ts'; + state.invocation = { + cwd: '/prior', + command: 'build', + args: [], + configFilePath: '/prior/rstack.config.ts', + }; + process.argv = ['node', 'rs', '--config', './ignored.config.ts', '--help']; + (globalThis as { RSTACK_VERSION?: string }).RSTACK_VERSION = 'test'; + + await setupCommands(); + + expect(state).toEqual({}); +}); diff --git a/packages/rstack/tests/cli/staged/index.test.ts b/packages/rstack/tests/cli/staged/index.test.ts index 418b8c5e..30dbed08 100644 --- a/packages/rstack/tests/cli/staged/index.test.ts +++ b/packages/rstack/tests/cli/staged/index.test.ts @@ -18,7 +18,7 @@ const stagedConfig: StagedConfig = { }; const noStagedModifiers = { - configModifiers: { staged: [] }, + hasConfigModifier: () => false, applyConfigModifiers: async (_kind: 'staged', config: StagedConfig) => config, }; @@ -55,7 +55,7 @@ test('should accept a staged config supplied only by a plugin modifier', async ( dependencies: [], }); mocks.getRstackPluginRuntime.mockResolvedValue({ - configModifiers: { staged: [() => modifierConfig] }, + hasConfigModifier: () => true, applyConfigModifiers: async () => modifierConfig, } as never); diff --git a/packages/rstack/tests/config/plugin-modifiers/index.test.ts b/packages/rstack/tests/config/plugin-modifiers/index.test.ts index 4925f9f1..b16163f7 100644 --- a/packages/rstack/tests/config/plugin-modifiers/index.test.ts +++ b/packages/rstack/tests/config/plugin-modifiers/index.test.ts @@ -82,3 +82,19 @@ test('initializes plugins again for each fresh config load', async () => { configFilePath, }); }); + +test('uses the programmatic config cwd in plugin context', async () => { + const loaded = await loadRstackConfig({ + configFilePath, + cwd: import.meta.dirname, + }); + + await applyRstackConfigModifiers(loaded, 'app', {}); + + expect(globalThis.__rstackPluginModifierContext).toEqual({ + cwd: import.meta.dirname, + command: 'programmatic', + args: [], + configFilePath, + }); +}); diff --git a/packages/rstack/tests/pluginRuntime.test.ts b/packages/rstack/tests/pluginRuntime.test.ts index 73a59c29..35fd1d72 100644 --- a/packages/rstack/tests/pluginRuntime.test.ts +++ b/packages/rstack/tests/pluginRuntime.test.ts @@ -61,13 +61,24 @@ test('flattens nested asynchronous plugins in declaration order and initializes test('rejects invalid plugin objects and duplicate plugin names', async () => { const options = { - context: { cwd: '/project', command: 'command', args: [], configFilePath: null }, + context: { + cwd: '/project', + command: 'command', + args: [], + configFilePath: null, + }, logger, }; await expect( - createPluginRuntime({ ...options, plugins: [true] as unknown as RstackPlugins }), + createPluginRuntime({ + ...options, + plugins: [true] as unknown as RstackPlugins, + }), ).rejects.toThrow('Invalid Rstack plugin'); + await expect(createPluginRuntime({ ...options, plugins: {} as RstackPlugins })).rejects.toThrow( + 'Invalid Rstack plugins', + ); await expect( createPluginRuntime({ ...options, @@ -85,6 +96,45 @@ test('rejects invalid plugin objects and duplicate plugin names', async () => { ).rejects.toThrow('Duplicate Rstack plugin'); }); +test('rejects invalid config modifier registrations', async () => { + const options = { + context: { + cwd: '/project', + command: 'command', + args: [], + configFilePath: null, + }, + logger, + }; + + await expect( + createPluginRuntime({ + ...options, + plugins: [ + { + name: 'invalid-kind', + setup({ modifyConfig }) { + modifyConfig('unknown' as never, () => {}); + }, + }, + ], + }), + ).rejects.toThrow('Invalid Rstack config kind'); + await expect( + createPluginRuntime({ + ...options, + plugins: [ + { + name: 'invalid-handler', + setup({ modifyConfig }) { + modifyConfig('app', undefined as never); + }, + }, + ], + }), + ).rejects.toThrow('Invalid Rstack app config modifier'); +}); + test('validates every plugin before setup begins', async () => { let setupRan = false; @@ -99,7 +149,12 @@ test('validates every plugin before setup begins', async () => { }, { name: 'invalid' } as never, ], - context: { cwd: '/project', command: 'command', args: [], configFilePath: null }, + context: { + cwd: '/project', + command: 'command', + args: [], + configFilePath: null, + }, logger, }), ).rejects.toThrow('Expected a setup function'); @@ -108,7 +163,12 @@ test('validates every plugin before setup begins', async () => { test('rejects invalid, duplicate, and built-in command names', async () => { const options = { - context: { cwd: '/project', command: 'command', args: [], configFilePath: null }, + context: { + cwd: '/project', + command: 'command', + args: [], + configFilePath: null, + }, logger, }; @@ -178,7 +238,10 @@ test('runs config modifiers after setup and keeps each modifier result', async ( modifyConfig('app', (config) => { config.root = '/first'; }); - modifyConfig('app', (config) => ({ ...config, root: `${config.root}/second` })); + modifyConfig('app', (config) => ({ + ...config, + root: `${config.root}/second`, + })); modifyConfig('app', async (config) => ({ ...config, root: `${config.root}/third`, @@ -186,11 +249,17 @@ test('runs config modifiers after setup and keeps each modifier result', async ( }, }, ], - context: { cwd: '/project', command: 'command', args: [], configFilePath: null }, + context: { + cwd: '/project', + command: 'command', + args: [], + configFilePath: null, + }, logger, }); - expect(runtime.configModifiers.app).toHaveLength(3); + expect(runtime.hasConfigModifier('app')).toBe(true); + expect(runtime.hasConfigModifier('lib')).toBe(false); await expect(runtime.applyConfigModifiers('app', {})).resolves.toMatchObject({ root: '/first/second/third', }); From 623c4cba776111cbad56d1bc4d6b88a5a904af9a Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 12 Aug 2026 23:51:06 +0000 Subject: [PATCH 5/9] docs: document Rstack plugins --- website/docs/en/guide/_meta.json | 5 + website/docs/en/guide/api-reference.mdx | 6 +- website/docs/en/guide/configuration.mdx | 25 +-- website/docs/en/guide/plugins.mdx | 194 ++++++++++++++++++++++++ website/docs/zh/guide/_meta.json | 5 + website/docs/zh/guide/api-reference.mdx | 6 +- website/docs/zh/guide/configuration.mdx | 25 +-- website/docs/zh/guide/plugins.mdx | 194 ++++++++++++++++++++++++ 8 files changed, 436 insertions(+), 24 deletions(-) create mode 100644 website/docs/en/guide/plugins.mdx create mode 100644 website/docs/zh/guide/plugins.mdx diff --git a/website/docs/en/guide/_meta.json b/website/docs/en/guide/_meta.json index 630bde07..359c36d3 100644 --- a/website/docs/en/guide/_meta.json +++ b/website/docs/en/guide/_meta.json @@ -13,6 +13,11 @@ "name": "configuration", "label": "Configuration" }, + { + "type": "file", + "name": "plugins", + "label": "Plugins" + }, { "type": "file", "name": "ai", diff --git a/website/docs/en/guide/api-reference.mdx b/website/docs/en/guide/api-reference.mdx index 8c61e7fc..eb3b3b5a 100644 --- a/website/docs/en/guide/api-reference.mdx +++ b/website/docs/en/guide/api-reference.mdx @@ -6,7 +6,7 @@ Rstack provides a unified configuration API and re-exports the public APIs of Rs | Import path | Contents | Use case | | ------------------------ | ------------------------------------------------- | --------------------------------------- | -| `rstack` | Rstack configuration API | Register tool configurations | +| `rstack` | Rstack configuration and plugin APIs | Configure and extend Rstack | | `rstack/app` | Public APIs from `@rsbuild/core` | Build applications and extend Rsbuild | | `rstack/lib` | Public APIs from `@rslib/core` | Build libraries and extend Rslib | | `rstack/test` | Public APIs from `@rstest/core` | Write tests and configure test projects | @@ -19,7 +19,9 @@ Rstack provides a unified configuration API and re-exports the public APIs of Rs ### `define` -Import `define` from `rstack` to register tool configurations in `rstack.config.ts`; see [Configuration APIs](./configuration#configuration-apis) for details. +Import `define` from `rstack` to register tool configurations and plugins in `rstack.config.ts`; see [Configuration APIs](./configuration#configuration-apis) and [Plugins](./plugins) for details. + +The main entry point also exports the public plugin types, including `RstackPlugin`, `RstackPluginAPI`, `RstackPluginContext`, `RstackConfigMap`, `FmtConfig`, and `StagedConfig`. ## Re-exports diff --git a/website/docs/en/guide/configuration.mdx b/website/docs/en/guide/configuration.mdx index e9db58fe..cab1dce4 100644 --- a/website/docs/en/guide/configuration.mdx +++ b/website/docs/en/guide/configuration.mdx @@ -29,7 +29,7 @@ define.fmt({ }); ``` -The configuration file does not require a default export. Each `define.*()` API can be called at most once; defining the same configuration type more than once throws an error. +The configuration file does not require a default export. Each `define.*()` API can be called at most once; defining the same configuration type more than once throws an error. Use [`define.plugins()`](./plugins#registering-plugins) when an external package needs to extend Rstack itself or contribute to several tool configurations. By default, Rstack looks for a file with one of the following names: @@ -66,15 +66,20 @@ define.app(async () => { Configuration options follow the formats of the underlying tools. When using APIs and helpers that Rstack re-exports, prefer the `rstack/app`, `rstack/lib`, `rstack/test`, and `rstack/lint` entry points. -| API | Tool | Commands | -| ----------------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------- | -| [`define.app()`](#define-app) | [Rsbuild](https://rsbuild.rs/config/) | [`rs dev`](./cli/dev), [`rs build`](./cli/build), [`rs preview`](./cli/preview) | -| [`define.lib()`](#define-lib) | [Rslib](https://rslib.rs/config/) | [`rs lib`](./cli/lib) | -| [`define.doc()`](#define-doc) | [Rspress](https://rspress.rs/api/config/config-basic) | [`rs doc`](./cli/doc) | -| [`define.test()`](#define-test) | [Rstest](https://rstest.rs/config/) | [`rs test`](./cli/test) | -| [`define.lint()`](#define-lint) | [Rslint](https://rslint.rs/config/) | [`rs lint`](./cli/lint) | -| [`define.fmt()`](#define-fmt) | [Prettier](https://prettier.io/docs/options) | [`rs fmt`](./cli/fmt) | -| [`define.staged()`](#define-staged) | [lint-staged](https://github.com/lint-staged/lint-staged#configuration) | [`rs staged`](./cli/staged) | +| API | Tool | Commands | +| ------------------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------- | +| [`define.app()`](#define-app) | [Rsbuild](https://rsbuild.rs/config/) | [`rs dev`](./cli/dev), [`rs build`](./cli/build), [`rs preview`](./cli/preview) | +| [`define.lib()`](#define-lib) | [Rslib](https://rslib.rs/config/) | [`rs lib`](./cli/lib) | +| [`define.doc()`](#define-doc) | [Rspress](https://rspress.rs/api/config/config-basic) | [`rs doc`](./cli/doc) | +| [`define.test()`](#define-test) | [Rstest](https://rstest.rs/config/) | [`rs test`](./cli/test) | +| [`define.lint()`](#define-lint) | [Rslint](https://rslint.rs/config/) | [`rs lint`](./cli/lint) | +| [`define.fmt()`](#define-fmt) | [Prettier](https://prettier.io/docs/options) | [`rs fmt`](./cli/fmt) | +| [`define.staged()`](#define-staged) | [lint-staged](https://github.com/lint-staged/lint-staged#configuration) | [`rs staged`](./cli/staged) | +| [`define.plugins()`](#define-plugins) | Rstack CLI | Plugin commands and configured tool commands | + +### `define.plugins()` \{#define-plugins} + +Registers explicit Rstack CLI plugins. Plugins can add top-level commands and transform the native configurations listed below. See [Plugins](./plugins) for the API, lifecycle, and examples. ### `define.app()` \{#define-app} diff --git a/website/docs/en/guide/plugins.mdx b/website/docs/en/guide/plugins.mdx new file mode 100644 index 00000000..c08ef3b5 --- /dev/null +++ b/website/docs/en/guide/plugins.mdx @@ -0,0 +1,194 @@ +# Plugins \{#plugins} + +Rstack plugins extend the Rstack CLI as a whole. A plugin can register a new `rs` command and transform the native configuration passed to any tool that Rstack orchestrates. + +This API complements the extension systems of the underlying tools. Use an Rsbuild or Rslib plugin for build hooks, an Rstest reporter for test results, an Rslint plugin or its programmatic API for lint behavior, and an Rspress plugin for documentation hooks. Use an Rstack plugin when one package needs to register a command or contribute to several of those tools from the shared Rstack configuration. + +## Registering plugins \{#registering-plugins} + +Register plugins explicitly with `define.plugins()` in `rstack.config.ts`: + +```ts title="rstack.config.ts" +import { pluginAcme } from '@acme/rstack-plugin'; +import { define } from 'rstack'; + +define.plugins([pluginAcme()]); +``` + +Rstack does not scan dependencies or discover plugins by package name. Plugin entries may be nested, asynchronous, or conditional; falsy entries are ignored: + +```ts title="rstack.config.ts" +import { define } from 'rstack'; + +define.plugins([ + import('@acme/rstack-plugin').then(({ pluginAcme }) => pluginAcme()), + process.env.CI ? import('@acme/rstack-ci').then(({ pluginCI }) => pluginCI()) : false, +]); +``` + +Like the other `define.*()` APIs, `define.plugins()` can be called at most once per configuration file. + +## Creating a plugin \{#creating-a-plugin} + +A plugin has a unique name and a `setup` function. The setup function receives the Rstack plugin API: + +```ts title="src/pluginAcme.ts" +import type { RstackPlugin } from 'rstack'; + +export const pluginAcme = (): RstackPlugin => ({ + name: 'acme', + setup(api) { + api.addCommand({ + name: 'acme-info', + async handler(args) { + await printWorkspaceInfo(api.context.cwd, args); + }, + }); + + api.modifyConfig('app', (config) => ({ + ...config, + source: { + ...config.source, + define: { + ...config.source?.define, + ACME_CHANNEL: JSON.stringify('stable'), + }, + }, + })); + + api.modifyConfig('test', (config) => ({ + ...config, + reporters: [...(config.reporters ?? []), createAcmeReporter()], + })); + }, +}); +``` + +The package owns `printWorkspaceInfo` and `createAcmeReporter`; Rstack only provides registration and configuration composition. `RstackPlugin`, `RstackPluginAPI`, `RstackPluginContext`, `RstackConfigMap`, `FmtConfig`, `StagedConfig`, and the other supporting types are exported from `rstack`. + +## Plugin lifecycle \{#plugin-lifecycle} + +For a command that loads project configuration, Rstack performs these steps in order: + +1. Load `rstack.config.*` and capture the registered tool configurations and plugins. +2. Flatten plugin entries in declaration order. +3. Validate every plugin, then run each `setup` function sequentially. +4. Resolve the native configuration factory required by the selected command. +5. Run matching configuration modifiers sequentially. +6. Apply Rstack's internal configuration and start the underlying tool. + +A modifier may mutate its input, return a replacement, or return a promise. When it returns `undefined`, Rstack keeps the current configuration. Errors from setup, command handlers, and modifiers stop the invocation. + +Root help and version output do not load project configuration. Other commands initialize plugins only when they load a configuration. + +## Registering commands \{#registering-commands} + +Use `addCommand()` to add a top-level `rs` command: + +```ts +import type { RstackPlugin } from 'rstack'; + +export const pluginInspect = (): RstackPlugin => ({ + name: 'inspect', + setup({ addCommand, context, logger }) { + addCommand({ + name: 'inspect-workspace', + async handler(args) { + logger.info(`Inspecting ${context.cwd}`); + await inspectWorkspace(args); + }, + }); + }, +}); +``` + +Command names use lowercase kebab case. A plugin cannot replace a built-in command or alias, and duplicate command names are rejected. + +The command handler receives the arguments after the command name. Global `--config` or `-c` options parsed by Rstack are removed first. The plugin owns command-specific parsing and help output. + +The read-only `context` exposes: + +- `cwd`: the working directory where Rstack was invoked. +- `command`: the selected command name. +- `args`: the same command arguments available during setup. +- `configFilePath`: the resolved Rstack configuration path, or `null` when no file was found. + +## Modifying tool configurations \{#modifying-tool-configurations} + +Use `modifyConfig()` to contribute directly to a tool's native configuration. The `kind` selects the configuration type and keeps the handler typed. + +### Configuration kinds \{#configuration-kinds} + +| Kind | Native configuration | Initialized by | +| -------- | -------------------- | ---------------------------------- | +| `app` | Rsbuild | `rs dev`, `rs build`, `rs preview` | +| `lib` | Rslib | `rs lib` | +| `doc` | Rspress | `rs doc` | +| `test` | Rstest | `rs test` | +| `lint` | Rslint | `rs lint` | +| `fmt` | Rstack/Prettier | `rs fmt` | +| `staged` | lint-staged | `rs staged` | + +Tool configuration factories resolve before their matching modifiers. Existing command-line overlays still run in the underlying tool afterward, so explicit CLI options keep their normal precedence. + +### Mutation and replacement \{#mutation-and-replacement} + +Modifiers run in registration order. Both mutation and replacement are supported: + +```ts +setup({ modifyConfig }) { + modifyConfig('app', (config) => { + config.plugins ??= []; + config.plugins.push(createRsbuildPlugin()); + }); + + modifyConfig('lint', async (config) => [ + ...config, + await createRslintConfig(), + ]); +} +``` + +When no `define.*()` value exists, modifiers receive the native empty configuration: an empty array for `lint` and an empty object for the other kinds. `staged` keeps its missing-configuration error unless a staged modifier is registered. + +### Rstest inheritance \{#rstest-inheritance} + +When Rstest uses Rstack's automatic application or library inheritance, Rstack applies the matching `app` or `lib` modifiers before constructing the Rstest `extends` value. It then applies `test` modifiers after automatic inheritance. This lets one plugin contribute native build configuration and an Rstest reporter without applying either contribution twice. + +Explicit Rstest `extends` values continue to opt out of automatic application or library inheritance. + +## Underlying tool extensions \{#underlying-tool-extensions} + +Configuration modifiers do not replace native extension APIs. They contribute native values before the tool starts: + +- Add Rsbuild or Rslib plugins through the `plugins` field and use their hook APIs for build lifecycle work. +- Add Rstest reporters through `reporters`, or use Rstest's programmatic API when a separate process needs structured test results. +- Add Rslint flat-config entries and plugins through `lint`, or use the Rslint programmatic API for direct lint execution. +- Add Rspress plugins through the Rspress configuration. +- Add Prettier plugins through `fmt` and lint-staged tasks through `staged`. + +Build hooks, test results, lint results, and documentation lifecycle events remain owned by the underlying tools. Rstack does not normalize those results into a cross-tool lifecycle. + +## Configuration loading and reloads \{#configuration-loading-and-reloads} + +Plugins initialize once for each loaded Rstack configuration. Rsbuild's configuration watch entries are added after plugin modifiers; when Rsbuild reloads a changed configuration, Rstack loads it again and initializes a fresh plugin set. + +Registration is local to the selected configuration file. Rstack does not merge plugins from another workspace root or a parent configuration. + +## Compatibility \{#compatibility} + +The exported Rstack plugin types and setup contract follow Rstack's semantic-versioning policy. Plugin packages should declare a peer dependency range for the Rstack versions they support. + +Configuration values remain native to the integrated tool versions. A plugin that uses a particular Rsbuild, Rslib, Rstest, Rslint, Rspress, Prettier, or lint-staged feature should also follow that tool's compatibility guidance. + +## Non-goals \{#non-goals} + +The initial SPI intentionally does not provide: + +- automatic package discovery or scanning; +- replacement of built-in commands; +- global before/after command hooks or middleware; +- plugin ordering descriptors or a plugin-to-plugin service registry; +- a cross-tool result or event abstraction. + +Use the native tool APIs for domain-specific lifecycle and result processing. diff --git a/website/docs/zh/guide/_meta.json b/website/docs/zh/guide/_meta.json index 518da9ca..b5f44f84 100644 --- a/website/docs/zh/guide/_meta.json +++ b/website/docs/zh/guide/_meta.json @@ -13,6 +13,11 @@ "name": "configuration", "label": "配置" }, + { + "type": "file", + "name": "plugins", + "label": "插件" + }, { "type": "file", "name": "ai", diff --git a/website/docs/zh/guide/api-reference.mdx b/website/docs/zh/guide/api-reference.mdx index 0f7a2dc3..02b4d003 100644 --- a/website/docs/zh/guide/api-reference.mdx +++ b/website/docs/zh/guide/api-reference.mdx @@ -6,7 +6,7 @@ Rstack 提供统一的配置 API,并通过专用子路径重导出 Rsbuild、R | 导入路径 | 内容 | 使用场景 | | ------------------------ | ----------------------------------------- | ------------------------ | -| `rstack` | Rstack 配置 API | 注册各项工具配置 | +| `rstack` | Rstack 配置与插件 API | 配置并扩展 Rstack | | `rstack/app` | `@rsbuild/core` 的公开 API | 构建应用及扩展 Rsbuild | | `rstack/lib` | `@rslib/core` 的公开 API | 构建库及扩展 Rslib | | `rstack/test` | `@rstest/core` 的公开 API | 编写测试及配置测试项目 | @@ -19,7 +19,9 @@ Rstack 提供统一的配置 API,并通过专用子路径重导出 Rsbuild、R ### `define` -从 `rstack` 导入 `define`,用于在 `rstack.config.ts` 中注册各项工具配置;详细用法请参阅[配置 API](./configuration#configuration-apis)。 +从 `rstack` 导入 `define`,用于在 `rstack.config.ts` 中注册各项工具配置和插件;详细用法请参阅[配置 API](./configuration#configuration-apis)和[插件](./plugins)。 + +主入口还会导出公开插件类型,包括 `RstackPlugin`、`RstackPluginAPI`、`RstackPluginContext`、`RstackConfigMap`、`FmtConfig` 和 `StagedConfig`。 ## 重导出 \{#re-exports} diff --git a/website/docs/zh/guide/configuration.mdx b/website/docs/zh/guide/configuration.mdx index f4cbf631..449dcbfa 100644 --- a/website/docs/zh/guide/configuration.mdx +++ b/website/docs/zh/guide/configuration.mdx @@ -29,7 +29,7 @@ define.fmt({ }); ``` -配置文件无需默认导出。每个 `define.*()` API 最多调用一次;重复定义同一类型的配置会抛出错误。 +配置文件无需默认导出。每个 `define.*()` API 最多调用一次;重复定义同一类型的配置会抛出错误。当外部包需要扩展 Rstack 本身或同时贡献多项工具配置时,请使用 [`define.plugins()`](./plugins#registering-plugins)。 Rstack 默认会查找使用以下任一文件名的配置文件: @@ -66,15 +66,20 @@ define.app(async () => { 各 API 沿用底层工具的配置格式。使用 Rstack 已重导出的 API 和辅助函数时,推荐从 `rstack/app`、`rstack/lib`、`rstack/test` 和 `rstack/lint` 入口导入。 -| API | 底层工具 | 对应命令 | -| ----------------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------- | -| [`define.app()`](#define-app) | [Rsbuild](https://rsbuild.rs/zh/config/) | [`rs dev`](./cli/dev)、[`rs build`](./cli/build)、[`rs preview`](./cli/preview) | -| [`define.lib()`](#define-lib) | [Rslib](https://rslib.rs/zh/config/) | [`rs lib`](./cli/lib) | -| [`define.doc()`](#define-doc) | [Rspress](https://rspress.rs/zh/api/config/config-basic) | [`rs doc`](./cli/doc) | -| [`define.test()`](#define-test) | [Rstest](https://rstest.rs/zh/config/) | [`rs test`](./cli/test) | -| [`define.lint()`](#define-lint) | [Rslint](https://rslint.rs/config/) | [`rs lint`](./cli/lint) | -| [`define.fmt()`](#define-fmt) | [Prettier](https://prettier.io/docs/options) | [`rs fmt`](./cli/fmt) | -| [`define.staged()`](#define-staged) | [lint-staged](https://github.com/lint-staged/lint-staged#configuration) | [`rs staged`](./cli/staged) | +| API | 底层工具 | 对应命令 | +| ------------------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------- | +| [`define.app()`](#define-app) | [Rsbuild](https://rsbuild.rs/zh/config/) | [`rs dev`](./cli/dev)、[`rs build`](./cli/build)、[`rs preview`](./cli/preview) | +| [`define.lib()`](#define-lib) | [Rslib](https://rslib.rs/zh/config/) | [`rs lib`](./cli/lib) | +| [`define.doc()`](#define-doc) | [Rspress](https://rspress.rs/zh/api/config/config-basic) | [`rs doc`](./cli/doc) | +| [`define.test()`](#define-test) | [Rstest](https://rstest.rs/zh/config/) | [`rs test`](./cli/test) | +| [`define.lint()`](#define-lint) | [Rslint](https://rslint.rs/config/) | [`rs lint`](./cli/lint) | +| [`define.fmt()`](#define-fmt) | [Prettier](https://prettier.io/docs/options) | [`rs fmt`](./cli/fmt) | +| [`define.staged()`](#define-staged) | [lint-staged](https://github.com/lint-staged/lint-staged#configuration) | [`rs staged`](./cli/staged) | +| [`define.plugins()`](#define-plugins) | Rstack CLI | 插件命令及已配置的工具命令 | + +### `define.plugins()` \{#define-plugins} + +显式注册 Rstack CLI 插件。插件可以添加顶层命令,也可以转换下方列出的原生工具配置。API、生命周期和示例请参阅[插件](./plugins)指南。 ### `define.app()` \{#define-app} diff --git a/website/docs/zh/guide/plugins.mdx b/website/docs/zh/guide/plugins.mdx new file mode 100644 index 00000000..84b7f2b8 --- /dev/null +++ b/website/docs/zh/guide/plugins.mdx @@ -0,0 +1,194 @@ +# 插件 \{#plugins} + +Rstack 插件用于扩展整个 Rstack CLI。插件可以注册新的 `rs` 命令,也可以转换 Rstack 所编排工具的原生配置。 + +该 API 是底层工具扩展机制的补充。构建生命周期请使用 Rsbuild 或 Rslib 插件,测试结果请使用 Rstest reporter,代码检查行为请使用 Rslint 插件或其编程式 API,文档生命周期请使用 Rspress 插件。当一个包需要从统一的 Rstack 配置中注册命令,或同时为多个工具提供配置时,请使用 Rstack 插件。 + +## 注册插件 \{#registering-plugins} + +在 `rstack.config.ts` 中通过 `define.plugins()` 显式注册插件: + +```ts title="rstack.config.ts" +import { pluginAcme } from '@acme/rstack-plugin'; +import { define } from 'rstack'; + +define.plugins([pluginAcme()]); +``` + +Rstack 不会扫描依赖,也不会根据包名自动发现插件。插件条目支持嵌套、异步和条件形式;假值条目会被忽略: + +```ts title="rstack.config.ts" +import { define } from 'rstack'; + +define.plugins([ + import('@acme/rstack-plugin').then(({ pluginAcme }) => pluginAcme()), + process.env.CI ? import('@acme/rstack-ci').then(({ pluginCI }) => pluginCI()) : false, +]); +``` + +与其他 `define.*()` API 相同,每份配置文件最多调用一次 `define.plugins()`。 + +## 创建插件 \{#creating-a-plugin} + +插件包含唯一名称和一个 `setup` 函数。setup 函数会接收 Rstack 插件 API: + +```ts title="src/pluginAcme.ts" +import type { RstackPlugin } from 'rstack'; + +export const pluginAcme = (): RstackPlugin => ({ + name: 'acme', + setup(api) { + api.addCommand({ + name: 'acme-info', + async handler(args) { + await printWorkspaceInfo(api.context.cwd, args); + }, + }); + + api.modifyConfig('app', (config) => ({ + ...config, + source: { + ...config.source, + define: { + ...config.source?.define, + ACME_CHANNEL: JSON.stringify('stable'), + }, + }, + })); + + api.modifyConfig('test', (config) => ({ + ...config, + reporters: [...(config.reporters ?? []), createAcmeReporter()], + })); + }, +}); +``` + +`printWorkspaceInfo` 和 `createAcmeReporter` 由插件包自身实现;Rstack 只负责注册与配置组合。`RstackPlugin`、`RstackPluginAPI`、`RstackPluginContext`、`RstackConfigMap`、`FmtConfig`、`StagedConfig` 及其他相关类型均从 `rstack` 导出。 + +## 插件生命周期 \{#plugin-lifecycle} + +对于需要加载项目配置的命令,Rstack 按以下顺序执行: + +1. 加载 `rstack.config.*`,并捕获已注册的工具配置和插件。 +2. 按声明顺序展开插件条目。 +3. 验证全部插件,然后依次执行每个 `setup` 函数。 +4. 解析当前命令所需的原生配置函数。 +5. 依次执行匹配的配置修改器。 +6. 应用 Rstack 内部配置,并启动底层工具。 + +修改器可以修改输入对象、返回替换值,也可以返回 Promise。返回 `undefined` 时,Rstack 会保留当前配置。setup、命令处理器或修改器抛出的错误都会终止本次调用。 + +根帮助和版本输出不会加载项目配置。其他命令仅在加载配置时初始化插件。 + +## 注册命令 \{#registering-commands} + +使用 `addCommand()` 添加顶层 `rs` 命令: + +```ts +import type { RstackPlugin } from 'rstack'; + +export const pluginInspect = (): RstackPlugin => ({ + name: 'inspect', + setup({ addCommand, context, logger }) { + addCommand({ + name: 'inspect-workspace', + async handler(args) { + logger.info(`Inspecting ${context.cwd}`); + await inspectWorkspace(args); + }, + }); + }, +}); +``` + +命令名使用小写 kebab-case。插件不能替换内置命令或别名,重复的命令名也会被拒绝。 + +命令处理器接收命令名之后的参数。Rstack 会先移除已经解析的全局 `--config` 或 `-c` 选项。插件负责命令专属的参数解析和帮助输出。 + +只读的 `context` 提供: + +- `cwd`:调用 Rstack 时的工作目录。 +- `command`:当前选中的命令名。 +- `args`:setup 阶段可用的同一组命令参数。 +- `configFilePath`:解析后的 Rstack 配置路径;未找到配置文件时为 `null`。 + +## 修改工具配置 \{#modifying-tool-configurations} + +使用 `modifyConfig()` 直接贡献底层工具的原生配置。`kind` 用于选择配置类型,并为处理器保留对应的类型信息。 + +### 配置类型 \{#configuration-kinds} + +| Kind | 原生配置 | 初始化命令 | +| -------- | --------------- | ---------------------------------- | +| `app` | Rsbuild | `rs dev`、`rs build`、`rs preview` | +| `lib` | Rslib | `rs lib` | +| `doc` | Rspress | `rs doc` | +| `test` | Rstest | `rs test` | +| `lint` | Rslint | `rs lint` | +| `fmt` | Rstack/Prettier | `rs fmt` | +| `staged` | lint-staged | `rs staged` | + +匹配的工具配置函数会先解析,再执行对应的修改器。底层工具现有的命令行覆盖仍会在之后执行,因此显式 CLI 选项保持原有优先级。 + +### 修改与替换 \{#mutation-and-replacement} + +修改器按注册顺序执行,同时支持修改和替换: + +```ts +setup({ modifyConfig }) { + modifyConfig('app', (config) => { + config.plugins ??= []; + config.plugins.push(createRsbuildPlugin()); + }); + + modifyConfig('lint', async (config) => [ + ...config, + await createRslintConfig(), + ]); +} +``` + +未定义对应的 `define.*()` 值时,修改器会收到原生的空配置:`lint` 为一个空数组,其他类型为空对象。`staged` 仍会保留缺少配置的错误,除非注册了 staged 修改器。 + +### Rstest 继承 \{#rstest-inheritance} + +当 Rstest 使用 Rstack 的应用或库自动继承时,Rstack 会先应用匹配的 `app` 或 `lib` 修改器,再构造 Rstest 的 `extends` 值,最后在自动继承之后应用 `test` 修改器。因此,同一个插件既可以贡献原生构建配置,也可以贡献 Rstest reporter,而不会重复应用任一配置。 + +显式设置 Rstest `extends` 时,仍会退出应用或库的自动继承。 + +## 底层工具扩展 \{#underlying-tool-extensions} + +配置修改器不会替代原生扩展 API。它们只是在工具启动前贡献原生配置值: + +- 通过 `plugins` 字段添加 Rsbuild 或 Rslib 插件,并使用其 hook API 处理构建生命周期。 +- 通过 `reporters` 添加 Rstest reporter;独立进程需要结构化测试结果时,请使用 Rstest 编程式 API。 +- 通过 `lint` 添加 Rslint flat config 条目和插件;需要直接执行代码检查时,请使用 Rslint 编程式 API。 +- 通过 Rspress 配置添加 Rspress 插件。 +- 通过 `fmt` 添加 Prettier 插件,通过 `staged` 添加 lint-staged 任务。 + +构建 hook、测试结果、代码检查结果和文档生命周期事件仍由底层工具负责。Rstack 不会将这些结果规范化为跨工具生命周期。 + +## 配置加载与重载 \{#configuration-loading-and-reloads} + +每次加载 Rstack 配置时,插件只初始化一次。Rsbuild 的配置监听条目会在插件修改器之后添加;Rsbuild 因配置变更而重载时,Rstack 会重新加载配置并初始化一组新的插件。 + +注册只作用于当前选中的配置文件。Rstack 不会合并其他 workspace 根目录或父级配置中的插件。 + +## 兼容性 \{#compatibility} + +导出的 Rstack 插件类型和 setup 契约遵循 Rstack 的语义化版本策略。插件包应为其支持的 Rstack 版本声明 peer dependency 范围。 + +配置值仍是集成工具版本的原生类型。插件使用特定 Rsbuild、Rslib、Rstest、Rslint、Rspress、Prettier 或 lint-staged 功能时,也应遵循对应工具的兼容性说明。 + +## 非目标 \{#non-goals} + +初始 SPI 刻意不提供: + +- 自动发现或扫描依赖包; +- 替换内置命令; +- 全局命令前后 hook 或中间件; +- 插件排序描述符或插件间服务注册表; +- 跨工具的结果或事件抽象。 + +领域专属的生命周期和结果处理应使用底层工具的原生 API。 From e333b4d586743be1e33bd490d3f55505c4eb1515 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 13 Aug 2026 00:02:46 +0000 Subject: [PATCH 6/9] refactor(rstack): reuse loaded plugin runtime --- packages/rstack/src/cli/commands.ts | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/packages/rstack/src/cli/commands.ts b/packages/rstack/src/cli/commands.ts index d877183f..ee6e24a1 100644 --- a/packages/rstack/src/cli/commands.ts +++ b/packages/rstack/src/cli/commands.ts @@ -1,7 +1,5 @@ import { join, resolve } from 'node:path'; -import { logger } from 'rslog'; -import { getConfigState, loadRstackConfig } from '../config.ts'; -import { createPluginRuntime } from '../pluginRuntime.ts'; +import { getConfigState, getRstackPluginRuntime, loadRstackConfig } from '../config.ts'; import { insertConfigArg, parseArgs, parseCliArgs } from './args.ts'; import { hasHelpFlag, printCommandHelp } from './help.ts'; @@ -249,12 +247,8 @@ export async function setupCommands(): Promise { return; } - const { plugins } = await loadRstackConfig(); - const runtime = await createPluginRuntime({ - plugins, - context: state.invocation, - logger, - }); + const loaded = await loadRstackConfig(); + const runtime = await getRstackPluginRuntime(loaded); if (await runtime.runCommand(command, state.invocation.args)) { return; From 38258948f7fbc864e48925764d76625ab1429243 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 13 Aug 2026 00:08:45 +0000 Subject: [PATCH 7/9] fix(rstack): initialize plugins before config factories --- packages/rstack/src/config.ts | 5 +++- .../config/plugin-modifiers/adapters.test.ts | 12 +++++++++ .../factory-order-rstack.config.ts | 26 +++++++++++++++++++ .../tests/config/plugin-modifiers/fmt.test.ts | 8 +++++- .../config/plugin-modifiers/lint.test.ts | 17 ++++++++++++ 5 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 packages/rstack/tests/config/plugin-modifiers/factory-order-rstack.config.ts create mode 100644 packages/rstack/tests/config/plugin-modifiers/lint.test.ts diff --git a/packages/rstack/src/config.ts b/packages/rstack/src/config.ts index b6dea6d5..792bbc10 100644 --- a/packages/rstack/src/config.ts +++ b/packages/rstack/src/config.ts @@ -254,7 +254,7 @@ export const loadRstackConfig = async ({ active: true, }; - return getConfigSessionStorage().run(session, async () => { + const loaded = await getConfigSessionStorage().run(session, async () => { try { const { filePath, dependencies } = await loadConfig({ loader: 'native', @@ -292,4 +292,7 @@ export const loadRstackConfig = async ({ session.pluginsDefined = false; } }); + + await getRstackPluginRuntime(loaded); + return loaded; }; diff --git a/packages/rstack/tests/config/plugin-modifiers/adapters.test.ts b/packages/rstack/tests/config/plugin-modifiers/adapters.test.ts index 4ac88284..a1e95fac 100644 --- a/packages/rstack/tests/config/plugin-modifiers/adapters.test.ts +++ b/packages/rstack/tests/config/plugin-modifiers/adapters.test.ts @@ -16,6 +16,7 @@ const loadAppConfig = loadRsbuildConfig as (params: never) => Promise; const loadLibConfig = loadRslibConfig as (params: never) => Promise; const loadTestConfig = loadRstestConfig as (params: never) => Promise; const configPath = path.join(import.meta.dirname, 'rstack.config.ts'); +const factoryOrderConfigPath = path.join(import.meta.dirname, 'factory-order-rstack.config.ts'); const explicitConfigPath = path.join(import.meta.dirname, 'explicit-rstack.config.ts'); const projectsExplicitConfigPath = path.join( import.meta.dirname, @@ -43,6 +44,17 @@ test('constructs automatic Rstest extends before applying test modifiers', async expect(globalThis.__rstackTestModifierExtendsAppCalls).toBe(1); }); +test.each([ + ['app', () => loadAppConfig({} as never)], + ['lib', () => loadLibConfig({} as never)], + ['doc', () => loadRspressConfig()], + ['test', () => loadTestConfig({} as never)], +])('initializes plugins before resolving the %s config factory', async (_kind, loadConfig) => { + state.configPath = factoryOrderConfigPath; + + await expect(loadConfig()).resolves.toBeDefined(); +}); + test('does not apply app modifiers when all Rstest projects explicitly extend configs', async () => { state.configPath = projectsExplicitConfigPath; diff --git a/packages/rstack/tests/config/plugin-modifiers/factory-order-rstack.config.ts b/packages/rstack/tests/config/plugin-modifiers/factory-order-rstack.config.ts new file mode 100644 index 00000000..a62c72c5 --- /dev/null +++ b/packages/rstack/tests/config/plugin-modifiers/factory-order-rstack.config.ts @@ -0,0 +1,26 @@ +import { define } from 'rstack'; + +let setupComplete = false; + +const assertSetupComplete = (): Record => { + if (!setupComplete) { + throw new Error('plugin setup must run before config factories'); + } + return {}; +}; + +define.plugins([ + { + name: 'factory-order', + setup() { + setupComplete = true; + }, + }, +]); + +define.app(assertSetupComplete); +define.lib(assertSetupComplete); +define.doc(async () => assertSetupComplete()); +define.test(assertSetupComplete); +define.lint(async () => [assertSetupComplete()]); +define.fmt(assertSetupComplete); diff --git a/packages/rstack/tests/config/plugin-modifiers/fmt.test.ts b/packages/rstack/tests/config/plugin-modifiers/fmt.test.ts index 55f03ae5..0ab9f3fe 100644 --- a/packages/rstack/tests/config/plugin-modifiers/fmt.test.ts +++ b/packages/rstack/tests/config/plugin-modifiers/fmt.test.ts @@ -12,12 +12,18 @@ define.plugins([ { name: 'fmt-modifier', setup({ modifyConfig }) { + globalThis.__fmtPluginSetupComplete = true; modifyConfig('fmt', async (config) => ({ ...config, singleQuote: true })); }, }, ]); -define.fmt(() => ({})); +define.fmt(() => { + if (!globalThis.__fmtPluginSetupComplete) { + throw new Error('plugin setup must run before config factories'); + } + return {}; +}); `, ); writeProjectFile('index.ts', 'const message="hello"'); diff --git a/packages/rstack/tests/config/plugin-modifiers/lint.test.ts b/packages/rstack/tests/config/plugin-modifiers/lint.test.ts new file mode 100644 index 00000000..765624d1 --- /dev/null +++ b/packages/rstack/tests/config/plugin-modifiers/lint.test.ts @@ -0,0 +1,17 @@ +import path from 'node:path'; +import { afterEach, expect, test } from 'rstack/test'; +import { getConfigState } from '../../../src/config.ts'; + +const state = getConfigState(); + +afterEach(() => { + delete state.configPath; +}); + +test('initializes plugins before resolving the lint config factory', async () => { + state.configPath = path.join(import.meta.dirname, 'factory-order-rstack.config.ts'); + + await expect(import('../../../src/rslintConfig.ts')).resolves.toMatchObject({ + default: [{}], + }); +}); From 8ab7c6079dacfe2ce246112d8e1e3ff68edd9304 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 13 Aug 2026 00:18:52 +0000 Subject: [PATCH 8/9] refactor(rstack): simplify plugin lifecycle code --- packages/rstack/src/config.ts | 12 ++++++------ .../factory-order-rstack.config.ts | 14 +++++++------- .../tests/config/plugin-modifiers/fmt.test.ts | 6 ++++-- 3 files changed, 17 insertions(+), 15 deletions(-) diff --git a/packages/rstack/src/config.ts b/packages/rstack/src/config.ts index 792bbc10..4d2a6769 100644 --- a/packages/rstack/src/config.ts +++ b/packages/rstack/src/config.ts @@ -254,7 +254,7 @@ export const loadRstackConfig = async ({ active: true, }; - const loaded = await getConfigSessionStorage().run(session, async () => { + const loadedConfig = await getConfigSessionStorage().run(session, async () => { try { const { filePath, dependencies } = await loadConfig({ loader: 'native', @@ -277,14 +277,14 @@ export const loadRstackConfig = async ({ state.invocation.configFilePath = filePath; } - const loaded = { + const result = { configs: session.configs, plugins: session.plugins, filePath, dependencies, }; - loadedConfigDirectories.set(loaded, resolve(cwd ?? process.cwd())); - return loaded; + loadedConfigDirectories.set(result, resolve(cwd ?? process.cwd())); + return result; } finally { session.active = false; session.configs = {}; @@ -293,6 +293,6 @@ export const loadRstackConfig = async ({ } }); - await getRstackPluginRuntime(loaded); - return loaded; + await getRstackPluginRuntime(loadedConfig); + return loadedConfig; }; diff --git a/packages/rstack/tests/config/plugin-modifiers/factory-order-rstack.config.ts b/packages/rstack/tests/config/plugin-modifiers/factory-order-rstack.config.ts index a62c72c5..75a3bc0a 100644 --- a/packages/rstack/tests/config/plugin-modifiers/factory-order-rstack.config.ts +++ b/packages/rstack/tests/config/plugin-modifiers/factory-order-rstack.config.ts @@ -2,7 +2,7 @@ import { define } from 'rstack'; let setupComplete = false; -const assertSetupComplete = (): Record => { +const createConfig = () => { if (!setupComplete) { throw new Error('plugin setup must run before config factories'); } @@ -18,9 +18,9 @@ define.plugins([ }, ]); -define.app(assertSetupComplete); -define.lib(assertSetupComplete); -define.doc(async () => assertSetupComplete()); -define.test(assertSetupComplete); -define.lint(async () => [assertSetupComplete()]); -define.fmt(assertSetupComplete); +define.app(createConfig); +define.lib(createConfig); +define.doc(async () => createConfig()); +define.test(createConfig); +define.lint(async () => [createConfig()]); +define.fmt(createConfig); diff --git a/packages/rstack/tests/config/plugin-modifiers/fmt.test.ts b/packages/rstack/tests/config/plugin-modifiers/fmt.test.ts index 0ab9f3fe..a726d990 100644 --- a/packages/rstack/tests/config/plugin-modifiers/fmt.test.ts +++ b/packages/rstack/tests/config/plugin-modifiers/fmt.test.ts @@ -8,18 +8,20 @@ test('applies fmt modifiers after resolving the native config definition', () => 'rstack.config.ts', `import { define } from 'rstack'; +let setupComplete = false; + define.plugins([ { name: 'fmt-modifier', setup({ modifyConfig }) { - globalThis.__fmtPluginSetupComplete = true; + setupComplete = true; modifyConfig('fmt', async (config) => ({ ...config, singleQuote: true })); }, }, ]); define.fmt(() => { - if (!globalThis.__fmtPluginSetupComplete) { + if (!setupComplete) { throw new Error('plugin setup must run before config factories'); } return {}; From 949e3e111fb9ddc59997648f100986a4dae9c21c Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 13 Aug 2026 00:26:55 +0000 Subject: [PATCH 9/9] refactor(rstack): clarify automatic test config resolution --- packages/rstack/src/rstestConfig.ts | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/packages/rstack/src/rstestConfig.ts b/packages/rstack/src/rstestConfig.ts index ec25209e..97bad605 100644 --- a/packages/rstack/src/rstestConfig.ts +++ b/packages/rstack/src/rstestConfig.ts @@ -21,14 +21,11 @@ const resolveAutomaticExtends = async ( /* rspackChunkName: 'adapterRsbuild' */ '@rstest/adapter-rsbuild' ); + const resolvedConfig = typeof appConfig === 'function' ? await appConfig(params) : appConfig; const config = await applyRstackConfigModifiers( loaded, 'app', - appConfig === undefined - ? {} - : typeof appConfig === 'function' - ? await appConfig(params) - : appConfig, + resolvedConfig === undefined ? {} : resolvedConfig, ); return withRsbuildConfig({ @@ -42,14 +39,11 @@ const resolveAutomaticExtends = async ( /* rspackChunkName: 'adapterRslib' */ '@rstest/adapter-rslib' ); + const resolvedConfig = typeof libConfig === 'function' ? await libConfig(params) : libConfig; const config = await applyRstackConfigModifiers( loaded, 'lib', - libConfig === undefined - ? {} - : typeof libConfig === 'function' - ? await libConfig(params) - : libConfig, + resolvedConfig === undefined ? {} : resolvedConfig, ); return withRslibConfig({