From 558ce98876c1fa20f9fe2fb5f38a78e9ef066520 Mon Sep 17 00:00:00 2001 From: Jeff Yates Date: Thu, 28 May 2026 11:27:36 -0500 Subject: [PATCH 1/4] [tweakoutputupdatepnpm] Update pnpm --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 868d0bd..b09ccca 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "author": "Jeff Yates ", "keywords": [], "license": "MIT", - "packageManager": "pnpm@11.0.0+sha512.5bd187500e49cc6c3d891d973b432c02b844a5eb7209172c90a517a3ef4f579ed5c23d409b699e6a9dc418ff7b2b1890e63f6d74f1d3fc49848f37779c89c84c", + "packageManager": "pnpm@11.4.0+sha512.f0febc7e37552ab485494a914241b338e0b3580b93d54ce31f00933015880863129038a1b4ae4e414a0ee63ac35bf21197e990172c4a68256450b5636310968f", "engines": { "node": ">= 24", "npm": "please-use-pnpm", From 3ed8cc1ac83acfe252607191a35d356f473442cd Mon Sep 17 00:00:00 2001 From: Jeff Yates Date: Thu, 28 May 2026 13:24:43 -0500 Subject: [PATCH 2/4] [tweakoutputupdatepnpm] Only warn about `--` separator for actual x flags The previous heuristic warned whenever any flag-looking argument was passed without a `--` separator, which was noisy: unknown flags pass through to the script just fine and don't need disambiguation. Now the warning only fires when one of x's own option flags (derived from the yargs config via `yi.parsed.aliases` plus the registered `--completion` command) appears _after_ the script name without `--`. Flags before the script name are unambiguously for x and don't trigger. Also adds `NO_WARN=1` as a mute, and advertises it in the tip itself. Co-Authored-By: Claude Opus 4.7 --- .changeset/quieter-flag-tip.md | 5 +++ src/__tests__/x.test.ts | 71 +++++++++++++++++++++++++++------- src/bin/x.ts | 47 ++++++++++++++++++---- 3 files changed, 100 insertions(+), 23 deletions(-) create mode 100644 .changeset/quieter-flag-tip.md diff --git a/.changeset/quieter-flag-tip.md b/.changeset/quieter-flag-tip.md new file mode 100644 index 0000000..c98dd76 --- /dev/null +++ b/.changeset/quieter-flag-tip.md @@ -0,0 +1,5 @@ +--- +"@somewhatabstract/x": patch +--- + +The "use `--` to separate flags" tip is now only shown when an x option flag (e.g. `--dry-run`, `-d`) appears after the script name without a `--` separator — previously it fired for any flag-looking argument, which was noisy because most of those flags pass through to the script just fine. Set `NO_WARN=1` to silence the tip entirely. diff --git a/src/__tests__/x.test.ts b/src/__tests__/x.test.ts index cbb9603..f54e0c1 100644 --- a/src/__tests__/x.test.ts +++ b/src/__tests__/x.test.ts @@ -34,6 +34,7 @@ describe("bin/x", () => { afterEach(() => { vi.restoreAllMocks(); + vi.unstubAllEnvs(); }); it("should call xImpl with the script name from argv", async () => { @@ -162,12 +163,12 @@ describe("bin/x", () => { ); }); - it("should show a tip when args contain flags and -- was not used", async () => { + it("should show a tip when an x flag appears after the script name without --", async () => { // Arrange xImplMock.mockResolvedValue({exitCode: 0}); // Act - await main(["node", "x.mjs", "my-script", "--unknown-flag", "value"]); + await main(["node", "x.mjs", "my-script", "--dry-run"]); // Assert expect(console.warn).toHaveBeenCalledWith( @@ -175,16 +176,29 @@ describe("bin/x", () => { ); }); - it("should show the corrected command in the tip when args contain flags without --", async () => { + it("should show the corrected command in the tip when an x flag follows the script name", async () => { // Arrange xImplMock.mockResolvedValue({exitCode: 0}); // Act - await main(["node", "x.mjs", "my-script", "--unknown-flag", "value"]); + await main(["node", "x.mjs", "my-script", "--dry-run", "value"]); + + // Assert + expect(console.warn).toHaveBeenCalledWith( + " x my-script -- --dry-run value", + ); + }); + + it("should advertise NO_WARN in the tip so users know how to silence it", async () => { + // Arrange + xImplMock.mockResolvedValue({exitCode: 0}); + + // Act + await main(["node", "x.mjs", "my-script", "--dry-run"]); // Assert expect(console.warn).toHaveBeenCalledWith( - " x my-script -- --unknown-flag value", + "(Set NO_WARN=1 to silence this tip.)", ); }); @@ -193,14 +207,7 @@ describe("bin/x", () => { xImplMock.mockResolvedValue({exitCode: 0}); // Act - await main([ - "node", - "x.mjs", - "my-script", - "--", - "--unknown-flag", - "value", - ]); + await main(["node", "x.mjs", "my-script", "--", "--dry-run", "value"]); // Assert expect(console.warn).not.toHaveBeenCalled(); @@ -223,6 +230,40 @@ describe("bin/x", () => { expect(console.warn).not.toHaveBeenCalled(); }); + it("should not show a tip when args contain non-x flags (script's own flags pass through cleanly)", async () => { + // Arrange + xImplMock.mockResolvedValue({exitCode: 0}); + + // Act + await main(["node", "x.mjs", "my-script", "--unknown-flag", "value"]); + + // Assert + expect(console.warn).not.toHaveBeenCalled(); + }); + + it("should not show a tip when an x flag appears before the script name (unambiguously for x)", async () => { + // Arrange + xImplMock.mockResolvedValue({exitCode: 0}); + + // Act + await main(["node", "x.mjs", "--dry-run", "my-script"]); + + // Assert + expect(console.warn).not.toHaveBeenCalled(); + }); + + it("should not show a tip when NO_WARN is set, even if an x flag follows the script name", async () => { + // Arrange + xImplMock.mockResolvedValue({exitCode: 0}); + vi.stubEnv("NO_WARN", "1"); + + // Act + await main(["node", "x.mjs", "my-script", "--dry-run"]); + + // Assert + expect(console.warn).not.toHaveBeenCalled(); + }); + it("should pass positional args to xImpl when user omits --", async () => { // Arrange xImplMock.mockResolvedValue({exitCode: 0}); @@ -310,11 +351,11 @@ describe("bin/x", () => { xImplMock.mockResolvedValue({exitCode: 0}); // Act - await main(["node", "x.mjs", "e2e", "setup", "--flag", "value"]); + await main(["node", "x.mjs", "e2e", "setup", "--dry-run", "value"]); // Assert expect(console.warn).toHaveBeenCalledWith( - " x e2e -- setup --flag value", + " x e2e -- setup --dry-run value", ); }); diff --git a/src/bin/x.ts b/src/bin/x.ts index d5d6b00..4ff5e39 100755 --- a/src/bin/x.ts +++ b/src/bin/x.ts @@ -2,7 +2,7 @@ import {realpathSync} from "node:fs"; import {basename} from "node:path"; import {fileURLToPath} from "node:url"; -import yargs from "yargs"; +import yargs, {type Argv} from "yargs"; import {hideBin} from "yargs/helpers"; import {HandledError} from "../errors"; import {getCompletions} from "../get-completions"; @@ -11,6 +11,28 @@ import {outputHelpWithSplash} from "../output-help-with-splash"; import {validateArgv} from "../validate-argv"; import {type XResult, xImpl} from "../x-impl"; +const POSITIONAL_NAMES = new Set(["script-name", "scriptName"]); + +/** + * Collect the flag forms (e.g. "--list", "-l") of all of x's own options from + * a configured yargs instance after parsing. Keeps the warning logic in sync + * with the yargs config without a hand-maintained list. + */ +function collectXFlags(yi: Argv): Set { + const flags = new Set(); + const parsed = yi.parsed; + if (parsed && typeof parsed === "object") { + for (const name of Object.keys(parsed.aliases)) { + if (POSITIONAL_NAMES.has(name)) continue; + flags.add(name.length === 1 ? `-${name}` : `--${name}`); + } + } + // --completion is registered as a yargs completion command, not an + // option, so it doesn't appear in parsed.aliases. + flags.add("--completion"); + return flags; +} + export async function main(rawArgv: string[]): Promise { const rawArgs = hideBin(rawArgv); @@ -122,18 +144,27 @@ export async function main(rawArgv: string[]): Promise { dryRun: argv["dry-run"] as boolean, }; - // If any args look like flags and -- was not used, warn the user and suggest - // using -- to explicitly separate flag arguments from x's own options. + // If one of x's own option flags appears after the script name without + // using `--`, it was consumed by x rather than forwarded to the script. + // Tip the user off so they can disambiguate. Flags before the script name + // are unambiguously for x, so we don't warn about those. Suppressed when + // NO_WARN is set. const scriptName = (argv["script-name"] as string).trim(); - if (!rawArgs.includes("--")) { - const flagLikeArgs = args.filter( - (arg) => typeof arg === "string" && arg.startsWith("-"), + if (!process.env.NO_WARN && !rawArgs.includes("--")) { + const scriptIdx = rawArgs.indexOf(scriptName); + const beforeScript = rawArgs.slice(0, scriptIdx); + const afterScript = rawArgs.slice(scriptIdx + 1); + const xFlags = collectXFlags(yi); + const hasXFlagAfterScript = afterScript.some( + (arg) => xFlags.has(arg) || xFlags.has(arg.split("=")[0]), ); - if (flagLikeArgs.length > 0) { + if (hasXFlagAfterScript) { + const prefix = [...beforeScript, scriptName].join(" "); console.warn( `Tip: To pass flags to "${scriptName}", use '--' to separate them:`, ); - console.warn(` x ${scriptName} -- ${args.join(" ")}`); + console.warn(` x ${prefix} -- ${afterScript.join(" ")}`); + console.warn(`(Set NO_WARN=1 to silence this tip.)`); } } From 70f2541065d2cd2982c2f546f9aebb379c50658c Mon Sep 17 00:00:00 2001 From: Jeff Yates Date: Thu, 28 May 2026 13:59:26 -0500 Subject: [PATCH 3/4] [tweakoutputupdatepnpm] Move flag-tip check before --help/--list early exits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous placement was below the early-exit branches for --help and --list, so `x my-script --list` silently entered list mode without showing the tip — exactly the kind of scenario the tip is meant to catch. Hoist the check to run right after parse, gated on a non-empty script-name (so `x --list` alone still doesn't trip it). Co-Authored-By: Claude Opus 4.7 --- src/__tests__/x.test.ts | 25 ++++++++++++++++++ src/bin/x.ts | 58 +++++++++++++++++++++++------------------ 2 files changed, 57 insertions(+), 26 deletions(-) diff --git a/src/__tests__/x.test.ts b/src/__tests__/x.test.ts index f54e0c1..f6e6664 100644 --- a/src/__tests__/x.test.ts +++ b/src/__tests__/x.test.ts @@ -264,6 +264,31 @@ describe("bin/x", () => { expect(console.warn).not.toHaveBeenCalled(); }); + it("should show a tip when --list follows the script name, even though x exits into list mode", async () => { + // Arrange + listImplMock.mockResolvedValue({exitCode: 0}); + + // Act + await main(["node", "x.mjs", "my-script", "--list"]); + + // Assert + expect(console.warn).toHaveBeenCalledWith( + `Tip: To pass flags to "my-script", use '--' to separate them:`, + ); + }); + + it("should show a tip when --help follows the script name, even though x exits into help mode", async () => { + // Arrange + + // Act + await main(["node", "x.mjs", "my-script", "--help"]); + + // Assert + expect(console.warn).toHaveBeenCalledWith( + `Tip: To pass flags to "my-script", use '--' to separate them:`, + ); + }); + it("should pass positional args to xImpl when user omits --", async () => { // Arrange xImplMock.mockResolvedValue({exitCode: 0}); diff --git a/src/bin/x.ts b/src/bin/x.ts index 4ff5e39..1b7d5fe 100755 --- a/src/bin/x.ts +++ b/src/bin/x.ts @@ -126,6 +126,34 @@ export async function main(rawArgv: string[]): Promise { ? await yi.parse() : await yi.scriptName("x").parse(); + // If one of x's own option flags appears after the script name without + // using `--`, it was consumed by x rather than forwarded to the script. + // Tip the user off so they can disambiguate. This must happen BEFORE the + // early-exit branches for --help/--list/etc. — those branches are exactly + // the cases where x is about to do something other than what the user + // probably intended. Flags before the script name are unambiguously for x. + // Suppressed when NO_WARN is set. + const scriptName = (argv["script-name"] as string | undefined)?.trim(); + if (scriptName && !process.env.NO_WARN && !rawArgs.includes("--")) { + const scriptIdx = rawArgs.indexOf(scriptName); + if (scriptIdx >= 0) { + const beforeScript = rawArgs.slice(0, scriptIdx); + const afterScript = rawArgs.slice(scriptIdx + 1); + const xFlags = collectXFlags(yi); + const hasXFlagAfterScript = afterScript.some( + (arg) => xFlags.has(arg) || xFlags.has(arg.split("=")[0]), + ); + if (hasXFlagAfterScript) { + const prefix = [...beforeScript, scriptName].join(" "); + console.warn( + `Tip: To pass flags to "${scriptName}", use '--' to separate them:`, + ); + console.warn(` x ${prefix} -- ${afterScript.join(" ")}`); + console.warn(`(Set NO_WARN=1 to silence this tip.)`); + } + } + } + if (argv.help || argv.h) { outputHelpWithSplash(yi); return {exitCode: 0}; @@ -144,32 +172,10 @@ export async function main(rawArgv: string[]): Promise { dryRun: argv["dry-run"] as boolean, }; - // If one of x's own option flags appears after the script name without - // using `--`, it was consumed by x rather than forwarded to the script. - // Tip the user off so they can disambiguate. Flags before the script name - // are unambiguously for x, so we don't warn about those. Suppressed when - // NO_WARN is set. - const scriptName = (argv["script-name"] as string).trim(); - if (!process.env.NO_WARN && !rawArgs.includes("--")) { - const scriptIdx = rawArgs.indexOf(scriptName); - const beforeScript = rawArgs.slice(0, scriptIdx); - const afterScript = rawArgs.slice(scriptIdx + 1); - const xFlags = collectXFlags(yi); - const hasXFlagAfterScript = afterScript.some( - (arg) => xFlags.has(arg) || xFlags.has(arg.split("=")[0]), - ); - if (hasXFlagAfterScript) { - const prefix = [...beforeScript, scriptName].join(" "); - console.warn( - `Tip: To pass flags to "${scriptName}", use '--' to separate them:`, - ); - console.warn(` x ${prefix} -- ${afterScript.join(" ")}`); - console.warn(`(Set NO_WARN=1 to silence this tip.)`); - } - } - - // Run the implementation and exit with the appropriate code - return xImpl(scriptName, args, options); + // Run the implementation and exit with the appropriate code. scriptName + // is guaranteed non-empty here by validateArgv (since --list/--help + // would have early-exited above if it was missing). + return xImpl(scriptName as string, args, options); } // Only run main if we aren't being imported as a module. From 59ad9cd08cdb9cee96f399cfae3a71091ff65d3a Mon Sep 17 00:00:00 2001 From: Jeff Yates Date: Thu, 28 May 2026 14:14:31 -0500 Subject: [PATCH 4/4] [tweakoutputupdatepnpm] Print flag tip after command output, not before MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tip is most useful next to where the user is already looking — the bottom of the terminal — so a long --help dump or --list listing doesn't scroll it off the top. Detection still happens up-front (before the --help/--list early-exits) but the actual console.warn calls are deferred to a finally block, captured in a closure built by makeFlagTipPrinter. On error paths the tip ends up just above the bootstrap-printed error, but on the common success path it's the last thing the user sees. Co-Authored-By: Claude Opus 4.7 --- src/__tests__/x.test.ts | 16 +++++++ src/bin/x.ts | 104 ++++++++++++++++++++++++---------------- 2 files changed, 78 insertions(+), 42 deletions(-) diff --git a/src/__tests__/x.test.ts b/src/__tests__/x.test.ts index f6e6664..bbb0ef9 100644 --- a/src/__tests__/x.test.ts +++ b/src/__tests__/x.test.ts @@ -289,6 +289,22 @@ describe("bin/x", () => { ); }); + it("should print the tip after the command's output, so it lands at the bottom of the terminal", async () => { + // Arrange + listImplMock.mockResolvedValue({exitCode: 0}); + + // Act + await main(["node", "x.mjs", "my-script", "--list"]); + + // Assert + const listCallOrder = listImplMock.mock.invocationCallOrder[0]; + const warnCallOrders = vi.mocked(console.warn).mock.invocationCallOrder; + const firstWarnAfterList = warnCallOrders.find( + (order) => order > listCallOrder, + ); + expect(firstWarnAfterList).toBeDefined(); + }); + it("should pass positional args to xImpl when user omits --", async () => { // Arrange xImplMock.mockResolvedValue({exitCode: 0}); diff --git a/src/bin/x.ts b/src/bin/x.ts index 1b7d5fe..8e1b3ce 100755 --- a/src/bin/x.ts +++ b/src/bin/x.ts @@ -126,56 +126,76 @@ export async function main(rawArgv: string[]): Promise { ? await yi.parse() : await yi.scriptName("x").parse(); - // If one of x's own option flags appears after the script name without - // using `--`, it was consumed by x rather than forwarded to the script. - // Tip the user off so they can disambiguate. This must happen BEFORE the - // early-exit branches for --help/--list/etc. — those branches are exactly - // the cases where x is about to do something other than what the user - // probably intended. Flags before the script name are unambiguously for x. - // Suppressed when NO_WARN is set. + // Decide up-front whether to print the misplaced-flag tip, but defer the + // actual output until the command finishes so it lands at the bottom of + // the terminal where the user is already looking. The detection has to + // happen now (before --help/--list early-exits) so we capture the user's + // intent against the raw args; the printing happens in a finally below. const scriptName = (argv["script-name"] as string | undefined)?.trim(); - if (scriptName && !process.env.NO_WARN && !rawArgs.includes("--")) { - const scriptIdx = rawArgs.indexOf(scriptName); - if (scriptIdx >= 0) { - const beforeScript = rawArgs.slice(0, scriptIdx); - const afterScript = rawArgs.slice(scriptIdx + 1); - const xFlags = collectXFlags(yi); - const hasXFlagAfterScript = afterScript.some( - (arg) => xFlags.has(arg) || xFlags.has(arg.split("=")[0]), - ); - if (hasXFlagAfterScript) { - const prefix = [...beforeScript, scriptName].join(" "); - console.warn( - `Tip: To pass flags to "${scriptName}", use '--' to separate them:`, - ); - console.warn(` x ${prefix} -- ${afterScript.join(" ")}`); - console.warn(`(Set NO_WARN=1 to silence this tip.)`); - } + const printFlagTip = makeFlagTipPrinter(scriptName, rawArgs, yi); + + try { + if (argv.help || argv.h) { + outputHelpWithSplash(yi); + return {exitCode: 0}; } - } - if (argv.help || argv.h) { - outputHelpWithSplash(yi); - return {exitCode: 0}; + // Check if we are in list mode + const listArg = argv.list; + if (listArg !== undefined) { + const mode = listArg === "full" ? "full" : "names-only"; + const json = !!argv.json; + return await listImpl({mode, json}); + } + + const args = (argv._ as string[]) || []; + const options = { + dryRun: argv["dry-run"] as boolean, + }; + + // scriptName is guaranteed non-empty here by validateArgv (since + // --list/--help would have early-exited above if it was missing). + return await xImpl(scriptName as string, args, options); + } finally { + printFlagTip(); } +} - // Check if we are in list mode - const listArg = argv.list; - if (listArg !== undefined) { - const mode = listArg === "full" ? "full" : "names-only"; - const json = !!argv.json; - return await listImpl({mode, json}); +/** + * Detect if the user passed an x flag after the script name without `--`, + * and return a function that prints the tip when called. Returns a no-op + * when there's nothing to warn about (or when NO_WARN is set). Doing the + * detection eagerly and the printing lazily lets us land the tip at the + * bottom of the output where it's most visible. + */ +function makeFlagTipPrinter( + scriptName: string | undefined, + rawArgs: string[], + yi: Argv, +): () => void { + const noop = () => {}; + if (!scriptName || process.env.NO_WARN || rawArgs.includes("--")) { + return noop; } + const scriptIdx = rawArgs.indexOf(scriptName); + if (scriptIdx < 0) return noop; - const args = (argv._ as string[]) || []; - const options = { - dryRun: argv["dry-run"] as boolean, - }; + const beforeScript = rawArgs.slice(0, scriptIdx); + const afterScript = rawArgs.slice(scriptIdx + 1); + const xFlags = collectXFlags(yi); + const hasXFlagAfterScript = afterScript.some( + (arg) => xFlags.has(arg) || xFlags.has(arg.split("=")[0]), + ); + if (!hasXFlagAfterScript) return noop; - // Run the implementation and exit with the appropriate code. scriptName - // is guaranteed non-empty here by validateArgv (since --list/--help - // would have early-exited above if it was missing). - return xImpl(scriptName as string, args, options); + const prefix = [...beforeScript, scriptName].join(" "); + return () => { + console.warn( + `Tip: To pass flags to "${scriptName}", use '--' to separate them:`, + ); + console.warn(` x ${prefix} -- ${afterScript.join(" ")}`); + console.warn(`(Set NO_WARN=1 to silence this tip.)`); + }; } // Only run main if we aren't being imported as a module.