diff --git a/common/changes/@microsoft/rush/feature-add-remainder-args_2025-10-08-23-54.json b/common/changes/@microsoft/rush/feature-add-remainder-args_2025-10-08-23-54.json new file mode 100644 index 00000000000..b84438cf259 --- /dev/null +++ b/common/changes/@microsoft/rush/feature-add-remainder-args_2025-10-08-23-54.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Add support for remainder arguments with new `allowRemainderArguments` option in a global, bulk, and phased command configurations in `common/config/rush/command-line.json`", + "type": "none" + } + ], + "packageName": "@microsoft/rush" +} \ No newline at end of file diff --git a/common/changes/@rushstack/hashed-folder-copy-plugin/feature-add-remainder-args_2025-10-08-23-54.json b/common/changes/@rushstack/hashed-folder-copy-plugin/feature-add-remainder-args_2025-10-08-23-54.json new file mode 100644 index 00000000000..e8c42a34e96 --- /dev/null +++ b/common/changes/@rushstack/hashed-folder-copy-plugin/feature-add-remainder-args_2025-10-08-23-54.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/hashed-folder-copy-plugin", + "comment": "", + "type": "none" + } + ], + "packageName": "@rushstack/hashed-folder-copy-plugin" +} \ No newline at end of file diff --git a/common/changes/@rushstack/heft-webpack5-plugin/feature-add-remainder-args_2025-11-14-18-31.json b/common/changes/@rushstack/heft-webpack5-plugin/feature-add-remainder-args_2025-11-14-18-31.json new file mode 100644 index 00000000000..ef498d5ac8b --- /dev/null +++ b/common/changes/@rushstack/heft-webpack5-plugin/feature-add-remainder-args_2025-11-14-18-31.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "comment": "", + "type": "none", + "packageName": "@rushstack/heft-webpack5-plugin" + } + ], + "packageName": "@rushstack/heft-webpack5-plugin", + "email": "sdotson@users.noreply.github.com" +} \ No newline at end of file diff --git a/common/changes/@rushstack/ts-command-line/feature-add-remainder-args_2025-10-16-14-00.json b/common/changes/@rushstack/ts-command-line/feature-add-remainder-args_2025-10-16-14-00.json new file mode 100644 index 00000000000..973dfb74d6c --- /dev/null +++ b/common/changes/@rushstack/ts-command-line/feature-add-remainder-args_2025-10-16-14-00.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/ts-command-line", + "comment": "This change introduces enhanced support for remainder arguments in the CommandLineRemainder class. The -- separator used to delimit remainder arguments is now automatically excluded from the values array. For example, my-tool --flag -- arg1 arg2 will result in values being [\"arg1\", \"arg2\"], not [\"--\", \"arg1\", \"arg2\"].", + "type": "patch" + } + ], + "packageName": "@rushstack/ts-command-line" +} \ No newline at end of file diff --git a/common/reviews/api/rush-lib.api.md b/common/reviews/api/rush-lib.api.md index cf0b56b9abb..f8a38a0d41d 100644 --- a/common/reviews/api/rush-lib.api.md +++ b/common/reviews/api/rush-lib.api.md @@ -415,6 +415,7 @@ export interface ICreateOperationsContext { readonly projectConfigurations: ReadonlyMap; readonly projectSelection: ReadonlySet; readonly projectsInUnknownState: ReadonlySet; + readonly remainderArgs?: ReadonlyArray; readonly rushConfiguration: RushConfiguration; } diff --git a/heft-plugins/heft-rspack-plugin/tsconfig.json b/heft-plugins/heft-rspack-plugin/tsconfig.json index e64ab1d2405..b3ad1222789 100644 --- a/heft-plugins/heft-rspack-plugin/tsconfig.json +++ b/heft-plugins/heft-rspack-plugin/tsconfig.json @@ -1,10 +1,8 @@ { "extends": "./node_modules/local-node-rig/profiles/default/tsconfig-base.json", "compilerOptions": { - "lib": [ - "DOM" - ], + "lib": ["DOM"], "module": "nodenext", "moduleResolution": "nodenext" } -} \ No newline at end of file +} diff --git a/heft-plugins/heft-webpack5-plugin/src/Webpack5Plugin.ts b/heft-plugins/heft-webpack5-plugin/src/Webpack5Plugin.ts index bd42b10cbe7..796302e4cf7 100644 --- a/heft-plugins/heft-webpack5-plugin/src/Webpack5Plugin.ts +++ b/heft-plugins/heft-webpack5-plugin/src/Webpack5Plugin.ts @@ -142,7 +142,9 @@ export default class Webpack5Plugin implements IHeftTaskPlugin { expect(phase.shellCommand).toEqual('echo'); }); }); + + describe('allowRemainderArguments configuration', () => { + it('should accept allowRemainderArguments for bulk commands', () => { + const commandLineConfiguration: CommandLineConfiguration = new CommandLineConfiguration({ + commands: [ + { + commandKind: 'bulk', + name: 'test-remainder-bulk', + summary: 'Test bulk command with remainder arguments', + enableParallelism: true, + safeForSimultaneousRushProcesses: false, + allowRemainderArguments: true + } + ] + }); + + const command = commandLineConfiguration.commands.get('test-remainder-bulk'); + expect(command).toBeDefined(); + expect(command?.allowRemainderArguments).toBe(true); + }); + + it('should accept allowRemainderArguments for global commands', () => { + const commandLineConfiguration: CommandLineConfiguration = new CommandLineConfiguration({ + commands: [ + { + commandKind: 'global', + name: 'test-remainder-global', + summary: 'Test global command with remainder arguments', + shellCommand: 'echo', + safeForSimultaneousRushProcesses: false, + allowRemainderArguments: true + } + ] + }); + + const command = commandLineConfiguration.commands.get('test-remainder-global'); + expect(command).toBeDefined(); + expect(command?.allowRemainderArguments).toBe(true); + }); + + it('should accept allowRemainderArguments for phased commands', () => { + const commandLineConfiguration: CommandLineConfiguration = new CommandLineConfiguration({ + commands: [ + { + commandKind: 'phased', + name: 'test-remainder-phased', + summary: 'Test phased command with remainder arguments', + enableParallelism: true, + safeForSimultaneousRushProcesses: false, + phases: ['_phase:test'], + allowRemainderArguments: true + } + ], + phases: [ + { + name: '_phase:test' + } + ] + }); + + const command = commandLineConfiguration.commands.get('test-remainder-phased'); + expect(command).toBeDefined(); + expect(command?.allowRemainderArguments).toBe(true); + }); + + it('should default allowRemainderArguments to false when not specified', () => { + const commandLineConfiguration: CommandLineConfiguration = new CommandLineConfiguration({ + commands: [ + { + commandKind: 'global', + name: 'test-no-remainder', + summary: 'Test command without remainder arguments', + shellCommand: 'echo', + safeForSimultaneousRushProcesses: false + } + ] + }); + + const command = commandLineConfiguration.commands.get('test-no-remainder'); + expect(command).toBeDefined(); + expect(command?.allowRemainderArguments).toBeUndefined(); + }); + + it('should work with both custom parameters and remainder arguments', () => { + const commandLineConfiguration: CommandLineConfiguration = new CommandLineConfiguration({ + commands: [ + { + commandKind: 'global', + name: 'test-mixed-params', + summary: 'Test command with both custom parameters and remainder arguments', + shellCommand: 'echo', + safeForSimultaneousRushProcesses: false, + allowRemainderArguments: true + } + ], + parameters: [ + { + parameterKind: 'flag', + longName: '--verbose', + associatedCommands: ['test-mixed-params'], + description: 'Enable verbose logging' + }, + { + parameterKind: 'string', + longName: '--output', + argumentName: 'PATH', + associatedCommands: ['test-mixed-params'], + description: 'Output file path' + }, + { + parameterKind: 'integer', + longName: '--count', + argumentName: 'NUM', + associatedCommands: ['test-mixed-params'], + description: 'Number of iterations' + } + ] + }); + + const command = commandLineConfiguration.commands.get('test-mixed-params'); + expect(command).toBeDefined(); + expect(command?.allowRemainderArguments).toBe(true); + expect(command?.associatedParameters.size).toBe(3); + }); + }); }); diff --git a/libraries/rush-lib/src/cli/scriptActions/BaseScriptAction.ts b/libraries/rush-lib/src/cli/scriptActions/BaseScriptAction.ts index 3d22215e517..81339e8b579 100644 --- a/libraries/rush-lib/src/cli/scriptActions/BaseScriptAction.ts +++ b/libraries/rush-lib/src/cli/scriptActions/BaseScriptAction.ts @@ -41,6 +41,14 @@ export abstract class BaseScriptAction extends BaseRus return; } + // Define remainder parameter if the command allows it + if (this.command.allowRemainderArguments) { + this.defineCommandLineRemainder({ + description: + 'Additional command-line arguments to be passed through to the shell command or npm script' + }); + } + // Use the centralized helper to create CommandLineParameter instances defineCustomParameters(this, this.command.associatedParameters, this.customParameters); } diff --git a/libraries/rush-lib/src/cli/scriptActions/GlobalScriptAction.ts b/libraries/rush-lib/src/cli/scriptActions/GlobalScriptAction.ts index 9b35156f188..7cd6996759c 100644 --- a/libraries/rush-lib/src/cli/scriptActions/GlobalScriptAction.ts +++ b/libraries/rush-lib/src/cli/scriptActions/GlobalScriptAction.ts @@ -135,6 +135,11 @@ export class GlobalScriptAction extends BaseScriptAction { tsCommandLineParameter.appendToArgList(customParameterValues); } + // Add remainder arguments if they exist + if (this.remainder) { + this.remainder.appendToArgList(customParameterValues); + } + for (let i: number = 0; i < customParameterValues.length; i++) { let customParameterValue: string = customParameterValues[i]; customParameterValue = customParameterValue.replace(/"/g, '\\"'); diff --git a/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts b/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts index c5f19629af5..4a26c2657d2 100644 --- a/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts +++ b/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts @@ -558,6 +558,7 @@ export class PhasedScriptAction extends BaseScriptAction i changedProjectsOnly, cobuildConfiguration, customParameters: customParametersByName, + remainderArgs: this.remainder?.values, isIncrementalBuildAllowed: this._isIncrementalBuildAllowed, isInitial: true, isWatch, diff --git a/libraries/rush-lib/src/logic/operations/IPCOperationRunnerPlugin.ts b/libraries/rush-lib/src/logic/operations/IPCOperationRunnerPlugin.ts index f87dcb1685a..1a30204e130 100644 --- a/libraries/rush-lib/src/logic/operations/IPCOperationRunnerPlugin.ts +++ b/libraries/rush-lib/src/logic/operations/IPCOperationRunnerPlugin.ts @@ -38,7 +38,7 @@ export class IPCOperationRunnerPlugin implements IPhasedCommandPlugin { before: ShellOperationPluginName }, async (operations: Set, context: ICreateOperationsContext) => { - const { isWatch, isInitial } = context; + const { isWatch, isInitial, remainderArgs } = context; if (!isWatch) { return operations; } @@ -46,7 +46,7 @@ export class IPCOperationRunnerPlugin implements IPhasedCommandPlugin { currentContext = context; const getCustomParameterValues: (operation: Operation) => ICustomParameterValuesForOperation = - getCustomParameterValuesByOperation(); + getCustomParameterValuesByOperation(remainderArgs); for (const operation of operations) { const { associatedPhase: phase, associatedProject: project, runner } = operation; diff --git a/libraries/rush-lib/src/logic/operations/ShardedPhaseOperationPlugin.ts b/libraries/rush-lib/src/logic/operations/ShardedPhaseOperationPlugin.ts index b4f017dfe3f..19bdcbecf91 100644 --- a/libraries/rush-lib/src/logic/operations/ShardedPhaseOperationPlugin.ts +++ b/libraries/rush-lib/src/logic/operations/ShardedPhaseOperationPlugin.ts @@ -44,10 +44,10 @@ export class ShardedPhasedOperationPlugin implements IPhasedCommandPlugin { } function spliceShards(existingOperations: Set, context: ICreateOperationsContext): Set { - const { rushConfiguration, projectConfigurations } = context; + const { rushConfiguration, projectConfigurations, remainderArgs } = context; const getCustomParameterValues: (operation: Operation) => ICustomParameterValuesForOperation = - getCustomParameterValuesByOperation(); + getCustomParameterValuesByOperation(remainderArgs); for (const operation of existingOperations) { const { diff --git a/libraries/rush-lib/src/logic/operations/ShellOperationRunnerPlugin.ts b/libraries/rush-lib/src/logic/operations/ShellOperationRunnerPlugin.ts index e48a52482f6..d40418403c5 100644 --- a/libraries/rush-lib/src/logic/operations/ShellOperationRunnerPlugin.ts +++ b/libraries/rush-lib/src/logic/operations/ShellOperationRunnerPlugin.ts @@ -29,10 +29,10 @@ export class ShellOperationRunnerPlugin implements IPhasedCommandPlugin { operations: Set, context: ICreateOperationsContext ): Set { - const { rushConfiguration, isInitial } = context; + const { rushConfiguration, isInitial, remainderArgs } = context; const getCustomParameterValues: (operation: Operation) => ICustomParameterValuesForOperation = - getCustomParameterValuesByOperation(); + getCustomParameterValuesByOperation(remainderArgs); for (const operation of operations) { const { associatedPhase: phase, associatedProject: project } = operation; @@ -138,11 +138,17 @@ export interface ICustomParameterValuesForOperation { /** * Helper function to collect all parameter arguments for a phase */ -function collectPhaseParameterArguments(phase: IPhase): string[] { +function collectPhaseParameterArguments(phase: IPhase, remainderArgs?: ReadonlyArray): string[] { const customParameterList: string[] = []; for (const tsCommandLineParameter of phase.associatedParameters) { tsCommandLineParameter.appendToArgList(customParameterList); } + + // Add remainder arguments if they exist + if (remainderArgs && remainderArgs.length > 0) { + customParameterList.push(...remainderArgs); + } + return customParameterList; } @@ -169,11 +175,12 @@ export function getCustomParameterValuesByPhase(): (phase: IPhase) => ReadonlyAr /** * Gets custom parameter values for an operation, filtering out any parameters that should be ignored * based on the operation's settings. + * @param remainderArgs - Optional remainder arguments to append to parameter values * @returns A function that returns the filtered custom parameter values and ignored parameter values for a given operation */ -export function getCustomParameterValuesByOperation(): ( - operation: Operation -) => ICustomParameterValuesForOperation { +export function getCustomParameterValuesByOperation( + remainderArgs?: ReadonlyArray +): (operation: Operation) => ICustomParameterValuesForOperation { const customParametersByPhase: Map = new Map(); function getCustomParameterValuesForOp(operation: Operation): ICustomParameterValuesForOperation { @@ -185,7 +192,7 @@ export function getCustomParameterValuesByOperation(): ( // No filtering needed - use the cached parameter list for efficiency let customParameterList: string[] | undefined = customParametersByPhase.get(phase); if (!customParameterList) { - customParameterList = collectPhaseParameterArguments(phase); + customParameterList = collectPhaseParameterArguments(phase, remainderArgs); customParametersByPhase.set(phase, customParameterList); } @@ -210,6 +217,11 @@ export function getCustomParameterValuesByOperation(): ( ); } + // Add remainder arguments to the filtered values (they can't be ignored individually) + if (remainderArgs && remainderArgs.length > 0) { + filteredParameterValues.push(...remainderArgs); + } + return { parameterValues: filteredParameterValues, ignoredParameterValues diff --git a/libraries/rush-lib/src/logic/operations/test/ShellOperationRunnerPlugin.test.ts b/libraries/rush-lib/src/logic/operations/test/ShellOperationRunnerPlugin.test.ts index 818144e85df..17b0b417d12 100644 --- a/libraries/rush-lib/src/logic/operations/test/ShellOperationRunnerPlugin.test.ts +++ b/libraries/rush-lib/src/logic/operations/test/ShellOperationRunnerPlugin.test.ts @@ -275,4 +275,65 @@ describe(ShellOperationRunnerPlugin.name, () => { // All projects snapshot expect(Array.from(operations, serializeOperation)).toMatchSnapshot(); }); + + it('should handle remainderArgs when provided in context', async () => { + const rushJsonFile: string = path.resolve(__dirname, `../../test/customShellCommandinBulkRepo/rush.json`); + const commandLineJsonFile: string = path.resolve( + __dirname, + `../../test/customShellCommandinBulkRepo/common/config/rush/command-line.json` + ); + + const rushConfiguration = RushConfiguration.loadFromConfigurationFile(rushJsonFile); + const commandLineJson: ICommandLineJson = JsonFile.load(commandLineJsonFile); + + const commandLineConfiguration = new CommandLineConfiguration(commandLineJson); + + const echoCommand: IPhasedCommandConfig = commandLineConfiguration.commands.get( + 'echo' + )! as IPhasedCommandConfig; + + // Create context with remainder arguments + const fakeCreateOperationsContext: Pick< + ICreateOperationsContext, + | 'phaseOriginal' + | 'phaseSelection' + | 'projectSelection' + | 'projectsInUnknownState' + | 'projectConfigurations' + | 'rushConfiguration' + | 'remainderArgs' + > = { + phaseOriginal: echoCommand.phases, + phaseSelection: echoCommand.phases, + projectSelection: new Set(rushConfiguration.projects), + projectsInUnknownState: new Set(rushConfiguration.projects), + projectConfigurations: new Map(), + rushConfiguration, + remainderArgs: ['--verbose', '--output', 'file.log'] + }; + + const hooks: PhasedCommandHooks = new PhasedCommandHooks(); + + // Generates the default operation graph + new PhasedOperationPlugin().apply(hooks); + // Applies the Shell Operation Runner to selected operations + new ShellOperationRunnerPlugin().apply(hooks); + + const operations: Set = await hooks.createOperations.promise( + new Set(), + fakeCreateOperationsContext as ICreateOperationsContext + ); + + // Verify that operations were created and include remainder args in config hash + expect(operations.size).toBeGreaterThan(0); + + // Get the first operation and check that remainder args affect the command configuration + const operation = Array.from(operations)[0]; + const configHash = operation.runner!.getConfigHash(); + + // The config hash should include the remainder arguments + expect(configHash).toContain('--verbose'); + expect(configHash).toContain('--output'); + expect(configHash).toContain('file.log'); + }); }); diff --git a/libraries/rush-lib/src/pluginFramework/PhasedCommandHooks.ts b/libraries/rush-lib/src/pluginFramework/PhasedCommandHooks.ts index 97ccdb064aa..a29c7d4685d 100644 --- a/libraries/rush-lib/src/pluginFramework/PhasedCommandHooks.ts +++ b/libraries/rush-lib/src/pluginFramework/PhasedCommandHooks.ts @@ -64,6 +64,11 @@ export interface ICreateOperationsContext { * Maps from the `longName` field in command-line.json to the parser configuration in ts-command-line. */ readonly customParameters: ReadonlyMap; + /** + * The remainder arguments from the command line, if any. + * These are additional arguments that were not recognized as regular parameters. + */ + readonly remainderArgs?: ReadonlyArray; /** * If true, projects may read their output from cache or be skipped if already up to date. * If false, neither of the above may occur, e.g. "rush rebuild" diff --git a/libraries/rush-lib/src/schemas/command-line.schema.json b/libraries/rush-lib/src/schemas/command-line.schema.json index 0091e7bb7ae..84b7da939bc 100644 --- a/libraries/rush-lib/src/schemas/command-line.schema.json +++ b/libraries/rush-lib/src/schemas/command-line.schema.json @@ -100,6 +100,11 @@ "title": "Disable build cache.", "description": "Disable build cache for this action. This may be useful if this command affects state outside of projects' own folders. If the build cache is not configured, this also disables the legacy skip detection logic.", "type": "boolean" + }, + "allowRemainderArguments": { + "title": "Allow Remainder Arguments", + "description": "If true, this command will accept additional command-line arguments that appear after the \"--\" separator. Everything after \"--\" (not including the \"--\" itself) will be passed through to the shell command or npm script. Any unrecognized arguments before \"--\" will still cause an error. Example: \"rush my-command --known-flag value -- --arbitrary --args here\" will pass \"--arbitrary --args here\" to the script, but \"rush my-command --unknown-flag\" will fail with an error about the unrecognized parameter.", + "type": "boolean" } } }, @@ -121,7 +126,8 @@ "incremental": { "$ref": "#/definitions/anything" }, "allowWarningsInSuccessfulBuild": { "$ref": "#/definitions/anything" }, "watchForChanges": { "$ref": "#/definitions/anything" }, - "disableBuildCache": { "$ref": "#/definitions/anything" } + "disableBuildCache": { "$ref": "#/definitions/anything" }, + "allowRemainderArguments": { "$ref": "#/definitions/anything" } } } ] @@ -149,6 +155,11 @@ "title": "Autoinstaller Name", "description": "If your \"shellCommand\" script depends on NPM packages, the recommended best practice is to make it into a regular Rush project that builds using your normal toolchain. In cases where the command needs to work without first having to run \"rush build\", the recommended practice is to publish the project to an NPM registry and use common/scripts/install-run.js to launch it.\n\nAutoinstallers offer another possibility: They are folders under \"common/autoinstallers\" with a package.json file and shrinkwrap file. Rush will automatically invoke the package manager to install these dependencies before an associated command is invoked. Autoinstallers have the advantage that they work even in a branch where \"rush install\" is broken, which makes them a good solution for Git hook scripts. But they have the disadvantages of not being buildable projects, and of increasing the overall installation footprint for your monorepo.\n\nThe \"autoinstallerName\" setting must not contain a path and must be a valid NPM package name.\n\nFor example, the name \"my-task\" would map to \"common/autoinstallers/my-task/package.json\", and the \"common/autoinstallers/my-task/node_modules/.bin\" folder would be added to the shell PATH when invoking the \"shellCommand\".", "type": "string" + }, + "allowRemainderArguments": { + "title": "Allow Remainder Arguments", + "description": "If true, this command will accept additional command-line arguments that appear after the \"--\" separator. Everything after \"--\" (not including the \"--\" itself) will be passed through to the shell command. Any unrecognized arguments before \"--\" will still cause an error. Example: \"rush my-command --known-flag value -- --arbitrary --args here\" will pass \"--arbitrary --args here\" to the script, but \"rush my-command --unknown-flag\" will fail with an error about the unrecognized parameter.", + "type": "boolean" } } }, @@ -163,7 +174,8 @@ "safeForSimultaneousRushProcesses": { "$ref": "#/definitions/anything" }, "shellCommand": { "$ref": "#/definitions/anything" }, - "autoinstallerName": { "$ref": "#/definitions/anything" } + "autoinstallerName": { "$ref": "#/definitions/anything" }, + "allowRemainderArguments": { "$ref": "#/definitions/anything" } } } ] @@ -250,6 +262,11 @@ "type": "boolean" } } + }, + "allowRemainderArguments": { + "title": "Allow Remainder Arguments", + "description": "If true, this command will accept additional command-line arguments that appear after the \"--\" separator. Everything after \"--\" (not including the \"--\" itself) will be passed through to the phase scripts. Any unrecognized arguments before \"--\" will still cause an error. Example: \"rush my-command --known-flag value -- --arbitrary --args here\" will pass \"--arbitrary --args here\" to the phase scripts, but \"rush my-command --unknown-flag\" will fail with an error about the unrecognized parameter.", + "type": "boolean" } } }, @@ -268,7 +285,8 @@ "incremental": { "$ref": "#/definitions/anything" }, "phases": { "$ref": "#/definitions/anything" }, "watchOptions": { "$ref": "#/definitions/anything" }, - "installOptions": { "$ref": "#/definitions/anything" } + "installOptions": { "$ref": "#/definitions/anything" }, + "allowRemainderArguments": { "$ref": "#/definitions/anything" } } } ] diff --git a/libraries/ts-command-line/src/parameters/CommandLineRemainder.ts b/libraries/ts-command-line/src/parameters/CommandLineRemainder.ts index 06e1106632a..26aee1367a0 100644 --- a/libraries/ts-command-line/src/parameters/CommandLineRemainder.ts +++ b/libraries/ts-command-line/src/parameters/CommandLineRemainder.ts @@ -24,6 +24,10 @@ export class CommandLineRemainder { * * @remarks * The array will be empty if the command-line has not been parsed yet. + * + * When the `--` separator is used to delimit remainder arguments, it is automatically + * excluded from this array. For example, `my-tool --flag -- arg1 arg2` will result in + * `values` being `["arg1", "arg2"]`, not `["--", "arg1", "arg2"]`. */ public get values(): ReadonlyArray { return this._values; @@ -39,7 +43,18 @@ export class CommandLineRemainder { throw new Error(`Unexpected data object for remainder: ` + JSON.stringify(data)); } - this._values.push(...data); + // Filter out the first '--' separator that argparse includes in the remainder values. + // Users expect everything AFTER '--' to be passed through, not including '--' itself. + // However, if '--' appears again later, it should be preserved in case the underlying + // tool needs it for its own purposes. + const firstSeparatorIndex: number = data.indexOf('--'); + if (firstSeparatorIndex !== -1) { + // Remove the first '--' and keep everything after it + this._values.push(...data.slice(firstSeparatorIndex + 1)); + } else { + // No separator found, push all data + this._values.push(...data); + } } /** {@inheritDoc CommandLineParameterBase.appendToArgList} @override */ diff --git a/libraries/ts-command-line/src/providers/ScopedCommandLineAction.ts b/libraries/ts-command-line/src/providers/ScopedCommandLineAction.ts index a75d582142f..7c533f6ea04 100644 --- a/libraries/ts-command-line/src/providers/ScopedCommandLineAction.ts +++ b/libraries/ts-command-line/src/providers/ScopedCommandLineAction.ts @@ -4,7 +4,6 @@ import { SCOPING_PARAMETER_GROUP } from '../Constants'; import { CommandLineAction, type ICommandLineActionOptions } from './CommandLineAction'; import { CommandLineParser, type ICommandLineParserOptions } from './CommandLineParser'; -import { CommandLineParserExitError } from './CommandLineParserExitError'; import type { CommandLineParameter } from '../parameters/BaseClasses'; import type { CommandLineParameterProvider, @@ -180,23 +179,9 @@ export abstract class ScopedCommandLineAction extends CommandLineAction { throw new Error('Parameters must be defined before execution.'); } - // The '--' argument is required to separate the action parameters from the scoped parameters, - // so it needs to be trimmed. If remainder values are provided but no '--' is found, then throw. - const scopedArgs: string[] = []; - if (this.remainder.values.length) { - if (this.remainder.values[0] !== '--') { - throw new CommandLineParserExitError( - // argparse sets exit code 2 for invalid arguments - 2, - // model the message off of the built-in "unrecognized arguments" message - `${this.renderUsageText()}\n${this._unscopedParserOptions.toolFilename} ${this.actionName}: ` + - `error: Unrecognized arguments: ${this.remainder.values[0]}.\n` - ); - } - for (const scopedArg of this.remainder.values.slice(1)) { - scopedArgs.push(scopedArg); - } - } + // The remainder values now have the '--' separator already filtered out by CommandLineRemainder._setValue(). + // All values in remainder are scoped arguments that should be passed to the scoped parser. + const scopedArgs: string[] = [...this.remainder.values]; // Call the scoped parser using only the scoped args to handle parsing await this._scopedCommandLineParser.executeWithoutErrorHandlingAsync(scopedArgs); diff --git a/libraries/ts-command-line/src/test/CommandLineRemainder.test.ts b/libraries/ts-command-line/src/test/CommandLineRemainder.test.ts deleted file mode 100644 index e6d08dabe2a..00000000000 --- a/libraries/ts-command-line/src/test/CommandLineRemainder.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. -// See LICENSE in the project root for license information. - -import type { CommandLineAction } from '../providers/CommandLineAction'; -import type { CommandLineParser } from '../providers/CommandLineParser'; -import { DynamicCommandLineParser } from '../providers/DynamicCommandLineParser'; -import { DynamicCommandLineAction } from '../providers/DynamicCommandLineAction'; -import { CommandLineRemainder } from '../parameters/CommandLineRemainder'; -import { ensureHelpTextMatchesSnapshot } from './helpTestUtilities'; - -function createParser(): DynamicCommandLineParser { - const commandLineParser: DynamicCommandLineParser = new DynamicCommandLineParser({ - toolFilename: 'example', - toolDescription: 'An example project' - }); - commandLineParser.defineFlagParameter({ - parameterLongName: '--verbose', - description: 'A flag that affects all actions' - }); - - const action: DynamicCommandLineAction = new DynamicCommandLineAction({ - actionName: 'run', - summary: 'does the job', - documentation: 'a longer description' - }); - commandLineParser.addAction(action); - - action.defineStringParameter({ - parameterLongName: '--title', - description: 'A string', - argumentName: 'TEXT' - }); - - // Although this is defined BEFORE the parameter, but it should still capture the end - action.defineCommandLineRemainder({ - description: 'The action remainder' - }); - - commandLineParser._registerDefinedParameters({ parentParameterNames: new Set() }); - - return commandLineParser; -} - -describe(CommandLineRemainder.name, () => { - it('renders help text', () => { - const commandLineParser: CommandLineParser = createParser(); - ensureHelpTextMatchesSnapshot(commandLineParser); - }); - - it('parses an action input with remainder', async () => { - const commandLineParser: CommandLineParser = createParser(); - const action: CommandLineAction = commandLineParser.getAction('run'); - const args: string[] = ['run', '--title', 'The title', 'the', 'remaining', 'args']; - - await commandLineParser.executeAsync(args); - - expect(commandLineParser.selectedAction).toBe(action); - - const copiedArgs: string[] = []; - for (const parameter of action.parameters) { - copiedArgs.push(`### ${parameter.longName} output: ###`); - parameter.appendToArgList(copiedArgs); - } - - copiedArgs.push(`### remainder output: ###`); - action.remainder!.appendToArgList(copiedArgs); - - expect(copiedArgs).toMatchSnapshot(); - }); - - it('parses an action input with remainder flagged options', async () => { - const commandLineParser: CommandLineParser = createParser(); - const action: CommandLineAction = commandLineParser.getAction('run'); - const args: string[] = ['run', '--title', 'The title', '--', '--the', 'remaining', '--args']; - - await commandLineParser.executeAsync(args); - - expect(commandLineParser.selectedAction).toBe(action); - - const copiedArgs: string[] = []; - for (const parameter of action.parameters) { - copiedArgs.push(`### ${parameter.longName} output: ###`); - parameter.appendToArgList(copiedArgs); - } - - copiedArgs.push(`### remainder output: ###`); - action.remainder!.appendToArgList(copiedArgs); - - expect(copiedArgs).toMatchSnapshot(); - }); -}); diff --git a/libraries/ts-command-line/src/test/__snapshots__/CommandLineRemainder.test.ts.snap b/libraries/ts-command-line/src/test/__snapshots__/CommandLineRemainder.test.ts.snap deleted file mode 100644 index 9205cbc2f2f..00000000000 --- a/libraries/ts-command-line/src/test/__snapshots__/CommandLineRemainder.test.ts.snap +++ /dev/null @@ -1,57 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`CommandLineRemainder parses an action input with remainder 1`] = ` -Array [ - "### --title output: ###", - "--title", - "The title", - "### remainder output: ###", - "the", - "remaining", - "args", -] -`; - -exports[`CommandLineRemainder parses an action input with remainder flagged options 1`] = ` -Array [ - "### --title output: ###", - "--title", - "The title", - "### remainder output: ###", - "--", - "--the", - "remaining", - "--args", -] -`; - -exports[`CommandLineRemainder renders help text: global help 1`] = ` -"usage: example [-h] [--verbose] ... - -An example project - -Positional arguments: - - run does the job - -Optional arguments: - -h, --help Show this help message and exit. - --verbose A flag that affects all actions - -[bold]For detailed help about a specific command, use: example -h[normal] -" -`; - -exports[`CommandLineRemainder renders help text: run 1`] = ` -"usage: example run [-h] [--title TEXT] ... - -a longer description - -Positional arguments: - \\"...\\" The action remainder - -Optional arguments: - -h, --help Show this help message and exit. - --title TEXT A string -" -`; diff --git a/libraries/ts-command-line/src/test/__snapshots__/ScopedCommandLineAction.test.ts.snap b/libraries/ts-command-line/src/test/__snapshots__/ScopedCommandLineAction.test.ts.snap index 30828b18fb2..985a8ffedff 100644 --- a/libraries/ts-command-line/src/test/__snapshots__/ScopedCommandLineAction.test.ts.snap +++ b/libraries/ts-command-line/src/test/__snapshots__/ScopedCommandLineAction.test.ts.snap @@ -93,9 +93,7 @@ Optional scoping arguments: `; exports[`CommandLineParser throws on missing positional arg divider with unknown positional args 1`] = ` -"usage: example scoped-action [-h] [--verbose] [--scope SCOPE] ... - -example scoped-action: error: Unrecognized arguments: bar. +"example scoped-action --scope foo --: error: Unrecognized arguments: bar. " `; diff --git a/repo-scripts/repo-toolbox/src/cli/actions/CollectJsonSchemasAction.ts b/repo-scripts/repo-toolbox/src/cli/actions/CollectJsonSchemasAction.ts index cdf38cb377a..1c39843eba2 100644 --- a/repo-scripts/repo-toolbox/src/cli/actions/CollectJsonSchemasAction.ts +++ b/repo-scripts/repo-toolbox/src/cli/actions/CollectJsonSchemasAction.ts @@ -82,22 +82,26 @@ export class CollectJsonSchemasAction extends CommandLineAction { `${projectFolder}/temp/json-schemas`, '' ); - await Async.forEachAsync(schemaFiles, async ({ absolutePath, relativePath, content }) => { - let contentByAbsolutePath: Map | undefined = - contentByAbsolutePathByRelativePath.get(relativePath); - if (!contentByAbsolutePath) { - contentByAbsolutePath = new Map(); - contentByAbsolutePathByRelativePath.set(relativePath, contentByAbsolutePath); - } - - let absolutePaths: string[] | undefined = contentByAbsolutePath.get(content); - if (!absolutePaths) { - absolutePaths = []; - contentByAbsolutePath.set(content, absolutePaths); - } - - absolutePaths.push(absolutePath); - }, { concurrency: 5 }); + await Async.forEachAsync( + schemaFiles, + async ({ absolutePath, relativePath, content }) => { + let contentByAbsolutePath: Map | undefined = + contentByAbsolutePathByRelativePath.get(relativePath); + if (!contentByAbsolutePath) { + contentByAbsolutePath = new Map(); + contentByAbsolutePathByRelativePath.set(relativePath, contentByAbsolutePath); + } + + let absolutePaths: string[] | undefined = contentByAbsolutePath.get(content); + if (!absolutePaths) { + absolutePaths = []; + contentByAbsolutePath.set(content, absolutePaths); + } + + absolutePaths.push(absolutePath); + }, + { concurrency: 5 } + ); }, { concurrency: 5 } );