From 227409df232f7efc520dfc761e901bb5e2eb5979 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Thu, 25 Dec 2025 20:18:36 +0000 Subject: [PATCH 1/2] feat(inquirerer): add positional arguments support with _ property - Add _?: boolean property to BaseQuestion interface to mark questions as positional - Implement extractPositionalArgs method to assign argv._ values to positional questions - Positional arguments are assigned in declaration order - Named arguments take precedence over positional (positional slot preserved for next question) - Values flow through override pipeline for list/autocomplete/checkbox option mapping - Add comprehensive test suite with 31 tests covering corner cases: - Basic positional argument handling - Named arguments taking precedence - Extra and missing positional values - Interleaved positional and non-positional questions - Positional with list/autocomplete/checkbox options - mutateArgs behavior - Required positional questions - Edge cases and complex scenarios - Update README documentation with Positional Arguments section --- packages/inquirerer/README.md | 83 +++ .../inquirerer/__tests__/positional.test.ts | 496 ++++++++++++++++++ packages/inquirerer/src/prompt.ts | 54 ++ packages/inquirerer/src/question/types.ts | 1 + 4 files changed, 634 insertions(+) create mode 100644 packages/inquirerer/__tests__/positional.test.ts diff --git a/packages/inquirerer/README.md b/packages/inquirerer/README.md index 0a07e4b..d644dec 100644 --- a/packages/inquirerer/README.md +++ b/packages/inquirerer/README.md @@ -50,6 +50,7 @@ npm install inquirerer - [Autocomplete Question](#autocomplete-question) - [Checkbox Question](#checkbox-question) - [Advanced Question Options](#advanced-question-options) + - [Positional Arguments](#positional-arguments) - [Real-World Examples](#real-world-examples) - [Project Setup Wizard](#project-setup-wizard) - [Configuration Builder](#configuration-builder) @@ -129,6 +130,7 @@ All questions support these base properties: interface BaseQuestion { name: string; // Property name in result object type: string; // Question type + _?: boolean; // Mark as positional argument (can be passed without --name flag) message?: string; // Prompt message to display description?: string; // Additional context default?: any; // Default value @@ -420,6 +422,87 @@ Ensure questions appear in the correct order: ] ``` +### Positional Arguments + +The `_` property allows you to name positional parameters, enabling users to pass values without flags. This is useful for CLI tools where the first few arguments have obvious meanings. + +#### Basic Usage + +```typescript +const questions: Question[] = [ + { + _: true, + name: 'database', + type: 'text', + message: 'Database name', + required: true + } +]; + +const argv = minimist(process.argv.slice(2)); +const result = await prompter.prompt(argv, questions); +``` + +Now users can run either: +```bash +node myprogram.js mydb1 +# or equivalently: +node myprogram.js --database mydb1 +``` + +#### Multiple Positional Arguments + +Positional arguments are assigned in declaration order: + +```typescript +const questions: Question[] = [ + { _: true, name: 'source', type: 'text', message: 'Source file' }, + { name: 'verbose', type: 'confirm', default: false }, + { _: true, name: 'destination', type: 'text', message: 'Destination file' } +]; + +// Running: node copy.js input.txt output.txt --verbose +// Results in: { source: 'input.txt', destination: 'output.txt', verbose: true } +``` + +#### Named Arguments Take Precedence + +When both positional and named arguments are provided, named arguments win and the positional slot is preserved for the next positional question: + +```typescript +const questions: Question[] = [ + { _: true, name: 'foo', type: 'text' }, + { _: true, name: 'bar', type: 'text' }, + { _: true, name: 'baz', type: 'text' } +]; + +// Running: node myprogram.js pos1 pos2 --bar named-bar +// Results in: { foo: 'pos1', bar: 'named-bar', baz: 'pos2' } +``` + +In this example, `bar` gets its value from the named flag, so the two positional values go to `foo` and `baz`. + +#### Positional with Options + +Positional arguments work with list, autocomplete, and checkbox questions. The value is mapped through the options: + +```typescript +const questions: Question[] = [ + { + _: true, + name: 'framework', + type: 'list', + options: [ + { name: 'React', value: 'react' }, + { name: 'Vue', value: 'vue' } + ] + } +]; + +// Running: node setup.js React +// Results in: { framework: 'react' } +``` + ## Real-World Examples ### Project Setup Wizard diff --git a/packages/inquirerer/__tests__/positional.test.ts b/packages/inquirerer/__tests__/positional.test.ts new file mode 100644 index 0000000..500d0d8 --- /dev/null +++ b/packages/inquirerer/__tests__/positional.test.ts @@ -0,0 +1,496 @@ +import { Inquirerer } from '../src'; +import { Question } from '../src/question'; + +interface TestResult { + _?: any[]; + [key: string]: any; +} + +describe('Positional Arguments (_: true)', () => { + let prompter: Inquirerer; + + beforeEach(() => { + prompter = new Inquirerer({ noTty: true }); + }); + + afterEach(() => { + prompter.close(); + }); + + describe('Basic positional argument handling', () => { + it('assigns single positional argument to question with _: true', async () => { + const questions: Question[] = [ + { _: true, name: 'database', type: 'text', required: true } + ]; + const argv: TestResult = { _: ['mydb1'] }; + + const result = await prompter.prompt(argv, questions); + + expect(result.database).toBe('mydb1'); + }); + + it('assigns multiple positional arguments in declaration order', async () => { + const questions: Question[] = [ + { _: true, name: 'foo', type: 'text' }, + { name: 'bar', type: 'text', default: 'default-bar' }, + { _: true, name: 'baz', type: 'text' } + ]; + const argv: TestResult = { _: ['1', '3'], bar: '2' }; + + const result = await prompter.prompt(argv, questions); + + expect(result.foo).toBe('1'); + expect(result.bar).toBe('2'); + expect(result.baz).toBe('3'); + }); + + it('works with numeric positional values', async () => { + const questions: Question[] = [ + { _: true, name: 'port', type: 'number' } + ]; + const argv: TestResult = { _: [3000] }; + + const result = await prompter.prompt(argv, questions); + + expect(result.port).toBe(3000); + }); + + it('works without any positional arguments (empty argv._)', async () => { + const questions: Question[] = [ + { _: true, name: 'foo', type: 'text', default: 'default-foo' } + ]; + const argv: TestResult = { _: [] }; + + const result = await prompter.prompt(argv, questions); + + expect(result.foo).toBe('default-foo'); + }); + + it('works when argv._ is undefined', async () => { + const questions: Question[] = [ + { _: true, name: 'foo', type: 'text', default: 'default-foo' } + ]; + const argv: TestResult = {}; + + const result = await prompter.prompt(argv, questions); + + expect(result.foo).toBe('default-foo'); + }); + }); + + describe('Named arguments take precedence over positional', () => { + it('uses named argument when both positional and named are provided', async () => { + const questions: Question[] = [ + { _: true, name: 'database', type: 'text' } + ]; + const argv: TestResult = { _: ['positional-db'], database: 'named-db' }; + + const result = await prompter.prompt(argv, questions); + + expect(result.database).toBe('named-db'); + }); + + it('does not consume positional when named argument is used', async () => { + const questions: Question[] = [ + { _: true, name: 'foo', type: 'text' }, + { _: true, name: 'bar', type: 'text' } + ]; + // foo is provided via --foo, so the positional 'first' should go to bar + const argv: TestResult = { _: ['first'], foo: 'named-foo' }; + + const result = await prompter.prompt(argv, questions); + + expect(result.foo).toBe('named-foo'); + expect(result.bar).toBe('first'); + }); + + it('handles mixed named and positional with multiple questions', async () => { + const questions: Question[] = [ + { _: true, name: 'a', type: 'text' }, + { _: true, name: 'b', type: 'text' }, + { _: true, name: 'c', type: 'text' } + ]; + // b is provided via --b, so positionals go to a and c + const argv: TestResult = { _: ['pos1', 'pos2'], b: 'named-b' }; + + const result = await prompter.prompt(argv, questions); + + expect(result.a).toBe('pos1'); + expect(result.b).toBe('named-b'); + expect(result.c).toBe('pos2'); + }); + + it('handles all named arguments with positionals left over', async () => { + const questions: Question[] = [ + { _: true, name: 'foo', type: 'text' }, + { _: true, name: 'bar', type: 'text' } + ]; + const argv: TestResult = { _: ['extra1', 'extra2'], foo: 'named-foo', bar: 'named-bar' }; + + const result = await prompter.prompt(argv, questions); + + expect(result.foo).toBe('named-foo'); + expect(result.bar).toBe('named-bar'); + // Extra positionals should remain in argv._ + expect(argv._).toEqual(['extra1', 'extra2']); + }); + }); + + describe('Extra and missing positional values', () => { + it('leaves extra positional values in argv._ untouched', async () => { + const questions: Question[] = [ + { _: true, name: 'first', type: 'text' } + ]; + const argv: TestResult = { _: ['value1', 'value2', 'value3'] }; + + const result = await prompter.prompt(argv, questions); + + expect(result.first).toBe('value1'); + // Extra positionals should remain + expect(argv._).toEqual(['value1', 'value2', 'value3']); + }); + + it('handles fewer positional values than positional questions', async () => { + const questions: Question[] = [ + { _: true, name: 'first', type: 'text' }, + { _: true, name: 'second', type: 'text', default: 'default-second' }, + { _: true, name: 'third', type: 'text', default: 'default-third' } + ]; + const argv: TestResult = { _: ['only-one'] }; + + const result = await prompter.prompt(argv, questions); + + expect(result.first).toBe('only-one'); + expect(result.second).toBe('default-second'); + expect(result.third).toBe('default-third'); + }); + }); + + describe('Interleaved positional and non-positional questions', () => { + it('correctly assigns positionals with non-positional questions in between', async () => { + const questions: Question[] = [ + { _: true, name: 'pos1', type: 'text' }, + { name: 'named1', type: 'text', default: 'default-named1' }, + { _: true, name: 'pos2', type: 'text' }, + { name: 'named2', type: 'text', default: 'default-named2' }, + { _: true, name: 'pos3', type: 'text' } + ]; + const argv: TestResult = { _: ['a', 'b', 'c'] }; + + const result = await prompter.prompt(argv, questions); + + expect(result.pos1).toBe('a'); + expect(result.pos2).toBe('b'); + expect(result.pos3).toBe('c'); + expect(result.named1).toBe('default-named1'); + expect(result.named2).toBe('default-named2'); + }); + + it('handles named args for non-positional questions alongside positionals', async () => { + const questions: Question[] = [ + { _: true, name: 'pos1', type: 'text' }, + { name: 'named1', type: 'text' }, + { _: true, name: 'pos2', type: 'text' } + ]; + const argv: TestResult = { _: ['first', 'second'], named1: 'named-value' }; + + const result = await prompter.prompt(argv, questions); + + expect(result.pos1).toBe('first'); + expect(result.named1).toBe('named-value'); + expect(result.pos2).toBe('second'); + }); + }); + + describe('Positional with list/autocomplete options', () => { + it('maps positional value through list options', async () => { + const questions: Question[] = [ + { + _: true, + name: 'framework', + type: 'list', + options: [ + { name: 'React', value: 'react' }, + { name: 'Vue', value: 'vue' }, + { name: 'Angular', value: 'angular' } + ] + } + ]; + const argv: TestResult = { _: ['React'] }; + + const result = await prompter.prompt(argv, questions); + + expect(result.framework).toBe('react'); + }); + + it('maps positional value through autocomplete options', async () => { + const questions: Question[] = [ + { + _: true, + name: 'database', + type: 'autocomplete', + options: [ + { name: 'PostgreSQL', value: 'postgres' }, + { name: 'MySQL', value: 'mysql' }, + { name: 'SQLite', value: 'sqlite' } + ] + } + ]; + const argv: TestResult = { _: ['PostgreSQL'] }; + + const result = await prompter.prompt(argv, questions); + + expect(result.database).toBe('postgres'); + }); + + it('allows custom options when allowCustomOptions is true', async () => { + const questions: Question[] = [ + { + _: true, + name: 'framework', + type: 'autocomplete', + options: ['React', 'Vue'], + allowCustomOptions: true + } + ]; + const argv: TestResult = { _: ['CustomFramework'] }; + + const result = await prompter.prompt(argv, questions); + + expect(result.framework).toBe('CustomFramework'); + }); + }); + + describe('Positional with checkbox (array handling)', () => { + it('handles single positional value for checkbox', async () => { + const questions: Question[] = [ + { + _: true, + name: 'features', + type: 'checkbox', + options: ['Auth', 'Database', 'API'] + } + ]; + const argv: TestResult = { _: ['Auth'] }; + + const result = await prompter.prompt(argv, questions); + + expect(result.features).toEqual([{ name: 'Auth', value: 'Auth', selected: true }]); + }); + + it('handles checkbox with returnFullResults', async () => { + const questions: Question[] = [ + { + _: true, + name: 'features', + type: 'checkbox', + options: ['Auth', 'Database', 'API'], + returnFullResults: true + } + ]; + const argv: TestResult = { _: ['Database'] }; + + const result = await prompter.prompt(argv, questions); + + expect(result.features).toEqual([ + { name: 'Auth', value: 'Auth', selected: false }, + { name: 'Database', value: 'Database', selected: true }, + { name: 'API', value: 'API', selected: false } + ]); + }); + }); + + describe('mutateArgs behavior', () => { + it('mutates argv when mutateArgs is true (default)', async () => { + const questions: Question[] = [ + { _: true, name: 'foo', type: 'text' } + ]; + const argv: TestResult = { _: ['value1'] }; + + await prompter.prompt(argv, questions); + + // argv should have foo added to it + expect(argv).toHaveProperty('foo', 'value1'); + }); + + it('does not mutate original argv when mutateArgs is false', async () => { + const nonMutatingPrompter = new Inquirerer({ noTty: true, mutateArgs: false }); + const questions: Question[] = [ + { _: true, name: 'foo', type: 'text' } + ]; + const argv: TestResult = { _: ['value1'] }; + const originalArgv = { ...argv }; + + const result = await nonMutatingPrompter.prompt(argv, questions); + + // Result should have the value + expect(result.foo).toBe('value1'); + // Original argv should not have foo (only _ was there originally) + // Note: extractPositionalArgs does modify argv to set the key for override processing + // but the returned object is a copy + nonMutatingPrompter.close(); + }); + + it('respects mutateArgs: false in prompt options', async () => { + const questions: Question[] = [ + { _: true, name: 'foo', type: 'text' } + ]; + const argv: TestResult = { _: ['value1'] }; + + const result = await prompter.prompt(argv, questions, { mutateArgs: false }); + + expect(result.foo).toBe('value1'); + }); + }); + + describe('Required positional questions in noTty mode', () => { + it('satisfies required question with positional argument', async () => { + const questions: Question[] = [ + { _: true, name: 'database', type: 'text', required: true } + ]; + const argv: TestResult = { _: ['mydb'] }; + + const result = await prompter.prompt(argv, questions); + + expect(result.database).toBe('mydb'); + }); + + it('throws error for missing required positional in noTty mode', async () => { + const questions: Question[] = [ + { _: true, name: 'database', type: 'text', required: true } + ]; + const argv: TestResult = { _: [] }; + + await expect(prompter.prompt(argv, questions)).rejects.toThrow( + 'Missing required arguments' + ); + }); + }); + + describe('Edge cases and complex scenarios', () => { + it('handles questions where some have _: true and some have _: false', async () => { + const questions: Question[] = [ + { _: true, name: 'pos', type: 'text' }, + { _: false, name: 'notPos', type: 'text', default: 'default' }, + { name: 'alsoNotPos', type: 'text', default: 'also-default' } + ]; + const argv: TestResult = { _: ['positional-value'] }; + + const result = await prompter.prompt(argv, questions); + + expect(result.pos).toBe('positional-value'); + expect(result.notPos).toBe('default'); + expect(result.alsoNotPos).toBe('also-default'); + }); + + it('handles empty string as positional value', async () => { + const questions: Question[] = [ + { _: true, name: 'foo', type: 'text' } + ]; + const argv: TestResult = { _: [''] }; + + const result = await prompter.prompt(argv, questions); + + expect(result.foo).toBe(''); + }); + + it('handles boolean-like string positional values', async () => { + const questions: Question[] = [ + { _: true, name: 'flag', type: 'text' } + ]; + const argv: TestResult = { _: ['true'] }; + + const result = await prompter.prompt(argv, questions); + + expect(result.flag).toBe('true'); + }); + + it('preserves order when all questions are positional', async () => { + const questions: Question[] = [ + { _: true, name: 'first', type: 'text' }, + { _: true, name: 'second', type: 'text' }, + { _: true, name: 'third', type: 'text' }, + { _: true, name: 'fourth', type: 'text' } + ]; + const argv: TestResult = { _: ['a', 'b', 'c', 'd'] }; + + const result = await prompter.prompt(argv, questions); + + expect(result.first).toBe('a'); + expect(result.second).toBe('b'); + expect(result.third).toBe('c'); + expect(result.fourth).toBe('d'); + }); + + it('handles complex mixed scenario', async () => { + const questions: Question[] = [ + { _: true, name: 'source', type: 'text' }, + { name: 'verbose', type: 'confirm', default: false }, + { _: true, name: 'destination', type: 'text' }, + { name: 'format', type: 'list', options: ['json', 'xml', 'csv'], default: 'json' }, + { _: true, name: 'count', type: 'number' } + ]; + const argv: TestResult = { + _: ['input.txt', 'output.txt', 42], + verbose: true, + format: 'csv' + }; + + const result = await prompter.prompt(argv, questions); + + expect(result.source).toBe('input.txt'); + expect(result.destination).toBe('output.txt'); + expect(result.count).toBe(42); + expect(result.verbose).toBe(true); + expect(result.format).toBe('csv'); + }); + + it('handles when first positional question has named arg but others do not', async () => { + const questions: Question[] = [ + { _: true, name: 'first', type: 'text' }, + { _: true, name: 'second', type: 'text' }, + { _: true, name: 'third', type: 'text' } + ]; + // first is named, so positionals go to second and third + const argv: TestResult = { _: ['pos1', 'pos2'], first: 'named-first' }; + + const result = await prompter.prompt(argv, questions); + + expect(result.first).toBe('named-first'); + expect(result.second).toBe('pos1'); + expect(result.third).toBe('pos2'); + }); + + it('handles when middle positional question has named arg', async () => { + const questions: Question[] = [ + { _: true, name: 'first', type: 'text' }, + { _: true, name: 'second', type: 'text' }, + { _: true, name: 'third', type: 'text' } + ]; + // second is named, so positionals go to first and third + const argv: TestResult = { _: ['pos1', 'pos2'], second: 'named-second' }; + + const result = await prompter.prompt(argv, questions); + + expect(result.first).toBe('pos1'); + expect(result.second).toBe('named-second'); + expect(result.third).toBe('pos2'); + }); + + it('handles when last positional question has named arg', async () => { + const questions: Question[] = [ + { _: true, name: 'first', type: 'text' }, + { _: true, name: 'second', type: 'text' }, + { _: true, name: 'third', type: 'text' } + ]; + // third is named, so positionals go to first and second + const argv: TestResult = { _: ['pos1', 'pos2'], third: 'named-third' }; + + const result = await prompter.prompt(argv, questions); + + expect(result.first).toBe('pos1'); + expect(result.second).toBe('pos2'); + expect(result.third).toBe('named-third'); + }); + }); +}); diff --git a/packages/inquirerer/src/prompt.ts b/packages/inquirerer/src/prompt.ts index 0aa3fec..93ab924 100644 --- a/packages/inquirerer/src/prompt.ts +++ b/packages/inquirerer/src/prompt.ts @@ -401,6 +401,10 @@ export class Inquirerer { // Resolve setFrom values - these bypass prompting entirely await this.resolveSetValues(questions, obj); + // Extract positional arguments from argv._ and assign to questions with _: true + // This must happen before applyOverrides so positional values flow through override pipeline + this.extractPositionalArgs(argv, obj, questions); + // first loop through the question, and set any overrides in case other questions use objs for validation this.applyOverrides(argv, obj, questions); @@ -554,6 +558,56 @@ export class Inquirerer { } } + /** + * Extracts positional arguments from argv._ and assigns them to questions marked with _: true. + * + * Rules: + * 1. Named arguments take precedence - if a question already has a value in argv, skip it + * 2. Positional questions consume from argv._ left-to-right in declaration order + * 3. Extra positional values remain in argv._ untouched + * 4. Missing positional values leave questions unset (for prompting/validation) + * + * This effectively allows "naming positional parameters" - users can pass values + * without flags and they'll be assigned to the appropriate question names. + */ + private extractPositionalArgs(argv: any, obj: any, questions: Question[]): void { + // Get positional arguments array from argv (minimist convention) + const positionals: any[] = Array.isArray(argv._) ? [...argv._] : []; + if (positionals.length === 0) { + return; + } + + // Track which positional index we're consuming from + let positionalIndex = 0; + + // Process questions in declaration order to maintain predictable assignment + for (const question of questions) { + // Only process questions marked as positional + if (!question._) { + continue; + } + + // Skip if this question already has a named argument value + // Named arguments always take precedence over positional + if (question.name in argv && question.name !== '_') { + continue; + } + + // Skip if we've exhausted all positional arguments + if (positionalIndex >= positionals.length) { + break; + } + + // Assign the next positional value to this question + const value = positionals[positionalIndex]; + obj[question.name] = value; + // Also set in argv so that handleOverrides can process it correctly + // (for list/autocomplete/checkbox option mapping) + argv[question.name] = value; + positionalIndex++; + } + } + private applyDefaultValues(questions: Question[], obj: any): void { questions.forEach(question => { if ('default' in question) { diff --git a/packages/inquirerer/src/question/types.ts b/packages/inquirerer/src/question/types.ts index 350d76b..f6ac6fa 100644 --- a/packages/inquirerer/src/question/types.ts +++ b/packages/inquirerer/src/question/types.ts @@ -17,6 +17,7 @@ export interface Validation { export interface BaseQuestion { name: string; type: string; + _?: boolean; default?: any; defaultFrom?: string; setFrom?: string; From 9341f860479b473f95af7daa482340b868774859 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Thu, 25 Dec 2025 20:59:12 +0000 Subject: [PATCH 2/2] fix(inquirerer): strip consumed positionals from argv._ and respect mutateArgs - extractPositionalArgs now returns the count of consumed positionals - Consumed positionals are stripped from result._ (remaining extras stay) - When mutateArgs is true (default), original argv._ is also stripped - When mutateArgs is false, original argv is not mutated at all - Deep clone argv._ array when mutateArgs is false to avoid shared reference - Updated tests to verify new stripping behavior --- .../inquirerer/__tests__/positional.test.ts | 40 ++++++++++------- packages/inquirerer/src/prompt.ts | 45 +++++++++++++------ 2 files changed, 55 insertions(+), 30 deletions(-) diff --git a/packages/inquirerer/__tests__/positional.test.ts b/packages/inquirerer/__tests__/positional.test.ts index 500d0d8..6d7278b 100644 --- a/packages/inquirerer/__tests__/positional.test.ts +++ b/packages/inquirerer/__tests__/positional.test.ts @@ -131,13 +131,13 @@ describe('Positional Arguments (_: true)', () => { expect(result.foo).toBe('named-foo'); expect(result.bar).toBe('named-bar'); - // Extra positionals should remain in argv._ - expect(argv._).toEqual(['extra1', 'extra2']); + // No positionals consumed (all questions had named args), so all remain + expect(result._).toEqual(['extra1', 'extra2']); }); }); describe('Extra and missing positional values', () => { - it('leaves extra positional values in argv._ untouched', async () => { + it('strips consumed positionals and leaves extras in result._', async () => { const questions: Question[] = [ { _: true, name: 'first', type: 'text' } ]; @@ -146,8 +146,10 @@ describe('Positional Arguments (_: true)', () => { const result = await prompter.prompt(argv, questions); expect(result.first).toBe('value1'); - // Extra positionals should remain - expect(argv._).toEqual(['value1', 'value2', 'value3']); + // Consumed positionals are stripped, extras remain in result._ + expect(result._).toEqual(['value2', 'value3']); + // Original argv._ is also mutated (default mutateArgs: true) + expect(argv._).toEqual(['value2', 'value3']); }); it('handles fewer positional values than positional questions', async () => { @@ -301,16 +303,19 @@ describe('Positional Arguments (_: true)', () => { }); describe('mutateArgs behavior', () => { - it('mutates argv when mutateArgs is true (default)', async () => { + it('mutates argv and strips positionals when mutateArgs is true (default)', async () => { const questions: Question[] = [ { _: true, name: 'foo', type: 'text' } ]; - const argv: TestResult = { _: ['value1'] }; + const argv: TestResult = { _: ['value1', 'extra'] }; - await prompter.prompt(argv, questions); + const result = await prompter.prompt(argv, questions); - // argv should have foo added to it + // argv should have foo added and _ stripped expect(argv).toHaveProperty('foo', 'value1'); + expect(argv._).toEqual(['extra']); + // result should also have stripped _ + expect(result._).toEqual(['extra']); }); it('does not mutate original argv when mutateArgs is false', async () => { @@ -318,16 +323,16 @@ describe('Positional Arguments (_: true)', () => { const questions: Question[] = [ { _: true, name: 'foo', type: 'text' } ]; - const argv: TestResult = { _: ['value1'] }; - const originalArgv = { ...argv }; + const argv: TestResult = { _: ['value1', 'extra'] }; const result = await nonMutatingPrompter.prompt(argv, questions); - // Result should have the value + // Result should have the value and stripped _ expect(result.foo).toBe('value1'); - // Original argv should not have foo (only _ was there originally) - // Note: extractPositionalArgs does modify argv to set the key for override processing - // but the returned object is a copy + expect(result._).toEqual(['extra']); + // Original argv should NOT be mutated + expect(argv._).toEqual(['value1', 'extra']); + expect(argv).not.toHaveProperty('foo'); nonMutatingPrompter.close(); }); @@ -335,11 +340,14 @@ describe('Positional Arguments (_: true)', () => { const questions: Question[] = [ { _: true, name: 'foo', type: 'text' } ]; - const argv: TestResult = { _: ['value1'] }; + const argv: TestResult = { _: ['value1', 'extra'] }; const result = await prompter.prompt(argv, questions, { mutateArgs: false }); expect(result.foo).toBe('value1'); + expect(result._).toEqual(['extra']); + // Original argv should NOT be mutated + expect(argv._).toEqual(['value1', 'extra']); }); }); diff --git a/packages/inquirerer/src/prompt.ts b/packages/inquirerer/src/prompt.ts index 93ab924..76631d5 100644 --- a/packages/inquirerer/src/prompt.ts +++ b/packages/inquirerer/src/prompt.ts @@ -390,7 +390,13 @@ export class Inquirerer { // use local mutateArgs if defined, otherwise global mutateArgs const shouldMutate = options?.mutateArgs !== undefined ? options.mutateArgs : this.mutateArgs; + + // Create a working copy of argv - deep clone the _ array to avoid shared reference let obj: any = shouldMutate ? argv : { ...argv }; + const argvAny = argv as any; + if (!shouldMutate && Array.isArray(argvAny._)) { + obj._ = [...argvAny._]; + } // Resolve dynamic defaults before processing questions await this.resolveDynamicDefaults(questions); @@ -403,10 +409,20 @@ export class Inquirerer { // Extract positional arguments from argv._ and assign to questions with _: true // This must happen before applyOverrides so positional values flow through override pipeline - this.extractPositionalArgs(argv, obj, questions); + // Returns the number of positional arguments consumed for stripping + const consumedCount = this.extractPositionalArgs(obj, questions); + + // Strip consumed positionals from obj._ (the working copy) + if (consumedCount > 0 && Array.isArray(obj._)) { + obj._ = obj._.slice(consumedCount); + // If mutating, also update the original argv._ + if (shouldMutate) { + argvAny._ = obj._; + } + } // first loop through the question, and set any overrides in case other questions use objs for validation - this.applyOverrides(argv, obj, questions); + this.applyOverrides(obj, obj, questions); // Check for required arguments when no terminal is available (non-interactive mode) if (this.noTty && this.hasMissingRequiredArgs(questions, argv)) { @@ -559,22 +575,24 @@ export class Inquirerer { } /** - * Extracts positional arguments from argv._ and assigns them to questions marked with _: true. + * Extracts positional arguments from obj._ and assigns them to questions marked with _: true. * * Rules: - * 1. Named arguments take precedence - if a question already has a value in argv, skip it - * 2. Positional questions consume from argv._ left-to-right in declaration order - * 3. Extra positional values remain in argv._ untouched + * 1. Named arguments take precedence - if a question already has a value in obj, skip it + * 2. Positional questions consume from obj._ left-to-right in declaration order + * 3. Returns the count of consumed positionals so caller can strip them from obj._ * 4. Missing positional values leave questions unset (for prompting/validation) * * This effectively allows "naming positional parameters" - users can pass values * without flags and they'll be assigned to the appropriate question names. + * + * @returns The number of positional arguments consumed */ - private extractPositionalArgs(argv: any, obj: any, questions: Question[]): void { - // Get positional arguments array from argv (minimist convention) - const positionals: any[] = Array.isArray(argv._) ? [...argv._] : []; + private extractPositionalArgs(obj: any, questions: Question[]): number { + // Get positional arguments array from obj (minimist convention) + const positionals: any[] = Array.isArray(obj._) ? obj._ : []; if (positionals.length === 0) { - return; + return 0; } // Track which positional index we're consuming from @@ -589,7 +607,7 @@ export class Inquirerer { // Skip if this question already has a named argument value // Named arguments always take precedence over positional - if (question.name in argv && question.name !== '_') { + if (question.name in obj && question.name !== '_') { continue; } @@ -601,11 +619,10 @@ export class Inquirerer { // Assign the next positional value to this question const value = positionals[positionalIndex]; obj[question.name] = value; - // Also set in argv so that handleOverrides can process it correctly - // (for list/autocomplete/checkbox option mapping) - argv[question.name] = value; positionalIndex++; } + + return positionalIndex; } private applyDefaultValues(questions: Question[], obj: any): void {