From fa023466ab3534356c6731919dee17fad239c099 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Sun, 20 Sep 2026 14:30:20 -0700 Subject: [PATCH 1/2] fix(cli): print the version for --version and -v The entry runs the root command through citty's runCommand rather than runMain so the finally that emits cli_run still runs, and --help was re-implemented in front of dispatch for that reason. --version was not, so it reached the root command as an unknown flag and rendered the usage banner; -v was never an alias at all. hasVersionFlag scans argv the way hasHelpFlag does, so a directory or a path after -- that reads like -v is still a value, and the flag answers from any position: taskless check --version prints the version instead of silently dropping the flag and running check. The root args list -v, --version so --help documents it. --- .changeset/version-flag.md | 5 + packages/cli/src/index.ts | 43 +++++--- packages/cli/src/util/argv.ts | 12 +++ packages/cli/test/version-flag.test.ts | 142 +++++++++++++++++++++++++ 4 files changed, 190 insertions(+), 12 deletions(-) create mode 100644 .changeset/version-flag.md create mode 100644 packages/cli/test/version-flag.test.ts diff --git a/.changeset/version-flag.md b/.changeset/version-flag.md new file mode 100644 index 00000000..554623a1 --- /dev/null +++ b/.changeset/version-flag.md @@ -0,0 +1,5 @@ +--- +"@taskless/cli": patch +--- + +`taskless --version` and `-v` print the version. Before, both fell through to the full usage banner, and `-v` was not recognised at all. diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 4369a572..cb030443 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -21,7 +21,12 @@ import { } from "./telemetry"; import { emitRunEvents, resolveCommandName, resolveCwd } from "./telemetry-run"; import { runWizard } from "./wizard"; -import { DIR_FLAGS, hasHelpFlag, splitRawArguments } from "./util/argv"; +import { + DIR_FLAGS, + hasHelpFlag, + hasVersionFlag, + splitRawArguments, +} from "./util/argv"; import { shouldLaunchWizard } from "./util/interactive"; import { showResolvedUsage } from "./util/help"; import { CLIError } from "./util/cli-error"; @@ -71,6 +76,14 @@ const main = defineCommand({ "Proceed when .taskless/ is newer than this CLI understands (skips migrations)", default: false, }, + // Listed so `--help` documents it; the flag itself is intercepted before + // dispatch (see the entry below) and never reaches a run handler. + version: { + type: "boolean", + alias: "v", + description: "Print the version and exit", + default: false, + }, }, subCommands: { ...subCommands, @@ -87,10 +100,10 @@ const main = defineCommand({ } // Only delegate to `init` when the only flags present are ones init - // also understands (`-d` / `--dir`). Version/json flags, a bare `--`, and - // any unknown flags should fall through to citty's default help instead of - // silently launching the wizard. (`--help`/`-h` never reach here — they - // are intercepted before dispatch below.) + // also understands (`-d` / `--dir`). `--json`, a bare `--`, and any + // unknown flags should fall through to citty's default help instead of + // silently launching the wizard. (`--help`/`-h` and `--version`/`-v` + // never reach here — they are intercepted before dispatch below.) const onlyInitFlags = flags.every((flag) => DIR_FLAGS.has(flag)); if (!onlyInitFlags) { await showUsage(cmd); @@ -142,13 +155,19 @@ const startedAt = Date.now(); const startIdentity = await resolveRunIdentity(runCwd); let thrown: unknown; try { - // Help is intercepted here, before dispatch, because citty implements - // `--help` only in runMain — and runMain exits the process on both its help - // and error paths, which would skip the `finally` below and drop the cli_run - // denominator. Rendering here returns normally instead. - await (hasHelpFlag(rawArguments) - ? showResolvedUsage(main, rawArguments) - : runCommand(main, { rawArgs: rawArguments })); + // Help and version are intercepted here, before dispatch, because citty + // implements `--help` and `--version` only in runMain — and runMain exits + // the process on both its help and error paths, which would skip the + // `finally` below and drop the cli_run denominator. Rendering here returns + // normally instead. Version goes to stdout as `\n` and nothing + // else, so `taskless --version | …` sees a version and not a banner. + if (hasHelpFlag(rawArguments)) { + await showResolvedUsage(main, rawArguments); + } else if (hasVersionFlag(rawArguments)) { + process.stdout.write(`${__VERSION__}\n`); + } else { + await runCommand(main, { rawArgs: rawArguments }); + } } catch (error) { // CLIError = expected failure. Most throw sites (the `fail()` helpers) print // and set exitCode first and mark themselves `reported`; one that does not diff --git a/packages/cli/src/util/argv.ts b/packages/cli/src/util/argv.ts index 879933a3..ec11d663 100644 --- a/packages/cli/src/util/argv.ts +++ b/packages/cli/src/util/argv.ts @@ -77,3 +77,15 @@ export function hasHelpFlag(rawArguments: string[]): boolean { const { flags } = splitRawArguments(rawArguments); return flags.includes("--help") || flags.includes("-h"); } + +/** + * True when argv asks for the version. Same scan as `hasHelpFlag`, for the + * same reason: `taskless -d -v check` names a directory, and `taskless check + * -- -v` names a path. Position is not consulted, so `taskless check + * --version` answers with the version too — the question is about the tool, + * not the subcommand, and no subcommand defines `-v` or `--version` itself. + */ +export function hasVersionFlag(rawArguments: string[]): boolean { + const { flags } = splitRawArguments(rawArguments); + return flags.includes("--version") || flags.includes("-v"); +} diff --git a/packages/cli/test/version-flag.test.ts b/packages/cli/test/version-flag.test.ts new file mode 100644 index 00000000..0b72e61f --- /dev/null +++ b/packages/cli/test/version-flag.test.ts @@ -0,0 +1,142 @@ +import { execFile } from "node:child_process"; +import { mkdtemp, readdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { promisify } from "node:util"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { hasVersionFlag } from "../src/util/argv"; + +const execFileAsync = promisify(execFile); +const binPath = resolve(import.meta.dirname, "../dist/index.js"); + +describe("--version", () => { + let temporaryDirectory: string; + + beforeEach(async () => { + temporaryDirectory = await mkdtemp(join(tmpdir(), "taskless-version-")); + }); + + afterEach(async () => { + await rm(temporaryDirectory, { recursive: true, force: true }); + }); + + // The defect was that both flags fell through to the bare-invocation usage + // banner (taskless/cli#352). The exact match is the point: a script doing + // `taskless --version | …` must see the version and nothing else. + it.each(["--version", "-v"])( + "%s prints exactly the version and a newline", + async (flag) => { + const { stdout, stderr } = await execFileAsync("node", [binPath, flag]); + + expect(stdout).toBe(`${__VERSION__}\n`); + expect(stderr).toBe(""); + } + ); + + // Pinned: the flag is about the tool, not the subcommand, so it wins from + // any position — the same rule `--help` follows. Before, `check --version` + // dropped the unknown flag and ran a full check. + it("prints the version in subcommand position and runs nothing", async () => { + const { stdout } = await execFileAsync("node", [ + binPath, + "check", + "--version", + "-d", + temporaryDirectory, + ]); + + expect(stdout).toBe(`${__VERSION__}\n`); + expect(await readdir(temporaryDirectory)).toEqual([]); + }); + + it("does not read a path after -- as a version request", async () => { + const { stdout } = await execFileAsync("node", [ + binPath, + "check", + "-d", + temporaryDirectory, + "--json", + "--", + "-v", + ]); + + expect(stdout).not.toBe(`${__VERSION__}\n`); + const parsed = JSON.parse(stdout.trim()) as { success: boolean }; + expect(parsed.success).toBe(true); + }); + + it("is listed in the root --help", async () => { + const { stdout } = await execFileAsync("node", [binPath, "--help"]); + + expect(stdout).toContain("-v, --version"); + expect(stdout).toContain("Print the version and exit"); + }); +}); + +describe("hasVersionFlag", () => { + it.each([ + [["--version"], true], + [["-v"], true], + [["check", "--version"], true], + [["-d", "/tmp", "check", "-v"], true], + [["check"], false], + // A path after `--`, and a directory that reads like a flag, are values. + [["check", "--", "-v"], false], + [["-d", "-v", "check"], false], + ])("resolves %j to %s", (argv, expected) => { + expect(hasVersionFlag(argv)).toBe(expected); + }); +}); + +// Same guard as help-flag.test.ts: the version path must return normally so +// the entry's `finally` still emits cli_run. Delegating to citty's runMain, +// which prints the version and calls process.exit(0), would drop it. +describe("version invocations still emit cli_run", () => { + const capture = vi.fn(); + + beforeEach(() => { + capture.mockClear(); + vi.resetModules(); + vi.doMock("../src/telemetry", () => ({ + getTelemetry: () => + Promise.resolve({ capture, shutdown: () => Promise.resolve() }), + resolveRunIdentity: () => + Promise.resolve({ anonymous: true, loggedIn: false }), + shutdownTelemetry: () => Promise.resolve(), + })); + }); + + afterEach(() => { + vi.doUnmock("../src/telemetry"); + vi.resetModules(); + }); + + it("captures cli_run for `--version`", async () => { + const argv = process.argv; + // Collected here rather than read off the spy: `mockRestore` clears the + // spy's recorded calls, and the restore must happen before any assertion. + const written: string[] = []; + const write = vi + .spyOn(process.stdout, "write") + .mockImplementation((chunk) => { + written.push(String(chunk)); + return true; + }); + process.argv = ["node", "taskless", "--version"]; + + try { + // Importing the entry runs the CLI: it is a top-level script. + await import("../src/index"); + } finally { + process.argv = argv; + write.mockRestore(); + } + + expect(written).toEqual([`${__VERSION__}\n`]); + expect(capture).toHaveBeenCalledWith( + "cli_run", + expect.objectContaining({ success: true }) + ); + }); +}); From a7c786f41b3af03109168631c3d79d14a279835d Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Sun, 20 Sep 2026 15:00:13 -0700 Subject: [PATCH 2/2] refactor(cli): share the flag scan between hasHelpFlag and hasVersionFlag Both did the same splitRawArguments walk over different literals; a private hasFlag carries the scan once and each keeps its own name. --- packages/cli/src/util/argv.ts | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/packages/cli/src/util/argv.ts b/packages/cli/src/util/argv.ts index ec11d663..9f429dc9 100644 --- a/packages/cli/src/util/argv.ts +++ b/packages/cli/src/util/argv.ts @@ -69,23 +69,28 @@ export function splitRawArguments( } /** - * True when argv asks for help. Scanned through `splitRawArguments` so neither - * a flag value nor a path after `--` that happens to read like `-h` is mistaken - * for a help request. + * True when any of `names` appears as a flag token. Scanned through + * `splitRawArguments` so neither a flag value nor a path after `--` that + * happens to read like `-h` or `-v` is mistaken for a request. Matched as + * whole tokens, the same way citty's own `runMain` matches `--help` and + * `--version`. */ -export function hasHelpFlag(rawArguments: string[]): boolean { +function hasFlag(rawArguments: string[], names: readonly string[]): boolean { const { flags } = splitRawArguments(rawArguments); - return flags.includes("--help") || flags.includes("-h"); + return names.some((name) => flags.includes(name)); +} + +/** True when argv asks for help. */ +export function hasHelpFlag(rawArguments: string[]): boolean { + return hasFlag(rawArguments, ["--help", "-h"]); } /** - * True when argv asks for the version. Same scan as `hasHelpFlag`, for the - * same reason: `taskless -d -v check` names a directory, and `taskless check - * -- -v` names a path. Position is not consulted, so `taskless check - * --version` answers with the version too — the question is about the tool, - * not the subcommand, and no subcommand defines `-v` or `--version` itself. + * True when argv asks for the version. Position is not consulted, so + * `taskless check --version` answers with the version too — the question is + * about the tool, not the subcommand, and no subcommand defines `-v` or + * `--version` itself. */ export function hasVersionFlag(rawArguments: string[]): boolean { - const { flags } = splitRawArguments(rawArguments); - return flags.includes("--version") || flags.includes("-v"); + return hasFlag(rawArguments, ["--version", "-v"]); }