From e3221ff686bd7568d1ef3e6daa53d8ff6094ffc6 Mon Sep 17 00:00:00 2001 From: "jared-outpost[bot]" Date: Sun, 2 Aug 2026 14:19:41 +0000 Subject: [PATCH 1/5] ref(cli): replace argv-hoist preprocessor with a Stricli top-level-flags patch Global flags placed before the subcommand (`sentry --verbose issue list`) used to be relocated to the tail of argv by the `argv-hoist.ts` preprocessor, because Stricli only parses flags at the leaf command and treats a global flag in a route position as an unknown subcommand. This teaches Stricli's route scanner about a fixed allow-list of Sentry global flags via the existing `@stricli/core` patch (same mechanism as the `-H` removal), so `buildRouteScanner` recognizes `--verbose`, `--json`, `--org`, `--project`, `--log-level`, `--fields` (and the `-v` alias, plus `=`-inline and value forms) at any route depth and forwards them to the leaf command instead of failing route resolution. The patch also drops Stricli's built-in `-v`=version alias so `-v` stays the CLI's `--verbose` alias at every position; `--version` remains the version flag. `argv-hoist.ts` is gone. The two transforms Stricli can't do at arbitrary route depth remain as thin app-boundary glue in `argv-glue.ts`: `--version` normalization (Stricli only prints it at argv[0]) and the `--help --json` rewrite to the `help` command (Stricli intercepts `--help` and ignores `--json`). - Regenerate the `@stricli/core` patch (both dist/index.{js,cjs}). - Guard the new patch effects in `check:patches` (requiredMarker support). - Replace argv-hoist tests with argv-glue unit tests + a run(app,...) integration suite covering global flags at depth, value-flag consumption, the `-v` regression, and `--` escape passthrough. Closes #1339 --- .../cli/patches/@stricli%2Fcore@1.2.8.patch | 180 ++++- packages/cli/script/check-patches.ts | 69 +- packages/cli/src/cli.ts | 18 +- packages/cli/src/lib/argv-glue.ts | 263 +++++++ packages/cli/src/lib/argv-hoist.ts | 486 ------------- packages/cli/src/lib/global-flags.ts | 16 +- .../test/lib/argv-glue.integration.test.ts | 177 +++++ packages/cli/test/lib/argv-glue.test.ts | 263 +++++++ .../cli/test/lib/argv-hoist.property.test.ts | 180 ----- packages/cli/test/lib/argv-hoist.test.ts | 683 ------------------ pnpm-lock.yaml | 8 +- 11 files changed, 959 insertions(+), 1384 deletions(-) create mode 100644 packages/cli/src/lib/argv-glue.ts delete mode 100644 packages/cli/src/lib/argv-hoist.ts create mode 100644 packages/cli/test/lib/argv-glue.integration.test.ts create mode 100644 packages/cli/test/lib/argv-glue.test.ts delete mode 100644 packages/cli/test/lib/argv-hoist.property.test.ts delete mode 100644 packages/cli/test/lib/argv-hoist.test.ts diff --git a/packages/cli/patches/@stricli%2Fcore@1.2.8.patch b/packages/cli/patches/@stricli%2Fcore@1.2.8.patch index abe73d40e2..f65af60632 100644 --- a/packages/cli/patches/@stricli%2Fcore@1.2.8.patch +++ b/packages/cli/patches/@stricli%2Fcore@1.2.8.patch @@ -1,8 +1,58 @@ diff --git a/dist/index.cjs b/dist/index.cjs -index a9afbc1ab78cad7ba97d53882a80236af62854df..8c66958d340038dd8669848d0e2dbd49064dcd1f 100644 +index a9afbc1ab78cad7ba97d53882a80236af62854df..7622c3b1e512fd9a180fce9cca7955001e22e5a9 100644 --- a/dist/index.cjs +++ b/dist/index.cjs -@@ -1300,7 +1300,7 @@ function buildRouteScanner(root, config, startingPrefix) { +@@ -1277,6 +1277,29 @@ var RouteMapSymbol = Symbol("RouteMap"); + var CommandSymbol = Symbol("Command"); + + // src/routing/scanner.ts ++// PATCH(getsentry/cli): top-level flags allow-list. ++// Stricli only parses flags at the leaf command, so a global flag placed before ++// the subcommand (e.g. `sentry --verbose issue list`) is treated as an unknown ++// route segment and fails route resolution. This allow-list lets the scanner ++// recognize a fixed set of Sentry global flags at any route depth and forward ++// them (and the value of value-taking flags) to the leaf command via ++// unprocessedInputs, instead of erroring. This replaces the app-level ++// argv-hoist preprocessor. If this constant is missing, `check:patches` fails. ++var SENTRY_TOP_LEVEL_BOOLEAN_FLAGS = new Set(["--verbose", "-v", "--json", "--no-verbose", "--no-json"]); ++var SENTRY_TOP_LEVEL_VALUE_FLAGS = new Set(["--log-level", "--fields", "--org", "--project"]); ++function matchSentryTopLevelFlag(input) { ++ if (SENTRY_TOP_LEVEL_BOOLEAN_FLAGS.has(input)) { ++ return { takesValue: false, inlineValue: false }; ++ } ++ if (SENTRY_TOP_LEVEL_VALUE_FLAGS.has(input)) { ++ return { takesValue: true, inlineValue: false }; ++ } ++ const eqIndex = input.indexOf("="); ++ if (eqIndex !== -1 && SENTRY_TOP_LEVEL_VALUE_FLAGS.has(input.slice(0, eqIndex))) { ++ return { takesValue: true, inlineValue: true }; ++ } ++ return null; ++} + function buildRouteScanner(root, config, startingPrefix) { + const prefix = [...startingPrefix]; + const unprocessedInputs = []; +@@ -1286,6 +1309,7 @@ function buildRouteScanner(root, config, startingPrefix) { + let rootLevel = true; + let helpRequested = false; + let treatInputsAsArguments = false; ++ let expectTopLevelFlagValue = false; + return { + next: (input) => { + if (!treatInputsAsArguments && config.allowArgumentEscapeSequence && input === "--") { +@@ -1293,6 +1317,11 @@ function buildRouteScanner(root, config, startingPrefix) { + unprocessedInputs.push(input); + return; + } ++ if (!treatInputsAsArguments && !target && expectTopLevelFlagValue) { ++ expectTopLevelFlagValue = false; ++ unprocessedInputs.push(input); ++ return; ++ } + if (!treatInputsAsArguments) { + if (input === "--help" || input === "-h") { + helpRequested = true; +@@ -1300,7 +1329,7 @@ function buildRouteScanner(root, config, startingPrefix) { target = current; } return; @@ -11,7 +61,37 @@ index a9afbc1ab78cad7ba97d53882a80236af62854df..8c66958d340038dd8669848d0e2dbd49 helpRequested = "all"; if (!target) { target = current; -@@ -1881,7 +1881,7 @@ function formatDocumentationForFlagParameters(flags, aliases, args) { +@@ -1308,6 +1337,16 @@ function buildRouteScanner(root, config, startingPrefix) { + return; + } + } ++ if (!treatInputsAsArguments && !target) { ++ const topLevelFlag = matchSentryTopLevelFlag(input); ++ if (topLevelFlag) { ++ unprocessedInputs.push(input); ++ if (topLevelFlag.takesValue && !topLevelFlag.inlineValue) { ++ expectTopLevelFlagValue = true; ++ } ++ return; ++ } ++ } + if (target) { + unprocessedInputs.push(input); + return; +@@ -1410,7 +1449,11 @@ async function runApplication({ root, defaultText, config }, rawInputs, context) + } + } + const inputs = rawInputs.slice(); +- if (config.versionInfo && (inputs[0] === "--version" || inputs[0] === "-v")) { ++ // PATCH(getsentry/cli): drop the built-in `-v`=version alias; the Sentry CLI ++ // maps `-v` to `--verbose` (see GLOBAL_FLAGS), and the route scanner now forwards ++ // `-v` to the leaf command. `--version` remains the version flag. If this comment ++ // is missing, `check:patches` fails. ++ if (config.versionInfo && inputs[0] === "--version") { + let currentVersion; + if ("currentVersion" in config.versionInfo) { + currentVersion = config.versionInfo.currentVersion; +@@ -1881,7 +1924,7 @@ function formatDocumentationForFlagParameters(flags, aliases, args) { if (args.includeHelpAllFlag) { const helpAllFlagName = formatForDisplay("helpAll", args.config.caseStyle); rows.push({ @@ -20,7 +100,7 @@ index a9afbc1ab78cad7ba97d53882a80236af62854df..8c66958d340038dd8669848d0e2dbd49 flagName: atLeastOneOptional ? ` --${helpAllFlagName}` : `--${helpAllFlagName}`, brief: briefs.helpAll, hidden: !args.config.alwaysShowHelpAllFlag -@@ -1920,7 +1920,7 @@ function* generateBuiltInFlagUsageLines(args) { +@@ -1920,7 +1963,7 @@ function* generateBuiltInFlagUsageLines(args) { yield args.config.useAliasInUsageLine ? "-h" : "--help"; if (args.includeHelpAllFlag) { const helpAllFlagName = formatForDisplay("helpAll", args.config.caseStyle); @@ -29,7 +109,7 @@ index a9afbc1ab78cad7ba97d53882a80236af62854df..8c66958d340038dd8669848d0e2dbd49 } if (args.includeVersionFlag) { yield args.config.useAliasInUsageLine ? "-v" : "--version"; -@@ -2057,7 +2057,7 @@ function checkForInvalidVariadicSeparators(flags) { +@@ -2057,7 +2100,7 @@ function checkForInvalidVariadicSeparators(flags) { function buildCommand(builderArgs) { const { flags = {}, aliases = {} } = builderArgs.parameters; checkForReservedFlags(flags, ["help", "helpAll", "help-all"]); @@ -39,10 +119,60 @@ index a9afbc1ab78cad7ba97d53882a80236af62854df..8c66958d340038dd8669848d0e2dbd49 checkForInvalidVariadicSeparators(flags); let loader; diff --git a/dist/index.js b/dist/index.js -index f76639637e812453d107bda3e3ec165fc195f4d8..47b8419d3977b45072d276eeaa71e29df7be36a8 100644 +index f76639637e812453d107bda3e3ec165fc195f4d8..13f059ca7f56375fe3fbdc160f0f3f9cb0d7f008 100644 --- a/dist/index.js +++ b/dist/index.js -@@ -1252,7 +1252,7 @@ function buildRouteScanner(root, config, startingPrefix) { +@@ -1229,6 +1229,29 @@ var RouteMapSymbol = Symbol("RouteMap"); + var CommandSymbol = Symbol("Command"); + + // src/routing/scanner.ts ++// PATCH(getsentry/cli): top-level flags allow-list. ++// Stricli only parses flags at the leaf command, so a global flag placed before ++// the subcommand (e.g. `sentry --verbose issue list`) is treated as an unknown ++// route segment and fails route resolution. This allow-list lets the scanner ++// recognize a fixed set of Sentry global flags at any route depth and forward ++// them (and the value of value-taking flags) to the leaf command via ++// unprocessedInputs, instead of erroring. This replaces the app-level ++// argv-hoist preprocessor. If this constant is missing, `check:patches` fails. ++var SENTRY_TOP_LEVEL_BOOLEAN_FLAGS = new Set(["--verbose", "-v", "--json", "--no-verbose", "--no-json"]); ++var SENTRY_TOP_LEVEL_VALUE_FLAGS = new Set(["--log-level", "--fields", "--org", "--project"]); ++function matchSentryTopLevelFlag(input) { ++ if (SENTRY_TOP_LEVEL_BOOLEAN_FLAGS.has(input)) { ++ return { takesValue: false, inlineValue: false }; ++ } ++ if (SENTRY_TOP_LEVEL_VALUE_FLAGS.has(input)) { ++ return { takesValue: true, inlineValue: false }; ++ } ++ const eqIndex = input.indexOf("="); ++ if (eqIndex !== -1 && SENTRY_TOP_LEVEL_VALUE_FLAGS.has(input.slice(0, eqIndex))) { ++ return { takesValue: true, inlineValue: true }; ++ } ++ return null; ++} + function buildRouteScanner(root, config, startingPrefix) { + const prefix = [...startingPrefix]; + const unprocessedInputs = []; +@@ -1238,6 +1261,7 @@ function buildRouteScanner(root, config, startingPrefix) { + let rootLevel = true; + let helpRequested = false; + let treatInputsAsArguments = false; ++ let expectTopLevelFlagValue = false; + return { + next: (input) => { + if (!treatInputsAsArguments && config.allowArgumentEscapeSequence && input === "--") { +@@ -1245,6 +1269,11 @@ function buildRouteScanner(root, config, startingPrefix) { + unprocessedInputs.push(input); + return; + } ++ if (!treatInputsAsArguments && !target && expectTopLevelFlagValue) { ++ expectTopLevelFlagValue = false; ++ unprocessedInputs.push(input); ++ return; ++ } + if (!treatInputsAsArguments) { + if (input === "--help" || input === "-h") { + helpRequested = true; +@@ -1252,7 +1281,7 @@ function buildRouteScanner(root, config, startingPrefix) { target = current; } return; @@ -51,7 +181,37 @@ index f76639637e812453d107bda3e3ec165fc195f4d8..47b8419d3977b45072d276eeaa71e29d helpRequested = "all"; if (!target) { target = current; -@@ -1833,7 +1833,7 @@ function formatDocumentationForFlagParameters(flags, aliases, args) { +@@ -1260,6 +1289,16 @@ function buildRouteScanner(root, config, startingPrefix) { + return; + } + } ++ if (!treatInputsAsArguments && !target) { ++ const topLevelFlag = matchSentryTopLevelFlag(input); ++ if (topLevelFlag) { ++ unprocessedInputs.push(input); ++ if (topLevelFlag.takesValue && !topLevelFlag.inlineValue) { ++ expectTopLevelFlagValue = true; ++ } ++ return; ++ } ++ } + if (target) { + unprocessedInputs.push(input); + return; +@@ -1362,7 +1401,11 @@ async function runApplication({ root, defaultText, config }, rawInputs, context) + } + } + const inputs = rawInputs.slice(); +- if (config.versionInfo && (inputs[0] === "--version" || inputs[0] === "-v")) { ++ // PATCH(getsentry/cli): drop the built-in `-v`=version alias; the Sentry CLI ++ // maps `-v` to `--verbose` (see GLOBAL_FLAGS), and the route scanner now forwards ++ // `-v` to the leaf command. `--version` remains the version flag. If this comment ++ // is missing, `check:patches` fails. ++ if (config.versionInfo && inputs[0] === "--version") { + let currentVersion; + if ("currentVersion" in config.versionInfo) { + currentVersion = config.versionInfo.currentVersion; +@@ -1833,7 +1876,7 @@ function formatDocumentationForFlagParameters(flags, aliases, args) { if (args.includeHelpAllFlag) { const helpAllFlagName = formatForDisplay("helpAll", args.config.caseStyle); rows.push({ @@ -60,7 +220,7 @@ index f76639637e812453d107bda3e3ec165fc195f4d8..47b8419d3977b45072d276eeaa71e29d flagName: atLeastOneOptional ? ` --${helpAllFlagName}` : `--${helpAllFlagName}`, brief: briefs.helpAll, hidden: !args.config.alwaysShowHelpAllFlag -@@ -1872,7 +1872,7 @@ function* generateBuiltInFlagUsageLines(args) { +@@ -1872,7 +1915,7 @@ function* generateBuiltInFlagUsageLines(args) { yield args.config.useAliasInUsageLine ? "-h" : "--help"; if (args.includeHelpAllFlag) { const helpAllFlagName = formatForDisplay("helpAll", args.config.caseStyle); @@ -69,7 +229,7 @@ index f76639637e812453d107bda3e3ec165fc195f4d8..47b8419d3977b45072d276eeaa71e29d } if (args.includeVersionFlag) { yield args.config.useAliasInUsageLine ? "-v" : "--version"; -@@ -2009,7 +2009,7 @@ function checkForInvalidVariadicSeparators(flags) { +@@ -2009,7 +2052,7 @@ function checkForInvalidVariadicSeparators(flags) { function buildCommand(builderArgs) { const { flags = {}, aliases = {} } = builderArgs.parameters; checkForReservedFlags(flags, ["help", "helpAll", "help-all"]); diff --git a/packages/cli/script/check-patches.ts b/packages/cli/script/check-patches.ts index 28bde4f496..936ef7a1dc 100644 --- a/packages/cli/script/check-patches.ts +++ b/packages/cli/script/check-patches.ts @@ -169,14 +169,28 @@ for (const [key, patchPath] of Object.entries(patches)) { /** * Content assertions: verify a patch's *effect* is present in the installed - * package, not just that the version matches. Each entry checks that a stale - * (pre-patch) marker is absent from a given installed file. If the marker is - * still present, the patch did not apply and we fail hard. + * package, not just that the version matches. Each entry checks either that a + * stale (pre-patch) `staleMarker` is absent or that a `requiredMarker` (added + * by the patch) is present in a given installed file. If the check fails, the + * patch did not apply and we fail hard. * - * @stricli/core: the unpatched source registers `-H` as the reserved alias for - * `--help-all` via `checkForReservedAliases(aliases, ["h", "H"])`. After our - * patch that becomes `["h"]`. The presence of `"H"` in that call is a reliable - * signal that the patch did NOT apply (in either the ESM or CJS bundle). + * @stricli/core (`-H` alias): the unpatched source registers `-H` as the + * reserved alias for `--help-all` via + * `checkForReservedAliases(aliases, ["h", "H"])`. After our patch that becomes + * `["h"]`. The presence of `"H"` in that call is a reliable signal that the + * patch did NOT apply (in either the ESM or CJS bundle). + * + * @stricli/core (top-level flags): the patch teaches `buildRouteScanner` to + * recognize a fixed allow-list of Sentry global flags (`--verbose`, `--json`, + * `--org`, …) at any route depth, so `sentry --verbose issue list` no longer + * fails route resolution. This is a pure insertion, so it's guarded by a + * `requiredMarker` (`matchSentryTopLevelFlag`) that must be present once + * patched. Its absence means global flags before a subcommand will crash. + * + * @stricli/core (`-v` version alias): the patch also drops Stricli's built-in + * `-v`=version alias in `runApplication` so `-v` stays the Sentry CLI's + * `--verbose` alias at every position; `--version` remains the version flag. + * The stale marker is the original `inputs[0] === "-v"` version check. * * @sentry/core and @sentry/node-core: these are tree-shaking patches that strip * unused re-exports (AI/integration modules) from the build barrels so esbuild @@ -191,7 +205,12 @@ const CONTENT_ASSERTIONS: ReadonlyArray<{ /** Installed file to inspect, relative to the resolved node_modules dir. */ file: string; /** Stale marker that MUST be absent once the patch is applied. */ - staleMarker: string; + staleMarker?: string; + /** + * Marker that MUST be present once the patch is applied. Used for patches + * that add code (pure insertions) with no stale line to key off of. + */ + requiredMarker?: string; /** Human-readable explanation shown on failure. */ description: string; }> = [ @@ -207,6 +226,30 @@ const CONTENT_ASSERTIONS: ReadonlyArray<{ description: "@stricli/core CJS: -H alias not freed (api -H/--header will crash)", }, + { + file: "@stricli/core/dist/index.js", + requiredMarker: "matchSentryTopLevelFlag", + description: + "@stricli/core ESM: top-level-flags scanner allow-list missing (global flags before a subcommand, e.g. `sentry --verbose issue list`, will fail route resolution)", + }, + { + file: "@stricli/core/dist/index.cjs", + requiredMarker: "matchSentryTopLevelFlag", + description: + "@stricli/core CJS: top-level-flags scanner allow-list missing (global flags before a subcommand, e.g. `sentry --verbose issue list`, will fail route resolution)", + }, + { + file: "@stricli/core/dist/index.js", + staleMarker: 'inputs[0] === "--version" || inputs[0] === "-v"', + description: + "@stricli/core ESM: built-in `-v`=version alias not dropped (`sentry -v ` prints the version instead of running the command verbosely)", + }, + { + file: "@stricli/core/dist/index.cjs", + staleMarker: 'inputs[0] === "--version" || inputs[0] === "-v"', + description: + "@stricli/core CJS: built-in `-v`=version alias not dropped (`sentry -v ` prints the version instead of running the command verbosely)", + }, { file: "@sentry/core/build/cjs/index.js", staleMarker: "exports.instrumentOpenAiClient", @@ -240,7 +283,15 @@ for (const assertion of CONTENT_ASSERTIONS) { throw new Error("unresolved"); } const contents = await readFile(assertionPath, "utf-8"); - if (contents.includes(assertion.staleMarker)) { + if (assertion.staleMarker && contents.includes(assertion.staleMarker)) { + errors.push( + ` ${assertion.description} — patch not applied to ${assertion.file} (regenerate the patch for the current dependency version)` + ); + } + if ( + assertion.requiredMarker && + !contents.includes(assertion.requiredMarker) + ) { errors.push( ` ${assertion.description} — patch not applied to ${assertion.file} (regenerate the patch for the current dependency version)` ); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 2e483d663e..8093a45685 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -161,7 +161,7 @@ export async function runCli(cliArgs: string[]): Promise { const { isatty } = await import("node:tty"); const { ExitCode, run } = await import("@stricli/core"); const { app } = await import("./app.js"); - const { preprocessArgv } = await import("./lib/argv-hoist.js"); + const { preprocessArgv } = await import("./lib/argv-glue.js"); const { buildContext } = await import("./context.js"); const { AuthError, OutputError, formatError, getExitCode } = await import( "./lib/errors.js" @@ -185,15 +185,17 @@ export async function runCli(cliArgs: string[]): Promise { shouldSuppressNotification, } = await import("./lib/version-check.js"); - // Preprocess argv before dispatch (see preprocessArgv): + // Normalize argv before dispatch (see preprocessArgv). Global-flag hoisting is + // now handled by Stricli's patched route scanner (top-level-flags allow-list), + // so only two application-boundary transforms remain: // - `--version` after a route group/subcommand (e.g. `sentry cli --version`) // is normalized to a top-level `--version`; Stricli only handles it at the // application proxy. `-v` is left alone — it's the --verbose alias. - // - global flags (--verbose, -v, --log-level, --json, --fields) are hoisted - // to the tail so `sentry --verbose issue list` works. + // - a flag-based `--help --json` request is rewritten to the `help` command + // so JSON help works for the `--help` forms agents reach for. // The original cliArgs are kept for post-run checks (e.g., help recovery) // that rely on the original token positions. - const hoistedArgs = preprocessArgv(cliArgs); + const normalizedArgs = preprocessArgv(cliArgs); // --------------------------------------------------------------------------- // Error-recovery middleware @@ -616,7 +618,7 @@ export async function runCli(cliArgs: string[]): Promise { // Use hoisted args so positional checks (e.g., args[0] === "cli") work // even when global flags precede the subcommand in the original argv. - const suppressNotification = shouldSuppressNotification(hoistedArgs); + const suppressNotification = shouldSuppressNotification(normalizedArgs); // Start background update check (non-blocking) if (!suppressNotification) { @@ -624,7 +626,7 @@ export async function runCli(cliArgs: string[]): Promise { } try { - await executor(hoistedArgs); + await executor(normalizedArgs); // When Stricli can't match a subcommand in a route group (e.g., // `sentry dashboard help`), it writes "No command registered for `help`" @@ -660,7 +662,7 @@ export async function runCli(cliArgs: string[]): Promise { } process.stderr.write(`${error("Error:")} ${formatError(err)}\n`); process.exitCode = getExitCode(err); - const notification = getErrorUpdateNotification(err, hoistedArgs); + const notification = getErrorUpdateNotification(err, normalizedArgs); if (notification) { process.stderr.write(notification); } diff --git a/packages/cli/src/lib/argv-glue.ts b/packages/cli/src/lib/argv-glue.ts new file mode 100644 index 0000000000..36c46a676e --- /dev/null +++ b/packages/cli/src/lib/argv-glue.ts @@ -0,0 +1,263 @@ +/** + * Application-boundary argv glue applied before Stricli dispatch. + * + * Global flags placed before the subcommand (`sentry --verbose issue list`) are + * now recognized directly by Stricli's route scanner via our `@stricli/core` + * patch (a top-level-flags allow-list in `buildRouteScanner`), so no argv + * hoisting is needed. Two behaviors still have to be handled at the application + * boundary, before `run()`, because Stricli only implements them at fixed + * positions: + * + * 1. **`--version` at any depth.** Stricli prints the version only when + * `--version`/`-v` is the very first token (handled in its `runApplication` + * before the scanner runs). `sentry cli --version` or + * `sentry --version` would otherwise route `--version` as an + * unknown subcommand. {@link isVersionRequest} detects a bare `--version` + * anywhere before a `--` escape so we can normalize it to `["--version"]`. + * + * 2. **`--help --json` structured output.** Stricli intercepts `--help` and + * prints its own text usage, ignoring `--json`. The dedicated `help` command + * already emits structured JSON via `introspectAllCommands` / + * `introspectCommand`, so {@link rewriteHelpJsonRequest} reroutes a flag-based + * `--help --json` request to that command, giving both help UX paths + * identical JSON. + * + * Flag metadata is derived from the shared {@link GLOBAL_FLAGS} definition so + * the glue stays in sync with the flags injected by `buildCommand`. + */ + +import { GLOBAL_FLAGS } from "./global-flags.js"; + +/** Long flag name → whether the flag consumes the next token as its value. */ +const FLAG_TAKES_VALUE = new Map( + GLOBAL_FLAGS.map((f) => [f.name, f.kind === "value"]) +); + +/** Short alias → whether the flag consumes the next token as its value. */ +const SHORT_TAKES_VALUE = new Map( + GLOBAL_FLAGS.filter( + (f): f is (typeof GLOBAL_FLAGS)[number] & { short: string } => + f.short !== null + ).map((f) => [f.short, f.kind === "value"]) +); + +/** Long names that support `--no-` negation (boolean flags). */ +const NEGATABLE_NAMES = new Set( + GLOBAL_FLAGS.filter((f) => f.kind === "boolean").map((f) => f.name) +); + +/** + * Detect a top-level `--version` request anywhere in the command path. + * + * Stricli only handles `--version` at the application proxy, so it works for + * `sentry --version` but not for `sentry cli --version` (the route map treats + * `--version` as an unknown subcommand) or `sentry --version`. + * Callers use this to normalize such invocations to a plain `--version` so the + * app-level handler prints the version consistently. + * + * Only the long `--version` form is recognized: `-v` is the reserved short + * alias for `--verbose` (see {@link GLOBAL_FLAGS}). Tokens after a `--` escape + * separator are ignored so `sentry monitor run -- tool --version` + * forwards `--version` to the wrapped command instead of printing the CLI + * version. The `--version=value` form is not matched (no command defines a + * `--version` value flag). + * + * This is a naive token scan, so a bare `--version` token always wins — even + * when it would otherwise be the value of a preceding value flag (e.g. the + * contrived `sentry issue list -q --version`). Use the `=` form + * (`-q=--version`) to pass the literal string instead. No command defines a + * `--version` flag, so there is no real collision. + * + * @param argv - Raw CLI arguments (e.g., `process.argv.slice(2)`) + * @returns true if a bare `--version` token appears before any `--` separator + */ +export function isVersionRequest(argv: readonly string[]): boolean { + for (const token of argv) { + if (token === "--") { + return false; + } + if (token === "--version") { + return true; + } + } + return false; +} + +/** + * Accumulator for {@link rewriteHelpJsonRequest} while scanning argv. + */ +type HelpJsonScan = { + hasHelp: boolean; + hasJson: boolean; + commandPath: string[]; + fields: string | undefined; +}; + +/** + * True when `token` is a boolean-style global flag that does NOT consume a + * following value token — a known boolean global flag (`--verbose`, `--json`, + * its `-v` alias, or a `--no-` negation). Everything else that starts with + * `-` is assumed to be value-taking, so its next token is a flag value rather + * than a command-path segment. + * + * Used by {@link scanHelpJsonToken} to avoid swallowing a real path segment + * after a boolean flag (`issue --verbose list`) while still discarding the + * values of value flags (`--org acme`, `--limit 5`). + */ +function isBooleanFlagToken(token: string): boolean { + if (token.length === 2 && token[0] === "-" && token[1] !== "-") { + const takesValue = SHORT_TAKES_VALUE.get(token[1] ?? ""); + return takesValue === undefined ? false : !takesValue; + } + if (!token.startsWith("--")) { + return false; + } + const name = token.slice(2); + if (name.startsWith("no-") && NEGATABLE_NAMES.has(name.slice(3))) { + return true; + } + const takesValue = FLAG_TAKES_VALUE.get(name); + return takesValue === undefined ? false : !takesValue; +} + +/** + * Fold a single argv token into the {@link HelpJsonScan} accumulator. + * + * Recognizes `--help` (and its `-h` alias), `--json`, and `--fields` (both + * spaced and `=` forms), collects non-flag tokens as the command path, and + * drops all other flags — including the spaced value of any value-taking flag + * (`--org acme`, `--limit 5`) so those values never leak into the resolved + * command path. + * + * @returns The number of tokens consumed (1, or 2 when a value flag's spaced + * value is dropped alongside it). + */ +function scanHelpJsonToken( + argv: readonly string[], + index: number, + scan: HelpJsonScan +): number { + const token = argv[index] ?? ""; + // `-h` is Stricli's built-in short alias for `--help`, so it must trigger the + // JSON rewrite too — otherwise `sentry -h --json` falls through to text usage. + if (token === "--help" || token === "-h") { + scan.hasHelp = true; + return 1; + } + if (token === "--json") { + scan.hasJson = true; + return 1; + } + if (token.startsWith("--fields=")) { + scan.fields = token.slice("--fields=".length); + return 1; + } + const next = argv[index + 1]; + // A spaced value is only present when the next token isn't itself a flag — + // `--fields --json` leaves --fields valueless rather than eating --json. + const hasSpacedValue = next !== undefined && !next.startsWith("-"); + if (token === "--fields") { + if (hasSpacedValue) { + scan.fields = next; + return 2; + } + return 1; + } + if (!token.startsWith("-")) { + scan.commandPath.push(token); + return 1; + } + // Any other flag is irrelevant to the help command's structured output and + // is dropped. A value flag (`--org acme`, `--limit 5`) also drops its spaced + // value so it isn't mistaken for a command-path segment; a boolean flag + // (`--verbose`) leaves the following token for the path. An `=`-form flag + // (`--org=acme`) already carries its value inline, so it never consumes the + // following token — dropping it would swallow a real command-path segment. + if (hasSpacedValue && !token.includes("=") && !isBooleanFlagToken(token)) { + return 2; + } + return 1; +} + +/** + * Rewrite a flag-based `--help --json` request into a `help` command invocation. + * + * Stricli handles `--help` internally by printing its own text usage and + * ignores `--json` entirely, so `sentry --help --json` and + * `sentry --help --json` never produce structured output. Agents and + * tooling reach for `--help` first, so we rewrite these forms to the dedicated + * `help` command — which already emits JSON via {@link introspectAllCommands} + * and {@link introspectCommand} — giving both help UX paths identical JSON. + * + * The rewrite only fires when **both** `--help` and `--json` appear before any + * `--` escape separator. A bare `--help` (no `--json`) is left untouched so + * Stricli's existing human usage output is preserved unchanged. + * + * The command path is the sequence of non-flag tokens (e.g. `issue list`), and + * a `--fields ` (or `--fields=`) flag is carried through so field + * selection keeps working. The result is `["help", "--json", ...path]` with + * `--fields` appended when present. + * + * @param argv - Raw CLI arguments (e.g., `process.argv.slice(2)`) + * @returns The rewritten `help`-command argv, or `null` if the request is not a + * `--help --json` combination and should be processed normally. + */ +export function rewriteHelpJsonRequest( + argv: readonly string[] +): string[] | null { + const scan: HelpJsonScan = { + hasHelp: false, + hasJson: false, + commandPath: [], + fields: undefined, + }; + + for (let i = 0; i < argv.length; ) { + // Tokens after -- are positional/pass-through — a --help there is not ours. + if (argv[i] === "--") { + return null; + } + i += scanHelpJsonToken(argv, i, scan); + } + + if (!(scan.hasHelp && scan.hasJson)) { + return null; + } + + const rewritten = ["help", "--json", ...scan.commandPath]; + if (scan.fields !== undefined) { + rewritten.push("--fields", scan.fields); + } + return rewritten; +} + +/** + * Normalize raw CLI argv before Stricli dispatch. + * + * Global-flag hoisting is handled inside Stricli's patched route scanner, so + * this only covers the two application-boundary transforms Stricli can't do at + * arbitrary route depth: + * + * 1. A flag-based `--help --json` request (see {@link rewriteHelpJsonRequest}) + * is rewritten to the dedicated `help` command so JSON help works for the + * `--help` forms agents reach for (`sentry --help --json`, + * `sentry issue --help --json`), matching `sentry help --json`. + * 2. A top-level `--version` (see {@link isVersionRequest}) is normalized to a + * plain `["--version"]` so the application-level version handler prints it + * regardless of how deep in the route tree it appeared. + * + * When neither applies, argv is returned unchanged for the scanner to handle. + * + * @param argv - Raw CLI arguments (e.g., `process.argv.slice(2)`) + * @returns The argv to hand to Stricli's `run` + */ +export function preprocessArgv(argv: readonly string[]): string[] { + const helpJson = rewriteHelpJsonRequest(argv); + if (helpJson) { + return helpJson; + } + if (isVersionRequest(argv)) { + return ["--version"]; + } + return [...argv]; +} diff --git a/packages/cli/src/lib/argv-hoist.ts b/packages/cli/src/lib/argv-hoist.ts deleted file mode 100644 index 32eca047cb..0000000000 --- a/packages/cli/src/lib/argv-hoist.ts +++ /dev/null @@ -1,486 +0,0 @@ -/** - * Argv preprocessor that moves global flags to the end of the argument list. - * - * Stricli only parses flags at the leaf command level, so flags like - * `--verbose` placed before the subcommand (`sentry --verbose issue list`) - * fail route resolution. This module relocates known global flags from any - * position to the tail of argv where Stricli's leaf-command parser can - * find them. - * - * Flag metadata is derived from the shared {@link GLOBAL_FLAGS} definition - * in `global-flags.ts` so both the hoisting preprocessor and the - * `buildCommand` injection stay in sync automatically. - */ - -import { GLOBAL_FLAGS } from "./global-flags.js"; - -/** Resolved flag metadata used by the hoisting algorithm. */ -type HoistableFlag = { - /** Long flag name without `--` prefix (e.g., `"verbose"`) */ - readonly name: string; - /** Single-char short alias without `-` prefix, or `null` if none */ - readonly short: string | null; - /** Whether the flag consumes the next token as its value */ - readonly takesValue: boolean; - /** Whether `--no-` is recognized as the negation form */ - readonly negatable: boolean; -}; - -/** Derive hoisting metadata from the shared flag definitions. */ -const HOISTABLE_FLAGS: readonly HoistableFlag[] = GLOBAL_FLAGS.map((f) => ({ - name: f.name, - short: f.short, - takesValue: f.kind === "value", - negatable: f.kind === "boolean", -})); - -/** Pre-built lookup: long name → flag definition */ -const FLAG_BY_NAME = new Map(HOISTABLE_FLAGS.map((f) => [f.name, f])); - -/** Pre-built lookup: short alias → flag definition */ -const FLAG_BY_SHORT = new Map( - HOISTABLE_FLAGS.filter( - (f): f is HoistableFlag & { short: string } => f.short !== null - ).map((f) => [f.short, f]) -); - -/** Names that support `--no-` negation */ -const NEGATABLE_NAMES = new Set( - HOISTABLE_FLAGS.filter((f) => f.negatable).map((f) => f.name) -); - -/** - * Flags whose values may start with `-`. - * - * Stricli treats a following token like `--format=x` as a separate flag, not - * as the value of `--from`. Rewriting `--from --format=x` → `--from=--format=x` - * lets the leaf parser pass the ref through to validation. - */ -const DASHED_VALUE_FLAGS = new Set(["from"]); - -/** - * Leaf flags on `release set-commits` that must not be swallowed as a `--from` - * value when rewrite runs (only `--from` uses {@link DASHED_VALUE_FLAGS} today). - */ -const SET_COMMITS_FROM_NEIGHBOR_FLAGS = new Set([ - "auto", - "local", - "clear", - "commit", - "path", - "from", - "initial-depth", -]); - -/** True when `token` is a registered global or set-commits leaf flag. */ -function isRegisteredFlagToken(token: string): boolean { - if (matchHoistable(token) !== null) { - return true; - } - if (!token.startsWith("--")) { - return false; - } - const eqIdx = token.indexOf("="); - const name = eqIdx === -1 ? token.slice(2) : token.slice(2, eqIdx); - return SET_COMMITS_FROM_NEIGHBOR_FLAGS.has(name); -} - -/** - * Match result from {@link matchHoistable}. - * - * - `"plain"`: `--flag` (boolean) or `--flag` (value-taking, value is next token) - * - `"eq"`: `--flag=value` (value embedded in token) - * - `"negated"`: `--no-flag` - * - `"short"`: `-v` (single-char alias) - */ -type MatchForm = "plain" | "eq" | "negated" | "short"; - -/** Try matching a `--no-` negation form. */ -function matchNegated( - name: string -): { flag: HoistableFlag; form: MatchForm } | null { - if (!name.startsWith("no-")) { - return null; - } - const baseName = name.slice(3); - if (!NEGATABLE_NAMES.has(baseName)) { - return null; - } - const flag = FLAG_BY_NAME.get(baseName); - return flag ? { flag, form: "negated" } : null; -} - -/** - * Match a token against the hoistable flag registry. - * - * @returns The matched flag and form, or `null` if not hoistable. - */ -function matchHoistable( - token: string -): { flag: HoistableFlag; form: MatchForm } | null { - // Short alias: -v (exactly two chars, dash + letter) - if (token.length === 2 && token[0] === "-" && token[1] !== "-") { - const flag = FLAG_BY_SHORT.get(token[1] ?? ""); - return flag ? { flag, form: "short" } : null; - } - - if (!token.startsWith("--")) { - return null; - } - - // --flag=value form - const eqIdx = token.indexOf("="); - if (eqIdx !== -1) { - const name = token.slice(2, eqIdx); - const flag = FLAG_BY_NAME.get(name); - return flag?.takesValue ? { flag, form: "eq" } : null; - } - - const name = token.slice(2); - const negated = matchNegated(name); - if (negated) { - return negated; - } - const flag = FLAG_BY_NAME.get(name); - return flag ? { flag, form: "plain" } : null; -} - -/** - * Hoist a single matched flag token (and its value if applicable) into the - * `hoisted` array, advancing the index past the consumed tokens. - * - * Extracted from the main loop to keep {@link hoistGlobalFlags} under - * Biome's cognitive complexity limit. - */ -function consumeFlag( - argv: readonly string[], - index: number, - match: { flag: HoistableFlag; form: MatchForm }, - hoisted: string[] -): number { - const token = argv[index] ?? ""; - - // --flag=value or --no-flag: always a single token - if (match.form === "eq" || match.form === "negated") { - hoisted.push(token); - return index + 1; - } - - // --flag or -v: may consume a following value token - if (match.flag.takesValue) { - hoisted.push(token); - const next = index + 1; - if (next < argv.length) { - hoisted.push(argv[next] ?? ""); - return next + 1; - } - // No value follows — the bare flag is still hoisted; - // Stricli will report the missing value at parse time. - return next; - } - - // Boolean flag (--flag or -v): single token - hoisted.push(token); - return index + 1; -} - -/** - * Detect a top-level `--version` request anywhere in the command path. - * - * Stricli only handles `--version` at the application proxy, so it works for - * `sentry --version` but not for `sentry cli --version` (the route map treats - * `--version` as an unknown subcommand) or `sentry --version`. - * Callers use this to normalize such invocations to a plain `--version` so the - * app-level handler prints the version consistently. - * - * Only the long `--version` form is recognized: `-v` is the reserved short - * alias for `--verbose` (see {@link GLOBAL_FLAGS}). Tokens after a `--` escape - * separator are ignored so `sentry monitor run -- tool --version` - * forwards `--version` to the wrapped command instead of printing the CLI - * version. The `--version=value` form is not matched (no command defines a - * `--version` value flag). - * - * This is a naive token scan, so a bare `--version` token always wins — even - * when it would otherwise be the value of a preceding value flag (e.g. the - * contrived `sentry issue list -q --version`). Use the `=` form - * (`-q=--version`) to pass the literal string instead. No command defines a - * `--version` flag, so there is no real collision. - * - * @param argv - Raw CLI arguments (e.g., `process.argv.slice(2)`) - * @returns true if a bare `--version` token appears before any `--` separator - */ -export function isVersionRequest(argv: readonly string[]): boolean { - for (const token of argv) { - if (token === "--") { - return false; - } - if (token === "--version") { - return true; - } - } - return false; -} - -/** - * Accumulator for {@link rewriteHelpJsonRequest} while scanning argv. - */ -type HelpJsonScan = { - hasHelp: boolean; - hasJson: boolean; - commandPath: string[]; - fields: string | undefined; -}; - -/** - * True when `token` is a boolean-style flag that does NOT consume a following - * value token — a known boolean global flag (`--verbose`, `--json`, its `-v` - * alias, or a `--no-` negation). Everything else that starts with `-` is - * assumed to be value-taking, so its next token is a flag value rather than a - * command-path segment. - * - * Used by {@link scanHelpJsonToken} to avoid swallowing a real path segment - * after a boolean flag (`issue --verbose list`) while still discarding the - * values of value flags (`--org acme`, `--limit 5`). - */ -function isBooleanFlagToken(token: string): boolean { - if (token.length === 2 && token[0] === "-" && token[1] !== "-") { - const flag = FLAG_BY_SHORT.get(token[1] ?? ""); - return flag ? !flag.takesValue : false; - } - if (!token.startsWith("--")) { - return false; - } - const name = token.slice(2); - if (name.startsWith("no-") && NEGATABLE_NAMES.has(name.slice(3))) { - return true; - } - const flag = FLAG_BY_NAME.get(name); - return flag ? !flag.takesValue : false; -} - -/** - * Fold a single argv token into the {@link HelpJsonScan} accumulator. - * - * Recognizes `--help` (and its `-h` alias), `--json`, and `--fields` (both - * spaced and `=` forms), collects non-flag tokens as the command path, and - * drops all other flags — including the spaced value of any value-taking flag - * (`--org acme`, `--limit 5`) so those values never leak into the resolved - * command path. - * - * @returns The number of tokens consumed (1, or 2 when a value flag's spaced - * value is dropped alongside it). - */ -function scanHelpJsonToken( - argv: readonly string[], - index: number, - scan: HelpJsonScan -): number { - const token = argv[index] ?? ""; - // `-h` is Stricli's built-in short alias for `--help`, so it must trigger the - // JSON rewrite too — otherwise `sentry -h --json` falls through to text usage. - if (token === "--help" || token === "-h") { - scan.hasHelp = true; - return 1; - } - if (token === "--json") { - scan.hasJson = true; - return 1; - } - if (token.startsWith("--fields=")) { - scan.fields = token.slice("--fields=".length); - return 1; - } - const next = argv[index + 1]; - // A spaced value is only present when the next token isn't itself a flag — - // `--fields --json` leaves --fields valueless rather than eating --json. - const hasSpacedValue = next !== undefined && !next.startsWith("-"); - if (token === "--fields") { - if (hasSpacedValue) { - scan.fields = next; - return 2; - } - return 1; - } - if (!token.startsWith("-")) { - scan.commandPath.push(token); - return 1; - } - // Any other flag is irrelevant to the help command's structured output and - // is dropped. A value flag (`--org acme`, `--limit 5`) also drops its spaced - // value so it isn't mistaken for a command-path segment; a boolean flag - // (`--verbose`) leaves the following token for the path. An `=`-form flag - // (`--org=acme`) already carries its value inline, so it never consumes the - // following token — dropping it would swallow a real command-path segment. - if (hasSpacedValue && !token.includes("=") && !isBooleanFlagToken(token)) { - return 2; - } - return 1; -} - -/** - * Rewrite a flag-based `--help --json` request into a `help` command invocation. - * - * Stricli handles `--help` internally by printing its own text usage and - * ignores `--json` entirely, so `sentry --help --json` and - * `sentry --help --json` never produce structured output. Agents and - * tooling reach for `--help` first, so we rewrite these forms to the dedicated - * `help` command — which already emits JSON via {@link introspectAllCommands} - * and {@link introspectCommand} — giving both help UX paths identical JSON. - * - * The rewrite only fires when **both** `--help` and `--json` appear before any - * `--` escape separator. A bare `--help` (no `--json`) is left untouched so - * Stricli's existing human usage output is preserved unchanged. - * - * The command path is the sequence of non-flag tokens (e.g. `issue list`), and - * a `--fields ` (or `--fields=`) flag is carried through so field - * selection keeps working. The result is `["help", "--json", ...path]` with - * `--fields` appended when present. - * - * @param argv - Raw CLI arguments (e.g., `process.argv.slice(2)`) - * @returns The rewritten `help`-command argv, or `null` if the request is not a - * `--help --json` combination and should be processed normally. - */ -export function rewriteHelpJsonRequest( - argv: readonly string[] -): string[] | null { - const scan: HelpJsonScan = { - hasHelp: false, - hasJson: false, - commandPath: [], - fields: undefined, - }; - - for (let i = 0; i < argv.length; ) { - // Tokens after -- are positional/pass-through — a --help there is not ours. - if (argv[i] === "--") { - return null; - } - i += scanHelpJsonToken(argv, i, scan); - } - - if (!(scan.hasHelp && scan.hasJson)) { - return null; - } - - const rewritten = ["help", "--json", ...scan.commandPath]; - if (scan.fields !== undefined) { - rewritten.push("--fields", scan.fields); - } - return rewritten; -} - -/** - * Move global flags from any position in argv to the end. - * - * Tokens after `--` are never touched. The relative order of both - * hoisted and non-hoisted tokens is preserved. - * - * @param argv - Raw CLI arguments (e.g., `process.argv.slice(2)`) - * @returns New array with global flags relocated to the tail - */ -export function hoistGlobalFlags(argv: readonly string[]): string[] { - const remaining: string[] = []; - const hoisted: string[] = []; - /** Tokens from `--` onward (positional-only region). */ - const positionalTail: string[] = []; - - let i = 0; - while (i < argv.length) { - const token = argv[i] ?? ""; - - // Stop scanning at -- separator; pass everything through verbatim. - // Hoisted flags must appear BEFORE -- so Stricli parses them as flags. - if (token === "--") { - for (let j = i; j < argv.length; j += 1) { - positionalTail.push(argv[j] ?? ""); - } - break; - } - - const match = matchHoistable(token); - if (match) { - i = consumeFlag(argv, i, match, hoisted); - } else { - remaining.push(token); - i += 1; - } - } - - return [...remaining, ...hoisted, ...positionalTail]; -} - -/** - * Rewrite `--flag VALUE` as `--flag=VALUE` when `VALUE` looks like a long flag. - * - * Stricli's parser treats dashed tokens as flags, so `--from --format=x` fails - * before the command sees the ref. Only unregistered `--`-prefixed tokens are - * merged — registered global flags (`--json`) and set-commits leaf flags - * (`--auto`) are left separate; short flags like `-v` are never merged. Git refs cannot start with `-` anyway; merged injection - * attempts still reach our validation guard. - * - * Tokens after `--` are never touched. - * - * @param argv - Raw CLI arguments (e.g., `process.argv.slice(2)`) - * @returns New array with eligible flag/value pairs collapsed to `--flag=value` - */ -export function rewriteDashedFlagValues(argv: readonly string[]): string[] { - const result: string[] = []; - let i = 0; - while (i < argv.length) { - const token = argv[i] ?? ""; - if (token === "--") { - result.push(...argv.slice(i)); - break; - } - if (token.startsWith("--") && !token.includes("=")) { - const name = token.slice(2); - if (DASHED_VALUE_FLAGS.has(name)) { - const next = argv[i + 1]; - if ( - next?.startsWith("--") && - next !== "--" && - !isRegisteredFlagToken(next) - ) { - result.push(`${token}=${next}`); - i += 2; - continue; - } - } - } - result.push(token); - i += 1; - } - return result; -} - -/** - * Preprocess raw CLI argv before Stricli dispatch. - * - * Composes the argv transforms applied on every invocation: - * 1. A flag-based `--help --json` request (see {@link rewriteHelpJsonRequest}) - * is rewritten to the dedicated `help` command so JSON help works for the - * `--help` forms agents reach for (`sentry --help --json`, - * `sentry issue --help --json`), matching `sentry help --json`. - * 2. A top-level `--version` (see {@link isVersionRequest}) is normalized to a - * plain `["--version"]` so the application-level version handler prints it - * regardless of how deep in the route tree it appeared. - * 3. Otherwise, dashed flag values are rewritten (see - * {@link rewriteDashedFlagValues}), then global flags are hoisted to the - * tail (see {@link hoistGlobalFlags}). - * - * Kept as a single entry point so callers apply one transform and stay under - * the cognitive-complexity budget. - * - * @param argv - Raw CLI arguments (e.g., `process.argv.slice(2)`) - * @returns The argv to hand to Stricli's `run` - */ -export function preprocessArgv(argv: readonly string[]): string[] { - const helpJson = rewriteHelpJsonRequest(argv); - if (helpJson) { - return helpJson; - } - if (isVersionRequest(argv)) { - return ["--version"]; - } - return hoistGlobalFlags(rewriteDashedFlagValues(argv)); -} diff --git a/packages/cli/src/lib/global-flags.ts b/packages/cli/src/lib/global-flags.ts index 4fed568992..ce3633218f 100644 --- a/packages/cli/src/lib/global-flags.ts +++ b/packages/cli/src/lib/global-flags.ts @@ -2,8 +2,9 @@ * Single source of truth for global CLI flags. * * Global flags are injected into every leaf command by {@link buildCommand} - * and hoisted from any argv position by {@link hoistGlobalFlags}. This - * module defines the metadata once so both systems stay in sync + * and recognized at any argv position by Stricli's patched route scanner (a + * top-level-flags allow-list) plus the app-boundary glue in `argv-glue.ts`. + * This module defines the metadata once so those systems stay in sync * automatically — adding a flag here is all that's needed. * * The Stricli flag *shapes* (kind, brief, default, etc.) remain in @@ -32,8 +33,15 @@ type GlobalFlagDef = { /** * All global flags that are injected into every leaf command. * - * Order doesn't matter — both the hoisting preprocessor and the - * `buildCommand` wrapper build lookup structures from this list. + * Order doesn't matter — both the `buildCommand` wrapper and the app-boundary + * glue build lookup structures from this list. + * + * IMPORTANT: the set of flag tokens recognized *before the subcommand* also + * lives, hardcoded, in the `@stricli/core` route-scanner patch + * (`packages/cli/patches/@stricli%2Fcore@1.2.8.patch`, + * `SENTRY_TOP_LEVEL_*_FLAGS`). Patching minified `dist` code can't import this + * list, so adding/removing a global flag here means updating that patch too, or + * the flag won't be accepted when placed before the subcommand. */ export const GLOBAL_FLAGS: readonly GlobalFlagDef[] = [ { name: "verbose", short: "v", kind: "boolean" }, diff --git a/packages/cli/test/lib/argv-glue.integration.test.ts b/packages/cli/test/lib/argv-glue.integration.test.ts new file mode 100644 index 0000000000..642ba42084 --- /dev/null +++ b/packages/cli/test/lib/argv-glue.integration.test.ts @@ -0,0 +1,177 @@ +/** + * Integration tests for top-level global-flag recognition by Stricli's patched + * route scanner. + * + * The `@stricli/core` patch (see + * `packages/cli/patches/@stricli%2Fcore@1.2.8.patch`) teaches `buildRouteScanner` + * to accept a fixed allow-list of Sentry global flags (`--verbose`, `--json`, + * `--org`, `--project`, `--log-level`, `--fields`, and the `-v` alias) at any + * route depth, forwarding them to the leaf command instead of failing route + * resolution. This replaces the old argv-hoisting preprocessor. + * + * These tests exercise the real `app` end-to-end via `run()` so the patch — + * not a preprocessor — is what makes `sentry --verbose bash-hook` and + * `sentry cli --verbose defaults` route correctly. + */ + +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { run } from "@stricli/core"; +import { describe, expect, test } from "vitest"; +import { app } from "../../src/app.js"; +import type { SentryContext } from "../../src/context.js"; +import { useTestConfigDir } from "../helpers.js"; + +useTestConfigDir("argv-glue-integration-"); + +// Empty working dir so any command that reaches target resolution finds no DSNs +// to auto-detect and fails fast instead of making real network calls. Routing +// has already happened by then, which is all these tests assert. +const emptyCwd = mkdtempSync(join(tmpdir(), "argv-glue-cwd-")); + +/** Run the real app with a mock context, capturing stdout and stderr. */ +async function runApp( + args: string[] +): Promise<{ stdout: string; stderr: string; exitCode: number }> { + let stdout = ""; + let stderr = ""; + const captureStderr = { + write(data: string | Uint8Array) { + stderr += + typeof data === "string" ? data : new TextDecoder().decode(data); + return true; + }, + }; + const context: SentryContext = { + process: { + ...process, + // Route the underlying process streams into our buffers too — Stricli's + // argument-scanner errors write to context.process.stderr, not context.stderr. + stdout: { + write(data: string | Uint8Array) { + stdout += + typeof data === "string" ? data : new TextDecoder().decode(data); + return true; + }, + }, + stderr: captureStderr, + exitCode: undefined, + } as unknown as typeof process, + env: { ...process.env }, + cwd: emptyCwd, + homeDir: "/tmp", + configDir: "/tmp", + stdout: { + write(data: string | Uint8Array) { + stdout += + typeof data === "string" ? data : new TextDecoder().decode(data); + return true; + }, + }, + stderr: captureStderr, + stdin: process.stdin, + }; + + const exitCode = await run(app, args, context); + return { stdout, stderr, exitCode: exitCode ?? 0 }; +} + +/** + * Stricli's error message when the route scanner treats a token as an unknown + * subcommand — the failure mode the patch fixes for global flags at depth. + */ +const NO_COMMAND_REGISTERED = "No command registered"; + +describe("top-level flags on a leaf command (bash-hook, no auth)", () => { + // bash-hook runs without auth and emits its script to stdout, so a successful + // route + execution is observable regardless of where the global flag sits. + + test("--verbose before the command still runs it", async () => { + const { stdout, stderr } = await runApp(["--verbose", "bash-hook"]); + expect(stderr).not.toContain(NO_COMMAND_REGISTERED); + expect(stdout).toContain("_sentry_err_trap"); + }); + + test("-v (verbose alias) before the command runs it, not `--version`", async () => { + // The patch drops Stricli's built-in `-v`=version alias, so `-v` stays the + // Sentry CLI's --verbose alias and reaches the leaf command via the scanner. + const { stdout, stderr } = await runApp(["-v", "bash-hook"]); + expect(stderr).not.toContain(NO_COMMAND_REGISTERED); + expect(stdout).toContain("_sentry_err_trap"); + // Not the bare version string. + expect(stdout).not.toMatch(/^\d+\.\d+\.\d+/); + }); + + test("--log-level with a value before the command still runs it", async () => { + const { stdout, stderr } = await runApp([ + "--log-level", + "debug", + "bash-hook", + ]); + expect(stderr).not.toContain(NO_COMMAND_REGISTERED); + expect(stdout).toContain("_sentry_err_trap"); + }); + + test("a value flag's value is not mistaken for the command", async () => { + // `--org acme` must consume `acme` as the flag value, leaving `bash-hook` + // as the route. A naive scanner would treat `acme` as the subcommand. + const { stdout, stderr } = await runApp(["--org", "acme", "bash-hook"]); + expect(stderr).not.toContain(NO_COMMAND_REGISTERED); + expect(stdout).toContain("_sentry_err_trap"); + }); + + test("--org=acme inline form before the command still runs it", async () => { + const { stdout, stderr } = await runApp(["--org=acme", "bash-hook"]); + expect(stderr).not.toContain(NO_COMMAND_REGISTERED); + expect(stdout).toContain("_sentry_err_trap"); + }); + + test("the command's own flags still parse alongside a global flag", async () => { + const { stdout, stderr } = await runApp([ + "--verbose", + "bash-hook", + "--release", + "1.0.0", + ]); + expect(stderr).not.toContain(NO_COMMAND_REGISTERED); + expect(stdout).toContain("--release '1.0.0'"); + }); +}); + +describe("top-level flags on a nested group command", () => { + // `cli defaults` is a no-auth, no-network group subcommand, so routing through + // the `cli` route map to the `defaults` leaf is observable without side effects. + // The patch is what lets a global flag between (or before) the group and + // subcommand resolve; without it the scanner rejects it as an unknown route. + + test("--verbose between group and subcommand resolves the route", async () => { + const { stderr } = await runApp(["cli", "--verbose", "defaults"]); + expect(stderr).not.toContain(NO_COMMAND_REGISTERED); + }); + + test("--verbose before the group resolves the route", async () => { + const { stderr } = await runApp(["--verbose", "cli", "defaults"]); + expect(stderr).not.toContain(NO_COMMAND_REGISTERED); + }); + + test("a value flag between group and subcommand does not break routing", async () => { + // `cli --org acme defaults`: `acme` is the --org value, `defaults` the + // subcommand — not a route segment. + const { stderr } = await runApp(["cli", "--org", "acme", "defaults"]); + expect(stderr).not.toContain(NO_COMMAND_REGISTERED); + }); +}); + +describe("escape sequence is still respected", () => { + test("a global flag after -- is not treated as a top-level flag", async () => { + // After `--`, tokens are positional/pass-through. `bash-hook` takes no + // positionals, so Stricli reports too-many-arguments — proving `--verbose` + // was NOT consumed as a global flag by the scanner (which would have made + // the command run cleanly). + const { stdout, stderr } = await runApp(["bash-hook", "--", "--verbose"]); + expect(stderr).not.toContain(NO_COMMAND_REGISTERED); + expect(stderr.toLowerCase()).toContain("too many arguments"); + expect(stdout).not.toContain("_sentry_err_trap"); + }); +}); diff --git a/packages/cli/test/lib/argv-glue.test.ts b/packages/cli/test/lib/argv-glue.test.ts new file mode 100644 index 0000000000..32030cd924 --- /dev/null +++ b/packages/cli/test/lib/argv-glue.test.ts @@ -0,0 +1,263 @@ +/** + * Unit tests for the application-boundary argv glue in {@link argv-glue}. + * + * Global-flag recognition before a subcommand (`sentry --verbose issue list`) + * is exercised end-to-end via Stricli's patched route scanner in + * `argv-glue.integration.test.ts`. These tests focus on the two transforms that + * still happen before dispatch: `--version` normalization and the `--help + * --json` rewrite. + */ + +import { describe, expect, test } from "vitest"; +import { + isVersionRequest, + preprocessArgv, + rewriteHelpJsonRequest, +} from "../../src/lib/argv-glue.js"; + +describe("isVersionRequest", () => { + test("true for top-level --version", () => { + expect(isVersionRequest(["--version"])).toBe(true); + }); + + test("true for --version after a route group (sentry cli --version)", () => { + expect(isVersionRequest(["cli", "--version"])).toBe(true); + }); + + test("true for --version after a nested subcommand", () => { + expect(isVersionRequest(["issue", "list", "--version"])).toBe(true); + }); + + test("false when --version is absent", () => { + expect(isVersionRequest(["cli", "upgrade"])).toBe(false); + expect(isVersionRequest([])).toBe(false); + }); + + test("does not match the -v short alias (reserved for --verbose)", () => { + expect(isVersionRequest(["cli", "-v"])).toBe(false); + }); + + test("ignores --version after the -- escape (passed to wrapped command)", () => { + // `sentry monitor run -- mytool --version` must forward --version + // to the wrapped command, not print the Sentry CLI version. + expect( + isVersionRequest(["monitor", "run", "job", "--", "mytool", "--version"]) + ).toBe(false); + }); + + test("does not match --version=foo (not a bare version flag)", () => { + expect(isVersionRequest(["cli", "--version=1.2.3"])).toBe(false); + }); +}); + +describe("preprocessArgv", () => { + test("normalizes a route-scoped --version to a plain --version", () => { + expect(preprocessArgv(["cli", "--version"])).toEqual(["--version"]); + expect(preprocessArgv(["issue", "list", "--version"])).toEqual([ + "--version", + ]); + }); + + test("leaves global flags in place for the scanner to handle", () => { + // Hoisting is gone — argv is passed through unchanged and the patched + // route scanner recognizes the flag before the subcommand. + expect(preprocessArgv(["--verbose", "issue", "list"])).toEqual([ + "--verbose", + "issue", + "list", + ]); + expect(preprocessArgv(["issue", "--org", "acme", "list"])).toEqual([ + "issue", + "--org", + "acme", + "list", + ]); + }); + + test("rewrites --help --json to the help command", () => { + expect(preprocessArgv(["--help", "--json"])).toEqual(["help", "--json"]); + expect(preprocessArgv(["issue", "list", "--help", "--json"])).toEqual([ + "help", + "--json", + "issue", + "list", + ]); + }); + + test("leaves a bare --help untouched (Stricli renders text help)", () => { + expect(preprocessArgv(["issue", "--help"])).toEqual(["issue", "--help"]); + }); + + test("leaves a wrapped-command --version (after --) untouched", () => { + expect( + preprocessArgv(["monitor", "run", "job", "--", "tool", "--version"]) + ).toEqual(["monitor", "run", "job", "--", "tool", "--version"]); + }); + + test("returns argv unchanged when no transform applies", () => { + expect(preprocessArgv(["issue", "list", "--limit", "25"])).toEqual([ + "issue", + "list", + "--limit", + "25", + ]); + expect(preprocessArgv([])).toEqual([]); + }); +}); + +describe("rewriteHelpJsonRequest", () => { + test("rewrites top-level --help --json to the help command", () => { + expect(rewriteHelpJsonRequest(["--help", "--json"])).toEqual([ + "help", + "--json", + ]); + }); + + test("rewrites a group --help --json to help ", () => { + expect(rewriteHelpJsonRequest(["issue", "--help", "--json"])).toEqual([ + "help", + "--json", + "issue", + ]); + }); + + test("recognizes the -h short alias for --help", () => { + // Stricli treats `-h` as an alias of `--help`, so the JSON rewrite must + // fire for it too — otherwise `sentry -h --json` falls through to text usage. + expect(rewriteHelpJsonRequest(["-h", "--json"])).toEqual([ + "help", + "--json", + ]); + expect(rewriteHelpJsonRequest(["issue", "-h", "--json"])).toEqual([ + "help", + "--json", + "issue", + ]); + }); + + test("rewrites a nested command --help --json to help ", () => { + expect( + rewriteHelpJsonRequest(["issue", "list", "--help", "--json"]) + ).toEqual(["help", "--json", "issue", "list"]); + }); + + test("is order-insensitive between --help and --json", () => { + expect(rewriteHelpJsonRequest(["--json", "issue", "--help"])).toEqual([ + "help", + "--json", + "issue", + ]); + }); + + test("carries a --fields value through to the help command", () => { + expect( + rewriteHelpJsonRequest([ + "issue", + "list", + "--help", + "--json", + "--fields", + "path,brief", + ]) + ).toEqual(["help", "--json", "issue", "list", "--fields", "path,brief"]); + }); + + test("carries a --fields=value form through to the help command", () => { + expect( + rewriteHelpJsonRequest(["issue", "--help", "--json", "--fields=path"]) + ).toEqual(["help", "--json", "issue", "--fields", "path"]); + }); + + test("drops unrelated flags from the rewritten path", () => { + expect( + rewriteHelpJsonRequest(["--verbose", "issue", "--help", "--json"]) + ).toEqual(["help", "--json", "issue"]); + }); + + test("drops a value flag's spaced value so it never becomes a path segment", () => { + // `--org acme` / `--limit 5` must not leak `acme` / `5` into the command + // path, which would resolve the wrong command or a not-found error. + expect( + rewriteHelpJsonRequest([ + "issue", + "list", + "--org", + "acme", + "--help", + "--json", + ]) + ).toEqual(["help", "--json", "issue", "list"]); + expect( + rewriteHelpJsonRequest([ + "issue", + "list", + "--limit", + "5", + "--help", + "--json", + ]) + ).toEqual(["help", "--json", "issue", "list"]); + }); + + test("keeps a path segment following a boolean flag", () => { + // `--verbose` is a known boolean flag, so the token after it (`list`) is a + // real command-path segment, not a flag value. + expect( + rewriteHelpJsonRequest(["issue", "--verbose", "list", "--help", "--json"]) + ).toEqual(["help", "--json", "issue", "list"]); + }); + + test("keeps a path segment following an =-form value flag", () => { + // `--org=acme` carries its value inline, so the next token (`issue`/`list`) + // is a real command-path segment. A naive length check would treat the + // whole `org=acme` string as an unknown value flag and swallow `issue`. + expect( + rewriteHelpJsonRequest([ + "--org=acme", + "issue", + "list", + "--help", + "--json", + ]) + ).toEqual(["help", "--json", "issue", "list"]); + expect( + rewriteHelpJsonRequest(["issue", "--limit=5", "list", "--help", "--json"]) + ).toEqual(["help", "--json", "issue", "list"]); + }); + + test("does not let --fields swallow a following flag", () => { + // `--fields --json`: --fields has no value, and --json must still register + // so the rewrite fires. + expect( + rewriteHelpJsonRequest(["issue", "list", "--help", "--fields", "--json"]) + ).toEqual(["help", "--json", "issue", "list"]); + }); + + test("returns null for bare --help without --json", () => { + expect(rewriteHelpJsonRequest(["issue", "--help"])).toBeNull(); + }); + + test("returns null for --json without --help", () => { + expect(rewriteHelpJsonRequest(["issue", "list", "--json"])).toBeNull(); + }); + + test("returns null when neither flag is present", () => { + expect(rewriteHelpJsonRequest(["issue", "list"])).toBeNull(); + }); + + test("ignores --help --json after the -- escape separator", () => { + // `sentry monitor run -- tool --help --json` must forward the flags + // to the wrapped command, not print the CLI's JSON help. + expect( + rewriteHelpJsonRequest([ + "monitor", + "run", + "job", + "--", + "tool", + "--help", + "--json", + ]) + ).toBeNull(); + }); +}); diff --git a/packages/cli/test/lib/argv-hoist.property.test.ts b/packages/cli/test/lib/argv-hoist.property.test.ts deleted file mode 100644 index 15a511b7ce..0000000000 --- a/packages/cli/test/lib/argv-hoist.property.test.ts +++ /dev/null @@ -1,180 +0,0 @@ -/** - * Property-based tests for {@link hoistGlobalFlags}. - * - * These verify invariants that must hold for any valid input: - * 1. Token conservation — no tokens added or dropped - * 2. Order preservation — non-hoisted tokens keep their relative order - * 3. Idempotency — hoisting twice gives the same result as hoisting once - */ - -import { array, constantFrom, assert as fcAssert, property } from "fast-check"; -import { describe, expect, test } from "vitest"; -import { hoistGlobalFlags } from "../../src/lib/argv-hoist.js"; -import { DEFAULT_NUM_RUNS } from "../model-based/helpers.js"; - -/** Tokens that should be hoisted (global flags and their values) */ -const GLOBAL_FLAG_TOKENS = [ - "--verbose", - "--no-verbose", - "--json", - "--no-json", - "-v", - "--log-level", - "--fields", - "--org", - "--project", -] as const; - -/** Tokens that should never be hoisted */ -const NON_GLOBAL_TOKENS = [ - "issue", - "list", - "view", - "my-org/", - "my-org/my-project", - "123", - "--limit", - "25", - "--sort", - "date", - "-x", - "-h", - "help", - "cli", - "upgrade", - "api", -] as const; - -/** All tokens mixed together (excluding -- separator, handled separately) */ -const ALL_TOKENS = [...GLOBAL_FLAG_TOKENS, ...NON_GLOBAL_TOKENS] as const; - -const nonGlobalTokenArb = constantFrom(...NON_GLOBAL_TOKENS); -const allTokenArb = constantFrom(...ALL_TOKENS); -const argvArb = array(allTokenArb, { minLength: 0, maxLength: 12 }); - -/** - * Check if a token is a hoistable global flag or its negation/short form. - * Must match the flag registry in argv-hoist.ts. - */ -const HOISTABLE_SET = new Set([ - "--verbose", - "--no-verbose", - "--json", - "--no-json", - "-v", - "--log-level", - "--fields", - "--org", - "--project", -]); - -function isHoistableToken(token: string): boolean { - return HOISTABLE_SET.has(token); -} - -describe("property: hoistGlobalFlags", () => { - test("token conservation: output contains exactly the same tokens as input", () => { - fcAssert( - property(argvArb, (argv) => { - const result = hoistGlobalFlags(argv); - expect([...result].sort()).toEqual([...argv].sort()); - }), - { numRuns: DEFAULT_NUM_RUNS } - ); - }); - - test("order preservation: non-hoistable tokens keep relative order", () => { - const mixedArgvArb = array( - constantFrom( - "--verbose", - "-v", - "--json", - "issue", - "list", - "my-org/", - "--limit", - "25", - "cli", - "upgrade" - ), - { minLength: 0, maxLength: 12 } - ); - - fcAssert( - property(mixedArgvArb, (argv) => { - const result = hoistGlobalFlags(argv); - const nonHoisted = result.filter((t) => !isHoistableToken(t)); - const originalNonHoisted = argv.filter((t) => !isHoistableToken(t)); - expect(nonHoisted).toEqual(originalNonHoisted); - }), - { numRuns: DEFAULT_NUM_RUNS } - ); - }); - - test("idempotency: hoisting twice gives the same result as once", () => { - fcAssert( - property(argvArb, (argv) => { - const once = hoistGlobalFlags(argv); - const twice = hoistGlobalFlags(once); - expect(twice).toEqual(once); - }), - { numRuns: DEFAULT_NUM_RUNS } - ); - }); - - test("hoisted tokens appear after all non-hoisted tokens", () => { - /** Value-taking flags whose next token also gets hoisted */ - const VALUE_TAKING = new Set([ - "--log-level", - "--fields", - "--org", - "--project", - ]); - - fcAssert( - property(argvArb, (argv) => { - const result = hoistGlobalFlags(argv); - // Find the index of the first hoisted token - const firstHoistedIdx = result.findIndex((t) => isHoistableToken(t)); - if (firstHoistedIdx === -1) { - return; // No hoistable tokens — nothing to check - } - // All tokens after the first hoisted token should be either: - // (a) a hoistable flag, or (b) a value following a value-taking flag - const tail = result.slice(firstHoistedIdx); - let skipNext = false; - for (const token of tail) { - if (skipNext) { - skipNext = false; - continue; - } - if (isHoistableToken(token)) { - // If it's a value-taking flag, skip the next token (its value) - if (VALUE_TAKING.has(token)) { - skipNext = true; - } - continue; - } - // Token is not hoistable — fail - expect(token).toBe( - `` - ); - } - }), - { numRuns: DEFAULT_NUM_RUNS } - ); - }); - - test("no-op for argv with only non-global tokens", () => { - const nonGlobalArgvArb = array(nonGlobalTokenArb, { - minLength: 0, - maxLength: 10, - }); - fcAssert( - property(nonGlobalArgvArb, (argv) => { - expect(hoistGlobalFlags(argv)).toEqual(argv); - }), - { numRuns: DEFAULT_NUM_RUNS } - ); - }); -}); diff --git a/packages/cli/test/lib/argv-hoist.test.ts b/packages/cli/test/lib/argv-hoist.test.ts deleted file mode 100644 index d8c5369442..0000000000 --- a/packages/cli/test/lib/argv-hoist.test.ts +++ /dev/null @@ -1,683 +0,0 @@ -/** - * Unit tests for {@link hoistGlobalFlags}. - * - * Core invariants (token conservation, order preservation, idempotency) are - * tested via property-based tests in argv-hoist.property.test.ts. These tests - * focus on specific scenarios and edge cases. - */ - -import { describe, expect, test } from "vitest"; -import { - hoistGlobalFlags, - isVersionRequest, - preprocessArgv, - rewriteDashedFlagValues, - rewriteHelpJsonRequest, -} from "../../src/lib/argv-hoist.js"; - -describe("hoistGlobalFlags", () => { - // ------------------------------------------------------------------------- - // Passthrough (no hoistable flags) - // ------------------------------------------------------------------------- - - test("returns empty array for empty input", () => { - expect(hoistGlobalFlags([])).toEqual([]); - }); - - test("returns argv unchanged when no global flags present", () => { - expect(hoistGlobalFlags(["issue", "list", "--limit", "25"])).toEqual([ - "issue", - "list", - "--limit", - "25", - ]); - }); - - test("does not hoist unknown flags", () => { - expect(hoistGlobalFlags(["--limit", "25", "issue", "list"])).toEqual([ - "--limit", - "25", - "issue", - "list", - ]); - }); - - // ------------------------------------------------------------------------- - // Boolean flag hoisting: --verbose - // ------------------------------------------------------------------------- - - test("hoists --verbose from before subcommand", () => { - expect(hoistGlobalFlags(["--verbose", "issue", "list"])).toEqual([ - "issue", - "list", - "--verbose", - ]); - }); - - test("hoists --verbose from middle position", () => { - expect(hoistGlobalFlags(["cli", "--verbose", "upgrade"])).toEqual([ - "cli", - "upgrade", - "--verbose", - ]); - }); - - test("flag already at end stays at end", () => { - expect(hoistGlobalFlags(["issue", "list", "--verbose"])).toEqual([ - "issue", - "list", - "--verbose", - ]); - }); - - // ------------------------------------------------------------------------- - // Short alias: -v - // ------------------------------------------------------------------------- - - test("hoists -v from before subcommand", () => { - expect(hoistGlobalFlags(["-v", "issue", "list"])).toEqual([ - "issue", - "list", - "-v", - ]); - }); - - test("hoists -v from middle position", () => { - expect(hoistGlobalFlags(["cli", "-v", "upgrade"])).toEqual([ - "cli", - "upgrade", - "-v", - ]); - }); - - test("does not hoist unknown short flags", () => { - expect(hoistGlobalFlags(["-x", "issue", "list"])).toEqual([ - "-x", - "issue", - "list", - ]); - }); - - // ------------------------------------------------------------------------- - // Negation: --no-verbose, --no-json - // ------------------------------------------------------------------------- - - test("hoists --no-verbose", () => { - expect(hoistGlobalFlags(["--no-verbose", "issue", "list"])).toEqual([ - "issue", - "list", - "--no-verbose", - ]); - }); - - test("hoists --no-json", () => { - expect(hoistGlobalFlags(["--no-json", "issue", "list"])).toEqual([ - "issue", - "list", - "--no-json", - ]); - }); - - test("does not hoist --no-log-level (not negatable)", () => { - expect(hoistGlobalFlags(["--no-log-level", "issue", "list"])).toEqual([ - "--no-log-level", - "issue", - "list", - ]); - }); - - // ------------------------------------------------------------------------- - // Boolean flag: --json - // ------------------------------------------------------------------------- - - test("hoists --json from before subcommand", () => { - expect(hoistGlobalFlags(["--json", "issue", "list"])).toEqual([ - "issue", - "list", - "--json", - ]); - }); - - // ------------------------------------------------------------------------- - // Value flag: --log-level (separate value) - // ------------------------------------------------------------------------- - - test("hoists --log-level with separate value", () => { - expect(hoistGlobalFlags(["--log-level", "debug", "issue", "list"])).toEqual( - ["issue", "list", "--log-level", "debug"] - ); - }); - - test("hoists --log-level=debug as single token", () => { - expect(hoistGlobalFlags(["--log-level=debug", "issue", "list"])).toEqual([ - "issue", - "list", - "--log-level=debug", - ]); - }); - - test("hoists --log-level at end with no value", () => { - expect(hoistGlobalFlags(["issue", "list", "--log-level"])).toEqual([ - "issue", - "list", - "--log-level", - ]); - }); - - // ------------------------------------------------------------------------- - // Value flag: --fields - // ------------------------------------------------------------------------- - - test("hoists --fields with separate value", () => { - expect(hoistGlobalFlags(["--fields", "id,title", "issue", "list"])).toEqual( - ["issue", "list", "--fields", "id,title"] - ); - }); - - test("hoists --fields=id,title as single token", () => { - expect(hoistGlobalFlags(["--fields=id,title", "issue", "list"])).toEqual([ - "issue", - "list", - "--fields=id,title", - ]); - }); - - // ------------------------------------------------------------------------- - // Value flag: --org (compat) - // ------------------------------------------------------------------------- - - test("hoists --org with separate value", () => { - expect(hoistGlobalFlags(["--org", "sentry", "issue", "list"])).toEqual([ - "issue", - "list", - "--org", - "sentry", - ]); - }); - - test("hoists --org=sentry as single token", () => { - expect(hoistGlobalFlags(["--org=sentry", "issue", "list"])).toEqual([ - "issue", - "list", - "--org=sentry", - ]); - }); - - // ------------------------------------------------------------------------- - // Value flag: --project (compat) - // ------------------------------------------------------------------------- - - test("hoists --project with separate value", () => { - expect(hoistGlobalFlags(["--project", "cli", "issue", "list"])).toEqual([ - "issue", - "list", - "--project", - "cli", - ]); - }); - - test("hoists --project=cli as single token", () => { - expect(hoistGlobalFlags(["--project=cli", "issue", "list"])).toEqual([ - "issue", - "list", - "--project=cli", - ]); - }); - - // ------------------------------------------------------------------------- - // Combined: --org + --project (compat) - // ------------------------------------------------------------------------- - - test("hoists --org and --project together", () => { - expect( - hoistGlobalFlags(["--org", "sentry", "--project", "cli", "issue", "list"]) - ).toEqual(["issue", "list", "--org", "sentry", "--project", "cli"]); - }); - - // ------------------------------------------------------------------------- - // Multiple flags - // ------------------------------------------------------------------------- - - test("hoists multiple global flags preserving relative order", () => { - expect(hoistGlobalFlags(["--verbose", "--json", "issue", "list"])).toEqual([ - "issue", - "list", - "--verbose", - "--json", - ]); - }); - - test("hoists flags from mixed positions", () => { - expect( - hoistGlobalFlags([ - "--verbose", - "issue", - "--json", - "list", - "--fields", - "id", - ]) - ).toEqual(["issue", "list", "--verbose", "--json", "--fields", "id"]); - }); - - // ------------------------------------------------------------------------- - // Repeated flags - // ------------------------------------------------------------------------- - - test("hoists duplicate --verbose flags", () => { - expect( - hoistGlobalFlags(["--verbose", "issue", "--verbose", "list"]) - ).toEqual(["issue", "list", "--verbose", "--verbose"]); - }); - - // ------------------------------------------------------------------------- - // -- separator - // ------------------------------------------------------------------------- - - test("does not hoist flags after -- separator", () => { - expect(hoistGlobalFlags(["issue", "--", "--verbose", "list"])).toEqual([ - "issue", - "--", - "--verbose", - "list", - ]); - }); - - test("hoists flags before -- and places them before the separator", () => { - expect(hoistGlobalFlags(["--verbose", "issue", "--", "--json"])).toEqual([ - "issue", - "--verbose", - "--", - "--json", - ]); - }); - - test("hoisted flags appear before -- not after it", () => { - expect( - hoistGlobalFlags(["--verbose", "issue", "--", "positional-arg"]) - ).toEqual(["issue", "--verbose", "--", "positional-arg"]); - }); - - // ------------------------------------------------------------------------- - // Real-world scenarios - // ------------------------------------------------------------------------- - - test("hoists from root level for deeply nested commands", () => { - expect(hoistGlobalFlags(["--verbose", "--json", "cli", "upgrade"])).toEqual( - ["cli", "upgrade", "--verbose", "--json"] - ); - }); - - test("hoists --verbose for api command", () => { - expect(hoistGlobalFlags(["--verbose", "api", "/endpoint"])).toEqual([ - "api", - "/endpoint", - "--verbose", - ]); - }); - - test("hoists -v with log-level from root", () => { - expect( - hoistGlobalFlags(["-v", "--log-level", "debug", "cli", "upgrade"]) - ).toEqual(["cli", "upgrade", "-v", "--log-level", "debug"]); - }); - - test("preserves non-global flags in original position", () => { - expect( - hoistGlobalFlags([ - "--verbose", - "issue", - "list", - "my-org/", - "--limit", - "25", - "--sort", - "date", - ]) - ).toEqual([ - "issue", - "list", - "my-org/", - "--limit", - "25", - "--sort", - "date", - "--verbose", - ]); - }); -}); - -describe("isVersionRequest", () => { - test("true for top-level --version", () => { - expect(isVersionRequest(["--version"])).toBe(true); - }); - - test("true for --version after a route group (sentry cli --version)", () => { - expect(isVersionRequest(["cli", "--version"])).toBe(true); - }); - - test("true for --version after a nested subcommand", () => { - expect(isVersionRequest(["issue", "list", "--version"])).toBe(true); - }); - - test("false when --version is absent", () => { - expect(isVersionRequest(["cli", "upgrade"])).toBe(false); - expect(isVersionRequest([])).toBe(false); - }); - - test("does not match the -v short alias (reserved for --verbose)", () => { - expect(isVersionRequest(["cli", "-v"])).toBe(false); - }); - - test("ignores --version after the -- escape (passed to wrapped command)", () => { - // `sentry monitor run -- mytool --version` must forward --version - // to the wrapped command, not print the Sentry CLI version. - expect( - isVersionRequest(["monitor", "run", "job", "--", "mytool", "--version"]) - ).toBe(false); - }); - - test("does not match --version=foo (not a bare version flag)", () => { - expect(isVersionRequest(["cli", "--version=1.2.3"])).toBe(false); - }); -}); - -describe("rewriteDashedFlagValues", () => { - test("rewrites --from followed by a dashed token to equals form", () => { - expect( - rewriteDashedFlagValues([ - "release", - "set-commits", - "1.0.0", - "--from", - "--format=x", - ]) - ).toEqual(["release", "set-commits", "1.0.0", "--from=--format=x"]); - }); - - test("does not rewrite --from followed by a single-dash token", () => { - expect( - rewriteDashedFlagValues([ - "release", - "set-commits", - "1.0.0", - "--from", - "-v1.0", - ]) - ).toEqual(["release", "set-commits", "1.0.0", "--from", "-v1.0"]); - }); - - test("does not rewrite --from followed by a global flag", () => { - expect( - rewriteDashedFlagValues([ - "release", - "set-commits", - "1.0.0", - "--from", - "--json", - ]) - ).toEqual(["release", "set-commits", "1.0.0", "--from", "--json"]); - }); - - test("leaves --from= already in equals form unchanged", () => { - expect( - rewriteDashedFlagValues([ - "release", - "set-commits", - "1.0.0", - "--from=-format=x", - ]) - ).toEqual(["release", "set-commits", "1.0.0", "--from=-format=x"]); - }); - - test("does not rewrite tokens after --", () => { - expect( - rewriteDashedFlagValues([ - "release", - "set-commits", - "1.0.0", - "--", - "--from", - "--format=x", - ]) - ).toEqual([ - "release", - "set-commits", - "1.0.0", - "--", - "--from", - "--format=x", - ]); - }); -}); - -describe("preprocessArgv", () => { - test("normalizes a route-scoped --version to a plain --version", () => { - expect(preprocessArgv(["cli", "--version"])).toEqual(["--version"]); - expect(preprocessArgv(["issue", "list", "--version"])).toEqual([ - "--version", - ]); - }); - - test("hoists global flags when no --version is present", () => { - expect(preprocessArgv(["--verbose", "issue", "list"])).toEqual([ - "issue", - "list", - "--verbose", - ]); - }); - - test("rewrites --help --json to the help command instead of hoisting", () => { - expect(preprocessArgv(["--help", "--json"])).toEqual(["help", "--json"]); - expect(preprocessArgv(["issue", "list", "--help", "--json"])).toEqual([ - "help", - "--json", - "issue", - "list", - ]); - }); - - test("leaves a bare --help to normal hoisting (Stricli renders text help)", () => { - expect(preprocessArgv(["issue", "--help"])).toEqual(["issue", "--help"]); - }); - - test("leaves a wrapped-command --version (after --) to hoisting, not version", () => { - expect( - preprocessArgv(["monitor", "run", "job", "--", "tool", "--version"]) - ).toEqual(["monitor", "run", "job", "--", "tool", "--version"]); - }); - - test("rewrites dashed --from values before hoisting global flags", () => { - expect( - preprocessArgv([ - "--verbose", - "release", - "set-commits", - "1.0.0", - "--from", - "--format=x", - ]) - ).toEqual([ - "release", - "set-commits", - "1.0.0", - "--from=--format=x", - "--verbose", - ]); - }); - - test("preserves --json when it follows --from without a ref", () => { - expect( - preprocessArgv(["release", "set-commits", "1.0.0", "--from", "--json"]) - ).toEqual(["release", "set-commits", "1.0.0", "--from", "--json"]); - }); - - test("does not rewrite --from followed by another set-commits flag", () => { - expect( - rewriteDashedFlagValues([ - "release", - "set-commits", - "1.0.0", - "--from", - "--auto", - ]) - ).toEqual(["release", "set-commits", "1.0.0", "--from", "--auto"]); - }); -}); - -describe("rewriteHelpJsonRequest", () => { - test("rewrites top-level --help --json to the help command", () => { - expect(rewriteHelpJsonRequest(["--help", "--json"])).toEqual([ - "help", - "--json", - ]); - }); - - test("rewrites a group --help --json to help ", () => { - expect(rewriteHelpJsonRequest(["issue", "--help", "--json"])).toEqual([ - "help", - "--json", - "issue", - ]); - }); - - test("recognizes the -h short alias for --help", () => { - // Stricli treats `-h` as an alias of `--help`, so the JSON rewrite must - // fire for it too — otherwise `sentry -h --json` falls through to text usage. - expect(rewriteHelpJsonRequest(["-h", "--json"])).toEqual([ - "help", - "--json", - ]); - expect(rewriteHelpJsonRequest(["issue", "-h", "--json"])).toEqual([ - "help", - "--json", - "issue", - ]); - }); - - test("rewrites a nested command --help --json to help ", () => { - expect( - rewriteHelpJsonRequest(["issue", "list", "--help", "--json"]) - ).toEqual(["help", "--json", "issue", "list"]); - }); - - test("is order-insensitive between --help and --json", () => { - expect(rewriteHelpJsonRequest(["--json", "issue", "--help"])).toEqual([ - "help", - "--json", - "issue", - ]); - }); - - test("carries a --fields value through to the help command", () => { - expect( - rewriteHelpJsonRequest([ - "issue", - "list", - "--help", - "--json", - "--fields", - "path,brief", - ]) - ).toEqual(["help", "--json", "issue", "list", "--fields", "path,brief"]); - }); - - test("carries a --fields=value form through to the help command", () => { - expect( - rewriteHelpJsonRequest(["issue", "--help", "--json", "--fields=path"]) - ).toEqual(["help", "--json", "issue", "--fields", "path"]); - }); - - test("drops unrelated flags from the rewritten path", () => { - expect( - rewriteHelpJsonRequest(["--verbose", "issue", "--help", "--json"]) - ).toEqual(["help", "--json", "issue"]); - }); - - test("drops a value flag's spaced value so it never becomes a path segment", () => { - // `--org acme` / `--limit 5` must not leak `acme` / `5` into the command - // path, which would resolve the wrong command or a not-found error. - expect( - rewriteHelpJsonRequest([ - "issue", - "list", - "--org", - "acme", - "--help", - "--json", - ]) - ).toEqual(["help", "--json", "issue", "list"]); - expect( - rewriteHelpJsonRequest([ - "issue", - "list", - "--limit", - "5", - "--help", - "--json", - ]) - ).toEqual(["help", "--json", "issue", "list"]); - }); - - test("keeps a path segment following a boolean flag", () => { - // `--verbose` is a known boolean flag, so the token after it (`list`) is a - // real command-path segment, not a flag value. - expect( - rewriteHelpJsonRequest(["issue", "--verbose", "list", "--help", "--json"]) - ).toEqual(["help", "--json", "issue", "list"]); - }); - - test("keeps a path segment following an =-form value flag", () => { - // `--org=acme` carries its value inline, so the next token (`issue`/`list`) - // is a real command-path segment. A naive length check would treat the - // whole `org=acme` string as an unknown value flag and swallow `issue`. - expect( - rewriteHelpJsonRequest([ - "--org=acme", - "issue", - "list", - "--help", - "--json", - ]) - ).toEqual(["help", "--json", "issue", "list"]); - expect( - rewriteHelpJsonRequest(["issue", "--limit=5", "list", "--help", "--json"]) - ).toEqual(["help", "--json", "issue", "list"]); - }); - - test("does not let --fields swallow a following flag", () => { - // `--fields --json`: --fields has no value, and --json must still register - // so the rewrite fires. - expect( - rewriteHelpJsonRequest(["issue", "list", "--help", "--fields", "--json"]) - ).toEqual(["help", "--json", "issue", "list"]); - }); - - test("returns null for bare --help without --json", () => { - expect(rewriteHelpJsonRequest(["issue", "--help"])).toBeNull(); - }); - - test("returns null for --json without --help", () => { - expect(rewriteHelpJsonRequest(["issue", "list", "--json"])).toBeNull(); - }); - - test("returns null when neither flag is present", () => { - expect(rewriteHelpJsonRequest(["issue", "list"])).toBeNull(); - }); - - test("ignores --help --json after the -- escape separator", () => { - // `sentry monitor run -- tool --help --json` must forward the flags - // to the wrapped command, not print the CLI's JSON help. - expect( - rewriteHelpJsonRequest([ - "monitor", - "run", - "job", - "--", - "tool", - "--help", - "--json", - ]) - ).toBeNull(); - }); -}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7bb7baff4c..5a389d82a6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,7 +23,7 @@ patchedDependencies: hash: 0a5f08471bd72cb833a2b36220c96001d966e5c390d71aaf598fdef75b552640 path: packages/cli/patches/@sentry%2Fnode-core@10.63.0.patch '@stricli/core@1.2.8': - hash: fe81cdc661f4c728826bca5a3a3d5a04d758af013889613ad701dc8c7473bd7f + hash: 6017aa2a74778fae943ce1cb7cb1321052fce0787277d0457b2b6666dd9c389a path: packages/cli/patches/@stricli%2Fcore@1.2.8.patch importers: @@ -91,7 +91,7 @@ importers: version: 1.3.0 '@stricli/core': specifier: 1.2.8 - version: 1.2.8(patch_hash=fe81cdc661f4c728826bca5a3a3d5a04d758af013889613ad701dc8c7473bd7f) + version: 1.2.8(patch_hash=6017aa2a74778fae943ce1cb7cb1321052fce0787277d0457b2b6666dd9c389a) '@types/http-cache-semantics': specifier: ^4.2.0 version: 4.2.0 @@ -5836,7 +5836,7 @@ snapshots: dependencies: '@stricli/core': 1.3.0 - '@stricli/core@1.2.8(patch_hash=fe81cdc661f4c728826bca5a3a3d5a04d758af013889613ad701dc8c7473bd7f)': {} + '@stricli/core@1.2.8(patch_hash=6017aa2a74778fae943ce1cb7cb1321052fce0787277d0457b2b6666dd9c389a)': {} '@stricli/core@1.3.0': {} @@ -6804,7 +6804,7 @@ snapshots: fossilize@0.10.1: dependencies: '@stricli/auto-complete': 1.3.0 - '@stricli/core': 1.2.8(patch_hash=fe81cdc661f4c728826bca5a3a3d5a04d758af013889613ad701dc8c7473bd7f) + '@stricli/core': 1.2.8(patch_hash=6017aa2a74778fae943ce1cb7cb1321052fce0787277d0457b2b6666dd9c389a) binpunch: 1.0.0 esbuild: 0.28.1 macho-unsign: 2.0.6 From 41b5b0c7b6e43a0de2dcd3e0cdd269e7ffecb3ce Mon Sep 17 00:00:00 2001 From: "jared-outpost[bot]" Date: Sun, 2 Aug 2026 14:35:59 +0000 Subject: [PATCH 2/5] fix(version-check): resolve cli subcommand past interleaved global flags Without argv hoisting, global flags can sit between `cli` and its subcommand (`sentry cli --verbose setup`), so the positional `args[1]` check in shouldSuppressNotification no longer sees `setup`/`fix` and update notifications leak into those management commands. Resolve the subcommand by skipping global flags (and value-flag values) after `cli`. Flagged by Cursor Bugbot on #1340. --- packages/cli/src/lib/version-check.ts | 59 +++++++++++++++++++-- packages/cli/test/lib/version-check.test.ts | 16 ++++++ 2 files changed, 72 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/lib/version-check.ts b/packages/cli/src/lib/version-check.ts index 837ddb9250..a83ccb4ed3 100644 --- a/packages/cli/src/lib/version-check.ts +++ b/packages/cli/src/lib/version-check.ts @@ -24,6 +24,7 @@ import { import { getEnv } from "./env.js"; import { isUserError } from "./errors.js"; import { cyan, muted } from "./formatters/colors.js"; +import { GLOBAL_FLAGS } from "./global-flags.js"; import { cleanupPatchCache } from "./patch-cache.js"; import { fetchLatestFromGitHub, fetchLatestNightlyVersion } from "./upgrade.js"; @@ -63,6 +64,50 @@ const SUPPRESSED_ARGS = new Set([ */ const SUPPRESSED_CLI_SUBCOMMANDS = new Set(["setup", "fix"]); +/** Global value-flag names that consume the following token as their value. */ +const GLOBAL_VALUE_FLAG_NAMES = new Set( + GLOBAL_FLAGS.filter((f) => f.kind === "value").map((f) => f.name) +); + +/** + * Find the first positional (non-global-flag) token after the leading `cli` + * group, or `undefined` if there is none. + * + * Global flags may sit anywhere in argv (they're recognized by the route + * scanner at any depth, not hoisted), so `sentry cli --verbose setup` keeps + * `--verbose` between `cli` and `setup`. This skips global flags and the value + * consumed by a value-taking global flag (`--org acme`) so the real subcommand + * is found regardless of interleaved flags. Tokens after a `--` escape are not + * command-path segments and stop the scan. + * + * @param args - CLI arguments (`process.argv.slice(2)`-style, post-normalize) + * @returns The `cli` subcommand token, or `undefined` when absent + */ +function cliSubcommandAfterGroup(args: readonly string[]): string | undefined { + for (let i = 1; i < args.length; i += 1) { + const token = args[i] ?? ""; + if (token === "--") { + return; + } + if (!token.startsWith("-")) { + return token; + } + // Skip a value-taking global flag's spaced value so it isn't mistaken for + // the subcommand (`cli --org acme setup` → subcommand is `setup`). + const name = token.startsWith("--") ? token.slice(2) : ""; + const next = args[i + 1]; + if ( + GLOBAL_VALUE_FLAG_NAMES.has(name) && + !token.includes("=") && + next !== undefined && + !next.startsWith("-") + ) { + i += 1; + } + } + return; +} + /** AbortController for pending version check fetch */ let pendingAbortController: AbortController | null = null; @@ -99,9 +144,17 @@ export function shouldSuppressNotification(args: string[]): boolean { if (args.some((arg) => SUPPRESSED_ARGS.has(arg))) { return true; } - // Suppress for "cli " management commands (setup, fix) - if (args[0] === "cli" && SUPPRESSED_CLI_SUBCOMMANDS.has(args[1] ?? "")) { - return true; + // Suppress for "cli " management commands (setup, fix). Global + // flags may sit between `cli` and the subcommand (they're no longer hoisted), + // so resolve the subcommand past any interleaved global flags. + if (args[0] === "cli") { + const subcommand = cliSubcommandAfterGroup(args); + if ( + subcommand !== undefined && + SUPPRESSED_CLI_SUBCOMMANDS.has(subcommand) + ) { + return true; + } } return false; } diff --git a/packages/cli/test/lib/version-check.test.ts b/packages/cli/test/lib/version-check.test.ts index 440624989d..3628d2d916 100644 --- a/packages/cli/test/lib/version-check.test.ts +++ b/packages/cli/test/lib/version-check.test.ts @@ -63,6 +63,22 @@ describe("shouldSuppressNotification", () => { ).toBe(true); }); + test("suppresses cli management commands with global flags before the subcommand", () => { + // Global flags are no longer hoisted to the tail, so they may sit between + // `cli` and the subcommand. Suppression must still find `setup`/`fix`. + expect(shouldSuppressNotification(["cli", "--verbose", "setup"])).toBe( + true + ); + expect(shouldSuppressNotification(["cli", "-v", "fix"])).toBe(true); + expect( + shouldSuppressNotification(["cli", "--log-level", "debug", "setup"]) + ).toBe(true); + expect(shouldSuppressNotification(["cli", "--org", "acme", "setup"])).toBe( + true + ); + expect(shouldSuppressNotification(["cli", "--org=acme", "fix"])).toBe(true); + }); + test("does not suppress for cli feedback", () => { expect(shouldSuppressNotification(["cli", "feedback"])).toBe(false); }); From 388f884886b7cd67ee7cd0d57e6dd7db3ebfd978 Mon Sep 17 00:00:00 2001 From: "jared-outpost[bot]" Date: Sun, 2 Aug 2026 14:43:21 +0000 Subject: [PATCH 3/5] fix(version-check): suppress cli commands when global flags precede cli MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit shouldSuppressNotification only matched `cli` at args[0], so global flags placed before the command (`sentry --verbose cli setup`) — now possible since flags are no longer hoisted — leaked update notifications into management commands. Locate the `cli` group past leading global flags, and stop rewriteHelpJsonRequest at `--` without discarding `--help --json` already seen before it. Flagged by Cursor Bugbot and Seer on #1340. --- packages/cli/src/lib/argv-glue.ts | 8 +- packages/cli/src/lib/version-check.ts | 101 +++++++++++++++----- packages/cli/test/lib/argv-glue.test.ts | 8 ++ packages/cli/test/lib/version-check.test.ts | 22 +++++ 4 files changed, 111 insertions(+), 28 deletions(-) diff --git a/packages/cli/src/lib/argv-glue.ts b/packages/cli/src/lib/argv-glue.ts index 36c46a676e..ec5fb6e88c 100644 --- a/packages/cli/src/lib/argv-glue.ts +++ b/packages/cli/src/lib/argv-glue.ts @@ -213,9 +213,13 @@ export function rewriteHelpJsonRequest( }; for (let i = 0; i < argv.length; ) { - // Tokens after -- are positional/pass-through — a --help there is not ours. + // Stop at the escape separator: tokens after `--` are positional/pass-through + // and must not be interpreted (a `--help`/`--json` there belongs to the + // wrapped command). Flags seen *before* `--` still count, so + // `sentry --help --json -- passthru` rewrites while + // `sentry -- tool --help --json` does not. if (argv[i] === "--") { - return null; + break; } i += scanHelpJsonToken(argv, i, scan); } diff --git a/packages/cli/src/lib/version-check.ts b/packages/cli/src/lib/version-check.ts index a83ccb4ed3..91ce947a9d 100644 --- a/packages/cli/src/lib/version-check.ts +++ b/packages/cli/src/lib/version-check.ts @@ -70,21 +70,79 @@ const GLOBAL_VALUE_FLAG_NAMES = new Set( ); /** - * Find the first positional (non-global-flag) token after the leading `cli` - * group, or `undefined` if there is none. + * Advance past a value-taking global flag's spaced value. * - * Global flags may sit anywhere in argv (they're recognized by the route - * scanner at any depth, not hoisted), so `sentry cli --verbose setup` keeps - * `--verbose` between `cli` and `setup`. This skips global flags and the value - * consumed by a value-taking global flag (`--org acme`) so the real subcommand - * is found regardless of interleaved flags. Tokens after a `--` escape are not - * command-path segments and stop the scan. + * A value flag like `--org acme` consumes the following token as its value, so + * that token must not be mistaken for a command-path segment. Returns the index + * of the flag's value when `args[i]` is such a flag with a spaced value, or `i` + * itself otherwise. The caller adds 1 for the normal single-token step. + * + * @param args - CLI arguments being scanned + * @param i - Index of the flag token under inspection + * @returns The last index consumed by the flag at `i` + */ +function skipGlobalValueFlagValue(args: readonly string[], i: number): number { + const token = args[i] ?? ""; + const name = token.startsWith("--") ? token.slice(2) : ""; + const next = args[i + 1]; + if ( + GLOBAL_VALUE_FLAG_NAMES.has(name) && + !token.includes("=") && + next !== undefined && + !next.startsWith("-") + ) { + return i + 1; + } + return i; +} + +/** + * Locate the `cli` command group in argv, skipping any leading global flags. + * + * Global flags may precede the command (`sentry --verbose cli setup`) since + * they're recognized by the route scanner at any depth rather than hoisted, so + * `cli` is not necessarily `args[0]`. Only global flags (and the values of + * value-taking global flags) may precede it — the first non-flag token settles + * the command group, and a `--` escape ends the search. * * @param args - CLI arguments (`process.argv.slice(2)`-style, post-normalize) + * @returns The index of the `cli` token, or `undefined` when the first command + * token is not `cli` + */ +function cliGroupIndex(args: readonly string[]): number | undefined { + for (let i = 0; i < args.length; i += 1) { + const token = args[i] ?? ""; + if (token === "--") { + return; + } + if (!token.startsWith("-")) { + return token === "cli" ? i : undefined; + } + i = skipGlobalValueFlagValue(args, i); + } + return; +} + +/** + * Find the first positional (non-global-flag) token after the `cli` group at + * `start`, or `undefined` if there is none. + * + * Global flags may sit between `cli` and its subcommand (`sentry cli --verbose + * setup`) because they're recognized by the route scanner at any depth, not + * hoisted. This skips global flags and the value consumed by a value-taking + * global flag (`--org acme`) so the real subcommand is found regardless of + * interleaved flags. Tokens after a `--` escape are not command-path segments + * and stop the scan. + * + * @param args - CLI arguments (`process.argv.slice(2)`-style, post-normalize) + * @param start - Index of the `cli` group token * @returns The `cli` subcommand token, or `undefined` when absent */ -function cliSubcommandAfterGroup(args: readonly string[]): string | undefined { - for (let i = 1; i < args.length; i += 1) { +function cliSubcommandAfterGroup( + args: readonly string[], + start: number +): string | undefined { + for (let i = start + 1; i < args.length; i += 1) { const token = args[i] ?? ""; if (token === "--") { return; @@ -92,18 +150,7 @@ function cliSubcommandAfterGroup(args: readonly string[]): string | undefined { if (!token.startsWith("-")) { return token; } - // Skip a value-taking global flag's spaced value so it isn't mistaken for - // the subcommand (`cli --org acme setup` → subcommand is `setup`). - const name = token.startsWith("--") ? token.slice(2) : ""; - const next = args[i + 1]; - if ( - GLOBAL_VALUE_FLAG_NAMES.has(name) && - !token.includes("=") && - next !== undefined && - !next.startsWith("-") - ) { - i += 1; - } + i = skipGlobalValueFlagValue(args, i); } return; } @@ -145,10 +192,12 @@ export function shouldSuppressNotification(args: string[]): boolean { return true; } // Suppress for "cli " management commands (setup, fix). Global - // flags may sit between `cli` and the subcommand (they're no longer hoisted), - // so resolve the subcommand past any interleaved global flags. - if (args[0] === "cli") { - const subcommand = cliSubcommandAfterGroup(args); + // flags are no longer hoisted, so they may precede `cli` (`--verbose cli + // setup`) or sit between `cli` and the subcommand (`cli --verbose setup`); + // resolve the group and its subcommand past any interleaved global flags. + const cliIndex = cliGroupIndex(args); + if (cliIndex !== undefined) { + const subcommand = cliSubcommandAfterGroup(args, cliIndex); if ( subcommand !== undefined && SUPPRESSED_CLI_SUBCOMMANDS.has(subcommand) diff --git a/packages/cli/test/lib/argv-glue.test.ts b/packages/cli/test/lib/argv-glue.test.ts index 32030cd924..ed7befc8bd 100644 --- a/packages/cli/test/lib/argv-glue.test.ts +++ b/packages/cli/test/lib/argv-glue.test.ts @@ -260,4 +260,12 @@ describe("rewriteHelpJsonRequest", () => { ]) ).toBeNull(); }); + + test("rewrites --help --json seen before a -- escape separator", () => { + // Flags before `--` still count: the scan stops at `--` but keeps what it + // already found, so `--help --json -- passthru` yields JSON help. + expect( + rewriteHelpJsonRequest(["issue", "list", "--help", "--json", "--", "x"]) + ).toEqual(["help", "--json", "issue", "list"]); + }); }); diff --git a/packages/cli/test/lib/version-check.test.ts b/packages/cli/test/lib/version-check.test.ts index 3628d2d916..20172cd839 100644 --- a/packages/cli/test/lib/version-check.test.ts +++ b/packages/cli/test/lib/version-check.test.ts @@ -79,6 +79,28 @@ describe("shouldSuppressNotification", () => { expect(shouldSuppressNotification(["cli", "--org=acme", "fix"])).toBe(true); }); + test("suppresses cli management commands with global flags before cli", () => { + // Global flags can also precede the command group (`sentry --verbose cli + // setup`) since they're no longer hoisted; `cli` need not be args[0]. + expect(shouldSuppressNotification(["--verbose", "cli", "setup"])).toBe( + true + ); + expect(shouldSuppressNotification(["-v", "cli", "fix"])).toBe(true); + expect(shouldSuppressNotification(["--org", "acme", "cli", "setup"])).toBe( + true + ); + expect(shouldSuppressNotification(["--org=acme", "cli", "fix"])).toBe(true); + expect( + shouldSuppressNotification(["--verbose", "cli", "--org", "acme", "setup"]) + ).toBe(true); + }); + + test("does not suppress when a non-flag token precedes cli", () => { + // The first positional token settles the command group. If it isn't `cli`, + // a later `cli setup` is an argument, not the management command. + expect(shouldSuppressNotification(["issue", "cli", "setup"])).toBe(false); + }); + test("does not suppress for cli feedback", () => { expect(shouldSuppressNotification(["cli", "feedback"])).toBe(false); }); From aa1f3f5af8bd97d3666e8870545cdfd05c95a8ff Mon Sep 17 00:00:00 2001 From: "jared-outpost[bot]" Date: Sun, 2 Aug 2026 14:53:03 +0000 Subject: [PATCH 4/5] fix(stricli-patch): don't let a value-flag latch swallow --help The top-level-flags scanner patch latched the next token as a value for value-taking global flags (`--org`, `--fields`, `--log-level`, `--project`) *before* the --help/-h interception. So `sentry --org --help` (value flag given without a value) consumed --help as the org value and help never fired. Skip the latch for help tokens so they fall through to the help handler, matching Stricli's leaf parser, which never consumes a following flag as a value. Flagged by Cursor Bugbot on #1340. --- .../cli/patches/@stricli%2Fcore@1.2.8.patch | 8 ++++---- .../cli/test/lib/argv-glue.integration.test.ts | 17 +++++++++++++++++ pnpm-lock.yaml | 8 ++++---- 3 files changed, 25 insertions(+), 8 deletions(-) diff --git a/packages/cli/patches/@stricli%2Fcore@1.2.8.patch b/packages/cli/patches/@stricli%2Fcore@1.2.8.patch index f65af60632..f11128fa6b 100644 --- a/packages/cli/patches/@stricli%2Fcore@1.2.8.patch +++ b/packages/cli/patches/@stricli%2Fcore@1.2.8.patch @@ -1,5 +1,5 @@ diff --git a/dist/index.cjs b/dist/index.cjs -index a9afbc1ab78cad7ba97d53882a80236af62854df..7622c3b1e512fd9a180fce9cca7955001e22e5a9 100644 +index a9afbc1ab78cad7ba97d53882a80236af62854df..d5aa6e2d46b08fcd20f1bf06fdb427e45e2ae620 100644 --- a/dist/index.cjs +++ b/dist/index.cjs @@ -1277,6 +1277,29 @@ var RouteMapSymbol = Symbol("RouteMap"); @@ -44,7 +44,7 @@ index a9afbc1ab78cad7ba97d53882a80236af62854df..7622c3b1e512fd9a180fce9cca795500 unprocessedInputs.push(input); return; } -+ if (!treatInputsAsArguments && !target && expectTopLevelFlagValue) { ++ if (!treatInputsAsArguments && !target && expectTopLevelFlagValue && input !== "--help" && input !== "-h" && input !== "--helpAll" && input !== "--help-all") { + expectTopLevelFlagValue = false; + unprocessedInputs.push(input); + return; @@ -119,7 +119,7 @@ index a9afbc1ab78cad7ba97d53882a80236af62854df..7622c3b1e512fd9a180fce9cca795500 checkForInvalidVariadicSeparators(flags); let loader; diff --git a/dist/index.js b/dist/index.js -index f76639637e812453d107bda3e3ec165fc195f4d8..13f059ca7f56375fe3fbdc160f0f3f9cb0d7f008 100644 +index f76639637e812453d107bda3e3ec165fc195f4d8..4a55791b41d95baf324c687553694616ac82c67e 100644 --- a/dist/index.js +++ b/dist/index.js @@ -1229,6 +1229,29 @@ var RouteMapSymbol = Symbol("RouteMap"); @@ -164,7 +164,7 @@ index f76639637e812453d107bda3e3ec165fc195f4d8..13f059ca7f56375fe3fbdc160f0f3f9c unprocessedInputs.push(input); return; } -+ if (!treatInputsAsArguments && !target && expectTopLevelFlagValue) { ++ if (!treatInputsAsArguments && !target && expectTopLevelFlagValue && input !== "--help" && input !== "-h" && input !== "--helpAll" && input !== "--help-all") { + expectTopLevelFlagValue = false; + unprocessedInputs.push(input); + return; diff --git a/packages/cli/test/lib/argv-glue.integration.test.ts b/packages/cli/test/lib/argv-glue.integration.test.ts index 642ba42084..30dc31a5e7 100644 --- a/packages/cli/test/lib/argv-glue.integration.test.ts +++ b/packages/cli/test/lib/argv-glue.integration.test.ts @@ -163,6 +163,23 @@ describe("top-level flags on a nested group command", () => { }); }); +describe("a value flag does not swallow --help", () => { + // A value-taking global flag given without its value (`--org --help`) must not + // consume the following `--help` as its value — help should still fire, matching + // Stricli's leaf-level behavior where a following flag isn't taken as a value. + test("--org --help renders help instead of eating --help", async () => { + const { stdout, stderr } = await runApp(["--org", "--help"]); + expect(stderr).not.toContain(NO_COMMAND_REGISTERED); + expect(stdout).toContain("USAGE"); + }); + + test("--org --help at a group renders help", async () => { + const { stdout, stderr } = await runApp(["cli", "--org", "--help"]); + expect(stderr).not.toContain(NO_COMMAND_REGISTERED); + expect(stdout).toContain("USAGE"); + }); +}); + describe("escape sequence is still respected", () => { test("a global flag after -- is not treated as a top-level flag", async () => { // After `--`, tokens are positional/pass-through. `bash-hook` takes no diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5a389d82a6..98afcf4876 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,7 +23,7 @@ patchedDependencies: hash: 0a5f08471bd72cb833a2b36220c96001d966e5c390d71aaf598fdef75b552640 path: packages/cli/patches/@sentry%2Fnode-core@10.63.0.patch '@stricli/core@1.2.8': - hash: 6017aa2a74778fae943ce1cb7cb1321052fce0787277d0457b2b6666dd9c389a + hash: f807f0fc24fb8fdbb501316398183a26a3d57940c465f7a6f1e7f96c2dcba61a path: packages/cli/patches/@stricli%2Fcore@1.2.8.patch importers: @@ -91,7 +91,7 @@ importers: version: 1.3.0 '@stricli/core': specifier: 1.2.8 - version: 1.2.8(patch_hash=6017aa2a74778fae943ce1cb7cb1321052fce0787277d0457b2b6666dd9c389a) + version: 1.2.8(patch_hash=f807f0fc24fb8fdbb501316398183a26a3d57940c465f7a6f1e7f96c2dcba61a) '@types/http-cache-semantics': specifier: ^4.2.0 version: 4.2.0 @@ -5836,7 +5836,7 @@ snapshots: dependencies: '@stricli/core': 1.3.0 - '@stricli/core@1.2.8(patch_hash=6017aa2a74778fae943ce1cb7cb1321052fce0787277d0457b2b6666dd9c389a)': {} + '@stricli/core@1.2.8(patch_hash=f807f0fc24fb8fdbb501316398183a26a3d57940c465f7a6f1e7f96c2dcba61a)': {} '@stricli/core@1.3.0': {} @@ -6804,7 +6804,7 @@ snapshots: fossilize@0.10.1: dependencies: '@stricli/auto-complete': 1.3.0 - '@stricli/core': 1.2.8(patch_hash=6017aa2a74778fae943ce1cb7cb1321052fce0787277d0457b2b6666dd9c389a) + '@stricli/core': 1.2.8(patch_hash=f807f0fc24fb8fdbb501316398183a26a3d57940c465f7a6f1e7f96c2dcba61a) binpunch: 1.0.0 esbuild: 0.28.1 macho-unsign: 2.0.6 From 3b43098e50113d7391764e892d0fd5375a5dfd17 Mon Sep 17 00:00:00 2001 From: "jared-outpost[bot]" Date: Mon, 3 Aug 2026 10:22:15 +0000 Subject: [PATCH 5/5] ref(stricli-patch): make top-level-flags allow-list a generic scanner option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rework the @stricli/core route-scanner patch so the top-level (global) flags allow-list is passed in via a new `scanner.topLevelFlags` config option instead of hardcoding SENTRY_TOP_LEVEL_*_FLAGS in the patch. The scanner now reads the allow-list (booleanFlags/valueFlags sets) from config and is inert when unset, keeping stock Stricli behavior unchanged — this makes the top-level-flags feature upstreamable. The Sentry CLI supplies the allow-list from GLOBAL_FLAGS via buildTopLevelFlags() wired into app.ts's scanner config, so adding a global flag stays a one-line change. Adds the topLevelFlags field to ScannerConfiguration in the patched .d.ts, updates check:patches to assert matchTopLevelFlag, and adds a test pinning the derivation contract. Addresses review feedback from @BYK on #1340. --- .../cli/patches/@stricli%2Fcore@1.2.8.patch | 208 +++++++++++++----- packages/cli/script/check-patches.ts | 15 +- packages/cli/src/app.ts | 6 + packages/cli/src/lib/global-flags.ts | 53 ++++- packages/cli/test/lib/global-flags.test.ts | 69 ++++++ pnpm-lock.yaml | 8 +- 6 files changed, 284 insertions(+), 75 deletions(-) create mode 100644 packages/cli/test/lib/global-flags.test.ts diff --git a/packages/cli/patches/@stricli%2Fcore@1.2.8.patch b/packages/cli/patches/@stricli%2Fcore@1.2.8.patch index f11128fa6b..248e1c5ad5 100644 --- a/packages/cli/patches/@stricli%2Fcore@1.2.8.patch +++ b/packages/cli/patches/@stricli%2Fcore@1.2.8.patch @@ -1,30 +1,36 @@ diff --git a/dist/index.cjs b/dist/index.cjs -index a9afbc1ab78cad7ba97d53882a80236af62854df..d5aa6e2d46b08fcd20f1bf06fdb427e45e2ae620 100644 +index a9afbc1ab78cad7ba97d53882a80236af62854df..a9569aedd3fe8ffe3c7c47fe06147b770ca1f523 100644 --- a/dist/index.cjs +++ b/dist/index.cjs -@@ -1277,6 +1277,29 @@ var RouteMapSymbol = Symbol("RouteMap"); +@@ -1277,6 +1277,35 @@ var RouteMapSymbol = Symbol("RouteMap"); var CommandSymbol = Symbol("Command"); // src/routing/scanner.ts -+// PATCH(getsentry/cli): top-level flags allow-list. -+// Stricli only parses flags at the leaf command, so a global flag placed before -+// the subcommand (e.g. `sentry --verbose issue list`) is treated as an unknown -+// route segment and fails route resolution. This allow-list lets the scanner -+// recognize a fixed set of Sentry global flags at any route depth and forward -+// them (and the value of value-taking flags) to the leaf command via -+// unprocessedInputs, instead of erroring. This replaces the app-level -+// argv-hoist preprocessor. If this constant is missing, `check:patches` fails. -+var SENTRY_TOP_LEVEL_BOOLEAN_FLAGS = new Set(["--verbose", "-v", "--json", "--no-verbose", "--no-json"]); -+var SENTRY_TOP_LEVEL_VALUE_FLAGS = new Set(["--log-level", "--fields", "--org", "--project"]); -+function matchSentryTopLevelFlag(input) { -+ if (SENTRY_TOP_LEVEL_BOOLEAN_FLAGS.has(input)) { ++// PATCH(getsentry/cli): pluggable top-level (global) flags allow-list. ++// Stricli only parses flags at the leaf command, so a flag placed before the ++// subcommand (e.g. `mycli --verbose sub cmd`) is treated as an unknown route ++// segment and fails route resolution. When the host application declares a ++// `scanner.topLevelFlags` allow-list, `buildRouteScanner` recognizes those ++// flags at any route depth and forwards them (and, for value-taking flags, ++// their value) to the leaf command via unprocessedInputs instead of erroring. ++// The option is inert when unset, so stock Stricli behavior is unchanged. This ++// is written to be upstreamable; the Sentry CLI supplies the allow-list from ++// its GLOBAL_FLAGS definition. If `matchTopLevelFlag` is missing, the CLI's ++// `check:patches` fails. ++function matchTopLevelFlag(input, topLevelFlags) { ++ if (!topLevelFlags) { ++ return null; ++ } ++ const booleanFlags = topLevelFlags.booleanFlags; ++ const valueFlags = topLevelFlags.valueFlags; ++ if (booleanFlags && booleanFlags.has(input)) { + return { takesValue: false, inlineValue: false }; + } -+ if (SENTRY_TOP_LEVEL_VALUE_FLAGS.has(input)) { ++ if (valueFlags && valueFlags.has(input)) { + return { takesValue: true, inlineValue: false }; + } + const eqIndex = input.indexOf("="); -+ if (eqIndex !== -1 && SENTRY_TOP_LEVEL_VALUE_FLAGS.has(input.slice(0, eqIndex))) { ++ if (eqIndex !== -1 && valueFlags && valueFlags.has(input.slice(0, eqIndex))) { + return { takesValue: true, inlineValue: true }; + } + return null; @@ -32,7 +38,7 @@ index a9afbc1ab78cad7ba97d53882a80236af62854df..d5aa6e2d46b08fcd20f1bf06fdb427e4 function buildRouteScanner(root, config, startingPrefix) { const prefix = [...startingPrefix]; const unprocessedInputs = []; -@@ -1286,6 +1309,7 @@ function buildRouteScanner(root, config, startingPrefix) { +@@ -1286,6 +1315,7 @@ function buildRouteScanner(root, config, startingPrefix) { let rootLevel = true; let helpRequested = false; let treatInputsAsArguments = false; @@ -40,7 +46,7 @@ index a9afbc1ab78cad7ba97d53882a80236af62854df..d5aa6e2d46b08fcd20f1bf06fdb427e4 return { next: (input) => { if (!treatInputsAsArguments && config.allowArgumentEscapeSequence && input === "--") { -@@ -1293,6 +1317,11 @@ function buildRouteScanner(root, config, startingPrefix) { +@@ -1293,6 +1323,11 @@ function buildRouteScanner(root, config, startingPrefix) { unprocessedInputs.push(input); return; } @@ -52,7 +58,7 @@ index a9afbc1ab78cad7ba97d53882a80236af62854df..d5aa6e2d46b08fcd20f1bf06fdb427e4 if (!treatInputsAsArguments) { if (input === "--help" || input === "-h") { helpRequested = true; -@@ -1300,7 +1329,7 @@ function buildRouteScanner(root, config, startingPrefix) { +@@ -1300,7 +1335,7 @@ function buildRouteScanner(root, config, startingPrefix) { target = current; } return; @@ -61,12 +67,12 @@ index a9afbc1ab78cad7ba97d53882a80236af62854df..d5aa6e2d46b08fcd20f1bf06fdb427e4 helpRequested = "all"; if (!target) { target = current; -@@ -1308,6 +1337,16 @@ function buildRouteScanner(root, config, startingPrefix) { +@@ -1308,6 +1343,16 @@ function buildRouteScanner(root, config, startingPrefix) { return; } } + if (!treatInputsAsArguments && !target) { -+ const topLevelFlag = matchSentryTopLevelFlag(input); ++ const topLevelFlag = matchTopLevelFlag(input, config.topLevelFlags); + if (topLevelFlag) { + unprocessedInputs.push(input); + if (topLevelFlag.takesValue && !topLevelFlag.inlineValue) { @@ -78,20 +84,30 @@ index a9afbc1ab78cad7ba97d53882a80236af62854df..d5aa6e2d46b08fcd20f1bf06fdb427e4 if (target) { unprocessedInputs.push(input); return; -@@ -1410,7 +1449,11 @@ async function runApplication({ root, defaultText, config }, rawInputs, context) +@@ -1410,7 +1455,11 @@ async function runApplication({ root, defaultText, config }, rawInputs, context) } } const inputs = rawInputs.slice(); - if (config.versionInfo && (inputs[0] === "--version" || inputs[0] === "-v")) { -+ // PATCH(getsentry/cli): drop the built-in `-v`=version alias; the Sentry CLI -+ // maps `-v` to `--verbose` (see GLOBAL_FLAGS), and the route scanner now forwards -+ // `-v` to the leaf command. `--version` remains the version flag. If this comment -+ // is missing, `check:patches` fails. ++ // PATCH(getsentry/cli): drop the built-in `-v`=version alias so a host app can ++ // remap `-v` (the Sentry CLI maps it to `--verbose` via GLOBAL_FLAGS and ++ // forwards it through the top-level-flags allow-list). `--version` remains the ++ // version flag. If this comment is missing, `check:patches` fails. + if (config.versionInfo && inputs[0] === "--version") { let currentVersion; if ("currentVersion" in config.versionInfo) { currentVersion = config.versionInfo.currentVersion; -@@ -1881,7 +1924,7 @@ function formatDocumentationForFlagParameters(flags, aliases, args) { +@@ -1514,6 +1563,9 @@ function withDefaults(config) { + const scannerConfig = { + caseStyle: scannerCaseStyle, + allowArgumentEscapeSequence: config.scanner?.allowArgumentEscapeSequence ?? false, ++ // PATCH(getsentry/cli): carry the optional top-level-flags allow-list through ++ // config normalization so it reaches buildRouteScanner. Inert when unset. ++ topLevelFlags: config.scanner?.topLevelFlags, + distanceOptions: config.scanner?.distanceOptions ?? { + threshold: 7, + weights: { +@@ -1881,7 +1933,7 @@ function formatDocumentationForFlagParameters(flags, aliases, args) { if (args.includeHelpAllFlag) { const helpAllFlagName = formatForDisplay("helpAll", args.config.caseStyle); rows.push({ @@ -100,7 +116,7 @@ index a9afbc1ab78cad7ba97d53882a80236af62854df..d5aa6e2d46b08fcd20f1bf06fdb427e4 flagName: atLeastOneOptional ? ` --${helpAllFlagName}` : `--${helpAllFlagName}`, brief: briefs.helpAll, hidden: !args.config.alwaysShowHelpAllFlag -@@ -1920,7 +1963,7 @@ function* generateBuiltInFlagUsageLines(args) { +@@ -1920,7 +1972,7 @@ function* generateBuiltInFlagUsageLines(args) { yield args.config.useAliasInUsageLine ? "-h" : "--help"; if (args.includeHelpAllFlag) { const helpAllFlagName = formatForDisplay("helpAll", args.config.caseStyle); @@ -109,7 +125,7 @@ index a9afbc1ab78cad7ba97d53882a80236af62854df..d5aa6e2d46b08fcd20f1bf06fdb427e4 } if (args.includeVersionFlag) { yield args.config.useAliasInUsageLine ? "-v" : "--version"; -@@ -2057,7 +2100,7 @@ function checkForInvalidVariadicSeparators(flags) { +@@ -2057,7 +2109,7 @@ function checkForInvalidVariadicSeparators(flags) { function buildCommand(builderArgs) { const { flags = {}, aliases = {} } = builderArgs.parameters; checkForReservedFlags(flags, ["help", "helpAll", "help-all"]); @@ -118,33 +134,99 @@ index a9afbc1ab78cad7ba97d53882a80236af62854df..d5aa6e2d46b08fcd20f1bf06fdb427e4 checkForNegationCollisions(flags); checkForInvalidVariadicSeparators(flags); let loader; +diff --git a/dist/index.d.cts b/dist/index.d.cts +index 111d1fd9e926f23b71359a692cd2e9e05ebf379e..6acd50dedd74bba094c142715881e0e7cd13eb40 100644 +--- a/dist/index.d.cts ++++ b/dist/index.d.cts +@@ -402,6 +402,25 @@ interface ScannerConfiguration { + * Default value is `false` + */ + readonly allowArgumentEscapeSequence: boolean; ++ /** ++ * Optional allow-list of "top-level" (global) flags recognized at any route ++ * depth, not just at the leaf command. By default Stricli only parses flags ++ * at the resolved command, so a flag placed before the subcommand ++ * (`cli --verbose sub cmd`) is treated as an unknown route segment. When this ++ * is set, the route scanner forwards a matching flag (and, for value flags, ++ * its value) to the leaf command instead of failing route resolution. ++ * ++ * - `booleanFlags` — standalone flag tokens (e.g. `--verbose`, `-v`, ++ * `--no-verbose`) that do not consume a value. ++ * - `valueFlags` — flag tokens (e.g. `--org`) that consume the following ++ * argv token, or an `=`-joined value (`--org=acme`), as their value. ++ * ++ * Default value is `undefined` (no top-level flags; stock behavior). ++ */ ++ readonly topLevelFlags?: { ++ readonly booleanFlags: ReadonlySet; ++ readonly valueFlags: ReadonlySet; ++ }; + /** + * Options used when calculating distance for alternative inputs ("did you mean?"). + * +diff --git a/dist/index.d.ts b/dist/index.d.ts +index 111d1fd9e926f23b71359a692cd2e9e05ebf379e..6acd50dedd74bba094c142715881e0e7cd13eb40 100644 +--- a/dist/index.d.ts ++++ b/dist/index.d.ts +@@ -402,6 +402,25 @@ interface ScannerConfiguration { + * Default value is `false` + */ + readonly allowArgumentEscapeSequence: boolean; ++ /** ++ * Optional allow-list of "top-level" (global) flags recognized at any route ++ * depth, not just at the leaf command. By default Stricli only parses flags ++ * at the resolved command, so a flag placed before the subcommand ++ * (`cli --verbose sub cmd`) is treated as an unknown route segment. When this ++ * is set, the route scanner forwards a matching flag (and, for value flags, ++ * its value) to the leaf command instead of failing route resolution. ++ * ++ * - `booleanFlags` — standalone flag tokens (e.g. `--verbose`, `-v`, ++ * `--no-verbose`) that do not consume a value. ++ * - `valueFlags` — flag tokens (e.g. `--org`) that consume the following ++ * argv token, or an `=`-joined value (`--org=acme`), as their value. ++ * ++ * Default value is `undefined` (no top-level flags; stock behavior). ++ */ ++ readonly topLevelFlags?: { ++ readonly booleanFlags: ReadonlySet; ++ readonly valueFlags: ReadonlySet; ++ }; + /** + * Options used when calculating distance for alternative inputs ("did you mean?"). + * diff --git a/dist/index.js b/dist/index.js -index f76639637e812453d107bda3e3ec165fc195f4d8..4a55791b41d95baf324c687553694616ac82c67e 100644 +index f76639637e812453d107bda3e3ec165fc195f4d8..2b049d94c7c41ae17532655410c7817d1f5cf3ca 100644 --- a/dist/index.js +++ b/dist/index.js -@@ -1229,6 +1229,29 @@ var RouteMapSymbol = Symbol("RouteMap"); +@@ -1229,6 +1229,35 @@ var RouteMapSymbol = Symbol("RouteMap"); var CommandSymbol = Symbol("Command"); // src/routing/scanner.ts -+// PATCH(getsentry/cli): top-level flags allow-list. -+// Stricli only parses flags at the leaf command, so a global flag placed before -+// the subcommand (e.g. `sentry --verbose issue list`) is treated as an unknown -+// route segment and fails route resolution. This allow-list lets the scanner -+// recognize a fixed set of Sentry global flags at any route depth and forward -+// them (and the value of value-taking flags) to the leaf command via -+// unprocessedInputs, instead of erroring. This replaces the app-level -+// argv-hoist preprocessor. If this constant is missing, `check:patches` fails. -+var SENTRY_TOP_LEVEL_BOOLEAN_FLAGS = new Set(["--verbose", "-v", "--json", "--no-verbose", "--no-json"]); -+var SENTRY_TOP_LEVEL_VALUE_FLAGS = new Set(["--log-level", "--fields", "--org", "--project"]); -+function matchSentryTopLevelFlag(input) { -+ if (SENTRY_TOP_LEVEL_BOOLEAN_FLAGS.has(input)) { ++// PATCH(getsentry/cli): pluggable top-level (global) flags allow-list. ++// Stricli only parses flags at the leaf command, so a flag placed before the ++// subcommand (e.g. `mycli --verbose sub cmd`) is treated as an unknown route ++// segment and fails route resolution. When the host application declares a ++// `scanner.topLevelFlags` allow-list, `buildRouteScanner` recognizes those ++// flags at any route depth and forwards them (and, for value-taking flags, ++// their value) to the leaf command via unprocessedInputs instead of erroring. ++// The option is inert when unset, so stock Stricli behavior is unchanged. This ++// is written to be upstreamable; the Sentry CLI supplies the allow-list from ++// its GLOBAL_FLAGS definition. If `matchTopLevelFlag` is missing, the CLI's ++// `check:patches` fails. ++function matchTopLevelFlag(input, topLevelFlags) { ++ if (!topLevelFlags) { ++ return null; ++ } ++ const booleanFlags = topLevelFlags.booleanFlags; ++ const valueFlags = topLevelFlags.valueFlags; ++ if (booleanFlags && booleanFlags.has(input)) { + return { takesValue: false, inlineValue: false }; + } -+ if (SENTRY_TOP_LEVEL_VALUE_FLAGS.has(input)) { ++ if (valueFlags && valueFlags.has(input)) { + return { takesValue: true, inlineValue: false }; + } + const eqIndex = input.indexOf("="); -+ if (eqIndex !== -1 && SENTRY_TOP_LEVEL_VALUE_FLAGS.has(input.slice(0, eqIndex))) { ++ if (eqIndex !== -1 && valueFlags && valueFlags.has(input.slice(0, eqIndex))) { + return { takesValue: true, inlineValue: true }; + } + return null; @@ -152,7 +234,7 @@ index f76639637e812453d107bda3e3ec165fc195f4d8..4a55791b41d95baf324c687553694616 function buildRouteScanner(root, config, startingPrefix) { const prefix = [...startingPrefix]; const unprocessedInputs = []; -@@ -1238,6 +1261,7 @@ function buildRouteScanner(root, config, startingPrefix) { +@@ -1238,6 +1267,7 @@ function buildRouteScanner(root, config, startingPrefix) { let rootLevel = true; let helpRequested = false; let treatInputsAsArguments = false; @@ -160,7 +242,7 @@ index f76639637e812453d107bda3e3ec165fc195f4d8..4a55791b41d95baf324c687553694616 return { next: (input) => { if (!treatInputsAsArguments && config.allowArgumentEscapeSequence && input === "--") { -@@ -1245,6 +1269,11 @@ function buildRouteScanner(root, config, startingPrefix) { +@@ -1245,6 +1275,11 @@ function buildRouteScanner(root, config, startingPrefix) { unprocessedInputs.push(input); return; } @@ -172,7 +254,7 @@ index f76639637e812453d107bda3e3ec165fc195f4d8..4a55791b41d95baf324c687553694616 if (!treatInputsAsArguments) { if (input === "--help" || input === "-h") { helpRequested = true; -@@ -1252,7 +1281,7 @@ function buildRouteScanner(root, config, startingPrefix) { +@@ -1252,7 +1287,7 @@ function buildRouteScanner(root, config, startingPrefix) { target = current; } return; @@ -181,12 +263,12 @@ index f76639637e812453d107bda3e3ec165fc195f4d8..4a55791b41d95baf324c687553694616 helpRequested = "all"; if (!target) { target = current; -@@ -1260,6 +1289,16 @@ function buildRouteScanner(root, config, startingPrefix) { +@@ -1260,6 +1295,16 @@ function buildRouteScanner(root, config, startingPrefix) { return; } } + if (!treatInputsAsArguments && !target) { -+ const topLevelFlag = matchSentryTopLevelFlag(input); ++ const topLevelFlag = matchTopLevelFlag(input, config.topLevelFlags); + if (topLevelFlag) { + unprocessedInputs.push(input); + if (topLevelFlag.takesValue && !topLevelFlag.inlineValue) { @@ -198,20 +280,30 @@ index f76639637e812453d107bda3e3ec165fc195f4d8..4a55791b41d95baf324c687553694616 if (target) { unprocessedInputs.push(input); return; -@@ -1362,7 +1401,11 @@ async function runApplication({ root, defaultText, config }, rawInputs, context) +@@ -1362,7 +1407,11 @@ async function runApplication({ root, defaultText, config }, rawInputs, context) } } const inputs = rawInputs.slice(); - if (config.versionInfo && (inputs[0] === "--version" || inputs[0] === "-v")) { -+ // PATCH(getsentry/cli): drop the built-in `-v`=version alias; the Sentry CLI -+ // maps `-v` to `--verbose` (see GLOBAL_FLAGS), and the route scanner now forwards -+ // `-v` to the leaf command. `--version` remains the version flag. If this comment -+ // is missing, `check:patches` fails. ++ // PATCH(getsentry/cli): drop the built-in `-v`=version alias so a host app can ++ // remap `-v` (the Sentry CLI maps it to `--verbose` via GLOBAL_FLAGS and ++ // forwards it through the top-level-flags allow-list). `--version` remains the ++ // version flag. If this comment is missing, `check:patches` fails. + if (config.versionInfo && inputs[0] === "--version") { let currentVersion; if ("currentVersion" in config.versionInfo) { currentVersion = config.versionInfo.currentVersion; -@@ -1833,7 +1876,7 @@ function formatDocumentationForFlagParameters(flags, aliases, args) { +@@ -1466,6 +1515,9 @@ function withDefaults(config) { + const scannerConfig = { + caseStyle: scannerCaseStyle, + allowArgumentEscapeSequence: config.scanner?.allowArgumentEscapeSequence ?? false, ++ // PATCH(getsentry/cli): carry the optional top-level-flags allow-list through ++ // config normalization so it reaches buildRouteScanner. Inert when unset. ++ topLevelFlags: config.scanner?.topLevelFlags, + distanceOptions: config.scanner?.distanceOptions ?? { + threshold: 7, + weights: { +@@ -1833,7 +1885,7 @@ function formatDocumentationForFlagParameters(flags, aliases, args) { if (args.includeHelpAllFlag) { const helpAllFlagName = formatForDisplay("helpAll", args.config.caseStyle); rows.push({ @@ -220,7 +312,7 @@ index f76639637e812453d107bda3e3ec165fc195f4d8..4a55791b41d95baf324c687553694616 flagName: atLeastOneOptional ? ` --${helpAllFlagName}` : `--${helpAllFlagName}`, brief: briefs.helpAll, hidden: !args.config.alwaysShowHelpAllFlag -@@ -1872,7 +1915,7 @@ function* generateBuiltInFlagUsageLines(args) { +@@ -1872,7 +1924,7 @@ function* generateBuiltInFlagUsageLines(args) { yield args.config.useAliasInUsageLine ? "-h" : "--help"; if (args.includeHelpAllFlag) { const helpAllFlagName = formatForDisplay("helpAll", args.config.caseStyle); @@ -229,7 +321,7 @@ index f76639637e812453d107bda3e3ec165fc195f4d8..4a55791b41d95baf324c687553694616 } if (args.includeVersionFlag) { yield args.config.useAliasInUsageLine ? "-v" : "--version"; -@@ -2009,7 +2052,7 @@ function checkForInvalidVariadicSeparators(flags) { +@@ -2009,7 +2061,7 @@ function checkForInvalidVariadicSeparators(flags) { function buildCommand(builderArgs) { const { flags = {}, aliases = {} } = builderArgs.parameters; checkForReservedFlags(flags, ["help", "helpAll", "help-all"]); diff --git a/packages/cli/script/check-patches.ts b/packages/cli/script/check-patches.ts index 936ef7a1dc..4ebe423f18 100644 --- a/packages/cli/script/check-patches.ts +++ b/packages/cli/script/check-patches.ts @@ -181,11 +181,12 @@ for (const [key, patchPath] of Object.entries(patches)) { * patch did NOT apply (in either the ESM or CJS bundle). * * @stricli/core (top-level flags): the patch teaches `buildRouteScanner` to - * recognize a fixed allow-list of Sentry global flags (`--verbose`, `--json`, - * `--org`, …) at any route depth, so `sentry --verbose issue list` no longer - * fails route resolution. This is a pure insertion, so it's guarded by a - * `requiredMarker` (`matchSentryTopLevelFlag`) that must be present once - * patched. Its absence means global flags before a subcommand will crash. + * recognize a host-supplied allow-list of global flags (`scanner.topLevelFlags`) + * at any route depth, so `sentry --verbose issue list` no longer fails route + * resolution. The allow-list itself is passed in from the app (derived from + * GLOBAL_FLAGS) rather than hardcoded in the patch. This is a pure insertion, so + * it's guarded by a `requiredMarker` (`matchTopLevelFlag`) that must be present + * once patched. Its absence means global flags before a subcommand will crash. * * @stricli/core (`-v` version alias): the patch also drops Stricli's built-in * `-v`=version alias in `runApplication` so `-v` stays the Sentry CLI's @@ -228,13 +229,13 @@ const CONTENT_ASSERTIONS: ReadonlyArray<{ }, { file: "@stricli/core/dist/index.js", - requiredMarker: "matchSentryTopLevelFlag", + requiredMarker: "matchTopLevelFlag", description: "@stricli/core ESM: top-level-flags scanner allow-list missing (global flags before a subcommand, e.g. `sentry --verbose issue list`, will fail route resolution)", }, { file: "@stricli/core/dist/index.cjs", - requiredMarker: "matchSentryTopLevelFlag", + requiredMarker: "matchTopLevelFlag", description: "@stricli/core CJS: top-level-flags scanner allow-list missing (global flags before a subcommand, e.g. `sentry --verbose issue list`, will fail route resolution)", }, diff --git a/packages/cli/src/app.ts b/packages/cli/src/app.ts index 00e799e9be..bc83981553 100644 --- a/packages/cli/src/app.ts +++ b/packages/cli/src/app.ts @@ -71,6 +71,7 @@ import { WizardError, } from "./lib/errors.js"; import { error as errorColor, warning } from "./lib/formatters/colors.js"; +import { buildTopLevelFlags } from "./lib/global-flags.js"; import { isRouteMap, type RouteMap } from "./lib/introspect.js"; import { buildRouteMap } from "./lib/route-map.js"; @@ -399,6 +400,11 @@ export const app = buildApplication(routes, { // `sentry monitor run -- `) can pass through flags // like `-e` or `--verbose` to the wrapped command unambiguously. allowArgumentEscapeSequence: true, + // Recognize global flags placed before the subcommand + // (`sentry --verbose issue list`) at any route depth and forward them to + // the leaf command, via our @stricli/core route-scanner patch. Derived + // from GLOBAL_FLAGS so adding a global flag there is all that's needed. + topLevelFlags: buildTopLevelFlags(), }, determineExitCode: getExitCode, localization: { diff --git a/packages/cli/src/lib/global-flags.ts b/packages/cli/src/lib/global-flags.ts index ce3633218f..45cc368601 100644 --- a/packages/cli/src/lib/global-flags.ts +++ b/packages/cli/src/lib/global-flags.ts @@ -36,12 +36,11 @@ type GlobalFlagDef = { * Order doesn't matter — both the `buildCommand` wrapper and the app-boundary * glue build lookup structures from this list. * - * IMPORTANT: the set of flag tokens recognized *before the subcommand* also - * lives, hardcoded, in the `@stricli/core` route-scanner patch - * (`packages/cli/patches/@stricli%2Fcore@1.2.8.patch`, - * `SENTRY_TOP_LEVEL_*_FLAGS`). Patching minified `dist` code can't import this - * list, so adding/removing a global flag here means updating that patch too, or - * the flag won't be accepted when placed before the subcommand. + * The set of flag tokens recognized *before the subcommand* is derived from + * this list by {@link buildTopLevelFlags} and handed to Stricli's patched route + * scanner via the `scanner.topLevelFlags` option (see `app.ts`). Adding or + * removing a global flag here is all that's needed — the allow-list is no + * longer hardcoded in the `@stricli/core` patch. */ export const GLOBAL_FLAGS: readonly GlobalFlagDef[] = [ { name: "verbose", short: "v", kind: "boolean" }, @@ -55,3 +54,45 @@ export const GLOBAL_FLAGS: readonly GlobalFlagDef[] = [ { name: "org", short: null, kind: "value" }, { name: "project", short: null, kind: "value" }, ]; + +/** + * Allow-list of global flag tokens recognized before the subcommand, in the + * shape consumed by Stricli's patched route scanner (`scanner.topLevelFlags`). + * + * - `booleanFlags` — tokens that stand alone (no value): each boolean flag's + * `--`, its `-` alias, and its `--no-` negation. + * - `valueFlags` — tokens that consume the following argv token (or an + * `=`-joined value) as their value: each value flag's `--` and + * `-` alias. + * + * The scanner uses these sets to forward a global flag placed before the + * subcommand (`sentry --verbose issue list`) to the leaf command instead of + * failing route resolution. Derived from {@link GLOBAL_FLAGS} so the two stay + * in sync automatically. + */ +export type TopLevelFlags = { + readonly booleanFlags: ReadonlySet; + readonly valueFlags: ReadonlySet; +}; + +/** + * Build the {@link TopLevelFlags} allow-list from {@link GLOBAL_FLAGS}. + * + * Passed to `buildApplication`'s `scanner.topLevelFlags` so the patched route + * scanner recognizes these flags at any route depth. + */ +export function buildTopLevelFlags(): TopLevelFlags { + const booleanFlags = new Set(); + const valueFlags = new Set(); + for (const flag of GLOBAL_FLAGS) { + const target = flag.kind === "boolean" ? booleanFlags : valueFlags; + target.add(`--${flag.name}`); + if (flag.short !== null) { + target.add(`-${flag.short}`); + } + if (flag.kind === "boolean") { + booleanFlags.add(`--no-${flag.name}`); + } + } + return { booleanFlags, valueFlags }; +} diff --git a/packages/cli/test/lib/global-flags.test.ts b/packages/cli/test/lib/global-flags.test.ts new file mode 100644 index 0000000000..47bbbb1db7 --- /dev/null +++ b/packages/cli/test/lib/global-flags.test.ts @@ -0,0 +1,69 @@ +/** + * Unit tests for {@link buildTopLevelFlags}, which derives the route-scanner + * allow-list from {@link GLOBAL_FLAGS}. + * + * The end-to-end behavior (a global flag placed before the subcommand reaching + * the leaf command) is covered via the patched Stricli scanner in + * `argv-glue.integration.test.ts`. These tests pin the derivation contract so + * the allow-list stays in sync with the flag definitions and matches the shape + * Stricli's `scanner.topLevelFlags` option expects. + */ + +import { describe, expect, test } from "vitest"; +import { + buildTopLevelFlags, + GLOBAL_FLAGS, +} from "../../src/lib/global-flags.js"; + +describe("buildTopLevelFlags", () => { + test("every boolean flag contributes --name, its short alias, and --no-name", () => { + const { booleanFlags } = buildTopLevelFlags(); + for (const flag of GLOBAL_FLAGS) { + if (flag.kind !== "boolean") { + continue; + } + expect(booleanFlags.has(`--${flag.name}`)).toBe(true); + expect(booleanFlags.has(`--no-${flag.name}`)).toBe(true); + if (flag.short !== null) { + expect(booleanFlags.has(`-${flag.short}`)).toBe(true); + } + } + }); + + test("every value flag contributes --name (and its short alias)", () => { + const { valueFlags } = buildTopLevelFlags(); + for (const flag of GLOBAL_FLAGS) { + if (flag.kind !== "value") { + continue; + } + expect(valueFlags.has(`--${flag.name}`)).toBe(true); + if (flag.short !== null) { + expect(valueFlags.has(`-${flag.short}`)).toBe(true); + } + } + }); + + test("boolean and value token sets are disjoint", () => { + const { booleanFlags, valueFlags } = buildTopLevelFlags(); + for (const token of booleanFlags) { + expect(valueFlags.has(token)).toBe(false); + } + }); + + test("value flags never carry a --no- negation", () => { + const { valueFlags } = buildTopLevelFlags(); + for (const token of valueFlags) { + expect(token.startsWith("--no-")).toBe(false); + } + }); + + test("matches the current GLOBAL_FLAGS definition", () => { + const { booleanFlags, valueFlags } = buildTopLevelFlags(); + expect([...booleanFlags].sort()).toEqual( + ["--verbose", "-v", "--no-verbose", "--json", "--no-json"].sort() + ); + expect([...valueFlags].sort()).toEqual( + ["--log-level", "--fields", "--org", "--project"].sort() + ); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 98afcf4876..d7984e1b6d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,7 +23,7 @@ patchedDependencies: hash: 0a5f08471bd72cb833a2b36220c96001d966e5c390d71aaf598fdef75b552640 path: packages/cli/patches/@sentry%2Fnode-core@10.63.0.patch '@stricli/core@1.2.8': - hash: f807f0fc24fb8fdbb501316398183a26a3d57940c465f7a6f1e7f96c2dcba61a + hash: c4d48f19f3a4c318a43284cd0b7457257dbd924ed4e8917f54c1af90646f0263 path: packages/cli/patches/@stricli%2Fcore@1.2.8.patch importers: @@ -91,7 +91,7 @@ importers: version: 1.3.0 '@stricli/core': specifier: 1.2.8 - version: 1.2.8(patch_hash=f807f0fc24fb8fdbb501316398183a26a3d57940c465f7a6f1e7f96c2dcba61a) + version: 1.2.8(patch_hash=c4d48f19f3a4c318a43284cd0b7457257dbd924ed4e8917f54c1af90646f0263) '@types/http-cache-semantics': specifier: ^4.2.0 version: 4.2.0 @@ -5836,7 +5836,7 @@ snapshots: dependencies: '@stricli/core': 1.3.0 - '@stricli/core@1.2.8(patch_hash=f807f0fc24fb8fdbb501316398183a26a3d57940c465f7a6f1e7f96c2dcba61a)': {} + '@stricli/core@1.2.8(patch_hash=c4d48f19f3a4c318a43284cd0b7457257dbd924ed4e8917f54c1af90646f0263)': {} '@stricli/core@1.3.0': {} @@ -6804,7 +6804,7 @@ snapshots: fossilize@0.10.1: dependencies: '@stricli/auto-complete': 1.3.0 - '@stricli/core': 1.2.8(patch_hash=f807f0fc24fb8fdbb501316398183a26a3d57940c465f7a6f1e7f96c2dcba61a) + '@stricli/core': 1.2.8(patch_hash=c4d48f19f3a4c318a43284cd0b7457257dbd924ed4e8917f54c1af90646f0263) binpunch: 1.0.0 esbuild: 0.28.1 macho-unsign: 2.0.6