fix(maestro): harden command resolution
This commit is contained in:
+43
-33
@@ -86,14 +86,11 @@ function legacyFirstWord(command: string): string {
|
||||
return first === undefined ? command.trim() : basename(first);
|
||||
}
|
||||
|
||||
/** One token from {@link tokenize}: its dequoted text, and — when a
|
||||
* literal, unquoted/unescaped `=` occurred in the source before any
|
||||
* quoting or escaping ever touched this word — the index of the name
|
||||
* preceding it. Only a *plain* `=` counts as an assignment's: quoting or
|
||||
* escaping it (`"FOO=1"`, `FOO\=1`) makes the word an executable command
|
||||
* named `FOO=1`, not an assignment (#199) — quoting elsewhere in the word
|
||||
* (a value, or even part of the name, as in `FO"O"=1`) doesn't disqualify
|
||||
* it, matching a real shell's own assignment-word grammar. */
|
||||
/** One token from {@link tokenize}: its dequoted text, and — when both the
|
||||
* name and `=` were entirely unquoted/unescaped in the source — the index of
|
||||
* the name. Quoting or escaping either (`"FOO"=1`, `F\OO=1`, `FOO\=1`)
|
||||
* makes the word an executable named `FOO=1`; quoting/escaping only the value
|
||||
* preserves the assignment, matching Bash's assignment-word grammar (#199). */
|
||||
interface Word {
|
||||
text: string;
|
||||
plainEq: number | null;
|
||||
@@ -150,6 +147,7 @@ function tokenize(command: string): Word[] | null {
|
||||
let current = "";
|
||||
let hasCurrent = false;
|
||||
let plainEq: number | null = null;
|
||||
let assignmentNameIsPlain = true;
|
||||
let i = 0;
|
||||
const n = command.length;
|
||||
|
||||
@@ -161,6 +159,7 @@ function tokenize(command: string): Word[] | null {
|
||||
current = "";
|
||||
hasCurrent = false;
|
||||
plainEq = null;
|
||||
assignmentNameIsPlain = true;
|
||||
}
|
||||
i += 1;
|
||||
} else if (c === "\n") {
|
||||
@@ -171,6 +170,7 @@ function tokenize(command: string): Word[] | null {
|
||||
return null;
|
||||
} else if (c === "'") {
|
||||
hasCurrent = true;
|
||||
if (plainEq === null) assignmentNameIsPlain = false;
|
||||
i += 1;
|
||||
const close = command.indexOf("'", i);
|
||||
if (close === -1) return null; // unterminated single quote
|
||||
@@ -178,6 +178,7 @@ function tokenize(command: string): Word[] | null {
|
||||
i = close + 1;
|
||||
} else if (c === '"') {
|
||||
hasCurrent = true;
|
||||
if (plainEq === null) assignmentNameIsPlain = false;
|
||||
i += 1;
|
||||
for (;;) {
|
||||
if (i >= n) return null; // unterminated double quote
|
||||
@@ -208,11 +209,12 @@ function tokenize(command: string): Word[] | null {
|
||||
continue;
|
||||
}
|
||||
hasCurrent = true;
|
||||
if (plainEq === null) assignmentNameIsPlain = false;
|
||||
current += escaped;
|
||||
i += 2;
|
||||
} else {
|
||||
hasCurrent = true;
|
||||
if (c === "=" && plainEq === null) {
|
||||
if (c === "=" && plainEq === null && assignmentNameIsPlain) {
|
||||
plainEq = current.length;
|
||||
}
|
||||
current += c;
|
||||
@@ -272,7 +274,7 @@ function consumeOptions(opts: OptionTable, words: string[], i: number): number |
|
||||
if (word === "-" || !word.startsWith("-")) return i;
|
||||
const eq = word.indexOf("=");
|
||||
if (eq >= 0) {
|
||||
if (opts.valued.has(word.slice(0, eq))) {
|
||||
if (opts.valued.has(word.slice(0, eq)) && word.slice(eq + 1).length > 0) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
@@ -283,7 +285,8 @@ function consumeOptions(opts: OptionTable, words: string[], i: number): number |
|
||||
continue;
|
||||
}
|
||||
if (opts.valued.has(word)) {
|
||||
if (words[i + 1] === undefined) return null;
|
||||
const value = words[i + 1];
|
||||
if (value === undefined || value.length === 0 || value.startsWith("-")) return null;
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
@@ -348,11 +351,13 @@ const COMMAND_BUILTIN_OPTS: OptionTable = {
|
||||
* `/usr/bin/time`, a different program a bare `time` word doesn't
|
||||
* necessarily name. Assuming them here would be guessing which `time`
|
||||
* resolves (#199). */
|
||||
const TIME_OPTS: OptionTable = {
|
||||
flags: new Set(["-p"]),
|
||||
valued: new Set(),
|
||||
bundlesShortOptions: true,
|
||||
};
|
||||
function resolveTime(words: string[], i: number): number | null {
|
||||
let next = i + 1;
|
||||
if (words[next] === "-p") next += 1;
|
||||
if (words[next] === "--") next += 1;
|
||||
const command = words[next];
|
||||
return command?.startsWith("-") ? null : next;
|
||||
}
|
||||
|
||||
/** `-c`/`--call` embeds a whole shell command string rather than naming the
|
||||
* program positionally — resolving past it would mean guessing at a
|
||||
@@ -368,7 +373,7 @@ const PNPM_OPTS: OptionTable = {
|
||||
flags: new Set(["--silent", "-s", "--stream", "--aggregate-output", "--no-progress"]),
|
||||
// `--package`/`--package=value` is documented in this pre-`dlx` position
|
||||
// (`pnpm --package=x dlx y`), alongside the identical flag `dlx` itself
|
||||
// also accepts (`DLX_TRAILING_OPTS`).
|
||||
// also accepts (`PNPM_DLX_OPTS`).
|
||||
valued: new Set(["-C", "--dir", "--filter", "--package"]),
|
||||
bundlesShortOptions: false,
|
||||
};
|
||||
@@ -379,33 +384,38 @@ const YARN_OPTS: OptionTable = {
|
||||
bundlesShortOptions: false,
|
||||
};
|
||||
|
||||
/** `--package`/`--package=value` right after `dlx`: both pnpm's and yarn's
|
||||
* `dlx` document it there, to run a binary whose name differs from the
|
||||
* package that provides it (#199). */
|
||||
const DLX_TRAILING_OPTS: OptionTable = {
|
||||
flags: new Set(),
|
||||
const PNPM_DLX_OPTS: OptionTable = {
|
||||
flags: new Set(["-s", "--silent"]),
|
||||
valued: new Set(["--package"]),
|
||||
bundlesShortOptions: false,
|
||||
};
|
||||
|
||||
/** `--bun`, forcing bun's own runtime instead of Node — bun's docs place it
|
||||
* right after the `x`/`bunx` trigger, before the executable (#199). */
|
||||
const YARN_DLX_OPTS: OptionTable = {
|
||||
flags: new Set(["-q", "--quiet"]),
|
||||
valued: new Set(["-p", "--package"]),
|
||||
bundlesShortOptions: false,
|
||||
};
|
||||
|
||||
const BUN_X_OPTS: OptionTable = {
|
||||
flags: new Set(["--bun"]),
|
||||
valued: new Set(),
|
||||
flags: new Set(["--bun", "--no-install", "--verbose", "--silent"]),
|
||||
valued: new Set(["-p", "--package"]),
|
||||
bundlesShortOptions: false,
|
||||
};
|
||||
|
||||
/** `pnpm`/`yarn` only look through to the package behind an explicit `dlx`
|
||||
* — without it, the next word is a `package.json` script name, not a
|
||||
* package to run (#199: `pnpm claude` is not `pnpm dlx claude`). `dlx`
|
||||
* itself takes options too (`DLX_TRAILING_OPTS`), consumed before the
|
||||
* executable. */
|
||||
function resolveDlx(opts: OptionTable, words: string[], i: number): number | null {
|
||||
* itself takes launcher-specific options too, consumed before the executable. */
|
||||
function resolveDlx(
|
||||
opts: OptionTable,
|
||||
dlxOpts: OptionTable,
|
||||
words: string[],
|
||||
i: number,
|
||||
): number | null {
|
||||
const j = consumeOptions(opts, words, i + 1);
|
||||
if (j === null) return null;
|
||||
if (words[j] !== "dlx") return null;
|
||||
return consumeOptions(DLX_TRAILING_OPTS, words, j + 1);
|
||||
return consumeOptions(dlxOpts, words, j + 1);
|
||||
}
|
||||
|
||||
/** `bun x <pkg>` is bun's `dlx`/`npx` equivalent; `bun run <script>` is not
|
||||
@@ -428,10 +438,10 @@ const WRAPPER_RESOLVERS: Record<string, WrapperResolver> = {
|
||||
sudo: (words, i) => consumeOptions(SUDO_OPTS, words, i + 1),
|
||||
command: (words, i) => consumeOptions(COMMAND_BUILTIN_OPTS, words, i + 1),
|
||||
nohup: (words, i) => consumeOptions(NO_OPTIONS, words, i + 1),
|
||||
time: (words, i) => consumeOptions(TIME_OPTS, words, i + 1),
|
||||
time: resolveTime,
|
||||
npx: (words, i) => consumeOptions(NPX_OPTS, words, i + 1),
|
||||
pnpm: (words, i) => resolveDlx(PNPM_OPTS, words, i),
|
||||
yarn: (words, i) => resolveDlx(YARN_OPTS, words, i),
|
||||
pnpm: (words, i) => resolveDlx(PNPM_OPTS, PNPM_DLX_OPTS, words, i),
|
||||
yarn: (words, i) => resolveDlx(YARN_OPTS, YARN_DLX_OPTS, words, i),
|
||||
bun: resolveBun,
|
||||
bunx: (words, i) => consumeOptions(BUN_X_OPTS, words, i + 1),
|
||||
uvx: (_words, i) => i + 1,
|
||||
|
||||
Reference in New Issue
Block a user