Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
1e4c789
feat(cli): add commander foundation (BeeCommand, RequiredOption, comm…
lollipop-onl Mar 8, 2026
c231e63
refactor(cli): migrate entry point and group index files to BeeCommand
lollipop-onl Mar 8, 2026
652c542
refactor(cli): migrate auth commands to commander
lollipop-onl Mar 8, 2026
23b5a0c
refactor(cli): migrate issue commands to commander
lollipop-onl Mar 8, 2026
f35dc8b
refactor(cli): migrate pr commands to commander
lollipop-onl Mar 8, 2026
2b9f68c
refactor(cli): migrate project commands to commander
lollipop-onl Mar 8, 2026
c89ebb2
refactor(cli): migrate remaining command groups to commander
lollipop-onl Mar 8, 2026
f39d073
refactor(cli): migrate standalone commands to commander
lollipop-onl Mar 8, 2026
02c866b
refactor(cli): migrate remaining command groups and remove citty depe…
lollipop-onl Mar 8, 2026
89df787
fix(cli): clean up unused import and nested ternary
lollipop-onl Mar 8, 2026
53266ec
chore: allow default exports in CLI commands directory
lollipop-onl Mar 8, 2026
7abbf0b
style: fix formatting in team commands and lib files
lollipop-onl Mar 8, 2026
aecbaf6
fix(cli): use positional args for project parameter in 9 commands
lollipop-onl Mar 8, 2026
2e71d7f
fix(cli): resolve @me in list/count commands, unify activity-type, in…
lollipop-onl Mar 8, 2026
8f12267
refactor(cli): replace comma-separated options with commander repeata…
lollipop-onl Mar 8, 2026
70f9338
chore(cli): delete inlined completion scripts and remove unnecessary …
lollipop-onl Mar 8, 2026
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
4 changes: 3 additions & 1 deletion .oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -195,9 +195,11 @@
{
// oxlint's type checker cannot reliably resolve types across workspace
// packages, causing false positives in command files.
// Commander's addCommands() requires default exports from command modules.
"files": ["apps/cli/src/commands/**"],
"rules": {
"typescript/no-redundant-type-constituents": "off"
"typescript/no-redundant-type-constituents": "off",
"import/no-default-export": "off"
}
}
],
Expand Down
2 changes: 1 addition & 1 deletion apps/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
"@repo/cli-utils": "workspace:*",
"@repo/config": "workspace:*",
"backlog-js": "^0.16.0",
"citty": "^0.2.1",
"commander": "^14.0.3",
"consola": "^3.4.2",
"is-unicode-supported": "^2.1.0",
"open": "^11.0.0",
Expand Down
32 changes: 16 additions & 16 deletions apps/cli/src/commands/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ describe("api", () => {
mockClient.get.mockResolvedValue({ id: 1, name: "test" });

await expectStdoutContaining(async () => {
const { api } = await import("./api");
await api.run?.({ args: { endpoint: "/users/myself" } } as never);
const { default: api } = await import("./api");
await api.parseAsync(["/users/myself"], { from: "user" });

expect(mockClient.get).toHaveBeenCalledWith("/users/myself", {});
}, '"name"');
Expand All @@ -32,8 +32,8 @@ describe("api", () => {

const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);

const { api } = await import("./api");
await api.run?.({ args: { endpoint: "/projects" } } as never);
const { default: api } = await import("./api");
await api.parseAsync(["/projects"], { from: "user" });

expect(mockClient.get).toHaveBeenCalledWith("/projects", {});
writeSpy.mockRestore();
Expand All @@ -44,8 +44,8 @@ describe("api", () => {

const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);

const { api } = await import("./api");
await api.run?.({ args: { endpoint: "/api/v2/space" } } as never);
const { default: api } = await import("./api");
await api.parseAsync(["/api/v2/space"], { from: "user" });

expect(mockClient.get).toHaveBeenCalledWith("space", {});
writeSpy.mockRestore();
Expand All @@ -56,8 +56,8 @@ describe("api", () => {

const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);

const { api } = await import("./api");
await api.run?.({ args: { endpoint: "/issues", method: "POST" } } as never);
const { default: api } = await import("./api");
await api.parseAsync(["/issues", "-X", "POST"], { from: "user" });

expect(mockClient.post).toHaveBeenCalledWith("/issues", {});
writeSpy.mockRestore();
Expand All @@ -68,8 +68,8 @@ describe("api", () => {

const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);

const { api } = await import("./api");
await api.run?.({ args: { endpoint: "/users/myself", silent: true } } as never);
const { default: api } = await import("./api");
await api.parseAsync(["/users/myself", "--silent"], { from: "user" });

expect(mockClient.get).toHaveBeenCalled();
expect(writeSpy).not.toHaveBeenCalled();
Expand All @@ -81,8 +81,8 @@ describe("api", () => {

const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);

const { api } = await import("./api");
await api.run?.({ args: { endpoint: "/users/myself", json: "id,name" } } as never);
const { default: api } = await import("./api");
await api.parseAsync(["/users/myself", "--json", "id,name"], { from: "user" });

const output = JSON.parse(writeSpy.mock.calls[0][0] as string);
expect(output).toEqual({ id: 1, name: "Test User" });
Expand All @@ -98,8 +98,8 @@ describe("api", () => {

const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);

const { api } = await import("./api");
await api.run?.({ args: { endpoint: "/projects", json: "id,name" } } as never);
const { default: api } = await import("./api");
await api.parseAsync(["/projects", "--json", "id,name"], { from: "user" });

const output = JSON.parse(writeSpy.mock.calls[0][0] as string);
expect(output).toEqual([
Expand All @@ -114,8 +114,8 @@ describe("api", () => {

const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);

const { api } = await import("./api");
await api.run?.({ args: { endpoint: "/users/myself", json: "" } } as never);
const { default: api } = await import("./api");
await api.parseAsync(["/users/myself", "--json"], { from: "user" });

const output = JSON.parse(writeSpy.mock.calls[0][0] as string);
expect(output).toEqual({ id: 1, name: "test" });
Expand Down
147 changes: 45 additions & 102 deletions apps/cli/src/commands/api.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import { type BacklogClient, getClient } from "@repo/backlog-utils";
import { UserError, outputArgs, outputResult } from "@repo/cli-utils";
import { defineCommand } from "citty";
import { type CommandUsage, ENV_AUTH, withUsage } from "../lib/command-usage";
import { UserError, outputResult } from "@repo/cli-utils";
import { BeeCommand, ENV_AUTH } from "../lib/bee-command";
import { collect } from "../lib/common-options";

const commandUsage: CommandUsage = {
long: `Make an authenticated Backlog API request.
type ParamValue = string | number | boolean;
type Params = Record<string, ParamValue | ParamValue[]>;

const api = new BeeCommand("api")
.summary("Make an authenticated API request")
.description(
`Make an authenticated Backlog API request.

The endpoint argument should be a path of the Backlog API
(e.g. \`users/myself\`). If the path includes the \`/api/v2/\` prefix
Expand All @@ -22,8 +27,25 @@ To send a single-element array, append \`[]\` to the key name

For GET requests, fields are sent as query parameters. For POST, PUT,
PATCH, and DELETE requests, fields are sent as the request body.`,

examples: [
)
.argument("<endpoint>", "API endpoint path")
.option("-X, --method <method>", "HTTP method", "GET")
.option(
"-f, --field <key=value>",
"Add a parameter with type inference (key=value, repeatable)",
collect,
[],
)
.option(
"-F, --raw-field <key=value>",
"Add a string parameter (key=value, repeatable)",
collect,
[],
)
.option("--json [fields]", "Output as JSON (optionally filter by field names, comma-separated)")
.option("--silent", "Do not print the response body")
.envVars([...ENV_AUTH])
.examples([
{ description: "Get your user profile", command: "bee api users/myself" },
{
description: "List issues in a project",
Expand All @@ -42,76 +64,26 @@ PATCH, and DELETE requests, fields are sent as the request body.`,
description: "Select specific fields",
command: "bee api users/myself --json id,name,mailAddress",
},
],

annotations: {
environment: [...ENV_AUTH],
},
};
])
.action(async (endpoint: string, opts) => {
const { client } = await getClient();

type ParamValue = string | number | boolean;
type Params = Record<string, ParamValue | ParamValue[]>;

const api = withUsage(
defineCommand({
meta: {
name: "api",
description: "Make an authenticated API request",
},
args: {
endpoint: {
type: "positional",
description: "API endpoint path",
required: true,
valueHint: "<endpoint>",
},
method: {
type: "string",
alias: "X",
description: "HTTP method",
valueHint: "{GET|POST|PUT|PATCH|DELETE}",
},
field: {
type: "string",
alias: "f",
description: "Add a parameter with type inference (key=value, repeatable)",
},
"raw-field": {
type: "string",
alias: "F",
description: "Add a string parameter (key=value, repeatable)",
},
...outputArgs,
silent: {
type: "boolean",
description: "Do not print the response body",
},
},
async run({ args }) {
const { client } = await getClient();
const method = opts.method.toUpperCase();
const normalizedEndpoint = normalizeEndpoint(endpoint);

const method = (args.method ?? "GET").toUpperCase();
const endpoint = normalizeEndpoint(args.endpoint);
const params = buildParams(opts.field, opts.rawField);

const params = buildParams(
collectMultiValues("field", process.argv),
collectMultiValues("raw-field", process.argv),
);
const data = await makeRequest(client, method, normalizedEndpoint, params);

const data = await makeRequest(client, method, endpoint, params);

if (args.silent) {
return;
}
if (opts.silent) {
return;
}

// Default to JSON output (api always returns JSON).
// --json with field names filters the output via outputResult.
const jsonArgs = args.json === undefined ? { json: "" } : { json: args.json };
outputResult(data, jsonArgs, () => {});
},
}),
commandUsage,
);
// Default to JSON output (api always returns JSON).
// --json with field names filters the output via outputResult.
const jsonVal = typeof opts.json === "string" ? opts.json : "";
outputResult(data, { json: jsonVal }, () => {});
});

/**
* Normalize API endpoint path for backlog-js client methods.
Expand All @@ -124,35 +96,6 @@ const normalizeEndpoint = (endpoint: string): string => {
return stripped;
};

/**
* Collect multiple values for repeatable flags from process.argv.
* citty only provides the last value for string args, so we parse argv directly.
*/
const collectMultiValues = (flagName: string, argv: string[]): string[] => {
const values: string[] = [];
const longFlag = `--${flagName}`;
const shortFlags: Record<string, string> = {
field: "-f",
"raw-field": "-F",
};
const shortFlag = shortFlags[flagName];

for (let i = 0; i < argv.length; i++) {
const arg = argv[i];

if (arg.startsWith(`${longFlag}=`)) {
values.push(arg.slice(longFlag.length + 1));
} else if (arg === longFlag || arg === shortFlag) {
const next = argv[i + 1];
if (next !== undefined) {
values.push(next);
i++;
}
}
}
return values;
};

/**
* Build params object from --field and --raw-field values.
* --field infers types (number, boolean, string).
Expand Down Expand Up @@ -240,4 +183,4 @@ const makeRequest = async (
}
};

export { commandUsage, api };
export default api;
28 changes: 13 additions & 15 deletions apps/cli/src/commands/auth/index.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,14 @@
import { defineCommand } from "citty";
import { BeeCommand } from "../../lib/bee-command";

export const auth = defineCommand({
meta: {
name: "auth",
description: "Authenticate bee with Backlog",
},
subCommands: {
login: () => import("./login.js").then((m) => m.login),
logout: () => import("./logout.js").then((m) => m.logout),
status: () => import("./status.js").then((m) => m.status),
token: () => import("./token.js").then((m) => m.token),
refresh: () => import("./refresh.js").then((m) => m.refresh),
switch: () => import("./switch.js").then((m) => m.switchSpace),
},
});
const auth = new BeeCommand("auth").summary("Authenticate bee with Backlog");

await auth.addCommands([
import("./login.js"),
import("./logout.js"),
import("./status.js"),
import("./token.js"),
import("./refresh.js"),
import("./switch.js"),
]);

export default auth;
Loading