Skip to content

Commit d75cd64

Browse files
committed
fix(cli): accept -d <path> before a subcommand
Fixes #424
1 parent 1aff9a8 commit d75cd64

6 files changed

Lines changed: 144 additions & 4 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@taskless/cli": patch
3+
---
4+
5+
`-d <path>` / `--dir <path>` now works before a subcommand. `taskless auth -d .` and `taskless -d . info` used to fail with "Unknown command `.`", because the path was read as the subcommand's name. Only the `--dir=<path>` spelling worked.

‎packages/cli/src/commands/auth.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { getToken, removeToken } from "../auth/token";
77
import { fetchWhoami } from "../auth/whoami";
88
import { getTelemetry } from "../telemetry";
99
import { type CLIErrorCode, writeJsonError } from "../types/errors";
10+
import { splitRawArguments } from "../util/argv";
1011

1112
const loginCommand = defineCommand({
1213
meta: {
@@ -162,8 +163,9 @@ export const authCommand = defineCommand({
162163
},
163164
async run({ args, rawArgs }) {
164165
// citty always calls the parent's run handler, even after a subcommand.
165-
// Only show status when no subcommand was provided.
166-
if (rawArgs.some((argument) => !argument.startsWith("-"))) {
166+
// Only show status when no subcommand was provided. The shared scanner
167+
// skips flag values, so the path in `auth -d <path>` is not read as one.
168+
if (splitRawArguments(rawArgs).positionals.length > 0) {
167169
return;
168170
}
169171

‎packages/cli/src/index.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
DIR_FLAGS,
2626
hasHelpFlag,
2727
hasVersionFlag,
28+
joinDirectoryValues,
2829
splitRawArguments,
2930
} from "./util/argv";
3031
import { shouldLaunchWizard } from "./util/interactive";
@@ -146,7 +147,9 @@ const main = defineCommand({
146147
});
147148

148149
// main loop to run cli and make every attempt to shut down gracefully
149-
const rawArguments = process.argv.slice(2);
150+
// `-d <path>` is joined into `--dir=<path>` so citty's subcommand resolution
151+
// cannot mistake the path for a command name (see joinDirectoryValues).
152+
const rawArguments = joinDirectoryValues(process.argv.slice(2));
150153
const runCwd = resolveCwd(rawArguments);
151154
const startedAt = Date.now();
152155
// Resolve identity at invocation START so cli_run reports who *initiated* the

‎packages/cli/src/util/argv.ts‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
*
77
* - `-d`/`--dir` take a value, so the token after one of them is a flag value
88
* and NOT a positional (`taskless -d /tmp check` runs `check`, not `/tmp`).
9+
* citty does not know this on its own; see `joinDirectoryValues`.
910
* - `--` is the POSIX end-of-options marker: every token after it is a
1011
* positional even if it starts with `-`, which is what lets `taskless check
1112
* -- -h` scan a path literally named `-h` instead of asking for help.
@@ -97,6 +98,45 @@ export function splitRawArguments(
9798
return { positionals, flags, values };
9899
}
99100

101+
/**
102+
* Rewrite `-d <path>` and `--dir <path>` into the one-token `--dir=<path>`.
103+
*
104+
* citty picks a subcommand from the first raw token that does not start with
105+
* `-`, at every level that has subcommands, and it does that before parsing
106+
* any flags. In `taskless auth -d .` that token is `.`, the flag's value, so
107+
* citty reports "Unknown command `.`" before `auth` ever runs. The `=`
108+
* spelling keeps the value inside the flag token, where citty's resolution
109+
* skips it and its parser still reads it as `dir`.
110+
*
111+
* Applied once to argv before dispatch rather than per command, because every
112+
* command with subcommands (the root included) has the same exposure. A value
113+
* that itself starts with `-` is left alone: citty already skips it, and
114+
* joining it would change what citty parses. Tokens after `--` are
115+
* positionals and are never touched.
116+
*/
117+
export function joinDirectoryValues(rawArguments: string[]): string[] {
118+
const joined: string[] = [];
119+
for (let index = 0; index < rawArguments.length; index++) {
120+
const argument = rawArguments[index]!;
121+
if (argument === END_OF_OPTIONS) {
122+
joined.push(...rawArguments.slice(index));
123+
break;
124+
}
125+
const value = rawArguments[index + 1];
126+
if (
127+
DIR_FLAGS.has(argument) &&
128+
value !== undefined &&
129+
!value.startsWith("-")
130+
) {
131+
joined.push(`--dir=${value}`);
132+
index++;
133+
continue;
134+
}
135+
joined.push(argument);
136+
}
137+
return joined;
138+
}
139+
100140
/**
101141
* True when any of `names` appears as a flag token. Scanned through
102142
* `splitRawArguments` so neither a flag value nor a path after `--` that

‎packages/cli/test/cli.test.ts‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,43 @@ describe("cli", () => {
5757
});
5858
});
5959

60+
// citty resolves a subcommand from the first token not starting with `-`,
61+
// so before argv was joined, the path after `-d` was read as a command name
62+
// and every parent with subcommands failed with "Unknown command".
63+
describe("-d <path> before a subcommand", () => {
64+
let temporaryDirectory: string;
65+
66+
beforeEach(async () => {
67+
temporaryDirectory = await mkdtemp(join(tmpdir(), "taskless-test-"));
68+
});
69+
70+
afterEach(async () => {
71+
await rm(temporaryDirectory, { recursive: true, force: true });
72+
});
73+
74+
it("runs auth status against the given directory", async () => {
75+
const environment = { ...process.env };
76+
delete environment.TASKLESS_TOKEN;
77+
const { stdout } = await execFileAsync(
78+
"node",
79+
[binPath, "auth", "-d", temporaryDirectory],
80+
{ env: environment }
81+
);
82+
expect(stdout).toContain("Not logged in.");
83+
});
84+
85+
it("dispatches the root's subcommand after -d", async () => {
86+
const { stdout } = await execFileAsync("node", [
87+
binPath,
88+
"-d",
89+
temporaryDirectory,
90+
"info",
91+
"--json",
92+
]);
93+
expect(JSON.parse(stdout.trim())).toHaveProperty("version");
94+
});
95+
});
96+
6097
describe("init", () => {
6198
let temporaryDirectory: string;
6299

‎packages/cli/test/help-flag.test.ts‎

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@ import { join, resolve } from "node:path";
55
import { promisify } from "node:util";
66
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
77

8-
import { hasHelpFlag, splitRawArguments } from "../src/util/argv";
8+
import {
9+
hasHelpFlag,
10+
joinDirectoryValues,
11+
splitRawArguments,
12+
} from "../src/util/argv";
913

1014
const execFileAsync = promisify(execFile);
1115
const binPath = resolve(import.meta.dirname, "../dist/index.js");
@@ -152,6 +156,55 @@ describe("splitRawArguments", () => {
152156
});
153157
});
154158

159+
describe("joinDirectoryValues", () => {
160+
it.each([
161+
[
162+
["auth", "-d", "."],
163+
["auth", "--dir=."],
164+
],
165+
[
166+
["-d", "/tmp", "info"],
167+
["--dir=/tmp", "info"],
168+
],
169+
[
170+
["auth", "--dir", "/tmp", "login"],
171+
["auth", "--dir=/tmp", "login"],
172+
],
173+
[
174+
["auth", "--dir=."],
175+
["auth", "--dir=."],
176+
],
177+
])("joins %j into %j", (argv, expected) => {
178+
expect(joinDirectoryValues(argv)).toEqual(expected);
179+
});
180+
181+
it("leaves a value that starts with - alone", () => {
182+
expect(joinDirectoryValues(["auth", "-d", "--json"])).toEqual([
183+
"auth",
184+
"-d",
185+
"--json",
186+
]);
187+
});
188+
189+
it("does not touch anything after --", () => {
190+
expect(joinDirectoryValues(["check", "--", "-d", "src"])).toEqual([
191+
"check",
192+
"--",
193+
"-d",
194+
"src",
195+
]);
196+
});
197+
198+
it("does not join -- as the value of -d", () => {
199+
expect(joinDirectoryValues(["check", "-d", "--", "src"])).toEqual([
200+
"check",
201+
"-d",
202+
"--",
203+
"src",
204+
]);
205+
});
206+
});
207+
155208
describe("hasHelpFlag", () => {
156209
it.each([
157210
[["check", "--help"], true],

0 commit comments

Comments
 (0)