Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: run scripts without -- #4290

Merged
merged 2 commits into from Jan 30, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/odd-kangaroos-care.md
@@ -0,0 +1,7 @@
---
"@pnpm/parse-cli-args": major
"@pnpm/plugin-commands-script-runners": major
"pnpm": major
Comment on lines +2 to +4
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should these all be major?

---

When using `pnpm run <script>`, all command line arguments after the script name are now passed to the script's argv. Previously flagged arguments (e.g. `--silent`) were intepreted as pnpm arguments unless `--` came before it.
36 changes: 34 additions & 2 deletions packages/parse-cli-args/src/index.ts
Expand Up @@ -59,7 +59,12 @@ export default async function parseCliArgs (
cmd = opts.fallbackCommand!
commandName = opts.fallbackCommand!
inputArgv.unshift(opts.fallbackCommand!)
} else if (noptExploratoryResults['help']) {
// The run command has special casing for --help and is handled further below.
} else if (cmd !== 'run' && noptExploratoryResults['help']) {
return getParsedArgsForHelp()
}

function getParsedArgsForHelp () {
return {
argv: noptExploratoryResults.argv,
cmd: 'help',
Expand All @@ -69,6 +74,7 @@ export default async function parseCliArgs (
fallbackCommandUsed: false,
}
}

const types = {
...opts.universalOptionsTypes,
...opts.getTypesByCommandName(commandName),
Expand All @@ -84,6 +90,26 @@ export default async function parseCliArgs (
return 'add'
}

function getEscapeArgsWithSpecialCaseForRun () {
if (cmd !== 'run') {
return opts.escapeArgs
}

// We'd like everything after the run script's name to be passed to the
// script's argv itself. For example, "pnpm run echo --test" should pass
// "--test" to the "echo" script. This requires determining the script's
// name and declaring it as the "escape arg".
//
// The name of the run script is normally the second argument (ex: pnpm
// run foo), but can be pushed back by recursive commands (ex: pnpm
// recursive run foo) or becomes the first argument when the fallback
// command (ex: pnpm foo) is set to 'run'.
const indexOfRunScriptName = 1 +
(recursiveCommandUsed ? 1 : 0) +
(fallbackCommandUsed && opts.fallbackCommand === 'run' ? -1 : 0)
Comment on lines +107 to +109
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The logic here might be a bit of a hacky way to do things. We could also:

  • run nopt again to find the script name to use as the escape arg. That would result in 3 nopt calls here.
  • add additional behavior in pnpm's nopt fork to dynamically determine whether something is an escape arg. I'm imagining a closure that gets passed the partial CLI parsing up to that point.

return [noptExploratoryResults.argv.remain[indexOfRunScriptName]]
}

const { argv, ...options } = nopt(
{
recursive: Boolean,
Expand All @@ -95,9 +121,15 @@ export default async function parseCliArgs (
},
inputArgv,
0,
{ escapeArgs: opts.escapeArgs }
{ escapeArgs: getEscapeArgsWithSpecialCaseForRun() }
)

// For the run command, it's not clear whether --help should be passed to the
// underlying script or invoke pnpm's help text until an additional nopt call.
if (cmd === 'run' && options['help']) {
return getParsedArgsForHelp()
}

if (opts.renamedOptions != null) {
for (const cliOption of Object.keys(options)) {
if (opts.renamedOptions[cliOption]) {
Expand Down
48 changes: 47 additions & 1 deletion packages/parse-cli-args/test/index.ts
Expand Up @@ -207,13 +207,59 @@ test('any unknown command is treated as a script', async () => {
fallbackCommand: 'run',
getCommandLongName: () => null,
universalOptionsTypes: { filter: [String, Array] },
}, ['foo', '--recursive'])
}, ['--recursive', 'foo'])
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Had to rearrange the --recursive flag here to fix the test after these changes. This was expected from the behavior change described in the pull request description.

expect(cmd).toBe('run')
expect(params).toStrictEqual(['foo'])
expect(options).toHaveProperty(['recursive'])
expect(fallbackCommandUsed).toBeTruthy()
})

test('run script with --help before script name is help command', async () => {
const { cmd, params } = await parseCliArgs({
...DEFAULT_OPTS,
fallbackCommand: 'run',
}, ['run', '--help', 'foo'])
expect(cmd).toBe('help')
expect(params).toStrictEqual(['run', 'foo'])
})

test.each([
['foo', { params: 'foo', options: {} }],
['foo --bar baz --qux', { params: 'foo --bar baz --qux', options: {} }],
['-r foo', { params: 'foo', options: { recursive: true } }],
['-r foo --bar baz --qux', { params: 'foo --bar baz --qux', options: { recursive: true } }],

// Edge case where option value is the script name. Fortunately nopt handles this correctly.
['--test-pattern test test foo', { params: 'test foo', options: { 'test-pattern': ['test'] } }],

// Ensure even builtin flags are passed to the script.
['foo -r', { params: 'foo -r', options: {} }],
['foo --recursive', { params: 'foo --recursive', options: {} }],
['foo -h', { params: 'foo -h', options: {} }],
['foo --help', { params: 'foo --help', options: {} }],
['foo --filter=bar', { params: 'foo --filter=bar', options: {} }],
])('run script arguments are correct for: %s', async (testInput, expected) => {
for (const testWithCommandFallback of [true, false]) {
// Whether or not the leading "run" portion of the command is written
// shouldn't affect its arg parsing. Test both scenarios for good measure.
const input = [...(testWithCommandFallback ? [] : ['run']), ...testInput.split(' ')]

const { options, cmd, params, fallbackCommandUsed } = await parseCliArgs({
...DEFAULT_OPTS,
fallbackCommand: 'run',
getCommandLongName: (name) => name === 'run' ? 'run' : null,
universalOptionsTypes: { filter: [String, Array], 'test-pattern': [String, Array] },
}, input)
expect(cmd).toBe('run')
expect(params).toStrictEqual(expected.params.split(' '))
expect(options).toStrictEqual(expected.options)

if (testWithCommandFallback) {
expect(fallbackCommandUsed).toBeTruthy()
}
}
})

test("don't use the fallback command if no command is present", async () => {
const { cmd, params } = await parseCliArgs({
...DEFAULT_OPTS,
Expand Down
7 changes: 6 additions & 1 deletion packages/plugin-commands-script-runners/src/run.ts
Expand Up @@ -107,7 +107,7 @@ For options that may be used with `-r`, see "pnpm help recursive"',
FILTERING,
],
url: docsUrl('run'),
usages: ['pnpm run <command> [-- <args>...]'],
usages: ['pnpm run <command> [<args>...]'],
})
}

Expand All @@ -134,6 +134,11 @@ export async function handler (
) {
let dir: string
const [scriptName, ...passedThruArgs] = params
// For backward compatibility
const firstDoubleDash = passedThruArgs.findIndex((arg) => arg === '--')
Copy link
Member Author

@gluxon gluxon Jan 30, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I pattern matched this from the same change in pnpm exec: #3492

// For backward compatibility
if (params[0] === '--') {
params.shift()
}

pnpm v6 allowed -- anywhere in the args portion of pnpm run <command> [..args], so this extra .findIndex logic tries to maintain full backwards compatible behavior for that.

Although perhaps we should only swallow the first -- like pnpm exec does. I could see the behavior in the scenario below being a bit surprising. This is how pnpm behaves before and after this pull request.

❯ pnpm run --silent echo a -- b
a b

You might expect:

❯ pnpm run --silent echo a -- b
a -- b

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Although the behavior above didn't change in this PR, I do worry this will come back to bite someone that thinks -- can be dropped entirely after this change. Just a heads up @zkochan.

Happy to make any additional followup changes before the pnpm v7 release if we want to.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it is fine if we don't swallow -- at all. This is a breaking change in v7. So it won't be a surprise to anyone.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 Cool. I'll open a pull request reverting this block later tonight when I'm near my computer.

if (firstDoubleDash !== -1) {
passedThruArgs.splice(firstDoubleDash, 1)
}
if (opts.recursive) {
if (scriptName || Object.keys(opts.selectedProjectsGraph).length > 1) {
return runRecursive(params, opts)
Expand Down
63 changes: 62 additions & 1 deletion packages/pnpm/test/run.ts
Expand Up @@ -17,7 +17,7 @@ test('run -r: pass the args to the command that is specfied in the build script'
await fs.writeFile('project/args.json', '[]', 'utf8')
await fs.writeFile('project/recordArgs.js', RECORD_ARGS_FILE, 'utf8')

await execPnpm(['run', '-r', '--config.enable-pre-post-scripts', 'foo', 'arg', '--', '--flag=true'])
await execPnpm(['run', '-r', '--config.enable-pre-post-scripts', 'foo', 'arg', '--flag=true'])

const { default: args } = await import(path.resolve('project/args.json'))
expect(args).toStrictEqual([
Expand All @@ -27,6 +27,67 @@ test('run -r: pass the args to the command that is specfied in the build script'
])
})

test('run: pass the args to the command that is specfied in the build script', async () => {
prepare({
name: 'project',
scripts: {
foo: 'node recordArgs',
postfoo: 'node recordArgs',
prefoo: 'node recordArgs',
},
})
await fs.writeFile('args.json', '[]', 'utf8')
await fs.writeFile('recordArgs.js', RECORD_ARGS_FILE, 'utf8')

await execPnpm(['run', 'foo', 'arg', '--flag=true'])

const { default: args } = await import(path.resolve('args.json'))
expect(args).toStrictEqual([
['arg', '--flag=true'],
])
})

// Before pnpm v7, `--` was required to pass flags to a build script.
test('run: handle -- in a backwards compatible manner', async () => {
prepare({
name: 'project',
scripts: {
foo: 'node recordArgs',
postfoo: 'node recordArgs',
prefoo: 'node recordArgs',
},
})
await fs.writeFile('args.json', '[]', 'utf8')
await fs.writeFile('recordArgs.js', RECORD_ARGS_FILE, 'utf8')

await execPnpm(['run', 'foo', 'arg', '--', '--flag=true'])

const { default: args } = await import(path.resolve('args.json'))
expect(args).toStrictEqual([
['arg', '--flag=true'],
])
})

test('run: pass -- to the build script if specified twice', async () => {
prepare({
name: 'project',
scripts: {
foo: 'node recordArgs',
postfoo: 'node recordArgs',
prefoo: 'node recordArgs',
},
})
await fs.writeFile('args.json', '[]', 'utf8')
await fs.writeFile('recordArgs.js', RECORD_ARGS_FILE, 'utf8')

await execPnpm(['run', 'foo', '--', 'arg', '--', '--flag=true'])

const { default: args } = await import(path.resolve('args.json'))
expect(args).toStrictEqual([
['arg', '--', '--flag=true'],
])
})

test('test -r: pass the args to the command that is specfied in the build script of a package.json manifest', async () => {
preparePackages([{
name: 'project',
Expand Down