Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/version-flag.md
Original file line number Diff line number Diff line change
@@ -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.
43 changes: 31 additions & 12 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand All @@ -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);
Expand Down Expand Up @@ -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 `<version>\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`);
Comment thread
theCodeDrift marked this conversation as resolved.
} 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
Expand Down
27 changes: 22 additions & 5 deletions packages/cli/src/util/argv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,11 +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. 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 {
return hasFlag(rawArguments, ["--version", "-v"]);
}
Comment thread
theCodeDrift marked this conversation as resolved.
142 changes: 142 additions & 0 deletions packages/cli/test/version-flag.test.ts
Original file line number Diff line number Diff line change
@@ -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 })
);
});
});
Loading