Skip to content

fix: keep the rest of the command line when expanding a wildcard#603

Open
webpro wants to merge 1 commit into
open-cli-tools:mainfrom
webpro:fix/expand-wildcard-preserve-command-line
Open

fix: keep the rest of the command line when expanding a wildcard#603
webpro wants to merge 1 commit into
open-cli-tools:mainfrom
webpro:fix/expand-wildcard-preserve-command-line

Conversation

@webpro

@webpro webpro commented Jul 27, 2026

Copy link
Copy Markdown

The problem

ExpandWildcard finds the runner invocation with a regex and rebuilds the command from
the captured pieces:

/((?:npm|yarn|pnpm|bun) run|node --run|deno task) (\S+)([^&]*)/
...
command: `${command} ${script}${args}`

([^&]*) stops at the first &, and nothing captures what follows, so it is dropped.
The pattern is also unanchored, so it matches inside quoted text.

Three things go wrong, all reproducible with the published package:

A chained command is silently discarded.

$ concurrently -n a "npm run build:* && echo DONE"
[aapp] APP
[alib] LIB
       # DONE never runs. Exit code 0, no warning.

$ concurrently -n b "npm run build:app && echo DONE"   # no wildcard, for contrast
[b] APP
[b] DONE

An & inside a quoted argument truncates the command mid-string.

$ concurrently -n c 'npm run build:* -- --grep "a & b"'
[capp] /bin/sh: -c: line 0: unexpected EOF while looking for matching `"'
[capp] npm run build:app -- --grep "a  exited with code 2

A runner named inside a quoted argument is expanded as if it were an invocation.
echo "npm run build:*" matches the pattern, finds no script ending in a quote, and
returns no commands at all, so the command disappears rather than running.

The change

Locate the invocation in the parsed command rather than in the raw text, then substitute the script using the source span of the glob:

commandInfo.command.slice(0, runner.glob.pos) + script + commandInfo.command.slice(runner.glob.end)

Every other byte of the command line is preserved by construction, which is what fixes all three cases at once rather than one at a time. The runner lookup walks the parsed command, so a runner name inside a quoted argument is not a runner invocation and an invocation after cd app && still is.

The (!...) omission syntax needs no special handling: it is extglob, and the parser reads watch-*(!js) as a single word.

About the dependency

This adds unbash, a zero dependency synchronous Bash parser with no WASM and no async initialisation. I wrote it. I also wrote knip, which depends on it, so it already runs against a large amount of real world shell: knip is at about 11.7M downloads a week and unbash at about 8.5M.

Worth noting given the two shell-quote advisory bumps in #591 and #599: shell-quote stays, because expand-arguments.ts uses its quote(), which is a different job from parsing. This PR does not touch that.

Verification

pnpm vitest --project unit   635 pass, 0 fail   (631 on main, +4 regressions)
pnpm lint                    exit 0
pnpm typecheck               exit 0
pnpm build                   exit 0

Both reproductions above were confirmed against the built binary before and after.

The wildcard expander matched the runner invocation with

    /((?:npm|yarn|pnpm|bun) run|node --run|deno task) (\S+)([^&]*)/

and rebuilt the command as `${command} ${script}${args}`. Everything from
the first `&` onward was captured by nothing and dropped, so

    concurrently 'npm run build:* && echo done'

ran the two builds and silently discarded `&& echo done`, exiting 0. The
same truncation cut

    concurrently 'npm run build:* -- --grep "a & b"'

mid-string, leaving `/bin/sh` with an unterminated quote. The pattern is
also unanchored, so `echo "npm run build:*"` was expanded as if it were a
runner invocation and, finding no script ending in a quote, returned no
commands at all.

Locate the invocation in the parsed command instead, and substitute the
script in place using the source span of the glob, so every other byte of
the command line is preserved. concurrently's own `(!...)` omission
syntax is extglob, which the parser reads as one word, so it needs no
special handling.
@coveralls

Copy link
Copy Markdown

Coverage Status

coverage: 98.81% (-0.05%) from 98.858% — webpro:fix/expand-wildcard-preserve-command-line into open-cli-tools:main

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants