From 1e4c78903c623cd56ef85ef5b2994aca59dd72ef Mon Sep 17 00:00:00 2001 From: Ryoya Tamura Date: Sun, 8 Mar 2026 17:47:19 +0900 Subject: [PATCH 01/16] feat(cli): add commander foundation (BeeCommand, RequiredOption, common-options) Co-Authored-By: Claude Opus 4.6 --- apps/cli/package.json | 1 + apps/cli/src/lib/bee-command.test.ts | 61 +++++++++++++++++++++ apps/cli/src/lib/bee-command.ts | 65 ++++++++++++++++++++++ apps/cli/src/lib/common-options.ts | 59 ++++++++++++++++++++ apps/cli/src/lib/error.ts | 24 +++++++++ apps/cli/src/lib/required-option.test.ts | 69 ++++++++++++++++++++++++ apps/cli/src/lib/required-option.ts | 24 +++++++++ pnpm-lock.yaml | 9 ++++ 8 files changed, 312 insertions(+) create mode 100644 apps/cli/src/lib/bee-command.test.ts create mode 100644 apps/cli/src/lib/bee-command.ts create mode 100644 apps/cli/src/lib/common-options.ts create mode 100644 apps/cli/src/lib/error.ts create mode 100644 apps/cli/src/lib/required-option.test.ts create mode 100644 apps/cli/src/lib/required-option.ts diff --git a/apps/cli/package.json b/apps/cli/package.json index 3402d678..1d6976a9 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -42,6 +42,7 @@ "@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", diff --git a/apps/cli/src/lib/bee-command.test.ts b/apps/cli/src/lib/bee-command.test.ts new file mode 100644 index 00000000..3816c3ab --- /dev/null +++ b/apps/cli/src/lib/bee-command.test.ts @@ -0,0 +1,61 @@ +import { Command, Option } from "commander"; +import { describe, expect, it } from "vitest"; +import { BeeCommand } from "./bee-command"; + +describe("BeeCommand", () => { + describe("examples", () => { + it("renders EXAMPLES section in help", () => { + const cmd = new BeeCommand("test").examples([ + { description: "Run it", command: "bee test --foo" }, + ]); + const help = cmd.helpInformation(); + expect(help).toContain("EXAMPLES"); + expect(help).toContain("# Run it"); + expect(help).toContain("$ bee test --foo"); + }); + }); + + describe("envVars", () => { + it("renders manual env vars in help", () => { + const cmd = new BeeCommand("test").envVars([["MY_VAR", "Some description"]]); + const help = cmd.helpInformation(); + expect(help).toContain("ENVIRONMENT VARIABLES"); + expect(help).toContain("MY_VAR"); + expect(help).toContain("Some description"); + }); + + it("auto-collects env vars from options with .env()", () => { + const cmd = new BeeCommand("test").addOption( + new Option("-p, --project ", "Project ID").env("BACKLOG_PROJECT"), + ); + const help = cmd.helpInformation(); + expect(help).toContain("BACKLOG_PROJECT"); + expect(help).toContain("Project ID"); + }); + + it("merges auto-collected and manual env vars", () => { + const cmd = new BeeCommand("test") + .addOption(new Option("-p, --project ", "Project ID").env("BACKLOG_PROJECT")) + .envVars([["BACKLOG_API_KEY", "API key"]]); + const help = cmd.helpInformation(); + expect(help).toContain("BACKLOG_PROJECT"); + expect(help).toContain("BACKLOG_API_KEY"); + }); + }); + + describe("addCommands", () => { + it("adds commands from dynamic imports", async () => { + const child = new Command("child").summary("A child"); + const parent = new BeeCommand("parent"); + await parent.addCommands([Promise.resolve({ default: child })]); + expect(parent.commands.map((c) => c.name())).toContain("child"); + }); + }); + + describe("createCommand", () => { + it("returns BeeCommand instance", () => { + const cmd = new BeeCommand("test"); + expect(cmd.createCommand("sub")).toBeInstanceOf(BeeCommand); + }); + }); +}); diff --git a/apps/cli/src/lib/bee-command.ts b/apps/cli/src/lib/bee-command.ts new file mode 100644 index 00000000..086ff6fd --- /dev/null +++ b/apps/cli/src/lib/bee-command.ts @@ -0,0 +1,65 @@ +import { Command } from "commander"; +import { colorize } from "consola/utils"; + +type Example = { description: string; command: string }; + +class BeeCommand extends Command { + private _examples: Example[] = []; + private _extraEnvVars: [string, string][] = []; + + helpInformation(): string { + return super.helpInformation() + this._renderExamples() + this._renderEnvVars(); + } + + createCommand(name?: string): BeeCommand { + return new BeeCommand(name); + } + + examples(examples: Example[]): this { + this._examples = examples; + return this; + } + + envVars(vars: [string, string][]): this { + this._extraEnvVars.push(...vars); + return this; + } + + async addCommands(mods: Promise<{ default: Command }>[]): Promise { + const resolved = await Promise.all(mods); + for (const mod of resolved) { + this.addCommand(mod.default); + } + return this; + } + + private _renderExamples(): string { + if (this._examples.length === 0) {return "";} + const lines = this._examples.flatMap((ex) => [ + ` # ${ex.description}`, + ` $ ${ex.command}`, + "", + ]); + return `\n${colorize("bold", "EXAMPLES")}\n${lines.join("\n")}`; + } + + private _renderEnvVars(): string { + const fromOptions: [string, string][] = this.options + .filter((opt) => opt.envVar) + .map((opt) => [opt.envVar!, opt.description ?? ""]); + const vars = [...fromOptions, ...this._extraEnvVars]; + if (vars.length === 0) {return "";} + const maxLen = Math.max(...vars.map(([k]) => k.length)); + const lines = vars.map(([k, d]) => ` ${k.padEnd(maxLen + 3)}${d}`); + return `\n${colorize("bold", "ENVIRONMENT VARIABLES")}\n${lines.join("\n")}`; + } +} + +const ENV_AUTH: [string, string][] = [ + ["BACKLOG_API_KEY", "Authenticate with an API key"], + ["BACKLOG_SPACE", "Default space hostname"], +]; +const ENV_PROJECT: [string, string] = ["BACKLOG_PROJECT", "Default project ID or project key"]; +const ENV_REPO: [string, string] = ["BACKLOG_REPO", "Default repository name"]; + +export { BeeCommand, type Example, ENV_AUTH, ENV_PROJECT, ENV_REPO }; diff --git a/apps/cli/src/lib/common-options.ts b/apps/cli/src/lib/common-options.ts new file mode 100644 index 00000000..46bbf842 --- /dev/null +++ b/apps/cli/src/lib/common-options.ts @@ -0,0 +1,59 @@ +import { Option } from "commander"; +import { RequiredOption } from "./required-option"; + +const collect = (val: string, prev: string[]): string[] => [...prev, val]; +const collectNum = (val: string, prev: number[]): number[] => [...prev, Number(val)]; + +const project = () => + new RequiredOption("-p, --project ", "Project ID or project key").env("BACKLOG_PROJECT"); +const repo = () => + new RequiredOption("-R, --repo ", "Repository name or ID").env("BACKLOG_REPO"); +const count = () => new Option("-L, --count ", "Number of results (default: 20)"); +const offset = () => new Option("--offset ", "Offset for pagination"); +const order = () => new Option("--order ", "Sort order").choices(["asc", "desc"]); +const minId = () => new Option("--min-id ", "Minimum ID for cursor-based pagination"); +const maxId = () => new Option("--max-id ", "Maximum ID for cursor-based pagination"); +const keyword = () => new Option("-k, --keyword ", "Keyword search"); +const assignee = () => new Option("-a, --assignee ", "Assignee user ID. Use @me for yourself."); +const assigneeList = () => + new Option( + "-a, --assignee ", + "Assignee user ID (repeatable). Use @me for yourself.", + collect, + [], + ); +const issue = () => new Option("--issue ", "Issue ID or issue key"); +const notify = () => new Option("--notify ", "User IDs to notify (repeatable)", collectNum, []); +const attachment = () => + new Option("--attachment ", "Attachment IDs (repeatable)", collectNum, []); +const comment = () => new Option("-c, --comment ", "Comment to add with the update"); +const web = (resource: string) => new Option("-w, --web", `Open the ${resource} in the browser`); +const noBrowser = () => + new Option("-n, --no-browser", "Print the URL instead of opening the browser"); +const json = () => + new Option( + "--json [fields]", + "Output as JSON (optionally filter by field names, comma-separated)", + ); + +export { + collect, + collectNum, + project, + repo, + count, + offset, + order, + minId, + maxId, + keyword, + assignee, + assigneeList, + issue, + notify, + attachment, + comment, + web, + noBrowser, + json, +}; diff --git a/apps/cli/src/lib/error.ts b/apps/cli/src/lib/error.ts new file mode 100644 index 00000000..ad26ef75 --- /dev/null +++ b/apps/cli/src/lib/error.ts @@ -0,0 +1,24 @@ +import { CommanderError } from "commander"; +import { handleBacklogApiError } from "@repo/backlog-utils"; +import { UserError, handleValidationError } from "@repo/cli-utils"; +import consola, { LogLevels } from "consola"; + +const handleError = (error: unknown): never | void => { + if (error instanceof CommanderError) { + if (error.exitCode === 0) {return;} + process.exit(error.exitCode); + } + + const showStack = consola.level >= LogLevels.debug; + const useJson = process.argv.includes("--json") || !process.stdout.isTTY; + + if (error instanceof UserError) { + consola.error(showStack ? error : error.message); + } else if (!handleBacklogApiError(error, { json: useJson }) && !handleValidationError(error)) { + consola.error(error); + } + + process.exit(1); +}; + +export { handleError }; diff --git a/apps/cli/src/lib/required-option.test.ts b/apps/cli/src/lib/required-option.test.ts new file mode 100644 index 00000000..94890cea --- /dev/null +++ b/apps/cli/src/lib/required-option.test.ts @@ -0,0 +1,69 @@ +import { Command } from "commander"; +import { describe, expect, it, vi } from "vitest"; +import { RequiredOption, resolveOptions } from "./required-option"; + +vi.mock("@repo/cli-utils", async (importOriginal) => ({ + ...(await importOriginal()), + promptRequired: vi.fn(), +})); + +describe("RequiredOption", () => { + it("appends (required) to description", () => { + const opt = new RequiredOption("-t, --title ", "Issue title"); + expect(opt.description).toBe("Issue title (required)"); + }); + + it("uses custom promptLabel", () => { + const opt = new RequiredOption("-t, --title <title>", "Issue title", "Summary"); + expect(opt.promptLabel).toBe("Summary"); + }); + + it("defaults promptLabel to description", () => { + const opt = new RequiredOption("-t, --title <title>", "Issue title"); + expect(opt.promptLabel).toBe("Issue title"); + }); +}); + +describe("resolveOptions", () => { + it("calls promptRequired for missing RequiredOption", async () => { + const { promptRequired } = await import("@repo/cli-utils"); + vi.mocked(promptRequired).mockResolvedValueOnce("prompted-value"); + + const cmd = new Command("test") + .addOption(new RequiredOption("-t, --title <title>", "Issue title")) + .action(() => {}); + + // parse with no args — title is undefined + cmd.parse([], { from: "user" }); + const opts = await resolveOptions(cmd); + + expect(promptRequired).toHaveBeenCalledWith("Issue title:", undefined); + expect(opts.title).toBe("prompted-value"); + }); + + it("passes existing value to promptRequired", async () => { + const { promptRequired } = await import("@repo/cli-utils"); + vi.mocked(promptRequired).mockResolvedValueOnce("existing"); + + const cmd = new Command("test") + .addOption(new RequiredOption("-t, --title <title>", "Issue title")) + .action(() => {}); + + cmd.parse(["--title", "existing"], { from: "user" }); + const opts = await resolveOptions(cmd); + + expect(promptRequired).toHaveBeenCalledWith("Issue title:", "existing"); + expect(opts.title).toBe("existing"); + }); + + it("ignores non-RequiredOption options", async () => { + const { promptRequired } = await import("@repo/cli-utils"); + + const cmd = new Command("test").option("-d, --desc <text>", "Description").action(() => {}); + + cmd.parse([], { from: "user" }); + await resolveOptions(cmd); + + expect(promptRequired).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/cli/src/lib/required-option.ts b/apps/cli/src/lib/required-option.ts new file mode 100644 index 00000000..c86372f9 --- /dev/null +++ b/apps/cli/src/lib/required-option.ts @@ -0,0 +1,24 @@ +import { type Command, Option } from "commander"; +import { promptRequired } from "@repo/cli-utils"; + +class RequiredOption extends Option { + promptLabel: string; + + constructor(flags: string, description: string, promptLabel?: string) { + super(flags, `${description} (required)`); + this.promptLabel = promptLabel ?? description; + } +} + +const resolveOptions = async (cmd: Command): Promise<Record<string, unknown>> => { + const opts = cmd.opts(); + for (const opt of cmd.options) { + if (opt instanceof RequiredOption) { + const key = opt.attributeName(); + opts[key] = await promptRequired(`${opt.promptLabel}:`, opts[key]); + } + } + return opts; +}; + +export { RequiredOption, resolveOptions }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5b6f2618..06278384 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,6 +53,9 @@ importers: citty: specifier: ^0.2.1 version: 0.2.1 + commander: + specifier: ^14.0.3 + version: 14.0.3 consola: specifier: ^3.4.2 version: 3.4.2 @@ -1619,6 +1622,10 @@ packages: resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} engines: {node: '>=16'} + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + common-ancestor-path@1.0.1: resolution: {integrity: sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w==} @@ -4679,6 +4686,8 @@ snapshots: commander@11.1.0: {} + commander@14.0.3: {} + common-ancestor-path@1.0.1: {} commondir@1.0.1: {} From c231e63268c8c3552c72a24e7a470623bf79ddb4 Mon Sep 17 00:00:00 2001 From: Ryoya Tamura <ryoya.tamura@nulab.com> Date: Sun, 8 Mar 2026 17:50:12 +0900 Subject: [PATCH 02/16] refactor(cli): migrate entry point and group index files to BeeCommand Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --- apps/cli/src/commands/auth/index.ts | 28 +++---- apps/cli/src/commands/category/index.ts | 24 +++--- apps/cli/src/commands/document/index.ts | 28 +++---- apps/cli/src/commands/issue-type/index.ts | 24 +++--- apps/cli/src/commands/issue/index.ts | 38 +++++---- apps/cli/src/commands/milestone/index.ts | 24 +++--- apps/cli/src/commands/notification/index.ts | 24 +++--- apps/cli/src/commands/pr/index.ts | 32 ++++---- apps/cli/src/commands/project/index.ts | 34 ++++---- apps/cli/src/commands/repo/index.ts | 18 ++--- apps/cli/src/commands/space/index.ts | 24 +++--- apps/cli/src/commands/star/index.ts | 24 +++--- apps/cli/src/commands/status/index.ts | 24 +++--- apps/cli/src/commands/team/index.ts | 26 +++--- apps/cli/src/commands/user/index.ts | 24 +++--- apps/cli/src/commands/watching/index.ts | 26 +++--- apps/cli/src/commands/webhook/index.ts | 26 +++--- apps/cli/src/commands/wiki/index.ts | 34 ++++---- apps/cli/src/index.ts | 90 ++++++++------------- 19 files changed, 253 insertions(+), 319 deletions(-) diff --git a/apps/cli/src/commands/auth/index.ts b/apps/cli/src/commands/auth/index.ts index ec5b7b39..0e02afb6 100644 --- a/apps/cli/src/commands/auth/index.ts +++ b/apps/cli/src/commands/auth/index.ts @@ -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; diff --git a/apps/cli/src/commands/category/index.ts b/apps/cli/src/commands/category/index.ts index ef7b0ed5..18c5466a 100644 --- a/apps/cli/src/commands/category/index.ts +++ b/apps/cli/src/commands/category/index.ts @@ -1,14 +1,12 @@ -import { defineCommand } from "citty"; +import { BeeCommand } from "../../lib/bee-command"; -export const category = defineCommand({ - meta: { - name: "category", - description: "Manage project categories", - }, - subCommands: { - list: () => import("./list").then((m) => m.list), - create: () => import("./create").then((m) => m.create), - edit: () => import("./edit").then((m) => m.edit), - delete: () => import("./delete").then((m) => m.deleteCategory), - }, -}); +const category = new BeeCommand("category").summary("Manage project categories"); + +await category.addCommands([ + import("./list.js"), + import("./create.js"), + import("./edit.js"), + import("./delete.js"), +]); + +export default category; diff --git a/apps/cli/src/commands/document/index.ts b/apps/cli/src/commands/document/index.ts index 29603a95..63c2bde2 100644 --- a/apps/cli/src/commands/document/index.ts +++ b/apps/cli/src/commands/document/index.ts @@ -1,16 +1,14 @@ -import { defineCommand } from "citty"; +import { BeeCommand } from "../../lib/bee-command"; -export const document = defineCommand({ - meta: { - name: "document", - description: "Manage Backlog documents", - }, - subCommands: { - list: () => import("./list").then((m) => m.list), - view: () => import("./view").then((m) => m.view), - tree: () => import("./tree").then((m) => m.tree), - attachments: () => import("./attachments").then((m) => m.attachments), - create: () => import("./create").then((m) => m.create), - delete: () => import("./delete").then((m) => m.deleteDocument), - }, -}); +const document = new BeeCommand("document").summary("Manage Backlog documents"); + +await document.addCommands([ + import("./list.js"), + import("./view.js"), + import("./tree.js"), + import("./attachments.js"), + import("./create.js"), + import("./delete.js"), +]); + +export default document; diff --git a/apps/cli/src/commands/issue-type/index.ts b/apps/cli/src/commands/issue-type/index.ts index 2502e38b..18c43f18 100644 --- a/apps/cli/src/commands/issue-type/index.ts +++ b/apps/cli/src/commands/issue-type/index.ts @@ -1,14 +1,12 @@ -import { defineCommand } from "citty"; +import { BeeCommand } from "../../lib/bee-command"; -export const issueType = defineCommand({ - meta: { - name: "issue-type", - description: "Manage project issue types", - }, - subCommands: { - list: () => import("./list").then((m) => m.list), - create: () => import("./create").then((m) => m.create), - edit: () => import("./edit").then((m) => m.edit), - delete: () => import("./delete").then((m) => m.deleteIssueType), - }, -}); +const issueType = new BeeCommand("issue-type").summary("Manage project issue types"); + +await issueType.addCommands([ + import("./list.js"), + import("./create.js"), + import("./edit.js"), + import("./delete.js"), +]); + +export default issueType; diff --git a/apps/cli/src/commands/issue/index.ts b/apps/cli/src/commands/issue/index.ts index e0081517..8fec1620 100644 --- a/apps/cli/src/commands/issue/index.ts +++ b/apps/cli/src/commands/issue/index.ts @@ -1,21 +1,19 @@ -import { defineCommand } from "citty"; +import { BeeCommand } from "../../lib/bee-command"; -export const issue = defineCommand({ - meta: { - name: "issue", - description: "Manage Backlog issues", - }, - subCommands: { - list: () => import("./list").then((m) => m.list), - view: () => import("./view").then((m) => m.view), - status: () => import("./status").then((m) => m.status), - create: () => import("./create").then((m) => m.create), - edit: () => import("./edit").then((m) => m.edit), - close: () => import("./close").then((m) => m.close), - reopen: () => import("./reopen").then((m) => m.reopen), - attachments: () => import("./attachments").then((m) => m.attachments), - comment: () => import("./comment").then((m) => m.comment), - count: () => import("./count").then((m) => m.count), - delete: () => import("./delete").then((m) => m.deleteIssue), - }, -}); +const issue = new BeeCommand("issue").summary("Manage Backlog issues"); + +await issue.addCommands([ + import("./list.js"), + import("./view.js"), + import("./status.js"), + import("./create.js"), + import("./edit.js"), + import("./close.js"), + import("./reopen.js"), + import("./attachments.js"), + import("./comment.js"), + import("./count.js"), + import("./delete.js"), +]); + +export default issue; diff --git a/apps/cli/src/commands/milestone/index.ts b/apps/cli/src/commands/milestone/index.ts index aaed3c4b..822714f9 100644 --- a/apps/cli/src/commands/milestone/index.ts +++ b/apps/cli/src/commands/milestone/index.ts @@ -1,14 +1,12 @@ -import { defineCommand } from "citty"; +import { BeeCommand } from "../../lib/bee-command"; -export const milestone = defineCommand({ - meta: { - name: "milestone", - description: "Manage project milestones", - }, - subCommands: { - list: () => import("./list").then((m) => m.list), - create: () => import("./create").then((m) => m.create), - edit: () => import("./edit").then((m) => m.edit), - delete: () => import("./delete").then((m) => m.deleteMilestone), - }, -}); +const milestone = new BeeCommand("milestone").summary("Manage project milestones"); + +await milestone.addCommands([ + import("./list.js"), + import("./create.js"), + import("./edit.js"), + import("./delete.js"), +]); + +export default milestone; diff --git a/apps/cli/src/commands/notification/index.ts b/apps/cli/src/commands/notification/index.ts index 3920c10e..7d096429 100644 --- a/apps/cli/src/commands/notification/index.ts +++ b/apps/cli/src/commands/notification/index.ts @@ -1,14 +1,12 @@ -import { defineCommand } from "citty"; +import { BeeCommand } from "../../lib/bee-command"; -export const notification = defineCommand({ - meta: { - name: "notification", - description: "Manage Backlog notifications", - }, - subCommands: { - list: () => import("./list").then((m) => m.list), - count: () => import("./count").then((m) => m.count), - read: () => import("./read").then((m) => m.read), - "read-all": () => import("./read-all").then((m) => m.readAll), - }, -}); +const notification = new BeeCommand("notification").summary("Manage Backlog notifications"); + +await notification.addCommands([ + import("./list.js"), + import("./count.js"), + import("./read.js"), + import("./read-all.js"), +]); + +export default notification; diff --git a/apps/cli/src/commands/pr/index.ts b/apps/cli/src/commands/pr/index.ts index 2fae6147..981c5061 100644 --- a/apps/cli/src/commands/pr/index.ts +++ b/apps/cli/src/commands/pr/index.ts @@ -1,18 +1,16 @@ -import { defineCommand } from "citty"; +import { BeeCommand } from "../../lib/bee-command"; -export const pr = defineCommand({ - meta: { - name: "pr", - description: "Manage Backlog pull requests", - }, - subCommands: { - list: () => import("./list").then((m) => m.list), - view: () => import("./view").then((m) => m.view), - comments: () => import("./comments").then((m) => m.comments), - status: () => import("./status").then((m) => m.status), - create: () => import("./create").then((m) => m.create), - edit: () => import("./edit").then((m) => m.edit), - comment: () => import("./comment").then((m) => m.comment), - count: () => import("./count").then((m) => m.count), - }, -}); +const pr = new BeeCommand("pr").summary("Manage Backlog pull requests"); + +await pr.addCommands([ + import("./list.js"), + import("./view.js"), + import("./comments.js"), + import("./status.js"), + import("./create.js"), + import("./edit.js"), + import("./comment.js"), + import("./count.js"), +]); + +export default pr; diff --git a/apps/cli/src/commands/project/index.ts b/apps/cli/src/commands/project/index.ts index 5a1b3442..37d1cf6f 100644 --- a/apps/cli/src/commands/project/index.ts +++ b/apps/cli/src/commands/project/index.ts @@ -1,19 +1,17 @@ -import { defineCommand } from "citty"; +import { BeeCommand } from "../../lib/bee-command"; -export const project = defineCommand({ - meta: { - name: "project", - description: "Manage Backlog projects", - }, - subCommands: { - list: () => import("./list").then((m) => m.list), - view: () => import("./view").then((m) => m.view), - create: () => import("./create").then((m) => m.create), - edit: () => import("./edit").then((m) => m.edit), - delete: () => import("./delete").then((m) => m.deleteProject), - users: () => import("./users").then((m) => m.users), - activities: () => import("./activities").then((m) => m.activities), - "add-user": () => import("./add-user").then((m) => m.addUser), - "remove-user": () => import("./remove-user").then((m) => m.removeUser), - }, -}); +const project = new BeeCommand("project").summary("Manage Backlog projects"); + +await project.addCommands([ + import("./list.js"), + import("./view.js"), + import("./create.js"), + import("./edit.js"), + import("./delete.js"), + import("./users.js"), + import("./activities.js"), + import("./add-user.js"), + import("./remove-user.js"), +]); + +export default project; diff --git a/apps/cli/src/commands/repo/index.ts b/apps/cli/src/commands/repo/index.ts index eaaeba40..4c2993e3 100644 --- a/apps/cli/src/commands/repo/index.ts +++ b/apps/cli/src/commands/repo/index.ts @@ -1,13 +1,7 @@ -import { defineCommand } from "citty"; +import { BeeCommand } from "../../lib/bee-command"; -export const repo = defineCommand({ - meta: { - name: "repo", - description: "Manage Backlog Git repositories", - }, - subCommands: { - list: () => import("./list").then((m) => m.list), - view: () => import("./view").then((m) => m.view), - clone: () => import("./clone").then((m) => m.clone), - }, -}); +const repo = new BeeCommand("repo").summary("Manage Backlog Git repositories"); + +await repo.addCommands([import("./list.js"), import("./view.js"), import("./clone.js")]); + +export default repo; diff --git a/apps/cli/src/commands/space/index.ts b/apps/cli/src/commands/space/index.ts index 85e0a36b..980a127b 100644 --- a/apps/cli/src/commands/space/index.ts +++ b/apps/cli/src/commands/space/index.ts @@ -1,14 +1,12 @@ -import { defineCommand } from "citty"; +import { BeeCommand } from "../../lib/bee-command"; -export const space = defineCommand({ - meta: { - name: "space", - description: "Manage space information", - }, - subCommands: { - info: () => import("./info").then((m) => m.info), - activities: () => import("./activities").then((m) => m.activities), - "disk-usage": () => import("./disk-usage").then((m) => m.diskUsage), - notification: () => import("./notification").then((m) => m.notification), - }, -}); +const space = new BeeCommand("space").summary("Manage space information"); + +await space.addCommands([ + import("./info.js"), + import("./activities.js"), + import("./disk-usage.js"), + import("./notification.js"), +]); + +export default space; diff --git a/apps/cli/src/commands/star/index.ts b/apps/cli/src/commands/star/index.ts index b19b9c61..a39e4c8f 100644 --- a/apps/cli/src/commands/star/index.ts +++ b/apps/cli/src/commands/star/index.ts @@ -1,14 +1,12 @@ -import { defineCommand } from "citty"; +import { BeeCommand } from "../../lib/bee-command"; -export const star = defineCommand({ - meta: { - name: "star", - description: "Manage stars", - }, - subCommands: { - add: () => import("./add").then((m) => m.add), - list: () => import("./list").then((m) => m.list), - count: () => import("./count").then((m) => m.count), - remove: () => import("./remove").then((m) => m.remove), - }, -}); +const star = new BeeCommand("star").summary("Manage stars"); + +await star.addCommands([ + import("./add.js"), + import("./list.js"), + import("./count.js"), + import("./remove.js"), +]); + +export default star; diff --git a/apps/cli/src/commands/status/index.ts b/apps/cli/src/commands/status/index.ts index b2de6b9f..fded491c 100644 --- a/apps/cli/src/commands/status/index.ts +++ b/apps/cli/src/commands/status/index.ts @@ -1,14 +1,12 @@ -import { defineCommand } from "citty"; +import { BeeCommand } from "../../lib/bee-command"; -export const status = defineCommand({ - meta: { - name: "status", - description: "Manage project statuses", - }, - subCommands: { - list: () => import("./list").then((m) => m.list), - create: () => import("./create").then((m) => m.create), - edit: () => import("./edit").then((m) => m.edit), - delete: () => import("./delete").then((m) => m.deleteStatus), - }, -}); +const status = new BeeCommand("status").summary("Manage project statuses"); + +await status.addCommands([ + import("./list.js"), + import("./create.js"), + import("./edit.js"), + import("./delete.js"), +]); + +export default status; diff --git a/apps/cli/src/commands/team/index.ts b/apps/cli/src/commands/team/index.ts index 339725e1..8a991d20 100644 --- a/apps/cli/src/commands/team/index.ts +++ b/apps/cli/src/commands/team/index.ts @@ -1,15 +1,13 @@ -import { defineCommand } from "citty"; +import { BeeCommand } from "../../lib/bee-command"; -export const team = defineCommand({ - meta: { - name: "team", - description: "Manage Backlog teams", - }, - subCommands: { - list: () => import("./list").then((m) => m.list), - view: () => import("./view").then((m) => m.view), - create: () => import("./create").then((m) => m.create), - edit: () => import("./edit").then((m) => m.edit), - delete: () => import("./delete").then((m) => m.deleteTeam), - }, -}); +const team = new BeeCommand("team").summary("Manage Backlog teams"); + +await team.addCommands([ + import("./list.js"), + import("./view.js"), + import("./create.js"), + import("./edit.js"), + import("./delete.js"), +]); + +export default team; diff --git a/apps/cli/src/commands/user/index.ts b/apps/cli/src/commands/user/index.ts index c4117799..065f7015 100644 --- a/apps/cli/src/commands/user/index.ts +++ b/apps/cli/src/commands/user/index.ts @@ -1,14 +1,12 @@ -import { defineCommand } from "citty"; +import { BeeCommand } from "../../lib/bee-command"; -export const user = defineCommand({ - meta: { - name: "user", - description: "Manage Backlog users", - }, - subCommands: { - list: () => import("./list").then((m) => m.list), - view: () => import("./view").then((m) => m.view), - me: () => import("./me").then((m) => m.me), - activities: () => import("./activities").then((m) => m.activities), - }, -}); +const user = new BeeCommand("user").summary("Manage Backlog users"); + +await user.addCommands([ + import("./list.js"), + import("./view.js"), + import("./me.js"), + import("./activities.js"), +]); + +export default user; diff --git a/apps/cli/src/commands/watching/index.ts b/apps/cli/src/commands/watching/index.ts index 5f64d422..29d11a1a 100644 --- a/apps/cli/src/commands/watching/index.ts +++ b/apps/cli/src/commands/watching/index.ts @@ -1,15 +1,13 @@ -import { defineCommand } from "citty"; +import { BeeCommand } from "../../lib/bee-command"; -export const watching = defineCommand({ - meta: { - name: "watching", - description: "Manage watching (issue subscriptions)", - }, - subCommands: { - list: () => import("./list").then((m) => m.list), - add: () => import("./add").then((m) => m.add), - view: () => import("./view").then((m) => m.view), - delete: () => import("./delete").then((m) => m.deleteWatching), - read: () => import("./read").then((m) => m.read), - }, -}); +const watching = new BeeCommand("watching").summary("Manage watching (issue subscriptions)"); + +await watching.addCommands([ + import("./list.js"), + import("./add.js"), + import("./view.js"), + import("./delete.js"), + import("./read.js"), +]); + +export default watching; diff --git a/apps/cli/src/commands/webhook/index.ts b/apps/cli/src/commands/webhook/index.ts index 55a97d1d..8c9dceb0 100644 --- a/apps/cli/src/commands/webhook/index.ts +++ b/apps/cli/src/commands/webhook/index.ts @@ -1,15 +1,13 @@ -import { defineCommand } from "citty"; +import { BeeCommand } from "../../lib/bee-command"; -export const webhook = defineCommand({ - meta: { - name: "webhook", - description: "Manage project webhooks", - }, - subCommands: { - list: () => import("./list").then((m) => m.list), - view: () => import("./view").then((m) => m.view), - create: () => import("./create").then((m) => m.create), - edit: () => import("./edit").then((m) => m.edit), - delete: () => import("./delete").then((m) => m.deleteWebhook), - }, -}); +const webhook = new BeeCommand("webhook").summary("Manage project webhooks"); + +await webhook.addCommands([ + import("./list.js"), + import("./view.js"), + import("./create.js"), + import("./edit.js"), + import("./delete.js"), +]); + +export default webhook; diff --git a/apps/cli/src/commands/wiki/index.ts b/apps/cli/src/commands/wiki/index.ts index 4153bcd8..343bed62 100644 --- a/apps/cli/src/commands/wiki/index.ts +++ b/apps/cli/src/commands/wiki/index.ts @@ -1,19 +1,17 @@ -import { defineCommand } from "citty"; +import { BeeCommand } from "../../lib/bee-command"; -export const wiki = defineCommand({ - meta: { - name: "wiki", - description: "Manage Backlog wiki pages", - }, - subCommands: { - list: () => import("./list").then((m) => m.list), - view: () => import("./view").then((m) => m.view), - count: () => import("./count").then((m) => m.count), - tags: () => import("./tags").then((m) => m.tags), - history: () => import("./history").then((m) => m.history), - attachments: () => import("./attachments").then((m) => m.attachments), - create: () => import("./create").then((m) => m.create), - edit: () => import("./edit").then((m) => m.edit), - delete: () => import("./delete").then((m) => m.deleteWiki), - }, -}); +const wiki = new BeeCommand("wiki").summary("Manage Backlog wiki pages"); + +await wiki.addCommands([ + import("./list.js"), + import("./view.js"), + import("./count.js"), + import("./tags.js"), + import("./history.js"), + import("./attachments.js"), + import("./create.js"), + import("./edit.js"), + import("./delete.js"), +]); + +export default wiki; diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index e14ad2c3..77245b4d 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -1,64 +1,38 @@ -import { handleBacklogApiError } from "@repo/backlog-utils"; -import { UserError, handleValidationError } from "@repo/cli-utils"; -import { defineCommand, runCommand, runMain } from "citty"; -import consola, { LogLevels } from "consola"; -import { showCommandUsage } from "./lib/command-usage"; +import { BeeCommand } from "./lib/bee-command"; +import { handleError } from "./lib/error"; import pkg from "../package.json" with { type: "json" }; -const main = defineCommand({ - meta: { - name: "bee", - version: pkg.version, - description: pkg.description, - }, - subCommands: { - auth: () => import("./commands/auth/index").then((m) => m.auth), - project: () => import("./commands/project/index").then((m) => m.project), - issue: () => import("./commands/issue/index").then((m) => m.issue), - document: () => import("./commands/document/index").then((m) => m.document), - notification: () => import("./commands/notification/index").then((m) => m.notification), - pr: () => import("./commands/pr/index").then((m) => m.pr), - repo: () => import("./commands/repo/index").then((m) => m.repo), - team: () => import("./commands/team/index").then((m) => m.team), - user: () => import("./commands/user/index").then((m) => m.user), - webhook: () => import("./commands/webhook/index").then((m) => m.webhook), - wiki: () => import("./commands/wiki/index").then((m) => m.wiki), - category: () => import("./commands/category/index").then((m) => m.category), - milestone: () => import("./commands/milestone/index").then((m) => m.milestone), - "issue-type": () => import("./commands/issue-type/index").then((m) => m.issueType), - space: () => import("./commands/space/index").then((m) => m.space), - status: () => import("./commands/status/index").then((m) => m.status), - star: () => import("./commands/star/index").then((m) => m.star), - watching: () => import("./commands/watching/index").then((m) => m.watching), - dashboard: () => import("./commands/dashboard").then((m) => m.dashboard), - browse: () => import("./commands/browse").then((m) => m.browse), - api: () => import("./commands/api").then((m) => m.api), - completion: () => import("./commands/completion").then((m) => m.completion), - }, -}); +const program = new BeeCommand("bee").version(pkg.version).description(pkg.description ?? ""); -const rawArgs = process.argv.slice(2); +await program.addCommands([ + import("./commands/auth/index.js"), + import("./commands/project/index.js"), + import("./commands/issue/index.js"), + import("./commands/document/index.js"), + import("./commands/notification/index.js"), + import("./commands/pr/index.js"), + import("./commands/repo/index.js"), + import("./commands/team/index.js"), + import("./commands/user/index.js"), + import("./commands/webhook/index.js"), + import("./commands/wiki/index.js"), + import("./commands/category/index.js"), + import("./commands/milestone/index.js"), + import("./commands/issue-type/index.js"), + import("./commands/space/index.js"), + import("./commands/status/index.js"), + import("./commands/star/index.js"), + import("./commands/watching/index.js"), + import("./commands/dashboard.js"), + import("./commands/browse.js"), + import("./commands/api.js"), + import("./commands/completion.js"), +]); -// Use runMain for --help / --version (preserves citty's built-in handling). -// For normal execution, use runCommand directly so we can intercept errors -// (runMain swallows errors internally, preventing custom error handling). -if (rawArgs.includes("--help") || rawArgs.includes("-h") || rawArgs.includes("--version")) { - void runMain(main, { showUsage: showCommandUsage }); -} else { - const useJson = rawArgs.includes("--json") || !process.stdout.isTTY; - try { - await runCommand(main, { rawArgs }); - } catch (error) { - // At debug level or above, always show the full error with stack trace - const showStack = consola.level >= LogLevels.debug; +program.exitOverride(); - // UserError = expected failure (bad input, missing config, …) → message only - if (error instanceof UserError) { - consola.error(showStack ? error : error.message); - } else if (!handleBacklogApiError(error, { json: useJson }) && !handleValidationError(error)) { - // Unrecognised error = unexpected bug → full object (includes stack trace) - consola.error(error); - } - process.exit(1); - } +try { + await program.parseAsync(); +} catch (error) { + handleError(error); } From 652c5426afc7c945e2c14ec119e1a8af4d71dd84 Mon Sep 17 00:00:00 2001 From: Ryoya Tamura <ryoya.tamura@nulab.com> Date: Sun, 8 Mar 2026 18:02:11 +0900 Subject: [PATCH 03/16] refactor(cli): migrate auth commands to commander Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --- apps/cli/src/commands/auth/login.test.ts | 116 +++++++++--------- apps/cli/src/commands/auth/login.ts | 106 ++++++----------- apps/cli/src/commands/auth/logout.test.ts | 24 ++-- apps/cli/src/commands/auth/logout.ts | 108 +++++++---------- apps/cli/src/commands/auth/refresh.test.ts | 24 ++-- apps/cli/src/commands/auth/refresh.ts | 130 +++++++++------------ apps/cli/src/commands/auth/status.test.ts | 38 +++--- apps/cli/src/commands/auth/status.ts | 119 ++++++++----------- apps/cli/src/commands/auth/switch.test.ts | 18 +-- apps/cli/src/commands/auth/switch.ts | 106 +++++++---------- apps/cli/src/commands/auth/token.test.ts | 12 +- apps/cli/src/commands/auth/token.ts | 58 +++------ 12 files changed, 350 insertions(+), 509 deletions(-) diff --git a/apps/cli/src/commands/auth/login.test.ts b/apps/cli/src/commands/auth/login.test.ts index 1953e37b..768ef205 100644 --- a/apps/cli/src/commands/auth/login.test.ts +++ b/apps/cli/src/commands/auth/login.test.ts @@ -48,10 +48,8 @@ describe("auth login", () => { updater({ spaces: [], defaultSpace: undefined, aliases: {} }), ); - const { login } = await import("./login"); - await login.run?.({ - args: { method: "api-key" }, - } as never); + const { default: login } = await import("./login"); + await login.parseAsync(["--method", "api-key"], { from: "user" }); expect(Backlog).toHaveBeenCalledWith({ host: "example.backlog.com", apiKey: "test-api-key" }); expect(mockGetMyself).toHaveBeenCalled(); @@ -83,10 +81,8 @@ describe("auth login", () => { }), ); - const { login } = await import("./login"); - await login.run?.({ - args: { method: "api-key" }, - } as never); + const { default: login } = await import("./login"); + await login.parseAsync(["--method", "api-key"], { from: "user" }); const result = vi.mocked(updateConfig).mock.results[0]?.value; expect(result.spaces).toEqual([ @@ -104,12 +100,8 @@ describe("auth login", () => { .mockResolvedValueOnce("example.backlog.com") .mockResolvedValueOnce("bad-key"); - const { login } = await import("./login"); - await expect( - login.run?.({ - args: { method: "api-key" }, - } as never), - ).rejects.toThrow( + const { default: login } = await import("./login"); + await expect(login.parseAsync(["--method", "api-key"], { from: "user" })).rejects.toThrow( "Authentication failed. Could not connect to example.backlog.com with the provided API key.", ); expect(updateConfig).not.toHaveBeenCalled(); @@ -118,12 +110,10 @@ describe("auth login", () => { describe("invalid method", () => { it("returns error for invalid method", async () => { - const { login } = await import("./login"); - await expect( - login.run?.({ - args: { method: "invalid" }, - } as never), - ).rejects.toThrow('Invalid auth method. Use "api-key" or "oauth".'); + const { default: login } = await import("./login"); + await expect(login.parseAsync(["--method", "invalid"], { from: "user" })).rejects.toThrow( + 'Invalid auth method. Use "api-key" or "oauth".', + ); }); }); @@ -158,14 +148,11 @@ describe("auth login", () => { .mockResolvedValueOnce("my-client-id") .mockResolvedValueOnce("my-client-secret"); - const { login } = await import("./login"); - await login.run?.({ - args: { - method: "oauth", - "client-id": "my-client-id", - "client-secret": "my-client-secret", - }, - } as never); + const { default: login } = await import("./login"); + await login.parseAsync( + ["--method", "oauth", "--client-id", "my-client-id", "--client-secret", "my-client-secret"], + { from: "user" }, + ); expect(startCallbackServer).toHaveBeenCalled(); expect(OAuth2).toHaveBeenCalledWith({ @@ -221,15 +208,19 @@ describe("auth login", () => { .mockResolvedValueOnce("my-client-id") .mockResolvedValueOnce("my-client-secret"); - const { login } = await import("./login"); + const { default: login } = await import("./login"); await expect( - login.run?.({ - args: { - method: "oauth", - "client-id": "my-client-id", - "client-secret": "my-client-secret", - }, - } as never), + login.parseAsync( + [ + "--method", + "oauth", + "--client-id", + "my-client-id", + "--client-secret", + "my-client-secret", + ], + { from: "user" }, + ), ).rejects.toThrow("OAuth authorization failed: OAuth callback timed out after 5 minutes"); expect(mockStop).toHaveBeenCalled(); }); @@ -242,15 +233,19 @@ describe("auth login", () => { .mockResolvedValueOnce("my-client-id") .mockResolvedValueOnce("my-client-secret"); - const { login } = await import("./login"); + const { default: login } = await import("./login"); await expect( - login.run?.({ - args: { - method: "oauth", - "client-id": "my-client-id", - "client-secret": "my-client-secret", - }, - } as never), + login.parseAsync( + [ + "--method", + "oauth", + "--client-id", + "my-client-id", + "--client-secret", + "my-client-secret", + ], + { from: "user" }, + ), ).rejects.toThrow("Failed to exchange authorization code for tokens."); }); @@ -262,15 +257,19 @@ describe("auth login", () => { .mockResolvedValueOnce("my-client-id") .mockResolvedValueOnce("my-client-secret"); - const { login } = await import("./login"); + const { default: login } = await import("./login"); await expect( - login.run?.({ - args: { - method: "oauth", - "client-id": "my-client-id", - "client-secret": "my-client-secret", - }, - } as never), + login.parseAsync( + [ + "--method", + "oauth", + "--client-id", + "my-client-id", + "--client-secret", + "my-client-secret", + ], + { from: "user" }, + ), ).rejects.toThrow("Authentication verification failed."); }); @@ -299,14 +298,11 @@ describe("auth login", () => { .mockResolvedValueOnce("my-client-id") .mockResolvedValueOnce("my-client-secret"); - const { login } = await import("./login"); - await login.run?.({ - args: { - method: "oauth", - "client-id": "my-client-id", - "client-secret": "my-client-secret", - }, - } as never); + const { default: login } = await import("./login"); + await login.parseAsync( + ["--method", "oauth", "--client-id", "my-client-id", "--client-secret", "my-client-secret"], + { from: "user" }, + ); const result = vi.mocked(updateConfig).mock.results[0]?.value; expect(result.spaces).toEqual([ diff --git a/apps/cli/src/commands/auth/login.ts b/apps/cli/src/commands/auth/login.ts index b486f820..a4094fab 100644 --- a/apps/cli/src/commands/auth/login.ts +++ b/apps/cli/src/commands/auth/login.ts @@ -2,12 +2,13 @@ import { exchangeAuthorizationCode, openUrl, startCallbackServer } from "@repo/b import { UserError, promptRequired, readStdin } from "@repo/cli-utils"; import { type RcAuth, updateConfig } from "@repo/config"; import { Backlog, OAuth2 } from "backlog-js"; -import { defineCommand } from "citty"; import consola from "consola"; -import { type CommandUsage, withUsage } from "../../lib/command-usage"; +import { BeeCommand } from "../../lib/bee-command"; -const commandUsage: CommandUsage = { - long: `Authenticate with a Backlog space. +const login = new BeeCommand("login") + .summary("Authenticate with a Backlog space") + .description( + `Authenticate with a Backlog space. The default authentication mode is API key. You will be prompted to enter the space hostname and API key interactively. @@ -15,79 +16,46 @@ the space hostname and API key interactively. Alternatively, use \`--with-token\` to pass an API key on standard input. For OAuth authentication, use \`--method oauth\`. You will need to provide an OAuth Client ID and Client Secret, then authorize in the browser.`, - - examples: [ + ) + .option("-m, --method <method>", "The authentication method to use", "api-key") + .option("--with-token", "Read token from standard input") + .option("--client-id <id>", "The OAuth Client ID to use when authenticating with Backlog") + .option( + "--client-secret <secret>", + "The OAuth Client Secret to use when authenticating with Backlog", + ) + .envVars([ + ["BACKLOG_SPACE", "Default space hostname"], + ["BACKLOG_OAUTH_CLIENT_ID", "OAuth Client ID"], + ["BACKLOG_OAUTH_CLIENT_SECRET", "OAuth Client Secret"], + ]) + .examples([ { description: "Start interactive setup", command: "bee auth login" }, { description: "Login with API key from stdin", command: "echo 'your-api-key' | bee auth login --with-token", }, { description: "Login with OAuth", command: "bee auth login -m oauth" }, - ], - - annotations: { - environment: [ - ["BACKLOG_SPACE", "Default space hostname"], - ["BACKLOG_OAUTH_CLIENT_ID", "OAuth Client ID"], - ["BACKLOG_OAUTH_CLIENT_SECRET", "OAuth Client Secret"], - ], - }, -}; - -const login = withUsage( - defineCommand({ - meta: { - name: "login", - description: "Authenticate with a Backlog space", - }, - args: { - method: { - type: "string", - alias: "m", - description: "The authentication method to use", - valueHint: "{api-key|oauth}", - default: "api-key", - }, - "with-token": { - type: "boolean", - description: "Read token from standard input", - }, - "client-id": { - type: "string", - description: "The OAuth Client ID to use when authenticating with Backlog", - }, - "client-secret": { - type: "string", - description: "The OAuth Client Secret to use when authenticating with Backlog", - }, - }, - async run({ args }) { - const { method } = args; + ]) + .action(async (opts) => { + const { method } = opts; - if (method !== "api-key" && method !== "oauth") { - throw new UserError('Invalid auth method. Use "api-key" or "oauth".'); - } + if (method !== "api-key" && method !== "oauth") { + throw new UserError('Invalid auth method. Use "api-key" or "oauth".'); + } - const hostname = await promptRequired("Backlog space hostname:", process.env.BACKLOG_SPACE, { - placeholder: "xxx.backlog.com", - }); + const hostname = await promptRequired("Backlog space hostname:", process.env.BACKLOG_SPACE, { + placeholder: "xxx.backlog.com", + }); - await (method === "api-key" - ? loginWithApiKey(hostname, args) - : loginWithOAuth(hostname, args)); - }, - }), - commandUsage, -); + await (method === "api-key" ? loginWithApiKey(hostname, opts) : loginWithOAuth(hostname, opts)); + }); -const loginWithApiKey = async ( - hostname: string, - args: { "with-token"?: boolean }, -): Promise<void> => { - if (!args["with-token"]) { +const loginWithApiKey = async (hostname: string, opts: { withToken?: boolean }): Promise<void> => { + if (!opts.withToken) { consola.info(`Tip: you can generate an API key at https://${hostname}/EditApiSettings.action`); } - const apiKey = args["with-token"] ? await readStdin() : await promptRequired("API key:"); + const apiKey = opts.withToken ? await readStdin() : await promptRequired("API key:"); consola.start(`Authenticating with ${hostname}...`); @@ -107,16 +75,16 @@ const loginWithApiKey = async ( const loginWithOAuth = async ( hostname: string, - args: { "client-id"?: string; "client-secret"?: string }, + opts: { clientId?: string; clientSecret?: string }, ): Promise<void> => { const clientId = await promptRequired( "OAuth Client ID:", - args["client-id"] ?? process.env.BACKLOG_OAUTH_CLIENT_ID, + opts.clientId ?? process.env.BACKLOG_OAUTH_CLIENT_ID, ); const clientSecret = await promptRequired( "OAuth Client Secret:", - args["client-secret"] ?? process.env.BACKLOG_OAUTH_CLIENT_SECRET, + opts.clientSecret ?? process.env.BACKLOG_OAUTH_CLIENT_SECRET, ); const callbackServer = startCallbackServer(); @@ -187,4 +155,4 @@ const saveSpace = (hostname: string, auth: RcAuth): void => { }); }; -export { commandUsage, login }; +export default login; diff --git a/apps/cli/src/commands/auth/logout.test.ts b/apps/cli/src/commands/auth/logout.test.ts index 572b6233..048cb67f 100644 --- a/apps/cli/src/commands/auth/logout.test.ts +++ b/apps/cli/src/commands/auth/logout.test.ts @@ -22,10 +22,8 @@ describe("auth logout", () => { aliases: {}, }); - const { logout } = await import("./logout"); - await logout.run?.({ - args: { space: "example.backlog.com" }, - } as never); + const { default: logout } = await import("./logout"); + await logout.parseAsync(["--space", "example.backlog.com"], { from: "user" }); expect(removeSpace).toHaveBeenCalledWith("example.backlog.com"); expect(consola.success).toHaveBeenCalledWith("Logged out of example.backlog.com."); @@ -38,10 +36,8 @@ describe("auth logout", () => { aliases: {}, }); - const { logout } = await import("./logout"); - await logout.run?.({ - args: {}, - } as never); + const { default: logout } = await import("./logout"); + await logout.parseAsync([], { from: "user" }); expect(consola.info).toHaveBeenCalledWith("No spaces are currently authenticated."); expect(removeSpace).not.toHaveBeenCalled(); @@ -57,11 +53,9 @@ describe("auth logout", () => { throw new Error("not found"); }); - const { logout } = await import("./logout"); + const { default: logout } = await import("./logout"); await expect( - logout.run?.({ - args: { space: "nonexistent.backlog.com" }, - } as never), + logout.parseAsync(["--space", "nonexistent.backlog.com"], { from: "user" }), ).rejects.toThrow('Space "nonexistent.backlog.com" is not configured.'); }); @@ -78,10 +72,8 @@ describe("auth logout", () => { }); vi.mocked(removeSpace).mockImplementation(() => {}); - const { logout } = await import("./logout"); - await logout.run?.({ - args: {}, - } as never); + const { default: logout } = await import("./logout"); + await logout.parseAsync([], { from: "user" }); expect(removeSpace).toHaveBeenCalledWith("only.backlog.com"); expect(consola.success).toHaveBeenCalledWith("Logged out of only.backlog.com."); diff --git a/apps/cli/src/commands/auth/logout.ts b/apps/cli/src/commands/auth/logout.ts index bfbc9df0..b3b48d84 100644 --- a/apps/cli/src/commands/auth/logout.ts +++ b/apps/cli/src/commands/auth/logout.ts @@ -1,87 +1,67 @@ import { UserError } from "@repo/cli-utils"; import { loadConfig, removeSpace } from "@repo/config"; -import { defineCommand } from "citty"; import consola from "consola"; -import { type CommandUsage, withUsage } from "../../lib/command-usage"; +import { BeeCommand } from "../../lib/bee-command"; const isNoInput = (): boolean => process.env.BACKLOG_NO_INPUT === "1"; -const commandUsage: CommandUsage = { - long: `Remove authentication for a Backlog space. +const logout = new BeeCommand("logout") + .summary("Remove authentication for a Backlog space") + .description( + `Remove authentication for a Backlog space. The stored credentials are removed locally. This does not revoke API keys or OAuth tokens on the Backlog server. If only one space is configured, it will be selected automatically. If multiple spaces are configured, you will be prompted to select one.`, - - examples: [ + ) + .option("-s, --space <hostname>", "The hostname of the Backlog space") + .envVars([ + ["BACKLOG_SPACE", "Space hostname to log out from"], + ["BACKLOG_NO_INPUT", "Set to 1 to disable interactive prompts"], + ]) + .examples([ { description: "Select space via prompt", command: "bee auth logout" }, { description: "Log out of a specific space", command: "bee auth logout -s xxx.backlog.com", }, - ], - - annotations: { - environment: [ - ["BACKLOG_SPACE", "Space hostname to log out from"], - ["BACKLOG_NO_INPUT", "Set to 1 to disable interactive prompts"], - ], - }, -}; - -const logout = withUsage( - defineCommand({ - meta: { - name: "logout", - description: "Remove authentication for a Backlog space", - }, - args: { - space: { - type: "string", - alias: "s", - description: "The hostname of the Backlog space", - valueHint: "<xxx.backlog.com>", - }, - }, - async run({ args }) { - const config = loadConfig(); + ]) + .action(async (opts) => { + const config = loadConfig(); - let hostname = args.space || process.env.BACKLOG_SPACE; - if (!hostname) { - if (config.spaces.length === 0) { - consola.info("No spaces are currently authenticated."); - return; - } + let hostname = opts.space || process.env.BACKLOG_SPACE; + if (!hostname) { + if (config.spaces.length === 0) { + consola.info("No spaces are currently authenticated."); + return; + } - const [firstSpace] = config.spaces; - if (config.spaces.length === 1) { - hostname = firstSpace.host; - } else if (isNoInput()) { - throw new UserError( - "Hostname is required. Use --space to provide it in BACKLOG_NO_INPUT mode.", - ); - } else { - hostname = await consola.prompt("Select a space to log out from:", { - type: "select", - options: config.spaces.map((s) => s.host), - }); + const [firstSpace] = config.spaces; + if (config.spaces.length === 1) { + hostname = firstSpace.host; + } else if (isNoInput()) { + throw new UserError( + "Hostname is required. Use --space to provide it in BACKLOG_NO_INPUT mode.", + ); + } else { + hostname = await consola.prompt("Select a space to log out from:", { + type: "select", + options: config.spaces.map((s) => s.host), + }); - if (typeof hostname !== "string" || !hostname) { - throw new UserError("No space selected."); - } + if (typeof hostname !== "string" || !hostname) { + throw new UserError("No space selected."); } } + } - try { - removeSpace(hostname); - } catch { - throw new UserError(`Space "${hostname}" is not configured.`); - } + try { + removeSpace(hostname); + } catch { + throw new UserError(`Space "${hostname}" is not configured.`); + } - consola.success(`Logged out of ${hostname}.`); - }, - }), - commandUsage, -); + consola.success(`Logged out of ${hostname}.`); + }); -export { commandUsage, logout }; +export default logout; diff --git a/apps/cli/src/commands/auth/refresh.test.ts b/apps/cli/src/commands/auth/refresh.test.ts index e1343d9f..438421c3 100644 --- a/apps/cli/src/commands/auth/refresh.test.ts +++ b/apps/cli/src/commands/auth/refresh.test.ts @@ -29,8 +29,8 @@ describe("auth refresh", () => { it("throws error when no space is configured", async () => { vi.mocked(resolveSpace).mockReturnValue(null); - const { refresh } = await import("./refresh"); - await expect(refresh.run?.({ args: {} } as never)).rejects.toThrow( + const { default: refresh } = await import("./refresh"); + await expect(refresh.parseAsync([], { from: "user" })).rejects.toThrow( "No space configured. Run `bee auth login` to authenticate.", ); }); @@ -41,8 +41,8 @@ describe("auth refresh", () => { auth: { method: "api-key" as const, apiKey: "key" }, }); - const { refresh } = await import("./refresh"); - await expect(refresh.run?.({ args: {} } as never)).rejects.toThrow( + const { default: refresh } = await import("./refresh"); + await expect(refresh.parseAsync([], { from: "user" })).rejects.toThrow( "Token refresh is only available for OAuth authentication. Current space uses API key.", ); }); @@ -57,8 +57,8 @@ describe("auth refresh", () => { }, }); - const { refresh } = await import("./refresh"); - await expect(refresh.run?.({ args: {} } as never)).rejects.toThrow( + const { default: refresh } = await import("./refresh"); + await expect(refresh.parseAsync([], { from: "user" })).rejects.toThrow( "Client ID and Client Secret are missing from the stored OAuth configuration. Please re-authenticate with `bee auth login -m oauth`.", ); }); @@ -82,8 +82,8 @@ describe("auth refresh", () => { }); mockGetMyself.mockResolvedValue({ name: "Test User", userId: "testuser" }); - const { refresh } = await import("./refresh"); - await refresh.run?.({ args: {} } as never); + const { default: refresh } = await import("./refresh"); + await refresh.parseAsync([], { from: "user" }); expect(refreshAccessToken).toHaveBeenCalledWith("example.backlog.com", { refreshToken: "old-refresh", @@ -120,8 +120,8 @@ describe("auth refresh", () => { }); vi.mocked(refreshAccessToken).mockRejectedValue(new Error("invalid_grant")); - const { refresh } = await import("./refresh"); - await expect(refresh.run?.({ args: {} } as never)).rejects.toThrow( + const { default: refresh } = await import("./refresh"); + await expect(refresh.parseAsync([], { from: "user" })).rejects.toThrow( "Failed to refresh OAuth token. Please re-authenticate with `bee auth login -m oauth`.", ); }); @@ -145,8 +145,8 @@ describe("auth refresh", () => { }); mockGetMyself.mockRejectedValue(new Error("Unauthorized")); - const { refresh } = await import("./refresh"); - await expect(refresh.run?.({ args: {} } as never)).rejects.toThrow( + const { default: refresh } = await import("./refresh"); + await expect(refresh.parseAsync([], { from: "user" })).rejects.toThrow( "Token verification failed after refresh.", ); }); diff --git a/apps/cli/src/commands/auth/refresh.ts b/apps/cli/src/commands/auth/refresh.ts index 80acc2cd..efd5c4fb 100644 --- a/apps/cli/src/commands/auth/refresh.ts +++ b/apps/cli/src/commands/auth/refresh.ts @@ -2,99 +2,79 @@ import { refreshAccessToken } from "@repo/backlog-utils"; import { UserError } from "@repo/cli-utils"; import { findSpace, loadConfig, resolveSpace, updateSpaceAuth } from "@repo/config"; import { Backlog } from "backlog-js"; -import { defineCommand } from "citty"; import consola from "consola"; -import { type CommandUsage, withUsage } from "../../lib/command-usage"; +import { BeeCommand } from "../../lib/bee-command"; -const commandUsage: CommandUsage = { - long: `Refresh the OAuth access token for a Backlog space. +const refresh = new BeeCommand("refresh") + .summary("Refresh OAuth token") + .description( + `Refresh the OAuth access token for a Backlog space. Uses the stored refresh token to obtain a new access token. Only available for spaces authenticated with OAuth. If the refresh token is expired or invalid, re-authenticate with \`bee auth login -m oauth\`.`, - - examples: [ + ) + .option("-s, --space <hostname>", "The hostname of the Backlog space") + .envVars([["BACKLOG_SPACE", "Default space hostname"]]) + .examples([ { description: "Refresh token for default space", command: "bee auth refresh" }, { description: "Refresh token for specific space", command: "bee auth refresh -s xxx.backlog.com", }, - ], - - annotations: { - environment: [["BACKLOG_SPACE", "Default space hostname"]], - }, -}; - -const refresh = withUsage( - defineCommand({ - meta: { - name: "refresh", - description: "Refresh OAuth token", - }, - args: { - space: { - type: "string", - alias: "s", - description: "The hostname of the Backlog space", - valueHint: "<xxx.backlog.com>", - }, - }, - async run({ args }) { - const space = args.space ? findSpace(loadConfig().spaces, args.space) : resolveSpace(); + ]) + .action(async (opts) => { + const space = opts.space ? findSpace(loadConfig().spaces, opts.space) : resolveSpace(); - if (!space) { - throw new UserError("No space configured. Run `bee auth login` to authenticate."); - } + if (!space) { + throw new UserError("No space configured. Run `bee auth login` to authenticate."); + } - if (space.auth.method !== "oauth") { - throw new UserError( - "Token refresh is only available for OAuth authentication. Current space uses API key.", - ); - } + if (space.auth.method !== "oauth") { + throw new UserError( + "Token refresh is only available for OAuth authentication. Current space uses API key.", + ); + } - const { clientId, clientSecret } = space.auth; - if (!clientId || !clientSecret) { - throw new UserError( - "Client ID and Client Secret are missing from the stored OAuth configuration. Please re-authenticate with `bee auth login -m oauth`.", - ); - } + const { clientId, clientSecret } = space.auth; + if (!clientId || !clientSecret) { + throw new UserError( + "Client ID and Client Secret are missing from the stored OAuth configuration. Please re-authenticate with `bee auth login -m oauth`.", + ); + } - consola.start(`Refreshing OAuth token for ${space.host}...`); + consola.start(`Refreshing OAuth token for ${space.host}...`); - let tokenResponse: Awaited<ReturnType<typeof refreshAccessToken>>; - try { - tokenResponse = await refreshAccessToken(space.host, { - clientId, - clientSecret, - refreshToken: space.auth.refreshToken, - }); - } catch { - throw new UserError( - "Failed to refresh OAuth token. Please re-authenticate with `bee auth login -m oauth`.", - ); - } - - let user; - try { - const client = new Backlog({ host: space.host, accessToken: tokenResponse.access_token }); - user = await client.getMyself(); - } catch { - throw new UserError("Token verification failed after refresh."); - } - - updateSpaceAuth(space.host, { - method: "oauth", - accessToken: tokenResponse.access_token, - refreshToken: tokenResponse.refresh_token, + let tokenResponse: Awaited<ReturnType<typeof refreshAccessToken>>; + try { + tokenResponse = await refreshAccessToken(space.host, { clientId, clientSecret, + refreshToken: space.auth.refreshToken, }); + } catch { + throw new UserError( + "Failed to refresh OAuth token. Please re-authenticate with `bee auth login -m oauth`.", + ); + } - consola.success(`Token refreshed for ${space.host} (${user.name})`); - }, - }), - commandUsage, -); + let user; + try { + const client = new Backlog({ host: space.host, accessToken: tokenResponse.access_token }); + user = await client.getMyself(); + } catch { + throw new UserError("Token verification failed after refresh."); + } + + updateSpaceAuth(space.host, { + method: "oauth", + accessToken: tokenResponse.access_token, + refreshToken: tokenResponse.refresh_token, + clientId, + clientSecret, + }); + + consola.success(`Token refreshed for ${space.host} (${user.name})`); + }); -export { commandUsage, refresh }; +export default refresh; diff --git a/apps/cli/src/commands/auth/status.test.ts b/apps/cli/src/commands/auth/status.test.ts index 3a5fe7cd..f8da73cd 100644 --- a/apps/cli/src/commands/auth/status.test.ts +++ b/apps/cli/src/commands/auth/status.test.ts @@ -29,8 +29,8 @@ describe("auth status", () => { mockGetMyself.mockResolvedValue({ name: "Test User", userId: "testuser" }); - const { status } = await import("./status"); - await status.run?.({ args: {} } as never); + const { default: status } = await import("./status"); + await status.parseAsync([], { from: "user" }); expect(Backlog).toHaveBeenCalledWith({ host: "example.backlog.com", apiKey: "key" }); expect(mockGetMyself).toHaveBeenCalled(); @@ -47,8 +47,8 @@ describe("auth status", () => { aliases: {}, }); - const { status } = await import("./status"); - await status.run?.({ args: {} } as never); + const { default: status } = await import("./status"); + await status.parseAsync([], { from: "user" }); expect(consola.info).toHaveBeenCalledWith( "No spaces are authenticated. Run `bee auth login` to get started.", @@ -67,10 +67,8 @@ describe("auth status", () => { aliases: {}, }); - const { status } = await import("./status"); - await status.run?.({ - args: { space: "other.backlog.com" }, - } as never); + const { default: status } = await import("./status"); + await status.parseAsync(["--space", "other.backlog.com"], { from: "user" }); expect(consola.info).toHaveBeenCalledWith( "No authentication configured for other.backlog.com.", @@ -92,10 +90,8 @@ describe("auth status", () => { mockGetMyself.mockResolvedValue({ name: "Test User", userId: "testuser" }); - const { status } = await import("./status"); - await status.run?.({ - args: { "show-token": true }, - } as never); + const { default: status } = await import("./status"); + await status.parseAsync(["--show-token"], { from: "user" }); expect(consola.log).toHaveBeenCalledWith(" Token my-secret-key"); }); @@ -114,8 +110,8 @@ describe("auth status", () => { mockGetMyself.mockRejectedValue(new Error("Unauthorized")); - const { status } = await import("./status"); - await status.run?.({ args: {} } as never); + const { default: status } = await import("./status"); + await status.parseAsync([], { from: "user" }); expect(consola.log).toHaveBeenCalledWith(" Status Authentication failed"); expect(consola.debug).toHaveBeenCalledWith("Token verification failed:", expect.any(Error)); @@ -139,8 +135,8 @@ describe("auth status", () => { mockGetMyself.mockResolvedValue({ name: "OAuth User", userId: "oauthuser" }); - const { status } = await import("./status"); - await status.run?.({ args: {} } as never); + const { default: status } = await import("./status"); + await status.parseAsync([], { from: "user" }); expect(Backlog).toHaveBeenCalledWith({ host: "example.backlog.com", @@ -169,10 +165,8 @@ describe("auth status", () => { mockGetMyself.mockResolvedValue({ name: "OAuth User", userId: "oauthuser" }); - const { status } = await import("./status"); - await status.run?.({ - args: { "show-token": true }, - } as never); + const { default: status } = await import("./status"); + await status.parseAsync(["--show-token"], { from: "user" }); expect(consola.log).toHaveBeenCalledWith(" Token oauth-access-token"); }); @@ -191,8 +185,8 @@ describe("auth status", () => { mockGetMyself.mockResolvedValue({ name: "Test User", userId: "testuser" }); - const { status } = await import("./status"); - await status.run?.({ args: {} } as never); + const { default: status } = await import("./status"); + await status.parseAsync([], { from: "user" }); expect(consola.log).toHaveBeenCalledWith(" example.backlog.com"); }); diff --git a/apps/cli/src/commands/auth/status.ts b/apps/cli/src/commands/auth/status.ts index 0681a746..cb7779a3 100644 --- a/apps/cli/src/commands/auth/status.ts +++ b/apps/cli/src/commands/auth/status.ts @@ -1,98 +1,75 @@ import { type RcAuth, loadConfig } from "@repo/config"; import { type Entity, Backlog } from "backlog-js"; -import { defineCommand } from "citty"; import consola from "consola"; import { printDefinitionList } from "@repo/cli-utils"; -import { type CommandUsage, withUsage } from "../../lib/command-usage"; +import { BeeCommand } from "../../lib/bee-command"; const getToken = (auth: RcAuth): string => auth.method === "api-key" ? auth.apiKey : auth.accessToken; -const commandUsage: CommandUsage = { - long: `Display authentication status for configured Backlog spaces. +const status = new BeeCommand("status") + .summary("Show authentication status") + .description( + `Display authentication status for configured Backlog spaces. For each space, the authentication method and credential validity are verified by calling the Backlog API. The active (default) space is indicated so you can see which space is used when \`--space\` is not provided.`, - - examples: [ + ) + .option("-s, --space <hostname>", "The hostname of the Backlog space") + .option("--show-token", "Display the auth token") + .envVars([["BACKLOG_SPACE", "Filter by space hostname"]]) + .examples([ { description: "Display status for all spaces", command: "bee auth status" }, { description: "Check a specific space", command: "bee auth status -s xxx.backlog.com", }, { description: "Show auth tokens in the output", command: "bee auth status --show-token" }, - ], - - annotations: { - environment: [["BACKLOG_SPACE", "Filter by space hostname"]], - }, -}; - -const status = withUsage( - defineCommand({ - meta: { - name: "status", - description: "Show authentication status", - }, - args: { - space: { - type: "string", - alias: "s", - description: "The hostname of the Backlog space", - valueHint: "<xxx.backlog.com>", - }, - "show-token": { - type: "boolean", - description: "Display the auth token", - }, - }, - async run({ args }) { - const config = loadConfig(); + ]) + .action(async (opts) => { + const config = loadConfig(); - const filterSpace = args.space || process.env.BACKLOG_SPACE; - const spaces = filterSpace - ? config.spaces.filter((s) => s.host === filterSpace) - : config.spaces; + const filterSpace = opts.space || process.env.BACKLOG_SPACE; + const spaces = filterSpace + ? config.spaces.filter((s) => s.host === filterSpace) + : config.spaces; - if (spaces.length === 0) { - if (filterSpace) { - consola.info(`No authentication configured for ${filterSpace}.`); - } else { - consola.info("No spaces are authenticated. Run `bee auth login` to get started."); - } - return; + if (spaces.length === 0) { + if (filterSpace) { + consola.info(`No authentication configured for ${filterSpace}.`); + } else { + consola.info("No spaces are authenticated. Run `bee auth login` to get started."); } + return; + } - for (const space of spaces) { - const isDefault = config.defaultSpace === space.host; - const label = isDefault ? `${space.host} (default)` : space.host; - - let user: Entity.User.User | null = null; - try { - const client = - space.auth.method === "api-key" - ? new Backlog({ host: space.host, apiKey: space.auth.apiKey }) - : new Backlog({ host: space.host, accessToken: space.auth.accessToken }); - user = await client.getMyself(); - } catch (error) { - consola.debug("Token verification failed:", error); - } + for (const space of spaces) { + const isDefault = config.defaultSpace === space.host; + const label = isDefault ? `${space.host} (default)` : space.host; - consola.log(""); - consola.log(` ${label}`); - printDefinitionList([ - ["Method", space.auth.method], - ["User", user ? `${user.name} (${user.userId})` : undefined], - ["Status", user ? "Authenticated" : "Authentication failed"], - ["Token", args["show-token"] ? getToken(space.auth) : undefined], - ]); + let user: Entity.User.User | null = null; + try { + const client = + space.auth.method === "api-key" + ? new Backlog({ host: space.host, apiKey: space.auth.apiKey }) + : new Backlog({ host: space.host, accessToken: space.auth.accessToken }); + user = await client.getMyself(); + } catch (error) { + consola.debug("Token verification failed:", error); } consola.log(""); - }, - }), - commandUsage, -); + consola.log(` ${label}`); + printDefinitionList([ + ["Method", space.auth.method], + ["User", user ? `${user.name} (${user.userId})` : undefined], + ["Status", user ? "Authenticated" : "Authentication failed"], + ["Token", opts.showToken ? getToken(space.auth) : undefined], + ]); + } + + consola.log(""); + }); -export { commandUsage, status }; +export default status; diff --git a/apps/cli/src/commands/auth/switch.test.ts b/apps/cli/src/commands/auth/switch.test.ts index 1b0c62ad..b3b04218 100644 --- a/apps/cli/src/commands/auth/switch.test.ts +++ b/apps/cli/src/commands/auth/switch.test.ts @@ -22,10 +22,8 @@ describe("auth switch", () => { aliases: {}, }); - const { switchSpace } = await import("./switch"); - await switchSpace.run?.({ - args: { space: "example.backlog.com" }, - } as never); + const { default: switchSpace } = await import("./switch"); + await switchSpace.parseAsync(["--space", "example.backlog.com"], { from: "user" }); expect(writeConfig).toHaveBeenCalledWith( expect.objectContaining({ @@ -42,11 +40,9 @@ describe("auth switch", () => { aliases: {}, }); - const { switchSpace } = await import("./switch"); + const { default: switchSpace } = await import("./switch"); await expect( - switchSpace.run?.({ - args: { space: "missing.backlog.com" }, - } as never), + switchSpace.parseAsync(["--space", "missing.backlog.com"], { from: "user" }), ).rejects.toThrow(); }); @@ -62,10 +58,8 @@ describe("auth switch", () => { aliases: {}, }); - const { switchSpace } = await import("./switch"); - await switchSpace.run?.({ - args: { space: "target.backlog.com" }, - } as never); + const { default: switchSpace } = await import("./switch"); + await switchSpace.parseAsync(["--space", "target.backlog.com"], { from: "user" }); expect(writeConfig).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/apps/cli/src/commands/auth/switch.ts b/apps/cli/src/commands/auth/switch.ts index 3f53fa40..9fa04134 100644 --- a/apps/cli/src/commands/auth/switch.ts +++ b/apps/cli/src/commands/auth/switch.ts @@ -1,13 +1,14 @@ import { UserError } from "@repo/cli-utils"; import { loadConfig, writeConfig } from "@repo/config"; -import { defineCommand } from "citty"; import consola from "consola"; -import { type CommandUsage, withUsage } from "../../lib/command-usage"; +import { BeeCommand } from "../../lib/bee-command"; const isNoInput = (): boolean => process.env.BACKLOG_NO_INPUT === "1"; -const commandUsage: CommandUsage = { - long: `Switch the active (default) Backlog space. +const switchSpace = new BeeCommand("switch") + .summary("Switch active space") + .description( + `Switch the active (default) Backlog space. Changes which space is used by default when running commands without \`--space\`. @@ -16,78 +17,57 @@ If multiple spaces are configured, you will be prompted to select one interactively. Use \`--space\` to switch directly without a prompt. For a list of configured spaces, see \`bee auth status\`.`, - - examples: [ + ) + .option("-s, --space <hostname>", "The hostname of the Backlog space") + .envVars([ + ["BACKLOG_SPACE", "Space hostname to switch to"], + ["BACKLOG_NO_INPUT", "Set to 1 to disable interactive prompts"], + ]) + .examples([ { description: "Select space via prompt", command: "bee auth switch" }, { description: "Switch to a specific space", command: "bee auth switch -s xxx.backlog.com", }, - ], - - annotations: { - environment: [ - ["BACKLOG_SPACE", "Space hostname to switch to"], - ["BACKLOG_NO_INPUT", "Set to 1 to disable interactive prompts"], - ], - }, -}; + ]) + .action(async (opts) => { + const config = loadConfig(); -const switchSpace = withUsage( - defineCommand({ - meta: { - name: "switch", - description: "Switch active space", - }, - args: { - space: { - type: "string", - alias: "s", - description: "The hostname of the Backlog space", - valueHint: "<xxx.backlog.com>", - }, - }, - async run({ args }) { - const config = loadConfig(); + let hostname = opts.space || process.env.BACKLOG_SPACE; - let hostname = args.space || process.env.BACKLOG_SPACE; - - if (!hostname) { - if (config.spaces.length === 0) { - throw new UserError("No spaces configured. Run `bee auth login` to add a space."); - } + if (!hostname) { + if (config.spaces.length === 0) { + throw new UserError("No spaces configured. Run `bee auth login` to add a space."); + } - if (isNoInput()) { - throw new UserError( - "Hostname is required. Use --space to provide it in BACKLOG_NO_INPUT mode.", - ); - } + if (isNoInput()) { + throw new UserError( + "Hostname is required. Use --space to provide it in BACKLOG_NO_INPUT mode.", + ); + } - const hosts = config.spaces.map((s) => s.host); - hostname = await consola.prompt("Select space:", { - type: "select", - options: hosts, - }); + const hosts = config.spaces.map((s) => s.host); + hostname = await consola.prompt("Select space:", { + type: "select", + options: hosts, + }); - if (typeof hostname !== "string" || !hostname) { - throw new UserError("No space selected."); - } + if (typeof hostname !== "string" || !hostname) { + throw new UserError("No space selected."); } + } - const space = config.spaces.find((s) => s.host === hostname); + const space = config.spaces.find((s) => s.host === hostname); - if (!space) { - throw new UserError( - `Space "${hostname}" not found. Available spaces: ${config.spaces.map((s) => s.host).join(", ")}`, - ); - } + if (!space) { + throw new UserError( + `Space "${hostname}" not found. Available spaces: ${config.spaces.map((s) => s.host).join(", ")}`, + ); + } - writeConfig({ ...config, defaultSpace: hostname }); + writeConfig({ ...config, defaultSpace: hostname }); - consola.success(`Switched active space to ${hostname}.`); - }, - }), - commandUsage, -); + consola.success(`Switched active space to ${hostname}.`); + }); -export { commandUsage, switchSpace }; +export default switchSpace; diff --git a/apps/cli/src/commands/auth/token.test.ts b/apps/cli/src/commands/auth/token.test.ts index c8959fe2..b0351762 100644 --- a/apps/cli/src/commands/auth/token.test.ts +++ b/apps/cli/src/commands/auth/token.test.ts @@ -16,8 +16,8 @@ describe("auth token", () => { const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); try { - const { token } = await import("./token"); - token.run?.({ args: {} } as never); + const { default: token } = await import("./token"); + await token.parseAsync([], { from: "user" }); expect(stdoutSpy).toHaveBeenCalledWith("my-api-key"); } finally { @@ -33,8 +33,8 @@ describe("auth token", () => { const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); try { - const { token } = await import("./token"); - token.run?.({ args: {} } as never); + const { default: token } = await import("./token"); + await token.parseAsync([], { from: "user" }); expect(stdoutSpy).toHaveBeenCalledWith("my-access-token"); } finally { @@ -45,8 +45,8 @@ describe("auth token", () => { it("throws error when no space is configured", async () => { vi.mocked(resolveSpace).mockReturnValue(null); - const { token } = await import("./token"); - expect(() => token.run?.({ args: {} } as never)).toThrow( + const { default: token } = await import("./token"); + await expect(token.parseAsync([], { from: "user" })).rejects.toThrow( "No space configured. Run `bee auth login` to authenticate.", ); }); diff --git a/apps/cli/src/commands/auth/token.ts b/apps/cli/src/commands/auth/token.ts index 3a9c3e25..b34eac8c 100644 --- a/apps/cli/src/commands/auth/token.ts +++ b/apps/cli/src/commands/auth/token.ts @@ -1,16 +1,19 @@ import { UserError } from "@repo/cli-utils"; import { findSpace, loadConfig, resolveSpace } from "@repo/config"; -import { defineCommand } from "citty"; -import { type CommandUsage, withUsage } from "../../lib/command-usage"; +import { BeeCommand } from "../../lib/bee-command"; -const commandUsage: CommandUsage = { - long: `Print the auth token for a Backlog space to standard output. +const tokenCommand = new BeeCommand("token") + .summary("Print the auth token to stdout") + .description( + `Print the auth token for a Backlog space to standard output. Without \`--space\`, the default space is used. The token output can be used with \`BACKLOG_API_KEY\` or piped to other commands.`, - - examples: [ + ) + .option("-s, --space <hostname>", "The hostname of the Backlog space") + .envVars([["BACKLOG_SPACE", "Default space hostname"]]) + .examples([ { description: "Print token for default space", command: "bee auth token" }, { description: "Print token for specific space", @@ -21,40 +24,17 @@ The token output can be used with \`BACKLOG_API_KEY\` or piped to other commands command: 'TOKEN=$(bee auth token) && curl -H "X-Api-Key: $TOKEN" https://xxx.backlog.com/api/v2/users/myself', }, - ], - - annotations: { - environment: [["BACKLOG_SPACE", "Default space hostname"]], - }, -}; - -const tokenCommand = withUsage( - defineCommand({ - meta: { - name: "token", - description: "Print the auth token to stdout", - }, - args: { - space: { - type: "string", - alias: "s", - description: "The hostname of the Backlog space", - valueHint: "<xxx.backlog.com>", - }, - }, - run({ args }) { - const space = args.space ? findSpace(loadConfig().spaces, args.space) : resolveSpace(); + ]) + .action((opts) => { + const space = opts.space ? findSpace(loadConfig().spaces, opts.space) : resolveSpace(); - if (!space) { - throw new UserError("No space configured. Run `bee auth login` to authenticate."); - } + if (!space) { + throw new UserError("No space configured. Run `bee auth login` to authenticate."); + } - const token = space.auth.method === "api-key" ? space.auth.apiKey : space.auth.accessToken; + const token = space.auth.method === "api-key" ? space.auth.apiKey : space.auth.accessToken; - process.stdout.write(token); - }, - }), - commandUsage, -); + process.stdout.write(token); + }); -export { commandUsage, tokenCommand as token }; +export default tokenCommand; From 23b5a0c6e3f4ca5e74dec93417297fa6be26577c Mon Sep 17 00:00:00 2001 From: Ryoya Tamura <ryoya.tamura@nulab.com> Date: Sun, 8 Mar 2026 18:21:44 +0900 Subject: [PATCH 04/16] refactor(cli): migrate issue commands to commander Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --- .../src/commands/issue/attachments.test.ts | 12 +- apps/cli/src/commands/issue/attachments.ts | 100 +++----- apps/cli/src/commands/issue/close.test.ts | 20 +- apps/cli/src/commands/issue/close.ts | 97 +++---- apps/cli/src/commands/issue/comment.test.ts | 64 +++-- apps/cli/src/commands/issue/comment.ts | 219 +++++++--------- apps/cli/src/commands/issue/count.test.ts | 18 +- apps/cli/src/commands/issue/count.ts | 192 ++++++-------- apps/cli/src/commands/issue/create.test.ts | 121 +++++---- apps/cli/src/commands/issue/create.ts | 178 +++++-------- apps/cli/src/commands/issue/delete.test.ts | 16 +- apps/cli/src/commands/issue/delete.ts | 81 +++--- apps/cli/src/commands/issue/edit.test.ts | 51 ++-- apps/cli/src/commands/issue/edit.ts | 191 +++++--------- apps/cli/src/commands/issue/list.test.ts | 28 +-- apps/cli/src/commands/issue/list.ts | 237 ++++++++---------- apps/cli/src/commands/issue/reopen.test.ts | 16 +- apps/cli/src/commands/issue/reopen.ts | 83 +++--- apps/cli/src/commands/issue/status.test.ts | 12 +- apps/cli/src/commands/issue/status.ts | 102 ++++---- apps/cli/src/commands/issue/view.test.ts | 24 +- apps/cli/src/commands/issue/view.ts | 185 ++++++-------- 22 files changed, 854 insertions(+), 1193 deletions(-) diff --git a/apps/cli/src/commands/issue/attachments.test.ts b/apps/cli/src/commands/issue/attachments.test.ts index 906d5840..5cefef31 100644 --- a/apps/cli/src/commands/issue/attachments.test.ts +++ b/apps/cli/src/commands/issue/attachments.test.ts @@ -29,8 +29,8 @@ describe("issue attachments", () => { created: "2025-01-01T00:00:00Z", }, ]); - const { attachments } = await import("./attachments"); - await attachments.run?.({ args: { issue: "TEST-1" } } as never); + const { default: attachments } = await import("./attachments"); + await attachments.parseAsync(["TEST-1"], { from: "user" }); expect(mockClient.getIssueAttachments).toHaveBeenCalledWith("TEST-1"); expect(printTable).toHaveBeenCalledWith( expect.arrayContaining([ @@ -41,8 +41,8 @@ describe("issue attachments", () => { it("shows message when no attachments found", async () => { mockClient.getIssueAttachments.mockResolvedValue([]); - const { attachments } = await import("./attachments"); - await attachments.run?.({ args: { issue: "TEST-1" } } as never); + const { default: attachments } = await import("./attachments"); + await attachments.parseAsync(["TEST-1"], { from: "user" }); expect(consola.info).toHaveBeenCalledWith("No attachments found."); }); @@ -57,8 +57,8 @@ describe("issue attachments", () => { }, ]); await expectStdoutContaining(async () => { - const { attachments } = await import("./attachments"); - await attachments.run?.({ args: { issue: "TEST-1", json: "" } } as never); + const { default: attachments } = await import("./attachments"); + await attachments.parseAsync(["TEST-1", "--json"], { from: "user" }); }, "file.png"); }); }); diff --git a/apps/cli/src/commands/issue/attachments.ts b/apps/cli/src/commands/issue/attachments.ts index 6d5b072b..83e4c3e4 100644 --- a/apps/cli/src/commands/issue/attachments.ts +++ b/apps/cli/src/commands/issue/attachments.ts @@ -1,70 +1,44 @@ import { getClient } from "@repo/backlog-utils"; -import { - type Row, - formatDate, - formatSize, - outputArgs, - outputResult, - printTable, -} from "@repo/cli-utils"; -import { defineCommand } from "citty"; +import { type Row, formatDate, formatSize, outputResult, printTable } from "@repo/cli-utils"; import consola from "consola"; -import { type CommandUsage, ENV_AUTH, withUsage } from "../../lib/command-usage"; +import { BeeCommand, ENV_AUTH } from "../../lib/bee-command"; +import * as opt from "../../lib/common-options"; -const commandUsage: CommandUsage = { - long: `List attachments of a Backlog issue. +const attachments = new BeeCommand("attachments") + .summary("List issue attachments") + .description( + `List attachments of a Backlog issue. Shows file name, size, creator, and creation date.`, - - examples: [ + ) + .argument("<issue>", "Issue ID or issue key") + .addOption(opt.json()) + .envVars([...ENV_AUTH]) + .examples([ { description: "List attachments", command: "bee issue attachments PROJECT-123" }, { description: "Output as JSON", command: "bee issue attachments PROJECT-123 --json" }, - ], - - annotations: { - environment: [...ENV_AUTH], - }, -}; - -const attachments = withUsage( - defineCommand({ - meta: { - name: "attachments", - description: "List issue attachments", - }, - args: { - ...outputArgs, - issue: { - type: "positional", - description: "Issue ID or issue key", - valueHint: "<PROJECT-123>", - required: true, - }, - }, - async run({ args }) { - const { client } = await getClient(); - - const files = await client.getIssueAttachments(args.issue); - - outputResult(files, args, (data) => { - if (data.length === 0) { - consola.info("No attachments found."); - return; - } - - const rows: Row[] = data.map((file) => [ - { header: "ID", value: String(file.id) }, - { header: "NAME", value: file.name }, - { header: "SIZE", value: formatSize(file.size) }, - { header: "CREATED BY", value: file.createdUser?.name ?? "Unknown" }, - { header: "CREATED", value: formatDate(file.created) }, - ]); - - printTable(rows); - }); - }, - }), - commandUsage, -); - -export { commandUsage, attachments }; + ]) + .action(async (issue, opts) => { + const { client } = await getClient(); + + const files = await client.getIssueAttachments(issue); + + outputResult(files, opts as { json?: string }, (data) => { + if (data.length === 0) { + consola.info("No attachments found."); + return; + } + + const rows: Row[] = data.map((file) => [ + { header: "ID", value: String(file.id) }, + { header: "NAME", value: file.name }, + { header: "SIZE", value: formatSize(file.size) }, + { header: "CREATED BY", value: file.createdUser?.name ?? "Unknown" }, + { header: "CREATED", value: formatDate(file.created) }, + ]); + + printTable(rows); + }); + }); + +export default attachments; diff --git a/apps/cli/src/commands/issue/close.test.ts b/apps/cli/src/commands/issue/close.test.ts index f257d1cb..602e2f95 100644 --- a/apps/cli/src/commands/issue/close.test.ts +++ b/apps/cli/src/commands/issue/close.test.ts @@ -17,8 +17,8 @@ describe("issue close", () => { it("closes an issue with default resolution", async () => { mockClient.patchIssue.mockResolvedValue({ issueKey: "TEST-1", summary: "Title" }); - const { close } = await import("./close"); - await close.run?.({ args: { issue: "TEST-1" } } as never); + const { default: close } = await import("./close"); + await close.parseAsync(["TEST-1"], { from: "user" }); expect(mockClient.patchIssue).toHaveBeenCalledWith("TEST-1", { statusId: 4, @@ -32,8 +32,8 @@ describe("issue close", () => { it("closes an issue with a comment", async () => { mockClient.patchIssue.mockResolvedValue({ issueKey: "TEST-1", summary: "Title" }); - const { close } = await import("./close"); - await close.run?.({ args: { issue: "TEST-1", comment: "Done" } } as never); + const { default: close } = await import("./close"); + await close.parseAsync(["TEST-1", "--comment", "Done"], { from: "user" }); expect(mockClient.patchIssue).toHaveBeenCalledWith( "TEST-1", @@ -44,8 +44,8 @@ describe("issue close", () => { it("closes an issue with a named resolution", async () => { mockClient.patchIssue.mockResolvedValue({ issueKey: "TEST-1", summary: "Title" }); - const { close } = await import("./close"); - await close.run?.({ args: { issue: "TEST-1", resolution: "duplicate" } } as never); + const { default: close } = await import("./close"); + await close.parseAsync(["TEST-1", "--resolution", "duplicate"], { from: "user" }); expect(mockClient.patchIssue).toHaveBeenCalledWith( "TEST-1", @@ -56,8 +56,8 @@ describe("issue close", () => { it("closes an issue with notified users", async () => { mockClient.patchIssue.mockResolvedValue({ issueKey: "TEST-1", summary: "Title" }); - const { close } = await import("./close"); - await close.run?.({ args: { issue: "TEST-1", notify: "111,222" } } as never); + const { default: close } = await import("./close"); + await close.parseAsync(["TEST-1", "--notify", "111", "--notify", "222"], { from: "user" }); expect(mockClient.patchIssue).toHaveBeenCalledWith( "TEST-1", @@ -69,8 +69,8 @@ describe("issue close", () => { mockClient.patchIssue.mockResolvedValue({ issueKey: "TEST-1", summary: "Title" }); await expectStdoutContaining(async () => { - const { close } = await import("./close"); - await close.run?.({ args: { issue: "TEST-1", json: "" } } as never); + const { default: close } = await import("./close"); + await close.parseAsync(["TEST-1", "--json"], { from: "user" }); }, "TEST-1"); }); }); diff --git a/apps/cli/src/commands/issue/close.ts b/apps/cli/src/commands/issue/close.ts index b63717c9..a3b916e5 100644 --- a/apps/cli/src/commands/issue/close.ts +++ b/apps/cli/src/commands/issue/close.ts @@ -1,19 +1,26 @@ import { IssueStatusId, RESOLUTION_NAMES, ResolutionId, getClient } from "@repo/backlog-utils"; -import { outputArgs, outputResult, splitArg } from "@repo/cli-utils"; -import { defineCommand } from "citty"; +import { outputResult } from "@repo/cli-utils"; import consola from "consola"; -import * as v from "valibot"; -import { type CommandUsage, ENV_AUTH, withUsage } from "../../lib/command-usage"; +import { BeeCommand, ENV_AUTH } from "../../lib/bee-command"; +import * as opt from "../../lib/common-options"; -const commandUsage: CommandUsage = { - long: `Close a Backlog issue by setting its status to \`Closed\`. +const close = new BeeCommand("close") + .summary("Close an issue") + .description( + `Close a Backlog issue by setting its status to \`Closed\`. By default the resolution is set to \`Fixed\`. Use \`--resolution\` to specify a different resolution. Optionally add a comment with \`--comment\`.`, - - examples: [ + ) + .argument("<issue>", "Issue ID or issue key") + .option("-c, --comment <text>", "Comment to add when closing") + .option("--resolution <name>", `Resolution`) + .addOption(opt.notify()) + .addOption(opt.json()) + .envVars([...ENV_AUTH]) + .examples([ { description: "Close an issue", command: "bee issue close PROJECT-123" }, { description: "Close with a comment", @@ -23,64 +30,26 @@ Optionally add a comment with \`--comment\`.`, description: "Close as duplicate", command: "bee issue close PROJECT-123 --resolution duplicate", }, - ], - - annotations: { - environment: [...ENV_AUTH], - }, -}; - -const close = withUsage( - defineCommand({ - meta: { - name: "close", - description: "Close an issue", - }, - args: { - ...outputArgs, - issue: { - type: "positional", - description: "Issue ID or issue key", - valueHint: "<PROJECT-123>", - required: true, - }, - comment: { - type: "string", - alias: "c", - description: "Comment to add when closing", - }, - resolution: { - type: "string", - description: "Resolution", - valueHint: `{${RESOLUTION_NAMES.join("|")}}`, - }, - notify: { - type: "string", - description: "User IDs to notify (comma-separated for multiple)", - }, - }, - async run({ args }) { - const { client } = await getClient(); + ]) + .action(async (issue, opts) => { + const { client } = await getClient(); - const resolutionId = args.resolution - ? (ResolutionId[args.resolution] ?? Number(args.resolution)) - : ResolutionId.fixed; + const resolutionId = opts.resolution + ? (ResolutionId[opts.resolution] ?? Number(opts.resolution)) + : ResolutionId.fixed; - const notifiedUserId = splitArg(args.notify, v.number()); + const notifiedUserId = (opts.notify as number[]) ?? []; - const issue = await client.patchIssue(args.issue, { - statusId: IssueStatusId.Closed, - resolutionId, - comment: args.comment, - notifiedUserId, - }); + const issueData = await client.patchIssue(issue, { + statusId: IssueStatusId.Closed, + resolutionId, + comment: opts.comment, + notifiedUserId, + }); - outputResult(issue, args, (data) => { - consola.success(`Closed issue ${data.issueKey}: ${data.summary}`); - }); - }, - }), - commandUsage, -); + outputResult(issueData, opts as { json?: string }, (data) => { + consola.success(`Closed issue ${data.issueKey}: ${data.summary}`); + }); + }); -export { commandUsage, close }; +export default close; diff --git a/apps/cli/src/commands/issue/comment.test.ts b/apps/cli/src/commands/issue/comment.test.ts index 57fccddf..71c31840 100644 --- a/apps/cli/src/commands/issue/comment.test.ts +++ b/apps/cli/src/commands/issue/comment.test.ts @@ -28,8 +28,8 @@ describe("issue comment", () => { it("adds a comment to an issue", async () => { mockClient.postIssueComments.mockResolvedValue({ id: 1, content: "Hello" }); - const { comment } = await import("./comment"); - await comment.run?.({ args: { issue: "TEST-1", body: "Hello" } } as never); + const { default: comment } = await import("./comment"); + await comment.parseAsync(["TEST-1", "--body", "Hello"], { from: "user" }); expect(mockClient.postIssueComments).toHaveBeenCalledWith("TEST-1", { content: "Hello", @@ -42,8 +42,8 @@ describe("issue comment", () => { vi.mocked(resolveStdinArg).mockResolvedValueOnce("Stdin content"); mockClient.postIssueComments.mockResolvedValue({ id: 2, content: "Stdin content" }); - const { comment } = await import("./comment"); - await comment.run?.({ args: { issue: "TEST-1", body: "" } } as never); + const { default: comment } = await import("./comment"); + await comment.parseAsync(["TEST-1", "--body", ""], { from: "user" }); expect(resolveStdinArg).toHaveBeenCalledWith(""); expect(mockClient.postIssueComments).toHaveBeenCalledWith("TEST-1", { @@ -55,8 +55,10 @@ describe("issue comment", () => { it("adds a comment with notified users", async () => { mockClient.postIssueComments.mockResolvedValue({ id: 3, content: "FYI" }); - const { comment } = await import("./comment"); - await comment.run?.({ args: { issue: "TEST-1", body: "FYI", notify: "111,222" } } as never); + const { default: comment } = await import("./comment"); + await comment.parseAsync(["TEST-1", "--body", "FYI", "--notify", "111", "--notify", "222"], { + from: "user", + }); expect(mockClient.postIssueComments).toHaveBeenCalledWith("TEST-1", { content: "FYI", @@ -68,8 +70,8 @@ describe("issue comment", () => { mockClient.postIssueComments.mockResolvedValue({ id: 1, content: "Hello" }); await expectStdoutContaining(async () => { - const { comment } = await import("./comment"); - await comment.run?.({ args: { issue: "TEST-1", body: "Hello", json: "" } } as never); + const { default: comment } = await import("./comment"); + await comment.parseAsync(["TEST-1", "--body", "Hello", "--json"], { from: "user" }); }, "Hello"); }); @@ -78,8 +80,8 @@ describe("issue comment", () => { { id: 1, content: "Hello", createdUser: { name: "Alice" }, created: "2025-01-01T00:00:00Z" }, ]); - const { comment } = await import("./comment"); - await comment.run?.({ args: { issue: "TEST-1", list: true } } as never); + const { default: comment } = await import("./comment"); + await comment.parseAsync(["TEST-1", "--list"], { from: "user" }); expect(mockClient.getIssueComments).toHaveBeenCalledWith("TEST-1", { order: "asc" }); expect(printTable).toHaveBeenCalled(); @@ -88,8 +90,8 @@ describe("issue comment", () => { it("shows message when no comments found with --list", async () => { mockClient.getIssueComments.mockResolvedValue([]); - const { comment } = await import("./comment"); - await comment.run?.({ args: { issue: "TEST-1", list: true } } as never); + const { default: comment } = await import("./comment"); + await comment.parseAsync(["TEST-1", "--list"], { from: "user" }); expect(consola.info).toHaveBeenCalledWith("No comments found."); }); @@ -102,10 +104,8 @@ describe("issue comment", () => { mockClient.getMyself.mockResolvedValue({ id: 1 }); mockClient.patchIssueComment.mockResolvedValue({ id: 20, content: "Updated" }); - const { comment } = await import("./comment"); - await comment.run?.({ - args: { issue: "TEST-1", "edit-last": true, body: "Updated" }, - } as never); + const { default: comment } = await import("./comment"); + await comment.parseAsync(["TEST-1", "--edit-last", "--body", "Updated"], { from: "user" }); expect(mockClient.patchIssueComment).toHaveBeenCalledWith("TEST-1", 20, { content: "Updated", @@ -119,10 +119,8 @@ describe("issue comment", () => { ]); mockClient.getMyself.mockResolvedValue({ id: 1 }); - const { comment } = await import("./comment"); - await comment.run?.({ - args: { issue: "TEST-1", "edit-last": true, body: "Updated" }, - } as never); + const { default: comment } = await import("./comment"); + await comment.parseAsync(["TEST-1", "--edit-last", "--body", "Updated"], { from: "user" }); expect(consola.error).toHaveBeenCalledWith("No comment by you was found on TEST-1."); }); @@ -135,8 +133,8 @@ describe("issue comment", () => { vi.mocked(confirmOrExit).mockResolvedValue(true); mockClient.deleteIssueComment.mockResolvedValue({ id: 20 }); - const { comment } = await import("./comment"); - await comment.run?.({ args: { issue: "TEST-1", "delete-last": true } } as never); + const { default: comment } = await import("./comment"); + await comment.parseAsync(["TEST-1", "--delete-last"], { from: "user" }); expect(confirmOrExit).toHaveBeenCalled(); expect(mockClient.deleteIssueComment).toHaveBeenCalledWith("TEST-1", 20); @@ -151,8 +149,8 @@ describe("issue comment", () => { vi.mocked(confirmOrExit).mockResolvedValue(true); mockClient.deleteIssueComment.mockResolvedValue({ id: 20 }); - const { comment } = await import("./comment"); - await comment.run?.({ args: { issue: "TEST-1", "delete-last": true, yes: true } } as never); + const { default: comment } = await import("./comment"); + await comment.parseAsync(["TEST-1", "--delete-last", "--yes"], { from: "user" }); expect(confirmOrExit).toHaveBeenCalledWith( "Are you sure you want to delete your comment on TEST-1?", @@ -167,8 +165,8 @@ describe("issue comment", () => { mockClient.getMyself.mockResolvedValue({ id: 1 }); vi.mocked(confirmOrExit).mockResolvedValue(false); - const { comment } = await import("./comment"); - await comment.run?.({ args: { issue: "TEST-1", "delete-last": true } } as never); + const { default: comment } = await import("./comment"); + await comment.parseAsync(["TEST-1", "--delete-last"], { from: "user" }); expect(mockClient.deleteIssueComment).not.toHaveBeenCalled(); }); @@ -176,8 +174,8 @@ describe("issue comment", () => { it("shows error when body is missing for add comment", async () => { vi.mocked(resolveStdinArg).mockResolvedValueOnce(undefined); - const { comment } = await import("./comment"); - await comment.run?.({ args: { issue: "TEST-1" } } as never); + const { default: comment } = await import("./comment"); + await comment.parseAsync(["TEST-1"], { from: "user" }); expect(consola.error).toHaveBeenCalledWith( "Comment body is required. Use --body or pipe input.", @@ -192,10 +190,8 @@ describe("issue comment", () => { ]); vi.mocked(resolveStdinArg).mockResolvedValueOnce(undefined); - const { comment } = await import("./comment"); - await comment.run?.({ - args: { issue: "TEST-1", "edit-last": true }, - } as never); + const { default: comment } = await import("./comment"); + await comment.parseAsync(["TEST-1", "--edit-last"], { from: "user" }); expect(consola.error).toHaveBeenCalledWith( "Comment body is required. Use --body or pipe input.", @@ -209,8 +205,8 @@ describe("issue comment", () => { { id: 10, content: "Other", createdUser: { id: 999 } }, ]); - const { comment } = await import("./comment"); - await comment.run?.({ args: { issue: "TEST-1", "delete-last": true } } as never); + const { default: comment } = await import("./comment"); + await comment.parseAsync(["TEST-1", "--delete-last"], { from: "user" }); expect(consola.error).toHaveBeenCalledWith("No comment by you was found on TEST-1."); expect(mockClient.deleteIssueComment).not.toHaveBeenCalled(); diff --git a/apps/cli/src/commands/issue/comment.ts b/apps/cli/src/commands/issue/comment.ts index f007ca4b..ff58c7fe 100644 --- a/apps/cli/src/commands/issue/comment.ts +++ b/apps/cli/src/commands/issue/comment.ts @@ -3,20 +3,18 @@ import { type Row, confirmOrExit, formatDate, - outputArgs, outputResult, printTable, resolveStdinArg, - splitArg, } from "@repo/cli-utils"; -import { defineCommand } from "citty"; import consola from "consola"; -import * as v from "valibot"; -import { type CommandUsage, ENV_AUTH, withUsage } from "../../lib/command-usage"; -import * as commonArgs from "../../lib/common-args"; +import { BeeCommand, ENV_AUTH } from "../../lib/bee-command"; +import * as opt from "../../lib/common-options"; -const commandUsage: CommandUsage = { - long: `Add a comment to a Backlog issue. +const comment = new BeeCommand("comment") + .summary("Add a comment to an issue") + .description( + `Add a comment to a Backlog issue. The comment body is required when adding a comment. When input is piped, it is used as the body automatically. @@ -24,8 +22,17 @@ it is used as the body automatically. Use \`--list\` to list all comments on an issue. Use \`--edit-last\` to edit your most recent comment. Use \`--delete-last\` to delete your most recent comment.`, - - examples: [ + ) + .argument("<issue>", "Issue ID or issue key") + .option("-b, --body <text>", "Comment body") + .addOption(opt.notify()) + .option("--list", "List comments on the issue") + .option("--edit-last", "Edit your most recent comment") + .option("--delete-last", "Delete your most recent comment") + .option("--yes", "Skip confirmation prompt for delete") + .addOption(opt.json()) + .envVars([...ENV_AUTH]) + .examples([ { description: "Add a comment", command: 'bee issue comment PROJECT-123 -b "This is a comment"', @@ -43,144 +50,98 @@ Use \`--delete-last\` to delete your most recent comment.`, description: "Delete your last comment", command: "bee issue comment PROJECT-123 --delete-last --yes", }, - ], - - annotations: { - environment: [...ENV_AUTH], - }, -}; - -const comment = withUsage( - defineCommand({ - meta: { - name: "comment", - description: "Add a comment to an issue", - }, - args: { - ...outputArgs, - issue: { - type: "positional", - description: "Issue ID or issue key", - valueHint: "<PROJECT-123>", - required: true, - }, - body: { - type: "string", - alias: "b", - description: "Comment body", - }, - notify: commonArgs.notify, - list: { - type: "boolean", - description: "List comments on the issue", - }, - "edit-last": { - type: "boolean", - description: "Edit your most recent comment", - }, - "delete-last": { - type: "boolean", - description: "Delete your most recent comment", - }, - yes: { - type: "boolean", - description: "Skip confirmation prompt for delete", - }, - }, - async run({ args }) { - const { client } = await getClient(); - - if (args.list) { - const comments = await client.getIssueComments(args.issue, { order: "asc" }); - - outputResult(comments, args, (data) => { - const filtered = data.filter((c) => c.content); - if (filtered.length === 0) { - consola.info("No comments found."); - return; - } - - const rows: Row[] = filtered.map((c) => [ - { header: "ID", value: String(c.id) }, - { header: "AUTHOR", value: c.createdUser.name }, - { header: "DATE", value: formatDate(c.created) }, - { header: "CONTENT", value: c.content! }, - ]); - - printTable(rows); - }); - return; - } + ]) + .action(async (issue, opts) => { + const { client } = await getClient(); - if (args["edit-last"]) { - const myself = await client.getMyself(); - const comments = await client.getIssueComments(args.issue, { order: "desc" }); - const myComment = comments.find((c) => c.createdUser.id === myself.id); + if (opts.list) { + const comments = await client.getIssueComments(issue, { order: "asc" }); - if (!myComment) { - consola.error(`No comment by you was found on ${args.issue}.`); + outputResult(comments, opts as { json?: string }, (data) => { + const filtered = data.filter((c) => c.content); + if (filtered.length === 0) { + consola.info("No comments found."); return; } - const content = (await resolveStdinArg(args.body)) ?? args.body; - if (!content) { - consola.error("Comment body is required. Use --body or pipe input."); - return; - } + const rows: Row[] = filtered.map((c) => [ + { header: "ID", value: String(c.id) }, + { header: "AUTHOR", value: c.createdUser.name }, + { header: "DATE", value: formatDate(c.created) }, + { header: "CONTENT", value: c.content! }, + ]); + + printTable(rows); + }); + return; + } - const result = await client.patchIssueComment(args.issue, myComment.id, { content }); + if (opts.editLast) { + const myself = await client.getMyself(); + const comments = await client.getIssueComments(issue, { order: "desc" }); + const myComment = comments.find((c) => c.createdUser.id === myself.id); - outputResult(result, args, () => { - consola.success(`Updated comment on ${args.issue}`); - }); + if (!myComment) { + consola.error(`No comment by you was found on ${issue}.`); return; } - if (args["delete-last"]) { - const myself = await client.getMyself(); - const comments = await client.getIssueComments(args.issue, { order: "desc" }); - const myComment = comments.find((c) => c.createdUser.id === myself.id); + const content = (await resolveStdinArg(opts.body)) ?? opts.body; + if (!content) { + consola.error("Comment body is required. Use --body or pipe input."); + return; + } - if (!myComment) { - consola.error(`No comment by you was found on ${args.issue}.`); - return; - } + const result = await client.patchIssueComment(issue, myComment.id, { content }); - const confirmed = await confirmOrExit( - `Are you sure you want to delete your comment on ${args.issue}?`, - args.yes, - ); - if (!confirmed) { - return; - } + outputResult(result, opts as { json?: string }, () => { + consola.success(`Updated comment on ${issue}`); + }); + return; + } - const result = await client.deleteIssueComment(args.issue, myComment.id); + if (opts.deleteLast) { + const myself = await client.getMyself(); + const comments = await client.getIssueComments(issue, { order: "desc" }); + const myComment = comments.find((c) => c.createdUser.id === myself.id); - outputResult(result, args, () => { - consola.success(`Deleted comment on ${args.issue}`); - }); + if (!myComment) { + consola.error(`No comment by you was found on ${issue}.`); return; } - // Default: add comment - const content = (await resolveStdinArg(args.body)) ?? args.body; - if (!content) { - consola.error("Comment body is required. Use --body or pipe input."); + const confirmed = await confirmOrExit( + `Are you sure you want to delete your comment on ${issue}?`, + opts.yes, + ); + if (!confirmed) { return; } - const notifiedUserId = splitArg(args.notify, v.number()); - const result = await client.postIssueComments(args.issue, { - content, - notifiedUserId, - }); + const result = await client.deleteIssueComment(issue, myComment.id); - outputResult(result, args, () => { - consola.success(`Added comment to ${args.issue}`); + outputResult(result, opts as { json?: string }, () => { + consola.success(`Deleted comment on ${issue}`); }); - }, - }), - commandUsage, -); - -export { commandUsage, comment }; + return; + } + + // Default: add comment + const content = (await resolveStdinArg(opts.body)) ?? opts.body; + if (!content) { + consola.error("Comment body is required. Use --body or pipe input."); + return; + } + const notifiedUserId = (opts.notify as number[]) ?? []; + + const result = await client.postIssueComments(issue, { + content, + notifiedUserId, + }); + + outputResult(result, opts as { json?: string }, () => { + consola.success(`Added comment to ${issue}`); + }); + }); + +export default comment; diff --git a/apps/cli/src/commands/issue/count.test.ts b/apps/cli/src/commands/issue/count.test.ts index 9cae1eb7..eca66fa7 100644 --- a/apps/cli/src/commands/issue/count.test.ts +++ b/apps/cli/src/commands/issue/count.test.ts @@ -23,8 +23,8 @@ describe("issue count", () => { it("outputs issue count", async () => { mockClient.getIssuesCount.mockResolvedValue({ count: 42 }); - const { count } = await import("./count"); - await count.run?.({ args: { project: "TEST" } } as never); + const { default: count } = await import("./count"); + await count.parseAsync(["--project", "TEST"], { from: "user" }); expect(mockClient.getIssuesCount).toHaveBeenCalled(); expect(consola.log).toHaveBeenCalledWith(42); @@ -33,8 +33,8 @@ describe("issue count", () => { it("passes filter parameters", async () => { mockClient.getIssuesCount.mockResolvedValue({ count: 5 }); - const { count } = await import("./count"); - await count.run?.({ args: { project: "TEST", keyword: "bug" } } as never); + const { default: count } = await import("./count"); + await count.parseAsync(["--project", "TEST", "--keyword", "bug"], { from: "user" }); expect(mockClient.getIssuesCount).toHaveBeenCalledWith( expect.objectContaining({ keyword: "bug" }), @@ -45,17 +45,15 @@ describe("issue count", () => { mockClient.getIssuesCount.mockResolvedValue({ count: 42 }); await expectStdoutContaining(async () => { - const { count } = await import("./count"); - await count.run?.({ args: { project: "TEST", json: "" } } as never); + const { default: count } = await import("./count"); + await count.parseAsync(["--project", "TEST", "--json"], { from: "user" }); }, "42"); }); it("throws error for unknown priority name", async () => { - const { count } = await import("./count"); + const { default: count } = await import("./count"); await expect( - count.run?.({ - args: { project: "TEST", priority: "invalid" }, - } as never), + count.parseAsync(["--project", "TEST", "--priority", "invalid"], { from: "user" }), ).rejects.toThrow('Unknown priority "invalid". Valid values: high, normal, low'); }); }); diff --git a/apps/cli/src/commands/issue/count.ts b/apps/cli/src/commands/issue/count.ts index a26c0625..bd981616 100644 --- a/apps/cli/src/commands/issue/count.ts +++ b/apps/cli/src/commands/issue/count.ts @@ -1,129 +1,97 @@ import { PRIORITY_NAMES, PriorityId, getClient, resolveProjectIds } from "@repo/backlog-utils"; -import { outputArgs, outputResult, splitArg } from "@repo/cli-utils"; -import { defineCommand } from "citty"; +import { outputResult } from "@repo/cli-utils"; import consola from "consola"; -import * as v from "valibot"; -import { type CommandUsage, ENV_AUTH, ENV_PROJECT, withUsage } from "../../lib/command-usage"; -import * as commonArgs from "../../lib/common-args"; +import { BeeCommand, ENV_AUTH, ENV_PROJECT } from "../../lib/bee-command"; +import { Option } from "commander"; +import * as opt from "../../lib/common-options"; -const commandUsage: CommandUsage = { - long: `Count issues matching the given filter criteria. +const count = new BeeCommand("count") + .summary("Count issues") + .description( + `Count issues matching the given filter criteria. Accepts the same filter flags as \`bee issue list\`. Outputs a plain number by default, or a JSON object with \`--json\`.`, - - examples: [ + ) + .addOption( + new Option( + "-p, --project <id>", + "Project ID or project key (comma-separated for multiple)", + ).env("BACKLOG_PROJECT"), + ) + .addOption(opt.assigneeList()) + .option("-S, --status <id>", "Status ID (comma-separated for multiple)") + .option("-P, --priority <name>", `Priority name (comma-separated for multiple)`) + .addOption(opt.keyword()) + .option("--created-since <date>", "Show issues created on or after this date") + .option("--created-until <date>", "Show issues created on or before this date") + .option("--updated-since <date>", "Show issues updated on or after this date") + .option("--updated-until <date>", "Show issues updated on or before this date") + .option("--due-since <date>", "Show issues due on or after this date") + .option("--due-until <date>", "Show issues due on or before this date") + .addOption(opt.json()) + .envVars([...ENV_AUTH, ENV_PROJECT]) + .examples([ { description: "Count all issues in a project", command: "bee issue count -p PROJECT" }, { description: "Count open bugs assigned to you", command: 'bee issue count -p PROJECT -a @me -k "bug"', }, { description: "Output as JSON", command: "bee issue count -p PROJECT --json" }, - ], - - annotations: { - environment: [...ENV_AUTH, ENV_PROJECT], - }, -}; - -const count = withUsage( - defineCommand({ - meta: { - name: "count", - description: "Count issues", - }, - args: { - ...outputArgs, - project: { - ...commonArgs.project, - description: "Project ID or project key (comma-separated for multiple)", - }, - assignee: commonArgs.assigneeList, - status: { - type: "string", - alias: "S", - description: "Status ID (comma-separated for multiple)", - }, - priority: { - type: "string", - alias: "P", - description: "Priority name (comma-separated for multiple)", - valueHint: `{${PRIORITY_NAMES.join("|")}}`, - }, - keyword: commonArgs.keyword, - "created-since": { - type: "string", - description: "Show issues created on or after this date", - valueHint: "<yyyy-MM-dd>", - }, - "created-until": { - type: "string", - description: "Show issues created on or before this date", - valueHint: "<yyyy-MM-dd>", - }, - "updated-since": { - type: "string", - description: "Show issues updated on or after this date", - valueHint: "<yyyy-MM-dd>", - }, - "updated-until": { - type: "string", - description: "Show issues updated on or before this date", - valueHint: "<yyyy-MM-dd>", - }, - "due-since": { - type: "string", - description: "Show issues due on or after this date", - valueHint: "<yyyy-MM-dd>", - }, - "due-until": { - type: "string", - description: "Show issues due on or before this date", - valueHint: "<yyyy-MM-dd>", - }, - }, - async run({ args }) { - const { client } = await getClient(); + ]) + .action(async (opts) => { + const { client } = await getClient(); - const projectId = await resolveProjectIds(client, splitArg(args.project, v.string())); - const assigneeId = splitArg(args.assignee, v.number()); - const statusId = splitArg(args.status, v.number()); - const priorityId = args.priority - ? args.priority + const projectId = opts.project + ? await resolveProjectIds( + client, + (opts.project as string) .split(",") - .map((s) => s.trim()) - .filter(Boolean) - .map((name) => { - const id = PriorityId[name.toLowerCase()]; - if (id === undefined) { - throw new Error( - `Unknown priority "${name}". Valid values: ${PRIORITY_NAMES.join(", ")}`, - ); - } - return id; - }) - : []; + .map((s: string) => s.trim()) + .filter(Boolean), + ) + : []; + const assigneeId = ((opts.assignee as string[]) ?? []).map(Number); + const statusId = opts.status + ? (opts.status as string) + .split(",") + .map((s: string) => s.trim()) + .filter(Boolean) + .map(Number) + : []; + const priorityId = opts.priority + ? (opts.priority as string) + .split(",") + .map((s: string) => s.trim()) + .filter(Boolean) + .map((name: string) => { + const id = PriorityId[name.toLowerCase()]; + if (id === undefined) { + throw new Error( + `Unknown priority "${name}". Valid values: ${PRIORITY_NAMES.join(", ")}`, + ); + } + return id; + }) + : []; - const result = await client.getIssuesCount({ - projectId, - assigneeId, - statusId, - priorityId, - keyword: args.keyword, - createdSince: args["created-since"], - createdUntil: args["created-until"], - updatedSince: args["updated-since"], - updatedUntil: args["updated-until"], - dueDateSince: args["due-since"], - dueDateUntil: args["due-until"], - }); + const result = await client.getIssuesCount({ + projectId, + assigneeId, + statusId, + priorityId, + keyword: opts.keyword as string | undefined, + createdSince: opts.createdSince as string | undefined, + createdUntil: opts.createdUntil as string | undefined, + updatedSince: opts.updatedSince as string | undefined, + updatedUntil: opts.updatedUntil as string | undefined, + dueDateSince: opts.dueSince as string | undefined, + dueDateUntil: opts.dueUntil as string | undefined, + }); - outputResult(result, args, (data) => { - consola.log(data.count); - }); - }, - }), - commandUsage, -); + outputResult(result, opts as { json?: string }, (data) => { + consola.log(data.count); + }); + }); -export { commandUsage, count }; +export default count; diff --git a/apps/cli/src/commands/issue/create.test.ts b/apps/cli/src/commands/issue/create.test.ts index b00f5b18..03feb8ba 100644 --- a/apps/cli/src/commands/issue/create.test.ts +++ b/apps/cli/src/commands/issue/create.test.ts @@ -33,10 +33,11 @@ describe("issue create", () => { summary: "Fix bug", }); - const { create } = await import("./create"); - await create.run?.({ - args: { project: "100", title: "Fix bug", type: "1", priority: "normal" }, - } as never); + const { default: create } = await import("./create"); + await create.parseAsync( + ["--project", "100", "--title", "Fix bug", "--type", "1", "--priority", "normal"], + { from: "user" }, + ); expect(mockClient.postIssue).toHaveBeenCalledWith( expect.objectContaining({ @@ -60,8 +61,8 @@ describe("issue create", () => { summary: "Title", }); - const { create } = await import("./create"); - await create.run?.({ args: {} } as never); + const { default: create } = await import("./create"); + await create.parseAsync([], { from: "user" }); expect(promptRequired).toHaveBeenCalledWith("Project:", undefined); expect(promptRequired).toHaveBeenCalledWith("Summary:", undefined); @@ -82,18 +83,26 @@ describe("issue create", () => { summary: "Title", }); - const { create } = await import("./create"); - await create.run?.({ - args: { - project: "100", - title: "Title", - type: "1", - priority: "normal", - description: "Details", - assignee: "12345", - "due-date": "2025-12-31", - }, - } as never); + const { default: create } = await import("./create"); + await create.parseAsync( + [ + "--project", + "100", + "--title", + "Title", + "--type", + "1", + "--priority", + "normal", + "--description", + "Details", + "--assignee", + "12345", + "--due-date", + "2025-12-31", + ], + { from: "user" }, + ); expect(mockClient.postIssue).toHaveBeenCalledWith( expect.objectContaining({ @@ -116,16 +125,22 @@ describe("issue create", () => { summary: "Title", }); - const { create } = await import("./create"); - await create.run?.({ - args: { - project: "100", - title: "Title", - type: "1", - priority: "normal", - assignee: "@me", - }, - } as never); + const { default: create } = await import("./create"); + await create.parseAsync( + [ + "--project", + "100", + "--title", + "Title", + "--type", + "1", + "--priority", + "normal", + "--assignee", + "@me", + ], + { from: "user" }, + ); expect(mockClient.getMyself).toHaveBeenCalled(); expect(mockClient.postIssue).toHaveBeenCalledWith( @@ -144,17 +159,28 @@ describe("issue create", () => { summary: "Title", }); - const { create } = await import("./create"); - await create.run?.({ - args: { - project: "100", - title: "Title", - type: "1", - priority: "normal", - notify: "111,222", - attachment: "1,2", - }, - } as never); + const { default: create } = await import("./create"); + await create.parseAsync( + [ + "--project", + "100", + "--title", + "Title", + "--type", + "1", + "--priority", + "normal", + "--notify", + "111", + "--notify", + "222", + "--attachment", + "1", + "--attachment", + "2", + ], + { from: "user" }, + ); expect(mockClient.postIssue).toHaveBeenCalledWith( expect.objectContaining({ @@ -176,10 +202,11 @@ describe("issue create", () => { }); await expectStdoutContaining(async () => { - const { create } = await import("./create"); - await create.run?.({ - args: { project: "100", title: "Title", type: "1", priority: "normal", json: "" }, - } as never); + const { default: create } = await import("./create"); + await create.parseAsync( + ["--project", "100", "--title", "Title", "--type", "1", "--priority", "normal", "--json"], + { from: "user" }, + ); }, "TEST-4"); }); @@ -190,11 +217,11 @@ describe("issue create", () => { .mockResolvedValueOnce("1") .mockResolvedValueOnce("invalid"); - const { create } = await import("./create"); + const { default: create } = await import("./create"); await expect( - create.run?.({ - args: { project: "TEST", summary: "test", priority: "invalid" }, - } as never), + create.parseAsync(["--project", "TEST", "--title", "test", "--priority", "invalid"], { + from: "user", + }), ).rejects.toThrow('Unknown priority "invalid". Valid values: high, normal, low'); }); }); diff --git a/apps/cli/src/commands/issue/create.ts b/apps/cli/src/commands/issue/create.ts index 8d01bb1a..20000a25 100644 --- a/apps/cli/src/commands/issue/create.ts +++ b/apps/cli/src/commands/issue/create.ts @@ -5,23 +5,39 @@ import { resolveProjectIds, resolveUserId, } from "@repo/backlog-utils"; -import { outputArgs, outputResult, promptRequired, splitArg } from "@repo/cli-utils"; -import { defineCommand } from "citty"; +import { outputResult, promptRequired } from "@repo/cli-utils"; import consola from "consola"; -import * as v from "valibot"; -import { type CommandUsage, ENV_AUTH, ENV_PROJECT, withUsage } from "../../lib/command-usage"; -import * as commonArgs from "../../lib/common-args"; +import { Option } from "commander"; +import { BeeCommand, ENV_AUTH, ENV_PROJECT } from "../../lib/bee-command"; +import * as opt from "../../lib/common-options"; -const commandUsage: CommandUsage = { - long: `Create a new Backlog issue. +const create = new BeeCommand("create") + .summary("Create an issue") + .description( + `Create a new Backlog issue. Requires a project, title, issue type, and priority. When run interactively, omitted required fields will be prompted. Issue type accepts a numeric ID. Priority accepts a name: \`high\`, \`normal\`, or \`low\`.`, - - examples: [ + ) + .addOption(new Option("-p, --project <id>", "Project ID or project key").env("BACKLOG_PROJECT")) + .option("-t, --title <text>", "Issue title") + .option("-T, --type <id>", "Issue type ID") + .option("-P, --priority <name>", "Priority") + .option("-d, --description <text>", "Issue description") + .addOption(opt.assignee()) + .option("--parent-issue <id>", "Parent issue ID") + .option("--start-date <date>", "Start date") + .option("--due-date <date>", "Due date") + .option("--estimated-hours <n>", "Estimated hours") + .option("--actual-hours <n>", "Actual hours") + .addOption(opt.notify()) + .addOption(opt.attachment()) + .addOption(opt.json()) + .envVars([...ENV_AUTH, ENV_PROJECT]) + .examples([ { description: "Create an issue with required fields", command: 'bee issue create -p PROJECT --type 1 --priority normal -t "Fix login bug"', @@ -39,113 +55,47 @@ or \`low\`.`, description: "Output as JSON", command: 'bee issue create -p PROJECT --type 1 --priority normal -t "Title" --json', }, - ], - - annotations: { - environment: [...ENV_AUTH, ENV_PROJECT], - }, -}; - -const create = withUsage( - defineCommand({ - meta: { - name: "create", - description: "Create an issue", - }, - args: { - ...outputArgs, - project: commonArgs.project, - title: { - type: "string", - alias: "t", - description: "Issue title", - }, - type: { - type: "string", - alias: "T", - description: "Issue type ID", - valueHint: "<number>", - }, - priority: { - type: "string", - alias: "P", - description: "Priority", - valueHint: `{${PRIORITY_NAMES.join("|")}}`, - }, - description: { - type: "string", - alias: "d", - description: "Issue description", - }, - assignee: commonArgs.assignee, - "parent-issue": { - type: "string", - description: "Parent issue ID", - }, - "start-date": { - type: "string", - description: "Start date", - valueHint: "<yyyy-MM-dd>", - }, - "due-date": { - type: "string", - description: "Due date", - valueHint: "<yyyy-MM-dd>", - }, - "estimated-hours": { - type: "string", - description: "Estimated hours", - }, - "actual-hours": { - type: "string", - description: "Actual hours", - }, - notify: commonArgs.notify, - attachment: commonArgs.attachment, - }, - async run({ args }) { - const { client } = await getClient(); + ]) + .action(async (opts) => { + const { client } = await getClient(); - const project = await promptRequired("Project:", args.project); - const title = await promptRequired("Summary:", args.title); - const issueTypeId = await promptRequired("Issue type ID:", args.type); - const priority = await promptRequired("Priority:", args.priority, { - valueHint: `{${PRIORITY_NAMES.join("|")}}`, - }); - const priorityId = PriorityId[priority.toLowerCase()]; - if (priorityId === undefined) { - throw new Error( - `Unknown priority "${priority}". Valid values: ${PRIORITY_NAMES.join(", ")}`, - ); - } + const project = await promptRequired("Project:", opts.project as string | undefined); + const title = await promptRequired("Summary:", opts.title as string | undefined); + const issueTypeId = await promptRequired("Issue type ID:", opts.type as string | undefined); + const priority = await promptRequired("Priority:", opts.priority as string | undefined, { + valueHint: `{${PRIORITY_NAMES.join("|")}}`, + }); + const priorityId = PriorityId[priority.toLowerCase()]; + if (priorityId === undefined) { + throw new Error(`Unknown priority "${priority}". Valid values: ${PRIORITY_NAMES.join(", ")}`); + } - const [projectId] = await resolveProjectIds(client, [project]); - const assigneeId = args.assignee ? await resolveUserId(client, args.assignee) : undefined; - const notifiedUserId = splitArg(args.notify, v.number()); - const attachmentId = splitArg(args.attachment, v.number()); + const [projectId] = await resolveProjectIds(client, [project]); + const assigneeId = opts.assignee + ? await resolveUserId(client, opts.assignee as string) + : undefined; + const notifiedUserId = (opts.notify as number[]) ?? []; + const attachmentId = (opts.attachment as number[]) ?? []; - const issue = await client.postIssue({ - projectId, - summary: title, - issueTypeId: Number(issueTypeId), - priorityId, - description: args.description, - assigneeId, - parentIssueId: args["parent-issue"] ? Number(args["parent-issue"]) : undefined, - startDate: args["start-date"], - dueDate: args["due-date"], - estimatedHours: args["estimated-hours"] ? Number(args["estimated-hours"]) : undefined, - actualHours: args["actual-hours"] ? Number(args["actual-hours"]) : undefined, - notifiedUserId, - attachmentId, - }); + const issue = await client.postIssue({ + projectId, + summary: title, + issueTypeId: Number(issueTypeId), + priorityId, + description: opts.description as string | undefined, + assigneeId, + parentIssueId: opts.parentIssue ? Number(opts.parentIssue) : undefined, + startDate: opts.startDate as string | undefined, + dueDate: opts.dueDate as string | undefined, + estimatedHours: opts.estimatedHours ? Number(opts.estimatedHours) : undefined, + actualHours: opts.actualHours ? Number(opts.actualHours) : undefined, + notifiedUserId, + attachmentId, + }); - outputResult(issue, args, (data) => { - consola.success(`Created issue ${data.issueKey}: ${data.summary}`); - }); - }, - }), - commandUsage, -); + outputResult(issue, opts as { json?: string }, (data) => { + consola.success(`Created issue ${data.issueKey}: ${data.summary}`); + }); + }); -export { commandUsage, create }; +export default create; diff --git a/apps/cli/src/commands/issue/delete.test.ts b/apps/cli/src/commands/issue/delete.test.ts index 5b5218e1..03e3861d 100644 --- a/apps/cli/src/commands/issue/delete.test.ts +++ b/apps/cli/src/commands/issue/delete.test.ts @@ -23,8 +23,8 @@ describe("issue delete", () => { vi.mocked(confirmOrExit).mockResolvedValue(true); mockClient.deleteIssue.mockResolvedValue({ issueKey: "TEST-1", summary: "Title" }); - const { deleteIssue } = await import("./delete"); - await deleteIssue.run?.({ args: { issue: "TEST-1" } } as never); + const { default: deleteIssue } = await import("./delete"); + await deleteIssue.parseAsync(["TEST-1"], { from: "user" }); expect(confirmOrExit).toHaveBeenCalledWith( "Are you sure you want to delete issue TEST-1? This cannot be undone.", @@ -38,8 +38,8 @@ describe("issue delete", () => { vi.mocked(confirmOrExit).mockResolvedValue(true); mockClient.deleteIssue.mockResolvedValue({ issueKey: "TEST-1", summary: "Title" }); - const { deleteIssue } = await import("./delete"); - await deleteIssue.run?.({ args: { issue: "TEST-1", yes: true } } as never); + const { default: deleteIssue } = await import("./delete"); + await deleteIssue.parseAsync(["TEST-1", "--yes"], { from: "user" }); expect(confirmOrExit).toHaveBeenCalledWith( "Are you sure you want to delete issue TEST-1? This cannot be undone.", @@ -50,8 +50,8 @@ describe("issue delete", () => { it("cancels when user declines confirmation", async () => { vi.mocked(confirmOrExit).mockResolvedValue(false); - const { deleteIssue } = await import("./delete"); - await deleteIssue.run?.({ args: { issue: "TEST-1" } } as never); + const { default: deleteIssue } = await import("./delete"); + await deleteIssue.parseAsync(["TEST-1"], { from: "user" }); expect(mockClient.deleteIssue).not.toHaveBeenCalled(); }); @@ -61,8 +61,8 @@ describe("issue delete", () => { mockClient.deleteIssue.mockResolvedValue({ issueKey: "TEST-1", summary: "Title" }); await expectStdoutContaining(async () => { - const { deleteIssue } = await import("./delete"); - await deleteIssue.run?.({ args: { issue: "TEST-1", yes: true, json: "" } } as never); + const { default: deleteIssue } = await import("./delete"); + await deleteIssue.parseAsync(["TEST-1", "--yes", "--json"], { from: "user" }); }, "TEST-1"); }); }); diff --git a/apps/cli/src/commands/issue/delete.ts b/apps/cli/src/commands/issue/delete.ts index 0992069d..ca84b13b 100644 --- a/apps/cli/src/commands/issue/delete.ts +++ b/apps/cli/src/commands/issue/delete.ts @@ -1,16 +1,22 @@ import { getClient } from "@repo/backlog-utils"; -import { confirmOrExit, outputArgs, outputResult } from "@repo/cli-utils"; -import { defineCommand } from "citty"; +import { confirmOrExit, outputResult } from "@repo/cli-utils"; import consola from "consola"; -import { type CommandUsage, ENV_AUTH, withUsage } from "../../lib/command-usage"; +import { BeeCommand, ENV_AUTH } from "../../lib/bee-command"; +import * as opt from "../../lib/common-options"; -const commandUsage: CommandUsage = { - long: `Delete a Backlog issue. +const deleteIssue = new BeeCommand("delete") + .summary("Delete an issue") + .description( + `Delete a Backlog issue. This action is irreversible. You will be prompted for confirmation unless \`--yes\` is provided.`, - - examples: [ + ) + .argument("<issue>", "Issue ID or issue key") + .option("-y, --yes", "Skip confirmation prompt") + .addOption(opt.json()) + .envVars([...ENV_AUTH]) + .examples([ { description: "Delete an issue (with confirmation)", command: "bee issue delete PROJECT-123", @@ -19,53 +25,24 @@ This action is irreversible. You will be prompted for confirmation unless description: "Delete an issue without confirmation", command: "bee issue delete PROJECT-123 --yes", }, - ], - - annotations: { - environment: [...ENV_AUTH], - }, -}; - -const deleteIssue = withUsage( - defineCommand({ - meta: { - name: "delete", - description: "Delete an issue", - }, - args: { - ...outputArgs, - issue: { - type: "positional", - description: "Issue ID or issue key", - valueHint: "<PROJECT-123>", - required: true, - }, - yes: { - type: "boolean", - alias: "y", - description: "Skip confirmation prompt", - }, - }, - async run({ args }) { - const confirmed = await confirmOrExit( - `Are you sure you want to delete issue ${args.issue}? This cannot be undone.`, - args.yes, - ); + ]) + .action(async (issue, opts) => { + const confirmed = await confirmOrExit( + `Are you sure you want to delete issue ${issue}? This cannot be undone.`, + opts.yes, + ); - if (!confirmed) { - return; - } + if (!confirmed) { + return; + } - const { client } = await getClient(); + const { client } = await getClient(); - const issue = await client.deleteIssue(args.issue); + const issueData = await client.deleteIssue(issue); - outputResult(issue, args, (data) => { - consola.success(`Deleted issue ${data.issueKey}: ${data.summary}`); - }); - }, - }), - commandUsage, -); + outputResult(issueData, opts as { json?: string }, (data) => { + consola.success(`Deleted issue ${data.issueKey}: ${data.summary}`); + }); + }); -export { commandUsage, deleteIssue }; +export default deleteIssue; diff --git a/apps/cli/src/commands/issue/edit.test.ts b/apps/cli/src/commands/issue/edit.test.ts index f551f683..90d40137 100644 --- a/apps/cli/src/commands/issue/edit.test.ts +++ b/apps/cli/src/commands/issue/edit.test.ts @@ -17,8 +17,8 @@ describe("issue edit", () => { it("updates issue summary", async () => { mockClient.patchIssue.mockResolvedValue({ issueKey: "TEST-1", summary: "New title" }); - const { edit } = await import("./edit"); - await edit.run?.({ args: { issue: "TEST-1", title: "New title" } } as never); + const { default: edit } = await import("./edit"); + await edit.parseAsync(["TEST-1", "--title", "New title"], { from: "user" }); expect(mockClient.patchIssue).toHaveBeenCalledWith( "TEST-1", @@ -30,10 +30,10 @@ describe("issue edit", () => { it("updates assignee and priority", async () => { mockClient.patchIssue.mockResolvedValue({ issueKey: "TEST-1", summary: "Title" }); - const { edit } = await import("./edit"); - await edit.run?.({ - args: { issue: "TEST-1", assignee: "12345", priority: "high" }, - } as never); + const { default: edit } = await import("./edit"); + await edit.parseAsync(["TEST-1", "--assignee", "12345", "--priority", "high"], { + from: "user", + }); expect(mockClient.patchIssue).toHaveBeenCalledWith( "TEST-1", @@ -44,10 +44,10 @@ describe("issue edit", () => { it("passes comment with the update", async () => { mockClient.patchIssue.mockResolvedValue({ issueKey: "TEST-1", summary: "Title" }); - const { edit } = await import("./edit"); - await edit.run?.({ - args: { issue: "TEST-1", title: "New title", comment: "Updated" }, - } as never); + const { default: edit } = await import("./edit"); + await edit.parseAsync(["TEST-1", "--title", "New title", "--comment", "Updated"], { + from: "user", + }); expect(mockClient.patchIssue).toHaveBeenCalledWith( "TEST-1", @@ -58,10 +58,23 @@ describe("issue edit", () => { it("passes notifiedUserId and attachmentId to API", async () => { mockClient.patchIssue.mockResolvedValue({ issueKey: "TEST-1", summary: "Title" }); - const { edit } = await import("./edit"); - await edit.run?.({ - args: { issue: "TEST-1", title: "Title", notify: "111,222", attachment: "1,2" }, - } as never); + const { default: edit } = await import("./edit"); + await edit.parseAsync( + [ + "TEST-1", + "--title", + "Title", + "--notify", + "111", + "--notify", + "222", + "--attachment", + "1", + "--attachment", + "2", + ], + { from: "user" }, + ); expect(mockClient.patchIssue).toHaveBeenCalledWith( "TEST-1", @@ -76,17 +89,15 @@ describe("issue edit", () => { mockClient.patchIssue.mockResolvedValue({ issueKey: "TEST-1", summary: "Title" }); await expectStdoutContaining(async () => { - const { edit } = await import("./edit"); - await edit.run?.({ args: { issue: "TEST-1", title: "Title", json: "" } } as never); + const { default: edit } = await import("./edit"); + await edit.parseAsync(["TEST-1", "--title", "Title", "--json"], { from: "user" }); }, "TEST-1"); }); it("throws error for unknown priority name", async () => { - const { edit } = await import("./edit"); + const { default: edit } = await import("./edit"); await expect( - edit.run?.({ - args: { issue: "TEST-1", priority: "invalid" }, - } as never), + edit.parseAsync(["TEST-1", "--priority", "invalid"], { from: "user" }), ).rejects.toThrow('Unknown priority "invalid". Valid values: high, normal, low'); }); }); diff --git a/apps/cli/src/commands/issue/edit.ts b/apps/cli/src/commands/issue/edit.ts index 3e4a1deb..03b33438 100644 --- a/apps/cli/src/commands/issue/edit.ts +++ b/apps/cli/src/commands/issue/edit.ts @@ -1,18 +1,36 @@ import { PRIORITY_NAMES, PriorityId, getClient } from "@repo/backlog-utils"; -import { outputArgs, outputResult, splitArg } from "@repo/cli-utils"; -import { defineCommand } from "citty"; +import { outputResult } from "@repo/cli-utils"; import consola from "consola"; -import * as v from "valibot"; -import { type CommandUsage, ENV_AUTH, withUsage } from "../../lib/command-usage"; -import * as commonArgs from "../../lib/common-args"; +import { BeeCommand, ENV_AUTH } from "../../lib/bee-command"; +import * as opt from "../../lib/common-options"; -const commandUsage: CommandUsage = { - long: `Update an existing Backlog issue. +const edit = new BeeCommand("edit") + .summary("Edit an issue") + .description( + `Update an existing Backlog issue. Only the specified fields will be updated. Fields that are not provided will remain unchanged.`, - - examples: [ + ) + .argument("<issue>", "Issue ID or issue key") + .option("-t, --title <text>", "New title of the issue") + .option("-d, --description <text>", "New description of the issue") + .option("-S, --status <id>", "New status ID") + .option("-P, --priority <name>", `Change priority`) + .option("-T, --type <id>", "New issue type ID") + .option("--assignee <id>", "New assignee user ID. Use @me for yourself.") + .option("--resolution <id>", "Resolution ID") + .option("--parent-issue <id>", "New parent issue ID") + .option("--start-date <date>", "New start date") + .option("--due-date <date>", "New due date") + .option("--estimated-hours <n>", "New estimated hours") + .option("--actual-hours <n>", "New actual hours") + .addOption(opt.comment()) + .addOption(opt.notify()) + .addOption(opt.attachment()) + .addOption(opt.json()) + .envVars([...ENV_AUTH]) + .examples([ { description: "Update issue title", command: 'bee issue edit PROJECT-123 -t "New title"', @@ -25,129 +43,44 @@ will remain unchanged.`, description: "Add a comment with the update", command: 'bee issue edit PROJECT-123 -t "New title" --comment "Updated title"', }, - ], - - annotations: { - environment: [...ENV_AUTH], - }, -}; - -const edit = withUsage( - defineCommand({ - meta: { - name: "edit", - description: "Edit an issue", - }, - args: { - ...outputArgs, - issue: { - type: "positional", - description: "Issue ID or issue key", - valueHint: "<PROJECT-123>", - required: true, - }, - title: { - type: "string", - alias: "t", - description: "New title of the issue", - }, - description: { - type: "string", - alias: "d", - description: "New description of the issue", - }, - status: { - type: "string", - alias: "S", - description: "New status ID", - }, - priority: { - type: "string", - alias: "P", - description: "Change priority", - valueHint: `{${PRIORITY_NAMES.join("|")}}`, - }, - type: { - type: "string", - alias: "T", - description: "New issue type ID", - valueHint: "<number>", - }, - assignee: { - ...commonArgs.assignee, - alias: undefined, - description: "New assignee user ID. Use @me for yourself.", - }, - resolution: { - type: "string", - description: "Resolution ID", - }, - "parent-issue": { - type: "string", - description: "New parent issue ID", - }, - "start-date": { - type: "string", - description: "New start date", - valueHint: "<yyyy-MM-dd>", - }, - "due-date": { - type: "string", - description: "New due date", - valueHint: "<yyyy-MM-dd>", - }, - "estimated-hours": { - type: "string", - description: "New estimated hours", - }, - "actual-hours": { - type: "string", - description: "New actual hours", - }, - comment: commonArgs.comment, - notify: commonArgs.notify, - attachment: commonArgs.attachment, - }, - async run({ args }) { - const { client } = await getClient(); + ]) + .action(async (issue, opts) => { + const { client } = await getClient(); - const notifiedUserId = splitArg(args.notify, v.number()); - const attachmentId = splitArg(args.attachment, v.number()); + const notifiedUserId = (opts.notify as number[]) ?? []; + const attachmentId = (opts.attachment as number[]) ?? []; - let priorityId: number | undefined; - if (args.priority) { - priorityId = PriorityId[args.priority.toLowerCase()]; - if (priorityId === undefined) { - throw new Error( - `Unknown priority "${args.priority}". Valid values: ${PRIORITY_NAMES.join(", ")}`, - ); - } + let priorityId: number | undefined; + if (opts.priority) { + priorityId = PriorityId[opts.priority.toLowerCase()]; + if (priorityId === undefined) { + throw new Error( + `Unknown priority "${opts.priority}". Valid values: ${PRIORITY_NAMES.join(", ")}`, + ); } + } - const issue = await client.patchIssue(args.issue, { - summary: args.title, - description: args.description, - statusId: args.status ? Number(args.status) : undefined, - priorityId, - issueTypeId: args.type ? Number(args.type) : undefined, - assigneeId: args.assignee ? Number(args.assignee) : undefined, - resolutionId: args.resolution ? Number(args.resolution) : undefined, - parentIssueId: args["parent-issue"] ? Number(args["parent-issue"]) : undefined, - startDate: args["start-date"], - dueDate: args["due-date"], - estimatedHours: args["estimated-hours"] ? Number(args["estimated-hours"]) : undefined, - actualHours: args["actual-hours"] ? Number(args["actual-hours"]) : undefined, - comment: args.comment, - notifiedUserId, - attachmentId, - }); + const issueData = await client.patchIssue(issue, { + summary: opts.title, + description: opts.description, + statusId: opts.status ? Number(opts.status) : undefined, + priorityId, + issueTypeId: opts.type ? Number(opts.type) : undefined, + assigneeId: opts.assignee ? Number(opts.assignee) : undefined, + resolutionId: opts.resolution ? Number(opts.resolution) : undefined, + parentIssueId: opts.parentIssue ? Number(opts.parentIssue) : undefined, + startDate: opts.startDate, + dueDate: opts.dueDate, + estimatedHours: opts.estimatedHours ? Number(opts.estimatedHours) : undefined, + actualHours: opts.actualHours ? Number(opts.actualHours) : undefined, + comment: opts.comment, + notifiedUserId, + attachmentId, + }); - outputResult(issue, args, (data) => { - consola.success(`Updated issue ${data.issueKey}: ${data.summary}`); - }); - }, - }), - commandUsage, -); + outputResult(issueData, opts as { json?: string }, (data) => { + consola.success(`Updated issue ${data.issueKey}: ${data.summary}`); + }); + }); -export { commandUsage, edit }; +export default edit; diff --git a/apps/cli/src/commands/issue/list.test.ts b/apps/cli/src/commands/issue/list.test.ts index 50114f9d..4d9bc723 100644 --- a/apps/cli/src/commands/issue/list.test.ts +++ b/apps/cli/src/commands/issue/list.test.ts @@ -38,8 +38,8 @@ describe("issue list", () => { it("displays issue list in tabular format", async () => { mockClient.getIssues.mockResolvedValue(sampleIssues); - const { list } = await import("./list"); - await list.run?.({ args: {} } as never); + const { default: list } = await import("./list"); + await list.parseAsync([], { from: "user" }); expect(getClient).toHaveBeenCalled(); expect(mockClient.getIssues).toHaveBeenCalled(); @@ -51,8 +51,8 @@ describe("issue list", () => { it("shows message when no issues found", async () => { mockClient.getIssues.mockResolvedValue([]); - const { list } = await import("./list"); - await list.run?.({ args: {} } as never); + const { default: list } = await import("./list"); + await list.parseAsync([], { from: "user" }); expect(consola.info).toHaveBeenCalledWith("No issues found."); }); @@ -60,8 +60,8 @@ describe("issue list", () => { it("shows Unassigned for issues without assignee", async () => { mockClient.getIssues.mockResolvedValue([sampleIssues[1]]); - const { list } = await import("./list"); - await list.run?.({ args: {} } as never); + const { default: list } = await import("./list"); + await list.parseAsync([], { from: "user" }); expect(consola.log).toHaveBeenCalledWith(expect.stringContaining("Unassigned")); }); @@ -69,8 +69,8 @@ describe("issue list", () => { it("passes project query parameter", async () => { mockClient.getIssues.mockResolvedValue([]); - const { list } = await import("./list"); - await list.run?.({ args: { project: "123" } } as never); + const { default: list } = await import("./list"); + await list.parseAsync(["--project", "123"], { from: "user" }); expect(mockClient.getIssues).toHaveBeenCalledWith( expect.objectContaining({ projectId: [123] }), @@ -80,8 +80,8 @@ describe("issue list", () => { it("passes assignee query parameter", async () => { mockClient.getIssues.mockResolvedValue([]); - const { list } = await import("./list"); - await list.run?.({ args: { assignee: "42" } } as never); + const { default: list } = await import("./list"); + await list.parseAsync(["--assignee", "42"], { from: "user" }); expect(mockClient.getIssues).toHaveBeenCalledWith( expect.objectContaining({ assigneeId: [42] }), @@ -91,8 +91,8 @@ describe("issue list", () => { it("passes keyword query parameter", async () => { mockClient.getIssues.mockResolvedValue([]); - const { list } = await import("./list"); - await list.run?.({ args: { keyword: "login bug" } } as never); + const { default: list } = await import("./list"); + await list.parseAsync(["--keyword", "login bug"], { from: "user" }); expect(mockClient.getIssues).toHaveBeenCalledWith( expect.objectContaining({ keyword: "login bug" }), @@ -103,8 +103,8 @@ describe("issue list", () => { mockClient.getIssues.mockResolvedValue(sampleIssues); await expectStdoutContaining(async () => { - const { list } = await import("./list"); - await list.run?.({ args: { json: "" } } as never); + const { default: list } = await import("./list"); + await list.parseAsync(["--json"], { from: "user" }); }, "PROJ-1"); }); }); diff --git a/apps/cli/src/commands/issue/list.ts b/apps/cli/src/commands/issue/list.ts index b11a2add..c6c27ba4 100644 --- a/apps/cli/src/commands/issue/list.ts +++ b/apps/cli/src/commands/issue/list.ts @@ -1,21 +1,44 @@ import { PRIORITY_NAMES, PriorityId, getClient, resolveProjectIds } from "@repo/backlog-utils"; -import { type Row, outputArgs, outputResult, printTable, splitArg } from "@repo/cli-utils"; +import { type Row, outputResult, printTable } from "@repo/cli-utils"; import { type Option } from "backlog-js"; -import { defineCommand } from "citty"; import consola from "consola"; -import * as v from "valibot"; -import { type CommandUsage, ENV_AUTH, ENV_PROJECT, withUsage } from "../../lib/command-usage"; -import * as commonArgs from "../../lib/common-args"; +import { BeeCommand, ENV_AUTH, ENV_PROJECT } from "../../lib/bee-command"; +import { Option } from "commander"; +import * as opt from "../../lib/common-options"; -const commandUsage: CommandUsage = { - long: `List issues from one or more Backlog projects. +const list = new BeeCommand("list") + .summary("List issues") + .description( + `List issues from one or more Backlog projects. By default, issues are sorted by last updated date in descending order. Use filtering flags to narrow results by assignee, status, priority, and more. Multiple project keys can be specified as a comma-separated list.`, - - examples: [ + ) + .addOption( + new Option( + "-p, --project <id>", + "Project ID or project key (comma-separated for multiple)", + ).env("BACKLOG_PROJECT"), + ) + .addOption(opt.assigneeList()) + .option("-S, --status <id>", "Status ID (comma-separated for multiple)") + .option("-P, --priority <name>", `Priority name (comma-separated for multiple)`) + .addOption(opt.keyword()) + .option("--created-since <date>", "Show issues created on or after this date") + .option("--created-until <date>", "Show issues created on or before this date") + .option("--updated-since <date>", "Show issues updated on or after this date") + .option("--updated-until <date>", "Show issues updated on or before this date") + .option("--due-since <date>", "Show issues due on or after this date") + .option("--due-until <date>", "Show issues due on or before this date") + .option("--sort <field>", "Sort field") + .addOption(opt.order()) + .addOption(opt.count()) + .addOption(opt.offset()) + .addOption(opt.json()) + .envVars([...ENV_AUTH, ENV_PROJECT]) + .examples([ { description: "List issues in a project", command: "bee issue list -p PROJECT" }, { description: "List your assigned issues", command: "bee issue list -p PROJECT -a @me" }, { @@ -23,138 +46,78 @@ Multiple project keys can be specified as a comma-separated list.`, command: 'bee issue list -p PROJECT -k "login bug" --priority high', }, { description: "Output as JSON", command: "bee issue list -p PROJECT --json" }, - ], - - annotations: { - environment: [...ENV_AUTH, ENV_PROJECT], - }, -}; - -const list = withUsage( - defineCommand({ - meta: { - name: "list", - description: "List issues", - }, - args: { - ...outputArgs, - project: { - ...commonArgs.project, - description: "Project ID or project key (comma-separated for multiple)", - }, - assignee: commonArgs.assigneeList, - status: { - type: "string", - alias: "S", - description: "Status ID (comma-separated for multiple)", - }, - priority: { - type: "string", - alias: "P", - description: "Priority name (comma-separated for multiple)", - valueHint: `{${PRIORITY_NAMES.join("|")}}`, - }, - keyword: commonArgs.keyword, - "created-since": { - type: "string", - description: "Show issues created on or after this date", - valueHint: "<yyyy-MM-dd>", - }, - "created-until": { - type: "string", - description: "Show issues created on or before this date", - valueHint: "<yyyy-MM-dd>", - }, - "updated-since": { - type: "string", - description: "Show issues updated on or after this date", - valueHint: "<yyyy-MM-dd>", - }, - "updated-until": { - type: "string", - description: "Show issues updated on or before this date", - valueHint: "<yyyy-MM-dd>", - }, - "due-since": { - type: "string", - description: "Show issues due on or after this date", - valueHint: "<yyyy-MM-dd>", - }, - "due-until": { - type: "string", - description: "Show issues due on or before this date", - valueHint: "<yyyy-MM-dd>", - }, - sort: { - type: "string", - description: "Sort field", - valueHint: - "{issueType|category|version|milestone|summary|status|priority|attachment|sharedFile|created|createdUser|updated|updatedUser|assignee|startDate|dueDate|estimatedHours|actualHours|childIssue}", - }, - order: commonArgs.order, - count: commonArgs.count, - offset: commonArgs.offset, - }, - async run({ args }) { - const { client } = await getClient(); + ]) + .action(async (opts) => { + const { client } = await getClient(); - const projectId = await resolveProjectIds(client, splitArg(args.project, v.string())); - const assigneeId = splitArg(args.assignee, v.number()); - const statusId = splitArg(args.status, v.number()); - const priorityId = args.priority - ? args.priority + const projectId = opts.project + ? await resolveProjectIds( + client, + (opts.project as string) .split(",") - .map((s) => s.trim()) - .filter(Boolean) - .map((name) => { - const id = PriorityId[name.toLowerCase()]; - if (id === undefined) { - throw new Error( - `Unknown priority "${name}". Valid values: ${PRIORITY_NAMES.join(", ")}`, - ); - } - return id; - }) - : []; + .map((s: string) => s.trim()) + .filter(Boolean), + ) + : []; + const assigneeId = ((opts.assignee as string[]) ?? []).map(Number); + const statusId = opts.status + ? (opts.status as string) + .split(",") + .map((s: string) => s.trim()) + .filter(Boolean) + .map(Number) + : []; + const priorityId = opts.priority + ? (opts.priority as string) + .split(",") + .map((s: string) => s.trim()) + .filter(Boolean) + .map((name: string) => { + const id = PriorityId[name.toLowerCase()]; + if (id === undefined) { + throw new Error( + `Unknown priority "${name}". Valid values: ${PRIORITY_NAMES.join(", ")}`, + ); + } + return id; + }) + : []; - const issues = await client.getIssues({ - projectId, - assigneeId, - statusId, - priorityId, - keyword: args.keyword, - sort: args.sort as Option.Issue.GetIssuesParams["sort"], - order: args.order as "asc" | "desc" | undefined, - count: args.count ? Number(args.count) : undefined, - offset: args.offset ? Number(args.offset) : undefined, - createdSince: args["created-since"], - createdUntil: args["created-until"], - updatedSince: args["updated-since"], - updatedUntil: args["updated-until"], - dueDateSince: args["due-since"], - dueDateUntil: args["due-until"], - }); + const issues = await client.getIssues({ + projectId, + assigneeId, + statusId, + priorityId, + keyword: opts.keyword as string | undefined, + sort: opts.sort as Option.Issue.GetIssuesParams["sort"], + order: opts.order as "asc" | "desc" | undefined, + count: opts.count ? Number(opts.count) : undefined, + offset: opts.offset ? Number(opts.offset) : undefined, + createdSince: opts.createdSince as string | undefined, + createdUntil: opts.createdUntil as string | undefined, + updatedSince: opts.updatedSince as string | undefined, + updatedUntil: opts.updatedUntil as string | undefined, + dueDateSince: opts.dueSince as string | undefined, + dueDateUntil: opts.dueUntil as string | undefined, + }); - outputResult(issues, args, (data) => { - if (data.length === 0) { - consola.info("No issues found."); - return; - } + outputResult(issues, opts as { json?: string }, (data) => { + if (data.length === 0) { + consola.info("No issues found."); + return; + } - const rows: Row[] = data.map((issue) => [ - { header: "KEY", value: issue.issueKey }, - { header: "STATUS", value: issue.status.name }, - { header: "TYPE", value: issue.issueType.name }, - { header: "PRIORITY", value: issue.priority.name }, - { header: "ASSIGNEE", value: issue.assignee?.name ?? "Unassigned" }, - { header: "SUMMARY", value: issue.summary }, - ]); + const rows: Row[] = data.map((issue) => [ + { header: "KEY", value: issue.issueKey }, + { header: "STATUS", value: issue.status.name }, + { header: "TYPE", value: issue.issueType.name }, + { header: "PRIORITY", value: issue.priority.name }, + { header: "ASSIGNEE", value: issue.assignee?.name ?? "Unassigned" }, + { header: "SUMMARY", value: issue.summary }, + ]); - printTable(rows); - }); - }, - }), - commandUsage, -); + printTable(rows); + }); + }); -export { commandUsage, list }; +export default list; diff --git a/apps/cli/src/commands/issue/reopen.test.ts b/apps/cli/src/commands/issue/reopen.test.ts index 253e7270..2b9d3a6b 100644 --- a/apps/cli/src/commands/issue/reopen.test.ts +++ b/apps/cli/src/commands/issue/reopen.test.ts @@ -17,8 +17,8 @@ describe("issue reopen", () => { it("reopens an issue", async () => { mockClient.patchIssue.mockResolvedValue({ issueKey: "TEST-1", summary: "Title" }); - const { reopen } = await import("./reopen"); - await reopen.run?.({ args: { issue: "TEST-1" } } as never); + const { default: reopen } = await import("./reopen"); + await reopen.parseAsync(["TEST-1"], { from: "user" }); expect(mockClient.patchIssue).toHaveBeenCalledWith("TEST-1", { statusId: 1, @@ -31,8 +31,8 @@ describe("issue reopen", () => { it("reopens an issue with a comment", async () => { mockClient.patchIssue.mockResolvedValue({ issueKey: "TEST-1", summary: "Title" }); - const { reopen } = await import("./reopen"); - await reopen.run?.({ args: { issue: "TEST-1", comment: "Regression found" } } as never); + const { default: reopen } = await import("./reopen"); + await reopen.parseAsync(["TEST-1", "--comment", "Regression found"], { from: "user" }); expect(mockClient.patchIssue).toHaveBeenCalledWith( "TEST-1", @@ -43,8 +43,8 @@ describe("issue reopen", () => { it("reopens an issue with notified users", async () => { mockClient.patchIssue.mockResolvedValue({ issueKey: "TEST-1", summary: "Title" }); - const { reopen } = await import("./reopen"); - await reopen.run?.({ args: { issue: "TEST-1", notify: "111,222" } } as never); + const { default: reopen } = await import("./reopen"); + await reopen.parseAsync(["TEST-1", "--notify", "111", "--notify", "222"], { from: "user" }); expect(mockClient.patchIssue).toHaveBeenCalledWith( "TEST-1", @@ -56,8 +56,8 @@ describe("issue reopen", () => { mockClient.patchIssue.mockResolvedValue({ issueKey: "TEST-1", summary: "Title" }); await expectStdoutContaining(async () => { - const { reopen } = await import("./reopen"); - await reopen.run?.({ args: { issue: "TEST-1", json: "" } } as never); + const { default: reopen } = await import("./reopen"); + await reopen.parseAsync(["TEST-1", "--json"], { from: "user" }); }, "TEST-1"); }); }); diff --git a/apps/cli/src/commands/issue/reopen.ts b/apps/cli/src/commands/issue/reopen.ts index a2ddcbe6..4629adbe 100644 --- a/apps/cli/src/commands/issue/reopen.ts +++ b/apps/cli/src/commands/issue/reopen.ts @@ -1,69 +1,42 @@ import { IssueStatusId, getClient } from "@repo/backlog-utils"; -import { outputArgs, outputResult, splitArg } from "@repo/cli-utils"; -import { defineCommand } from "citty"; +import { outputResult } from "@repo/cli-utils"; import consola from "consola"; -import * as v from "valibot"; -import { type CommandUsage, ENV_AUTH, withUsage } from "../../lib/command-usage"; +import { BeeCommand, ENV_AUTH } from "../../lib/bee-command"; +import * as opt from "../../lib/common-options"; -const commandUsage: CommandUsage = { - long: `Reopen a closed Backlog issue by setting its status back to \`Open\`. +const reopen = new BeeCommand("reopen") + .summary("Reopen an issue") + .description( + `Reopen a closed Backlog issue by setting its status back to \`Open\`. Optionally add a comment with \`--comment\`.`, - - examples: [ + ) + .argument("<issue>", "Issue ID or issue key") + .option("-c, --comment <text>", "Comment to add when reopening") + .addOption(opt.notify()) + .addOption(opt.json()) + .envVars([...ENV_AUTH]) + .examples([ { description: "Reopen an issue", command: "bee issue reopen PROJECT-123" }, { description: "Reopen with a comment", command: 'bee issue reopen PROJECT-123 -c "Reopening due to regression"', }, - ], - - annotations: { - environment: [...ENV_AUTH], - }, -}; - -const reopen = withUsage( - defineCommand({ - meta: { - name: "reopen", - description: "Reopen an issue", - }, - args: { - ...outputArgs, - issue: { - type: "positional", - description: "Issue ID or issue key", - valueHint: "<PROJECT-123>", - required: true, - }, - comment: { - type: "string", - alias: "c", - description: "Comment to add when reopening", - }, - notify: { - type: "string", - description: "User IDs to notify (comma-separated for multiple)", - }, - }, - async run({ args }) { - const { client } = await getClient(); + ]) + .action(async (issue, opts) => { + const { client } = await getClient(); - const notifiedUserId = splitArg(args.notify, v.number()); + const notifiedUserId = (opts.notify as number[]) ?? []; - const issue = await client.patchIssue(args.issue, { - statusId: IssueStatusId.Open, - comment: args.comment, - notifiedUserId, - }); + const issueData = await client.patchIssue(issue, { + statusId: IssueStatusId.Open, + comment: opts.comment, + notifiedUserId, + }); - outputResult(issue, args, (data) => { - consola.success(`Reopened issue ${data.issueKey}: ${data.summary}`); - }); - }, - }), - commandUsage, -); + outputResult(issueData, opts as { json?: string }, (data) => { + consola.success(`Reopened issue ${data.issueKey}: ${data.summary}`); + }); + }); -export { commandUsage, reopen }; +export default reopen; diff --git a/apps/cli/src/commands/issue/status.test.ts b/apps/cli/src/commands/issue/status.test.ts index 2775bd71..6dbb0ed1 100644 --- a/apps/cli/src/commands/issue/status.test.ts +++ b/apps/cli/src/commands/issue/status.test.ts @@ -27,8 +27,8 @@ describe("issue status", () => { { issueKey: "PROJ-3", summary: "In progress", status: { name: "In Progress" } }, ]); - const { status } = await import("./status"); - await status.run?.({ args: {} } as never); + const { default: status } = await import("./status"); + await status.parseAsync([], { from: "user" }); expect(mockClient.getMyself).toHaveBeenCalled(); expect(mockClient.getIssues).toHaveBeenCalledWith( @@ -48,8 +48,8 @@ describe("issue status", () => { mockClient.getMyself.mockResolvedValue(sampleUser); mockClient.getIssues.mockResolvedValue([]); - const { status } = await import("./status"); - await status.run?.({ args: {} } as never); + const { default: status } = await import("./status"); + await status.parseAsync([], { from: "user" }); expect(consola.info).toHaveBeenCalledWith("No issues assigned to you."); }); @@ -61,8 +61,8 @@ describe("issue status", () => { ]); await expectStdoutContaining(async () => { - const { status } = await import("./status"); - await status.run?.({ args: { json: "" } } as never); + const { default: status } = await import("./status"); + await status.parseAsync(["--json"], { from: "user" }); }, "PROJ-1"); }); }); diff --git a/apps/cli/src/commands/issue/status.ts b/apps/cli/src/commands/issue/status.ts index 685b6531..7f5a43f1 100644 --- a/apps/cli/src/commands/issue/status.ts +++ b/apps/cli/src/commands/issue/status.ts @@ -1,75 +1,61 @@ import { getClient } from "@repo/backlog-utils"; -import { outputArgs, outputResult } from "@repo/cli-utils"; -import { defineCommand } from "citty"; +import { outputResult } from "@repo/cli-utils"; import consola from "consola"; -import { type CommandUsage, ENV_AUTH, withUsage } from "../../lib/command-usage"; +import { BeeCommand, ENV_AUTH } from "../../lib/bee-command"; +import * as opt from "../../lib/common-options"; -const commandUsage: CommandUsage = { - long: `Show a summary of issues assigned to you, grouped by status. +const status = new BeeCommand("status") + .summary("Show issue status summary for yourself") + .description( + `Show a summary of issues assigned to you, grouped by status. Fetches issues where you are the assignee and displays them organized by their current status (e.g., Open, In Progress, Resolved).`, - - examples: [ + ) + .addOption(opt.json()) + .envVars([...ENV_AUTH]) + .examples([ { description: "Show your issue status summary", command: "bee issue status" }, { description: "Output as JSON", command: "bee issue status --json" }, - ], - - annotations: { - environment: [...ENV_AUTH], - }, -}; + ]) + .action(async (opts) => { + const { client } = await getClient(); -const status = withUsage( - defineCommand({ - meta: { - name: "status", - description: "Show issue status summary for yourself", - }, - args: { - ...outputArgs, - }, - async run({ args }) { - const { client } = await getClient(); + const me = await client.getMyself(); - const me = await client.getMyself(); + const issues = await client.getIssues({ + assigneeId: [me.id], + count: 100, + }); - const issues = await client.getIssues({ - assigneeId: [me.id], - count: 100, + if (issues.length === 0) { + outputResult({ user: me, issues: [] }, opts as { json?: string }, () => { + consola.info("No issues assigned to you."); }); - - if (issues.length === 0) { - outputResult({ user: me, issues: [] }, args, () => { - consola.info("No issues assigned to you."); - }); - return; + return; + } + + outputResult({ user: me, issues }, opts as { json?: string }, (data) => { + const grouped = new Map<string, typeof data.issues>(); + for (const issue of data.issues) { + const { name } = issue.status; + const group = grouped.get(name) ?? []; + group.push(issue); + grouped.set(name, group); } - outputResult({ user: me, issues }, args, (data) => { - const grouped = new Map<string, typeof data.issues>(); - for (const issue of data.issues) { - const { name } = issue.status; - const group = grouped.get(name) ?? []; - group.push(issue); - grouped.set(name, group); - } - - consola.log(""); - consola.log(` Issues assigned to ${data.user.name}:`); - consola.log(""); + consola.log(""); + consola.log(` Issues assigned to ${data.user.name}:`); + consola.log(""); - for (const [statusName, statusIssues] of grouped) { - consola.log(` ${statusName} (${statusIssues.length}):`); - for (const issue of statusIssues) { - consola.log(` ${issue.issueKey} ${issue.summary}`); - } - consola.log(""); + for (const [statusName, statusIssues] of grouped) { + consola.log(` ${statusName} (${statusIssues.length}):`); + for (const issue of statusIssues) { + consola.log(` ${issue.issueKey} ${issue.summary}`); } - }); - }, - }), - commandUsage, -); + consola.log(""); + } + }); + }); -export { commandUsage, status }; +export default status; diff --git a/apps/cli/src/commands/issue/view.test.ts b/apps/cli/src/commands/issue/view.test.ts index 943a0c45..297f1a5c 100644 --- a/apps/cli/src/commands/issue/view.test.ts +++ b/apps/cli/src/commands/issue/view.test.ts @@ -42,8 +42,8 @@ describe("issue view", () => { it("displays issue details", async () => { mockClient.getIssue.mockResolvedValue(sampleIssue); - const { view } = await import("./view"); - await view.run?.({ args: { issue: "PROJ-1" } } as never); + const { default: view } = await import("./view"); + await view.parseAsync(["PROJ-1"], { from: "user" }); expect(mockClient.getIssue).toHaveBeenCalledWith("PROJ-1"); expect(consola.log).toHaveBeenCalledWith(expect.stringContaining("PROJ-1")); @@ -57,8 +57,8 @@ describe("issue view", () => { it("shows Unassigned for issues without assignee", async () => { mockClient.getIssue.mockResolvedValue({ ...sampleIssue, assignee: null }); - const { view } = await import("./view"); - await view.run?.({ args: { issue: "PROJ-1" } } as never); + const { default: view } = await import("./view"); + await view.parseAsync(["PROJ-1"], { from: "user" }); expect(consola.log).toHaveBeenCalledWith(expect.stringContaining("Unassigned")); }); @@ -66,8 +66,8 @@ describe("issue view", () => { it("displays description when present", async () => { mockClient.getIssue.mockResolvedValue(sampleIssue); - const { view } = await import("./view"); - await view.run?.({ args: { issue: "PROJ-1" } } as never); + const { default: view } = await import("./view"); + await view.parseAsync(["PROJ-1"], { from: "user" }); expect(consola.log).toHaveBeenCalledWith(expect.stringContaining("Description")); expect(consola.log).toHaveBeenCalledWith(expect.stringContaining("A test description")); @@ -83,8 +83,8 @@ describe("issue view", () => { }, ]); - const { view } = await import("./view"); - await view.run?.({ args: { issue: "PROJ-1", comments: true } } as never); + const { default: view } = await import("./view"); + await view.parseAsync(["PROJ-1", "--comments"], { from: "user" }); expect(mockClient.getIssueComments).toHaveBeenCalledWith("PROJ-1", { order: "asc" }); expect(consola.log).toHaveBeenCalledWith(expect.stringContaining("Comments")); @@ -93,8 +93,8 @@ describe("issue view", () => { }); it("opens browser with --web flag", async () => { - const { view } = await import("./view"); - await view.run?.({ args: { issue: "PROJ-1", web: true } } as never); + const { default: view } = await import("./view"); + await view.parseAsync(["PROJ-1", "--web"], { from: "user" }); expect(openOrPrintUrl).toHaveBeenCalledWith( "https://example.backlog.com/view/PROJ-1", @@ -108,8 +108,8 @@ describe("issue view", () => { mockClient.getIssue.mockResolvedValue(sampleIssue); await expectStdoutContaining(async () => { - const { view } = await import("./view"); - await view.run?.({ args: { issue: "PROJ-1", json: "" } } as never); + const { default: view } = await import("./view"); + await view.parseAsync(["PROJ-1", "--json"], { from: "user" }); }, "PROJ-1"); }); }); diff --git a/apps/cli/src/commands/issue/view.ts b/apps/cli/src/commands/issue/view.ts index b0d0d0a2..2c1d208f 100644 --- a/apps/cli/src/commands/issue/view.ts +++ b/apps/cli/src/commands/issue/view.ts @@ -1,132 +1,107 @@ import { getClient, issueUrl, openOrPrintUrl } from "@repo/backlog-utils"; -import { formatDate, outputArgs, outputResult, printDefinitionList } from "@repo/cli-utils"; -import { defineCommand } from "citty"; +import { formatDate, outputResult, printDefinitionList } from "@repo/cli-utils"; import consola from "consola"; -import { type CommandUsage, ENV_AUTH, withUsage } from "../../lib/command-usage"; -import * as commonArgs from "../../lib/common-args"; +import { BeeCommand, ENV_AUTH } from "../../lib/bee-command"; +import * as opt from "../../lib/common-options"; -const commandUsage: CommandUsage = { - long: `Display details of a Backlog issue. +const view = new BeeCommand("view") + .summary("View an issue") + .description( + `Display details of a Backlog issue. Shows the issue summary, status, type, priority, assignee, dates, and description. Use \`--comments\` to also fetch and display comments. Use \`--web\` to open the issue in your default browser instead.`, - - examples: [ + ) + .argument("<issue>", "Issue ID or issue key") + .option("--comments", "Include comments") + .addOption(opt.web("issue")) + .addOption(opt.noBrowser()) + .addOption(opt.json()) + .envVars([...ENV_AUTH]) + .examples([ { description: "View issue details", command: "bee issue view PROJECT-123" }, { description: "View with comments", command: "bee issue view PROJECT-123 --comments" }, { description: "Open issue in browser", command: "bee issue view PROJECT-123 --web" }, { description: "Output as JSON", command: "bee issue view PROJECT-123 --json" }, - ], - - annotations: { - environment: [...ENV_AUTH], - }, -}; + ]) + .action(async (issue, opts) => { + const { client, host } = await getClient(); -const view = withUsage( - defineCommand({ - meta: { - name: "view", - description: "View an issue", - }, - args: { - ...outputArgs, - issue: { - type: "positional", - description: "Issue ID or issue key", - valueHint: "<PROJECT-123>", - required: true, - }, - comments: { - type: "boolean", - description: "Include comments", - }, - web: commonArgs.web("issue"), - "no-browser": commonArgs.noBrowser, - }, - async run({ args }) { - const { client, host } = await getClient(); + if (opts.web || opts.browser === false) { + const url = issueUrl(host, issue); + await openOrPrintUrl(url, opts.browser === false, consola); + return; + } - if (args.web || args["no-browser"]) { - const url = issueUrl(host, args.issue); - await openOrPrintUrl(url, Boolean(args["no-browser"]), consola); - return; - } + const issueData = await client.getIssue(issue); - const issue = await client.getIssue(args.issue); + const comments = opts.comments ? await client.getIssueComments(issue, { order: "asc" }) : []; - const comments = args.comments - ? await client.getIssueComments(args.issue, { order: "asc" }) - : []; + outputResult(issueData, opts as { json?: string }, (data) => { + consola.log(""); + consola.log(` ${data.issueKey}: ${data.summary}`); + consola.log(""); + printDefinitionList([ + ["Status", data.status.name], + ["Type", data.issueType.name], + ["Priority", data.priority.name], + ["Assignee", data.assignee?.name ?? "Unassigned"], + ["Created by", data.createdUser?.name ?? "Unknown"], + ["Created", formatDate(data.created)], + ["Updated", formatDate(data.updated)], + ["Start Date", data.startDate ? formatDate(data.startDate) : undefined], + ["Due Date", data.dueDate ? formatDate(data.dueDate) : undefined], + ["Estimated", data.estimatedHours === undefined ? undefined : `${data.estimatedHours}h`], + ["Actual", data.actualHours === undefined ? undefined : `${data.actualHours}h`], + [ + "Categories", + data.category.length > 0 ? data.category.map((c) => c.name).join(", ") : undefined, + ], + [ + "Milestones", + data.milestone.length > 0 ? data.milestone.map((m) => m.name).join(", ") : undefined, + ], + [ + "Versions", + data.versions && data.versions.length > 0 + ? data.versions.map((v: { name: string }) => v.name).join(", ") + : undefined, + ], + ]); - outputResult(issue, args, (data) => { - consola.log(""); - consola.log(` ${data.issueKey}: ${data.summary}`); + if (data.description) { consola.log(""); - printDefinitionList([ - ["Status", data.status.name], - ["Type", data.issueType.name], - ["Priority", data.priority.name], - ["Assignee", data.assignee?.name ?? "Unassigned"], - ["Created by", data.createdUser?.name ?? "Unknown"], - ["Created", formatDate(data.created)], - ["Updated", formatDate(data.updated)], - ["Start Date", data.startDate ? formatDate(data.startDate) : undefined], - ["Due Date", data.dueDate ? formatDate(data.dueDate) : undefined], - ["Estimated", data.estimatedHours === undefined ? undefined : `${data.estimatedHours}h`], - ["Actual", data.actualHours === undefined ? undefined : `${data.actualHours}h`], - [ - "Categories", - data.category.length > 0 ? data.category.map((c) => c.name).join(", ") : undefined, - ], - [ - "Milestones", - data.milestone.length > 0 ? data.milestone.map((m) => m.name).join(", ") : undefined, - ], - [ - "Versions", - data.versions && data.versions.length > 0 - ? data.versions.map((v: { name: string }) => v.name).join(", ") - : undefined, - ], - ]); + consola.log(" Description:"); + consola.log( + data.description + .split("\n") + .map((line) => ` ${line}`) + .join("\n"), + ); + } - if (data.description) { + if (comments.length > 0) { + consola.log(""); + consola.log(" Comments:"); + for (const comment of comments) { + if (!comment.content) { + continue; + } consola.log(""); - consola.log(" Description:"); + consola.log(` ${comment.createdUser.name} (${formatDate(comment.created)}):`); consola.log( - data.description + comment.content .split("\n") - .map((line) => ` ${line}`) + .map((line) => ` ${line}`) .join("\n"), ); } + } - if (comments.length > 0) { - consola.log(""); - consola.log(" Comments:"); - for (const comment of comments) { - if (!comment.content) { - continue; - } - consola.log(""); - consola.log(` ${comment.createdUser.name} (${formatDate(comment.created)}):`); - consola.log( - comment.content - .split("\n") - .map((line) => ` ${line}`) - .join("\n"), - ); - } - } - - consola.log(""); - }); - }, - }), - commandUsage, -); + consola.log(""); + }); + }); -export { commandUsage, view }; +export default view; From f35dc8b73e816e77386b473401e3cc0dc5f27638 Mon Sep 17 00:00:00 2001 From: Ryoya Tamura <ryoya.tamura@nulab.com> Date: Sun, 8 Mar 2026 18:21:56 +0900 Subject: [PATCH 05/16] refactor(cli): migrate pr commands to commander Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --- apps/cli/src/commands/pr/comment.test.ts | 111 +++++------ apps/cli/src/commands/pr/comment.ts | 228 ++++++++++------------ apps/cli/src/commands/pr/comments.test.ts | 20 +- apps/cli/src/commands/pr/comments.ts | 126 +++++------- apps/cli/src/commands/pr/count.test.ts | 24 ++- apps/cli/src/commands/pr/count.ts | 131 +++++-------- apps/cli/src/commands/pr/create.test.ts | 156 +++++++++------ apps/cli/src/commands/pr/create.ts | 153 ++++++--------- apps/cli/src/commands/pr/edit.test.ts | 57 +++--- apps/cli/src/commands/pr/edit.ts | 141 +++++-------- apps/cli/src/commands/pr/list.test.ts | 34 ++-- apps/cli/src/commands/pr/list.ts | 163 +++++++--------- apps/cli/src/commands/pr/status.test.ts | 12 +- apps/cli/src/commands/pr/status.ts | 126 +++++------- apps/cli/src/commands/pr/view.test.ts | 26 +-- apps/cli/src/commands/pr/view.ts | 141 ++++++------- 16 files changed, 742 insertions(+), 907 deletions(-) diff --git a/apps/cli/src/commands/pr/comment.test.ts b/apps/cli/src/commands/pr/comment.test.ts index 9b2fe09d..932838d6 100644 --- a/apps/cli/src/commands/pr/comment.test.ts +++ b/apps/cli/src/commands/pr/comment.test.ts @@ -26,10 +26,10 @@ describe("pr comment", () => { it("adds a comment to a pull request", async () => { mockClient.postPullRequestComments.mockResolvedValue({ id: 1, content: "LGTM" }); - const { comment } = await import("./comment"); - await comment.run?.({ - args: { number: "42", project: "TEST", repo: "my-repo", body: "LGTM" }, - } as never); + const { default: comment } = await import("./comment"); + await comment.parseAsync(["42", "--project", "TEST", "--repo", "my-repo", "--body", "LGTM"], { + from: "user", + }); expect(mockClient.postPullRequestComments).toHaveBeenCalledWith("TEST", "my-repo", 42, { content: "LGTM", @@ -42,10 +42,10 @@ describe("pr comment", () => { vi.mocked(resolveStdinArg).mockResolvedValueOnce("Stdin content"); mockClient.postPullRequestComments.mockResolvedValue({ id: 2, content: "Stdin content" }); - const { comment } = await import("./comment"); - await comment.run?.({ - args: { number: "42", project: "TEST", repo: "my-repo", body: "" }, - } as never); + const { default: comment } = await import("./comment"); + await comment.parseAsync(["42", "--project", "TEST", "--repo", "my-repo", "--body", ""], { + from: "user", + }); expect(resolveStdinArg).toHaveBeenCalledWith(""); expect(mockClient.postPullRequestComments).toHaveBeenCalledWith("TEST", "my-repo", 42, { @@ -57,10 +57,23 @@ describe("pr comment", () => { it("adds a comment with notified users", async () => { mockClient.postPullRequestComments.mockResolvedValue({ id: 3, content: "FYI" }); - const { comment } = await import("./comment"); - await comment.run?.({ - args: { number: "42", project: "TEST", repo: "my-repo", body: "FYI", notify: "111,222" }, - } as never); + const { default: comment } = await import("./comment"); + await comment.parseAsync( + [ + "42", + "--project", + "TEST", + "--repo", + "my-repo", + "--body", + "FYI", + "--notify", + "111", + "--notify", + "222", + ], + { from: "user" }, + ); expect(mockClient.postPullRequestComments).toHaveBeenCalledWith("TEST", "my-repo", 42, { content: "FYI", @@ -71,10 +84,11 @@ describe("pr comment", () => { it("outputs JSON when --json flag is set", async () => { mockClient.postPullRequestComments.mockResolvedValue({ id: 1, content: "LGTM" }); await expectStdoutContaining(async () => { - const { comment } = await import("./comment"); - await comment.run?.({ - args: { number: "42", project: "TEST", repo: "my-repo", body: "LGTM", json: "" }, - } as never); + const { default: comment } = await import("./comment"); + await comment.parseAsync( + ["42", "--project", "TEST", "--repo", "my-repo", "--body", "LGTM", "--json"], + { from: "user" }, + ); }, "LGTM"); }); @@ -83,10 +97,10 @@ describe("pr comment", () => { { id: 1, content: "Hello", createdUser: { name: "Alice" }, created: "2025-01-01T00:00:00Z" }, ]); - const { comment } = await import("./comment"); - await comment.run?.({ - args: { number: "42", project: "TEST", repo: "my-repo", list: true }, - } as never); + const { default: comment } = await import("./comment"); + await comment.parseAsync(["42", "--project", "TEST", "--repo", "my-repo", "--list"], { + from: "user", + }); expect(mockClient.getPullRequestComments).toHaveBeenCalledWith("TEST", "my-repo", 42, { order: "asc", @@ -97,10 +111,10 @@ describe("pr comment", () => { it("shows message when no comments found with --list", async () => { mockClient.getPullRequestComments.mockResolvedValue([]); - const { comment } = await import("./comment"); - await comment.run?.({ - args: { number: "42", project: "TEST", repo: "my-repo", list: true }, - } as never); + const { default: comment } = await import("./comment"); + await comment.parseAsync(["42", "--project", "TEST", "--repo", "my-repo", "--list"], { + from: "user", + }); expect(consola.info).toHaveBeenCalledWith("No comments found."); }); @@ -112,16 +126,11 @@ describe("pr comment", () => { mockClient.getMyself.mockResolvedValue({ id: 1 }); mockClient.patchPullRequestComments.mockResolvedValue({ id: 20, content: "Updated" }); - const { comment } = await import("./comment"); - await comment.run?.({ - args: { - number: "42", - project: "TEST", - repo: "my-repo", - "edit-last": true, - body: "Updated", - }, - } as never); + const { default: comment } = await import("./comment"); + await comment.parseAsync( + ["42", "--project", "TEST", "--repo", "my-repo", "--edit-last", "--body", "Updated"], + { from: "user" }, + ); expect(mockClient.patchPullRequestComments).toHaveBeenCalledWith("TEST", "my-repo", 42, 20, { content: "Updated", @@ -135,15 +144,10 @@ describe("pr comment", () => { ]); mockClient.getMyself.mockResolvedValue({ id: 1 }); - const { comment } = await import("./comment"); - await comment.run?.({ - args: { - number: "42", - project: "TEST", - repo: "my-repo", - "edit-last": true, - }, - } as never); + const { default: comment } = await import("./comment"); + await comment.parseAsync(["42", "--project", "TEST", "--repo", "my-repo", "--edit-last"], { + from: "user", + }); expect(consola.error).toHaveBeenCalledWith( "Comment body is required. Use --body or pipe input.", @@ -151,10 +155,8 @@ describe("pr comment", () => { }); it("shows error when body is missing for add comment", async () => { - const { comment } = await import("./comment"); - await comment.run?.({ - args: { number: "42", project: "TEST", repo: "my-repo" }, - } as never); + const { default: comment } = await import("./comment"); + await comment.parseAsync(["42", "--project", "TEST", "--repo", "my-repo"], { from: "user" }); expect(consola.error).toHaveBeenCalledWith( "Comment body is required. Use --body or pipe input.", @@ -167,16 +169,11 @@ describe("pr comment", () => { ]); mockClient.getMyself.mockResolvedValue({ id: 1 }); - const { comment } = await import("./comment"); - await comment.run?.({ - args: { - number: "42", - project: "TEST", - repo: "my-repo", - "edit-last": true, - body: "Updated", - }, - } as never); + const { default: comment } = await import("./comment"); + await comment.parseAsync( + ["42", "--project", "TEST", "--repo", "my-repo", "--edit-last", "--body", "Updated"], + { from: "user" }, + ); expect(consola.error).toHaveBeenCalledWith("No comment by you was found on pull request #42."); }); diff --git a/apps/cli/src/commands/pr/comment.ts b/apps/cli/src/commands/pr/comment.ts index 158f222a..3fb194df 100644 --- a/apps/cli/src/commands/pr/comment.ts +++ b/apps/cli/src/commands/pr/comment.ts @@ -1,35 +1,31 @@ import { getClient } from "@repo/backlog-utils"; -import { - type Row, - formatDate, - outputArgs, - outputResult, - printTable, - resolveStdinArg, - splitArg, -} from "@repo/cli-utils"; -import { defineCommand } from "citty"; +import { type Row, formatDate, outputResult, printTable, resolveStdinArg } from "@repo/cli-utils"; import consola from "consola"; -import * as v from "valibot"; -import { - type CommandUsage, - ENV_AUTH, - ENV_PROJECT, - ENV_REPO, - withUsage, -} from "../../lib/command-usage"; -import * as commonArgs from "../../lib/common-args"; - -const commandUsage: CommandUsage = { - long: `Add a comment to a Backlog pull request. +import { BeeCommand, ENV_AUTH, ENV_PROJECT, ENV_REPO } from "../../lib/bee-command"; +import * as opt from "../../lib/common-options"; +import { resolveOptions } from "../../lib/required-option"; + +const comment = new BeeCommand("comment") + .summary("Add a comment to a pull request") + .description( + `Add a comment to a Backlog pull request. The comment body is required when adding a comment. When input is piped, it is used as the body automatically. Use \`--list\` to list all comments on a pull request. Use \`--edit-last\` to edit your most recent comment.`, - - examples: [ + ) + .argument("<number>", "Pull request number") + .addOption(opt.project()) + .addOption(opt.repo()) + .option("-b, --body <text>", "Comment body") + .addOption(opt.notify()) + .option("--list", "List comments on the pull request") + .option("--edit-last", "Edit your most recent comment") + .addOption(opt.json()) + .envVars([...ENV_AUTH, ENV_PROJECT, ENV_REPO]) + .examples([ { description: "Add a comment", command: 'bee pr comment 42 -p PROJECT -R repo -b "Looks good!"', @@ -43,123 +39,101 @@ Use \`--edit-last\` to edit your most recent comment.`, description: "Edit your last comment", command: 'bee pr comment 42 -p PROJECT -R repo --edit-last -b "Updated"', }, - ], - - annotations: { - environment: [...ENV_AUTH, ENV_PROJECT, ENV_REPO], - }, -}; - -const comment = withUsage( - defineCommand({ - meta: { - name: "comment", - description: "Add a comment to a pull request", - }, - args: { - ...outputArgs, - number: { - type: "positional", - description: "Pull request number", - valueHint: "<number>", - required: true, - }, - project: { ...commonArgs.project, required: true }, - repo: commonArgs.repo, - body: { - type: "string", - alias: "b", - description: "Comment body", - }, - notify: commonArgs.notify, - list: { - type: "boolean", - description: "List comments on the pull request", - }, - "edit-last": { - type: "boolean", - description: "Edit your most recent comment", - }, - }, - async run({ args }) { - const { client } = await getClient(); - const prNumber = Number(args.number); - - if (args.list) { - const comments = await client.getPullRequestComments(args.project, args.repo, prNumber, { + ]) + .action(async (number, _opts, cmd) => { + const opts = await resolveOptions(cmd); + const { client } = await getClient(); + const prNumber = Number(number); + + const json = opts.json === true ? "" : (opts.json as string | undefined); + + if (opts.list) { + const comments = await client.getPullRequestComments( + opts.project as string, + opts.repo as string, + prNumber, + { order: "asc", - }); - - outputResult(comments, args, (data) => { - const filtered = data.filter((c) => c.content); - if (filtered.length === 0) { - consola.info("No comments found."); - return; - } - - const rows: Row[] = filtered.map((c) => [ - { header: "ID", value: String(c.id) }, - { header: "AUTHOR", value: c.createdUser.name }, - { header: "DATE", value: formatDate(c.created) }, - { header: "CONTENT", value: c.content! }, - ]); - - printTable(rows); - }); - return; - } - - if (args["edit-last"]) { - const myself = await client.getMyself(); - const comments = await client.getPullRequestComments(args.project, args.repo, prNumber, { - order: "desc", - }); - const myComment = comments.find((c) => c.createdUser.id === myself.id); + }, + ); - if (!myComment) { - consola.error(`No comment by you was found on pull request #${args.number}.`); + outputResult(comments, { json }, (data) => { + const filtered = data.filter((c) => c.content); + if (filtered.length === 0) { + consola.info("No comments found."); return; } - const content = (await resolveStdinArg(args.body)) ?? args.body; - if (!content) { - consola.error("Comment body is required. Use --body or pipe input."); - return; - } + const rows: Row[] = filtered.map((c) => [ + { header: "ID", value: String(c.id) }, + { header: "AUTHOR", value: c.createdUser.name }, + { header: "DATE", value: formatDate(c.created) }, + { header: "CONTENT", value: c.content! }, + ]); + + printTable(rows); + }); + return; + } + + if (opts.editLast) { + const myself = await client.getMyself(); + const comments = await client.getPullRequestComments( + opts.project as string, + opts.repo as string, + prNumber, + { + order: "desc", + }, + ); + const myComment = comments.find((c) => c.createdUser.id === myself.id); - const result = await client.patchPullRequestComments( - args.project, - args.repo, - prNumber, - myComment.id, - { content }, - ); - - outputResult(result, args, () => { - consola.success(`Updated comment on pull request #${args.number}`); - }); + if (!myComment) { + consola.error(`No comment by you was found on pull request #${number}.`); return; } - // Default: add comment - const content = (await resolveStdinArg(args.body)) ?? args.body; + const content = (await resolveStdinArg(opts.body as string | undefined)) ?? opts.body; if (!content) { consola.error("Comment body is required. Use --body or pipe input."); return; } - const notifiedUserId = splitArg(args.notify, v.number()); - const result = await client.postPullRequestComments(args.project, args.repo, prNumber, { - content, - notifiedUserId, - }); + const result = await client.patchPullRequestComments( + opts.project as string, + opts.repo as string, + prNumber, + myComment.id, + { content: content as string }, + ); - outputResult(result, args, () => { - consola.success(`Added comment to pull request #${args.number}`); + outputResult(result, { json }, () => { + consola.success(`Updated comment on pull request #${number}`); }); - }, - }), - commandUsage, -); + return; + } + + // Default: add comment + const content = (await resolveStdinArg(opts.body as string | undefined)) ?? opts.body; + if (!content) { + consola.error("Comment body is required. Use --body or pipe input."); + return; + } + const notifiedUserId = (opts.notify as number[]) ?? []; + + const result = await client.postPullRequestComments( + opts.project as string, + opts.repo as string, + prNumber, + { + content: content as string, + notifiedUserId, + }, + ); + + outputResult(result, { json }, () => { + consola.success(`Added comment to pull request #${number}`); + }); + }); -export { commandUsage, comment }; +export default comment; diff --git a/apps/cli/src/commands/pr/comments.test.ts b/apps/cli/src/commands/pr/comments.test.ts index fb489b41..52f6df4a 100644 --- a/apps/cli/src/commands/pr/comments.test.ts +++ b/apps/cli/src/commands/pr/comments.test.ts @@ -31,8 +31,8 @@ describe("pr comments", () => { it("displays pull request comments", async () => { mockClient.getPullRequestComments.mockResolvedValue(sampleComments); - const { comments } = await import("./comments"); - await comments.run?.({ args: { number: "42", project: "PROJ", repo: "repo" } } as never); + const { default: comments } = await import("./comments"); + await comments.parseAsync(["42", "--project", "PROJ", "--repo", "repo"], { from: "user" }); expect(mockClient.getPullRequestComments).toHaveBeenCalledWith("PROJ", "repo", 42, { order: "asc", @@ -47,8 +47,8 @@ describe("pr comments", () => { it("shows message when no comments found", async () => { mockClient.getPullRequestComments.mockResolvedValue([]); - const { comments } = await import("./comments"); - await comments.run?.({ args: { number: "42", project: "PROJ", repo: "repo" } } as never); + const { default: comments } = await import("./comments"); + await comments.parseAsync(["42", "--project", "PROJ", "--repo", "repo"], { from: "user" }); expect(consola.info).toHaveBeenCalledWith("No comments found."); }); @@ -58,8 +58,8 @@ describe("pr comments", () => { { id: 1, content: "", createdUser: { name: "Alice" }, created: "2025-01-01T00:00:00Z" }, ]); - const { comments } = await import("./comments"); - await comments.run?.({ args: { number: "42", project: "PROJ", repo: "repo" } } as never); + const { default: comments } = await import("./comments"); + await comments.parseAsync(["42", "--project", "PROJ", "--repo", "repo"], { from: "user" }); expect(consola.info).toHaveBeenCalledWith("No comments found."); }); @@ -68,10 +68,10 @@ describe("pr comments", () => { mockClient.getPullRequestComments.mockResolvedValue(sampleComments); await expectStdoutContaining(async () => { - const { comments } = await import("./comments"); - await comments.run?.({ - args: { number: "42", project: "PROJ", repo: "repo", json: "" }, - } as never); + const { default: comments } = await import("./comments"); + await comments.parseAsync(["42", "--project", "PROJ", "--repo", "repo", "--json"], { + from: "user", + }); }, "Looks good!"); }); }); diff --git a/apps/cli/src/commands/pr/comments.ts b/apps/cli/src/commands/pr/comments.ts index f14564a0..18edd4b9 100644 --- a/apps/cli/src/commands/pr/comments.ts +++ b/apps/cli/src/commands/pr/comments.ts @@ -1,90 +1,72 @@ import { getClient } from "@repo/backlog-utils"; -import { formatDate, outputArgs, outputResult } from "@repo/cli-utils"; -import { defineCommand } from "citty"; +import { formatDate, outputResult } from "@repo/cli-utils"; import consola from "consola"; -import { - type CommandUsage, - ENV_AUTH, - ENV_PROJECT, - ENV_REPO, - withUsage, -} from "../../lib/command-usage"; -import * as commonArgs from "../../lib/common-args"; +import { BeeCommand, ENV_AUTH, ENV_PROJECT, ENV_REPO } from "../../lib/bee-command"; +import * as opt from "../../lib/common-options"; +import { resolveOptions } from "../../lib/required-option"; -const commandUsage: CommandUsage = { - long: `List comments on a Backlog pull request. +const comments = new BeeCommand("comments") + .summary("List comments on a pull request") + .description( + `List comments on a Backlog pull request. Displays all comments in chronological order with the author and date.`, - - examples: [ + ) + .argument("<number>", "Pull request number") + .addOption(opt.project()) + .addOption(opt.repo()) + .addOption(opt.minId()) + .addOption(opt.maxId()) + .addOption(opt.count()) + .addOption(opt.order()) + .addOption(opt.json()) + .envVars([...ENV_AUTH, ENV_PROJECT, ENV_REPO]) + .examples([ { description: "List pull request comments", command: "bee pr comments 42 -p PROJECT -R repo", }, { description: "Output as JSON", command: "bee pr comments 42 -p PROJECT -R repo --json" }, - ], + ]) + .action(async (number, _opts, cmd) => { + const opts = await resolveOptions(cmd); + const { client } = await getClient(); - annotations: { - environment: [...ENV_AUTH, ENV_PROJECT, ENV_REPO], - }, -}; + const prNumber = Number(number); -const comments = withUsage( - defineCommand({ - meta: { - name: "comments", - description: "List comments on a pull request", - }, - args: { - ...outputArgs, - number: { - type: "positional", - description: "Pull request number", - valueHint: "<number>", - required: true, + const prComments = await client.getPullRequestComments( + opts.project as string, + opts.repo as string, + prNumber, + { + minId: opts.minId ? Number(opts.minId) : undefined, + maxId: opts.maxId ? Number(opts.maxId) : undefined, + order: (opts.order as "asc" | "desc") ?? "asc", + count: opts.count ? Number(opts.count) : undefined, }, - project: { ...commonArgs.project, required: true }, - repo: commonArgs.repo, - "min-id": commonArgs.minId, - "max-id": commonArgs.maxId, - count: commonArgs.count, - order: commonArgs.order, - }, - async run({ args }) { - const { client } = await getClient(); + ); - const prNumber = Number(args.number); + const json = opts.json === true ? "" : (opts.json as string | undefined); + outputResult(prComments, { json }, (data) => { + const contentComments = data.filter((c) => c.content); - const prComments = await client.getPullRequestComments(args.project, args.repo, prNumber, { - minId: args["min-id"] ? Number(args["min-id"]) : undefined, - maxId: args["max-id"] ? Number(args["max-id"]) : undefined, - order: (args.order as "asc" | "desc") ?? "asc", - count: args.count ? Number(args.count) : undefined, - }); - - outputResult(prComments, args, (data) => { - const contentComments = data.filter((c) => c.content); - - if (contentComments.length === 0) { - consola.info("No comments found."); - return; - } + if (contentComments.length === 0) { + consola.info("No comments found."); + return; + } + consola.log(""); + for (const comment of contentComments) { + consola.log(` ${comment.createdUser.name} (${formatDate(comment.created)}):`); + consola.log( + comment.content + .split("\n") + .map((line) => ` ${line}`) + .join("\n"), + ); consola.log(""); - for (const comment of contentComments) { - consola.log(` ${comment.createdUser.name} (${formatDate(comment.created)}):`); - consola.log( - comment.content - .split("\n") - .map((line) => ` ${line}`) - .join("\n"), - ); - consola.log(""); - } - }); - }, - }), - commandUsage, -); + } + }); + }); -export { commandUsage, comments }; +export default comments; diff --git a/apps/cli/src/commands/pr/count.test.ts b/apps/cli/src/commands/pr/count.test.ts index 8c0246e7..c3f6d6eb 100644 --- a/apps/cli/src/commands/pr/count.test.ts +++ b/apps/cli/src/commands/pr/count.test.ts @@ -21,8 +21,8 @@ vi.mock("consola", () => import("@repo/test-utils/mock-consola")); describe("pr count", () => { it("outputs pull request count", async () => { mockClient.getPullRequestsCount.mockResolvedValue({ count: 10 }); - const { count } = await import("./count"); - await count.run?.({ args: { project: "TEST", repo: "my-repo" } } as never); + const { default: count } = await import("./count"); + await count.parseAsync(["--project", "TEST", "--repo", "my-repo"], { from: "user" }); expect(mockClient.getPullRequestsCount).toHaveBeenCalledWith( "TEST", "my-repo", @@ -33,8 +33,10 @@ describe("pr count", () => { it("passes status filter", async () => { mockClient.getPullRequestsCount.mockResolvedValue({ count: 3 }); - const { count } = await import("./count"); - await count.run?.({ args: { project: "TEST", repo: "my-repo", status: "open" } } as never); + const { default: count } = await import("./count"); + await count.parseAsync(["--project", "TEST", "--repo", "my-repo", "--status", "open"], { + from: "user", + }); expect(mockClient.getPullRequestsCount).toHaveBeenCalledWith( "TEST", "my-repo", @@ -45,17 +47,19 @@ describe("pr count", () => { it("outputs JSON when --json flag is set", async () => { mockClient.getPullRequestsCount.mockResolvedValue({ count: 10 }); await expectStdoutContaining(async () => { - const { count } = await import("./count"); - await count.run?.({ args: { project: "TEST", repo: "my-repo", json: "" } } as never); + const { default: count } = await import("./count"); + await count.parseAsync(["--project", "TEST", "--repo", "my-repo", "--json"], { + from: "user", + }); }, "10"); }); it("throws error for unknown status name", async () => { - const { count } = await import("./count"); + const { default: count } = await import("./count"); await expect( - count.run?.({ - args: { repo: "my-repo", status: "invalid" }, - } as never), + count.parseAsync(["--project", "TEST", "--repo", "my-repo", "--status", "invalid"], { + from: "user", + }), ).rejects.toThrow('Unknown status "invalid". Valid values: open, closed, merged'); }); }); diff --git a/apps/cli/src/commands/pr/count.ts b/apps/cli/src/commands/pr/count.ts index ce4f14e7..3802647d 100644 --- a/apps/cli/src/commands/pr/count.ts +++ b/apps/cli/src/commands/pr/count.ts @@ -1,95 +1,72 @@ import { PR_STATUS_NAMES, PrStatusName, getClient } from "@repo/backlog-utils"; -import { outputArgs, outputResult, splitArg } from "@repo/cli-utils"; -import { defineCommand } from "citty"; +import { outputResult, splitArg } from "@repo/cli-utils"; import consola from "consola"; import * as v from "valibot"; -import { - type CommandUsage, - ENV_AUTH, - ENV_PROJECT, - ENV_REPO, - withUsage, -} from "../../lib/command-usage"; -import * as commonArgs from "../../lib/common-args"; +import { BeeCommand, ENV_AUTH, ENV_PROJECT, ENV_REPO } from "../../lib/bee-command"; +import * as opt from "../../lib/common-options"; +import { resolveOptions } from "../../lib/required-option"; -const commandUsage: CommandUsage = { - long: `Count pull requests in a Backlog repository. +const count = new BeeCommand("count") + .summary("Count pull requests") + .description( + `Count pull requests in a Backlog repository. Accepts the same status filter as \`bee pr list\`. Outputs a plain number by default, or a JSON object with \`--json\`.`, - examples: [ + ) + .addOption(opt.project()) + .addOption(opt.repo()) + .option("-S, --status <name>", "Status name (comma-separated for multiple)") + .addOption(opt.assigneeList()) + .option("--issue <ids>", "Issue ID (comma-separated for multiple)") + .option("--created-user <ids>", "Created user ID (comma-separated for multiple)") + .addOption(opt.json()) + .envVars([...ENV_AUTH, ENV_PROJECT, ENV_REPO]) + .examples([ { description: "Count all pull requests", command: "bee pr count -p PROJECT -R repo" }, { description: "Count open pull requests", command: "bee pr count -p PROJECT -R repo --status open", }, { description: "Output as JSON", command: "bee pr count -p PROJECT -R repo --json" }, - ], - annotations: { environment: [...ENV_AUTH, ENV_PROJECT, ENV_REPO] }, -}; + ]) + .action(async (_opts, cmd) => { + const opts = await resolveOptions(cmd); + const { client } = await getClient(); -const count = withUsage( - defineCommand({ - meta: { - name: "count", - description: "Count pull requests", - }, - args: { - ...outputArgs, - project: { ...commonArgs.project, required: true }, - repo: commonArgs.repo, - status: { - type: "string", - alias: "S", - description: "Status name (comma-separated for multiple)", - valueHint: `{${PR_STATUS_NAMES.join("|")}}`, - }, - assignee: commonArgs.assigneeList, - issue: { - type: "string", - description: "Issue ID (comma-separated for multiple)", - }, - "created-user": { - type: "string", - description: "Created user ID (comma-separated for multiple)", - }, - }, - async run({ args }) { - const { client } = await getClient(); + const statusId = opts.status + ? (opts.status as string) + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + .map((name) => { + const id = PrStatusName[name.toLowerCase()]; + if (id === undefined) { + throw new Error( + `Unknown status "${name}". Valid values: ${PR_STATUS_NAMES.join(", ")}`, + ); + } + return id; + }) + : undefined; - const statusId = args.status - ? args.status - .split(",") - .map((s) => s.trim()) - .filter(Boolean) - .map((name) => { - const id = PrStatusName[name.toLowerCase()]; - if (id === undefined) { - throw new Error( - `Unknown status "${name}". Valid values: ${PR_STATUS_NAMES.join(", ")}`, - ); - } - return id; - }) - : undefined; + const assigneeId = ((opts.assignee as string[]) ?? []) + .map(Number) + .filter((n) => !Number.isNaN(n)); + const issueId = splitArg(opts.issue as string | undefined, v.number()); + const createdUserId = splitArg(opts.createdUser as string | undefined, v.number()); - const assigneeId = splitArg(args.assignee, v.number()); - const issueId = splitArg(args.issue, v.number()); - const createdUserId = splitArg(args["created-user"], v.number()); + const result = await client.getPullRequestsCount(opts.project as string, opts.repo as string, { + statusId, + assigneeId: assigneeId.length > 0 ? assigneeId : undefined, + issueId: issueId.length > 0 ? issueId : undefined, + createdUserId: createdUserId.length > 0 ? createdUserId : undefined, + }); - const result = await client.getPullRequestsCount(args.project, args.repo, { - statusId, - assigneeId: assigneeId.length > 0 ? assigneeId : undefined, - issueId: issueId.length > 0 ? issueId : undefined, - createdUserId: createdUserId.length > 0 ? createdUserId : undefined, - }); - - outputResult(result, args, (data) => { - consola.log(data.count); - }); - }, - }), - commandUsage, -); + const json = opts.json === true ? "" : (opts.json as string | undefined); + outputResult(result, { json }, (data) => { + consola.log(data.count); + }); + }); -export { commandUsage, count }; +export default count; diff --git a/apps/cli/src/commands/pr/create.test.ts b/apps/cli/src/commands/pr/create.test.ts index 17057625..95fedd66 100644 --- a/apps/cli/src/commands/pr/create.test.ts +++ b/apps/cli/src/commands/pr/create.test.ts @@ -24,17 +24,24 @@ describe("pr create", () => { it("creates a pull request with required fields", async () => { mockClient.postPullRequest.mockResolvedValue({ number: 1, summary: "Add feature" }); - const { create } = await import("./create"); - await create.run?.({ - args: { - project: "PROJ", - repo: "repo", - base: "main", - head: "feature", - title: "Add feature", - body: "Details here", - }, - } as never); + const { default: create } = await import("./create"); + await create.parseAsync( + [ + "--project", + "PROJ", + "--repo", + "repo", + "--base", + "main", + "--head", + "feature", + "--title", + "Add feature", + "--body", + "Details here", + ], + { from: "user" }, + ); expect(mockClient.postPullRequest).toHaveBeenCalledWith("PROJ", "repo", { summary: "Add feature", @@ -53,18 +60,26 @@ describe("pr create", () => { mockClient.getMyself.mockResolvedValue({ id: 99 }); mockClient.postPullRequest.mockResolvedValue({ number: 2, summary: "Title" }); - const { create } = await import("./create"); - await create.run?.({ - args: { - project: "PROJ", - repo: "repo", - base: "main", - head: "feature", - title: "Title", - body: "Desc", - assignee: "@me", - }, - } as never); + const { default: create } = await import("./create"); + await create.parseAsync( + [ + "--project", + "PROJ", + "--repo", + "repo", + "--base", + "main", + "--head", + "feature", + "--title", + "Title", + "--body", + "Desc", + "--assignee", + "@me", + ], + { from: "user" }, + ); expect(mockClient.postPullRequest).toHaveBeenCalledWith( "PROJ", @@ -76,18 +91,26 @@ describe("pr create", () => { it("creates a pull request with related issue", async () => { mockClient.postPullRequest.mockResolvedValue({ number: 3, summary: "Title" }); - const { create } = await import("./create"); - await create.run?.({ - args: { - project: "PROJ", - repo: "repo", - base: "main", - head: "feature", - title: "Title", - body: "Desc", - issue: "456", - }, - } as never); + const { default: create } = await import("./create"); + await create.parseAsync( + [ + "--project", + "PROJ", + "--repo", + "repo", + "--base", + "main", + "--head", + "feature", + "--title", + "Title", + "--body", + "Desc", + "--issue", + "456", + ], + { from: "user" }, + ); expect(mockClient.postPullRequest).toHaveBeenCalledWith( "PROJ", @@ -100,18 +123,26 @@ describe("pr create", () => { mockClient.getIssue.mockResolvedValue({ id: 789 }); mockClient.postPullRequest.mockResolvedValue({ number: 4, summary: "Title" }); - const { create } = await import("./create"); - await create.run?.({ - args: { - project: "PROJ", - repo: "repo", - base: "main", - head: "feature", - title: "Title", - body: "Desc", - issue: "PROJ-123", - }, - } as never); + const { default: create } = await import("./create"); + await create.parseAsync( + [ + "--project", + "PROJ", + "--repo", + "repo", + "--base", + "main", + "--head", + "feature", + "--title", + "Title", + "--body", + "Desc", + "--issue", + "PROJ-123", + ], + { from: "user" }, + ); expect(mockClient.getIssue).toHaveBeenCalledWith("PROJ-123"); expect(mockClient.postPullRequest).toHaveBeenCalledWith( @@ -125,18 +156,25 @@ describe("pr create", () => { mockClient.postPullRequest.mockResolvedValue({ number: 1, summary: "Add feature" }); await expectStdoutContaining(async () => { - const { create } = await import("./create"); - await create.run?.({ - args: { - project: "PROJ", - repo: "repo", - base: "main", - head: "feature", - title: "Add feature", - body: "Details", - json: "", - }, - } as never); + const { default: create } = await import("./create"); + await create.parseAsync( + [ + "--project", + "PROJ", + "--repo", + "repo", + "--base", + "main", + "--head", + "feature", + "--title", + "Add feature", + "--body", + "Details", + "--json", + ], + { from: "user" }, + ); }, "Add feature"); }); }); diff --git a/apps/cli/src/commands/pr/create.ts b/apps/cli/src/commands/pr/create.ts index d8be5992..915c892a 100644 --- a/apps/cli/src/commands/pr/create.ts +++ b/apps/cli/src/commands/pr/create.ts @@ -1,24 +1,31 @@ import { getClient, resolveUserId } from "@repo/backlog-utils"; -import { outputArgs, outputResult, promptRequired, splitArg } from "@repo/cli-utils"; -import { defineCommand } from "citty"; +import { outputResult, promptRequired } from "@repo/cli-utils"; import consola from "consola"; -import * as v from "valibot"; -import { - type CommandUsage, - ENV_AUTH, - ENV_PROJECT, - ENV_REPO, - withUsage, -} from "../../lib/command-usage"; -import * as commonArgs from "../../lib/common-args"; +import { BeeCommand, ENV_AUTH, ENV_PROJECT, ENV_REPO } from "../../lib/bee-command"; +import * as opt from "../../lib/common-options"; +import { resolveOptions } from "../../lib/required-option"; -const commandUsage: CommandUsage = { - long: `Create a new pull request in a Backlog repository. +const create = new BeeCommand("create") + .summary("Create a pull request") + .description( + `Create a new pull request in a Backlog repository. Requires a base branch, head branch, title, and description. When run interactively, omitted required fields will be prompted.`, - - examples: [ + ) + .addOption(opt.project()) + .addOption(opt.repo()) + .option("--base <branch>", "Base branch name") + .option("--head <branch>", "Head branch name") + .option("-t, --title <text>", "Pull request title") + .option("-b, --body <text>", "Pull request description") + .addOption(opt.assignee()) + .option("--issue <key>", "Related issue ID or issue key") + .addOption(opt.notify()) + .addOption(opt.attachment()) + .addOption(opt.json()) + .envVars([...ENV_AUTH, ENV_PROJECT, ENV_REPO]) + .examples([ { description: "Create a pull request", command: @@ -34,89 +41,47 @@ interactively, omitted required fields will be prompted.`, command: 'bee pr create -p PROJECT -R repo --base main --head feature -t "Title" -b "Desc" --issue 123', }, - ], + ]) + .action(async (_opts, cmd) => { + const opts = await resolveOptions(cmd); + const { client } = await getClient(); - annotations: { - environment: [...ENV_AUTH, ENV_PROJECT, ENV_REPO], - }, -}; + const base = await promptRequired("Base branch:", opts.base as string | undefined); + const head = await promptRequired("Head branch:", opts.head as string | undefined); + const summary = await promptRequired("Summary:", opts.title as string | undefined); + const description = await promptRequired("Body:", opts.body as string | undefined); -const create = withUsage( - defineCommand({ - meta: { - name: "create", - description: "Create a pull request", - }, - args: { - ...outputArgs, - project: { ...commonArgs.project, required: true }, - repo: commonArgs.repo, - base: { - type: "string", - description: "Base branch name", - }, - head: { - type: "string", - description: "Head branch name", - }, - title: { - type: "string", - alias: "t", - description: "Pull request title", - }, - body: { - type: "string", - alias: "b", - description: "Pull request description", - }, - assignee: commonArgs.assignee, - issue: { - type: "string", - description: "Related issue ID or issue key", - valueHint: "<PROJECT-123>", - }, - notify: commonArgs.notify, - attachment: commonArgs.attachment, - }, - async run({ args }) { - const { client } = await getClient(); + const assigneeId = opts.assignee + ? await resolveUserId(client, opts.assignee as string) + : undefined; + const notifiedUserId = (opts.notify as number[]) ?? []; + const attachmentId = (opts.attachment as number[]) ?? []; - const base = await promptRequired("Base branch:", args.base); - const head = await promptRequired("Head branch:", args.head); - const summary = await promptRequired("Summary:", args.title); - const description = await promptRequired("Body:", args.body); - - const assigneeId = args.assignee ? await resolveUserId(client, args.assignee) : undefined; - const notifiedUserId = splitArg(args.notify, v.number()); - const attachmentId = splitArg(args.attachment, v.number()); - - let issueId: number | undefined; - if (args.issue) { - if (Number.isNaN(Number(args.issue))) { - const issue = await client.getIssue(args.issue); - issueId = issue.id; - } else { - issueId = Number(args.issue); - } + let issueId: number | undefined; + if (opts.issue) { + if (Number.isNaN(Number(opts.issue))) { + const issue = await client.getIssue(opts.issue as string); + issueId = issue.id; + } else { + issueId = Number(opts.issue); } + } - const pullRequest = await client.postPullRequest(args.project, args.repo, { - summary, - description, - base, - branch: head, - issueId, - assigneeId, - notifiedUserId, - attachmentId, - }); + const pullRequest = await client.postPullRequest(opts.project as string, opts.repo as string, { + summary, + description, + base, + branch: head, + issueId, + assigneeId, + notifiedUserId, + attachmentId, + }); - outputResult(pullRequest, args, (data) => { - consola.success(`Created pull request #${data.number}: ${data.summary}`); - }); - }, - }), - commandUsage, -); + const json = opts.json === true ? "" : (opts.json as string | undefined); + outputResult(pullRequest, { json }, (data) => { + consola.success(`Created pull request #${data.number}: ${data.summary}`); + }); + }); -export { commandUsage, create }; +export default create; diff --git a/apps/cli/src/commands/pr/edit.test.ts b/apps/cli/src/commands/pr/edit.test.ts index d6be340b..d601c43d 100644 --- a/apps/cli/src/commands/pr/edit.test.ts +++ b/apps/cli/src/commands/pr/edit.test.ts @@ -19,10 +19,10 @@ describe("pr edit", () => { it("updates pull request summary", async () => { mockClient.patchPullRequest.mockResolvedValue({ number: 42, summary: "New title" }); - const { edit } = await import("./edit"); - await edit.run?.({ - args: { number: "42", project: "PROJ", repo: "repo", title: "New title" }, - } as never); + const { default: edit } = await import("./edit"); + await edit.parseAsync(["42", "--project", "PROJ", "--repo", "repo", "--title", "New title"], { + from: "user", + }); expect(mockClient.patchPullRequest).toHaveBeenCalledWith("PROJ", "repo", 42, { summary: "New title", @@ -38,16 +38,11 @@ describe("pr edit", () => { it("updates pull request with a comment", async () => { mockClient.patchPullRequest.mockResolvedValue({ number: 42, summary: "Title" }); - const { edit } = await import("./edit"); - await edit.run?.({ - args: { - number: "42", - project: "PROJ", - repo: "repo", - title: "Title", - comment: "Updated", - }, - } as never); + const { default: edit } = await import("./edit"); + await edit.parseAsync( + ["42", "--project", "PROJ", "--repo", "repo", "--title", "Title", "--comment", "Updated"], + { from: "user" }, + ); expect(mockClient.patchPullRequest).toHaveBeenCalledWith( "PROJ", @@ -60,10 +55,11 @@ describe("pr edit", () => { it("updates pull request with notified users", async () => { mockClient.patchPullRequest.mockResolvedValue({ number: 42, summary: "Title" }); - const { edit } = await import("./edit"); - await edit.run?.({ - args: { number: "42", project: "PROJ", repo: "repo", notify: "111,222" }, - } as never); + const { default: edit } = await import("./edit"); + await edit.parseAsync( + ["42", "--project", "PROJ", "--repo", "repo", "--notify", "111", "--notify", "222"], + { from: "user" }, + ); expect(mockClient.patchPullRequest).toHaveBeenCalledWith( "PROJ", @@ -77,10 +73,10 @@ describe("pr edit", () => { mockClient.getIssue.mockResolvedValue({ id: 789 }); mockClient.patchPullRequest.mockResolvedValue({ number: 42, summary: "Title" }); - const { edit } = await import("./edit"); - await edit.run?.({ - args: { number: "42", project: "PROJ", repo: "repo", issue: "PROJ-123" }, - } as never); + const { default: edit } = await import("./edit"); + await edit.parseAsync(["42", "--project", "PROJ", "--repo", "repo", "--issue", "PROJ-123"], { + from: "user", + }); expect(mockClient.getIssue).toHaveBeenCalledWith("PROJ-123"); expect(mockClient.patchPullRequest).toHaveBeenCalledWith( @@ -95,10 +91,10 @@ describe("pr edit", () => { mockClient.getMyself.mockResolvedValue({ id: 99 }); mockClient.patchPullRequest.mockResolvedValue({ number: 42, summary: "Title" }); - const { edit } = await import("./edit"); - await edit.run?.({ - args: { number: "42", project: "PROJ", repo: "repo", assignee: "@me" }, - } as never); + const { default: edit } = await import("./edit"); + await edit.parseAsync(["42", "--project", "PROJ", "--repo", "repo", "--assignee", "@me"], { + from: "user", + }); expect(mockClient.getMyself).toHaveBeenCalled(); expect(mockClient.patchPullRequest).toHaveBeenCalledWith( @@ -113,10 +109,11 @@ describe("pr edit", () => { mockClient.patchPullRequest.mockResolvedValue({ number: 42, summary: "Title" }); await expectStdoutContaining(async () => { - const { edit } = await import("./edit"); - await edit.run?.({ - args: { number: "42", project: "PROJ", repo: "repo", title: "Title", json: "" }, - } as never); + const { default: edit } = await import("./edit"); + await edit.parseAsync( + ["42", "--project", "PROJ", "--repo", "repo", "--title", "Title", "--json"], + { from: "user" }, + ); }, "Title"); }); }); diff --git a/apps/cli/src/commands/pr/edit.ts b/apps/cli/src/commands/pr/edit.ts index 84bc5dc3..b73ce527 100644 --- a/apps/cli/src/commands/pr/edit.ts +++ b/apps/cli/src/commands/pr/edit.ts @@ -1,24 +1,30 @@ import { getClient, resolveUserId } from "@repo/backlog-utils"; -import { outputArgs, outputResult, splitArg } from "@repo/cli-utils"; -import { defineCommand } from "citty"; +import { outputResult } from "@repo/cli-utils"; import consola from "consola"; -import * as v from "valibot"; -import { - type CommandUsage, - ENV_AUTH, - ENV_PROJECT, - ENV_REPO, - withUsage, -} from "../../lib/command-usage"; -import * as commonArgs from "../../lib/common-args"; +import { BeeCommand, ENV_AUTH, ENV_PROJECT, ENV_REPO } from "../../lib/bee-command"; +import * as opt from "../../lib/common-options"; +import { resolveOptions } from "../../lib/required-option"; -const commandUsage: CommandUsage = { - long: `Update an existing Backlog pull request. +const edit = new BeeCommand("edit") + .summary("Edit a pull request") + .description( + `Update an existing Backlog pull request. Only the specified fields will be updated. Fields that are not provided will remain unchanged.`, - - examples: [ + ) + .argument("<number>", "Pull request number") + .addOption(opt.project()) + .addOption(opt.repo()) + .option("-t, --title <text>", "New title of the pull request") + .option("-b, --body <text>", "New description of the pull request") + .option("--assignee <id>", "New assignee user ID. Use @me for yourself.") + .option("--issue <key>", "New related issue ID or issue key") + .addOption(opt.comment()) + .addOption(opt.notify()) + .addOption(opt.json()) + .envVars([...ENV_AUTH, ENV_PROJECT, ENV_REPO]) + .examples([ { description: "Update pull request title", command: 'bee pr edit 42 -p PROJECT -R repo -t "New title"', @@ -31,84 +37,45 @@ will remain unchanged.`, description: "Add a comment with the update", command: 'bee pr edit 42 -p PROJECT -R repo -t "New title" --comment "Updated title"', }, - ], - - annotations: { - environment: [...ENV_AUTH, ENV_PROJECT, ENV_REPO], - }, -}; - -const edit = withUsage( - defineCommand({ - meta: { - name: "edit", - description: "Edit a pull request", - }, - args: { - ...outputArgs, - number: { - type: "positional", - description: "Pull request number", - valueHint: "<number>", - required: true, - }, - project: { ...commonArgs.project, required: true }, - repo: commonArgs.repo, - title: { - type: "string", - alias: "t", - description: "New title of the pull request", - }, - body: { - type: "string", - alias: "b", - description: "New description of the pull request", - }, - assignee: { - ...commonArgs.assignee, - alias: undefined, - description: "New assignee user ID. Use @me for yourself.", - }, - issue: { - type: "string", - description: "New related issue ID or issue key", - valueHint: "<PROJECT-123>", - }, - comment: commonArgs.comment, - notify: commonArgs.notify, - }, - async run({ args }) { - const { client } = await getClient(); + ]) + .action(async (number, _opts, cmd) => { + const opts = await resolveOptions(cmd); + const { client } = await getClient(); - const prNumber = Number(args.number); - const notifiedUserId = splitArg(args.notify, v.number()); + const prNumber = Number(number); + const notifiedUserId = (opts.notify as number[]) ?? []; - let issueId: number | undefined; - if (args.issue) { - if (Number.isNaN(Number(args.issue))) { - const issue = await client.getIssue(args.issue); - issueId = issue.id; - } else { - issueId = Number(args.issue); - } + let issueId: number | undefined; + if (opts.issue) { + if (Number.isNaN(Number(opts.issue))) { + const issue = await client.getIssue(opts.issue as string); + issueId = issue.id; + } else { + issueId = Number(opts.issue); } + } - const pullRequest = await client.patchPullRequest(args.project, args.repo, prNumber, { - summary: args.title, - description: args.body, + const pullRequest = await client.patchPullRequest( + opts.project as string, + opts.repo as string, + prNumber, + { + summary: opts.title as string | undefined, + description: opts.body as string | undefined, issueId, - assigneeId: args.assignee ? await resolveUserId(client, args.assignee) : undefined, + assigneeId: opts.assignee + ? await resolveUserId(client, opts.assignee as string) + : undefined, // @ts-expect-error backlog-js types say string[] but Backlog API accepts a single string - comment: args.comment ?? undefined, + comment: opts.comment ?? undefined, notifiedUserId, - }); + }, + ); - outputResult(pullRequest, args, (data) => { - consola.success(`Updated pull request #${data.number}: ${data.summary}`); - }); - }, - }), - commandUsage, -); + const json = opts.json === true ? "" : (opts.json as string | undefined); + outputResult(pullRequest, { json }, (data) => { + consola.success(`Updated pull request #${data.number}: ${data.summary}`); + }); + }); -export { commandUsage, edit }; +export default edit; diff --git a/apps/cli/src/commands/pr/list.test.ts b/apps/cli/src/commands/pr/list.test.ts index 5fcf8171..37c693cd 100644 --- a/apps/cli/src/commands/pr/list.test.ts +++ b/apps/cli/src/commands/pr/list.test.ts @@ -33,8 +33,8 @@ describe("pr list", () => { it("displays pull request list in tabular format", async () => { mockClient.getPullRequests.mockResolvedValue(samplePullRequests); - const { list } = await import("./list"); - await list.run?.({ args: { project: "PROJ", repo: "repo" } } as never); + const { default: list } = await import("./list"); + await list.parseAsync(["--project", "PROJ", "--repo", "repo"], { from: "user" }); expect(getClient).toHaveBeenCalled(); expect(mockClient.getPullRequests).toHaveBeenCalledWith("PROJ", "repo", expect.any(Object)); @@ -46,8 +46,8 @@ describe("pr list", () => { it("shows message when no pull requests found", async () => { mockClient.getPullRequests.mockResolvedValue([]); - const { list } = await import("./list"); - await list.run?.({ args: { project: "PROJ", repo: "repo" } } as never); + const { default: list } = await import("./list"); + await list.parseAsync(["--project", "PROJ", "--repo", "repo"], { from: "user" }); expect(consola.info).toHaveBeenCalledWith("No pull requests found."); }); @@ -55,8 +55,8 @@ describe("pr list", () => { it("shows Unassigned for pull requests without assignee", async () => { mockClient.getPullRequests.mockResolvedValue([samplePullRequests[1]]); - const { list } = await import("./list"); - await list.run?.({ args: { project: "PROJ", repo: "repo" } } as never); + const { default: list } = await import("./list"); + await list.parseAsync(["--project", "PROJ", "--repo", "repo"], { from: "user" }); expect(consola.log).toHaveBeenCalledWith(expect.stringContaining("Unassigned")); }); @@ -64,8 +64,10 @@ describe("pr list", () => { it("passes status filter parameter", async () => { mockClient.getPullRequests.mockResolvedValue([]); - const { list } = await import("./list"); - await list.run?.({ args: { project: "PROJ", repo: "repo", status: "open" } } as never); + const { default: list } = await import("./list"); + await list.parseAsync(["--project", "PROJ", "--repo", "repo", "--status", "open"], { + from: "user", + }); expect(mockClient.getPullRequests).toHaveBeenCalledWith( "PROJ", @@ -75,17 +77,21 @@ describe("pr list", () => { }); it("throws error for unknown status name", async () => { - const { list } = await import("./list"); + const { default: list } = await import("./list"); await expect( - list.run?.({ args: { project: "PROJ", repo: "repo", status: "invalid" } } as never), + list.parseAsync(["--project", "PROJ", "--repo", "repo", "--status", "invalid"], { + from: "user", + }), ).rejects.toThrow('Unknown status "invalid"'); }); it("passes assignee filter parameter", async () => { mockClient.getPullRequests.mockResolvedValue([]); - const { list } = await import("./list"); - await list.run?.({ args: { project: "PROJ", repo: "repo", assignee: "42" } } as never); + const { default: list } = await import("./list"); + await list.parseAsync(["--project", "PROJ", "--repo", "repo", "--assignee", "42"], { + from: "user", + }); expect(mockClient.getPullRequests).toHaveBeenCalledWith( "PROJ", @@ -98,8 +104,8 @@ describe("pr list", () => { mockClient.getPullRequests.mockResolvedValue(samplePullRequests); await expectStdoutContaining(async () => { - const { list } = await import("./list"); - await list.run?.({ args: { project: "PROJ", repo: "repo", json: "" } } as never); + const { default: list } = await import("./list"); + await list.parseAsync(["--project", "PROJ", "--repo", "repo", "--json"], { from: "user" }); }, "Add feature A"); }); }); diff --git a/apps/cli/src/commands/pr/list.ts b/apps/cli/src/commands/pr/list.ts index 4f960a2a..a47e1453 100644 --- a/apps/cli/src/commands/pr/list.ts +++ b/apps/cli/src/commands/pr/list.ts @@ -1,24 +1,30 @@ import { PR_STATUS_NAMES, PrStatusName, getClient } from "@repo/backlog-utils"; -import { type Row, outputArgs, outputResult, printTable, splitArg } from "@repo/cli-utils"; -import { defineCommand } from "citty"; +import { type Row, outputResult, printTable, splitArg } from "@repo/cli-utils"; import consola from "consola"; import * as v from "valibot"; -import { - type CommandUsage, - ENV_AUTH, - ENV_PROJECT, - ENV_REPO, - withUsage, -} from "../../lib/command-usage"; -import * as commonArgs from "../../lib/common-args"; +import { BeeCommand, ENV_AUTH, ENV_PROJECT, ENV_REPO } from "../../lib/bee-command"; +import * as opt from "../../lib/common-options"; +import { resolveOptions } from "../../lib/required-option"; -const commandUsage: CommandUsage = { - long: `List pull requests in a Backlog repository. +const list = new BeeCommand("list") + .summary("List pull requests") + .description( + `List pull requests in a Backlog repository. By default, all pull requests are returned. Use \`--status\` to filter by status (open, closed, merged).`, - - examples: [ + ) + .addOption(opt.project()) + .addOption(opt.repo()) + .option("-S, --status <name>", "Status name (comma-separated for multiple)") + .addOption(opt.assigneeList()) + .option("--issue <ids>", "Issue ID (comma-separated for multiple)") + .option("--created-user <ids>", "Created user ID (comma-separated for multiple)") + .addOption(opt.count()) + .addOption(opt.offset()) + .addOption(opt.json()) + .envVars([...ENV_AUTH, ENV_PROJECT, ENV_REPO]) + .examples([ { description: "List pull requests", command: "bee pr list -p PROJECT -R repo" }, { description: "List open pull requests only", @@ -29,91 +35,58 @@ status (open, closed, merged).`, command: "bee pr list -p PROJECT -R repo --assignee @me", }, { description: "Output as JSON", command: "bee pr list -p PROJECT -R repo --json" }, - ], - - annotations: { - environment: [...ENV_AUTH, ENV_PROJECT, ENV_REPO], - }, -}; - -const list = withUsage( - defineCommand({ - meta: { - name: "list", - description: "List pull requests", - }, - args: { - ...outputArgs, - project: { ...commonArgs.project, required: true }, - repo: commonArgs.repo, - status: { - type: "string", - alias: "S", - description: "Status name (comma-separated for multiple)", - valueHint: `{${PR_STATUS_NAMES.join("|")}}`, - }, - assignee: commonArgs.assigneeList, - issue: { - type: "string", - description: "Issue ID (comma-separated for multiple)", - }, - "created-user": { - type: "string", - description: "Created user ID (comma-separated for multiple)", - }, - count: commonArgs.count, - offset: commonArgs.offset, - }, - async run({ args }) { - const { client } = await getClient(); + ]) + .action(async (_opts, cmd) => { + const opts = await resolveOptions(cmd); + const { client } = await getClient(); - const statusId = args.status - ? args.status - .split(",") - .map((s) => s.trim()) - .filter(Boolean) - .map((name) => { - const id = PrStatusName[name.toLowerCase()]; - if (id === undefined) { - throw new Error( - `Unknown status "${name}". Valid values: ${PR_STATUS_NAMES.join(", ")}`, - ); - } - return id; - }) - : undefined; + const statusId = opts.status + ? (opts.status as string) + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + .map((name) => { + const id = PrStatusName[name.toLowerCase()]; + if (id === undefined) { + throw new Error( + `Unknown status "${name}". Valid values: ${PR_STATUS_NAMES.join(", ")}`, + ); + } + return id; + }) + : undefined; - const assigneeId = splitArg(args.assignee, v.number()); - const issueId = splitArg(args.issue, v.number()); - const createdUserId = splitArg(args["created-user"], v.number()); + const assigneeId = ((opts.assignee as string[]) ?? []) + .map(Number) + .filter((n) => !Number.isNaN(n)); + const issueId = splitArg(opts.issue as string | undefined, v.number()); + const createdUserId = splitArg(opts.createdUser as string | undefined, v.number()); - const pullRequests = await client.getPullRequests(args.project, args.repo, { - statusId, - assigneeId: assigneeId.length > 0 ? assigneeId : undefined, - issueId: issueId.length > 0 ? issueId : undefined, - createdUserId: createdUserId.length > 0 ? createdUserId : undefined, - count: args.count ? Number(args.count) : undefined, - offset: args.offset ? Number(args.offset) : undefined, - }); + const pullRequests = await client.getPullRequests(opts.project as string, opts.repo as string, { + statusId, + assigneeId: assigneeId.length > 0 ? assigneeId : undefined, + issueId: issueId.length > 0 ? issueId : undefined, + createdUserId: createdUserId.length > 0 ? createdUserId : undefined, + count: opts.count ? Number(opts.count) : undefined, + offset: opts.offset ? Number(opts.offset) : undefined, + }); - outputResult(pullRequests, args, (data) => { - if (data.length === 0) { - consola.info("No pull requests found."); - return; - } + const json = opts.json === true ? "" : (opts.json as string | undefined); + outputResult(pullRequests, { json }, (data) => { + if (data.length === 0) { + consola.info("No pull requests found."); + return; + } - const rows: Row[] = data.map((pr) => [ - { header: "#", value: String(pr.number) }, - { header: "STATUS", value: pr.status.name }, - { header: "ASSIGNEE", value: pr.assignee?.name ?? "Unassigned" }, - { header: "SUMMARY", value: pr.summary }, - ]); + const rows: Row[] = data.map((pr) => [ + { header: "#", value: String(pr.number) }, + { header: "STATUS", value: pr.status.name }, + { header: "ASSIGNEE", value: pr.assignee?.name ?? "Unassigned" }, + { header: "SUMMARY", value: pr.summary }, + ]); - printTable(rows); - }); - }, - }), - commandUsage, -); + printTable(rows); + }); + }); -export { commandUsage, list }; +export default list; diff --git a/apps/cli/src/commands/pr/status.test.ts b/apps/cli/src/commands/pr/status.test.ts index db69ecb2..bc1275d3 100644 --- a/apps/cli/src/commands/pr/status.test.ts +++ b/apps/cli/src/commands/pr/status.test.ts @@ -25,8 +25,8 @@ describe("pr status", () => { mockClient.getMyself.mockResolvedValue({ id: 1, name: "Alice" }); mockClient.getPullRequests.mockResolvedValue(samplePullRequests); - const { status } = await import("./status"); - await status.run?.({ args: { project: "PROJ", repo: "repo" } } as never); + const { default: status } = await import("./status"); + await status.parseAsync(["--project", "PROJ", "--repo", "repo"], { from: "user" }); expect(mockClient.getMyself).toHaveBeenCalled(); expect(mockClient.getPullRequests).toHaveBeenCalledWith( @@ -43,8 +43,8 @@ describe("pr status", () => { mockClient.getMyself.mockResolvedValue({ id: 1, name: "Alice" }); mockClient.getPullRequests.mockResolvedValue([]); - const { status } = await import("./status"); - await status.run?.({ args: { project: "PROJ", repo: "repo" } } as never); + const { default: status } = await import("./status"); + await status.parseAsync(["--project", "PROJ", "--repo", "repo"], { from: "user" }); expect(consola.info).toHaveBeenCalledWith("No pull requests assigned to you."); }); @@ -54,8 +54,8 @@ describe("pr status", () => { mockClient.getPullRequests.mockResolvedValue(samplePullRequests); await expectStdoutContaining(async () => { - const { status } = await import("./status"); - await status.run?.({ args: { project: "PROJ", repo: "repo", json: "" } } as never); + const { default: status } = await import("./status"); + await status.parseAsync(["--project", "PROJ", "--repo", "repo", "--json"], { from: "user" }); }, "Alice"); }); }); diff --git a/apps/cli/src/commands/pr/status.ts b/apps/cli/src/commands/pr/status.ts index 2d9462e4..eba6e35c 100644 --- a/apps/cli/src/commands/pr/status.ts +++ b/apps/cli/src/commands/pr/status.ts @@ -1,99 +1,71 @@ import { PrStatusId, getClient } from "@repo/backlog-utils"; -import { outputArgs, outputResult } from "@repo/cli-utils"; -import { defineCommand } from "citty"; +import { outputResult } from "@repo/cli-utils"; import consola from "consola"; -import { - type CommandUsage, - ENV_AUTH, - ENV_PROJECT, - ENV_REPO, - withUsage, -} from "../../lib/command-usage"; +import { BeeCommand, ENV_AUTH, ENV_PROJECT, ENV_REPO } from "../../lib/bee-command"; +import * as opt from "../../lib/common-options"; +import { resolveOptions } from "../../lib/required-option"; -const commandUsage: CommandUsage = { - long: `Show a summary of pull requests assigned to you, grouped by status. +const status = new BeeCommand("status") + .summary("Show pull request status summary for yourself") + .description( + `Show a summary of pull requests assigned to you, grouped by status. Fetches pull requests where you are the assignee and displays them organized by their current status (Open, Closed, Merged).`, - - examples: [ + ) + .addOption(opt.project()) + .addOption(opt.repo()) + .addOption(opt.json()) + .envVars([...ENV_AUTH, ENV_PROJECT, ENV_REPO]) + .examples([ { description: "Show your pull request status summary", command: "bee pr status -p PROJECT -R repo", }, { description: "Output as JSON", command: "bee pr status -p PROJECT -R repo --json" }, - ], + ]) + .action(async (_opts, cmd) => { + const opts = await resolveOptions(cmd); + const { client } = await getClient(); - annotations: { - environment: [...ENV_AUTH, ENV_PROJECT, ENV_REPO], - }, -}; + const me = await client.getMyself(); -const status = withUsage( - defineCommand({ - meta: { - name: "status", - description: "Show pull request status summary for yourself", - }, - args: { - ...outputArgs, - project: { - type: "string", - alias: "p", - description: "Project ID or project key", - default: process.env.BACKLOG_PROJECT, - required: true, - }, - repo: { - type: "string", - alias: "R", - description: "Repository name or ID", - default: process.env.BACKLOG_REPO, - required: true, - }, - }, - async run({ args }) { - const { client } = await getClient(); + const pullRequests = await client.getPullRequests(opts.project as string, opts.repo as string, { + assigneeId: [me.id], + statusId: [PrStatusId.Open, PrStatusId.Closed, PrStatusId.Merged], + count: 100, + }); - const me = await client.getMyself(); + const json = opts.json === true ? "" : (opts.json as string | undefined); - const pullRequests = await client.getPullRequests(args.project, args.repo, { - assigneeId: [me.id], - statusId: [PrStatusId.Open, PrStatusId.Closed, PrStatusId.Merged], - count: 100, + if (pullRequests.length === 0) { + outputResult({ user: me, pullRequests: [] }, { json }, () => { + consola.info("No pull requests assigned to you."); }); + return; + } - if (pullRequests.length === 0) { - outputResult({ user: me, pullRequests: [] }, args, () => { - consola.info("No pull requests assigned to you."); - }); - return; + outputResult({ user: me, pullRequests }, { json }, (data) => { + const grouped = new Map<string, typeof data.pullRequests>(); + for (const pr of data.pullRequests) { + const { name } = pr.status; + const group = grouped.get(name) ?? []; + group.push(pr); + grouped.set(name, group); } - outputResult({ user: me, pullRequests }, args, (data) => { - const grouped = new Map<string, typeof data.pullRequests>(); - for (const pr of data.pullRequests) { - const { name } = pr.status; - const group = grouped.get(name) ?? []; - group.push(pr); - grouped.set(name, group); - } + consola.log(""); + consola.log(` Pull requests assigned to ${data.user.name}:`); + consola.log(""); - consola.log(""); - consola.log(` Pull requests assigned to ${data.user.name}:`); - consola.log(""); - - for (const [statusName, statusPrs] of grouped) { - consola.log(` ${statusName} (${statusPrs.length}):`); - for (const pr of statusPrs) { - consola.log(` #${pr.number} ${pr.summary}`); - } - consola.log(""); + for (const [statusName, statusPrs] of grouped) { + consola.log(` ${statusName} (${statusPrs.length}):`); + for (const pr of statusPrs) { + consola.log(` #${pr.number} ${pr.summary}`); } - }); - }, - }), - commandUsage, -); + consola.log(""); + } + }); + }); -export { commandUsage, status }; +export default status; diff --git a/apps/cli/src/commands/pr/view.test.ts b/apps/cli/src/commands/pr/view.test.ts index 1ee446e4..e4996ca0 100644 --- a/apps/cli/src/commands/pr/view.test.ts +++ b/apps/cli/src/commands/pr/view.test.ts @@ -39,8 +39,8 @@ describe("pr view", () => { it("displays pull request details", async () => { mockClient.getPullRequest.mockResolvedValue(samplePullRequest); - const { view } = await import("./view"); - await view.run?.({ args: { number: "42", project: "PROJ", repo: "repo" } } as never); + const { default: view } = await import("./view"); + await view.parseAsync(["42", "--project", "PROJ", "--repo", "repo"], { from: "user" }); expect(mockClient.getPullRequest).toHaveBeenCalledWith("PROJ", "repo", 42); expect(consola.log).toHaveBeenCalledWith(expect.stringContaining("#42")); @@ -53,8 +53,8 @@ describe("pr view", () => { it("shows Unassigned for pull requests without assignee", async () => { mockClient.getPullRequest.mockResolvedValue({ ...samplePullRequest, assignee: null }); - const { view } = await import("./view"); - await view.run?.({ args: { number: "42", project: "PROJ", repo: "repo" } } as never); + const { default: view } = await import("./view"); + await view.parseAsync(["42", "--project", "PROJ", "--repo", "repo"], { from: "user" }); expect(consola.log).toHaveBeenCalledWith(expect.stringContaining("Unassigned")); }); @@ -62,16 +62,18 @@ describe("pr view", () => { it("displays description when present", async () => { mockClient.getPullRequest.mockResolvedValue(samplePullRequest); - const { view } = await import("./view"); - await view.run?.({ args: { number: "42", project: "PROJ", repo: "repo" } } as never); + const { default: view } = await import("./view"); + await view.parseAsync(["42", "--project", "PROJ", "--repo", "repo"], { from: "user" }); expect(consola.log).toHaveBeenCalledWith(expect.stringContaining("Description")); expect(consola.log).toHaveBeenCalledWith(expect.stringContaining("Detailed description")); }); it("opens browser with --web flag", async () => { - const { view } = await import("./view"); - await view.run?.({ args: { number: "42", project: "PROJ", repo: "repo", web: true } } as never); + const { default: view } = await import("./view"); + await view.parseAsync(["42", "--project", "PROJ", "--repo", "repo", "--web"], { + from: "user", + }); expect(openOrPrintUrl).toHaveBeenCalledWith( "https://example.backlog.com/git/PROJ/repo/pullRequests/42", @@ -85,10 +87,10 @@ describe("pr view", () => { mockClient.getPullRequest.mockResolvedValue(samplePullRequest); await expectStdoutContaining(async () => { - const { view } = await import("./view"); - await view.run?.({ - args: { number: "42", project: "PROJ", repo: "repo", json: "" }, - } as never); + const { default: view } = await import("./view"); + await view.parseAsync(["42", "--project", "PROJ", "--repo", "repo", "--json"], { + from: "user", + }); }, "Add feature A"); }); }); diff --git a/apps/cli/src/commands/pr/view.ts b/apps/cli/src/commands/pr/view.ts index 82a4ec39..33c61139 100644 --- a/apps/cli/src/commands/pr/view.ts +++ b/apps/cli/src/commands/pr/view.ts @@ -1,102 +1,83 @@ import { getClient, openOrPrintUrl, pullRequestUrl } from "@repo/backlog-utils"; -import { formatDate, outputArgs, outputResult, printDefinitionList } from "@repo/cli-utils"; -import { defineCommand } from "citty"; +import { formatDate, outputResult, printDefinitionList } from "@repo/cli-utils"; import consola from "consola"; -import { - type CommandUsage, - ENV_AUTH, - ENV_PROJECT, - ENV_REPO, - withUsage, -} from "../../lib/command-usage"; -import * as commonArgs from "../../lib/common-args"; +import { BeeCommand, ENV_AUTH, ENV_PROJECT, ENV_REPO } from "../../lib/bee-command"; +import * as opt from "../../lib/common-options"; +import { resolveOptions } from "../../lib/required-option"; -const commandUsage: CommandUsage = { - long: `Display details of a Backlog pull request. +const view = new BeeCommand("view") + .summary("View a pull request") + .description( + `Display details of a Backlog pull request. Shows the pull request summary, status, assignee, base/head branches, and description. Use \`--web\` to open the pull request in your default browser instead.`, - - examples: [ + ) + .argument("<number>", "Pull request number") + .addOption(opt.project()) + .addOption(opt.repo()) + .addOption(opt.web("pull request")) + .addOption(opt.noBrowser()) + .addOption(opt.json()) + .envVars([...ENV_AUTH, ENV_PROJECT, ENV_REPO]) + .examples([ { description: "View pull request details", command: "bee pr view 42 -p PROJECT -R repo" }, { description: "Open pull request in browser", command: "bee pr view 42 -p PROJECT -R repo --web", }, { description: "Output as JSON", command: "bee pr view 42 -p PROJECT -R repo --json" }, - ], + ]) + .action(async (number, _opts, cmd) => { + const opts = await resolveOptions(cmd); + const { client, host } = await getClient(); - annotations: { - environment: [...ENV_AUTH, ENV_PROJECT, ENV_REPO], - }, -}; + const prNumber = Number(number); -const view = withUsage( - defineCommand({ - meta: { - name: "view", - description: "View a pull request", - }, - args: { - ...outputArgs, - number: { - type: "positional", - description: "Pull request number", - valueHint: "<number>", - required: true, - }, - project: { ...commonArgs.project, required: true }, - repo: commonArgs.repo, - web: commonArgs.web("pull request"), - "no-browser": commonArgs.noBrowser, - }, - async run({ args }) { - const { client, host } = await getClient(); + if (opts.web || opts.browser === false) { + const url = pullRequestUrl(host, opts.project as string, opts.repo as string, prNumber); + await openOrPrintUrl(url, opts.browser === false, consola); + return; + } - const prNumber = Number(args.number); - - if (args.web || args["no-browser"]) { - const url = pullRequestUrl(host, args.project, args.repo, prNumber); - await openOrPrintUrl(url, Boolean(args["no-browser"]), consola); - return; - } + const pullRequest = await client.getPullRequest( + opts.project as string, + opts.repo as string, + prNumber, + ); - const pullRequest = await client.getPullRequest(args.project, args.repo, prNumber); + const json = opts.json === true ? "" : (opts.json as string | undefined); + outputResult(pullRequest, { json }, (data) => { + consola.log(""); + consola.log(` #${data.number}: ${data.summary}`); + consola.log(""); + printDefinitionList([ + ["Status", data.status.name], + ["Base", data.base], + ["Head", data.branch], + ["Assignee", data.assignee?.name ?? "Unassigned"], + ["Created by", data.createdUser?.name ?? "Unknown"], + ["Created", formatDate(data.created)], + ["Updated", formatDate(data.updated)], + ["Merged at", data.mergeAt ? formatDate(data.mergeAt) : undefined], + ["Closed at", data.closeAt ? formatDate(data.closeAt) : undefined], + ]); - outputResult(pullRequest, args, (data) => { + if (data.description) { consola.log(""); - consola.log(` #${data.number}: ${data.summary}`); - consola.log(""); - printDefinitionList([ - ["Status", data.status.name], - ["Base", data.base], - ["Head", data.branch], - ["Assignee", data.assignee?.name ?? "Unassigned"], - ["Created by", data.createdUser?.name ?? "Unknown"], - ["Created", formatDate(data.created)], - ["Updated", formatDate(data.updated)], - ["Merged at", data.mergeAt ? formatDate(data.mergeAt) : undefined], - ["Closed at", data.closeAt ? formatDate(data.closeAt) : undefined], - ]); - - if (data.description) { - consola.log(""); - consola.log(" Description:"); - consola.log( - data.description - .split("\n") - .map((line) => ` ${line}`) - .join("\n"), - ); - } + consola.log(" Description:"); + consola.log( + data.description + .split("\n") + .map((line) => ` ${line}`) + .join("\n"), + ); + } - consola.log(""); - }); - }, - }), - commandUsage, -); + consola.log(""); + }); + }); -export { commandUsage, view }; +export default view; From 2b9f68c6459df63b525afa5f6d92ef768aa074bb Mon Sep 17 00:00:00 2001 From: Ryoya Tamura <ryoya.tamura@nulab.com> Date: Sun, 8 Mar 2026 18:22:05 +0900 Subject: [PATCH 06/16] refactor(cli): migrate project commands to commander Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --- .../src/commands/project/activities.test.ts | 31 ++-- apps/cli/src/commands/project/activities.ts | 152 ++++++++---------- .../cli/src/commands/project/add-user.test.ts | 25 +-- apps/cli/src/commands/project/add-user.ts | 80 ++++----- apps/cli/src/commands/project/create.test.ts | 26 ++- apps/cli/src/commands/project/create.ts | 117 +++++--------- apps/cli/src/commands/project/delete.test.ts | 17 +- apps/cli/src/commands/project/delete.ts | 87 ++++------ apps/cli/src/commands/project/edit.test.ts | 25 +-- apps/cli/src/commands/project/edit.ts | 128 ++++++--------- apps/cli/src/commands/project/list.test.ts | 20 +-- apps/cli/src/commands/project/list.ts | 99 +++++------- .../src/commands/project/remove-user.test.ts | 25 +-- apps/cli/src/commands/project/remove-user.ts | 80 ++++----- apps/cli/src/commands/project/users.test.ts | 17 +- apps/cli/src/commands/project/users.ts | 99 +++++------- apps/cli/src/commands/project/view.test.ts | 21 ++- apps/cli/src/commands/project/view.ts | 117 ++++++-------- 18 files changed, 497 insertions(+), 669 deletions(-) diff --git a/apps/cli/src/commands/project/activities.test.ts b/apps/cli/src/commands/project/activities.test.ts index 22e54620..53479509 100644 --- a/apps/cli/src/commands/project/activities.test.ts +++ b/apps/cli/src/commands/project/activities.test.ts @@ -11,6 +11,11 @@ vi.mock("@repo/backlog-utils", async (importOriginal) => ({ getClient: vi.fn(() => Promise.resolve({ client: mockClient, host: "example.backlog.com" })), })); +vi.mock("@repo/cli-utils", async (importOriginal) => ({ + ...(await importOriginal()), + promptRequired: vi.fn((label: string, value: unknown) => Promise.resolve(value)), +})); + vi.mock("consola", () => import("@repo/test-utils/mock-consola")); describe("project activities", () => { @@ -32,8 +37,8 @@ describe("project activities", () => { }, ]); - const { activities } = await import("./activities"); - await activities.run?.({ args: { project: "PROJ1" } } as never); + const { default: activities } = await import("./activities"); + await activities.parseAsync(["--project", "PROJ1"], { from: "user" }); expect(mockClient.getProjectActivities).toHaveBeenCalledWith("PROJ1", expect.any(Object)); expect(consola.log).toHaveBeenCalledWith(expect.stringContaining("2024-01-15")); @@ -45,8 +50,8 @@ describe("project activities", () => { it("shows message when no activities found", async () => { mockClient.getProjectActivities.mockResolvedValue([]); - const { activities } = await import("./activities"); - await activities.run?.({ args: { project: "PROJ1" } } as never); + const { default: activities } = await import("./activities"); + await activities.parseAsync(["--project", "PROJ1"], { from: "user" }); expect(consola.info).toHaveBeenCalledWith("No activities found."); }); @@ -54,10 +59,10 @@ describe("project activities", () => { it("passes activity type filter as array of numbers", async () => { mockClient.getProjectActivities.mockResolvedValue([]); - const { activities } = await import("./activities"); - await activities.run?.({ - args: { project: "PROJ1", "activity-type": "1,2,3" }, - } as never); + const { default: activities } = await import("./activities"); + await activities.parseAsync(["--project", "PROJ1", "--activity-type", "1,2,3"], { + from: "user", + }); expect(mockClient.getProjectActivities).toHaveBeenCalledWith( "PROJ1", @@ -70,10 +75,8 @@ describe("project activities", () => { it("passes count parameter", async () => { mockClient.getProjectActivities.mockResolvedValue([]); - const { activities } = await import("./activities"); - await activities.run?.({ - args: { project: "PROJ1", count: "50" }, - } as never); + const { default: activities } = await import("./activities"); + await activities.parseAsync(["--project", "PROJ1", "--count", "50"], { from: "user" }); expect(mockClient.getProjectActivities).toHaveBeenCalledWith( "PROJ1", @@ -93,8 +96,8 @@ describe("project activities", () => { ]); await expectStdoutContaining(async () => { - const { activities } = await import("./activities"); - await activities.run?.({ args: { project: "PROJ1", json: "" } } as never); + const { default: activities } = await import("./activities"); + await activities.parseAsync(["--project", "PROJ1", "--json"], { from: "user" }); }, "Test"); }); }); diff --git a/apps/cli/src/commands/project/activities.ts b/apps/cli/src/commands/project/activities.ts index 36122793..5c3795d5 100644 --- a/apps/cli/src/commands/project/activities.ts +++ b/apps/cli/src/commands/project/activities.ts @@ -1,50 +1,10 @@ import { ACTIVITY_LABELS, getClient } from "@repo/backlog-utils"; -import { - type Row, - formatDate, - outputArgs, - outputResult, - printTable, - splitArg, -} from "@repo/cli-utils"; -import { defineCommand } from "citty"; +import { type Row, formatDate, outputResult, printTable } from "@repo/cli-utils"; +import { Option } from "commander"; import consola from "consola"; -import * as v from "valibot"; -import { type CommandUsage, ENV_AUTH, ENV_PROJECT, withUsage } from "../../lib/command-usage"; -import * as commonArgs from "../../lib/common-args"; - -const commandUsage: CommandUsage = { - long: `List recent activities of a Backlog project. - -Shows the most recent updates including issue changes, wiki edits, git pushes, -and other project activities. Results are ordered by most recent first. - -Use \`--activity-type\` to filter by specific activity types (comma-separated IDs). -Use \`--count\` to control how many activities are returned (default: 20, max: 100). - -For a list of activity type IDs, see: -https://developer.nulab.com/docs/backlog/api/2/get-project-recent-updates/#activity-type`, - - examples: [ - { description: "List recent activities", command: "bee project activities PROJECT_KEY" }, - { - description: "Show only issue-related activities", - command: "bee project activities PROJECT_KEY --activity-type 1,2,3", - }, - { - description: "Show last 50 activities", - command: "bee project activities PROJECT_KEY --count 50", - }, - { - description: "Output as JSON", - command: "bee project activities PROJECT_KEY --json", - }, - ], - - annotations: { - environment: [...ENV_AUTH, ENV_PROJECT], - }, -}; +import { BeeCommand, ENV_AUTH, ENV_PROJECT } from "../../lib/bee-command"; +import * as opt from "../../lib/common-options"; +import { resolveOptions } from "../../lib/required-option"; const getActivitySummary = (activity: { type: number; @@ -73,52 +33,72 @@ const getActivitySummary = (activity: { return ""; }; -const activities = withUsage( - defineCommand({ - meta: { - name: "activities", - description: "List project activities", +const activities = new BeeCommand("activities") + .summary("List project activities") + .description( + `List recent activities of a Backlog project. + +Shows the most recent updates including issue changes, wiki edits, git pushes, +and other project activities. Results are ordered by most recent first. + +Use \`--activity-type\` to filter by specific activity types (comma-separated IDs). +Use \`--count\` to control how many activities are returned (default: 20, max: 100). + +For a list of activity type IDs, see: +https://developer.nulab.com/docs/backlog/api/2/get-project-recent-updates/#activity-type`, + ) + .addOption(opt.project()) + .addOption(new Option("--activity-type <ids>", "Filter by activity type IDs (comma-separated)")) + .addOption(opt.count()) + .addOption(opt.order()) + .addOption(opt.json()) + .envVars([...ENV_AUTH, ENV_PROJECT]) + .examples([ + { description: "List recent activities", command: "bee project activities -p PROJECT_KEY" }, + { + description: "Show only issue-related activities", + command: "bee project activities -p PROJECT_KEY --activity-type 1,2,3", + }, + { + description: "Show last 50 activities", + command: "bee project activities -p PROJECT_KEY --count 50", }, - args: { - ...outputArgs, - project: commonArgs.projectPositional, - "activity-type": { - type: "string", - description: "Filter by activity type IDs (comma-separated)", - valueHint: "<1,2,3>", - }, - count: commonArgs.count, - order: commonArgs.order, + { + description: "Output as JSON", + command: "bee project activities -p PROJECT_KEY --json", }, - async run({ args }) { - const { client } = await getClient(); + ]) + .action(async (_opts, cmd) => { + const opts = await resolveOptions(cmd); - const activityTypeId = splitArg(args["activity-type"], v.number()); + const { client } = await getClient(); - const activityList = await client.getProjectActivities(args.project, { - activityTypeId, - count: args.count ? Number(args.count) : undefined, - order: args.order as "asc" | "desc" | undefined, - }); + const activityTypeId = opts.activityType + ? String(opts.activityType).split(",").map(Number) + : undefined; - outputResult(activityList, args, (data) => { - if (data.length === 0) { - consola.info("No activities found."); - return; - } + const activityList = await client.getProjectActivities(opts.project as string, { + activityTypeId, + count: opts.count ? Number(opts.count) : undefined, + order: opts.order as "asc" | "desc" | undefined, + }); - const rows: Row[] = data.map((activity) => [ - { header: "DATE", value: formatDate(activity.created) }, - { header: "TYPE", value: ACTIVITY_LABELS[activity.type] ?? `Type ${activity.type}` }, - { header: "USER", value: activity.createdUser?.name ?? "Unknown" }, - { header: "SUMMARY", value: getActivitySummary(activity) }, - ]); + const jsonArg = opts.json === true ? "" : (opts.json as string | undefined); + outputResult(activityList, { ...opts, json: jsonArg }, (data) => { + if (data.length === 0) { + consola.info("No activities found."); + return; + } - printTable(rows); - }); - }, - }), - commandUsage, -); + const rows: Row[] = data.map((activity) => [ + { header: "DATE", value: formatDate(activity.created) }, + { header: "TYPE", value: ACTIVITY_LABELS[activity.type] ?? `Type ${activity.type}` }, + { header: "USER", value: activity.createdUser?.name ?? "Unknown" }, + { header: "SUMMARY", value: getActivitySummary(activity) }, + ]); + + printTable(rows); + }); + }); -export { commandUsage, activities }; +export default activities; diff --git a/apps/cli/src/commands/project/add-user.test.ts b/apps/cli/src/commands/project/add-user.test.ts index df451c86..943644df 100644 --- a/apps/cli/src/commands/project/add-user.test.ts +++ b/apps/cli/src/commands/project/add-user.test.ts @@ -10,28 +10,29 @@ vi.mock("@repo/backlog-utils", () => ({ getClient: vi.fn(() => Promise.resolve({ client: mockClient, host: "example.backlog.com" })), })); +vi.mock("@repo/cli-utils", async (importOriginal) => ({ + ...(await importOriginal()), + promptRequired: vi.fn((label: string, value: unknown) => Promise.resolve(value)), +})); + vi.mock("consola", () => import("@repo/test-utils/mock-consola")); describe("project add-user", () => { it("adds a user to a project", async () => { mockClient.postProjectUser.mockResolvedValue({ id: 12_345, name: "John Doe" }); - const { addUser } = await import("./add-user"); - await addUser.run?.({ - args: { project: "TEST", "user-id": "12345" }, - } as never); + const { default: addUser } = await import("./add-user"); + await addUser.parseAsync(["--project", "TEST", "--user-id", "12345"], { from: "user" }); expect(mockClient.postProjectUser).toHaveBeenCalledWith("TEST", "12345"); expect(consola.success).toHaveBeenCalledWith("Added user John Doe to project TEST."); }); it("throws error when user-id is not a number", async () => { - const { addUser } = await import("./add-user"); + const { default: addUser } = await import("./add-user"); await expect( - addUser.run?.({ - args: { project: "TEST", "user-id": "invalid" }, - } as never), + addUser.parseAsync(["--project", "TEST", "--user-id", "invalid"], { from: "user" }), ).rejects.toThrow("User ID must be a number."); }); @@ -39,10 +40,10 @@ describe("project add-user", () => { mockClient.postProjectUser.mockResolvedValue({ id: 12_345, name: "John Doe" }); await expectStdoutContaining(async () => { - const { addUser } = await import("./add-user"); - await addUser.run?.({ - args: { project: "TEST", "user-id": "12345", json: "" }, - } as never); + const { default: addUser } = await import("./add-user"); + await addUser.parseAsync(["--project", "TEST", "--user-id", "12345", "--json"], { + from: "user", + }); }, "John Doe"); }); }); diff --git a/apps/cli/src/commands/project/add-user.ts b/apps/cli/src/commands/project/add-user.ts index ce833fab..fc73508a 100644 --- a/apps/cli/src/commands/project/add-user.ts +++ b/apps/cli/src/commands/project/add-user.ts @@ -1,67 +1,47 @@ import { getClient } from "@repo/backlog-utils"; -import { UserError, outputArgs, outputResult } from "@repo/cli-utils"; -import { defineCommand } from "citty"; +import { UserError, outputResult } from "@repo/cli-utils"; import consola from "consola"; -import { type CommandUsage, ENV_AUTH, ENV_PROJECT, withUsage } from "../../lib/command-usage"; +import { BeeCommand, ENV_AUTH, ENV_PROJECT } from "../../lib/bee-command"; +import * as opt from "../../lib/common-options"; +import { resolveOptions } from "../../lib/required-option"; +import { RequiredOption } from "../../lib/required-option"; -const commandUsage: CommandUsage = { - long: `Add a user to a Backlog project. +const addUser = new BeeCommand("add-user") + .summary("Add a user to a project") + .description( + `Add a user to a Backlog project. The user is specified by their numeric user ID. Use \`bee project users\` to look up user IDs. Requires Administrator or Project Administrator role.`, - - examples: [ + ) + .addOption(opt.project()) + .addOption(new RequiredOption("--user-id <id>", "User ID")) + .addOption(opt.json()) + .envVars([...ENV_AUTH, ENV_PROJECT]) + .examples([ { description: "Add a user to a project", command: "bee project add-user -p PROJECT_KEY --user-id 12345", }, - ], - - annotations: { - environment: [...ENV_AUTH, ENV_PROJECT], - }, -}; + ]) + .action(async (_opts, cmd) => { + const opts = await resolveOptions(cmd); -const addUser = withUsage( - defineCommand({ - meta: { - name: "add-user", - description: "Add a user to a project", - }, - args: { - ...outputArgs, - project: { - type: "string", - alias: "p", - description: "Project ID or project key", - required: true, - default: process.env.BACKLOG_PROJECT, - }, - "user-id": { - type: "string", - description: "User ID", - valueHint: "<number>", - required: true, - }, - }, - async run({ args }) { - const userId = Number(args["user-id"]); - if (Number.isNaN(userId)) { - throw new UserError("User ID must be a number."); - } + const userId = Number(opts.userId); + if (Number.isNaN(userId)) { + throw new UserError("User ID must be a number."); + } - const { client } = await getClient(); + const { client } = await getClient(); - const user = await client.postProjectUser(args.project, String(userId)); + const user = await client.postProjectUser(opts.project as string, String(userId)); - outputResult(user, args, (data) => { - consola.success(`Added user ${data.name} to project ${args.project}.`); - }); - }, - }), - commandUsage, -); + const jsonArg = opts.json === true ? "" : (opts.json as string | undefined); + outputResult(user, { ...opts, json: jsonArg }, (data) => { + consola.success(`Added user ${data.name} to project ${opts.project}.`); + }); + }); -export { commandUsage, addUser }; +export default addUser; diff --git a/apps/cli/src/commands/project/create.test.ts b/apps/cli/src/commands/project/create.test.ts index 40bedbb3..ae6e63a8 100644 --- a/apps/cli/src/commands/project/create.test.ts +++ b/apps/cli/src/commands/project/create.test.ts @@ -27,8 +27,8 @@ describe("project create", () => { textFormattingRule: "markdown", }); - const { create } = await import("./create"); - await create.run?.({ args: { key: "TEST", name: "Test Project" } } as never); + const { default: create } = await import("./create"); + await create.parseAsync(["--key", "TEST", "--name", "Test Project"], { from: "user" }); expect(mockClient.postProject).toHaveBeenCalledWith( expect.objectContaining({ @@ -49,8 +49,8 @@ describe("project create", () => { textFormattingRule: "backlog", }); - const { create } = await import("./create"); - await create.run?.({ args: {} } as never); + const { default: create } = await import("./create"); + await create.parseAsync([], { from: "user" }); expect(promptRequired).toHaveBeenCalledWith("Project key:", undefined); expect(promptRequired).toHaveBeenCalledWith("Project name:", undefined); @@ -70,15 +70,11 @@ describe("project create", () => { textFormattingRule: "markdown", }); - const { create } = await import("./create"); - await create.run?.({ - args: { - key: "TEST", - name: "Test", - "chart-enabled": true, - "text-formatting-rule": "markdown", - }, - } as never); + const { default: create } = await import("./create"); + await create.parseAsync( + ["--key", "TEST", "--name", "Test", "--chart-enabled", "--text-formatting-rule", "markdown"], + { from: "user" }, + ); expect(mockClient.postProject).toHaveBeenCalledWith( expect.objectContaining({ @@ -97,8 +93,8 @@ describe("project create", () => { }); await expectStdoutContaining(async () => { - const { create } = await import("./create"); - await create.run?.({ args: { key: "TEST", name: "Test", json: "" } } as never); + const { default: create } = await import("./create"); + await create.parseAsync(["--key", "TEST", "--name", "Test", "--json"], { from: "user" }); }, "TEST"); }); }); diff --git a/apps/cli/src/commands/project/create.ts b/apps/cli/src/commands/project/create.ts index a724dc72..8090b83a 100644 --- a/apps/cli/src/commands/project/create.ts +++ b/apps/cli/src/commands/project/create.ts @@ -1,17 +1,30 @@ import { getClient } from "@repo/backlog-utils"; -import { outputArgs, outputResult, promptRequired } from "@repo/cli-utils"; -import { defineCommand } from "citty"; +import { outputResult, promptRequired } from "@repo/cli-utils"; import consola from "consola"; -import { type CommandUsage, ENV_AUTH, withUsage } from "../../lib/command-usage"; +import { BeeCommand, ENV_AUTH } from "../../lib/bee-command"; +import * as opt from "../../lib/common-options"; -const commandUsage: CommandUsage = { - long: `Create a new Backlog project. +const create = new BeeCommand("create") + .summary("Create a project") + .description( + `Create a new Backlog project. Project key must consist of uppercase letters (A\u2013Z), numbers (0\u20139), and underscores (\`_\`). If \`--name\` or \`--key\` is not provided, you will be prompted interactively.`, - - examples: [ + ) + .option("-k, --key <key>", "Project key") + .option("-n, --name <name>", "Project name") + .option("--chart-enabled", "Enable chart") + .option("--subtasking-enabled", "Enable subtasking") + .option( + "--project-leader-can-edit-project-leader", + "Allow project administrators to manage each other", + ) + .option("--text-formatting-rule <rule>", "Formatting rules") + .addOption(opt.json()) + .envVars([...ENV_AUTH]) + .examples([ { description: "Create a project with key and name", command: 'bee project create --key TEST --name "Test Project"', @@ -25,70 +38,26 @@ prompted interactively.`, description: "Create a project interactively", command: "bee project create", }, - ], - - annotations: { - environment: [...ENV_AUTH], - }, -}; - -const create = withUsage( - defineCommand({ - meta: { - name: "create", - description: "Create a project", - }, - args: { - ...outputArgs, - key: { - type: "string", - alias: "k", - description: "Project key", - }, - name: { - type: "string", - alias: "n", - description: "Project name", - }, - "chart-enabled": { - type: "boolean", - description: "Enable chart", - }, - "subtasking-enabled": { - type: "boolean", - description: "Enable subtasking", - }, - "project-leader-can-edit-project-leader": { - type: "boolean", - description: "Allow project administrators to manage each other", - }, - "text-formatting-rule": { - type: "string", - description: "Formatting rules", - valueHint: "{backlog|markdown}", - }, - }, - async run({ args }) { - const { client } = await getClient(); - - const key = await promptRequired("Project key:", args.key); - const name = await promptRequired("Project name:", args.name); - - const project = await client.postProject({ - key, - name, - chartEnabled: args["chart-enabled"] ?? false, - subtaskingEnabled: args["subtasking-enabled"] ?? false, - projectLeaderCanEditProjectLeader: args["project-leader-can-edit-project-leader"], - textFormattingRule: (args["text-formatting-rule"] ?? "markdown") as "backlog" | "markdown", - }); - - outputResult(project, args, (data) => { - consola.success(`Created project ${data.projectKey}: ${data.name}`); - }); - }, - }), - commandUsage, -); - -export { commandUsage, create }; + ]) + .action(async (opts) => { + const { client } = await getClient(); + + const key = await promptRequired("Project key:", opts.key); + const name = await promptRequired("Project name:", opts.name); + + const project = await client.postProject({ + key, + name, + chartEnabled: opts.chartEnabled ?? false, + subtaskingEnabled: opts.subtaskingEnabled ?? false, + projectLeaderCanEditProjectLeader: opts.projectLeaderCanEditProjectLeader, + textFormattingRule: (opts.textFormattingRule ?? "markdown") as "backlog" | "markdown", + }); + + const jsonArg = opts.json === true ? "" : opts.json; + outputResult(project, { ...opts, json: jsonArg }, (data) => { + consola.success(`Created project ${data.projectKey}: ${data.name}`); + }); + }); + +export default create; diff --git a/apps/cli/src/commands/project/delete.test.ts b/apps/cli/src/commands/project/delete.test.ts index f04499ed..f5a2024d 100644 --- a/apps/cli/src/commands/project/delete.test.ts +++ b/apps/cli/src/commands/project/delete.test.ts @@ -14,6 +14,7 @@ vi.mock("@repo/backlog-utils", () => ({ vi.mock("@repo/cli-utils", async (importOriginal) => ({ ...(await importOriginal()), confirmOrExit: vi.fn(), + promptRequired: vi.fn((label: string, value: unknown) => Promise.resolve(value)), })); vi.mock("consola", () => import("@repo/test-utils/mock-consola")); @@ -23,8 +24,8 @@ describe("project delete", () => { vi.mocked(confirmOrExit).mockResolvedValue(true); mockClient.deleteProject.mockResolvedValue({ projectKey: "TEST", name: "Test Project" }); - const { deleteProject } = await import("./delete"); - await deleteProject.run?.({ args: { project: "TEST" } } as never); + const { default: deleteProject } = await import("./delete"); + await deleteProject.parseAsync(["--project", "TEST"], { from: "user" }); expect(confirmOrExit).toHaveBeenCalledWith( "Are you sure you want to delete project TEST? This cannot be undone.", @@ -38,8 +39,8 @@ describe("project delete", () => { vi.mocked(confirmOrExit).mockResolvedValue(true); mockClient.deleteProject.mockResolvedValue({ projectKey: "TEST", name: "Test Project" }); - const { deleteProject } = await import("./delete"); - await deleteProject.run?.({ args: { project: "TEST", yes: true } } as never); + const { default: deleteProject } = await import("./delete"); + await deleteProject.parseAsync(["--project", "TEST", "--yes"], { from: "user" }); expect(confirmOrExit).toHaveBeenCalledWith( "Are you sure you want to delete project TEST? This cannot be undone.", @@ -50,8 +51,8 @@ describe("project delete", () => { it("cancels when user declines confirmation", async () => { vi.mocked(confirmOrExit).mockResolvedValue(false); - const { deleteProject } = await import("./delete"); - await deleteProject.run?.({ args: { project: "TEST" } } as never); + const { default: deleteProject } = await import("./delete"); + await deleteProject.parseAsync(["--project", "TEST"], { from: "user" }); expect(mockClient.deleteProject).not.toHaveBeenCalled(); }); @@ -61,8 +62,8 @@ describe("project delete", () => { mockClient.deleteProject.mockResolvedValue({ projectKey: "TEST", name: "Test Project" }); await expectStdoutContaining(async () => { - const { deleteProject } = await import("./delete"); - await deleteProject.run?.({ args: { project: "TEST", yes: true, json: "" } } as never); + const { default: deleteProject } = await import("./delete"); + await deleteProject.parseAsync(["--project", "TEST", "--yes", "--json"], { from: "user" }); }, "TEST"); }); }); diff --git a/apps/cli/src/commands/project/delete.ts b/apps/cli/src/commands/project/delete.ts index ec28c1fe..3b8cb9e8 100644 --- a/apps/cli/src/commands/project/delete.ts +++ b/apps/cli/src/commands/project/delete.ts @@ -1,73 +1,54 @@ import { getClient } from "@repo/backlog-utils"; -import { confirmOrExit, outputArgs, outputResult } from "@repo/cli-utils"; -import { defineCommand } from "citty"; +import { confirmOrExit, outputResult } from "@repo/cli-utils"; import consola from "consola"; -import { type CommandUsage, ENV_AUTH, ENV_PROJECT, withUsage } from "../../lib/command-usage"; +import { BeeCommand, ENV_AUTH, ENV_PROJECT } from "../../lib/bee-command"; +import * as opt from "../../lib/common-options"; +import { resolveOptions } from "../../lib/required-option"; -const commandUsage: CommandUsage = { - long: `Delete a Backlog project. +const deleteProject = new BeeCommand("delete") + .summary("Delete a project") + .description( + `Delete a Backlog project. This action is irreversible. You will be prompted for confirmation unless \`--yes\` is provided. Requires Administrator role.`, - - examples: [ + ) + .addOption(opt.project()) + .option("-y, --yes", "Skip confirmation prompt") + .addOption(opt.json()) + .envVars([...ENV_AUTH, ENV_PROJECT]) + .examples([ { description: "Delete a project (with confirmation)", - command: "bee project delete PROJECT_KEY", + command: "bee project delete -p PROJECT_KEY", }, { description: "Delete a project without confirmation", - command: "bee project delete PROJECT_KEY --yes", + command: "bee project delete -p PROJECT_KEY --yes", }, - ], + ]) + .action(async (_opts, cmd) => { + const opts = await resolveOptions(cmd); - annotations: { - environment: [...ENV_AUTH, ENV_PROJECT], - }, -}; + const confirmed = await confirmOrExit( + `Are you sure you want to delete project ${opts.project}? This cannot be undone.`, + opts.yes as boolean | undefined, + ); -const deleteProject = withUsage( - defineCommand({ - meta: { - name: "delete", - description: "Delete a project", - }, - args: { - ...outputArgs, - project: { - type: "positional", - description: "Project ID or project key", - required: true, - default: process.env.BACKLOG_PROJECT, - }, - yes: { - type: "boolean", - alias: "y", - description: "Skip confirmation prompt", - }, - }, - async run({ args }) { - const confirmed = await confirmOrExit( - `Are you sure you want to delete project ${args.project}? This cannot be undone.`, - args.yes, - ); + if (!confirmed) { + return; + } - if (!confirmed) { - return; - } + const { client } = await getClient(); - const { client } = await getClient(); + const project = await client.deleteProject(opts.project as string); - const project = await client.deleteProject(args.project); - - outputResult(project, args, (data) => { - consola.success(`Deleted project ${data.projectKey}: ${data.name}`); - }); - }, - }), - commandUsage, -); + const jsonArg = opts.json === true ? "" : (opts.json as string | undefined); + outputResult(project, { ...opts, json: jsonArg }, (data) => { + consola.success(`Deleted project ${data.projectKey}: ${data.name}`); + }); + }); -export { commandUsage, deleteProject }; +export default deleteProject; diff --git a/apps/cli/src/commands/project/edit.test.ts b/apps/cli/src/commands/project/edit.test.ts index 2d41fc3c..252f1cf7 100644 --- a/apps/cli/src/commands/project/edit.test.ts +++ b/apps/cli/src/commands/project/edit.test.ts @@ -10,14 +10,19 @@ vi.mock("@repo/backlog-utils", () => ({ getClient: vi.fn(() => Promise.resolve({ client: mockClient, host: "example.backlog.com" })), })); +vi.mock("@repo/cli-utils", async (importOriginal) => ({ + ...(await importOriginal()), + promptRequired: vi.fn((label: string, value: unknown) => Promise.resolve(value)), +})); + vi.mock("consola", () => import("@repo/test-utils/mock-consola")); describe("project edit", () => { it("updates project name", async () => { mockClient.patchProject.mockResolvedValue({ projectKey: "TEST", name: "New Name" }); - const { edit } = await import("./edit"); - await edit.run?.({ args: { project: "TEST", name: "New Name" } } as never); + const { default: edit } = await import("./edit"); + await edit.parseAsync(["--project", "TEST", "--name", "New Name"], { from: "user" }); expect(mockClient.patchProject).toHaveBeenCalledWith( "TEST", @@ -29,8 +34,8 @@ describe("project edit", () => { it("updates project archived status", async () => { mockClient.patchProject.mockResolvedValue({ projectKey: "TEST", name: "Test" }); - const { edit } = await import("./edit"); - await edit.run?.({ args: { project: "TEST", archived: true } } as never); + const { default: edit } = await import("./edit"); + await edit.parseAsync(["--project", "TEST", "--archived"], { from: "user" }); expect(mockClient.patchProject).toHaveBeenCalledWith( "TEST", @@ -41,10 +46,10 @@ describe("project edit", () => { it("passes text formatting rule", async () => { mockClient.patchProject.mockResolvedValue({ projectKey: "TEST", name: "Test" }); - const { edit } = await import("./edit"); - await edit.run?.({ - args: { project: "TEST", "text-formatting-rule": "markdown" }, - } as never); + const { default: edit } = await import("./edit"); + await edit.parseAsync(["--project", "TEST", "--text-formatting-rule", "markdown"], { + from: "user", + }); expect(mockClient.patchProject).toHaveBeenCalledWith( "TEST", @@ -56,8 +61,8 @@ describe("project edit", () => { mockClient.patchProject.mockResolvedValue({ projectKey: "TEST", name: "Test" }); await expectStdoutContaining(async () => { - const { edit } = await import("./edit"); - await edit.run?.({ args: { project: "TEST", name: "Test", json: "" } } as never); + const { default: edit } = await import("./edit"); + await edit.parseAsync(["--project", "TEST", "--name", "Test", "--json"], { from: "user" }); }, "TEST"); }); }); diff --git a/apps/cli/src/commands/project/edit.ts b/apps/cli/src/commands/project/edit.ts index 431f6d21..aa1325d2 100644 --- a/apps/cli/src/commands/project/edit.ts +++ b/apps/cli/src/commands/project/edit.ts @@ -1,100 +1,66 @@ import { getClient } from "@repo/backlog-utils"; -import { outputArgs, outputResult } from "@repo/cli-utils"; -import { defineCommand } from "citty"; +import { outputResult } from "@repo/cli-utils"; import consola from "consola"; -import { type CommandUsage, ENV_AUTH, ENV_PROJECT, withUsage } from "../../lib/command-usage"; +import { BeeCommand, ENV_AUTH, ENV_PROJECT } from "../../lib/bee-command"; +import * as opt from "../../lib/common-options"; +import { resolveOptions } from "../../lib/required-option"; -const commandUsage: CommandUsage = { - long: `Update an existing Backlog project. +const edit = new BeeCommand("edit") + .summary("Edit a project") + .description( + `Update an existing Backlog project. Only the specified fields will be updated. Fields that are not provided will remain unchanged.`, - - examples: [ + ) + .addOption(opt.project()) + .option("-n, --name <name>", "New name of the project") + .option("-k, --key <key>", "New key of the project") + .option("--chart-enabled", "Change whether the chart is enabled") + .option("--subtasking-enabled", "Change whether subtasking is enabled") + .option( + "--project-leader-can-edit-project-leader", + "Change whether project administrators can manage each other", + ) + .option("--text-formatting-rule <rule>", "Change text formatting rule") + .option("--archived", "Change whether the project is archived") + .addOption(opt.json()) + .envVars([...ENV_AUTH, ENV_PROJECT]) + .examples([ { description: "Rename a project", - command: 'bee project edit PROJECT_KEY --name "New Name"', + command: 'bee project edit -p PROJECT_KEY --name "New Name"', }, { description: "Archive a project", - command: "bee project edit PROJECT_KEY --archived", + command: "bee project edit -p PROJECT_KEY --archived", }, { description: "Change formatting rule to markdown", - command: "bee project edit PROJECT_KEY --text-formatting-rule markdown", + command: "bee project edit -p PROJECT_KEY --text-formatting-rule markdown", }, - ], + ]) + .action(async (_opts, cmd) => { + const opts = await resolveOptions(cmd); - annotations: { - environment: [...ENV_AUTH, ENV_PROJECT], - }, -}; + const { client } = await getClient(); -const edit = withUsage( - defineCommand({ - meta: { - name: "edit", - description: "Edit a project", - }, - args: { - ...outputArgs, - project: { - type: "positional", - description: "Project ID or project key", - required: true, - default: process.env.BACKLOG_PROJECT, - }, - name: { - type: "string", - alias: "n", - description: "New name of the project", - }, - key: { - type: "string", - alias: "k", - description: "New key of the project", - }, - "chart-enabled": { - type: "boolean", - description: "Change whether the chart is enabled", - }, - "subtasking-enabled": { - type: "boolean", - description: "Change whether subtasking is enabled", - }, - "project-leader-can-edit-project-leader": { - type: "boolean", - description: "Change whether project administrators can manage each other", - }, - "text-formatting-rule": { - type: "string", - description: "Change text formatting rule", - valueHint: "{backlog|markdown}", - }, - archived: { - type: "boolean", - description: "Change whether the project is archived", - }, - }, - async run({ args }) { - const { client } = await getClient(); + const project = await client.patchProject(opts.project as string, { + name: opts.name as string | undefined, + key: opts.key as string | undefined, + chartEnabled: opts.chartEnabled as boolean | undefined, + subtaskingEnabled: opts.subtaskingEnabled as boolean | undefined, + projectLeaderCanEditProjectLeader: opts.projectLeaderCanEditProjectLeader as + | boolean + | undefined, + textFormattingRule: opts.textFormattingRule as "backlog" | "markdown" | undefined, + archived: opts.archived as boolean | undefined, + }); - const project = await client.patchProject(args.project, { - name: args.name, - key: args.key, - chartEnabled: args["chart-enabled"], - subtaskingEnabled: args["subtasking-enabled"], - projectLeaderCanEditProjectLeader: args["project-leader-can-edit-project-leader"], - textFormattingRule: args["text-formatting-rule"] as "backlog" | "markdown" | undefined, - archived: args.archived, - }); - - outputResult(project, args, (data) => { - consola.success(`Updated project ${data.projectKey}: ${data.name}`); - }); - }, - }), - commandUsage, -); + const jsonArg = opts.json === true ? "" : (opts.json as string | undefined); + outputResult(project, { ...opts, json: jsonArg }, (data) => { + consola.success(`Updated project ${data.projectKey}: ${data.name}`); + }); + }); -export { commandUsage, edit }; +export default edit; diff --git a/apps/cli/src/commands/project/list.test.ts b/apps/cli/src/commands/project/list.test.ts index 7a982f07..b0ea618c 100644 --- a/apps/cli/src/commands/project/list.test.ts +++ b/apps/cli/src/commands/project/list.test.ts @@ -20,8 +20,8 @@ describe("project list", () => { { projectKey: "PROJ2", name: "Project Two", archived: true }, ]); - const { list } = await import("./list"); - await list.run?.({ args: {} } as never); + const { default: list } = await import("./list"); + await list.parseAsync([], { from: "user" }); expect(getClient).toHaveBeenCalled(); expect(mockClient.getProjects).toHaveBeenCalled(); @@ -33,8 +33,8 @@ describe("project list", () => { it("shows message when no projects found", async () => { mockClient.getProjects.mockResolvedValue([]); - const { list } = await import("./list"); - await list.run?.({ args: {} } as never); + const { default: list } = await import("./list"); + await list.parseAsync([], { from: "user" }); expect(consola.info).toHaveBeenCalledWith("No projects found."); }); @@ -42,8 +42,8 @@ describe("project list", () => { it("passes archived query parameter", async () => { mockClient.getProjects.mockResolvedValue([]); - const { list } = await import("./list"); - await list.run?.({ args: { archived: true } } as never); + const { default: list } = await import("./list"); + await list.parseAsync(["--archived"], { from: "user" }); expect(mockClient.getProjects).toHaveBeenCalledWith( expect.objectContaining({ archived: true }), @@ -53,8 +53,8 @@ describe("project list", () => { it("passes all query parameter", async () => { mockClient.getProjects.mockResolvedValue([]); - const { list } = await import("./list"); - await list.run?.({ args: { all: true } } as never); + const { default: list } = await import("./list"); + await list.parseAsync(["--all"], { from: "user" }); expect(mockClient.getProjects).toHaveBeenCalledWith(expect.objectContaining({ all: true })); }); @@ -65,8 +65,8 @@ describe("project list", () => { ]); await expectStdoutContaining(async () => { - const { list } = await import("./list"); - await list.run?.({ args: { json: "" } } as never); + const { default: list } = await import("./list"); + await list.parseAsync(["--json"], { from: "user" }); }, "PROJ1"); }); }); diff --git a/apps/cli/src/commands/project/list.ts b/apps/cli/src/commands/project/list.ts index 934ee76a..a3e90997 100644 --- a/apps/cli/src/commands/project/list.ts +++ b/apps/cli/src/commands/project/list.ts @@ -1,72 +1,53 @@ import { getClient } from "@repo/backlog-utils"; -import { type Row, outputArgs, outputResult, printTable } from "@repo/cli-utils"; -import { defineCommand } from "citty"; +import { type Row, outputResult, printTable } from "@repo/cli-utils"; import consola from "consola"; -import { type CommandUsage, ENV_AUTH, withUsage } from "../../lib/command-usage"; +import { BeeCommand, ENV_AUTH } from "../../lib/bee-command"; +import * as opt from "../../lib/common-options"; -const commandUsage: CommandUsage = { - long: `List projects accessible to the authenticated user. +const list = new BeeCommand("list") + .summary("List projects") + .description( + `List projects accessible to the authenticated user. By default, only active (non-archived) projects are shown. Use \`--archived\` to include archived projects. Administrators can use \`--all\` to list every project in the space, not just the ones they have joined.`, - - examples: [ + ) + .option("--archived", "Include archived projects") + .option("--all", "Include all projects (admin only)") + .addOption(opt.json()) + .envVars([...ENV_AUTH]) + .examples([ { description: "List your active projects", command: "bee project list" }, { description: "Include archived projects", command: "bee project list --archived" }, { description: "List all projects (admin only)", command: "bee project list --all" }, { description: "Output as JSON", command: "bee project list --json" }, - ], - - annotations: { - environment: [...ENV_AUTH], - }, -}; - -const list = withUsage( - defineCommand({ - meta: { - name: "list", - description: "List projects", - }, - args: { - ...outputArgs, - archived: { - type: "boolean", - description: "Include archived projects", - }, - all: { - type: "boolean", - description: "Include all projects (admin only)", - }, - }, - async run({ args }) { - const { client } = await getClient(); - - const projects = await client.getProjects({ - archived: args.archived, - all: args.all, - }); - - outputResult(projects, args, (data) => { - if (data.length === 0) { - consola.info("No projects found."); - return; - } - - const rows: Row[] = data.map((project) => [ - { header: "KEY", value: project.projectKey }, - { header: "NAME", value: project.name }, - { header: "STATUS", value: project.archived ? "Archived" : "Active" }, - ]); - - printTable(rows); - }); - }, - }), - commandUsage, -); - -export { commandUsage, list }; + ]) + .action(async (opts) => { + const { client } = await getClient(); + + const projects = await client.getProjects({ + archived: opts.archived, + all: opts.all, + }); + + const jsonArg = opts.json === true ? "" : opts.json; + outputResult(projects, { ...opts, json: jsonArg }, (data) => { + if (data.length === 0) { + consola.info("No projects found."); + return; + } + + const rows: Row[] = data.map((project) => [ + { header: "KEY", value: project.projectKey }, + { header: "NAME", value: project.name }, + { header: "STATUS", value: project.archived ? "Archived" : "Active" }, + ]); + + printTable(rows); + }); + }); + +export default list; diff --git a/apps/cli/src/commands/project/remove-user.test.ts b/apps/cli/src/commands/project/remove-user.test.ts index 82156463..b5bb03b6 100644 --- a/apps/cli/src/commands/project/remove-user.test.ts +++ b/apps/cli/src/commands/project/remove-user.test.ts @@ -10,28 +10,29 @@ vi.mock("@repo/backlog-utils", () => ({ getClient: vi.fn(() => Promise.resolve({ client: mockClient, host: "example.backlog.com" })), })); +vi.mock("@repo/cli-utils", async (importOriginal) => ({ + ...(await importOriginal()), + promptRequired: vi.fn((label: string, value: unknown) => Promise.resolve(value)), +})); + vi.mock("consola", () => import("@repo/test-utils/mock-consola")); describe("project remove-user", () => { it("removes a user from a project", async () => { mockClient.deleteProjectUsers.mockResolvedValue({ id: 12_345, name: "John Doe" }); - const { removeUser } = await import("./remove-user"); - await removeUser.run?.({ - args: { project: "TEST", "user-id": "12345" }, - } as never); + const { default: removeUser } = await import("./remove-user"); + await removeUser.parseAsync(["--project", "TEST", "--user-id", "12345"], { from: "user" }); expect(mockClient.deleteProjectUsers).toHaveBeenCalledWith("TEST", { userId: 12_345 }); expect(consola.success).toHaveBeenCalledWith("Removed user John Doe from project TEST."); }); it("throws error when user-id is not a number", async () => { - const { removeUser } = await import("./remove-user"); + const { default: removeUser } = await import("./remove-user"); await expect( - removeUser.run?.({ - args: { project: "TEST", "user-id": "abc" }, - } as never), + removeUser.parseAsync(["--project", "TEST", "--user-id", "abc"], { from: "user" }), ).rejects.toThrow("User ID must be a number."); }); @@ -39,10 +40,10 @@ describe("project remove-user", () => { mockClient.deleteProjectUsers.mockResolvedValue({ id: 12_345, name: "John Doe" }); await expectStdoutContaining(async () => { - const { removeUser } = await import("./remove-user"); - await removeUser.run?.({ - args: { project: "TEST", "user-id": "12345", json: "" }, - } as never); + const { default: removeUser } = await import("./remove-user"); + await removeUser.parseAsync(["--project", "TEST", "--user-id", "12345", "--json"], { + from: "user", + }); }, "John Doe"); }); }); diff --git a/apps/cli/src/commands/project/remove-user.ts b/apps/cli/src/commands/project/remove-user.ts index 709f5c32..58b67846 100644 --- a/apps/cli/src/commands/project/remove-user.ts +++ b/apps/cli/src/commands/project/remove-user.ts @@ -1,67 +1,47 @@ import { getClient } from "@repo/backlog-utils"; -import { UserError, outputArgs, outputResult } from "@repo/cli-utils"; -import { defineCommand } from "citty"; +import { UserError, outputResult } from "@repo/cli-utils"; import consola from "consola"; -import { type CommandUsage, ENV_AUTH, ENV_PROJECT, withUsage } from "../../lib/command-usage"; +import { BeeCommand, ENV_AUTH, ENV_PROJECT } from "../../lib/bee-command"; +import * as opt from "../../lib/common-options"; +import { resolveOptions } from "../../lib/required-option"; +import { RequiredOption } from "../../lib/required-option"; -const commandUsage: CommandUsage = { - long: `Remove a user from a Backlog project. +const removeUser = new BeeCommand("remove-user") + .summary("Remove a user from a project") + .description( + `Remove a user from a Backlog project. The user is specified by their numeric user ID. Use \`bee project users\` to look up user IDs. Requires Administrator or Project Administrator role.`, - - examples: [ + ) + .addOption(opt.project()) + .addOption(new RequiredOption("--user-id <id>", "User ID")) + .addOption(opt.json()) + .envVars([...ENV_AUTH, ENV_PROJECT]) + .examples([ { description: "Remove a user from a project", command: "bee project remove-user -p PROJECT_KEY --user-id 12345", }, - ], - - annotations: { - environment: [...ENV_AUTH, ENV_PROJECT], - }, -}; + ]) + .action(async (_opts, cmd) => { + const opts = await resolveOptions(cmd); -const removeUser = withUsage( - defineCommand({ - meta: { - name: "remove-user", - description: "Remove a user from a project", - }, - args: { - ...outputArgs, - project: { - type: "string", - alias: "p", - description: "Project ID or project key", - required: true, - default: process.env.BACKLOG_PROJECT, - }, - "user-id": { - type: "string", - description: "User ID", - valueHint: "<number>", - required: true, - }, - }, - async run({ args }) { - const userId = Number(args["user-id"]); - if (Number.isNaN(userId)) { - throw new UserError("User ID must be a number."); - } + const userId = Number(opts.userId); + if (Number.isNaN(userId)) { + throw new UserError("User ID must be a number."); + } - const { client } = await getClient(); + const { client } = await getClient(); - const user = await client.deleteProjectUsers(args.project, { userId }); + const user = await client.deleteProjectUsers(opts.project as string, { userId }); - outputResult(user, args, (data) => { - consola.success(`Removed user ${data.name} from project ${args.project}.`); - }); - }, - }), - commandUsage, -); + const jsonArg = opts.json === true ? "" : (opts.json as string | undefined); + outputResult(user, { ...opts, json: jsonArg }, (data) => { + consola.success(`Removed user ${data.name} from project ${opts.project}.`); + }); + }); -export { commandUsage, removeUser }; +export default removeUser; diff --git a/apps/cli/src/commands/project/users.test.ts b/apps/cli/src/commands/project/users.test.ts index 083ff245..5ce53568 100644 --- a/apps/cli/src/commands/project/users.test.ts +++ b/apps/cli/src/commands/project/users.test.ts @@ -11,6 +11,11 @@ vi.mock("@repo/backlog-utils", async (importOriginal) => ({ getClient: vi.fn(() => Promise.resolve({ client: mockClient, host: "example.backlog.com" })), })); +vi.mock("@repo/cli-utils", async (importOriginal) => ({ + ...(await importOriginal()), + promptRequired: vi.fn((label: string, value: unknown) => Promise.resolve(value)), +})); + vi.mock("consola", () => import("@repo/test-utils/mock-consola")); describe("project users", () => { @@ -20,8 +25,8 @@ describe("project users", () => { { id: 2, userId: "user2", name: "User Two", roleType: 2 }, ]); - const { users } = await import("./users"); - await users.run?.({ args: { project: "PROJ1" } } as never); + const { default: users } = await import("./users"); + await users.parseAsync(["--project", "PROJ1"], { from: "user" }); expect(mockClient.getProjectUsers).toHaveBeenCalledWith("PROJ1"); expect(consola.log).toHaveBeenCalledWith(expect.stringContaining("ID")); @@ -33,8 +38,8 @@ describe("project users", () => { it("shows message when no users found", async () => { mockClient.getProjectUsers.mockResolvedValue([]); - const { users } = await import("./users"); - await users.run?.({ args: { project: "PROJ1" } } as never); + const { default: users } = await import("./users"); + await users.parseAsync(["--project", "PROJ1"], { from: "user" }); expect(consola.info).toHaveBeenCalledWith("No users found."); }); @@ -45,8 +50,8 @@ describe("project users", () => { ]); await expectStdoutContaining(async () => { - const { users } = await import("./users"); - await users.run?.({ args: { project: "PROJ1", json: "" } } as never); + const { default: users } = await import("./users"); + await users.parseAsync(["--project", "PROJ1", "--json"], { from: "user" }); }, "user1"); }); }); diff --git a/apps/cli/src/commands/project/users.ts b/apps/cli/src/commands/project/users.ts index 50bd0e8b..910a6c47 100644 --- a/apps/cli/src/commands/project/users.ts +++ b/apps/cli/src/commands/project/users.ts @@ -1,62 +1,47 @@ import { ROLE_LABELS, getClient } from "@repo/backlog-utils"; -import { type Row, outputArgs, outputResult, printTable } from "@repo/cli-utils"; -import { defineCommand } from "citty"; +import { type Row, outputResult, printTable } from "@repo/cli-utils"; import consola from "consola"; -import { type CommandUsage, ENV_AUTH, ENV_PROJECT, withUsage } from "../../lib/command-usage"; +import { BeeCommand, ENV_AUTH, ENV_PROJECT } from "../../lib/bee-command"; +import * as opt from "../../lib/common-options"; +import { resolveOptions } from "../../lib/required-option"; -const commandUsage: CommandUsage = { - long: `List members of a Backlog project. +const users = new BeeCommand("users") + .summary("List project users") + .description( + `List members of a Backlog project. Displays each user's ID, user ID, name, and role within the project.`, - - examples: [ - { description: "List project members", command: "bee project users PROJECT_KEY" }, - { description: "Output as JSON", command: "bee project users PROJECT_KEY --json" }, - ], - - annotations: { - environment: [...ENV_AUTH, ENV_PROJECT], - }, -}; - -const users = withUsage( - defineCommand({ - meta: { - name: "users", - description: "List project users", - }, - args: { - ...outputArgs, - project: { - type: "positional", - description: "Project ID or project key", - required: true, - default: process.env.BACKLOG_PROJECT, - }, - }, - async run({ args }) { - const { client } = await getClient(); - - const members = await client.getProjectUsers(args.project); - - outputResult(members, args, (data) => { - if (data.length === 0) { - consola.info("No users found."); - return; - } - - const rows: Row[] = data.map((user) => [ - { header: "ID", value: String(user.id) }, - { header: "USER ID", value: user.userId ?? "" }, - { header: "NAME", value: user.name }, - { header: "ROLE", value: ROLE_LABELS[user.roleType] ?? `Unknown (${user.roleType})` }, - ]); - - printTable(rows); - }); - }, - }), - commandUsage, -); - -export { commandUsage, users }; + ) + .addOption(opt.project()) + .addOption(opt.json()) + .envVars([...ENV_AUTH, ENV_PROJECT]) + .examples([ + { description: "List project members", command: "bee project users -p PROJECT_KEY" }, + { description: "Output as JSON", command: "bee project users -p PROJECT_KEY --json" }, + ]) + .action(async (_opts, cmd) => { + const opts = await resolveOptions(cmd); + + const { client } = await getClient(); + + const members = await client.getProjectUsers(opts.project as string); + + const jsonArg = opts.json === true ? "" : (opts.json as string | undefined); + outputResult(members, { ...opts, json: jsonArg }, (data) => { + if (data.length === 0) { + consola.info("No users found."); + return; + } + + const rows: Row[] = data.map((user) => [ + { header: "ID", value: String(user.id) }, + { header: "USER ID", value: user.userId ?? "" }, + { header: "NAME", value: user.name }, + { header: "ROLE", value: ROLE_LABELS[user.roleType] ?? `Unknown (${user.roleType})` }, + ]); + + printTable(rows); + }); + }); + +export default users; diff --git a/apps/cli/src/commands/project/view.test.ts b/apps/cli/src/commands/project/view.test.ts index 5707f429..eb351a65 100644 --- a/apps/cli/src/commands/project/view.test.ts +++ b/apps/cli/src/commands/project/view.test.ts @@ -14,6 +14,11 @@ vi.mock("@repo/backlog-utils", () => ({ projectUrl: vi.fn((host: string, key: string) => `https://${host}/projects/${key}`), })); +vi.mock("@repo/cli-utils", async (importOriginal) => ({ + ...(await importOriginal()), + promptRequired: vi.fn((label: string, value: unknown) => Promise.resolve(value)), +})); + vi.mock("consola", () => import("@repo/test-utils/mock-consola")); const sampleProject = { @@ -35,8 +40,8 @@ describe("project view", () => { it("displays project details", async () => { mockClient.getProject.mockResolvedValue(sampleProject); - const { view } = await import("./view"); - await view.run?.({ args: { project: "PROJ1" } } as never); + const { default: view } = await import("./view"); + await view.parseAsync(["--project", "PROJ1"], { from: "user" }); expect(mockClient.getProject).toHaveBeenCalledWith("PROJ1"); expect(consola.log).toHaveBeenCalledWith(expect.stringContaining("Test Project")); @@ -48,15 +53,15 @@ describe("project view", () => { it("shows Archived status for archived project", async () => { mockClient.getProject.mockResolvedValue({ ...sampleProject, archived: true }); - const { view } = await import("./view"); - await view.run?.({ args: { project: "PROJ1" } } as never); + const { default: view } = await import("./view"); + await view.parseAsync(["--project", "PROJ1"], { from: "user" }); expect(consola.log).toHaveBeenCalledWith(expect.stringContaining("Archived")); }); it("opens browser with --web flag", async () => { - const { view } = await import("./view"); - await view.run?.({ args: { project: "PROJ1", web: true } } as never); + const { default: view } = await import("./view"); + await view.parseAsync(["--project", "PROJ1", "--web"], { from: "user" }); expect(openOrPrintUrl).toHaveBeenCalledWith( "https://example.backlog.com/projects/PROJ1", @@ -70,8 +75,8 @@ describe("project view", () => { mockClient.getProject.mockResolvedValue(sampleProject); await expectStdoutContaining(async () => { - const { view } = await import("./view"); - await view.run?.({ args: { project: "PROJ1", json: "" } } as never); + const { default: view } = await import("./view"); + await view.parseAsync(["--project", "PROJ1", "--json"], { from: "user" }); }, "PROJ1"); }); }); diff --git a/apps/cli/src/commands/project/view.ts b/apps/cli/src/commands/project/view.ts index 58c2d220..be880fa2 100644 --- a/apps/cli/src/commands/project/view.ts +++ b/apps/cli/src/commands/project/view.ts @@ -1,72 +1,61 @@ import { getClient, openOrPrintUrl, projectUrl } from "@repo/backlog-utils"; -import { outputArgs, outputResult, printDefinitionList } from "@repo/cli-utils"; -import { defineCommand } from "citty"; +import { outputResult, printDefinitionList } from "@repo/cli-utils"; import consola from "consola"; -import { type CommandUsage, ENV_AUTH, ENV_PROJECT, withUsage } from "../../lib/command-usage"; -import * as commonArgs from "../../lib/common-args"; +import { BeeCommand, ENV_AUTH, ENV_PROJECT } from "../../lib/bee-command"; +import * as opt from "../../lib/common-options"; +import { resolveOptions } from "../../lib/required-option"; -const commandUsage: CommandUsage = { - long: `Display details of a Backlog project. +const view = new BeeCommand("view") + .summary("View a project") + .description( + `Display details of a Backlog project. Shows project settings including chart, subtasking, wiki, file sharing, and git/subversion integration status. Use \`--web\` to open the project in your default browser instead.`, - - examples: [ - { description: "View project details", command: "bee project view PROJECT_KEY" }, - { description: "Open project in browser", command: "bee project view PROJECT_KEY --web" }, - { description: "Output as JSON", command: "bee project view PROJECT_KEY --json" }, - ], - - annotations: { - environment: [...ENV_AUTH, ENV_PROJECT], - }, -}; - -const view = withUsage( - defineCommand({ - meta: { - name: "view", - description: "View a project", - }, - args: { - ...outputArgs, - project: commonArgs.projectPositional, - web: commonArgs.web("project"), - "no-browser": commonArgs.noBrowser, - }, - async run({ args }) { - const { client, host } = await getClient(); - - if (args.web || args["no-browser"]) { - const url = projectUrl(host, args.project); - await openOrPrintUrl(url, Boolean(args["no-browser"]), consola); - return; - } - - const project = await client.getProject(args.project); - - outputResult(project, args, (data) => { - consola.log(""); - consola.log(` ${data.name} (${data.projectKey})`); - consola.log(""); - printDefinitionList([ - ["Status", data.archived ? "Archived" : "Active"], - ["Text Formatting", data.textFormattingRule], - ["Chart", data.chartEnabled ? "Yes" : "No"], - ["Subtasking", data.subtaskingEnabled ? "Yes" : "No"], - ["Wiki", data.useWiki ? "Yes" : "No"], - ["File Sharing", data.useFileSharing ? "Yes" : "No"], - ["Git", data.useGit ? "Yes" : "No"], - ["Subversion", data.useSubversion ? "Yes" : "No"], - ["Dev Attributes", data.useDevAttributes ? "Yes" : "No"], - ]); - consola.log(""); - }); - }, - }), - commandUsage, -); - -export { commandUsage, view }; + ) + .addOption(opt.project()) + .addOption(opt.web("project")) + .addOption(opt.noBrowser()) + .addOption(opt.json()) + .envVars([...ENV_AUTH, ENV_PROJECT]) + .examples([ + { description: "View project details", command: "bee project view -p PROJECT_KEY" }, + { description: "Open project in browser", command: "bee project view -p PROJECT_KEY --web" }, + { description: "Output as JSON", command: "bee project view -p PROJECT_KEY --json" }, + ]) + .action(async (_opts, cmd) => { + const opts = await resolveOptions(cmd); + + const { client, host } = await getClient(); + + if (opts.web || opts.browser === false) { + const url = projectUrl(host, opts.project as string); + await openOrPrintUrl(url, opts.browser === false, consola); + return; + } + + const project = await client.getProject(opts.project as string); + + const jsonArg = opts.json === true ? "" : opts.json; + outputResult(project, { ...opts, json: jsonArg }, (data) => { + consola.log(""); + consola.log(` ${data.name} (${data.projectKey})`); + consola.log(""); + printDefinitionList([ + ["Status", data.archived ? "Archived" : "Active"], + ["Text Formatting", data.textFormattingRule], + ["Chart", data.chartEnabled ? "Yes" : "No"], + ["Subtasking", data.subtaskingEnabled ? "Yes" : "No"], + ["Wiki", data.useWiki ? "Yes" : "No"], + ["File Sharing", data.useFileSharing ? "Yes" : "No"], + ["Git", data.useGit ? "Yes" : "No"], + ["Subversion", data.useSubversion ? "Yes" : "No"], + ["Dev Attributes", data.useDevAttributes ? "Yes" : "No"], + ]); + consola.log(""); + }); + }); + +export default view; From c89ebb23e30227a36aa8d251114142fb047e8c12 Mon Sep 17 00:00:00 2001 From: Ryoya Tamura <ryoya.tamura@nulab.com> Date: Sun, 8 Mar 2026 18:22:06 +0900 Subject: [PATCH 07/16] refactor(cli): migrate remaining command groups to commander Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --- apps/cli/src/commands/star/add.test.ts | 30 ++-- apps/cli/src/commands/star/add.ts | 121 +++++++---------- apps/cli/src/commands/star/count.test.ts | 18 ++- apps/cli/src/commands/star/count.ts | 111 ++++++--------- apps/cli/src/commands/star/list.test.ts | 16 +-- apps/cli/src/commands/star/list.ts | 99 ++++++-------- apps/cli/src/commands/star/remove.test.ts | 8 +- apps/cli/src/commands/star/remove.ts | 43 ++---- apps/cli/src/commands/team/create.test.ts | 22 +-- apps/cli/src/commands/team/create.ts | 100 ++++++-------- apps/cli/src/commands/team/delete.test.ts | 16 +-- apps/cli/src/commands/team/delete.ts | 81 ++++------- apps/cli/src/commands/team/edit.test.ts | 16 +-- apps/cli/src/commands/team/edit.ts | 102 ++++++-------- apps/cli/src/commands/team/list.test.ts | 20 +-- apps/cli/src/commands/team/list.ts | 85 +++++------- apps/cli/src/commands/team/view.test.ts | 12 +- apps/cli/src/commands/team/view.ts | 97 ++++++------- apps/cli/src/commands/user/activities.test.ts | 27 ++-- apps/cli/src/commands/user/activities.ts | 128 +++++++----------- apps/cli/src/commands/user/list.test.ts | 12 +- apps/cli/src/commands/user/list.ts | 84 +++++------- apps/cli/src/commands/user/me.test.ts | 8 +- apps/cli/src/commands/user/me.ts | 82 +++++------ apps/cli/src/commands/user/view.test.ts | 12 +- apps/cli/src/commands/user/view.ts | 89 +++++------- apps/cli/src/commands/watching/add.test.ts | 12 +- apps/cli/src/commands/watching/add.ts | 68 ++++------ apps/cli/src/commands/watching/delete.test.ts | 18 ++- apps/cli/src/commands/watching/delete.ts | 81 ++++------- apps/cli/src/commands/watching/list.test.ts | 12 +- apps/cli/src/commands/watching/list.ts | 86 +++++------- apps/cli/src/commands/watching/read.test.ts | 8 +- apps/cli/src/commands/watching/read.ts | 50 ++----- apps/cli/src/commands/watching/view.test.ts | 8 +- apps/cli/src/commands/watching/view.ts | 91 +++++-------- apps/cli/src/commands/webhook/create.test.ts | 81 ++++++----- apps/cli/src/commands/webhook/create.ts | 106 ++++++--------- apps/cli/src/commands/webhook/delete.test.ts | 19 ++- apps/cli/src/commands/webhook/delete.ts | 86 +++++------- apps/cli/src/commands/webhook/edit.test.ts | 46 ++++--- apps/cli/src/commands/webhook/edit.ts | 116 ++++++---------- apps/cli/src/commands/webhook/list.test.ts | 17 ++- apps/cli/src/commands/webhook/list.ts | 94 ++++++------- apps/cli/src/commands/webhook/view.test.ts | 13 +- apps/cli/src/commands/webhook/view.ts | 99 ++++++-------- .../cli/src/commands/wiki/attachments.test.ts | 12 +- apps/cli/src/commands/wiki/attachments.ts | 95 ++++++------- apps/cli/src/commands/wiki/count.test.ts | 13 +- apps/cli/src/commands/wiki/count.ts | 60 ++++---- apps/cli/src/commands/wiki/create.test.ts | 28 ++-- apps/cli/src/commands/wiki/create.ts | 92 +++++-------- apps/cli/src/commands/wiki/delete.test.ts | 20 +-- apps/cli/src/commands/wiki/delete.ts | 86 ++++-------- apps/cli/src/commands/wiki/edit.test.ts | 20 +-- apps/cli/src/commands/wiki/edit.ts | 88 ++++-------- apps/cli/src/commands/wiki/history.test.ts | 24 ++-- apps/cli/src/commands/wiki/history.ts | 112 +++++++-------- apps/cli/src/commands/wiki/list.test.ts | 21 +-- apps/cli/src/commands/wiki/list.ts | 96 ++++++------- apps/cli/src/commands/wiki/tags.test.ts | 17 ++- apps/cli/src/commands/wiki/tags.ts | 84 +++++------- apps/cli/src/commands/wiki/view.test.ts | 16 +-- apps/cli/src/commands/wiki/view.ts | 120 +++++++--------- 64 files changed, 1487 insertions(+), 2067 deletions(-) diff --git a/apps/cli/src/commands/star/add.test.ts b/apps/cli/src/commands/star/add.test.ts index 44f9564c..4f8a47e8 100644 --- a/apps/cli/src/commands/star/add.test.ts +++ b/apps/cli/src/commands/star/add.test.ts @@ -17,8 +17,8 @@ describe("star add", () => { it("stars an issue", async () => { mockClient.postStar.mockResolvedValue(undefined); - const { add } = await import("./add"); - await add.run?.({ args: { issue: "12345" } } as never); + const { default: add } = await import("./add"); + await add.parseAsync(["--issue", "12345"], { from: "user" }); expect(getClient).toHaveBeenCalled(); expect(mockClient.postStar).toHaveBeenCalledWith({ issueId: 12_345 }); @@ -29,8 +29,8 @@ describe("star add", () => { mockClient.getIssue.mockResolvedValue({ id: 99_999 }); mockClient.postStar.mockResolvedValue(undefined); - const { add } = await import("./add"); - await add.run?.({ args: { issue: "PROJECT-123" } } as never); + const { default: add } = await import("./add"); + await add.parseAsync(["--issue", "PROJECT-123"], { from: "user" }); expect(mockClient.getIssue).toHaveBeenCalledWith("PROJECT-123"); expect(mockClient.postStar).toHaveBeenCalledWith({ issueId: 99_999 }); @@ -40,8 +40,8 @@ describe("star add", () => { it("stars a comment", async () => { mockClient.postStar.mockResolvedValue(undefined); - const { add } = await import("./add"); - await add.run?.({ args: { comment: "67890" } } as never); + const { default: add } = await import("./add"); + await add.parseAsync(["--comment", "67890"], { from: "user" }); expect(mockClient.postStar).toHaveBeenCalledWith({ commentId: 67_890 }); expect(consola.success).toHaveBeenCalledWith("Starred comment 67890."); @@ -50,8 +50,8 @@ describe("star add", () => { it("stars a wiki page", async () => { mockClient.postStar.mockResolvedValue(undefined); - const { add } = await import("./add"); - await add.run?.({ args: { wiki: "111" } } as never); + const { default: add } = await import("./add"); + await add.parseAsync(["--wiki", "111"], { from: "user" }); expect(mockClient.postStar).toHaveBeenCalledWith({ wikiId: 111 }); expect(consola.success).toHaveBeenCalledWith("Starred wiki 111."); @@ -60,25 +60,27 @@ describe("star add", () => { it("stars a pull request comment", async () => { mockClient.postStar.mockResolvedValue(undefined); - const { add } = await import("./add"); - await add.run?.({ args: { "pr-comment": "222" } } as never); + const { default: add } = await import("./add"); + await add.parseAsync(["--pr-comment", "222"], { from: "user" }); expect(mockClient.postStar).toHaveBeenCalledWith({ pullRequestCommentId: 222 }); expect(consola.success).toHaveBeenCalledWith("Starred pull request comment 222."); }); it("throws error when no option is provided", async () => { - const { add } = await import("./add"); + const { default: add } = await import("./add"); - await expect(add.run?.({ args: {} } as never)).rejects.toThrow( + await expect(add.parseAsync([], { from: "user" })).rejects.toThrow( "Exactly one of --issue, --comment, --wiki, or --pr-comment must be provided.", ); }); it("throws error when multiple options are provided", async () => { - const { add } = await import("./add"); + const { default: add } = await import("./add"); - await expect(add.run?.({ args: { issue: "1", comment: "2" } } as never)).rejects.toThrow( + await expect( + add.parseAsync(["--issue", "1", "--comment", "2"], { from: "user" }), + ).rejects.toThrow( "Only one of --issue, --comment, --wiki, or --pr-comment can be provided at a time.", ); }); diff --git a/apps/cli/src/commands/star/add.ts b/apps/cli/src/commands/star/add.ts index 8cdf4e90..a1bbaa30 100644 --- a/apps/cli/src/commands/star/add.ts +++ b/apps/cli/src/commands/star/add.ts @@ -1,93 +1,66 @@ import { getClient } from "@repo/backlog-utils"; import { UserError } from "@repo/cli-utils"; -import { defineCommand } from "citty"; import consola from "consola"; -import { type CommandUsage, ENV_AUTH, withUsage } from "../../lib/command-usage"; -import * as commonArgs from "../../lib/common-args"; +import { BeeCommand, ENV_AUTH } from "../../lib/bee-command"; +import * as opt from "../../lib/common-options"; -const commandUsage: CommandUsage = { - long: `Add a star to an issue, comment, wiki page, or pull request comment. +const add = new BeeCommand("add") + .summary("Add a star") + .description( + `Add a star to an issue, comment, wiki page, or pull request comment. Exactly one of \`--issue\`, \`--comment\`, \`--wiki\`, or \`--pr-comment\` must be provided. The \`--issue\` flag accepts an issue key (e.g., PROJECT-123) or a numeric ID. Other flags require numeric IDs.`, - - examples: [ + ) + .addOption(opt.issue()) + .option("--comment <number>", "Comment ID to star") + .option("--wiki <number>", "Wiki page ID to star") + .option("--pr-comment <number>", "Pull request comment ID to star") + .envVars([...ENV_AUTH]) + .examples([ { description: "Star an issue by key", command: "bee star add --issue PROJECT-123" }, { description: "Star an issue by ID", command: "bee star add --issue 12345" }, { description: "Star a comment", command: "bee star add --comment 67890" }, { description: "Star a wiki page", command: "bee star add --wiki 111" }, { description: "Star a pull request comment", command: "bee star add --pr-comment 222" }, - ], - - annotations: { - environment: [...ENV_AUTH], - }, -}; + ]) + .action(async (opts) => { + const flags = [opts.issue, opts.comment, opts.wiki, opts.prComment].filter( + (v) => v !== undefined, + ); -const add = withUsage( - defineCommand({ - meta: { - name: "add", - description: "Add a star", - }, - args: { - issue: commonArgs.issue, - comment: { - type: "string", - description: "Comment ID to star", - valueHint: "<number>", - }, - wiki: { - type: "string", - description: "Wiki page ID to star", - valueHint: "<number>", - }, - "pr-comment": { - type: "string", - description: "Pull request comment ID to star", - valueHint: "<number>", - }, - }, - async run({ args }) { - const flags = [args.issue, args.comment, args.wiki, args["pr-comment"]].filter( - (v) => v !== undefined, + if (flags.length === 0) { + throw new UserError( + "Exactly one of --issue, --comment, --wiki, or --pr-comment must be provided.", ); + } - if (flags.length === 0) { - throw new UserError( - "Exactly one of --issue, --comment, --wiki, or --pr-comment must be provided.", - ); - } - - if (flags.length > 1) { - throw new UserError( - "Only one of --issue, --comment, --wiki, or --pr-comment can be provided at a time.", - ); - } + if (flags.length > 1) { + throw new UserError( + "Only one of --issue, --comment, --wiki, or --pr-comment can be provided at a time.", + ); + } - const { client } = await getClient(); + const { client } = await getClient(); - if (args.issue) { - const issue = /^\d+$/.test(args.issue) - ? { id: Number(args.issue) } - : await client.getIssue(args.issue); - const issueId = issue.id; - await client.postStar({ issueId }); - consola.success(`Starred issue ${args.issue}.`); - } else if (args.comment) { - await client.postStar({ commentId: Number(args.comment) }); - consola.success(`Starred comment ${args.comment}.`); - } else if (args.wiki) { - await client.postStar({ wikiId: Number(args.wiki) }); - consola.success(`Starred wiki ${args.wiki}.`); - } else if (args["pr-comment"]) { - await client.postStar({ pullRequestCommentId: Number(args["pr-comment"]) }); - consola.success(`Starred pull request comment ${args["pr-comment"]}.`); - } - }, - }), - commandUsage, -); + if (opts.issue) { + const issue = /^\d+$/.test(opts.issue) + ? { id: Number(opts.issue) } + : await client.getIssue(opts.issue); + const issueId = issue.id; + await client.postStar({ issueId }); + consola.success(`Starred issue ${opts.issue}.`); + } else if (opts.comment) { + await client.postStar({ commentId: Number(opts.comment) }); + consola.success(`Starred comment ${opts.comment}.`); + } else if (opts.wiki) { + await client.postStar({ wikiId: Number(opts.wiki) }); + consola.success(`Starred wiki ${opts.wiki}.`); + } else if (opts.prComment) { + await client.postStar({ pullRequestCommentId: Number(opts.prComment) }); + consola.success(`Starred pull request comment ${opts.prComment}.`); + } + }); -export { add, commandUsage }; +export default add; diff --git a/apps/cli/src/commands/star/count.test.ts b/apps/cli/src/commands/star/count.test.ts index c797cfb2..165ee474 100644 --- a/apps/cli/src/commands/star/count.test.ts +++ b/apps/cli/src/commands/star/count.test.ts @@ -19,8 +19,8 @@ describe("star count", () => { mockClient.getMyself.mockResolvedValue({ id: 100 }); mockClient.getUserStarsCount.mockResolvedValue({ count: 42 }); - const { count } = await import("./count"); - await count.run?.({ args: {} } as never); + const { default: count } = await import("./count"); + await count.parseAsync([], { from: "user" }); expect(getClient).toHaveBeenCalled(); expect(mockClient.getMyself).toHaveBeenCalled(); @@ -31,8 +31,8 @@ describe("star count", () => { it("counts stars for a specific user", async () => { mockClient.getUserStarsCount.mockResolvedValue({ count: 10 }); - const { count } = await import("./count"); - await count.run?.({ args: { user: "200" } } as never); + const { default: count } = await import("./count"); + await count.parseAsync(["200"], { from: "user" }); expect(mockClient.getUserStarsCount).toHaveBeenCalledWith(200, {}); expect(mockClient.getMyself).not.toHaveBeenCalled(); @@ -42,10 +42,8 @@ describe("star count", () => { mockClient.getMyself.mockResolvedValue({ id: 100 }); mockClient.getUserStarsCount.mockResolvedValue({ count: 5 }); - const { count } = await import("./count"); - await count.run?.({ - args: { since: "2025-01-01", until: "2025-12-31" }, - } as never); + const { default: count } = await import("./count"); + await count.parseAsync(["--since", "2025-01-01", "--until", "2025-12-31"], { from: "user" }); expect(mockClient.getUserStarsCount).toHaveBeenCalledWith(100, { since: "2025-01-01", @@ -58,8 +56,8 @@ describe("star count", () => { mockClient.getUserStarsCount.mockResolvedValue({ count: 42 }); await expectStdoutContaining(async () => { - const { count } = await import("./count"); - await count.run?.({ args: { json: "" } } as never); + const { default: count } = await import("./count"); + await count.parseAsync(["--json"], { from: "user" }); }, "42"); }); }); diff --git a/apps/cli/src/commands/star/count.ts b/apps/cli/src/commands/star/count.ts index 11432ac0..8be5d9e4 100644 --- a/apps/cli/src/commands/star/count.ts +++ b/apps/cli/src/commands/star/count.ts @@ -1,16 +1,23 @@ import { getClient } from "@repo/backlog-utils"; -import { outputArgs, outputResult } from "@repo/cli-utils"; -import { defineCommand } from "citty"; +import { outputResult } from "@repo/cli-utils"; import consola from "consola"; -import { type CommandUsage, ENV_AUTH, withUsage } from "../../lib/command-usage"; +import { BeeCommand, ENV_AUTH } from "../../lib/bee-command"; +import * as opt from "../../lib/common-options"; -const commandUsage: CommandUsage = { - long: `Count stars received by a user. +const count = new BeeCommand("count") + .summary("Count received stars") + .description( + `Count stars received by a user. If no user ID is specified, counts stars for the authenticated user. Use \`--since\` and \`--until\` to filter by date range.`, - - examples: [ + ) + .argument("[user]", "User ID") + .option("--since <yyyy-MM-dd>", "Count stars received on or after this date") + .option("--until <yyyy-MM-dd>", "Count stars received on or before this date") + .addOption(opt.json()) + .envVars([...ENV_AUTH]) + .examples([ { description: "Count your stars", command: "bee star count" }, { description: "Count stars for a specific user", command: "bee star count 12345" }, { @@ -18,65 +25,31 @@ If no user ID is specified, counts stars for the authenticated user. Use command: "bee star count --since 2025-01-01 --until 2025-12-31", }, { description: "Output as JSON", command: "bee star count --json" }, - ], - - annotations: { - environment: [...ENV_AUTH], - }, -}; - -const count = withUsage( - defineCommand({ - meta: { - name: "count", - description: "Count received stars", - }, - args: { - ...outputArgs, - user: { - type: "positional", - description: "User ID", - required: false, - valueHint: "<number>", - }, - since: { - type: "string", - description: "Count stars received on or after this date", - valueHint: "<yyyy-MM-dd>", - }, - until: { - type: "string", - description: "Count stars received on or before this date", - valueHint: "<yyyy-MM-dd>", - }, - }, - async run({ args }) { - const { client } = await getClient(); - - let userId: number; - if (args.user) { - userId = Number(args.user); - } else { - const myself = await client.getMyself(); - userId = myself.id; - } - - const params: { since?: string; until?: string } = {}; - if (args.since) { - params.since = args.since; - } - if (args.until) { - params.until = args.until; - } - - const result = await client.getUserStarsCount(userId, params); - - outputResult(result, args, (data) => { - consola.log(String(data.count)); - }); - }, - }), - commandUsage, -); - -export { commandUsage, count }; + ]) + .action(async (user, opts) => { + const { client } = await getClient(); + + let userId: number; + if (user) { + userId = Number(user); + } else { + const myself = await client.getMyself(); + userId = myself.id; + } + + const params: { since?: string; until?: string } = {}; + if (opts.since) { + params.since = opts.since; + } + if (opts.until) { + params.until = opts.until; + } + + const result = await client.getUserStarsCount(userId, params); + + outputResult(result, opts, (data) => { + consola.log(String(data.count)); + }); + }); + +export default count; diff --git a/apps/cli/src/commands/star/list.test.ts b/apps/cli/src/commands/star/list.test.ts index a518acab..aa92d0e0 100644 --- a/apps/cli/src/commands/star/list.test.ts +++ b/apps/cli/src/commands/star/list.test.ts @@ -34,8 +34,8 @@ describe("star list", () => { mockClient.getMyself.mockResolvedValue({ id: 100 }); mockClient.getUserStars.mockResolvedValue(sampleStars); - const { list } = await import("./list"); - await list.run?.({ args: {} } as never); + const { default: list } = await import("./list"); + await list.parseAsync([], { from: "user" }); expect(getClient).toHaveBeenCalled(); expect(mockClient.getMyself).toHaveBeenCalled(); @@ -46,8 +46,8 @@ describe("star list", () => { it("lists stars for a specific user", async () => { mockClient.getUserStars.mockResolvedValue(sampleStars); - const { list } = await import("./list"); - await list.run?.({ args: { user: "200" } } as never); + const { default: list } = await import("./list"); + await list.parseAsync(["200"], { from: "user" }); expect(mockClient.getUserStars).toHaveBeenCalledWith(200, {}); expect(mockClient.getMyself).not.toHaveBeenCalled(); @@ -57,8 +57,8 @@ describe("star list", () => { mockClient.getMyself.mockResolvedValue({ id: 100 }); mockClient.getUserStars.mockResolvedValue([]); - const { list } = await import("./list"); - await list.run?.({ args: {} } as never); + const { default: list } = await import("./list"); + await list.parseAsync([], { from: "user" }); expect(consola.info).toHaveBeenCalledWith("No stars found."); }); @@ -68,8 +68,8 @@ describe("star list", () => { mockClient.getUserStars.mockResolvedValue(sampleStars); await expectStdoutContaining(async () => { - const { list } = await import("./list"); - await list.run?.({ args: { json: "" } } as never); + const { default: list } = await import("./list"); + await list.parseAsync(["--json"], { from: "user" }); }, "Sample issue"); }); }); diff --git a/apps/cli/src/commands/star/list.ts b/apps/cli/src/commands/star/list.ts index 75893f3f..a667b4be 100644 --- a/apps/cli/src/commands/star/list.ts +++ b/apps/cli/src/commands/star/list.ts @@ -1,71 +1,52 @@ import { getClient } from "@repo/backlog-utils"; -import { type Row, formatDate, outputArgs, outputResult, printTable } from "@repo/cli-utils"; -import { defineCommand } from "citty"; +import { type Row, formatDate, outputResult, printTable } from "@repo/cli-utils"; import consola from "consola"; -import { type CommandUsage, ENV_AUTH, withUsage } from "../../lib/command-usage"; +import { BeeCommand, ENV_AUTH } from "../../lib/bee-command"; +import * as opt from "../../lib/common-options"; -const commandUsage: CommandUsage = { - long: `List stars received by a user. +const list = new BeeCommand("list") + .summary("List received stars") + .description( + `List stars received by a user. If no user ID is specified, lists stars for the authenticated user.`, - - examples: [ + ) + .argument("[user]", "User ID") + .addOption(opt.json()) + .envVars([...ENV_AUTH]) + .examples([ { description: "List your stars", command: "bee star list" }, { description: "List stars for a specific user", command: "bee star list 12345" }, { description: "Output as JSON", command: "bee star list --json" }, - ], - - annotations: { - environment: [...ENV_AUTH], - }, -}; - -const list = withUsage( - defineCommand({ - meta: { - name: "list", - description: "List received stars", - }, - args: { - ...outputArgs, - user: { - type: "positional", - description: "User ID", - required: false, - valueHint: "<number>", - }, - }, - async run({ args }) { - const { client } = await getClient(); - - let userId: number; - if (args.user) { - userId = Number(args.user); - } else { - const myself = await client.getMyself(); - userId = myself.id; + ]) + .action(async (user, opts) => { + const { client } = await getClient(); + + let userId: number; + if (user) { + userId = Number(user); + } else { + const myself = await client.getMyself(); + userId = myself.id; + } + + const stars = await client.getUserStars(userId, {}); + + outputResult(stars, opts, (data) => { + if (data.length === 0) { + consola.info("No stars found."); + return; } - const stars = await client.getUserStars(userId, {}); - - outputResult(stars, args, (data) => { - if (data.length === 0) { - consola.info("No stars found."); - return; - } - - const rows: Row[] = data.map((s) => [ - { header: "ID", value: String(s.id) }, - { header: "TITLE", value: s.title }, - { header: "PRESENTER", value: s.presenter.name }, - { header: "DATE", value: formatDate(s.created) }, - ]); + const rows: Row[] = data.map((s) => [ + { header: "ID", value: String(s.id) }, + { header: "TITLE", value: s.title }, + { header: "PRESENTER", value: s.presenter.name }, + { header: "DATE", value: formatDate(s.created) }, + ]); - printTable(rows); - }); - }, - }), - commandUsage, -); + printTable(rows); + }); + }); -export { commandUsage, list }; +export default list; diff --git a/apps/cli/src/commands/star/remove.test.ts b/apps/cli/src/commands/star/remove.test.ts index c4a70a75..462cb6df 100644 --- a/apps/cli/src/commands/star/remove.test.ts +++ b/apps/cli/src/commands/star/remove.test.ts @@ -14,16 +14,16 @@ vi.mock("consola", () => import("@repo/test-utils/mock-consola")); describe("star remove", () => { it("removes a star by ID", async () => { mockClient.removeStar.mockResolvedValue(undefined); - const { remove } = await import("./remove"); - await remove.run?.({ args: { star: "12345" } } as never); + const { default: remove } = await import("./remove"); + await remove.parseAsync(["12345"], { from: "user" }); expect(mockClient.removeStar).toHaveBeenCalledWith(12_345); expect(consola.success).toHaveBeenCalledWith("Removed star 12345."); }); it("converts string ID to number", async () => { mockClient.removeStar.mockResolvedValue(undefined); - const { remove } = await import("./remove"); - await remove.run?.({ args: { star: "7" } } as never); + const { default: remove } = await import("./remove"); + await remove.parseAsync(["7"], { from: "user" }); expect(mockClient.removeStar).toHaveBeenCalledWith(7); }); }); diff --git a/apps/cli/src/commands/star/remove.ts b/apps/cli/src/commands/star/remove.ts index 6018057f..af228d4a 100644 --- a/apps/cli/src/commands/star/remove.ts +++ b/apps/cli/src/commands/star/remove.ts @@ -1,34 +1,21 @@ import { getClient } from "@repo/backlog-utils"; -import { defineCommand } from "citty"; import consola from "consola"; -import { type CommandUsage, ENV_AUTH, withUsage } from "../../lib/command-usage"; +import { BeeCommand, ENV_AUTH } from "../../lib/bee-command"; -const commandUsage: CommandUsage = { - long: `Remove a star. +const remove = new BeeCommand("remove") + .summary("Remove a star") + .description( + `Remove a star. Use \`bee star list\` to find star IDs.`, - examples: [{ description: "Remove a star", command: "bee star remove 12345" }], - annotations: { environment: [...ENV_AUTH] }, -}; + ) + .argument("<star>", "Star ID") + .envVars([...ENV_AUTH]) + .examples([{ description: "Remove a star", command: "bee star remove 12345" }]) + .action(async (star) => { + const { client } = await getClient(); + await client.removeStar(Number(star)); + consola.success(`Removed star ${star}.`); + }); -const remove = withUsage( - defineCommand({ - meta: { name: "remove", description: "Remove a star" }, - args: { - star: { - type: "positional", - description: "Star ID", - valueHint: "<number>", - required: true, - }, - }, - async run({ args }) { - const { client } = await getClient(); - await client.removeStar(Number(args.star)); - consola.success(`Removed star ${args.star}.`); - }, - }), - commandUsage, -); - -export { commandUsage, remove }; +export default remove; diff --git a/apps/cli/src/commands/team/create.test.ts b/apps/cli/src/commands/team/create.test.ts index 09f717fe..26df4c48 100644 --- a/apps/cli/src/commands/team/create.test.ts +++ b/apps/cli/src/commands/team/create.test.ts @@ -23,8 +23,8 @@ describe("team create", () => { vi.mocked(promptRequired).mockResolvedValueOnce("Dev Team"); mockClient.postTeam.mockResolvedValue({ id: 1, name: "Dev Team", members: [] }); - const { create } = await import("./create"); - await create.run?.({ args: { name: "Dev Team" } } as never); + const { default: create } = await import("./create"); + await create.parseAsync(["--name", "Dev Team"], { from: "user" }); expect(mockClient.postTeam).toHaveBeenCalledWith(expect.objectContaining({ name: "Dev Team" })); expect(consola.success).toHaveBeenCalledWith("Created team Dev Team (ID: 1)"); @@ -34,8 +34,8 @@ describe("team create", () => { vi.mocked(promptRequired).mockResolvedValueOnce("Prompted Team"); mockClient.postTeam.mockResolvedValue({ id: 2, name: "Prompted Team", members: [] }); - const { create } = await import("./create"); - await create.run?.({ args: {} } as never); + const { default: create } = await import("./create"); + await create.parseAsync([], { from: "user" }); expect(promptRequired).toHaveBeenCalledWith("Team name:", undefined); expect(mockClient.postTeam).toHaveBeenCalledWith( @@ -47,8 +47,10 @@ describe("team create", () => { vi.mocked(promptRequired).mockResolvedValueOnce("Team"); mockClient.postTeam.mockResolvedValue({ id: 3, name: "Team", members: [] }); - const { create } = await import("./create"); - await create.run?.({ args: { name: "Team", members: "111,222" } } as never); + const { default: create } = await import("./create"); + await create.parseAsync(["--name", "Team", "--members", "111", "--members", "222"], { + from: "user", + }); expect(mockClient.postTeam).toHaveBeenCalledWith( expect.objectContaining({ members: [111, 222] }), @@ -60,9 +62,9 @@ describe("team create", () => { const apiError = Object.assign(new Error("Bad Request"), { _status: 400, _body: undefined }); mockClient.postTeam.mockRejectedValue(apiError); - const { create } = await import("./create"); + const { default: create } = await import("./create"); - await expect(create.run?.({ args: { name: "Team" } } as never)).rejects.toThrow( + await expect(create.parseAsync(["--name", "Team"], { from: "user" })).rejects.toThrow( "Administrator role", ); }); @@ -72,8 +74,8 @@ describe("team create", () => { mockClient.postTeam.mockResolvedValue({ id: 1, name: "Team", members: [] }); await expectStdoutContaining(async () => { - const { create } = await import("./create"); - await create.run?.({ args: { name: "Team", json: "" } } as never); + const { default: create } = await import("./create"); + await create.parseAsync(["--name", "Team", "--json"], { from: "user" }); }, "Team"); }); }); diff --git a/apps/cli/src/commands/team/create.ts b/apps/cli/src/commands/team/create.ts index 55be1e65..55a80318 100644 --- a/apps/cli/src/commands/team/create.ts +++ b/apps/cli/src/commands/team/create.ts @@ -1,75 +1,53 @@ import { getClient } from "@repo/backlog-utils"; -import { outputArgs, outputResult, promptRequired, splitArg } from "@repo/cli-utils"; -import { defineCommand } from "citty"; +import { outputResult, promptRequired } from "@repo/cli-utils"; import consola from "consola"; -import * as v from "valibot"; -import { type CommandUsage, ENV_AUTH, withUsage } from "../../lib/command-usage"; +import { BeeCommand, ENV_AUTH } from "../../lib/bee-command"; +import { collectNum } from "../../lib/common-options"; +import * as opt from "../../lib/common-options"; import { handleTeamWriteError } from "./warn-team-write-restriction"; -const commandUsage: CommandUsage = { - long: `Create a new Backlog team. +const create = new BeeCommand("create") + .summary("Create a team") + .description( + `Create a new Backlog team. If \`--name\` is not provided, you will be prompted interactively. -Optionally specify \`--members\` with a comma-separated list of user IDs to -add members when creating the team.`, - - examples: [ +Optionally specify \`--members\` with user IDs (repeatable) to add members +when creating the team.`, + ) + .option("-n, --name <name>", "Team name") + .option("--members <id>", "User IDs to add as members (repeatable)", collectNum, [] as number[]) + .addOption(opt.json()) + .envVars([...ENV_AUTH]) + .examples([ { description: "Create a team interactively", command: "bee team create" }, { description: "Create a team with a name", command: 'bee team create --name "Design Team"' }, { description: "Create a team with members", - command: 'bee team create --name "Dev Team" --members 111,222,333', - }, - ], - - annotations: { - environment: [...ENV_AUTH], - }, -}; - -const create = withUsage( - defineCommand({ - meta: { - name: "create", - description: "Create a team", - }, - args: { - ...outputArgs, - name: { - type: "string", - alias: "n", - description: "Team name", - }, - members: { - type: "string", - description: "Comma-separated list of user IDs to add as members", - valueHint: "<userId,...>", - }, + command: 'bee team create --name "Dev Team" --members 111 --members 222 --members 333', }, - async run({ args }) { - const { client } = await getClient(); - - const name = await promptRequired("Team name:", args.name); - const members = splitArg(args.members, v.number()); - - let t; - try { - t = await client.postTeam({ - name, - members: members.length > 0 ? members : undefined, - }); - } catch (error) { - handleTeamWriteError(error); - throw error; - } - - outputResult(t, args, (data) => { - consola.success(`Created team ${data.name} (ID: ${data.id})`); + ]) + .action(async (opts) => { + const { client } = await getClient(); + + const name = await promptRequired("Team name:", opts.name); + const {members} = opts; + + let t; + try { + t = await client.postTeam({ + name, + members: members.length > 0 ? members : undefined, }); - }, - }), - commandUsage, -); + } catch (error) { + handleTeamWriteError(error); + throw error; + } + + outputResult(t, opts, (data) => { + consola.success(`Created team ${data.name} (ID: ${data.id})`); + }); + }); -export { commandUsage, create }; +export default create; diff --git a/apps/cli/src/commands/team/delete.test.ts b/apps/cli/src/commands/team/delete.test.ts index 2a7299ec..428539dd 100644 --- a/apps/cli/src/commands/team/delete.test.ts +++ b/apps/cli/src/commands/team/delete.test.ts @@ -23,8 +23,8 @@ describe("team delete", () => { vi.mocked(confirmOrExit).mockResolvedValue(true); mockClient.deleteTeam.mockResolvedValue({ id: 1, name: "Test Team", members: [] }); - const { deleteTeam } = await import("./delete"); - await deleteTeam.run?.({ args: { team: "1" } } as never); + const { default: deleteTeam } = await import("./delete"); + await deleteTeam.parseAsync(["1"], { from: "user" }); expect(confirmOrExit).toHaveBeenCalledWith( "Are you sure you want to delete team 1? This cannot be undone.", @@ -38,8 +38,8 @@ describe("team delete", () => { vi.mocked(confirmOrExit).mockResolvedValue(true); mockClient.deleteTeam.mockResolvedValue({ id: 1, name: "Test Team", members: [] }); - const { deleteTeam } = await import("./delete"); - await deleteTeam.run?.({ args: { team: "1", yes: true } } as never); + const { default: deleteTeam } = await import("./delete"); + await deleteTeam.parseAsync(["1", "--yes"], { from: "user" }); expect(confirmOrExit).toHaveBeenCalledWith( "Are you sure you want to delete team 1? This cannot be undone.", @@ -50,8 +50,8 @@ describe("team delete", () => { it("cancels when user declines confirmation", async () => { vi.mocked(confirmOrExit).mockResolvedValue(false); - const { deleteTeam } = await import("./delete"); - await deleteTeam.run?.({ args: { team: "1" } } as never); + const { default: deleteTeam } = await import("./delete"); + await deleteTeam.parseAsync(["1"], { from: "user" }); expect(mockClient.deleteTeam).not.toHaveBeenCalled(); }); @@ -61,8 +61,8 @@ describe("team delete", () => { mockClient.deleteTeam.mockResolvedValue({ id: 1, name: "Test Team", members: [] }); await expectStdoutContaining(async () => { - const { deleteTeam } = await import("./delete"); - await deleteTeam.run?.({ args: { team: "1", yes: true, json: "" } } as never); + const { default: deleteTeam } = await import("./delete"); + await deleteTeam.parseAsync(["1", "--yes", "--json"], { from: "user" }); }, "Test Team"); }); }); diff --git a/apps/cli/src/commands/team/delete.ts b/apps/cli/src/commands/team/delete.ts index d614956e..979f771e 100644 --- a/apps/cli/src/commands/team/delete.ts +++ b/apps/cli/src/commands/team/delete.ts @@ -1,16 +1,22 @@ import { getClient } from "@repo/backlog-utils"; -import { confirmOrExit, outputArgs, outputResult } from "@repo/cli-utils"; -import { defineCommand } from "citty"; +import { confirmOrExit, outputResult } from "@repo/cli-utils"; import consola from "consola"; -import { type CommandUsage, ENV_AUTH, withUsage } from "../../lib/command-usage"; +import { BeeCommand, ENV_AUTH } from "../../lib/bee-command"; +import * as opt from "../../lib/common-options"; -const commandUsage: CommandUsage = { - long: `Delete a Backlog team. +const deleteTeam = new BeeCommand("delete") + .summary("Delete a team") + .description( + `Delete a Backlog team. This action is irreversible. You will be prompted for confirmation unless \`--yes\` is provided.`, - - examples: [ + ) + .argument("<team>", "Team ID") + .option("-y, --yes", "Skip confirmation prompt") + .addOption(opt.json()) + .envVars([...ENV_AUTH]) + .examples([ { description: "Delete a team (with confirmation)", command: "bee team delete 12345", @@ -19,53 +25,24 @@ This action is irreversible. You will be prompted for confirmation unless description: "Delete a team without confirmation", command: "bee team delete 12345 --yes", }, - ], - - annotations: { - environment: [...ENV_AUTH], - }, -}; - -const deleteTeam = withUsage( - defineCommand({ - meta: { - name: "delete", - description: "Delete a team", - }, - args: { - ...outputArgs, - team: { - type: "positional", - description: "Team ID", - required: true, - valueHint: "<number>", - }, - yes: { - type: "boolean", - alias: "y", - description: "Skip confirmation prompt", - }, - }, - async run({ args }) { - const confirmed = await confirmOrExit( - `Are you sure you want to delete team ${args.team}? This cannot be undone.`, - args.yes, - ); + ]) + .action(async (team, opts) => { + const confirmed = await confirmOrExit( + `Are you sure you want to delete team ${team}? This cannot be undone.`, + opts.yes, + ); - if (!confirmed) { - return; - } + if (!confirmed) { + return; + } - const { client } = await getClient(); + const { client } = await getClient(); - const t = await client.deleteTeam(Number(args.team)); + const t = await client.deleteTeam(Number(team)); - outputResult(t, args, (data) => { - consola.success(`Deleted team ${data.name} (ID: ${data.id})`); - }); - }, - }), - commandUsage, -); + outputResult(t, opts, (data) => { + consola.success(`Deleted team ${data.name} (ID: ${data.id})`); + }); + }); -export { commandUsage, deleteTeam }; +export default deleteTeam; diff --git a/apps/cli/src/commands/team/edit.test.ts b/apps/cli/src/commands/team/edit.test.ts index ef116067..c90af586 100644 --- a/apps/cli/src/commands/team/edit.test.ts +++ b/apps/cli/src/commands/team/edit.test.ts @@ -16,8 +16,8 @@ describe("team edit", () => { it("updates team name", async () => { mockClient.patchTeam.mockResolvedValue({ id: 1, name: "New Name", members: [] }); - const { edit } = await import("./edit"); - await edit.run?.({ args: { team: "1", name: "New Name" } } as never); + const { default: edit } = await import("./edit"); + await edit.parseAsync(["1", "--name", "New Name"], { from: "user" }); expect(mockClient.patchTeam).toHaveBeenCalledWith( 1, @@ -29,8 +29,8 @@ describe("team edit", () => { it("updates team members", async () => { mockClient.patchTeam.mockResolvedValue({ id: 1, name: "Team", members: [] }); - const { edit } = await import("./edit"); - await edit.run?.({ args: { team: "1", members: "111,222" } } as never); + const { default: edit } = await import("./edit"); + await edit.parseAsync(["1", "--members", "111", "--members", "222"], { from: "user" }); expect(mockClient.patchTeam).toHaveBeenCalledWith( 1, @@ -42,9 +42,9 @@ describe("team edit", () => { const apiError = Object.assign(new Error("Bad Request"), { _status: 400, _body: undefined }); mockClient.patchTeam.mockRejectedValue(apiError); - const { edit } = await import("./edit"); + const { default: edit } = await import("./edit"); - await expect(edit.run?.({ args: { team: "1", name: "X" } } as never)).rejects.toThrow( + await expect(edit.parseAsync(["1", "--name", "X"], { from: "user" })).rejects.toThrow( "Administrator role", ); }); @@ -53,8 +53,8 @@ describe("team edit", () => { mockClient.patchTeam.mockResolvedValue({ id: 1, name: "Team", members: [] }); await expectStdoutContaining(async () => { - const { edit } = await import("./edit"); - await edit.run?.({ args: { team: "1", name: "Team", json: "" } } as never); + const { default: edit } = await import("./edit"); + await edit.parseAsync(["1", "--name", "Team", "--json"], { from: "user" }); }, "Team"); }); }); diff --git a/apps/cli/src/commands/team/edit.ts b/apps/cli/src/commands/team/edit.ts index 12042171..486aaa71 100644 --- a/apps/cli/src/commands/team/edit.ts +++ b/apps/cli/src/commands/team/edit.ts @@ -1,83 +1,61 @@ import { getClient } from "@repo/backlog-utils"; -import { outputArgs, outputResult, splitArg } from "@repo/cli-utils"; -import { defineCommand } from "citty"; +import { outputResult } from "@repo/cli-utils"; import consola from "consola"; -import * as v from "valibot"; -import { type CommandUsage, ENV_AUTH, withUsage } from "../../lib/command-usage"; +import { BeeCommand, ENV_AUTH } from "../../lib/bee-command"; +import { collectNum } from "../../lib/common-options"; +import * as opt from "../../lib/common-options"; import { handleTeamWriteError } from "./warn-team-write-restriction"; -const commandUsage: CommandUsage = { - long: `Update an existing Backlog team. +const edit = new BeeCommand("edit") + .summary("Edit a team") + .description( + `Update an existing Backlog team. Only the specified fields will be updated. Fields that are not provided will remain unchanged. When \`--members\` is specified, it replaces the entire member list with the given user IDs.`, - - examples: [ + ) + .argument("<team>", "Team ID") + .option("-n, --name <name>", "New name of the team") + .option( + "--members <id>", + "Replace members with user IDs (repeatable)", + collectNum, + [] as number[], + ) + .addOption(opt.json()) + .envVars([...ENV_AUTH]) + .examples([ { description: "Rename a team", command: 'bee team edit 12345 --name "New Team Name"', }, { description: "Replace team members", - command: "bee team edit 12345 --members 111,222,333", - }, - ], - - annotations: { - environment: [...ENV_AUTH], - }, -}; - -const edit = withUsage( - defineCommand({ - meta: { - name: "edit", - description: "Edit a team", + command: "bee team edit 12345 --members 111 --members 222 --members 333", }, - args: { - ...outputArgs, - team: { - type: "positional", - description: "Team ID", - required: true, - valueHint: "<number>", - }, - name: { - type: "string", - alias: "n", - description: "New name of the team", - }, - members: { - type: "string", - description: "Replace members with comma-separated list of user IDs", - valueHint: "<userId,...>", - }, - }, - async run({ args }) { - const { client } = await getClient(); - - const members = splitArg(args.members, v.number()); + ]) + .action(async (team, opts) => { + const { client } = await getClient(); - let t; - try { - t = await client.patchTeam(Number(args.team), { - name: args.name, - members: members.length > 0 ? members : undefined, - }); - } catch (error) { - handleTeamWriteError(error); - throw error; - } + const {members} = opts; - outputResult(t, args, (data) => { - consola.success(`Updated team ${data.name} (ID: ${data.id})`); + let t; + try { + t = await client.patchTeam(Number(team), { + name: opts.name, + members: members.length > 0 ? members : undefined, }); - }, - }), - commandUsage, -); + } catch (error) { + handleTeamWriteError(error); + throw error; + } + + outputResult(t, opts, (data) => { + consola.success(`Updated team ${data.name} (ID: ${data.id})`); + }); + }); -export { commandUsage, edit }; +export default edit; diff --git a/apps/cli/src/commands/team/list.test.ts b/apps/cli/src/commands/team/list.test.ts index 47636f8c..3007f0d0 100644 --- a/apps/cli/src/commands/team/list.test.ts +++ b/apps/cli/src/commands/team/list.test.ts @@ -41,8 +41,8 @@ describe("team list", () => { it("displays team list in tabular format", async () => { mockClient.getTeams.mockResolvedValue(sampleTeams); - const { list } = await import("./list"); - await list.run?.({ args: {} } as never); + const { default: list } = await import("./list"); + await list.parseAsync([], { from: "user" }); expect(getClient).toHaveBeenCalled(); expect(mockClient.getTeams).toHaveBeenCalled(); @@ -54,8 +54,8 @@ describe("team list", () => { it("shows message when no teams found", async () => { mockClient.getTeams.mockResolvedValue([]); - const { list } = await import("./list"); - await list.run?.({ args: {} } as never); + const { default: list } = await import("./list"); + await list.parseAsync([], { from: "user" }); expect(consola.info).toHaveBeenCalledWith("No teams found."); }); @@ -63,8 +63,8 @@ describe("team list", () => { it("passes order parameter", async () => { mockClient.getTeams.mockResolvedValue([]); - const { list } = await import("./list"); - await list.run?.({ args: { order: "desc" } } as never); + const { default: list } = await import("./list"); + await list.parseAsync(["--order", "desc"], { from: "user" }); expect(mockClient.getTeams).toHaveBeenCalledWith(expect.objectContaining({ order: "desc" })); }); @@ -72,8 +72,8 @@ describe("team list", () => { it("passes offset and count parameters", async () => { mockClient.getTeams.mockResolvedValue([]); - const { list } = await import("./list"); - await list.run?.({ args: { offset: "10", count: "5" } } as never); + const { default: list } = await import("./list"); + await list.parseAsync(["--offset", "10", "--count", "5"], { from: "user" }); expect(mockClient.getTeams).toHaveBeenCalledWith( expect.objectContaining({ offset: 10, count: 5 }), @@ -84,8 +84,8 @@ describe("team list", () => { mockClient.getTeams.mockResolvedValue(sampleTeams); await expectStdoutContaining(async () => { - const { list } = await import("./list"); - await list.run?.({ args: { json: "" } } as never); + const { default: list } = await import("./list"); + await list.parseAsync(["--json"], { from: "user" }); }, "Design Team"); }); }); diff --git a/apps/cli/src/commands/team/list.ts b/apps/cli/src/commands/team/list.ts index 24e074eb..9d89c5fd 100644 --- a/apps/cli/src/commands/team/list.ts +++ b/apps/cli/src/commands/team/list.ts @@ -1,67 +1,52 @@ import { getClient } from "@repo/backlog-utils"; -import { type Row, outputArgs, outputResult, printTable } from "@repo/cli-utils"; -import { defineCommand } from "citty"; +import { type Row, outputResult, printTable } from "@repo/cli-utils"; import consola from "consola"; import * as v from "valibot"; -import { type CommandUsage, ENV_AUTH, withUsage } from "../../lib/command-usage"; -import * as commonArgs from "../../lib/common-args"; +import { BeeCommand, ENV_AUTH } from "../../lib/bee-command"; +import * as opt from "../../lib/common-options"; -const commandUsage: CommandUsage = { - long: `List teams in the space. +const list = new BeeCommand("list") + .summary("List teams") + .description( + `List teams in the space. Teams are groups of users that can be assigned to projects collectively. Use \`--order\` to control sort direction and \`--offset\` / \`--count\` for pagination.`, - - examples: [ + ) + .addOption(opt.json()) + .addOption(opt.order()) + .addOption(opt.offset()) + .addOption(opt.count()) + .envVars([...ENV_AUTH]) + .examples([ { description: "List all teams", command: "bee team list" }, { description: "List teams in descending order", command: "bee team list --order desc" }, { description: "Output as JSON", command: "bee team list --json" }, - ], - - annotations: { - environment: [...ENV_AUTH], - }, -}; - -const list = withUsage( - defineCommand({ - meta: { - name: "list", - description: "List teams", - }, - args: { - ...outputArgs, - order: commonArgs.order, - offset: commonArgs.offset, - count: commonArgs.count, - }, - async run({ args }) { - const { client } = await getClient(); + ]) + .action(async (opts) => { + const { client } = await getClient(); - const order = v.parse(v.optional(v.picklist(["asc", "desc"])), args.order); - const offset = v.parse(v.optional(v.pipe(v.string(), v.transform(Number))), args.offset); - const count = v.parse(v.optional(v.pipe(v.string(), v.transform(Number))), args.count); + const order = v.parse(v.optional(v.picklist(["asc", "desc"])), opts.order); + const offset = v.parse(v.optional(v.pipe(v.string(), v.transform(Number))), opts.offset); + const count = v.parse(v.optional(v.pipe(v.string(), v.transform(Number))), opts.count); - const teams = await client.getTeams({ order, offset, count }); + const teams = await client.getTeams({ order, offset, count }); - outputResult(teams, args, (data) => { - if (data.length === 0) { - consola.info("No teams found."); - return; - } + outputResult(teams, opts, (data) => { + if (data.length === 0) { + consola.info("No teams found."); + return; + } - const rows: Row[] = data.map((t) => [ - { header: "ID", value: String(t.id) }, - { header: "NAME", value: t.name }, - { header: "MEMBERS", value: String(t.members.length) }, - ]); + const rows: Row[] = data.map((t) => [ + { header: "ID", value: String(t.id) }, + { header: "NAME", value: t.name }, + { header: "MEMBERS", value: String(t.members.length) }, + ]); - printTable(rows); - }); - }, - }), - commandUsage, -); + printTable(rows); + }); + }); -export { commandUsage, list }; +export default list; diff --git a/apps/cli/src/commands/team/view.test.ts b/apps/cli/src/commands/team/view.test.ts index 345683e8..05749b77 100644 --- a/apps/cli/src/commands/team/view.test.ts +++ b/apps/cli/src/commands/team/view.test.ts @@ -30,8 +30,8 @@ describe("team view", () => { it("displays team details", async () => { mockClient.getTeam.mockResolvedValue(sampleTeam); - const { view } = await import("./view"); - await view.run?.({ args: { team: "1" } } as never); + const { default: view } = await import("./view"); + await view.parseAsync(["1"], { from: "user" }); expect(getClient).toHaveBeenCalled(); expect(mockClient.getTeam).toHaveBeenCalledWith(1); @@ -45,8 +45,8 @@ describe("team view", () => { it("displays team with no members", async () => { mockClient.getTeam.mockResolvedValue({ ...sampleTeam, members: [] }); - const { view } = await import("./view"); - await view.run?.({ args: { team: "1" } } as never); + const { default: view } = await import("./view"); + await view.parseAsync(["1"], { from: "user" }); expect(consola.log).toHaveBeenCalledWith(expect.stringContaining("Design Team")); expect(consola.log).not.toHaveBeenCalledWith(expect.stringContaining("Members:")); @@ -56,8 +56,8 @@ describe("team view", () => { mockClient.getTeam.mockResolvedValue(sampleTeam); await expectStdoutContaining(async () => { - const { view } = await import("./view"); - await view.run?.({ args: { team: "1", json: "" } } as never); + const { default: view } = await import("./view"); + await view.parseAsync(["1", "--json"], { from: "user" }); }, "Design Team"); }); }); diff --git a/apps/cli/src/commands/team/view.ts b/apps/cli/src/commands/team/view.ts index b1ae089e..64f214fa 100644 --- a/apps/cli/src/commands/team/view.ts +++ b/apps/cli/src/commands/team/view.ts @@ -1,70 +1,51 @@ import { getClient } from "@repo/backlog-utils"; -import { outputArgs, outputResult, printDefinitionList } from "@repo/cli-utils"; -import { defineCommand } from "citty"; +import { outputResult, printDefinitionList } from "@repo/cli-utils"; import consola from "consola"; -import { type CommandUsage, ENV_AUTH, withUsage } from "../../lib/command-usage"; +import { BeeCommand, ENV_AUTH } from "../../lib/bee-command"; +import * as opt from "../../lib/common-options"; -const commandUsage: CommandUsage = { - long: `Display details of a Backlog team. +const view = new BeeCommand("view") + .summary("View a team") + .description( + `Display details of a Backlog team. Shows team name, ID, creator, creation date, and the list of members belonging to the team.`, - - examples: [ + ) + .argument("<team>", "Team ID") + .addOption(opt.json()) + .envVars([...ENV_AUTH]) + .examples([ { description: "View team details", command: "bee team view 12345" }, { description: "Output as JSON", command: "bee team view 12345 --json" }, - ], - - annotations: { - environment: [...ENV_AUTH], - }, -}; - -const view = withUsage( - defineCommand({ - meta: { - name: "view", - description: "View a team", - }, - args: { - ...outputArgs, - team: { - type: "positional", - description: "Team ID", - required: true, - valueHint: "<number>", - }, - }, - async run({ args }) { - const { client } = await getClient(); - - const t = await client.getTeam(Number(args.team)); - - outputResult(t, args, (data) => { - consola.log(""); - consola.log(` ${data.name}`); + ]) + .action(async (team, opts) => { + const { client } = await getClient(); + + const t = await client.getTeam(Number(team)); + + outputResult(t, opts, (data) => { + consola.log(""); + consola.log(` ${data.name}`); + consola.log(""); + printDefinitionList([ + ["ID", String(data.id)], + ["Created by", data.createdUser?.name ?? "—"], + ["Created", data.created], + ["Updated", data.updated], + ["Members", String(data.members.length)], + ]); + + if (data.members.length > 0) { consola.log(""); - printDefinitionList([ - ["ID", String(data.id)], - ["Created by", data.createdUser?.name ?? "—"], - ["Created", data.created], - ["Updated", data.updated], - ["Members", String(data.members.length)], - ]); - - if (data.members.length > 0) { - consola.log(""); - consola.log(" Members:"); - for (const member of data.members) { - consola.log(` - ${member.name} (${member.userId ?? member.id})`); - } + consola.log(" Members:"); + for (const member of data.members) { + consola.log(` - ${member.name} (${member.userId ?? member.id})`); } + } - consola.log(""); - }); - }, - }), - commandUsage, -); + consola.log(""); + }); + }); -export { commandUsage, view }; +export default view; diff --git a/apps/cli/src/commands/user/activities.test.ts b/apps/cli/src/commands/user/activities.test.ts index af0b8f49..c2ba3970 100644 --- a/apps/cli/src/commands/user/activities.test.ts +++ b/apps/cli/src/commands/user/activities.test.ts @@ -34,8 +34,8 @@ describe("user activities", () => { }, ]); - const { activities } = await import("./activities"); - await activities.run?.({ args: { user: "12345" } } as never); + const { default: activities } = await import("./activities"); + await activities.parseAsync(["12345"], { from: "user" }); expect(mockClient.getUserActivities).toHaveBeenCalledWith(12_345, expect.any(Object)); expect(consola.log).toHaveBeenCalledWith(expect.stringContaining("2024-01-15")); @@ -48,8 +48,8 @@ describe("user activities", () => { it("shows message when no activities found", async () => { mockClient.getUserActivities.mockResolvedValue([]); - const { activities } = await import("./activities"); - await activities.run?.({ args: { user: "12345" } } as never); + const { default: activities } = await import("./activities"); + await activities.parseAsync(["12345"], { from: "user" }); expect(consola.info).toHaveBeenCalledWith("No activities found."); }); @@ -57,10 +57,11 @@ describe("user activities", () => { it("passes activity type filter as array of numbers", async () => { mockClient.getUserActivities.mockResolvedValue([]); - const { activities } = await import("./activities"); - await activities.run?.({ - args: { user: "12345", "activity-type": "1,2,3" }, - } as never); + const { default: activities } = await import("./activities"); + await activities.parseAsync( + ["12345", "--activity-type", "1", "--activity-type", "2", "--activity-type", "3"], + { from: "user" }, + ); expect(mockClient.getUserActivities).toHaveBeenCalledWith( 12_345, @@ -73,10 +74,8 @@ describe("user activities", () => { it("passes count parameter", async () => { mockClient.getUserActivities.mockResolvedValue([]); - const { activities } = await import("./activities"); - await activities.run?.({ - args: { user: "12345", count: "50" }, - } as never); + const { default: activities } = await import("./activities"); + await activities.parseAsync(["12345", "--count", "50"], { from: "user" }); expect(mockClient.getUserActivities).toHaveBeenCalledWith( 12_345, @@ -97,8 +96,8 @@ describe("user activities", () => { ]); await expectStdoutContaining(async () => { - const { activities } = await import("./activities"); - await activities.run?.({ args: { user: "12345", json: "" } } as never); + const { default: activities } = await import("./activities"); + await activities.parseAsync(["12345", "--json"], { from: "user" }); }, "Test"); }); }); diff --git a/apps/cli/src/commands/user/activities.ts b/apps/cli/src/commands/user/activities.ts index 4fea5eb1..0b77e64b 100644 --- a/apps/cli/src/commands/user/activities.ts +++ b/apps/cli/src/commands/user/activities.ts @@ -1,17 +1,9 @@ import { ACTIVITY_LABELS, getClient } from "@repo/backlog-utils"; -import { - type Row, - formatDate, - outputArgs, - outputResult, - printTable, - splitArg, -} from "@repo/cli-utils"; -import { defineCommand } from "citty"; +import { type Row, formatDate, outputResult, printTable } from "@repo/cli-utils"; import consola from "consola"; -import * as v from "valibot"; -import { type CommandUsage, ENV_AUTH, withUsage } from "../../lib/command-usage"; -import * as commonArgs from "../../lib/common-args"; +import { BeeCommand, ENV_AUTH } from "../../lib/bee-command"; +import * as opt from "../../lib/common-options"; +import { collectNum } from "../../lib/common-options"; const getActivitySummary = (activity: { type: number; @@ -40,24 +32,39 @@ const getActivitySummary = (activity: { return ""; }; -const commandUsage: CommandUsage = { - long: `List recent activities of a Backlog user. +const activities = new BeeCommand("activities") + .summary("List user activities") + .description( + `List recent activities of a Backlog user. Shows the most recent updates performed by the specified user, including issue changes, wiki edits, git pushes, and other activities. Results are ordered by most recent first. -Use \`--activity-type\` to filter by specific activity types (comma-separated IDs). +Use \`--activity-type\` to filter by specific activity types (repeatable IDs). Use \`--count\` to control how many activities are returned (default: 20, max: 100). For a list of activity type IDs, see: https://developer.nulab.com/docs/backlog/api/2/get-user-recent-updates/#activity-type`, - - examples: [ + ) + .argument("<user>", "User ID") + .option( + "--activity-type <id>", + "Filter by activity type IDs (repeatable)", + collectNum, + [] as number[], + ) + .addOption(opt.count()) + .addOption(opt.order()) + .addOption(opt.minId()) + .addOption(opt.maxId()) + .addOption(opt.json()) + .envVars([...ENV_AUTH]) + .examples([ { description: "List user activities", command: "bee user activities 12345" }, { description: "Show only issue-related activities", - command: "bee user activities 12345 --activity-type 1,2,3", + command: "bee user activities 12345 --activity-type 1 --activity-type 2 --activity-type 3", }, { description: "Show last 50 activities", @@ -67,68 +74,35 @@ https://developer.nulab.com/docs/backlog/api/2/get-user-recent-updates/#activity description: "Output as JSON", command: "bee user activities 12345 --json", }, - ], + ]) + .action(async (user, opts) => { + const { client } = await getClient(); - annotations: { - environment: [...ENV_AUTH], - }, -}; + const activityTypeId: number[] = opts.activityType; -const activities = withUsage( - defineCommand({ - meta: { - name: "activities", - description: "List user activities", - }, - args: { - ...outputArgs, - user: { - type: "positional", - description: "User ID", - required: true, - valueHint: "<number>", - }, - "activity-type": { - type: "string", - description: "Filter by activity type IDs (comma-separated)", - valueHint: "<1,2,3>", - }, - count: commonArgs.count, - order: commonArgs.order, - "min-id": commonArgs.minId, - "max-id": commonArgs.maxId, - }, - async run({ args }) { - const { client } = await getClient(); + const activityList = await client.getUserActivities(Number(user), { + activityTypeId, + count: opts.count ? Number(opts.count) : undefined, + order: opts.order as "asc" | "desc" | undefined, + minId: opts.minId ? Number(opts.minId) : undefined, + maxId: opts.maxId ? Number(opts.maxId) : undefined, + }); - const activityTypeId = splitArg(args["activity-type"], v.number()); + outputResult(activityList, opts, (data) => { + if (data.length === 0) { + consola.info("No activities found."); + return; + } - const activityList = await client.getUserActivities(Number(args.user), { - activityTypeId, - count: args.count ? Number(args.count) : undefined, - order: args.order as "asc" | "desc" | undefined, - minId: args["min-id"] ? Number(args["min-id"]) : undefined, - maxId: args["max-id"] ? Number(args["max-id"]) : undefined, - }); + const rows: Row[] = data.map((activity) => [ + { header: "DATE", value: formatDate(activity.created) }, + { header: "TYPE", value: ACTIVITY_LABELS[activity.type] ?? `Type ${activity.type}` }, + { header: "PROJECT", value: activity.project?.name ?? "" }, + { header: "SUMMARY", value: getActivitySummary(activity) }, + ]); - outputResult(activityList, args, (data) => { - if (data.length === 0) { - consola.info("No activities found."); - return; - } - - const rows: Row[] = data.map((activity) => [ - { header: "DATE", value: formatDate(activity.created) }, - { header: "TYPE", value: ACTIVITY_LABELS[activity.type] ?? `Type ${activity.type}` }, - { header: "PROJECT", value: activity.project?.name ?? "" }, - { header: "SUMMARY", value: getActivitySummary(activity) }, - ]); - - printTable(rows); - }); - }, - }), - commandUsage, -); + printTable(rows); + }); + }); -export { commandUsage, activities }; +export default activities; diff --git a/apps/cli/src/commands/user/list.test.ts b/apps/cli/src/commands/user/list.test.ts index dcd6738b..16c0876d 100644 --- a/apps/cli/src/commands/user/list.test.ts +++ b/apps/cli/src/commands/user/list.test.ts @@ -21,8 +21,8 @@ describe("user list", () => { { id: 2, userId: "user2", name: "User Two", roleType: 2 }, ]); - const { list } = await import("./list"); - await list.run?.({ args: {} } as never); + const { default: list } = await import("./list"); + await list.parseAsync([], { from: "user" }); expect(getClient).toHaveBeenCalled(); expect(mockClient.getUsers).toHaveBeenCalled(); @@ -35,8 +35,8 @@ describe("user list", () => { it("shows message when no users found", async () => { mockClient.getUsers.mockResolvedValue([]); - const { list } = await import("./list"); - await list.run?.({ args: {} } as never); + const { default: list } = await import("./list"); + await list.parseAsync([], { from: "user" }); expect(consola.info).toHaveBeenCalledWith("No users found."); }); @@ -47,8 +47,8 @@ describe("user list", () => { ]); await expectStdoutContaining(async () => { - const { list } = await import("./list"); - await list.run?.({ args: { json: "" } } as never); + const { default: list } = await import("./list"); + await list.parseAsync(["--json"], { from: "user" }); }, "user1"); }); }); diff --git a/apps/cli/src/commands/user/list.ts b/apps/cli/src/commands/user/list.ts index 6e645e18..82b3c353 100644 --- a/apps/cli/src/commands/user/list.ts +++ b/apps/cli/src/commands/user/list.ts @@ -1,57 +1,43 @@ import { ROLE_LABELS, getClient } from "@repo/backlog-utils"; -import { type Row, outputArgs, outputResult, printTable } from "@repo/cli-utils"; -import { defineCommand } from "citty"; +import { type Row, outputResult, printTable } from "@repo/cli-utils"; import consola from "consola"; -import { type CommandUsage, ENV_AUTH, withUsage } from "../../lib/command-usage"; +import { BeeCommand, ENV_AUTH } from "../../lib/bee-command"; +import * as opt from "../../lib/common-options"; -const commandUsage: CommandUsage = { - long: `List all users in the Backlog space. +const list = new BeeCommand("list") + .summary("List users") + .description( + `List all users in the Backlog space. Displays each user's ID, user ID, name, and role. Only space administrators can see the full list of users.`, - - examples: [ + ) + .addOption(opt.json()) + .envVars([...ENV_AUTH]) + .examples([ { description: "List all users", command: "bee user list" }, { description: "Output as JSON", command: "bee user list --json" }, - ], - - annotations: { - environment: [...ENV_AUTH], - }, -}; - -const list = withUsage( - defineCommand({ - meta: { - name: "list", - description: "List users", - }, - args: { - ...outputArgs, - }, - async run({ args }) { - const { client } = await getClient(); - - const users = await client.getUsers(); - - outputResult(users, args, (data) => { - if (data.length === 0) { - consola.info("No users found."); - return; - } - - const rows: Row[] = data.map((u) => [ - { header: "ID", value: String(u.id) }, - { header: "USER ID", value: u.userId ?? "" }, - { header: "NAME", value: u.name }, - { header: "ROLE", value: ROLE_LABELS[u.roleType] ?? `Unknown (${u.roleType})` }, - ]); - - printTable(rows); - }); - }, - }), - commandUsage, -); - -export { commandUsage, list }; + ]) + .action(async (opts) => { + const { client } = await getClient(); + + const users = await client.getUsers(); + + outputResult(users, opts, (data) => { + if (data.length === 0) { + consola.info("No users found."); + return; + } + + const rows: Row[] = data.map((u) => [ + { header: "ID", value: String(u.id) }, + { header: "USER ID", value: u.userId ?? "" }, + { header: "NAME", value: u.name }, + { header: "ROLE", value: ROLE_LABELS[u.roleType] ?? `Unknown (${u.roleType})` }, + ]); + + printTable(rows); + }); + }); + +export default list; diff --git a/apps/cli/src/commands/user/me.test.ts b/apps/cli/src/commands/user/me.test.ts index df7629ca..75d278ed 100644 --- a/apps/cli/src/commands/user/me.test.ts +++ b/apps/cli/src/commands/user/me.test.ts @@ -28,8 +28,8 @@ describe("user me", () => { it("displays authenticated user details", async () => { mockClient.getMyself.mockResolvedValue(sampleUser); - const { me } = await import("./me"); - await me.run?.({ args: {} } as never); + const { default: me } = await import("./me"); + await me.parseAsync([], { from: "user" }); expect(getClient).toHaveBeenCalled(); expect(mockClient.getMyself).toHaveBeenCalled(); @@ -43,8 +43,8 @@ describe("user me", () => { mockClient.getMyself.mockResolvedValue(sampleUser); await expectStdoutContaining(async () => { - const { me } = await import("./me"); - await me.run?.({ args: { json: "" } } as never); + const { default: me } = await import("./me"); + await me.parseAsync(["--json"], { from: "user" }); }, "myself"); }); }); diff --git a/apps/cli/src/commands/user/me.ts b/apps/cli/src/commands/user/me.ts index 2edf591e..6f4cdc18 100644 --- a/apps/cli/src/commands/user/me.ts +++ b/apps/cli/src/commands/user/me.ts @@ -1,57 +1,43 @@ import { ROLE_LABELS, getClient } from "@repo/backlog-utils"; -import { formatDate, outputArgs, outputResult, printDefinitionList } from "@repo/cli-utils"; -import { defineCommand } from "citty"; +import { formatDate, outputResult, printDefinitionList } from "@repo/cli-utils"; import consola from "consola"; -import { type CommandUsage, ENV_AUTH, withUsage } from "../../lib/command-usage"; +import { BeeCommand, ENV_AUTH } from "../../lib/bee-command"; +import * as opt from "../../lib/common-options"; -const commandUsage: CommandUsage = { - long: `Display details of the authenticated user. +const me = new BeeCommand("me") + .summary("View the authenticated user") + .description( + `Display details of the authenticated user. This is a shortcut for \`bee user view\` that automatically looks up the currently authenticated user. Shows the same profile information: name, user ID, email address, role, language, and last login time.`, - - examples: [ + ) + .addOption(opt.json()) + .envVars([...ENV_AUTH]) + .examples([ { description: "View your own profile", command: "bee user me" }, { description: "Output as JSON", command: "bee user me --json" }, - ], - - annotations: { - environment: [...ENV_AUTH], - }, -}; - -const me = withUsage( - defineCommand({ - meta: { - name: "me", - description: "View the authenticated user", - }, - args: { - ...outputArgs, - }, - async run({ args }) { - const { client } = await getClient(); - - const myself = await client.getMyself(); - - outputResult(myself, args, (data) => { - consola.log(""); - consola.log(` ${data.name}`); - consola.log(""); - printDefinitionList([ - ["ID", String(data.id)], - ["User ID", data.userId], - ["Email", data.mailAddress], - ["Role", ROLE_LABELS[data.roleType] ?? `Unknown (${data.roleType})`], - ["Language", data.lang], - ["Last Login", data.lastLoginTime ? formatDate(data.lastLoginTime) : undefined], - ]); - consola.log(""); - }); - }, - }), - commandUsage, -); - -export { commandUsage, me }; + ]) + .action(async (opts) => { + const { client } = await getClient(); + + const myself = await client.getMyself(); + + outputResult(myself, opts, (data) => { + consola.log(""); + consola.log(` ${data.name}`); + consola.log(""); + printDefinitionList([ + ["ID", String(data.id)], + ["User ID", data.userId], + ["Email", data.mailAddress], + ["Role", ROLE_LABELS[data.roleType] ?? `Unknown (${data.roleType})`], + ["Language", data.lang], + ["Last Login", data.lastLoginTime ? formatDate(data.lastLoginTime) : undefined], + ]); + consola.log(""); + }); + }); + +export default me; diff --git a/apps/cli/src/commands/user/view.test.ts b/apps/cli/src/commands/user/view.test.ts index ab4bd2e3..16742e50 100644 --- a/apps/cli/src/commands/user/view.test.ts +++ b/apps/cli/src/commands/user/view.test.ts @@ -28,8 +28,8 @@ describe("user view", () => { it("displays user details", async () => { mockClient.getUser.mockResolvedValue(sampleUser); - const { view } = await import("./view"); - await view.run?.({ args: { user: "12345" } } as never); + const { default: view } = await import("./view"); + await view.parseAsync(["12345"], { from: "user" }); expect(getClient).toHaveBeenCalled(); expect(mockClient.getUser).toHaveBeenCalledWith(12_345); @@ -43,8 +43,8 @@ describe("user view", () => { it("displays role label correctly for administrator", async () => { mockClient.getUser.mockResolvedValue({ ...sampleUser, roleType: 1 }); - const { view } = await import("./view"); - await view.run?.({ args: { user: "12345" } } as never); + const { default: view } = await import("./view"); + await view.parseAsync(["12345"], { from: "user" }); expect(consola.log).toHaveBeenCalledWith(expect.stringContaining("Administrator")); }); @@ -53,8 +53,8 @@ describe("user view", () => { mockClient.getUser.mockResolvedValue(sampleUser); await expectStdoutContaining(async () => { - const { view } = await import("./view"); - await view.run?.({ args: { user: "12345", json: "" } } as never); + const { default: view } = await import("./view"); + await view.parseAsync(["12345", "--json"], { from: "user" }); }, "testuser"); }); }); diff --git a/apps/cli/src/commands/user/view.ts b/apps/cli/src/commands/user/view.ts index 5d1a12a5..48df41ea 100644 --- a/apps/cli/src/commands/user/view.ts +++ b/apps/cli/src/commands/user/view.ts @@ -1,64 +1,45 @@ import { ROLE_LABELS, getClient } from "@repo/backlog-utils"; -import { formatDate, outputArgs, outputResult, printDefinitionList } from "@repo/cli-utils"; -import { defineCommand } from "citty"; +import { formatDate, outputResult, printDefinitionList } from "@repo/cli-utils"; import consola from "consola"; -import { type CommandUsage, ENV_AUTH, withUsage } from "../../lib/command-usage"; +import { BeeCommand, ENV_AUTH } from "../../lib/bee-command"; +import * as opt from "../../lib/common-options"; -const commandUsage: CommandUsage = { - long: `Display details of a Backlog user. +const view = new BeeCommand("view") + .summary("View a user") + .description( + `Display details of a Backlog user. Shows user profile information including name, user ID, email address, role, language, and last login time. Use \`bee user me\` as a shortcut to view your own profile.`, - - examples: [ + ) + .argument("<user>", "User ID") + .addOption(opt.json()) + .envVars([...ENV_AUTH]) + .examples([ { description: "View user details", command: "bee user view 12345" }, { description: "Output as JSON", command: "bee user view 12345 --json" }, - ], - - annotations: { - environment: [...ENV_AUTH], - }, -}; - -const view = withUsage( - defineCommand({ - meta: { - name: "view", - description: "View a user", - }, - args: { - ...outputArgs, - user: { - type: "positional", - description: "User ID", - required: true, - valueHint: "<number>", - }, - }, - async run({ args }) { - const { client } = await getClient(); - - const userData = await client.getUser(Number(args.user)); - - outputResult(userData, args, (data) => { - consola.log(""); - consola.log(` ${data.name}`); - consola.log(""); - printDefinitionList([ - ["ID", String(data.id)], - ["User ID", data.userId], - ["Email", data.mailAddress], - ["Role", ROLE_LABELS[data.roleType] ?? `Unknown (${data.roleType})`], - ["Language", data.lang], - ["Last Login", data.lastLoginTime ? formatDate(data.lastLoginTime) : undefined], - ]); - consola.log(""); - }); - }, - }), - commandUsage, -); - -export { commandUsage, view }; + ]) + .action(async (user, opts) => { + const { client } = await getClient(); + + const userData = await client.getUser(Number(user)); + + outputResult(userData, opts, (data) => { + consola.log(""); + consola.log(` ${data.name}`); + consola.log(""); + printDefinitionList([ + ["ID", String(data.id)], + ["User ID", data.userId], + ["Email", data.mailAddress], + ["Role", ROLE_LABELS[data.roleType] ?? `Unknown (${data.roleType})`], + ["Language", data.lang], + ["Last Login", data.lastLoginTime ? formatDate(data.lastLoginTime) : undefined], + ]); + consola.log(""); + }); + }); + +export default view; diff --git a/apps/cli/src/commands/watching/add.test.ts b/apps/cli/src/commands/watching/add.test.ts index c6910118..6f4f78ac 100644 --- a/apps/cli/src/commands/watching/add.test.ts +++ b/apps/cli/src/commands/watching/add.test.ts @@ -20,8 +20,8 @@ describe("watching add", () => { issue: { issueKey: "TEST-1", summary: "Fix bug" }, }); - const { add } = await import("./add"); - await add.run?.({ args: { issue: "TEST-1" } } as never); + const { default: add } = await import("./add"); + await add.parseAsync(["--issue", "TEST-1"], { from: "user" }); expect(getClient).toHaveBeenCalled(); expect(mockClient.postWatchingListItem).toHaveBeenCalledWith({ @@ -37,8 +37,8 @@ describe("watching add", () => { issue: { issueKey: "TEST-2", summary: "Add feature" }, }); - const { add } = await import("./add"); - await add.run?.({ args: { issue: "TEST-2", note: "Track progress" } } as never); + const { default: add } = await import("./add"); + await add.parseAsync(["--issue", "TEST-2", "--note", "Track progress"], { from: "user" }); expect(mockClient.postWatchingListItem).toHaveBeenCalledWith({ issueIdOrKey: "TEST-2", @@ -54,8 +54,8 @@ describe("watching add", () => { }); await expectStdoutContaining(async () => { - const { add } = await import("./add"); - await add.run?.({ args: { issue: "TEST-1", json: "" } } as never); + const { default: add } = await import("./add"); + await add.parseAsync(["--issue", "TEST-1", "--json"], { from: "user" }); }, "TEST-1"); }); }); diff --git a/apps/cli/src/commands/watching/add.ts b/apps/cli/src/commands/watching/add.ts index 77e590d1..e908c4fd 100644 --- a/apps/cli/src/commands/watching/add.ts +++ b/apps/cli/src/commands/watching/add.ts @@ -1,57 +1,39 @@ import { getClient } from "@repo/backlog-utils"; -import { outputArgs, outputResult } from "@repo/cli-utils"; -import { defineCommand } from "citty"; +import { outputResult } from "@repo/cli-utils"; import consola from "consola"; -import { type CommandUsage, ENV_AUTH, withUsage } from "../../lib/command-usage"; -import * as commonArgs from "../../lib/common-args"; +import { BeeCommand, ENV_AUTH } from "../../lib/bee-command"; +import * as opt from "../../lib/common-options"; -const commandUsage: CommandUsage = { - long: `Add an issue to your watching list. +const add = new BeeCommand("add") + .summary("Add a watching item") + .description( + `Add an issue to your watching list. Subscribe to an issue to receive notifications when it is updated. Optionally attach a note for your own reference.`, - - examples: [ + ) + .requiredOption("--issue <key>", "Issue ID or issue key") + .option("--note <text>", "Note to attach to the watching item") + .addOption(opt.json()) + .envVars([...ENV_AUTH]) + .examples([ { description: "Watch an issue", command: "bee watching add --issue PROJECT-123" }, { description: "Watch an issue with a note", command: 'bee watching add --issue PROJECT-123 --note "Track progress"', }, - ], - - annotations: { - environment: [...ENV_AUTH], - }, -}; - -const add = withUsage( - defineCommand({ - meta: { - name: "add", - description: "Add a watching item", - }, - args: { - ...outputArgs, - issue: { ...commonArgs.issue, required: true }, - note: { - type: "string", - description: "Note to attach to the watching item", - }, - }, - async run({ args }) { - const { client } = await getClient(); + ]) + .action(async (opts) => { + const { client } = await getClient(); - const result = await client.postWatchingListItem({ - issueIdOrKey: args.issue, - note: args.note ?? "", - }); + const result = await client.postWatchingListItem({ + issueIdOrKey: opts.issue, + note: opts.note ?? "", + }); - outputResult(result, args, (data) => { - consola.success(`Added watching for issue ${data.issue.issueKey} (ID: ${data.id}).`); - }); - }, - }), - commandUsage, -); + outputResult(result, opts, (data) => { + consola.success(`Added watching for issue ${data.issue.issueKey} (ID: ${data.id}).`); + }); + }); -export { add, commandUsage }; +export default add; diff --git a/apps/cli/src/commands/watching/delete.test.ts b/apps/cli/src/commands/watching/delete.test.ts index cdfb4c1a..25e533da 100644 --- a/apps/cli/src/commands/watching/delete.test.ts +++ b/apps/cli/src/commands/watching/delete.test.ts @@ -23,8 +23,8 @@ describe("watching delete", () => { vi.mocked(confirmOrExit).mockResolvedValue(true); mockClient.deletehWatchingListItem.mockResolvedValue({ id: 1 }); - const { deleteWatching } = await import("./delete"); - await deleteWatching.run?.({ args: { watching: "1" } } as never); + const { default: deleteWatching } = await import("./delete"); + await deleteWatching.parseAsync(["1"], { from: "user" }); expect(confirmOrExit).toHaveBeenCalledWith( "Are you sure you want to delete watching 1? This cannot be undone.", @@ -38,8 +38,8 @@ describe("watching delete", () => { vi.mocked(confirmOrExit).mockResolvedValue(true); mockClient.deletehWatchingListItem.mockResolvedValue({ id: 1 }); - const { deleteWatching } = await import("./delete"); - await deleteWatching.run?.({ args: { watching: "1", yes: true } } as never); + const { default: deleteWatching } = await import("./delete"); + await deleteWatching.parseAsync(["1", "--yes"], { from: "user" }); expect(confirmOrExit).toHaveBeenCalledWith( "Are you sure you want to delete watching 1? This cannot be undone.", @@ -50,8 +50,8 @@ describe("watching delete", () => { it("cancels when user declines confirmation", async () => { vi.mocked(confirmOrExit).mockResolvedValue(false); - const { deleteWatching } = await import("./delete"); - await deleteWatching.run?.({ args: { watching: "1" } } as never); + const { default: deleteWatching } = await import("./delete"); + await deleteWatching.parseAsync(["1"], { from: "user" }); expect(mockClient.deletehWatchingListItem).not.toHaveBeenCalled(); }); @@ -61,10 +61,8 @@ describe("watching delete", () => { mockClient.deletehWatchingListItem.mockResolvedValue({ id: 1 }); await expectStdoutContaining(async () => { - const { deleteWatching } = await import("./delete"); - await deleteWatching.run?.({ - args: { watching: "1", yes: true, json: "" }, - } as never); + const { default: deleteWatching } = await import("./delete"); + await deleteWatching.parseAsync(["1", "--yes", "--json"], { from: "user" }); }, "1"); }); }); diff --git a/apps/cli/src/commands/watching/delete.ts b/apps/cli/src/commands/watching/delete.ts index bcd002fc..b38ef91f 100644 --- a/apps/cli/src/commands/watching/delete.ts +++ b/apps/cli/src/commands/watching/delete.ts @@ -1,17 +1,23 @@ import { getClient } from "@repo/backlog-utils"; -import { confirmOrExit, outputArgs, outputResult } from "@repo/cli-utils"; -import { defineCommand } from "citty"; +import { confirmOrExit, outputResult } from "@repo/cli-utils"; import consola from "consola"; -import { type CommandUsage, ENV_AUTH, withUsage } from "../../lib/command-usage"; +import { BeeCommand, ENV_AUTH } from "../../lib/bee-command"; +import * as opt from "../../lib/common-options"; -const commandUsage: CommandUsage = { - long: `Delete a watching item. +const deleteWatching = new BeeCommand("delete") + .summary("Delete a watching item") + .description( + `Delete a watching item. This removes the issue from your watching list. You will no longer receive notifications for updates to the issue. You will be prompted for confirmation unless \`--yes\` is provided.`, - - examples: [ + ) + .argument("<watching>", "Watching ID") + .option("-y, --yes", "Skip confirmation prompt") + .addOption(opt.json()) + .envVars([...ENV_AUTH]) + .examples([ { description: "Delete a watching item (with confirmation)", command: "bee watching delete 12345", @@ -20,53 +26,24 @@ unless \`--yes\` is provided.`, description: "Delete without confirmation", command: "bee watching delete 12345 --yes", }, - ], - - annotations: { - environment: [...ENV_AUTH], - }, -}; - -const deleteWatching = withUsage( - defineCommand({ - meta: { - name: "delete", - description: "Delete a watching item", - }, - args: { - ...outputArgs, - watching: { - type: "positional", - description: "Watching ID", - required: true, - valueHint: "<number>", - }, - yes: { - type: "boolean", - alias: "y", - description: "Skip confirmation prompt", - }, - }, - async run({ args }) { - const confirmed = await confirmOrExit( - `Are you sure you want to delete watching ${args.watching}? This cannot be undone.`, - args.yes, - ); + ]) + .action(async (watching, opts) => { + const confirmed = await confirmOrExit( + `Are you sure you want to delete watching ${watching}? This cannot be undone.`, + opts.yes, + ); - if (!confirmed) { - return; - } + if (!confirmed) { + return; + } - const { client } = await getClient(); + const { client } = await getClient(); - const result = await client.deletehWatchingListItem(Number(args.watching)); + const result = await client.deletehWatchingListItem(Number(watching)); - outputResult(result, args, (data) => { - consola.success(`Deleted watching ${data.id}.`); - }); - }, - }), - commandUsage, -); + outputResult(result, opts, (data) => { + consola.success(`Deleted watching ${data.id}.`); + }); + }); -export { commandUsage, deleteWatching }; +export default deleteWatching; diff --git a/apps/cli/src/commands/watching/list.test.ts b/apps/cli/src/commands/watching/list.test.ts index 001e0bd9..50dffec3 100644 --- a/apps/cli/src/commands/watching/list.test.ts +++ b/apps/cli/src/commands/watching/list.test.ts @@ -32,8 +32,8 @@ describe("watching list", () => { mockClient.getMyself.mockResolvedValue({ id: 100 }); mockClient.getWatchingListItems.mockResolvedValue(sampleWatchings); - const { list } = await import("./list"); - await list.run?.({ args: {} } as never); + const { default: list } = await import("./list"); + await list.parseAsync([], { from: "user" }); expect(getClient).toHaveBeenCalled(); expect(mockClient.getMyself).toHaveBeenCalled(); @@ -45,8 +45,8 @@ describe("watching list", () => { mockClient.getMyself.mockResolvedValue({ id: 100 }); mockClient.getWatchingListItems.mockResolvedValue([]); - const { list } = await import("./list"); - await list.run?.({ args: {} } as never); + const { default: list } = await import("./list"); + await list.parseAsync([], { from: "user" }); expect(consola.info).toHaveBeenCalledWith("No watching items found."); }); @@ -56,8 +56,8 @@ describe("watching list", () => { mockClient.getWatchingListItems.mockResolvedValue(sampleWatchings); await expectStdoutContaining(async () => { - const { list } = await import("./list"); - await list.run?.({ args: { json: "" } } as never); + const { default: list } = await import("./list"); + await list.parseAsync(["--json"], { from: "user" }); }, "TEST-1"); }); }); diff --git a/apps/cli/src/commands/watching/list.ts b/apps/cli/src/commands/watching/list.ts index 47b8ac73..10898450 100644 --- a/apps/cli/src/commands/watching/list.ts +++ b/apps/cli/src/commands/watching/list.ts @@ -1,58 +1,44 @@ import { getClient } from "@repo/backlog-utils"; -import { type Row, outputArgs, outputResult, printTable } from "@repo/cli-utils"; -import { defineCommand } from "citty"; +import { type Row, outputResult, printTable } from "@repo/cli-utils"; import consola from "consola"; -import { type CommandUsage, ENV_AUTH, withUsage } from "../../lib/command-usage"; +import { BeeCommand, ENV_AUTH } from "../../lib/bee-command"; +import * as opt from "../../lib/common-options"; -const commandUsage: CommandUsage = { - long: `List watching items for the authenticated user. +const list = new BeeCommand("list") + .summary("List watching items") + .description( + `List watching items for the authenticated user. Watching items are issue subscriptions. Unread items are marked with an asterisk (\`*\`).`, - - examples: [ + ) + .addOption(opt.json()) + .envVars([...ENV_AUTH]) + .examples([ { description: "List your watching items", command: "bee watching list" }, { description: "Output as JSON", command: "bee watching list --json" }, - ], - - annotations: { - environment: [...ENV_AUTH], - }, -}; - -const list = withUsage( - defineCommand({ - meta: { - name: "list", - description: "List watching items", - }, - args: { - ...outputArgs, - }, - async run({ args }) { - const { client } = await getClient(); - - const myself = await client.getMyself(); - const watchings = await client.getWatchingListItems(myself.id); - - outputResult(watchings, args, (data) => { - if (data.length === 0) { - consola.info("No watching items found."); - return; - } - - const rows: Row[] = data.map((w) => [ - { header: "", value: w.resourceAlreadyRead ? " " : "*" }, - { header: "ID", value: String(w.id) }, - { header: "ISSUE KEY", value: w.issue.issueKey }, - { header: "TITLE", value: w.issue.summary }, - ]); - - printTable(rows); - }); - }, - }), - commandUsage, -); - -export { commandUsage, list }; + ]) + .action(async (opts) => { + const { client } = await getClient(); + + const myself = await client.getMyself(); + const watchings = await client.getWatchingListItems(myself.id); + + outputResult(watchings, opts, (data) => { + if (data.length === 0) { + consola.info("No watching items found."); + return; + } + + const rows: Row[] = data.map((w) => [ + { header: "", value: w.resourceAlreadyRead ? " " : "*" }, + { header: "ID", value: String(w.id) }, + { header: "ISSUE KEY", value: w.issue.issueKey }, + { header: "TITLE", value: w.issue.summary }, + ]); + + printTable(rows); + }); + }); + +export default list; diff --git a/apps/cli/src/commands/watching/read.test.ts b/apps/cli/src/commands/watching/read.test.ts index afdaa684..eccfec1e 100644 --- a/apps/cli/src/commands/watching/read.test.ts +++ b/apps/cli/src/commands/watching/read.test.ts @@ -16,8 +16,8 @@ describe("watching read", () => { it("marks a watching item as read", async () => { mockClient.resetWatchingListItemAsRead.mockResolvedValue(undefined); - const { read } = await import("./read"); - await read.run?.({ args: { watching: "12345" } } as never); + const { default: read } = await import("./read"); + await read.parseAsync(["12345"], { from: "user" }); expect(getClient).toHaveBeenCalled(); expect(mockClient.resetWatchingListItemAsRead).toHaveBeenCalledWith(12_345); @@ -27,8 +27,8 @@ describe("watching read", () => { it("converts string ID to number", async () => { mockClient.resetWatchingListItemAsRead.mockResolvedValue(undefined); - const { read } = await import("./read"); - await read.run?.({ args: { watching: "42" } } as never); + const { default: read } = await import("./read"); + await read.parseAsync(["42"], { from: "user" }); expect(mockClient.resetWatchingListItemAsRead).toHaveBeenCalledWith(42); }); diff --git a/apps/cli/src/commands/watching/read.ts b/apps/cli/src/commands/watching/read.ts index d280ae00..e8c13c1f 100644 --- a/apps/cli/src/commands/watching/read.ts +++ b/apps/cli/src/commands/watching/read.ts @@ -1,44 +1,24 @@ import { getClient } from "@repo/backlog-utils"; -import { defineCommand } from "citty"; import consola from "consola"; -import { type CommandUsage, ENV_AUTH, withUsage } from "../../lib/command-usage"; +import { BeeCommand, ENV_AUTH } from "../../lib/bee-command"; -const commandUsage: CommandUsage = { - long: `Mark a watching item as read. +const read = new BeeCommand("read") + .summary("Mark a watching item as read") + .description( + `Mark a watching item as read. Specify the watching ID to mark as read. Use \`bee watching list\` to find watching IDs.`, + ) + .argument("<watching>", "Watching ID") + .envVars([...ENV_AUTH]) + .examples([{ description: "Mark a watching item as read", command: "bee watching read 12345" }]) + .action(async (watching) => { + const { client } = await getClient(); - examples: [{ description: "Mark a watching item as read", command: "bee watching read 12345" }], + await client.resetWatchingListItemAsRead(Number(watching)); - annotations: { - environment: [...ENV_AUTH], - }, -}; + consola.success(`Marked watching ${watching} as read.`); + }); -const read = withUsage( - defineCommand({ - meta: { - name: "read", - description: "Mark a watching item as read", - }, - args: { - watching: { - type: "positional", - description: "Watching ID", - required: true, - valueHint: "<number>", - }, - }, - async run({ args }) { - const { client } = await getClient(); - - await client.resetWatchingListItemAsRead(Number(args.watching)); - - consola.success(`Marked watching ${args.watching} as read.`); - }, - }), - commandUsage, -); - -export { commandUsage, read }; +export default read; diff --git a/apps/cli/src/commands/watching/view.test.ts b/apps/cli/src/commands/watching/view.test.ts index 106b3a17..a7ed810d 100644 --- a/apps/cli/src/commands/watching/view.test.ts +++ b/apps/cli/src/commands/watching/view.test.ts @@ -26,8 +26,8 @@ describe("watching view", () => { it("displays watching item details", async () => { mockClient.getWatchingListItem.mockResolvedValue(sampleWatching); - const { view } = await import("./view"); - await view.run?.({ args: { watching: "1" } } as never); + const { default: view } = await import("./view"); + await view.parseAsync(["1"], { from: "user" }); expect(getClient).toHaveBeenCalled(); expect(mockClient.getWatchingListItem).toHaveBeenCalledWith(1); @@ -41,8 +41,8 @@ describe("watching view", () => { mockClient.getWatchingListItem.mockResolvedValue(sampleWatching); await expectStdoutContaining(async () => { - const { view } = await import("./view"); - await view.run?.({ args: { watching: "1", json: "" } } as never); + const { default: view } = await import("./view"); + await view.parseAsync(["1", "--json"], { from: "user" }); }, "TEST-1"); }); }); diff --git a/apps/cli/src/commands/watching/view.ts b/apps/cli/src/commands/watching/view.ts index db552355..03c4ed6d 100644 --- a/apps/cli/src/commands/watching/view.ts +++ b/apps/cli/src/commands/watching/view.ts @@ -1,62 +1,43 @@ import { getClient } from "@repo/backlog-utils"; -import { formatDate, outputArgs, outputResult, printDefinitionList } from "@repo/cli-utils"; -import { defineCommand } from "citty"; +import { formatDate, outputResult, printDefinitionList } from "@repo/cli-utils"; import consola from "consola"; -import { type CommandUsage, ENV_AUTH, withUsage } from "../../lib/command-usage"; +import { BeeCommand, ENV_AUTH } from "../../lib/bee-command"; +import * as opt from "../../lib/common-options"; -const commandUsage: CommandUsage = { - long: `Display details of a watching item. +const view = new BeeCommand("view") + .summary("View a watching item") + .description( + `Display details of a watching item. Shows the watching ID, associated issue, note, read status, and timestamps.`, - - examples: [ + ) + .argument("<watching>", "Watching ID") + .addOption(opt.json()) + .envVars([...ENV_AUTH]) + .examples([ { description: "View a watching item", command: "bee watching view 12345" }, { description: "Output as JSON", command: "bee watching view 12345 --json" }, - ], - - annotations: { - environment: [...ENV_AUTH], - }, -}; - -const view = withUsage( - defineCommand({ - meta: { - name: "view", - description: "View a watching item", - }, - args: { - ...outputArgs, - watching: { - type: "positional", - description: "Watching ID", - required: true, - valueHint: "<number>", - }, - }, - async run({ args }) { - const { client } = await getClient(); - - const watching = await client.getWatchingListItem(Number(args.watching)); - - outputResult(watching, args, (data) => { - consola.log(""); - consola.log(` ${data.issue.issueKey}: ${data.issue.summary}`); - consola.log(""); - printDefinitionList([ - ["ID", String(data.id)], - ["Issue Key", data.issue.issueKey], - ["Title", data.issue.summary], - ["Note", data.note || undefined], - ["Read", data.resourceAlreadyRead ? "Read" : "Unread"], - ["Created", formatDate(data.created)], - ["Updated", formatDate(data.updated)], - ]); - consola.log(""); - }); - }, - }), - commandUsage, -); - -export { commandUsage, view }; + ]) + .action(async (watching, opts) => { + const { client } = await getClient(); + + const watchingData = await client.getWatchingListItem(Number(watching)); + + outputResult(watchingData, opts, (data) => { + consola.log(""); + consola.log(` ${data.issue.issueKey}: ${data.issue.summary}`); + consola.log(""); + printDefinitionList([ + ["ID", String(data.id)], + ["Issue Key", data.issue.issueKey], + ["Title", data.issue.summary], + ["Note", data.note || undefined], + ["Read", data.resourceAlreadyRead ? "Read" : "Unread"], + ["Created", formatDate(data.created)], + ["Updated", formatDate(data.updated)], + ]); + consola.log(""); + }); + }); + +export default view; diff --git a/apps/cli/src/commands/webhook/create.test.ts b/apps/cli/src/commands/webhook/create.test.ts index a7da45f1..03421fff 100644 --- a/apps/cli/src/commands/webhook/create.test.ts +++ b/apps/cli/src/commands/webhook/create.test.ts @@ -20,18 +20,24 @@ vi.mock("consola", () => import("@repo/test-utils/mock-consola")); describe("webhook create", () => { it("creates a webhook with provided arguments", async () => { + vi.mocked(promptRequired).mockImplementation((_, val) => Promise.resolve(val ?? "")); + vi.mocked(promptRequired).mockResolvedValueOnce("TEST"); vi.mocked(promptRequired).mockResolvedValueOnce("Deploy Hook"); mockClient.postWebhook.mockResolvedValue({ id: 1, name: "Deploy Hook" }); - const { create } = await import("./create"); - await create.run?.({ - args: { - project: "TEST", - name: "Deploy Hook", - "hook-url": "https://example.com/hook", - "all-event": true, - }, - } as never); + const { default: create } = await import("./create"); + await create.parseAsync( + [ + "-p", + "TEST", + "--name", + "Deploy Hook", + "--hook-url", + "https://example.com/hook", + "--all-event", + ], + { from: "user" }, + ); expect(mockClient.postWebhook).toHaveBeenCalledWith("TEST", { name: "Deploy Hook", @@ -43,13 +49,15 @@ describe("webhook create", () => { }); it("prompts for name when not provided", async () => { + vi.mocked(promptRequired).mockImplementation((_, val) => Promise.resolve(val ?? "")); + vi.mocked(promptRequired).mockResolvedValueOnce("TEST"); vi.mocked(promptRequired).mockResolvedValueOnce("Prompted Hook"); mockClient.postWebhook.mockResolvedValue({ id: 2, name: "Prompted Hook" }); - const { create } = await import("./create"); - await create.run?.({ - args: { project: "TEST", "hook-url": "https://example.com/hook" }, - } as never); + const { default: create } = await import("./create"); + await create.parseAsync(["-p", "TEST", "--hook-url", "https://example.com/hook"], { + from: "user", + }); expect(promptRequired).toHaveBeenCalledWith("Webhook name:", undefined); expect(mockClient.postWebhook).toHaveBeenCalledWith("TEST", { @@ -60,19 +68,30 @@ describe("webhook create", () => { }); }); - it("parses activity type IDs from comma-separated string", async () => { + it("parses activity type IDs from repeatable option", async () => { + vi.mocked(promptRequired).mockImplementation((_, val) => Promise.resolve(val ?? "")); + vi.mocked(promptRequired).mockResolvedValueOnce("TEST"); vi.mocked(promptRequired).mockResolvedValueOnce("Hook"); mockClient.postWebhook.mockResolvedValue({ id: 3, name: "Hook" }); - const { create } = await import("./create"); - await create.run?.({ - args: { - project: "TEST", - name: "Hook", - "hook-url": "https://example.com/hook", - "activity-type-ids": "1,2,3", - }, - } as never); + const { default: create } = await import("./create"); + await create.parseAsync( + [ + "-p", + "TEST", + "--name", + "Hook", + "--hook-url", + "https://example.com/hook", + "--activity-type-ids", + "1", + "--activity-type-ids", + "2", + "--activity-type-ids", + "3", + ], + { from: "user" }, + ); expect(mockClient.postWebhook).toHaveBeenCalledWith("TEST", { name: "Hook", @@ -83,19 +102,17 @@ describe("webhook create", () => { }); it("outputs JSON when --json flag is set", async () => { + vi.mocked(promptRequired).mockImplementation((_, val) => Promise.resolve(val ?? "")); + vi.mocked(promptRequired).mockResolvedValueOnce("TEST"); vi.mocked(promptRequired).mockResolvedValueOnce("Hook"); mockClient.postWebhook.mockResolvedValue({ id: 1, name: "Hook" }); await expectStdoutContaining(async () => { - const { create } = await import("./create"); - await create.run?.({ - args: { - project: "TEST", - name: "Hook", - "hook-url": "https://example.com/hook", - json: "", - }, - } as never); + const { default: create } = await import("./create"); + await create.parseAsync( + ["-p", "TEST", "--name", "Hook", "--hook-url", "https://example.com/hook", "--json"], + { from: "user" }, + ); }, "Hook"); }); }); diff --git a/apps/cli/src/commands/webhook/create.ts b/apps/cli/src/commands/webhook/create.ts index e26f0176..d6f0d01e 100644 --- a/apps/cli/src/commands/webhook/create.ts +++ b/apps/cli/src/commands/webhook/create.ts @@ -1,21 +1,35 @@ import { getClient } from "@repo/backlog-utils"; -import { outputArgs, outputResult, promptRequired, splitArg } from "@repo/cli-utils"; -import { defineCommand } from "citty"; +import { outputResult, promptRequired } from "@repo/cli-utils"; import consola from "consola"; -import * as v from "valibot"; -import { type CommandUsage, ENV_AUTH, ENV_PROJECT, withUsage } from "../../lib/command-usage"; -import * as commonArgs from "../../lib/common-args"; +import { BeeCommand, ENV_AUTH, ENV_PROJECT } from "../../lib/bee-command"; +import * as opt from "../../lib/common-options"; +import { collectNum } from "../../lib/common-options"; +import { resolveOptions } from "../../lib/required-option"; -const commandUsage: CommandUsage = { - long: `Create a new webhook in a Backlog project. +const create = new BeeCommand("create") + .summary("Create a webhook") + .description( + `Create a new webhook in a Backlog project. If \`--name\` is not provided, you will be prompted interactively. Either \`--all-event\` or \`--activity-type-ids\` must be specified. Use \`--all-event\` to subscribe to all activity types, or specify individual activity type IDs with \`--activity-type-ids\`.`, - - examples: [ + ) + .addOption(opt.project()) + .option("-n, --name <name>", "Webhook name") + .requiredOption("--hook-url <url>", "URL to receive webhook notifications") + .option("--all-event", "Subscribe to all event types") + .option( + "--activity-type-ids <id>", + "Activity type IDs to subscribe to (repeatable)", + collectNum, + [] as number[], + ) + .addOption(opt.json()) + .envVars([...ENV_AUTH, ENV_PROJECT]) + .examples([ { description: "Create a webhook", command: 'bee webhook create -p PROJECT -n "Deploy Hook" --hook-url https://example.com/hook', @@ -28,63 +42,27 @@ activity type IDs with \`--activity-type-ids\`.`, { description: "Create a webhook for specific activity types", command: - "bee webhook create -p PROJECT -n CI --hook-url https://example.com/hook --activity-type-ids 1,2,3", + "bee webhook create -p PROJECT -n CI --hook-url https://example.com/hook --activity-type-ids 1 --activity-type-ids 2 --activity-type-ids 3", }, - ], + ]) + .action(async (_opts, cmd) => { + const opts = await resolveOptions(cmd); + const { client } = await getClient(); - annotations: { - environment: [...ENV_AUTH, ENV_PROJECT], - }, -}; + const name = await promptRequired("Webhook name:", opts.name as string | undefined); + const activityTypeIds: number[] = (opts.activityTypeIds as number[]) ?? []; -const create = withUsage( - defineCommand({ - meta: { - name: "create", - description: "Create a webhook", - }, - args: { - ...outputArgs, - project: { ...commonArgs.project, required: true }, - name: { - type: "string", - alias: "n", - description: "Webhook name", - }, - "hook-url": { - type: "string", - description: "URL to receive webhook notifications", - required: true, - }, - "all-event": { - type: "boolean", - description: "Subscribe to all event types", - }, - "activity-type-ids": { - type: "string", - description: "Activity type IDs to subscribe to", - valueHint: "<1,2,3,...>", - }, - }, - async run({ args }) { - const { client } = await getClient(); + const webhook = await client.postWebhook(opts.project as string, { + name, + hookUrl: opts.hookUrl as string, + allEvent: opts.allEvent as boolean | undefined, + activityTypeIds, + }); - const name = await promptRequired("Webhook name:", args.name); - const activityTypeIds = splitArg(args["activity-type-ids"], v.number()); - - const webhook = await client.postWebhook(args.project, { - name, - hookUrl: args["hook-url"], - allEvent: args["all-event"], - activityTypeIds, - }); - - outputResult(webhook, args, (data) => { - consola.success(`Created webhook ${data.name} (ID: ${data.id})`); - }); - }, - }), - commandUsage, -); + const json = opts.json === true ? "" : (opts.json as string | undefined); + outputResult(webhook, { json }, (data) => { + consola.success(`Created webhook ${data.name} (ID: ${data.id})`); + }); + }); -export { commandUsage, create }; +export default create; diff --git a/apps/cli/src/commands/webhook/delete.test.ts b/apps/cli/src/commands/webhook/delete.test.ts index f8e1679c..64db6138 100644 --- a/apps/cli/src/commands/webhook/delete.test.ts +++ b/apps/cli/src/commands/webhook/delete.test.ts @@ -14,6 +14,7 @@ vi.mock("@repo/backlog-utils", () => ({ vi.mock("@repo/cli-utils", async (importOriginal) => ({ ...(await importOriginal()), confirmOrExit: vi.fn(), + promptRequired: vi.fn((_, val) => Promise.resolve(val)), })); vi.mock("consola", () => import("@repo/test-utils/mock-consola")); @@ -23,8 +24,8 @@ describe("webhook delete", () => { vi.mocked(confirmOrExit).mockResolvedValue(true); mockClient.deleteWebhook.mockResolvedValue({ id: 1, name: "Deploy Hook" }); - const { deleteWebhook } = await import("./delete"); - await deleteWebhook.run?.({ args: { webhook: "1", project: "TEST" } } as never); + const { default: deleteWebhook } = await import("./delete"); + await deleteWebhook.parseAsync(["1", "-p", "TEST"], { from: "user" }); expect(confirmOrExit).toHaveBeenCalledWith( "Are you sure you want to delete webhook 1? This cannot be undone.", @@ -38,8 +39,8 @@ describe("webhook delete", () => { vi.mocked(confirmOrExit).mockResolvedValue(true); mockClient.deleteWebhook.mockResolvedValue({ id: 1, name: "Deploy Hook" }); - const { deleteWebhook } = await import("./delete"); - await deleteWebhook.run?.({ args: { webhook: "1", project: "TEST", yes: true } } as never); + const { default: deleteWebhook } = await import("./delete"); + await deleteWebhook.parseAsync(["1", "-p", "TEST", "--yes"], { from: "user" }); expect(confirmOrExit).toHaveBeenCalledWith( "Are you sure you want to delete webhook 1? This cannot be undone.", @@ -50,8 +51,8 @@ describe("webhook delete", () => { it("cancels when user declines confirmation", async () => { vi.mocked(confirmOrExit).mockResolvedValue(false); - const { deleteWebhook } = await import("./delete"); - await deleteWebhook.run?.({ args: { webhook: "1", project: "TEST" } } as never); + const { default: deleteWebhook } = await import("./delete"); + await deleteWebhook.parseAsync(["1", "-p", "TEST"], { from: "user" }); expect(mockClient.deleteWebhook).not.toHaveBeenCalled(); }); @@ -61,10 +62,8 @@ describe("webhook delete", () => { mockClient.deleteWebhook.mockResolvedValue({ id: 1, name: "Deploy Hook" }); await expectStdoutContaining(async () => { - const { deleteWebhook } = await import("./delete"); - await deleteWebhook.run?.({ - args: { webhook: "1", project: "TEST", yes: true, json: "" }, - } as never); + const { default: deleteWebhook } = await import("./delete"); + await deleteWebhook.parseAsync(["1", "-p", "TEST", "--yes", "--json"], { from: "user" }); }, "Deploy Hook"); }); }); diff --git a/apps/cli/src/commands/webhook/delete.ts b/apps/cli/src/commands/webhook/delete.ts index cb8a8d1e..4926b170 100644 --- a/apps/cli/src/commands/webhook/delete.ts +++ b/apps/cli/src/commands/webhook/delete.ts @@ -1,17 +1,24 @@ import { getClient } from "@repo/backlog-utils"; -import { confirmOrExit, outputArgs, outputResult } from "@repo/cli-utils"; -import { defineCommand } from "citty"; +import { confirmOrExit, outputResult } from "@repo/cli-utils"; import consola from "consola"; -import { type CommandUsage, ENV_AUTH, ENV_PROJECT, withUsage } from "../../lib/command-usage"; -import * as commonArgs from "../../lib/common-args"; +import { BeeCommand, ENV_AUTH, ENV_PROJECT } from "../../lib/bee-command"; +import * as opt from "../../lib/common-options"; +import { resolveOptions } from "../../lib/required-option"; -const commandUsage: CommandUsage = { - long: `Delete a webhook from a Backlog project. +const deleteWebhook = new BeeCommand("delete") + .summary("Delete a webhook") + .description( + `Delete a webhook from a Backlog project. This action is irreversible. You will be prompted for confirmation unless \`--yes\` is provided.`, - - examples: [ + ) + .argument("<webhook>", "Webhook ID") + .addOption(opt.project()) + .option("-y, --yes", "Skip confirmation prompt") + .addOption(opt.json()) + .envVars([...ENV_AUTH, ENV_PROJECT]) + .examples([ { description: "Delete a webhook (with confirmation)", command: "bee webhook delete 12345 -p PROJECT", @@ -20,54 +27,27 @@ This action is irreversible. You will be prompted for confirmation unless description: "Delete without confirmation", command: "bee webhook delete 12345 -p PROJECT --yes", }, - ], + ]) + .action(async (webhook, _opts, cmd) => { + const opts = await resolveOptions(cmd); - annotations: { - environment: [...ENV_AUTH, ENV_PROJECT], - }, -}; + const confirmed = await confirmOrExit( + `Are you sure you want to delete webhook ${webhook}? This cannot be undone.`, + opts.yes as boolean | undefined, + ); -const deleteWebhook = withUsage( - defineCommand({ - meta: { - name: "delete", - description: "Delete a webhook", - }, - args: { - ...outputArgs, - webhook: { - type: "positional", - description: "Webhook ID", - required: true, - valueHint: "<number>", - }, - project: { ...commonArgs.project, required: true }, - yes: { - type: "boolean", - alias: "y", - description: "Skip confirmation prompt", - }, - }, - async run({ args }) { - const confirmed = await confirmOrExit( - `Are you sure you want to delete webhook ${args.webhook}? This cannot be undone.`, - args.yes, - ); + if (!confirmed) { + return; + } - if (!confirmed) { - return; - } + const { client } = await getClient(); - const { client } = await getClient(); + const webhookData = await client.deleteWebhook(opts.project as string, webhook); - const webhook = await client.deleteWebhook(args.project, args.webhook); - - outputResult(webhook, args, (data) => { - consola.success(`Deleted webhook ${data.name} (ID: ${data.id})`); - }); - }, - }), - commandUsage, -); + const json = opts.json === true ? "" : (opts.json as string | undefined); + outputResult(webhookData, { json }, (data) => { + consola.success(`Deleted webhook ${data.name} (ID: ${data.id})`); + }); + }); -export { commandUsage, deleteWebhook }; +export default deleteWebhook; diff --git a/apps/cli/src/commands/webhook/edit.test.ts b/apps/cli/src/commands/webhook/edit.test.ts index e9e73611..5c6759d0 100644 --- a/apps/cli/src/commands/webhook/edit.test.ts +++ b/apps/cli/src/commands/webhook/edit.test.ts @@ -10,16 +10,19 @@ vi.mock("@repo/backlog-utils", () => ({ getClient: vi.fn(() => Promise.resolve({ client: mockClient, host: "example.backlog.com" })), })); +vi.mock("@repo/cli-utils", async (importOriginal) => ({ + ...(await importOriginal()), + promptRequired: vi.fn((_, val) => Promise.resolve(val)), +})); + vi.mock("consola", () => import("@repo/test-utils/mock-consola")); describe("webhook edit", () => { it("updates webhook name", async () => { mockClient.patchWebhook.mockResolvedValue({ id: 1, name: "New Name" }); - const { edit } = await import("./edit"); - await edit.run?.({ - args: { webhook: "1", project: "TEST", name: "New Name" }, - } as never); + const { default: edit } = await import("./edit"); + await edit.parseAsync(["1", "-p", "TEST", "--name", "New Name"], { from: "user" }); expect(mockClient.patchWebhook).toHaveBeenCalledWith("TEST", "1", { name: "New Name", @@ -33,10 +36,10 @@ describe("webhook edit", () => { it("updates webhook hook URL", async () => { mockClient.patchWebhook.mockResolvedValue({ id: 1, name: "Hook" }); - const { edit } = await import("./edit"); - await edit.run?.({ - args: { webhook: "1", project: "TEST", "hook-url": "https://example.com/new" }, - } as never); + const { default: edit } = await import("./edit"); + await edit.parseAsync(["1", "-p", "TEST", "--hook-url", "https://example.com/new"], { + from: "user", + }); expect(mockClient.patchWebhook).toHaveBeenCalledWith("TEST", "1", { name: undefined, @@ -46,13 +49,24 @@ describe("webhook edit", () => { }); }); - it("updates activity type IDs from comma-separated string", async () => { + it("updates activity type IDs from repeatable option", async () => { mockClient.patchWebhook.mockResolvedValue({ id: 1, name: "Hook" }); - const { edit } = await import("./edit"); - await edit.run?.({ - args: { webhook: "1", project: "TEST", "activity-type-ids": "1,2,3" }, - } as never); + const { default: edit } = await import("./edit"); + await edit.parseAsync( + [ + "1", + "-p", + "TEST", + "--activity-type-ids", + "1", + "--activity-type-ids", + "2", + "--activity-type-ids", + "3", + ], + { from: "user" }, + ); expect(mockClient.patchWebhook).toHaveBeenCalledWith("TEST", "1", { name: undefined, @@ -66,10 +80,8 @@ describe("webhook edit", () => { mockClient.patchWebhook.mockResolvedValue({ id: 1, name: "Hook" }); await expectStdoutContaining(async () => { - const { edit } = await import("./edit"); - await edit.run?.({ - args: { webhook: "1", project: "TEST", name: "Hook", json: "" }, - } as never); + const { default: edit } = await import("./edit"); + await edit.parseAsync(["1", "-p", "TEST", "--name", "Hook", "--json"], { from: "user" }); }, "Hook"); }); }); diff --git a/apps/cli/src/commands/webhook/edit.ts b/apps/cli/src/commands/webhook/edit.ts index f842b123..ff3227bc 100644 --- a/apps/cli/src/commands/webhook/edit.ts +++ b/apps/cli/src/commands/webhook/edit.ts @@ -1,17 +1,32 @@ import { getClient } from "@repo/backlog-utils"; -import { outputArgs, outputResult, splitArg } from "@repo/cli-utils"; -import { defineCommand } from "citty"; +import { outputResult } from "@repo/cli-utils"; import consola from "consola"; -import * as v from "valibot"; -import { type CommandUsage, ENV_AUTH, ENV_PROJECT, withUsage } from "../../lib/command-usage"; -import * as commonArgs from "../../lib/common-args"; +import { BeeCommand, ENV_AUTH, ENV_PROJECT } from "../../lib/bee-command"; +import * as opt from "../../lib/common-options"; +import { collectNum } from "../../lib/common-options"; +import { resolveOptions } from "../../lib/required-option"; -const commandUsage: CommandUsage = { - long: `Update an existing webhook in a Backlog project. +const edit = new BeeCommand("edit") + .summary("Edit a webhook") + .description( + `Update an existing webhook in a Backlog project. All fields are optional. Only the specified fields will be updated.`, - - examples: [ + ) + .argument("<webhook>", "Webhook ID") + .addOption(opt.project()) + .option("-n, --name <name>", "New name of the webhook") + .option("--hook-url <url>", "New URL to receive webhook notifications") + .option("--all-event", "Change whether to subscribe to all event types") + .option( + "--activity-type-ids <id>", + "New activity type IDs to subscribe to (repeatable)", + collectNum, + [] as number[], + ) + .addOption(opt.json()) + .envVars([...ENV_AUTH, ENV_PROJECT]) + .examples([ { description: "Rename a webhook", command: 'bee webhook edit 12345 -p PROJECT -n "New Name"', @@ -24,65 +39,24 @@ All fields are optional. Only the specified fields will be updated.`, description: "Subscribe to all events", command: "bee webhook edit 12345 -p PROJECT --all-event", }, - ], - - annotations: { - environment: [...ENV_AUTH, ENV_PROJECT], - }, -}; - -const edit = withUsage( - defineCommand({ - meta: { - name: "edit", - description: "Edit a webhook", - }, - args: { - ...outputArgs, - webhook: { - type: "positional", - description: "Webhook ID", - required: true, - valueHint: "<number>", - }, - project: { ...commonArgs.project, required: true }, - name: { - type: "string", - alias: "n", - description: "New name of the webhook", - }, - "hook-url": { - type: "string", - description: "New URL to receive webhook notifications", - }, - "all-event": { - type: "boolean", - description: "Change whether to subscribe to all event types", - }, - "activity-type-ids": { - type: "string", - description: "New activity type IDs to subscribe to", - valueHint: "<1,2,3,...>", - }, - }, - async run({ args }) { - const { client } = await getClient(); - - const activityTypeIds = splitArg(args["activity-type-ids"], v.number()); - - const webhook = await client.patchWebhook(args.project, args.webhook, { - name: args.name, - hookUrl: args["hook-url"], - allEvent: args["all-event"], - activityTypeIds, - }); - - outputResult(webhook, args, (data) => { - consola.success(`Updated webhook ${data.name} (ID: ${data.id})`); - }); - }, - }), - commandUsage, -); - -export { commandUsage, edit }; + ]) + .action(async (webhook, _opts, cmd) => { + const opts = await resolveOptions(cmd); + const { client } = await getClient(); + + const activityTypeIds: number[] = (opts.activityTypeIds as number[]) ?? []; + + const webhookData = await client.patchWebhook(opts.project as string, webhook, { + name: opts.name as string | undefined, + hookUrl: opts.hookUrl as string | undefined, + allEvent: opts.allEvent as boolean | undefined, + activityTypeIds, + }); + + const json = opts.json === true ? "" : (opts.json as string | undefined); + outputResult(webhookData, { json }, (data) => { + consola.success(`Updated webhook ${data.name} (ID: ${data.id})`); + }); + }); + +export default edit; diff --git a/apps/cli/src/commands/webhook/list.test.ts b/apps/cli/src/commands/webhook/list.test.ts index e3a1aa26..c7fad3e3 100644 --- a/apps/cli/src/commands/webhook/list.test.ts +++ b/apps/cli/src/commands/webhook/list.test.ts @@ -13,6 +13,11 @@ vi.mock("@repo/backlog-utils", () => ({ vi.mock("consola", () => import("@repo/test-utils/mock-consola")); +vi.mock("@repo/cli-utils", async (importOriginal) => ({ + ...(await importOriginal()), + promptRequired: vi.fn((_, val) => Promise.resolve(val)), +})); + const sampleWebhooks = [ { id: 1, name: "Deploy Hook", hookUrl: "https://example.com/deploy", allEvent: true }, { id: 2, name: "CI Hook", hookUrl: "https://example.com/ci", allEvent: false }, @@ -22,8 +27,8 @@ describe("webhook list", () => { it("displays webhook list in tabular format", async () => { mockClient.getWebhooks.mockResolvedValue(sampleWebhooks); - const { list } = await import("./list"); - await list.run?.({ args: { project: "TEST" } } as never); + const { default: list } = await import("./list"); + await list.parseAsync(["-p", "TEST"], { from: "user" }); expect(getClient).toHaveBeenCalled(); expect(mockClient.getWebhooks).toHaveBeenCalledWith("TEST"); @@ -35,8 +40,8 @@ describe("webhook list", () => { it("shows message when no webhooks found", async () => { mockClient.getWebhooks.mockResolvedValue([]); - const { list } = await import("./list"); - await list.run?.({ args: { project: "TEST" } } as never); + const { default: list } = await import("./list"); + await list.parseAsync(["-p", "TEST"], { from: "user" }); expect(consola.info).toHaveBeenCalledWith("No webhooks found."); }); @@ -45,8 +50,8 @@ describe("webhook list", () => { mockClient.getWebhooks.mockResolvedValue(sampleWebhooks); await expectStdoutContaining(async () => { - const { list } = await import("./list"); - await list.run?.({ args: { project: "TEST", json: "" } } as never); + const { default: list } = await import("./list"); + await list.parseAsync(["-p", "TEST", "--json"], { from: "user" }); }, "Deploy Hook"); }); }); diff --git a/apps/cli/src/commands/webhook/list.ts b/apps/cli/src/commands/webhook/list.ts index 79ae73a5..61ecbde1 100644 --- a/apps/cli/src/commands/webhook/list.ts +++ b/apps/cli/src/commands/webhook/list.ts @@ -1,61 +1,49 @@ import { getClient } from "@repo/backlog-utils"; -import { type Row, outputArgs, outputResult, printTable } from "@repo/cli-utils"; -import { defineCommand } from "citty"; +import { type Row, outputResult, printTable } from "@repo/cli-utils"; import consola from "consola"; -import { type CommandUsage, ENV_AUTH, ENV_PROJECT, withUsage } from "../../lib/command-usage"; -import * as commonArgs from "../../lib/common-args"; +import { BeeCommand, ENV_AUTH, ENV_PROJECT } from "../../lib/bee-command"; +import * as opt from "../../lib/common-options"; +import { resolveOptions } from "../../lib/required-option"; -const commandUsage: CommandUsage = { - long: `List webhooks in a Backlog project. +const list = new BeeCommand("list") + .summary("List webhooks") + .description( + `List webhooks in a Backlog project. Webhooks allow external services to receive notifications when events occur in a project.`, - - examples: [ + ) + .addOption(opt.project()) + .addOption(opt.json()) + .envVars([...ENV_AUTH, ENV_PROJECT]) + .examples([ { description: "List all webhooks in a project", command: "bee webhook list -p PROJECT" }, { description: "Output as JSON", command: "bee webhook list -p PROJECT --json" }, - ], - - annotations: { - environment: [...ENV_AUTH, ENV_PROJECT], - }, -}; - -const list = withUsage( - defineCommand({ - meta: { - name: "list", - description: "List webhooks", - }, - args: { - ...outputArgs, - project: { ...commonArgs.project, required: true }, - }, - async run({ args }) { - const { client } = await getClient(); - - const webhooks = await client.getWebhooks(args.project); - - outputResult(webhooks, args, (data) => { - if (data.length === 0) { - consola.info("No webhooks found."); - return; - } - - const rows: Row[] = data.map( - (w: { id: number; name: string; hookUrl: string; allEvent: boolean }) => [ - { header: "ID", value: String(w.id) }, - { header: "NAME", value: w.name }, - { header: "HOOK URL", value: w.hookUrl }, - { header: "ALL EVENT", value: w.allEvent ? "Yes" : "No" }, - ], - ); - - printTable(rows); - }); - }, - }), - commandUsage, -); - -export { commandUsage, list }; + ]) + .action(async (_opts, cmd) => { + const opts = await resolveOptions(cmd); + const { client } = await getClient(); + + const webhooks = await client.getWebhooks(opts.project as string); + + const json = opts.json === true ? "" : (opts.json as string | undefined); + outputResult(webhooks, { json }, (data) => { + if (data.length === 0) { + consola.info("No webhooks found."); + return; + } + + const rows: Row[] = data.map( + (w: { id: number; name: string; hookUrl: string; allEvent: boolean }) => [ + { header: "ID", value: String(w.id) }, + { header: "NAME", value: w.name }, + { header: "HOOK URL", value: w.hookUrl }, + { header: "ALL EVENT", value: w.allEvent ? "Yes" : "No" }, + ], + ); + + printTable(rows); + }); + }); + +export default list; diff --git a/apps/cli/src/commands/webhook/view.test.ts b/apps/cli/src/commands/webhook/view.test.ts index 04d36312..9ffa2ada 100644 --- a/apps/cli/src/commands/webhook/view.test.ts +++ b/apps/cli/src/commands/webhook/view.test.ts @@ -13,6 +13,11 @@ vi.mock("@repo/backlog-utils", () => ({ vi.mock("consola", () => import("@repo/test-utils/mock-consola")); +vi.mock("@repo/cli-utils", async (importOriginal) => ({ + ...(await importOriginal()), + promptRequired: vi.fn((_, val) => Promise.resolve(val)), +})); + const sampleWebhook = { id: 1, name: "Deploy Hook", @@ -26,8 +31,8 @@ describe("webhook view", () => { it("displays webhook details", async () => { mockClient.getWebhook.mockResolvedValue(sampleWebhook); - const { view } = await import("./view"); - await view.run?.({ args: { webhook: "1", project: "TEST" } } as never); + const { default: view } = await import("./view"); + await view.parseAsync(["1", "-p", "TEST"], { from: "user" }); expect(getClient).toHaveBeenCalled(); expect(mockClient.getWebhook).toHaveBeenCalledWith("TEST", "1"); @@ -41,8 +46,8 @@ describe("webhook view", () => { mockClient.getWebhook.mockResolvedValue(sampleWebhook); await expectStdoutContaining(async () => { - const { view } = await import("./view"); - await view.run?.({ args: { webhook: "1", project: "TEST", json: "" } } as never); + const { default: view } = await import("./view"); + await view.parseAsync(["1", "-p", "TEST", "--json"], { from: "user" }); }, "Deploy Hook"); }); }); diff --git a/apps/cli/src/commands/webhook/view.ts b/apps/cli/src/commands/webhook/view.ts index bfa90605..142f33e6 100644 --- a/apps/cli/src/commands/webhook/view.ts +++ b/apps/cli/src/commands/webhook/view.ts @@ -1,65 +1,48 @@ import { getClient } from "@repo/backlog-utils"; -import { outputArgs, outputResult, printDefinitionList } from "@repo/cli-utils"; -import { defineCommand } from "citty"; +import { outputResult, printDefinitionList } from "@repo/cli-utils"; import consola from "consola"; -import { type CommandUsage, ENV_AUTH, ENV_PROJECT, withUsage } from "../../lib/command-usage"; -import * as commonArgs from "../../lib/common-args"; +import { BeeCommand, ENV_AUTH, ENV_PROJECT } from "../../lib/bee-command"; +import * as opt from "../../lib/common-options"; +import { resolveOptions } from "../../lib/required-option"; -const commandUsage: CommandUsage = { - long: `Display details of a Backlog webhook. +const view = new BeeCommand("view") + .summary("View a webhook") + .description( + `Display details of a Backlog webhook. Shows the webhook name, ID, hook URL, description, and activity type IDs.`, - - examples: [ + ) + .argument("<webhook>", "Webhook ID") + .addOption(opt.project()) + .addOption(opt.json()) + .envVars([...ENV_AUTH, ENV_PROJECT]) + .examples([ { description: "View a webhook", command: "bee webhook view 12345 -p PROJECT" }, { description: "Output as JSON", command: "bee webhook view 12345 -p PROJECT --json" }, - ], - - annotations: { - environment: [...ENV_AUTH, ENV_PROJECT], - }, -}; - -const view = withUsage( - defineCommand({ - meta: { - name: "view", - description: "View a webhook", - }, - args: { - ...outputArgs, - webhook: { - type: "positional", - description: "Webhook ID", - required: true, - valueHint: "<number>", - }, - project: { ...commonArgs.project, required: true }, - }, - async run({ args }) { - const { client } = await getClient(); - - const webhook = await client.getWebhook(args.project, args.webhook); - - outputResult(webhook, args, (data) => { - consola.log(""); - consola.log(` ${data.name}`); - consola.log(""); - printDefinitionList([ - ["ID", String(data.id)], - ["Hook URL", data.hookUrl], - ["Description", data.description || undefined], - ["All Event", data.allEvent ? "Yes" : "No"], - [ - "Activity Type IDs", - data.activityTypeIds?.length > 0 ? data.activityTypeIds.join(", ") : undefined, - ], - ]); - consola.log(""); - }); - }, - }), - commandUsage, -); - -export { commandUsage, view }; + ]) + .action(async (webhook, _opts, cmd) => { + const opts = await resolveOptions(cmd); + const { client } = await getClient(); + + const webhookData = await client.getWebhook(opts.project as string, webhook); + + const json = opts.json === true ? "" : (opts.json as string | undefined); + outputResult(webhookData, { json }, (data) => { + consola.log(""); + consola.log(` ${data.name}`); + consola.log(""); + printDefinitionList([ + ["ID", String(data.id)], + ["Hook URL", data.hookUrl], + ["Description", data.description || undefined], + ["All Event", data.allEvent ? "Yes" : "No"], + [ + "Activity Type IDs", + data.activityTypeIds?.length > 0 ? data.activityTypeIds.join(", ") : undefined, + ], + ]); + consola.log(""); + }); + }); + +export default view; diff --git a/apps/cli/src/commands/wiki/attachments.test.ts b/apps/cli/src/commands/wiki/attachments.test.ts index d85580d0..31e88e82 100644 --- a/apps/cli/src/commands/wiki/attachments.test.ts +++ b/apps/cli/src/commands/wiki/attachments.test.ts @@ -30,8 +30,8 @@ describe("wiki attachments", () => { }, ]); - const { attachments } = await import("./attachments"); - await attachments.run?.({ args: { wiki: "123" } } as never); + const { default: attachments } = await import("./attachments"); + await attachments.parseAsync(["123"], { from: "user" }); expect(getClient).toHaveBeenCalled(); expect(mockClient.getWikisAttachments).toHaveBeenCalledWith(123); @@ -43,8 +43,8 @@ describe("wiki attachments", () => { it("shows message when no attachments found", async () => { mockClient.getWikisAttachments.mockResolvedValue([]); - const { attachments } = await import("./attachments"); - await attachments.run?.({ args: { wiki: "123" } } as never); + const { default: attachments } = await import("./attachments"); + await attachments.parseAsync(["123"], { from: "user" }); expect(consola.info).toHaveBeenCalledWith("No attachments found."); }); @@ -60,8 +60,8 @@ describe("wiki attachments", () => { ]); await expectStdoutContaining(async () => { - const { attachments } = await import("./attachments"); - await attachments.run?.({ args: { wiki: "123", json: "" } } as never); + const { default: attachments } = await import("./attachments"); + await attachments.parseAsync(["123", "--json"], { from: "user" }); }, "doc.pdf"); }); }); diff --git a/apps/cli/src/commands/wiki/attachments.ts b/apps/cli/src/commands/wiki/attachments.ts index 3275eaa8..f759bd47 100644 --- a/apps/cli/src/commands/wiki/attachments.ts +++ b/apps/cli/src/commands/wiki/attachments.ts @@ -1,64 +1,45 @@ import { getClient } from "@repo/backlog-utils"; -import { type Row, formatDate, outputArgs, outputResult, printTable } from "@repo/cli-utils"; -import { defineCommand } from "citty"; +import { type Row, formatDate, outputResult, printTable } from "@repo/cli-utils"; import consola from "consola"; -import { type CommandUsage, ENV_AUTH, withUsage } from "../../lib/command-usage"; +import { BeeCommand, ENV_AUTH } from "../../lib/bee-command"; +import * as opt from "../../lib/common-options"; -const commandUsage: CommandUsage = { - long: `List files attached to a Backlog wiki page. +const attachments = new BeeCommand("attachments") + .summary("List wiki page attachments") + .description( + `List files attached to a Backlog wiki page. Shows file name, size, creator, and creation date.`, - - examples: [ + ) + .argument("<wiki>", "Wiki page ID") + .addOption(opt.json()) + .envVars([...ENV_AUTH]) + .examples([ { description: "List wiki attachments", command: "bee wiki attachments 12345" }, { description: "Output as JSON", command: "bee wiki attachments 12345 --json" }, - ], - - annotations: { - environment: [...ENV_AUTH], - }, -}; - -const attachments = withUsage( - defineCommand({ - meta: { - name: "attachments", - description: "List wiki page attachments", - }, - args: { - ...outputArgs, - wiki: { - type: "positional", - description: "Wiki page ID", - valueHint: "<number>", - required: true, - }, - }, - async run({ args }) { - const { client } = await getClient(); - - const files = await client.getWikisAttachments(Number(args.wiki)); - - outputResult(files, args, (data) => { - if (data.length === 0) { - consola.info("No attachments found."); - return; - } - - const rows: Row[] = data.map( - (f: { name: string; size: number; createdUser: { name: string }; created: string }) => [ - { header: "NAME", value: f.name }, - { header: "SIZE", value: String(f.size) }, - { header: "CREATED BY", value: f.createdUser.name }, - { header: "CREATED", value: formatDate(f.created) }, - ], - ); - - printTable(rows); - }); - }, - }), - commandUsage, -); - -export { commandUsage, attachments }; + ]) + .action(async (wiki, opts) => { + const { client } = await getClient(); + + const files = await client.getWikisAttachments(Number(wiki)); + + outputResult(files, opts, (data) => { + if (data.length === 0) { + consola.info("No attachments found."); + return; + } + + const rows: Row[] = data.map( + (f: { name: string; size: number; createdUser: { name: string }; created: string }) => [ + { header: "NAME", value: f.name }, + { header: "SIZE", value: String(f.size) }, + { header: "CREATED BY", value: f.createdUser.name }, + { header: "CREATED", value: formatDate(f.created) }, + ], + ); + + printTable(rows); + }); + }); + +export default attachments; diff --git a/apps/cli/src/commands/wiki/count.test.ts b/apps/cli/src/commands/wiki/count.test.ts index 3afbd6fa..dbb11f84 100644 --- a/apps/cli/src/commands/wiki/count.test.ts +++ b/apps/cli/src/commands/wiki/count.test.ts @@ -13,12 +13,17 @@ vi.mock("@repo/backlog-utils", () => ({ vi.mock("consola", () => import("@repo/test-utils/mock-consola")); +vi.mock("@repo/cli-utils", async (importOriginal) => ({ + ...(await importOriginal()), + promptRequired: vi.fn((_, val) => Promise.resolve(val)), +})); + describe("wiki count", () => { it("displays wiki page count", async () => { mockClient.getWikisCount.mockResolvedValue({ count: 42 }); - const { count } = await import("./count"); - await count.run?.({ args: { project: "TEST" } } as never); + const { default: count } = await import("./count"); + await count.parseAsync(["-p", "TEST"], { from: "user" }); expect(getClient).toHaveBeenCalled(); expect(mockClient.getWikisCount).toHaveBeenCalledWith("TEST"); @@ -29,8 +34,8 @@ describe("wiki count", () => { mockClient.getWikisCount.mockResolvedValue({ count: 42 }); await expectStdoutContaining(async () => { - const { count } = await import("./count"); - await count.run?.({ args: { project: "TEST", json: "" } } as never); + const { default: count } = await import("./count"); + await count.parseAsync(["-p", "TEST", "--json"], { from: "user" }); }, "42"); }); }); diff --git a/apps/cli/src/commands/wiki/count.ts b/apps/cli/src/commands/wiki/count.ts index ac95d8e7..27c2c28b 100644 --- a/apps/cli/src/commands/wiki/count.ts +++ b/apps/cli/src/commands/wiki/count.ts @@ -1,46 +1,34 @@ import { getClient } from "@repo/backlog-utils"; -import { outputArgs, outputResult } from "@repo/cli-utils"; -import { defineCommand } from "citty"; +import { outputResult } from "@repo/cli-utils"; import consola from "consola"; -import { type CommandUsage, ENV_AUTH, ENV_PROJECT, withUsage } from "../../lib/command-usage"; -import * as commonArgs from "../../lib/common-args"; +import { BeeCommand, ENV_AUTH, ENV_PROJECT } from "../../lib/bee-command"; +import * as opt from "../../lib/common-options"; +import { resolveOptions } from "../../lib/required-option"; -const commandUsage: CommandUsage = { - long: `Display the number of wiki pages in a Backlog project. +const count = new BeeCommand("count") + .summary("Count wiki pages") + .description( + `Display the number of wiki pages in a Backlog project. The count includes all wiki pages regardless of tag or keyword.`, - - examples: [ + ) + .addOption(opt.project()) + .addOption(opt.json()) + .envVars([...ENV_AUTH, ENV_PROJECT]) + .examples([ { description: "Count wiki pages", command: "bee wiki count -p PROJECT" }, { description: "Output as JSON", command: "bee wiki count -p PROJECT --json" }, - ], - - annotations: { - environment: [...ENV_AUTH, ENV_PROJECT], - }, -}; - -const count = withUsage( - defineCommand({ - meta: { - name: "count", - description: "Count wiki pages", - }, - args: { - ...outputArgs, - project: commonArgs.projectPositional, - }, - async run({ args }) { - const { client } = await getClient(); + ]) + .action(async (_opts, cmd) => { + const opts = await resolveOptions(cmd); + const { client } = await getClient(); - const result = await client.getWikisCount(args.project); + const result = await client.getWikisCount(opts.project as string); - outputResult(result, args, (data) => { - consola.log(String(data.count)); - }); - }, - }), - commandUsage, -); + const json = opts.json === true ? "" : (opts.json as string | undefined); + outputResult(result, { json }, (data) => { + consola.log(String(data.count)); + }); + }); -export { commandUsage, count }; +export default count; diff --git a/apps/cli/src/commands/wiki/create.test.ts b/apps/cli/src/commands/wiki/create.test.ts index f0994c5e..ac0e4f7d 100644 --- a/apps/cli/src/commands/wiki/create.test.ts +++ b/apps/cli/src/commands/wiki/create.test.ts @@ -27,8 +27,8 @@ describe("wiki create", () => { mockClient.getProjects.mockResolvedValue([{ id: 100, projectKey: "TEST" }]); mockClient.postWiki.mockResolvedValue({ id: 1, name: "My Page" }); - const { create } = await import("./create"); - await create.run?.({ args: { project: "TEST", name: "My Page", body: "Hello" } } as never); + const { default: create } = await import("./create"); + await create.parseAsync(["-p", "TEST", "-n", "My Page", "-b", "Hello"], { from: "user" }); expect(mockClient.postWiki).toHaveBeenCalledWith({ projectId: 100, @@ -45,8 +45,8 @@ describe("wiki create", () => { mockClient.getProjects.mockResolvedValue([{ id: 100, projectKey: "TEST" }]); mockClient.postWiki.mockResolvedValue({ id: 1, name: "My Page" }); - const { create } = await import("./create"); - await create.run?.({ args: { project: "TEST", name: "My Page", body: "" } } as never); + const { default: create } = await import("./create"); + await create.parseAsync(["-p", "TEST", "-n", "My Page", "-b", ""], { from: "user" }); expect(resolveStdinArg).toHaveBeenCalledWith(""); expect(mockClient.postWiki).toHaveBeenCalledWith( @@ -61,8 +61,8 @@ describe("wiki create", () => { mockClient.getProjects.mockResolvedValue([{ id: 200, projectKey: "PROMPTED" }]); mockClient.postWiki.mockResolvedValue({ id: 2, name: "Prompted Page" }); - const { create } = await import("./create"); - await create.run?.({ args: {} } as never); + const { default: create } = await import("./create"); + await create.parseAsync([], { from: "user" }); expect(promptRequired).toHaveBeenCalledWith("Project:", undefined); expect(promptRequired).toHaveBeenCalledWith("Page name:", undefined); @@ -73,10 +73,10 @@ describe("wiki create", () => { mockClient.getProjects.mockResolvedValue([{ id: 100, projectKey: "TEST" }]); mockClient.postWiki.mockResolvedValue({ id: 1, name: "My Page" }); - const { create } = await import("./create"); - await create.run?.({ - args: { project: "TEST", name: "My Page", body: "Hello", "mail-notify": true }, - } as never); + const { default: create } = await import("./create"); + await create.parseAsync(["-p", "TEST", "-n", "My Page", "-b", "Hello", "--mail-notify"], { + from: "user", + }); expect(mockClient.postWiki).toHaveBeenCalledWith(expect.objectContaining({ mailNotify: true })); }); @@ -87,10 +87,10 @@ describe("wiki create", () => { mockClient.postWiki.mockResolvedValue({ id: 1, name: "My Page" }); await expectStdoutContaining(async () => { - const { create } = await import("./create"); - await create.run?.({ - args: { project: "TEST", name: "My Page", body: "Hello", json: "" }, - } as never); + const { default: create } = await import("./create"); + await create.parseAsync(["-p", "TEST", "-n", "My Page", "-b", "Hello", "--json"], { + from: "user", + }); }, "My Page"); }); }); diff --git a/apps/cli/src/commands/wiki/create.ts b/apps/cli/src/commands/wiki/create.ts index 2fe99fc7..5a77f251 100644 --- a/apps/cli/src/commands/wiki/create.ts +++ b/apps/cli/src/commands/wiki/create.ts @@ -1,17 +1,24 @@ import { getClient, resolveProjectIds } from "@repo/backlog-utils"; -import { outputArgs, outputResult, promptRequired, resolveStdinArg } from "@repo/cli-utils"; -import { defineCommand } from "citty"; +import { outputResult, promptRequired, resolveStdinArg } from "@repo/cli-utils"; import consola from "consola"; -import { type CommandUsage, ENV_AUTH, ENV_PROJECT, withUsage } from "../../lib/command-usage"; -import * as commonArgs from "../../lib/common-args"; +import { BeeCommand, ENV_AUTH, ENV_PROJECT } from "../../lib/bee-command"; +import * as opt from "../../lib/common-options"; -const commandUsage: CommandUsage = { - long: `Create a new Backlog wiki page. +const create = new BeeCommand("create") + .summary("Create a wiki page") + .description( + `Create a new Backlog wiki page. Requires a project, page name, and body content. When input is piped, it is used as the body automatically.`, - - examples: [ + ) + .option("-p, --project <id>", "Project ID or project key") + .option("-n, --name <name>", "Wiki page name") + .option("-b, --body <text>", "Wiki page content") + .option("--mail-notify", "Send notification email") + .addOption(opt.json()) + .envVars([...ENV_AUTH, ENV_PROJECT]) + .examples([ { description: "Create a wiki page", command: 'bee wiki create -p PROJECT -n "Page Name" -b "Content"', @@ -24,59 +31,26 @@ it is used as the body automatically.`, description: "Create and send notification email", command: 'bee wiki create -p PROJECT -n "Name" -b "Content" --mail-notify', }, - ], + ]) + .action(async (opts) => { + const { client } = await getClient(); - annotations: { - environment: [...ENV_AUTH, ENV_PROJECT], - }, -}; + const project = await promptRequired("Project:", opts.project); + const name = await promptRequired("Page name:", opts.name); + const body = (await resolveStdinArg(opts.body)) ?? ""; -const create = withUsage( - defineCommand({ - meta: { - name: "create", - description: "Create a wiki page", - }, - args: { - ...outputArgs, - project: commonArgs.project, - name: { - type: "string", - alias: "n", - description: "Wiki page name", - }, - body: { - type: "string", - alias: "b", - description: "Wiki page content", - }, - "mail-notify": { - type: "boolean", - description: "Send notification email", - }, - }, - async run({ args }) { - const { client } = await getClient(); + const [projectId] = await resolveProjectIds(client, [project]); - const project = await promptRequired("Project:", args.project); - const name = await promptRequired("Page name:", args.name); - const body = (await resolveStdinArg(args.body)) ?? ""; + const wiki = await client.postWiki({ + projectId, + name, + content: body, + mailNotify: opts.mailNotify, + }); - const [projectId] = await resolveProjectIds(client, [project]); - - const wiki = await client.postWiki({ - projectId, - name, - content: body, - mailNotify: args["mail-notify"], - }); - - outputResult(wiki, args, (data) => { - consola.success(`Created wiki page ${data.id}: ${data.name}`); - }); - }, - }), - commandUsage, -); + outputResult(wiki, opts, (data) => { + consola.success(`Created wiki page ${data.id}: ${data.name}`); + }); + }); -export { commandUsage, create }; +export default create; diff --git a/apps/cli/src/commands/wiki/delete.test.ts b/apps/cli/src/commands/wiki/delete.test.ts index 1472d309..7c97a403 100644 --- a/apps/cli/src/commands/wiki/delete.test.ts +++ b/apps/cli/src/commands/wiki/delete.test.ts @@ -23,8 +23,8 @@ describe("wiki delete", () => { vi.mocked(confirmOrExit).mockResolvedValue(true); mockClient.deleteWiki.mockResolvedValue({ id: 123, name: "Old Page" }); - const { deleteWiki } = await import("./delete"); - await deleteWiki.run?.({ args: { wiki: "123" } } as never); + const { default: deleteWiki } = await import("./delete"); + await deleteWiki.parseAsync(["123"], { from: "user" }); expect(confirmOrExit).toHaveBeenCalledWith( "Are you sure you want to delete wiki page 123? This cannot be undone.", @@ -38,8 +38,8 @@ describe("wiki delete", () => { vi.mocked(confirmOrExit).mockResolvedValue(true); mockClient.deleteWiki.mockResolvedValue({ id: 123, name: "Old Page" }); - const { deleteWiki } = await import("./delete"); - await deleteWiki.run?.({ args: { wiki: "123", yes: true } } as never); + const { default: deleteWiki } = await import("./delete"); + await deleteWiki.parseAsync(["123", "--yes"], { from: "user" }); expect(confirmOrExit).toHaveBeenCalledWith( "Are you sure you want to delete wiki page 123? This cannot be undone.", @@ -50,8 +50,8 @@ describe("wiki delete", () => { it("cancels when user declines confirmation", async () => { vi.mocked(confirmOrExit).mockResolvedValue(false); - const { deleteWiki } = await import("./delete"); - await deleteWiki.run?.({ args: { wiki: "123" } } as never); + const { default: deleteWiki } = await import("./delete"); + await deleteWiki.parseAsync(["123"], { from: "user" }); expect(mockClient.deleteWiki).not.toHaveBeenCalled(); }); @@ -60,8 +60,8 @@ describe("wiki delete", () => { vi.mocked(confirmOrExit).mockResolvedValue(true); mockClient.deleteWiki.mockResolvedValue({ id: 123, name: "Old Page" }); - const { deleteWiki } = await import("./delete"); - await deleteWiki.run?.({ args: { wiki: "123", yes: true, "mail-notify": true } } as never); + const { default: deleteWiki } = await import("./delete"); + await deleteWiki.parseAsync(["123", "--yes", "--mail-notify"], { from: "user" }); expect(mockClient.deleteWiki).toHaveBeenCalledWith(123, true); }); @@ -71,8 +71,8 @@ describe("wiki delete", () => { mockClient.deleteWiki.mockResolvedValue({ id: 123, name: "Old Page" }); await expectStdoutContaining(async () => { - const { deleteWiki } = await import("./delete"); - await deleteWiki.run?.({ args: { wiki: "123", yes: true, json: "" } } as never); + const { default: deleteWiki } = await import("./delete"); + await deleteWiki.parseAsync(["123", "--yes", "--json"], { from: "user" }); }, "Old Page"); }); }); diff --git a/apps/cli/src/commands/wiki/delete.ts b/apps/cli/src/commands/wiki/delete.ts index 27a4a2e7..2e7989ee 100644 --- a/apps/cli/src/commands/wiki/delete.ts +++ b/apps/cli/src/commands/wiki/delete.ts @@ -1,16 +1,23 @@ import { getClient } from "@repo/backlog-utils"; -import { confirmOrExit, outputArgs, outputResult } from "@repo/cli-utils"; -import { defineCommand } from "citty"; +import { confirmOrExit, outputResult } from "@repo/cli-utils"; import consola from "consola"; -import { type CommandUsage, ENV_AUTH, withUsage } from "../../lib/command-usage"; +import { BeeCommand, ENV_AUTH } from "../../lib/bee-command"; +import * as opt from "../../lib/common-options"; -const commandUsage: CommandUsage = { - long: `Delete a Backlog wiki page. +const deleteWiki = new BeeCommand("delete") + .summary("Delete a wiki page") + .description( + `Delete a Backlog wiki page. This action is irreversible. You will be prompted for confirmation unless \`--yes\` is provided.`, - - examples: [ + ) + .argument("<wiki>", "Wiki page ID") + .option("-y, --yes", "Skip confirmation prompt") + .option("--mail-notify", "Send notification email") + .addOption(opt.json()) + .envVars([...ENV_AUTH]) + .examples([ { description: "Delete a wiki page (with confirmation)", command: "bee wiki delete 12345", @@ -19,57 +26,24 @@ This action is irreversible. You will be prompted for confirmation unless description: "Delete a wiki page without confirmation", command: "bee wiki delete 12345 --yes", }, - ], - - annotations: { - environment: [...ENV_AUTH], - }, -}; - -const deleteWiki = withUsage( - defineCommand({ - meta: { - name: "delete", - description: "Delete a wiki page", - }, - args: { - ...outputArgs, - wiki: { - type: "positional", - description: "Wiki page ID", - valueHint: "<number>", - required: true, - }, - yes: { - type: "boolean", - alias: "y", - description: "Skip confirmation prompt", - }, - "mail-notify": { - type: "boolean", - description: "Send notification email", - }, - }, - async run({ args }) { - const confirmed = await confirmOrExit( - `Are you sure you want to delete wiki page ${args.wiki}? This cannot be undone.`, - args.yes, - ); + ]) + .action(async (wiki, opts) => { + const confirmed = await confirmOrExit( + `Are you sure you want to delete wiki page ${wiki}? This cannot be undone.`, + opts.yes, + ); - if (!confirmed) { - return; - } + if (!confirmed) { + return; + } - const { client } = await getClient(); + const { client } = await getClient(); - const wiki = await client.deleteWiki(Number(args.wiki), args["mail-notify"] ?? false); + const wikiData = await client.deleteWiki(Number(wiki), opts.mailNotify ?? false); - outputResult(wiki, args, (data) => { - consola.success(`Deleted wiki page ${data.id}: ${data.name}`); - }); - }, - }), - commandUsage, -); + outputResult(wikiData, opts, (data) => { + consola.success(`Deleted wiki page ${data.id}: ${data.name}`); + }); + }); -export { commandUsage, deleteWiki }; +export default deleteWiki; diff --git a/apps/cli/src/commands/wiki/edit.test.ts b/apps/cli/src/commands/wiki/edit.test.ts index 8f67edcb..d8d73ddb 100644 --- a/apps/cli/src/commands/wiki/edit.test.ts +++ b/apps/cli/src/commands/wiki/edit.test.ts @@ -22,8 +22,8 @@ describe("wiki edit", () => { it("updates wiki page name", async () => { mockClient.patchWiki.mockResolvedValue({ id: 123, name: "New Name" }); - const { edit } = await import("./edit"); - await edit.run?.({ args: { wiki: "123", name: "New Name" } } as never); + const { default: edit } = await import("./edit"); + await edit.parseAsync(["123", "-n", "New Name"], { from: "user" }); expect(mockClient.patchWiki).toHaveBeenCalledWith(123, { name: "New Name", @@ -36,8 +36,8 @@ describe("wiki edit", () => { it("updates wiki page body", async () => { mockClient.patchWiki.mockResolvedValue({ id: 123, name: "Page" }); - const { edit } = await import("./edit"); - await edit.run?.({ args: { wiki: "123", body: "New content" } } as never); + const { default: edit } = await import("./edit"); + await edit.parseAsync(["123", "-b", "New content"], { from: "user" }); expect(mockClient.patchWiki).toHaveBeenCalledWith(123, { name: undefined, @@ -50,8 +50,8 @@ describe("wiki edit", () => { vi.mocked(resolveStdinArg).mockResolvedValueOnce("Stdin content"); mockClient.patchWiki.mockResolvedValue({ id: 123, name: "Page" }); - const { edit } = await import("./edit"); - await edit.run?.({ args: { wiki: "123", body: "" } } as never); + const { default: edit } = await import("./edit"); + await edit.parseAsync(["123", "-b", ""], { from: "user" }); expect(resolveStdinArg).toHaveBeenCalledWith(""); expect(mockClient.patchWiki).toHaveBeenCalledWith( @@ -63,8 +63,8 @@ describe("wiki edit", () => { it("passes notify flag", async () => { mockClient.patchWiki.mockResolvedValue({ id: 123, name: "Page" }); - const { edit } = await import("./edit"); - await edit.run?.({ args: { wiki: "123", name: "Page", "mail-notify": true } } as never); + const { default: edit } = await import("./edit"); + await edit.parseAsync(["123", "-n", "Page", "--mail-notify"], { from: "user" }); expect(mockClient.patchWiki).toHaveBeenCalledWith( 123, @@ -76,8 +76,8 @@ describe("wiki edit", () => { mockClient.patchWiki.mockResolvedValue({ id: 123, name: "Page" }); await expectStdoutContaining(async () => { - const { edit } = await import("./edit"); - await edit.run?.({ args: { wiki: "123", name: "Page", json: "" } } as never); + const { default: edit } = await import("./edit"); + await edit.parseAsync(["123", "-n", "Page", "--json"], { from: "user" }); }, "Page"); }); }); diff --git a/apps/cli/src/commands/wiki/edit.ts b/apps/cli/src/commands/wiki/edit.ts index 3af2b1f5..8be0f2d3 100644 --- a/apps/cli/src/commands/wiki/edit.ts +++ b/apps/cli/src/commands/wiki/edit.ts @@ -1,17 +1,25 @@ import { getClient } from "@repo/backlog-utils"; -import { outputArgs, outputResult, resolveStdinArg } from "@repo/cli-utils"; -import { defineCommand } from "citty"; +import { outputResult, resolveStdinArg } from "@repo/cli-utils"; import consola from "consola"; -import { type CommandUsage, ENV_AUTH, withUsage } from "../../lib/command-usage"; +import { BeeCommand, ENV_AUTH } from "../../lib/bee-command"; +import * as opt from "../../lib/common-options"; -const commandUsage: CommandUsage = { - long: `Update an existing Backlog wiki page. +const edit = new BeeCommand("edit") + .summary("Edit a wiki page") + .description( + `Update an existing Backlog wiki page. Only the specified fields will be updated. Fields that are not provided will remain unchanged. When input is piped, it is used as the body automatically.`, - - examples: [ + ) + .argument("<wiki>", "Wiki page ID") + .option("-n, --name <name>", "New name of the wiki page") + .option("-b, --body <text>", "New content of the wiki page") + .option("--mail-notify", "Send notification email") + .addOption(opt.json()) + .envVars([...ENV_AUTH]) + .examples([ { description: "Update wiki page name", command: 'bee wiki edit 12345 -n "New Name"', @@ -24,59 +32,21 @@ automatically.`, description: "Update body from stdin", command: 'echo "New content" | bee wiki edit 12345', }, - ], - - annotations: { - environment: [...ENV_AUTH], - }, -}; - -const edit = withUsage( - defineCommand({ - meta: { - name: "edit", - description: "Edit a wiki page", - }, - args: { - ...outputArgs, - wiki: { - type: "positional", - description: "Wiki page ID", - valueHint: "<number>", - required: true, - }, - name: { - type: "string", - alias: "n", - description: "New name of the wiki page", - }, - body: { - type: "string", - alias: "b", - description: "New content of the wiki page", - }, - "mail-notify": { - type: "boolean", - description: "Send notification email", - }, - }, - async run({ args }) { - const { client } = await getClient(); + ]) + .action(async (wiki, opts) => { + const { client } = await getClient(); - const content = await resolveStdinArg(args.body); + const content = await resolveStdinArg(opts.body); - const wiki = await client.patchWiki(Number(args.wiki), { - name: args.name, - content, - mailNotify: args["mail-notify"], - }); + const wikiData = await client.patchWiki(Number(wiki), { + name: opts.name, + content, + mailNotify: opts.mailNotify, + }); - outputResult(wiki, args, (data) => { - consola.success(`Updated wiki page ${data.id}: ${data.name}`); - }); - }, - }), - commandUsage, -); + outputResult(wikiData, opts, (data) => { + consola.success(`Updated wiki page ${data.id}: ${data.name}`); + }); + }); -export { commandUsage, edit }; +export default edit; diff --git a/apps/cli/src/commands/wiki/history.test.ts b/apps/cli/src/commands/wiki/history.test.ts index 1a407bc8..2cf74d43 100644 --- a/apps/cli/src/commands/wiki/history.test.ts +++ b/apps/cli/src/commands/wiki/history.test.ts @@ -20,8 +20,8 @@ describe("wiki history", () => { { version: 2, createdUser: { name: "Bob" }, created: "2025-01-02T00:00:00Z" }, ]); - const { history } = await import("./history"); - await history.run?.({ args: { wiki: "123" } } as never); + const { default: history } = await import("./history"); + await history.parseAsync(["123"], { from: "user" }); expect(getClient).toHaveBeenCalled(); expect(mockClient.getWikisHistory).toHaveBeenCalledWith(123, { @@ -37,8 +37,8 @@ describe("wiki history", () => { it("shows message when no history found", async () => { mockClient.getWikisHistory.mockResolvedValue([]); - const { history } = await import("./history"); - await history.run?.({ args: { wiki: "123" } } as never); + const { default: history } = await import("./history"); + await history.parseAsync(["123"], { from: "user" }); expect(consola.info).toHaveBeenCalledWith("No history found."); }); @@ -46,8 +46,8 @@ describe("wiki history", () => { it("passes order parameter", async () => { mockClient.getWikisHistory.mockResolvedValue([]); - const { history } = await import("./history"); - await history.run?.({ args: { wiki: "123", order: "asc" } } as never); + const { default: history } = await import("./history"); + await history.parseAsync(["123", "--order", "asc"], { from: "user" }); expect(mockClient.getWikisHistory).toHaveBeenCalledWith( 123, @@ -58,10 +58,10 @@ describe("wiki history", () => { it("passes count and ID range parameters", async () => { mockClient.getWikisHistory.mockResolvedValue([]); - const { history } = await import("./history"); - await history.run?.({ - args: { wiki: "123", count: "10", "min-id": "1", "max-id": "5" }, - } as never); + const { default: history } = await import("./history"); + await history.parseAsync(["123", "--count", "10", "--min-id", "1", "--max-id", "5"], { + from: "user", + }); expect(mockClient.getWikisHistory).toHaveBeenCalledWith(123, { minId: 1, @@ -77,8 +77,8 @@ describe("wiki history", () => { ]); await expectStdoutContaining(async () => { - const { history } = await import("./history"); - await history.run?.({ args: { wiki: "123", json: "" } } as never); + const { default: history } = await import("./history"); + await history.parseAsync(["123", "--json"], { from: "user" }); }, "Alice"); }); }); diff --git a/apps/cli/src/commands/wiki/history.ts b/apps/cli/src/commands/wiki/history.ts index ec3bc1fc..3dd5e3fa 100644 --- a/apps/cli/src/commands/wiki/history.ts +++ b/apps/cli/src/commands/wiki/history.ts @@ -1,77 +1,57 @@ import { getClient } from "@repo/backlog-utils"; -import { type Row, formatDate, outputArgs, outputResult, printTable } from "@repo/cli-utils"; -import { defineCommand } from "citty"; +import { type Row, formatDate, outputResult, printTable } from "@repo/cli-utils"; import consola from "consola"; -import { type CommandUsage, ENV_AUTH, withUsage } from "../../lib/command-usage"; -import * as commonArgs from "../../lib/common-args"; +import { BeeCommand, ENV_AUTH } from "../../lib/bee-command"; +import * as opt from "../../lib/common-options"; -const commandUsage: CommandUsage = { - long: `Display the revision history of a Backlog wiki page. +const history = new BeeCommand("history") + .summary("View wiki page history") + .description( + `Display the revision history of a Backlog wiki page. Shows version number, updater, and update date for each revision.`, - - examples: [ + ) + .argument("<wiki>", "Wiki page ID") + .addOption(opt.minId()) + .addOption(opt.maxId()) + .addOption(opt.count()) + .addOption(opt.order()) + .addOption(opt.json()) + .envVars([...ENV_AUTH]) + .examples([ { description: "View wiki page history", command: "bee wiki history 12345" }, { description: "View history in ascending order", command: "bee wiki history 12345 --order asc", }, { description: "Output as JSON", command: "bee wiki history 12345 --json" }, - ], - - annotations: { - environment: [...ENV_AUTH], - }, -}; - -const history = withUsage( - defineCommand({ - meta: { - name: "history", - description: "View wiki page history", - }, - args: { - ...outputArgs, - wiki: { - type: "positional", - description: "Wiki page ID", - valueHint: "<number>", - required: true, - }, - "min-id": commonArgs.minId, - "max-id": commonArgs.maxId, - count: commonArgs.count, - order: commonArgs.order, - }, - async run({ args }) { - const { client } = await getClient(); - - const histories = await client.getWikisHistory(Number(args.wiki), { - minId: args["min-id"] ? Number(args["min-id"]) : undefined, - maxId: args["max-id"] ? Number(args["max-id"]) : undefined, - count: args.count ? Number(args.count) : undefined, - order: args.order as "asc" | "desc" | undefined, - }); - - outputResult(histories, args, (data) => { - if (data.length === 0) { - consola.info("No history found."); - return; - } - - const rows: Row[] = data.map( - (h: { version: number; createdUser: { name: string }; created: string }) => [ - { header: "VERSION", value: String(h.version) }, - { header: "UPDATED BY", value: h.createdUser.name }, - { header: "UPDATED", value: formatDate(h.created) }, - ], - ); - - printTable(rows); - }); - }, - }), - commandUsage, -); - -export { commandUsage, history }; + ]) + .action(async (wiki, opts) => { + const { client } = await getClient(); + + const histories = await client.getWikisHistory(Number(wiki), { + minId: opts.minId ? Number(opts.minId) : undefined, + maxId: opts.maxId ? Number(opts.maxId) : undefined, + count: opts.count ? Number(opts.count) : undefined, + order: opts.order as "asc" | "desc" | undefined, + }); + + outputResult(histories, opts, (data) => { + if (data.length === 0) { + consola.info("No history found."); + return; + } + + const rows: Row[] = data.map( + (h: { version: number; createdUser: { name: string }; created: string }) => [ + { header: "VERSION", value: String(h.version) }, + { header: "UPDATED BY", value: h.createdUser.name }, + { header: "UPDATED", value: formatDate(h.created) }, + ], + ); + + printTable(rows); + }); + }); + +export default history; diff --git a/apps/cli/src/commands/wiki/list.test.ts b/apps/cli/src/commands/wiki/list.test.ts index 6279fa75..97fe655c 100644 --- a/apps/cli/src/commands/wiki/list.test.ts +++ b/apps/cli/src/commands/wiki/list.test.ts @@ -13,6 +13,11 @@ vi.mock("@repo/backlog-utils", () => ({ vi.mock("consola", () => import("@repo/test-utils/mock-consola")); +vi.mock("@repo/cli-utils", async (importOriginal) => ({ + ...(await importOriginal()), + promptRequired: vi.fn((_, val) => Promise.resolve(val)), +})); + describe("wiki list", () => { it("displays wiki pages in tabular format", async () => { mockClient.getWikis.mockResolvedValue([ @@ -20,8 +25,8 @@ describe("wiki list", () => { { id: 2, name: "Setup Guide", updated: "2025-01-02T00:00:00Z" }, ]); - const { list } = await import("./list"); - await list.run?.({ args: { project: "TEST" } } as never); + const { default: list } = await import("./list"); + await list.parseAsync(["-p", "TEST"], { from: "user" }); expect(getClient).toHaveBeenCalled(); expect(mockClient.getWikis).toHaveBeenCalledWith({ @@ -36,8 +41,8 @@ describe("wiki list", () => { it("shows message when no wiki pages found", async () => { mockClient.getWikis.mockResolvedValue([]); - const { list } = await import("./list"); - await list.run?.({ args: { project: "TEST" } } as never); + const { default: list } = await import("./list"); + await list.parseAsync(["-p", "TEST"], { from: "user" }); expect(consola.info).toHaveBeenCalledWith("No wiki pages found."); }); @@ -45,8 +50,8 @@ describe("wiki list", () => { it("passes keyword parameter", async () => { mockClient.getWikis.mockResolvedValue([]); - const { list } = await import("./list"); - await list.run?.({ args: { project: "TEST", keyword: "setup" } } as never); + const { default: list } = await import("./list"); + await list.parseAsync(["-p", "TEST", "--keyword", "setup"], { from: "user" }); expect(mockClient.getWikis).toHaveBeenCalledWith({ projectIdOrKey: "TEST", @@ -60,8 +65,8 @@ describe("wiki list", () => { ]); await expectStdoutContaining(async () => { - const { list } = await import("./list"); - await list.run?.({ args: { project: "TEST", json: "" } } as never); + const { default: list } = await import("./list"); + await list.parseAsync(["-p", "TEST", "--json"], { from: "user" }); }, "Home"); }); }); diff --git a/apps/cli/src/commands/wiki/list.ts b/apps/cli/src/commands/wiki/list.ts index 20eea19d..18773ecd 100644 --- a/apps/cli/src/commands/wiki/list.ts +++ b/apps/cli/src/commands/wiki/list.ts @@ -1,65 +1,53 @@ import { getClient } from "@repo/backlog-utils"; -import { type Row, outputArgs, outputResult, printTable } from "@repo/cli-utils"; -import { defineCommand } from "citty"; +import { type Row, outputResult, printTable } from "@repo/cli-utils"; import consola from "consola"; -import { type CommandUsage, ENV_AUTH, ENV_PROJECT, withUsage } from "../../lib/command-usage"; -import * as commonArgs from "../../lib/common-args"; +import { BeeCommand, ENV_AUTH, ENV_PROJECT } from "../../lib/bee-command"; +import * as opt from "../../lib/common-options"; +import { resolveOptions } from "../../lib/required-option"; -const commandUsage: CommandUsage = { - long: `List wiki pages in a Backlog project. +const list = new BeeCommand("list") + .summary("List wiki pages") + .description( + `List wiki pages in a Backlog project. Use \`--keyword\` to filter pages by name or content.`, - - examples: [ + ) + .addOption(opt.project()) + .addOption(opt.keyword()) + .addOption(opt.json()) + .envVars([...ENV_AUTH, ENV_PROJECT]) + .examples([ { description: "List all wiki pages in a project", command: "bee wiki list -p PROJECT" }, { description: "Search wiki pages by keyword", command: 'bee wiki list -p PROJECT --keyword "setup"', }, { description: "Output as JSON", command: "bee wiki list -p PROJECT --json" }, - ], - - annotations: { - environment: [...ENV_AUTH, ENV_PROJECT], - }, -}; - -const list = withUsage( - defineCommand({ - meta: { - name: "list", - description: "List wiki pages", - }, - args: { - ...outputArgs, - project: commonArgs.projectPositional, - keyword: commonArgs.keyword, - }, - async run({ args }) { - const { client } = await getClient(); - - const wikis = await client.getWikis({ - projectIdOrKey: args.project, - keyword: args.keyword, - }); - - outputResult(wikis, args, (data) => { - if (data.length === 0) { - consola.info("No wiki pages found."); - return; - } - - const rows: Row[] = data.map((wiki) => [ - { header: "ID", value: String(wiki.id) }, - { header: "NAME", value: wiki.name }, - { header: "UPDATED", value: wiki.updated.slice(0, 10) }, - ]); - - printTable(rows); - }); - }, - }), - commandUsage, -); - -export { commandUsage, list }; + ]) + .action(async (_opts, cmd) => { + const opts = await resolveOptions(cmd); + const { client } = await getClient(); + + const wikis = await client.getWikis({ + projectIdOrKey: opts.project as string, + keyword: opts.keyword as string | undefined, + }); + + const json = opts.json === true ? "" : (opts.json as string | undefined); + outputResult(wikis, { json }, (data) => { + if (data.length === 0) { + consola.info("No wiki pages found."); + return; + } + + const rows: Row[] = data.map((wiki) => [ + { header: "ID", value: String(wiki.id) }, + { header: "NAME", value: wiki.name }, + { header: "UPDATED", value: wiki.updated.slice(0, 10) }, + ]); + + printTable(rows); + }); + }); + +export default list; diff --git a/apps/cli/src/commands/wiki/tags.test.ts b/apps/cli/src/commands/wiki/tags.test.ts index 68825b01..38a0f008 100644 --- a/apps/cli/src/commands/wiki/tags.test.ts +++ b/apps/cli/src/commands/wiki/tags.test.ts @@ -13,6 +13,11 @@ vi.mock("@repo/backlog-utils", () => ({ vi.mock("consola", () => import("@repo/test-utils/mock-consola")); +vi.mock("@repo/cli-utils", async (importOriginal) => ({ + ...(await importOriginal()), + promptRequired: vi.fn((_, val) => Promise.resolve(val)), +})); + describe("wiki tags", () => { it("displays wiki tags", async () => { mockClient.getWikisTags.mockResolvedValue([ @@ -20,8 +25,8 @@ describe("wiki tags", () => { { id: 2, name: "setup" }, ]); - const { tags } = await import("./tags"); - await tags.run?.({ args: { project: "TEST" } } as never); + const { default: tags } = await import("./tags"); + await tags.parseAsync(["-p", "TEST"], { from: "user" }); expect(getClient).toHaveBeenCalled(); expect(mockClient.getWikisTags).toHaveBeenCalledWith("TEST"); @@ -32,8 +37,8 @@ describe("wiki tags", () => { it("shows message when no tags found", async () => { mockClient.getWikisTags.mockResolvedValue([]); - const { tags } = await import("./tags"); - await tags.run?.({ args: { project: "TEST" } } as never); + const { default: tags } = await import("./tags"); + await tags.parseAsync(["-p", "TEST"], { from: "user" }); expect(consola.info).toHaveBeenCalledWith("No wiki tags found."); }); @@ -42,8 +47,8 @@ describe("wiki tags", () => { mockClient.getWikisTags.mockResolvedValue([{ id: 1, name: "guide" }]); await expectStdoutContaining(async () => { - const { tags } = await import("./tags"); - await tags.run?.({ args: { project: "TEST", json: "" } } as never); + const { default: tags } = await import("./tags"); + await tags.parseAsync(["-p", "TEST", "--json"], { from: "user" }); }, "guide"); }); }); diff --git a/apps/cli/src/commands/wiki/tags.ts b/apps/cli/src/commands/wiki/tags.ts index b761ac9e..89a0774a 100644 --- a/apps/cli/src/commands/wiki/tags.ts +++ b/apps/cli/src/commands/wiki/tags.ts @@ -1,57 +1,41 @@ import { getClient } from "@repo/backlog-utils"; -import { outputArgs, outputResult } from "@repo/cli-utils"; -import { defineCommand } from "citty"; +import { outputResult } from "@repo/cli-utils"; import consola from "consola"; -import { type CommandUsage, ENV_AUTH, ENV_PROJECT, withUsage } from "../../lib/command-usage"; +import { BeeCommand, ENV_AUTH, ENV_PROJECT } from "../../lib/bee-command"; +import * as opt from "../../lib/common-options"; +import { resolveOptions } from "../../lib/required-option"; -const commandUsage: CommandUsage = { - long: `List wiki tags in a Backlog project. +const tags = new BeeCommand("tags") + .summary("List wiki tags") + .description( + `List wiki tags in a Backlog project. Tags are labels attached to wiki pages for organization.`, - - examples: [ + ) + .addOption(opt.project()) + .addOption(opt.json()) + .envVars([...ENV_AUTH, ENV_PROJECT]) + .examples([ { description: "List wiki tags", command: "bee wiki tags -p PROJECT" }, { description: "Output as JSON", command: "bee wiki tags -p PROJECT --json" }, - ], - - annotations: { - environment: [...ENV_AUTH, ENV_PROJECT], - }, -}; - -const tags = withUsage( - defineCommand({ - meta: { - name: "tags", - description: "List wiki tags", - }, - args: { - ...outputArgs, - project: { - type: "positional", - description: "Project ID or project key", - required: true, - default: process.env.BACKLOG_PROJECT, - }, - }, - async run({ args }) { - const { client } = await getClient(); - - const result = await client.getWikisTags(args.project); - - outputResult(result, args, (data) => { - if (data.length === 0) { - consola.info("No wiki tags found."); - return; - } - - for (const tag of data) { - consola.log(tag.name); - } - }); - }, - }), - commandUsage, -); - -export { commandUsage, tags }; + ]) + .action(async (_opts, cmd) => { + const opts = await resolveOptions(cmd); + const { client } = await getClient(); + + const result = await client.getWikisTags(opts.project as string); + + const json = opts.json === true ? "" : (opts.json as string | undefined); + outputResult(result, { json }, (data) => { + if (data.length === 0) { + consola.info("No wiki tags found."); + return; + } + + for (const tag of data) { + consola.log(tag.name); + } + }); + }); + +export default tags; diff --git a/apps/cli/src/commands/wiki/view.test.ts b/apps/cli/src/commands/wiki/view.test.ts index 22846f66..c5953322 100644 --- a/apps/cli/src/commands/wiki/view.test.ts +++ b/apps/cli/src/commands/wiki/view.test.ts @@ -31,8 +31,8 @@ describe("wiki view", () => { it("displays wiki page details", async () => { mockClient.getWiki.mockResolvedValue(sampleWiki); - const { view } = await import("./view"); - await view.run?.({ args: { wiki: "123" } } as never); + const { default: view } = await import("./view"); + await view.parseAsync(["123"], { from: "user" }); expect(mockClient.getWiki).toHaveBeenCalledWith(123); expect(consola.log).toHaveBeenCalledWith(expect.stringContaining("Home")); @@ -42,8 +42,8 @@ describe("wiki view", () => { }); it("opens browser with --web flag", async () => { - const { view } = await import("./view"); - await view.run?.({ args: { wiki: "123", web: true } } as never); + const { default: view } = await import("./view"); + await view.parseAsync(["123", "--web"], { from: "user" }); expect(openOrPrintUrl).toHaveBeenCalledWith( "https://example.backlog.com/alias/wiki/123", @@ -57,16 +57,16 @@ describe("wiki view", () => { mockClient.getWiki.mockResolvedValue(sampleWiki); await expectStdoutContaining(async () => { - const { view } = await import("./view"); - await view.run?.({ args: { wiki: "123", json: "" } } as never); + const { default: view } = await import("./view"); + await view.parseAsync(["123", "--json"], { from: "user" }); }, "Home"); }); it("handles wiki page with no tags", async () => { mockClient.getWiki.mockResolvedValue({ ...sampleWiki, tags: [] }); - const { view } = await import("./view"); - await view.run?.({ args: { wiki: "123" } } as never); + const { default: view } = await import("./view"); + await view.parseAsync(["123"], { from: "user" }); expect(mockClient.getWiki).toHaveBeenCalledWith(123); }); diff --git a/apps/cli/src/commands/wiki/view.ts b/apps/cli/src/commands/wiki/view.ts index 7f7c94d7..861bfbff 100644 --- a/apps/cli/src/commands/wiki/view.ts +++ b/apps/cli/src/commands/wiki/view.ts @@ -1,83 +1,63 @@ import { getClient, openOrPrintUrl, wikiUrl } from "@repo/backlog-utils"; -import { formatDate, outputArgs, outputResult, printDefinitionList } from "@repo/cli-utils"; -import { defineCommand } from "citty"; +import { formatDate, outputResult, printDefinitionList } from "@repo/cli-utils"; import consola from "consola"; -import { type CommandUsage, ENV_AUTH, withUsage } from "../../lib/command-usage"; -import * as commonArgs from "../../lib/common-args"; +import { BeeCommand, ENV_AUTH } from "../../lib/bee-command"; +import * as opt from "../../lib/common-options"; -const commandUsage: CommandUsage = { - long: `Display details of a Backlog wiki page. +const view = new BeeCommand("view") + .summary("View a wiki page") + .description( + `Display details of a Backlog wiki page. Shows the page name, ID, tags, created/updated info, and the full body content. Use \`--web\` to open the wiki page in your default browser instead.`, - - examples: [ + ) + .argument("<wiki>", "Wiki page ID") + .addOption(opt.web("wiki page")) + .addOption(opt.noBrowser()) + .addOption(opt.json()) + .envVars([...ENV_AUTH]) + .examples([ { description: "View a wiki page", command: "bee wiki view 12345" }, { description: "Open wiki page in browser", command: "bee wiki view 12345 --web" }, { description: "Output as JSON", command: "bee wiki view 12345 --json" }, - ], - - annotations: { - environment: [...ENV_AUTH], - }, -}; - -const view = withUsage( - defineCommand({ - meta: { - name: "view", - description: "View a wiki page", - }, - args: { - ...outputArgs, - wiki: { - type: "positional", - description: "Wiki page ID", - valueHint: "<number>", - required: true, - }, - web: commonArgs.web("wiki page"), - "no-browser": commonArgs.noBrowser, - }, - async run({ args }) { - const { client, host } = await getClient(); - - if (args.web || args["no-browser"]) { - const url = wikiUrl(host, Number(args.wiki)); - await openOrPrintUrl(url, Boolean(args["no-browser"]), consola); - return; - } - - const wiki = await client.getWiki(Number(args.wiki)); - - outputResult(wiki, args, (data) => { - consola.log(""); - consola.log(` ${data.name}`); + ]) + .action(async (wiki, opts) => { + const { client, host } = await getClient(); + + if (opts.web || opts.browser === false) { + const url = wikiUrl(host, Number(wiki)); + await openOrPrintUrl(url, opts.browser === false, consola); + return; + } + + const wikiData = await client.getWiki(Number(wiki)); + + outputResult(wikiData, opts, (data) => { + consola.log(""); + consola.log(` ${data.name}`); + consola.log(""); + printDefinitionList([ + ["ID", String(data.id)], + [ + "Tags", + data.tags.length > 0 + ? data.tags.map((t: { name: string }) => t.name).join(", ") + : undefined, + ], + ["Created by", data.createdUser?.name], + ["Created", formatDate(data.created)], + ["Updated by", data.updatedUser?.name], + ["Updated", formatDate(data.updated)], + ]); + if (data.content) { consola.log(""); - printDefinitionList([ - ["ID", String(data.id)], - [ - "Tags", - data.tags.length > 0 - ? data.tags.map((t: { name: string }) => t.name).join(", ") - : undefined, - ], - ["Created by", data.createdUser?.name], - ["Created", formatDate(data.created)], - ["Updated by", data.updatedUser?.name], - ["Updated", formatDate(data.updated)], - ]); - if (data.content) { - consola.log(""); - consola.log(` ${data.content}`); - } - consola.log(""); - }); - }, - }), - commandUsage, -); + consola.log(` ${data.content}`); + } + consola.log(""); + }); + }); -export { commandUsage, view }; +export default view; From f39d073eaea136ba47bcfaecc46ff748ba1ecb4c Mon Sep 17 00:00:00 2001 From: Ryoya Tamura <ryoya.tamura@nulab.com> Date: Sun, 8 Mar 2026 18:22:07 +0900 Subject: [PATCH 08/16] refactor(cli): migrate standalone commands to commander Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --- apps/cli/src/commands/api.test.ts | 32 ++--- apps/cli/src/commands/api.ts | 138 +++++--------------- apps/cli/src/commands/browse.test.ts | 12 +- apps/cli/src/commands/browse.ts | 143 +++++++------------- apps/cli/src/commands/completion.test.ts | 16 +-- apps/cli/src/commands/completion.ts | 81 +++++------- apps/cli/src/commands/dashboard.test.ts | 16 +-- apps/cli/src/commands/dashboard.ts | 159 +++++++++++------------ apps/cli/src/lib/common-options.ts | 16 +-- 9 files changed, 230 insertions(+), 383 deletions(-) diff --git a/apps/cli/src/commands/api.test.ts b/apps/cli/src/commands/api.test.ts index 29a0bd29..8906934b 100644 --- a/apps/cli/src/commands/api.test.ts +++ b/apps/cli/src/commands/api.test.ts @@ -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"'); @@ -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(); @@ -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(); @@ -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(); @@ -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(); @@ -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" }); @@ -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([ @@ -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" }); diff --git a/apps/cli/src/commands/api.ts b/apps/cli/src/commands/api.ts index e6a30c7a..b19904e1 100644 --- a/apps/cli/src/commands/api.ts +++ b/apps/cli/src/commands/api.ts @@ -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 @@ -22,8 +27,15 @@ 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", @@ -42,76 +54,27 @@ 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. + // commander gives `true` for bare --json, a string for --json fields + const jsonVal = opts.json === undefined ? "" : (opts.json === true ? "" : opts.json); + outputResult(data, { json: jsonVal }, () => {}); + }); /** * Normalize API endpoint path for backlog-js client methods. @@ -124,35 +87,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). @@ -240,4 +174,4 @@ const makeRequest = async ( } }; -export { commandUsage, api }; +export default api; diff --git a/apps/cli/src/commands/browse.test.ts b/apps/cli/src/commands/browse.test.ts index ce4a6ef6..2b686ccf 100644 --- a/apps/cli/src/commands/browse.test.ts +++ b/apps/cli/src/commands/browse.test.ts @@ -23,8 +23,8 @@ const { resolveUrl: mockResolveUrl } = vi.mocked(await import("./browse-url")); describe("browse", () => { it("opens resolved URL in browser", async () => { - const { browse } = await import("./browse"); - await browse.run?.({ args: {} } as never); + const { default: browse } = await import("./browse"); + await browse.parseAsync([], { from: "user" }); expect(openOrPrintUrl).toHaveBeenCalledWith( "https://example.backlog.com/mock-url", @@ -34,8 +34,8 @@ describe("browse", () => { }); it("prints URL without opening browser with --no-browser", async () => { - const { browse } = await import("./browse"); - await browse.run?.({ args: { "no-browser": true } } as never); + const { default: browse } = await import("./browse"); + await browse.parseAsync(["--no-browser"], { from: "user" }); expect(openOrPrintUrl).toHaveBeenCalledWith( "https://example.backlog.com/mock-url", @@ -57,8 +57,8 @@ describe("browse", () => { getLatestCommit.mockResolvedValueOnce("deadbeef"); getRepoRelativePath.mockResolvedValueOnce("src/"); - const { browse } = await import("./browse"); - await browse.run?.({ args: { target: "main.ts" } } as never); + const { default: browse } = await import("./browse"); + await browse.parseAsync(["main.ts"], { from: "user" }); expect(mockResolveUrl).toHaveBeenCalledWith( "example.backlog.com", diff --git a/apps/cli/src/commands/browse.ts b/apps/cli/src/commands/browse.ts index 5f05842a..df862ea5 100644 --- a/apps/cli/src/commands/browse.ts +++ b/apps/cli/src/commands/browse.ts @@ -7,14 +7,15 @@ import { openOrPrintUrl, } from "@repo/backlog-utils"; import { UserError } from "@repo/cli-utils"; -import { defineCommand } from "citty"; import consola from "consola"; -import { type CommandUsage, ENV_AUTH, ENV_PROJECT, withUsage } from "../lib/command-usage"; -import * as commonArgs from "../lib/common-args"; +import { BeeCommand, ENV_AUTH, ENV_PROJECT } from "../lib/bee-command"; +import * as opt from "../lib/common-options"; import { resolveUrl } from "./browse-url"; -const commandUsage: CommandUsage = { - long: `Open a Backlog page in the browser. +const browse = new BeeCommand("browse") + .summary("Open a Backlog page in the browser") + .description( + `Open a Backlog page in the browser. With no arguments, the behavior depends on context. Inside a Backlog Git repository it opens the repository page; otherwise it opens the dashboard. @@ -27,8 +28,23 @@ to a specific project page. A file path opens the file in the Backlog Git viewer (e.g. \`src/main.ts\`). Append \`:<line>\` to jump to a specific line (e.g. \`src/main.ts:42\`). Paths ending with \`/\` open the directory tree view.`, - - examples: [ + ) + .argument("[target]", "Issue key, issue number, file path, or project key") + .addOption(opt.project().makeOptionMandatory(false).default(undefined)) + .option("-b, --branch <name>", "View file at a specific branch") + .option("-c, --commit", "View file at the latest commit") + .addOption(opt.noBrowser()) + .option("--issues", "Open the issues page") + .option("--board", "Open the board page") + .option("--gantt", "Open the Gantt chart page") + .option("--wiki", "Open the wiki page") + .option("--documents", "Open the documents page") + .option("--files", "Open the shared files page") + .option("--git", "Open the git repositories page") + .option("--svn", "Open the Subversion page") + .option("--settings", "Open the project settings page") + .envVars([...ENV_AUTH, ENV_PROJECT]) + .examples([ { description: "Open repository page (in a Backlog repo)", command: "bee browse" }, { description: "Open dashboard (outside a Backlog repo)", command: "bee browse" }, { description: "Open an issue", command: "bee browse PROJECT-123" }, @@ -42,100 +58,31 @@ Paths ending with \`/\` open the directory tree view.`, { description: "Open project issues page", command: "bee browse -p PROJECT --issues" }, { description: "Open project board", command: "bee browse -p PROJECT --board" }, { description: "Open Gantt chart", command: "bee browse -p PROJECT --gantt" }, - ], - - annotations: { - environment: [...ENV_AUTH, ENV_PROJECT], - }, -}; + ]) + .action(async (target: string | undefined, opts) => { + const { host } = await getClient(); -const browse = withUsage( - defineCommand({ - meta: { - name: "browse", - description: "Open a Backlog page in the browser", - }, - args: { - target: { - type: "positional", - description: "Issue key, issue number, file path, or project key", - required: false, - valueHint: "<PROJECT-123>", - }, - project: commonArgs.project, - branch: { - type: "string", - alias: "b", - description: "View file at a specific branch", - }, - commit: { - type: "boolean", - alias: "c", - description: "View file at the latest commit", - }, - "no-browser": commonArgs.noBrowser, - issues: { - type: "boolean", - description: "Open the issues page", - }, - board: { - type: "boolean", - description: "Open the board page", - }, - gantt: { - type: "boolean", - description: "Open the Gantt chart page", - }, - wiki: { - type: "boolean", - description: "Open the wiki page", - }, - documents: { - type: "boolean", - description: "Open the documents page", - }, - files: { - type: "boolean", - description: "Open the shared files page", - }, - git: { - type: "boolean", - description: "Open the git repositories page", - }, - svn: { - type: "boolean", - description: "Open the Subversion page", - }, - settings: { - type: "boolean", - description: "Open the project settings page", - }, - }, - async run({ args }) { - const { host } = await getClient(); + const [context, currentBranch, latestCommit, repoRelativePath] = await Promise.all([ + detectGitContext(), + getCurrentBranch(), + getLatestCommit(), + getRepoRelativePath(), + ]); - const [context, currentBranch, latestCommit, repoRelativePath] = await Promise.all([ - detectGitContext(), - getCurrentBranch(), - getLatestCommit(), - getRepoRelativePath(), - ]); + const browseArgs = { target, ...opts }; - const result = resolveUrl(context?.host ?? host, args, { - context, - currentBranch, - latestCommit, - repoRelativePath, - }); + const result = resolveUrl(context?.host ?? host, browseArgs, { + context, + currentBranch, + latestCommit, + repoRelativePath, + }); - if (!result.ok) { - throw new UserError(result.error); - } + if (!result.ok) { + throw new UserError(result.error); + } - await openOrPrintUrl(result.url, Boolean(args["no-browser"]), consola); - }, - }), - commandUsage, -); + await openOrPrintUrl(result.url, opts.browser === false, consola); + }); -export { commandUsage, browse }; +export default browse; diff --git a/apps/cli/src/commands/completion.test.ts b/apps/cli/src/commands/completion.test.ts index 035a6f67..5b504959 100644 --- a/apps/cli/src/commands/completion.test.ts +++ b/apps/cli/src/commands/completion.test.ts @@ -4,8 +4,8 @@ describe("completion", () => { it("generates bash completion script", async () => { const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); - const { completion } = await import("./completion"); - completion.run?.({ args: { shell: "bash" } } as never); + const { default: completion } = await import("./completion"); + await completion.parseAsync(["bash"], { from: "user" }); const output = writeSpy.mock.calls[0][0] as string; expect(output).toContain("_bee_completions"); @@ -18,8 +18,8 @@ describe("completion", () => { it("generates zsh completion script", async () => { const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); - const { completion } = await import("./completion"); - completion.run?.({ args: { shell: "zsh" } } as never); + const { default: completion } = await import("./completion"); + await completion.parseAsync(["zsh"], { from: "user" }); const output = writeSpy.mock.calls[0][0] as string; expect(output).toContain("#compdef bee"); @@ -32,8 +32,8 @@ describe("completion", () => { it("generates fish completion script", async () => { const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); - const { completion } = await import("./completion"); - completion.run?.({ args: { shell: "fish" } } as never); + const { default: completion } = await import("./completion"); + await completion.parseAsync(["fish"], { from: "user" }); const output = writeSpy.mock.calls[0][0] as string; expect(output).toContain("complete -c bee"); @@ -43,9 +43,9 @@ describe("completion", () => { }); it("throws error for unsupported shell", async () => { - const { completion } = await import("./completion"); + const { default: completion } = await import("./completion"); - expect(() => completion.run?.({ args: { shell: "powershell" } } as never)).toThrow( + await expect(completion.parseAsync(["powershell"], { from: "user" })).rejects.toThrow( "Unsupported shell", ); }); diff --git a/apps/cli/src/commands/completion.ts b/apps/cli/src/commands/completion.ts index f9f50b01..a2c64f89 100644 --- a/apps/cli/src/commands/completion.ts +++ b/apps/cli/src/commands/completion.ts @@ -2,18 +2,29 @@ import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { UserError } from "@repo/cli-utils"; -import { defineCommand } from "citty"; -import { type CommandUsage, withUsage } from "../lib/command-usage"; +import { BeeCommand } from "../lib/bee-command"; const __dirname = fileURLToPath(new URL(".", import.meta.url)); -const commandUsage: CommandUsage = { - long: `Generate shell completion scripts for bee. +const shellExtensions: Record<string, string> = { + bash: "sh", + zsh: "zsh", + fish: "fish", +}; + +const loadCompletionScript = (shell: string): string => + readFileSync(resolve(__dirname, `completions/${shell}.${shellExtensions[shell]}`), "utf8"); + +const completion = new BeeCommand("completion") + .summary("Generate shell completion scripts") + .description( + `Generate shell completion scripts for bee. The generated script should be sourced in your shell's configuration file. Follow the instructions in the output for your specific shell.`, - - examples: [ + ) + .argument("<shell>", "Shell to generate completions for") + .examples([ { description: "Set up completions for bash (add to ~/.bashrc)", command: "echo 'eval \"$(bee completion bash)\"' >> ~/.bashrc", @@ -26,49 +37,19 @@ Follow the instructions in the output for your specific shell.`, description: "Set up completions for fish", command: "bee completion fish > ~/.config/fish/completions/bee.fish", }, - ], -}; - -const shellExtensions: Record<string, string> = { - bash: "sh", - zsh: "zsh", - fish: "fish", -}; - -const loadCompletionScript = (shell: string): string => - readFileSync(resolve(__dirname, `completions/${shell}.${shellExtensions[shell]}`), "utf8"); - -const completion = withUsage( - defineCommand({ - meta: { - name: "completion", - description: "Generate shell completion scripts", - }, - args: { - shell: { - type: "positional", - description: "Shell type", - required: true, - valueHint: "{bash|zsh|fish}", - }, - }, - run({ args }) { - switch (args.shell) { - case "bash": - case "zsh": - case "fish": { - process.stdout.write(loadCompletionScript(args.shell)); - break; - } - default: { - throw new UserError( - `Unsupported shell: "${args.shell}". Supported shells: bash, zsh, fish.`, - ); - } + ]) + .action((shell: string) => { + switch (shell) { + case "bash": + case "zsh": + case "fish": { + process.stdout.write(loadCompletionScript(shell)); + break; } - }, - }), - commandUsage, -); + default: { + throw new UserError(`Unsupported shell: "${shell}". Supported shells: bash, zsh, fish.`); + } + } + }); -export { commandUsage, completion }; +export default completion; diff --git a/apps/cli/src/commands/dashboard.test.ts b/apps/cli/src/commands/dashboard.test.ts index 2ab4f890..c3dbbc25 100644 --- a/apps/cli/src/commands/dashboard.test.ts +++ b/apps/cli/src/commands/dashboard.test.ts @@ -58,8 +58,8 @@ describe("dashboard", () => { mockClient.getIssues.mockResolvedValue(sampleIssues); mockClient.getProjects.mockResolvedValue(sampleProjects); - const { dashboard } = await import("./dashboard"); - await dashboard.run?.({ args: {} } as never); + const { default: dashboard } = await import("./dashboard"); + await dashboard.parseAsync([], { from: "user" }); expect(mockClient.getMyself).toHaveBeenCalled(); expect(mockClient.getNotificationsCount).toHaveBeenCalledWith({ @@ -81,8 +81,8 @@ describe("dashboard", () => { }); it("opens browser with --web flag", async () => { - const { dashboard } = await import("./dashboard"); - await dashboard.run?.({ args: { web: true } } as never); + const { default: dashboard } = await import("./dashboard"); + await dashboard.parseAsync(["--web"], { from: "user" }); expect(openOrPrintUrl).toHaveBeenCalledWith( "https://example.backlog.com/dashboard", @@ -99,8 +99,8 @@ describe("dashboard", () => { mockClient.getProjects.mockResolvedValue(sampleProjects); await expectStdoutContaining(async () => { - const { dashboard } = await import("./dashboard"); - await dashboard.run?.({ args: { json: "" } } as never); + const { default: dashboard } = await import("./dashboard"); + await dashboard.parseAsync(["--json"], { from: "user" }); }, "Test User"); }); @@ -110,8 +110,8 @@ describe("dashboard", () => { mockClient.getIssues.mockResolvedValue([]); mockClient.getProjects.mockResolvedValue([]); - const { dashboard } = await import("./dashboard"); - await dashboard.run?.({ args: {} } as never); + const { default: dashboard } = await import("./dashboard"); + await dashboard.parseAsync([], { from: "user" }); expect(consola.log).toHaveBeenCalledWith(expect.stringContaining("No assigned issues")); }); diff --git a/apps/cli/src/commands/dashboard.ts b/apps/cli/src/commands/dashboard.ts index 493bbb87..b630f5a0 100644 --- a/apps/cli/src/commands/dashboard.ts +++ b/apps/cli/src/commands/dashboard.ts @@ -1,104 +1,91 @@ import { dashboardUrl, getClient, openOrPrintUrl } from "@repo/backlog-utils"; -import { type Row, formatDate, outputArgs, outputResult, printTable } from "@repo/cli-utils"; -import { defineCommand } from "citty"; +import { type Row, formatDate, outputResult, printTable } from "@repo/cli-utils"; import consola from "consola"; -import { type CommandUsage, ENV_AUTH, withUsage } from "../lib/command-usage"; -import * as commonArgs from "../lib/common-args"; +import { BeeCommand, ENV_AUTH } from "../lib/bee-command"; +import * as opt from "../lib/common-options"; -const commandUsage: CommandUsage = { - long: `Show a summary of your Backlog activity. +const dashboard = new BeeCommand("dashboard") + .summary("Show a summary of your Backlog activity") + .description( + `Show a summary of your Backlog activity. Displays your assigned issues sorted by due date, unread notification count, and your projects. The layout is modeled after the Backlog web dashboard. Use \`--web\` to open the Backlog dashboard in your browser instead.`, - - examples: [ + ) + .addOption(opt.json()) + .addOption(opt.web("dashboard")) + .addOption(opt.noBrowser()) + .envVars([...ENV_AUTH]) + .examples([ { description: "Show dashboard", command: "bee dashboard" }, { description: "Open dashboard in browser", command: "bee dashboard --web" }, { description: "Output as JSON", command: "bee dashboard --json" }, - ], - - annotations: { - environment: [...ENV_AUTH], - }, -}; - -const dashboard = withUsage( - defineCommand({ - meta: { - name: "dashboard", - description: "Show a summary of your Backlog activity", - }, - args: { - ...outputArgs, - web: commonArgs.web("dashboard"), - "no-browser": commonArgs.noBrowser, - }, - async run({ args }) { - const { client, host } = await getClient(); - - if (args.web || args["no-browser"]) { - const url = dashboardUrl(host); - await openOrPrintUrl(url, Boolean(args["no-browser"]), consola); - return; + ]) + .action(async (opts) => { + const { client, host } = await getClient(); + + if (opts.web || opts.browser === false) { + const url = dashboardUrl(host); + await openOrPrintUrl(url, opts.browser === false, consola); + return; + } + + const [myself, notifications, issues, projects] = await Promise.all([ + client.getMyself(), + client.getNotificationsCount({ alreadyRead: false, resourceAlreadyRead: false }), + client.getIssues({ + assigneeId: [-1], + statusId: [1, 2, 3], + count: 20, + sort: "dueDate", + order: "asc", + }), + client.getProjects({ all: false }), + ]); + + const result = { myself, notifications, issues, projects }; + + // commander gives `true` for bare --json, a string for --json fields + const jsonVal = opts.json === true ? "" : opts.json; + outputResult(result, { json: jsonVal } as { json?: string }, (data) => { + consola.log(""); + consola.log(` ${data.myself.name} (${host})`); + + // Unread notifications + if (data.notifications.count > 0) { + consola.log(` Unread notifications: ${data.notifications.count}`); } - const [myself, notifications, issues, projects] = await Promise.all([ - client.getMyself(), - client.getNotificationsCount({ alreadyRead: false, resourceAlreadyRead: false }), - client.getIssues({ - assigneeId: [-1], - statusId: [1, 2, 3], - count: 20, - sort: "dueDate", - order: "asc", - }), - client.getProjects({ all: false }), - ]); - - const result = { myself, notifications, issues, projects }; - - outputResult(result, args, (data) => { - consola.log(""); - consola.log(` ${data.myself.name} (${host})`); - - // Unread notifications - if (data.notifications.count > 0) { - consola.log(` Unread notifications: ${data.notifications.count}`); - } + // Assigned issues table + consola.log(""); + consola.log(" Assigned Issues:"); + + if (data.issues.length === 0) { + consola.log(" No assigned issues."); + } else { + const issueRows: Row[] = data.issues.map((issue) => [ + { header: "KEY", value: issue.issueKey }, + { header: "SUMMARY", value: issue.summary }, + { header: "STATUS", value: issue.status?.name ?? "" }, + { header: "PRIORITY", value: issue.priority?.name ?? "" }, + { header: "DUE DATE", value: issue.dueDate ? formatDate(issue.dueDate) : "" }, + ]); + printTable(issueRows); + } - // Assigned issues table + // Projects list + if (data.projects.length > 0) { consola.log(""); - consola.log(" Assigned Issues:"); - - if (data.issues.length === 0) { - consola.log(" No assigned issues."); - } else { - const issueRows: Row[] = data.issues.map((issue) => [ - { header: "KEY", value: issue.issueKey }, - { header: "SUMMARY", value: issue.summary }, - { header: "STATUS", value: issue.status?.name ?? "" }, - { header: "PRIORITY", value: issue.priority?.name ?? "" }, - { header: "DUE DATE", value: issue.dueDate ? formatDate(issue.dueDate) : "" }, - ]); - printTable(issueRows); - } - - // Projects list - if (data.projects.length > 0) { - consola.log(""); - consola.log(" Projects:"); - for (const project of data.projects) { - consola.log(` ${project.projectKey.padEnd(20)} ${project.name}`); - } + consola.log(" Projects:"); + for (const project of data.projects) { + consola.log(` ${project.projectKey.padEnd(20)} ${project.name}`); } + } - consola.log(""); - }); - }, - }), - commandUsage, -); + consola.log(""); + }); + }); -export { commandUsage, dashboard }; +export default dashboard; diff --git a/apps/cli/src/lib/common-options.ts b/apps/cli/src/lib/common-options.ts index 46bbf842..fffe6fe7 100644 --- a/apps/cli/src/lib/common-options.ts +++ b/apps/cli/src/lib/common-options.ts @@ -16,16 +16,14 @@ const maxId = () => new Option("--max-id <n>", "Maximum ID for cursor-based pagi const keyword = () => new Option("-k, --keyword <text>", "Keyword search"); const assignee = () => new Option("-a, --assignee <id>", "Assignee user ID. Use @me for yourself."); const assigneeList = () => - new Option( - "-a, --assignee <id>", - "Assignee user ID (repeatable). Use @me for yourself.", - collect, - [], - ); + new Option("-a, --assignee <id>", "Assignee user ID (repeatable). Use @me for yourself.") + .argParser(collect) + .default([]); const issue = () => new Option("--issue <key>", "Issue ID or issue key"); -const notify = () => new Option("--notify <id>", "User IDs to notify (repeatable)", collectNum, []); +const notify = () => + new Option("--notify <id>", "User IDs to notify (repeatable)").argParser(collectNum).default([]); const attachment = () => - new Option("--attachment <id>", "Attachment IDs (repeatable)", collectNum, []); + new Option("--attachment <id>", "Attachment IDs (repeatable)").argParser(collectNum).default([]); const comment = () => new Option("-c, --comment <text>", "Comment to add with the update"); const web = (resource: string) => new Option("-w, --web", `Open the ${resource} in the browser`); const noBrowser = () => @@ -34,7 +32,7 @@ const json = () => new Option( "--json [fields]", "Output as JSON (optionally filter by field names, comma-separated)", - ); + ).preset(""); export { collect, From 02c866bedbf45e8e0eb91f221bce6e60b5a77542 Mon Sep 17 00:00:00 2001 From: Ryoya Tamura <ryoya.tamura@nulab.com> Date: Sun, 8 Mar 2026 18:53:24 +0900 Subject: [PATCH 09/16] refactor(cli): migrate remaining command groups and remove citty dependency - Migrate repo, space, document, category, milestone, status, notification, and issue-type commands from citty to commander - Delete legacy command-usage.ts and common-args.ts - Remove citty dependency - Change resolveOptions to void (side-effect only) to avoid unnecessary type assertions on opts - Remove all redundant `as` casts from command files - Fix Option naming conflict in issue/list.ts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --- apps/cli/package.json | 1 - apps/cli/src/commands/category/create.test.ts | 20 +- apps/cli/src/commands/category/create.ts | 67 ++--- apps/cli/src/commands/category/delete.test.ts | 18 +- apps/cli/src/commands/category/delete.ts | 86 ++---- apps/cli/src/commands/category/edit.test.ts | 20 +- apps/cli/src/commands/category/edit.ts | 74 ++--- apps/cli/src/commands/category/list.test.ts | 12 +- apps/cli/src/commands/category/list.ts | 73 ++--- .../src/commands/document/attachments.test.ts | 16 +- apps/cli/src/commands/document/attachments.ts | 100 +++---- apps/cli/src/commands/document/create.test.ts | 39 +-- apps/cli/src/commands/document/create.ts | 106 +++---- apps/cli/src/commands/document/delete.test.ts | 16 +- apps/cli/src/commands/document/delete.ts | 81 ++--- apps/cli/src/commands/document/list.test.ts | 31 +- apps/cli/src/commands/document/list.ts | 120 +++----- apps/cli/src/commands/document/tree.test.ts | 24 +- apps/cli/src/commands/document/tree.ts | 83 ++---- apps/cli/src/commands/document/view.test.ts | 34 +-- apps/cli/src/commands/document/view.ts | 135 ++++----- .../src/commands/issue-type/create.test.ts | 26 +- apps/cli/src/commands/issue-type/create.ts | 84 ++---- .../src/commands/issue-type/delete.test.ts | 40 ++- apps/cli/src/commands/issue-type/delete.ts | 110 +++---- apps/cli/src/commands/issue-type/edit.test.ts | 14 +- apps/cli/src/commands/issue-type/edit.ts | 94 +++--- apps/cli/src/commands/issue-type/list.test.ts | 16 +- apps/cli/src/commands/issue-type/list.ts | 75 ++--- apps/cli/src/commands/issue/close.ts | 4 +- apps/cli/src/commands/issue/comment.ts | 2 +- apps/cli/src/commands/issue/count.ts | 22 +- apps/cli/src/commands/issue/create.ts | 22 +- apps/cli/src/commands/issue/edit.ts | 4 +- apps/cli/src/commands/issue/list.ts | 30 +- apps/cli/src/commands/issue/reopen.ts | 2 +- .../cli/src/commands/milestone/create.test.ts | 44 +-- apps/cli/src/commands/milestone/create.ts | 103 +++---- .../cli/src/commands/milestone/delete.test.ts | 18 +- apps/cli/src/commands/milestone/delete.ts | 86 ++---- apps/cli/src/commands/milestone/edit.test.ts | 38 +-- apps/cli/src/commands/milestone/edit.ts | 114 +++---- apps/cli/src/commands/milestone/list.test.ts | 16 +- apps/cli/src/commands/milestone/list.ts | 89 +++--- .../src/commands/notification/count.test.ts | 24 +- apps/cli/src/commands/notification/count.ts | 96 +++--- .../src/commands/notification/list.test.ts | 24 +- apps/cli/src/commands/notification/list.ts | 109 +++---- .../commands/notification/read-all.test.ts | 4 +- .../cli/src/commands/notification/read-all.ts | 44 +-- .../src/commands/notification/read.test.ts | 8 +- apps/cli/src/commands/notification/read.ts | 52 +--- apps/cli/src/commands/pr/comment.ts | 55 ++-- apps/cli/src/commands/pr/comments.ts | 23 +- apps/cli/src/commands/pr/count.ts | 22 +- apps/cli/src/commands/pr/create.ts | 26 +- apps/cli/src/commands/pr/edit.ts | 34 +-- apps/cli/src/commands/pr/list.ts | 22 +- apps/cli/src/commands/pr/status.ts | 8 +- apps/cli/src/commands/pr/view.ts | 14 +- apps/cli/src/commands/project/activities.ts | 10 +- apps/cli/src/commands/project/add-user.ts | 11 +- apps/cli/src/commands/project/delete.ts | 10 +- apps/cli/src/commands/project/edit.ts | 24 +- apps/cli/src/commands/project/remove-user.ts | 11 +- apps/cli/src/commands/project/users.ts | 8 +- apps/cli/src/commands/project/view.ts | 8 +- apps/cli/src/commands/repo/clone.test.ts | 28 +- apps/cli/src/commands/repo/clone.ts | 121 +++----- apps/cli/src/commands/repo/list.test.ts | 12 +- apps/cli/src/commands/repo/list.ts | 91 +++--- apps/cli/src/commands/repo/view.test.ts | 14 +- apps/cli/src/commands/repo/view.ts | 109 +++---- .../cli/src/commands/space/activities.test.ts | 24 +- apps/cli/src/commands/space/activities.ts | 110 +++---- .../cli/src/commands/space/disk-usage.test.ts | 8 +- apps/cli/src/commands/space/disk-usage.ts | 80 +++-- apps/cli/src/commands/space/info.test.ts | 8 +- apps/cli/src/commands/space/info.ts | 84 +++--- .../src/commands/space/notification.test.ts | 12 +- apps/cli/src/commands/space/notification.ts | 80 +++-- apps/cli/src/commands/status/create.test.ts | 28 +- apps/cli/src/commands/status/create.ts | 84 ++---- apps/cli/src/commands/status/delete.test.ts | 39 ++- apps/cli/src/commands/status/delete.ts | 111 +++---- apps/cli/src/commands/status/edit.test.ts | 12 +- apps/cli/src/commands/status/edit.ts | 94 +++--- apps/cli/src/commands/status/list.test.ts | 16 +- apps/cli/src/commands/status/list.ts | 75 ++--- apps/cli/src/commands/user/activities.ts | 2 +- apps/cli/src/commands/webhook/create.ts | 16 +- apps/cli/src/commands/webhook/delete.ts | 10 +- apps/cli/src/commands/webhook/edit.ts | 16 +- apps/cli/src/commands/webhook/list.ts | 8 +- apps/cli/src/commands/webhook/view.ts | 8 +- apps/cli/src/commands/wiki/count.ts | 8 +- apps/cli/src/commands/wiki/history.ts | 2 +- apps/cli/src/commands/wiki/list.ts | 10 +- apps/cli/src/commands/wiki/tags.ts | 8 +- apps/cli/src/lib/command-usage.ts | 280 ------------------ apps/cli/src/lib/common-args.ts | 172 ----------- apps/cli/src/lib/required-option.test.ts | 8 +- apps/cli/src/lib/required-option.ts | 3 +- pnpm-lock.yaml | 8 - 104 files changed, 1712 insertions(+), 2943 deletions(-) delete mode 100644 apps/cli/src/lib/command-usage.ts delete mode 100644 apps/cli/src/lib/common-args.ts diff --git a/apps/cli/package.json b/apps/cli/package.json index 1d6976a9..aabfefc2 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -41,7 +41,6 @@ "@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", diff --git a/apps/cli/src/commands/category/create.test.ts b/apps/cli/src/commands/category/create.test.ts index 1c3cceb6..3af8d217 100644 --- a/apps/cli/src/commands/category/create.test.ts +++ b/apps/cli/src/commands/category/create.test.ts @@ -13,41 +13,41 @@ vi.mock("@repo/backlog-utils", () => ({ vi.mock("@repo/cli-utils", async (importOriginal) => ({ ...(await importOriginal()), - promptRequired: vi.fn(), + promptRequired: vi.fn((_, val) => Promise.resolve(val)), })); vi.mock("consola", () => import("@repo/test-utils/mock-consola")); describe("category create", () => { it("creates a category with provided name", async () => { - vi.mocked(promptRequired).mockResolvedValueOnce("Bug Report"); mockClient.postCategories.mockResolvedValue({ id: 1, name: "Bug Report" }); - const { create } = await import("./create"); - await create.run?.({ args: { project: "TEST", name: "Bug Report" } } as never); + const { default: create } = await import("./create"); + await create.parseAsync(["-p", "TEST", "-n", "Bug Report"], { from: "user" }); expect(mockClient.postCategories).toHaveBeenCalledWith("TEST", { name: "Bug Report" }); expect(consola.success).toHaveBeenCalledWith("Created category Bug Report (ID: 1)"); }); it("prompts for name when not provided", async () => { - vi.mocked(promptRequired).mockResolvedValueOnce("Prompted Category"); + vi.mocked(promptRequired) + .mockResolvedValueOnce("TEST") + .mockResolvedValueOnce("Prompted Category"); mockClient.postCategories.mockResolvedValue({ id: 2, name: "Prompted Category" }); - const { create } = await import("./create"); - await create.run?.({ args: { project: "TEST" } } as never); + const { default: create } = await import("./create"); + await create.parseAsync(["-p", "TEST"], { from: "user" }); expect(promptRequired).toHaveBeenCalledWith("Category name:", undefined); expect(mockClient.postCategories).toHaveBeenCalledWith("TEST", { name: "Prompted Category" }); }); it("outputs JSON when --json flag is set", async () => { - vi.mocked(promptRequired).mockResolvedValueOnce("Bug"); mockClient.postCategories.mockResolvedValue({ id: 1, name: "Bug" }); await expectStdoutContaining(async () => { - const { create } = await import("./create"); - await create.run?.({ args: { project: "TEST", name: "Bug", json: "" } } as never); + const { default: create } = await import("./create"); + await create.parseAsync(["-p", "TEST", "-n", "Bug", "--json"], { from: "user" }); }, "Bug"); }); }); diff --git a/apps/cli/src/commands/category/create.ts b/apps/cli/src/commands/category/create.ts index 6cc83fcd..96db5dd6 100644 --- a/apps/cli/src/commands/category/create.ts +++ b/apps/cli/src/commands/category/create.ts @@ -1,53 +1,36 @@ import { getClient } from "@repo/backlog-utils"; -import { outputArgs, outputResult, promptRequired } from "@repo/cli-utils"; -import { defineCommand } from "citty"; +import { outputResult, promptRequired } from "@repo/cli-utils"; import consola from "consola"; -import { type CommandUsage, ENV_AUTH, ENV_PROJECT, withUsage } from "../../lib/command-usage"; -import * as commonArgs from "../../lib/common-args"; +import { BeeCommand, ENV_AUTH, ENV_PROJECT } from "../../lib/bee-command"; +import * as opt from "../../lib/common-options"; +import { resolveOptions } from "../../lib/required-option"; -const commandUsage: CommandUsage = { - long: `Create a new category in a Backlog project. +const create = new BeeCommand("create") + .summary("Create a category") + .description( + `Create a new category in a Backlog project. If \`--name\` is not provided, you will be prompted interactively.`, - - examples: [ + ) + .addOption(opt.project()) + .option("-n, --name <value>", "Category name") + .addOption(opt.json()) + .envVars([...ENV_AUTH, ENV_PROJECT]) + .examples([ { description: "Create a category", command: 'bee category create -p PROJECT -n "Bug Report"' }, { description: "Create interactively", command: "bee category create -p PROJECT" }, - ], - - annotations: { - environment: [...ENV_AUTH, ENV_PROJECT], - }, -}; - -const create = withUsage( - defineCommand({ - meta: { - name: "create", - description: "Create a category", - }, - args: { - ...outputArgs, - project: { ...commonArgs.project, required: true }, - name: { - type: "string", - alias: "n", - description: "Category name", - }, - }, - async run({ args }) { - const { client } = await getClient(); + ]) + .action(async (opts, cmd) => { + await resolveOptions(cmd); + const { client } = await getClient(); - const name = await promptRequired("Category name:", args.name); + const name = await promptRequired("Category name:", opts.name); - const category = await client.postCategories(args.project, { name }); + const category = await client.postCategories(opts.project, { name }); - outputResult(category, args, (data) => { - consola.success(`Created category ${data.name} (ID: ${data.id})`); - }); - }, - }), - commandUsage, -); + outputResult(category, opts, (data) => { + consola.success(`Created category ${data.name} (ID: ${data.id})`); + }); + }); -export { commandUsage, create }; +export default create; diff --git a/apps/cli/src/commands/category/delete.test.ts b/apps/cli/src/commands/category/delete.test.ts index c76206fd..65aa47c2 100644 --- a/apps/cli/src/commands/category/delete.test.ts +++ b/apps/cli/src/commands/category/delete.test.ts @@ -23,8 +23,8 @@ describe("category delete", () => { vi.mocked(confirmOrExit).mockResolvedValue(true); mockClient.deleteCategories.mockResolvedValue({ id: 1, name: "Bug" }); - const { deleteCategory } = await import("./delete"); - await deleteCategory.run?.({ args: { category: "1", project: "TEST" } } as never); + const { default: deleteCategory } = await import("./delete"); + await deleteCategory.parseAsync(["1", "-p", "TEST"], { from: "user" }); expect(confirmOrExit).toHaveBeenCalledWith( "Are you sure you want to delete category 1? This cannot be undone.", @@ -38,8 +38,8 @@ describe("category delete", () => { vi.mocked(confirmOrExit).mockResolvedValue(true); mockClient.deleteCategories.mockResolvedValue({ id: 1, name: "Bug" }); - const { deleteCategory } = await import("./delete"); - await deleteCategory.run?.({ args: { category: "1", project: "TEST", yes: true } } as never); + const { default: deleteCategory } = await import("./delete"); + await deleteCategory.parseAsync(["1", "-p", "TEST", "--yes"], { from: "user" }); expect(confirmOrExit).toHaveBeenCalledWith( "Are you sure you want to delete category 1? This cannot be undone.", @@ -50,8 +50,8 @@ describe("category delete", () => { it("cancels when user declines confirmation", async () => { vi.mocked(confirmOrExit).mockResolvedValue(false); - const { deleteCategory } = await import("./delete"); - await deleteCategory.run?.({ args: { category: "1", project: "TEST" } } as never); + const { default: deleteCategory } = await import("./delete"); + await deleteCategory.parseAsync(["1", "-p", "TEST"], { from: "user" }); expect(mockClient.deleteCategories).not.toHaveBeenCalled(); }); @@ -61,10 +61,8 @@ describe("category delete", () => { mockClient.deleteCategories.mockResolvedValue({ id: 1, name: "Bug" }); await expectStdoutContaining(async () => { - const { deleteCategory } = await import("./delete"); - await deleteCategory.run?.({ - args: { category: "1", project: "TEST", yes: true, json: "" }, - } as never); + const { default: deleteCategory } = await import("./delete"); + await deleteCategory.parseAsync(["1", "-p", "TEST", "--yes", "--json"], { from: "user" }); }, "Bug"); }); }); diff --git a/apps/cli/src/commands/category/delete.ts b/apps/cli/src/commands/category/delete.ts index 6575e03e..e4ca25e3 100644 --- a/apps/cli/src/commands/category/delete.ts +++ b/apps/cli/src/commands/category/delete.ts @@ -1,17 +1,24 @@ import { getClient } from "@repo/backlog-utils"; -import { confirmOrExit, outputArgs, outputResult } from "@repo/cli-utils"; -import { defineCommand } from "citty"; +import { confirmOrExit, outputResult } from "@repo/cli-utils"; import consola from "consola"; -import { type CommandUsage, ENV_AUTH, ENV_PROJECT, withUsage } from "../../lib/command-usage"; -import * as commonArgs from "../../lib/common-args"; +import { BeeCommand, ENV_AUTH, ENV_PROJECT } from "../../lib/bee-command"; +import * as opt from "../../lib/common-options"; +import { resolveOptions } from "../../lib/required-option"; -const commandUsage: CommandUsage = { - long: `Delete a category from a Backlog project. +const deleteCategory = new BeeCommand("delete") + .summary("Delete a category") + .description( + `Delete a category from a Backlog project. This action is irreversible. You will be prompted for confirmation unless \`--yes\` is provided.`, - - examples: [ + ) + .argument("<category>", "Category ID") + .addOption(opt.project()) + .option("-y, --yes", "Skip confirmation prompt") + .addOption(opt.json()) + .envVars([...ENV_AUTH, ENV_PROJECT]) + .examples([ { description: "Delete a category (with confirmation)", command: "bee category delete 12345 -p PROJECT", @@ -20,54 +27,25 @@ This action is irreversible. You will be prompted for confirmation unless description: "Delete without confirmation", command: "bee category delete 12345 -p PROJECT --yes", }, - ], - - annotations: { - environment: [...ENV_AUTH, ENV_PROJECT], - }, -}; - -const deleteCategory = withUsage( - defineCommand({ - meta: { - name: "delete", - description: "Delete a category", - }, - args: { - ...outputArgs, - category: { - type: "positional", - description: "Category ID", - required: true, - valueHint: "<number>", - }, - project: { ...commonArgs.project, required: true }, - yes: { - type: "boolean", - alias: "y", - description: "Skip confirmation prompt", - }, - }, - async run({ args }) { - const confirmed = await confirmOrExit( - `Are you sure you want to delete category ${args.category}? This cannot be undone.`, - args.yes, - ); + ]) + .action(async (category, opts, cmd) => { + await resolveOptions(cmd); + const confirmed = await confirmOrExit( + `Are you sure you want to delete category ${category}? This cannot be undone.`, + opts.yes, + ); - if (!confirmed) { - return; - } + if (!confirmed) { + return; + } - const { client } = await getClient(); + const { client } = await getClient(); - const category = await client.deleteCategories(args.project, Number(args.category)); + const result = await client.deleteCategories(opts.project, Number(category)); - outputResult(category, args, (data) => { - consola.success(`Deleted category ${data.name} (ID: ${data.id})`); - }); - }, - }), - commandUsage, -); + outputResult(result, opts, (data) => { + consola.success(`Deleted category ${data.name} (ID: ${data.id})`); + }); + }); -export { commandUsage, deleteCategory }; +export default deleteCategory; diff --git a/apps/cli/src/commands/category/edit.test.ts b/apps/cli/src/commands/category/edit.test.ts index fa424d33..8561c6c9 100644 --- a/apps/cli/src/commands/category/edit.test.ts +++ b/apps/cli/src/commands/category/edit.test.ts @@ -13,42 +13,38 @@ vi.mock("@repo/backlog-utils", () => ({ vi.mock("@repo/cli-utils", async (importOriginal) => ({ ...(await importOriginal()), - promptRequired: vi.fn(), + promptRequired: vi.fn((_, val) => Promise.resolve(val)), })); vi.mock("consola", () => import("@repo/test-utils/mock-consola")); describe("category edit", () => { it("updates category name", async () => { - vi.mocked(promptRequired).mockResolvedValueOnce("New Name"); mockClient.patchCategories.mockResolvedValue({ id: 1, name: "New Name" }); - const { edit } = await import("./edit"); - await edit.run?.({ args: { category: "1", project: "TEST", name: "New Name" } } as never); + const { default: edit } = await import("./edit"); + await edit.parseAsync(["1", "-p", "TEST", "-n", "New Name"], { from: "user" }); expect(mockClient.patchCategories).toHaveBeenCalledWith("TEST", 1, { name: "New Name" }); expect(consola.success).toHaveBeenCalledWith("Updated category New Name (ID: 1)"); }); it("prompts for name when not provided", async () => { - vi.mocked(promptRequired).mockResolvedValueOnce("Prompted Name"); + vi.mocked(promptRequired).mockResolvedValueOnce("TEST").mockResolvedValueOnce("Prompted Name"); mockClient.patchCategories.mockResolvedValue({ id: 1, name: "Prompted Name" }); - const { edit } = await import("./edit"); - await edit.run?.({ args: { category: "1", project: "TEST" } } as never); + const { default: edit } = await import("./edit"); + await edit.parseAsync(["1", "-p", "TEST"], { from: "user" }); expect(promptRequired).toHaveBeenCalledWith("Category name:", undefined); }); it("outputs JSON when --json flag is set", async () => { - vi.mocked(promptRequired).mockResolvedValueOnce("Name"); mockClient.patchCategories.mockResolvedValue({ id: 1, name: "Name" }); await expectStdoutContaining(async () => { - const { edit } = await import("./edit"); - await edit.run?.({ - args: { category: "1", project: "TEST", name: "Name", json: "" }, - } as never); + const { default: edit } = await import("./edit"); + await edit.parseAsync(["1", "-p", "TEST", "-n", "Name", "--json"], { from: "user" }); }, "Name"); }); }); diff --git a/apps/cli/src/commands/category/edit.ts b/apps/cli/src/commands/category/edit.ts index 9e87804b..9452e022 100644 --- a/apps/cli/src/commands/category/edit.ts +++ b/apps/cli/src/commands/category/edit.ts @@ -1,61 +1,39 @@ import { getClient } from "@repo/backlog-utils"; -import { outputArgs, outputResult, promptRequired } from "@repo/cli-utils"; -import { defineCommand } from "citty"; +import { outputResult, promptRequired } from "@repo/cli-utils"; import consola from "consola"; -import { type CommandUsage, ENV_AUTH, ENV_PROJECT, withUsage } from "../../lib/command-usage"; -import * as commonArgs from "../../lib/common-args"; +import { BeeCommand, ENV_AUTH, ENV_PROJECT } from "../../lib/bee-command"; +import * as opt from "../../lib/common-options"; +import { resolveOptions } from "../../lib/required-option"; -const commandUsage: CommandUsage = { - long: `Update an existing category in a Backlog project. +const edit = new BeeCommand("edit") + .summary("Edit a category") + .description( + `Update an existing category in a Backlog project. Renames the specified category.`, - - examples: [ + ) + .argument("<category>", "Category ID") + .addOption(opt.project()) + .option("-n, --name <value>", "New name of the category") + .addOption(opt.json()) + .envVars([...ENV_AUTH, ENV_PROJECT]) + .examples([ { description: "Rename a category", command: 'bee category edit 12345 -p PROJECT -n "New Name"', }, - ], - - annotations: { - environment: [...ENV_AUTH, ENV_PROJECT], - }, -}; - -const edit = withUsage( - defineCommand({ - meta: { - name: "edit", - description: "Edit a category", - }, - args: { - ...outputArgs, - category: { - type: "positional", - description: "Category ID", - required: true, - valueHint: "<number>", - }, - project: { ...commonArgs.project, required: true }, - name: { - type: "string", - alias: "n", - description: "New name of the category", - }, - }, - async run({ args }) { - const { client } = await getClient(); + ]) + .action(async (category, opts, cmd) => { + await resolveOptions(cmd); + const { client } = await getClient(); - const name = await promptRequired("Category name:", args.name); + const name = await promptRequired("Category name:", opts.name); - const category = await client.patchCategories(args.project, Number(args.category), { name }); + const result = await client.patchCategories(opts.project, Number(category), { name }); - outputResult(category, args, (data) => { - consola.success(`Updated category ${data.name} (ID: ${data.id})`); - }); - }, - }), - commandUsage, -); + outputResult(result, opts, (data) => { + consola.success(`Updated category ${data.name} (ID: ${data.id})`); + }); + }); -export { commandUsage, edit }; +export default edit; diff --git a/apps/cli/src/commands/category/list.test.ts b/apps/cli/src/commands/category/list.test.ts index c5fe4c07..ce290e5f 100644 --- a/apps/cli/src/commands/category/list.test.ts +++ b/apps/cli/src/commands/category/list.test.ts @@ -22,8 +22,8 @@ describe("category list", () => { it("displays category list in tabular format", async () => { mockClient.getCategories.mockResolvedValue(sampleCategories); - const { list } = await import("./list"); - await list.run?.({ args: { project: "TEST" } } as never); + const { default: list } = await import("./list"); + await list.parseAsync(["TEST"], { from: "user" }); expect(getClient).toHaveBeenCalled(); expect(mockClient.getCategories).toHaveBeenCalledWith("TEST"); @@ -35,8 +35,8 @@ describe("category list", () => { it("shows message when no categories found", async () => { mockClient.getCategories.mockResolvedValue([]); - const { list } = await import("./list"); - await list.run?.({ args: { project: "TEST" } } as never); + const { default: list } = await import("./list"); + await list.parseAsync(["TEST"], { from: "user" }); expect(consola.info).toHaveBeenCalledWith("No categories found."); }); @@ -45,8 +45,8 @@ describe("category list", () => { mockClient.getCategories.mockResolvedValue(sampleCategories); await expectStdoutContaining(async () => { - const { list } = await import("./list"); - await list.run?.({ args: { project: "TEST", json: "" } } as never); + const { default: list } = await import("./list"); + await list.parseAsync(["TEST", "--json"], { from: "user" }); }, "Bug"); }); }); diff --git a/apps/cli/src/commands/category/list.ts b/apps/cli/src/commands/category/list.ts index b0fd970e..fabe9704 100644 --- a/apps/cli/src/commands/category/list.ts +++ b/apps/cli/src/commands/category/list.ts @@ -1,56 +1,41 @@ import { getClient } from "@repo/backlog-utils"; -import { type Row, outputArgs, outputResult, printTable } from "@repo/cli-utils"; -import { defineCommand } from "citty"; +import { type Row, outputResult, printTable } from "@repo/cli-utils"; import consola from "consola"; -import { type CommandUsage, ENV_AUTH, ENV_PROJECT, withUsage } from "../../lib/command-usage"; -import * as commonArgs from "../../lib/common-args"; +import { BeeCommand, ENV_AUTH, ENV_PROJECT } from "../../lib/bee-command"; +import * as opt from "../../lib/common-options"; -const commandUsage: CommandUsage = { - long: `List categories in a Backlog project. +const list = new BeeCommand("list") + .summary("List categories") + .description( + `List categories in a Backlog project. Categories help organize issues by grouping them into logical areas.`, - - examples: [ + ) + .argument("[project]", "Project ID or project key") + .addOption(opt.json()) + .envVars([...ENV_AUTH, ENV_PROJECT]) + .examples([ { description: "List all categories in a project", command: "bee category list PROJECT" }, { description: "Output as JSON", command: "bee category list PROJECT --json" }, - ], - - annotations: { - environment: [...ENV_AUTH, ENV_PROJECT], - }, -}; - -const list = withUsage( - defineCommand({ - meta: { - name: "list", - description: "List categories", - }, - args: { - ...outputArgs, - project: commonArgs.projectPositional, - }, - async run({ args }) { - const { client } = await getClient(); + ]) + .action(async (project, opts) => { + const { client } = await getClient(); - const categories = await client.getCategories(args.project); + const categories = await client.getCategories(project); - outputResult(categories, args, (data) => { - if (data.length === 0) { - consola.info("No categories found."); - return; - } + outputResult(categories, opts, (data) => { + if (data.length === 0) { + consola.info("No categories found."); + return; + } - const rows: Row[] = data.map((c) => [ - { header: "ID", value: String(c.id) }, - { header: "NAME", value: c.name }, - ]); + const rows: Row[] = data.map((c) => [ + { header: "ID", value: String(c.id) }, + { header: "NAME", value: c.name }, + ]); - printTable(rows); - }); - }, - }), - commandUsage, -); + printTable(rows); + }); + }); -export { commandUsage, list }; +export default list; diff --git a/apps/cli/src/commands/document/attachments.test.ts b/apps/cli/src/commands/document/attachments.test.ts index b3723c3e..5f506bf1 100644 --- a/apps/cli/src/commands/document/attachments.test.ts +++ b/apps/cli/src/commands/document/attachments.test.ts @@ -36,8 +36,8 @@ describe("document attachments", () => { attachments: sampleAttachments, }); - const { attachments } = await import("./attachments"); - await attachments.run?.({ args: { document: "doc-1" } } as never); + const { default: attachments } = await import("./attachments"); + await attachments.parseAsync(["doc-1"], { from: "user" }); expect(mockClient.getDocument).toHaveBeenCalledWith("doc-1"); expect(consola.log).toHaveBeenCalledWith(expect.stringContaining("NAME")); @@ -52,8 +52,8 @@ describe("document attachments", () => { attachments: [], }); - const { attachments } = await import("./attachments"); - await attachments.run?.({ args: { document: "doc-1" } } as never); + const { default: attachments } = await import("./attachments"); + await attachments.parseAsync(["doc-1"], { from: "user" }); expect(consola.info).toHaveBeenCalledWith("No attachments found."); }); @@ -86,8 +86,8 @@ describe("document attachments", () => { ], }); - const { attachments } = await import("./attachments"); - await attachments.run?.({ args: { document: "doc-1" } } as never); + const { default: attachments } = await import("./attachments"); + await attachments.parseAsync(["doc-1"], { from: "user" }); expect(consola.log).toHaveBeenCalledWith(expect.stringContaining("500 B")); expect(consola.log).toHaveBeenCalledWith(expect.stringContaining("5.0 KB")); @@ -101,8 +101,8 @@ describe("document attachments", () => { }); await expectStdoutContaining(async () => { - const { attachments } = await import("./attachments"); - await attachments.run?.({ args: { document: "doc-1", json: "" } } as never); + const { default: attachments } = await import("./attachments"); + await attachments.parseAsync(["doc-1", "--json"], { from: "user" }); }, "report.pdf"); }); }); diff --git a/apps/cli/src/commands/document/attachments.ts b/apps/cli/src/commands/document/attachments.ts index 0a9ba8a0..5c7d1935 100644 --- a/apps/cli/src/commands/document/attachments.ts +++ b/apps/cli/src/commands/document/attachments.ts @@ -1,70 +1,44 @@ import { getClient } from "@repo/backlog-utils"; -import { - type Row, - formatDate, - formatSize, - outputArgs, - outputResult, - printTable, -} from "@repo/cli-utils"; -import { defineCommand } from "citty"; +import { type Row, formatDate, formatSize, outputResult, printTable } from "@repo/cli-utils"; import consola from "consola"; -import { type CommandUsage, ENV_AUTH, withUsage } from "../../lib/command-usage"; +import { BeeCommand, ENV_AUTH } from "../../lib/bee-command"; +import * as opt from "../../lib/common-options"; -const commandUsage: CommandUsage = { - long: `List attachments of a Backlog document. +const attachments = new BeeCommand("attachments") + .summary("List document attachments") + .description( + `List attachments of a Backlog document. Shows file name, size, creator, and creation date.`, - - examples: [ + ) + .argument("<document>", "Document ID") + .addOption(opt.json()) + .envVars([...ENV_AUTH]) + .examples([ { description: "List attachments", command: "bee document attachments 12345" }, { description: "Output as JSON", command: "bee document attachments 12345 --json" }, - ], - - annotations: { - environment: [...ENV_AUTH], - }, -}; - -const attachments = withUsage( - defineCommand({ - meta: { - name: "attachments", - description: "List document attachments", - }, - args: { - ...outputArgs, - document: { - type: "positional", - description: "Document ID", - valueHint: "<number>", - required: true, - }, - }, - async run({ args }) { - const { client } = await getClient(); - - const doc = await client.getDocument(args.document); - - outputResult(doc.attachments, args, (data) => { - if (data.length === 0) { - consola.info("No attachments found."); - return; - } - - const rows: Row[] = data.map((file) => [ - { header: "ID", value: String(file.id) }, - { header: "NAME", value: file.name }, - { header: "SIZE", value: formatSize(file.size) }, - { header: "CREATED BY", value: file.createdUser?.name ?? "Unknown" }, - { header: "CREATED", value: formatDate(file.created) }, - ]); - - printTable(rows); - }); - }, - }), - commandUsage, -); - -export { commandUsage, attachments }; + ]) + .action(async (document, opts) => { + const { client } = await getClient(); + + const doc = await client.getDocument(document); + + outputResult(doc.attachments, opts, (data) => { + if (data.length === 0) { + consola.info("No attachments found."); + return; + } + + const rows: Row[] = data.map((file) => [ + { header: "ID", value: String(file.id) }, + { header: "NAME", value: file.name }, + { header: "SIZE", value: formatSize(file.size) }, + { header: "CREATED BY", value: file.createdUser?.name ?? "Unknown" }, + { header: "CREATED", value: formatDate(file.created) }, + ]); + + printTable(rows); + }); + }); + +export default attachments; diff --git a/apps/cli/src/commands/document/create.test.ts b/apps/cli/src/commands/document/create.test.ts index 67c49995..de9ec4cc 100644 --- a/apps/cli/src/commands/document/create.test.ts +++ b/apps/cli/src/commands/document/create.test.ts @@ -29,10 +29,10 @@ describe("document create", () => { title: "Meeting Notes", }); - const { create } = await import("./create"); - await create.run?.({ - args: { project: "100", title: "Meeting Notes", body: "Content here" }, - } as never); + const { default: create } = await import("./create"); + await create.parseAsync(["-p", "100", "-t", "Meeting Notes", "-b", "Content here"], { + from: "user", + }); expect(mockClient.addDocument).toHaveBeenCalledWith( expect.objectContaining({ @@ -51,8 +51,8 @@ describe("document create", () => { title: "Title", }); - const { create } = await import("./create"); - await create.run?.({ args: {} } as never); + const { default: create } = await import("./create"); + await create.parseAsync([], { from: "user" }); expect(promptRequired).toHaveBeenCalledWith("Project:", undefined); expect(promptRequired).toHaveBeenCalledWith("Title:", undefined); @@ -66,10 +66,8 @@ describe("document create", () => { title: "Title", }); - const { create } = await import("./create"); - await create.run?.({ - args: { project: "100", title: "Title", body: "" }, - } as never); + const { default: create } = await import("./create"); + await create.parseAsync(["-p", "100", "-t", "Title", "-b", ""], { from: "user" }); expect(resolveStdinArg).toHaveBeenCalledWith(""); expect(mockClient.addDocument).toHaveBeenCalledWith( @@ -86,16 +84,11 @@ describe("document create", () => { title: "Title", }); - const { create } = await import("./create"); - await create.run?.({ - args: { - project: "100", - title: "Title", - emoji: "star", - "parent-id": "999", - "add-last": true, - }, - } as never); + const { default: create } = await import("./create"); + await create.parseAsync( + ["-p", "100", "-t", "Title", "--emoji", "star", "--parent-id", "999", "--add-last"], + { from: "user" }, + ); expect(mockClient.addDocument).toHaveBeenCalledWith( expect.objectContaining({ @@ -114,10 +107,8 @@ describe("document create", () => { }); await expectStdoutContaining(async () => { - const { create } = await import("./create"); - await create.run?.({ - args: { project: "100", title: "Title", json: "" }, - } as never); + const { default: create } = await import("./create"); + await create.parseAsync(["-p", "100", "-t", "Title", "--json"], { from: "user" }); }, "Title"); }); }); diff --git a/apps/cli/src/commands/document/create.ts b/apps/cli/src/commands/document/create.ts index d356d18e..030691e5 100644 --- a/apps/cli/src/commands/document/create.ts +++ b/apps/cli/src/commands/document/create.ts @@ -1,19 +1,28 @@ import { getClient, resolveProjectIds } from "@repo/backlog-utils"; -import { outputArgs, outputResult, promptRequired, resolveStdinArg } from "@repo/cli-utils"; -import { defineCommand } from "citty"; +import { outputResult, promptRequired, resolveStdinArg } from "@repo/cli-utils"; import consola from "consola"; -import { type CommandUsage, ENV_AUTH, ENV_PROJECT, withUsage } from "../../lib/command-usage"; -import * as commonArgs from "../../lib/common-args"; +import { BeeCommand, ENV_AUTH, ENV_PROJECT } from "../../lib/bee-command"; +import * as opt from "../../lib/common-options"; -const commandUsage: CommandUsage = { - long: `Create a new Backlog document. +const create = new BeeCommand("create") + .summary("Create a document") + .description( + `Create a new Backlog document. Requires a project and title. When run interactively, omitted required fields will be prompted. When input is piped, it is used as the body automatically.`, - - examples: [ + ) + .option("-p, --project <id>", "Project ID or project key") + .option("-t, --title <text>", "Document title") + .option("-b, --body <text>", "Document body content") + .option("--emoji <emoji>", "Emoji for the document") + .option("--parent-id <id>", "Parent document ID for creating as a child document") + .option("--add-last", "Add document to the end of the list") + .addOption(opt.json()) + .envVars([...ENV_AUTH, ENV_PROJECT]) + .examples([ { description: "Create a document with title and body", command: 'bee document create -p PROJECT -t "Meeting Notes" -b "Content here"', @@ -30,70 +39,29 @@ When input is piped, it is used as the body automatically.`, description: "Output as JSON", command: 'bee document create -p PROJECT -t "Title" --json', }, - ], - - annotations: { - environment: [...ENV_AUTH, ENV_PROJECT], - }, -}; - -const create = withUsage( - defineCommand({ - meta: { - name: "create", - description: "Create a document", - }, - args: { - ...outputArgs, - project: commonArgs.project, - title: { - type: "string", - alias: "t", - description: "Document title", - }, - body: { - type: "string", - alias: "b", - description: "Document body content", - }, - emoji: { - type: "string", - description: "Emoji for the document", - }, - "parent-id": { - type: "string", - description: "Parent document ID for creating as a child document", - }, - "add-last": { - type: "boolean", - description: "Add document to the end of the list", - }, - }, - async run({ args }) { - const { client } = await getClient(); + ]) + .action(async (opts) => { + const { client } = await getClient(); - const project = await promptRequired("Project:", args.project); - const title = await promptRequired("Title:", args.title); + const project = await promptRequired("Project:", opts.project); + const title = await promptRequired("Title:", opts.title); - const [projectId] = await resolveProjectIds(client, [project]); + const [projectId] = await resolveProjectIds(client, [project]); - const body = await resolveStdinArg(args.body); + const body = await resolveStdinArg(opts.body); - const doc = await client.addDocument({ - projectId, - title, - content: body, - emoji: args.emoji, - parentId: args["parent-id"], - addLast: args["add-last"], - }); + const doc = await client.addDocument({ + projectId, + title, + content: body, + emoji: opts.emoji, + parentId: opts.parentId, + addLast: opts.addLast, + }); - outputResult(doc, args, (data) => { - consola.success(`Created document ${data.title} (ID: ${data.id})`); - }); - }, - }), - commandUsage, -); + outputResult(doc, opts, (data) => { + consola.success(`Created document ${data.title} (ID: ${data.id})`); + }); + }); -export { commandUsage, create }; +export default create; diff --git a/apps/cli/src/commands/document/delete.test.ts b/apps/cli/src/commands/document/delete.test.ts index 37edb9c2..791df44e 100644 --- a/apps/cli/src/commands/document/delete.test.ts +++ b/apps/cli/src/commands/document/delete.test.ts @@ -23,8 +23,8 @@ describe("document delete", () => { vi.mocked(confirmOrExit).mockResolvedValue(true); mockClient.deleteDocument.mockResolvedValue({ id: "1", title: "My Doc" }); - const { deleteDocument } = await import("./delete"); - await deleteDocument.run?.({ args: { document: "12345" } } as never); + const { default: deleteDocument } = await import("./delete"); + await deleteDocument.parseAsync(["12345"], { from: "user" }); expect(confirmOrExit).toHaveBeenCalledWith( "Are you sure you want to delete document 12345? This cannot be undone.", @@ -38,8 +38,8 @@ describe("document delete", () => { vi.mocked(confirmOrExit).mockResolvedValue(true); mockClient.deleteDocument.mockResolvedValue({ id: "1", title: "My Doc" }); - const { deleteDocument } = await import("./delete"); - await deleteDocument.run?.({ args: { document: "12345", yes: true } } as never); + const { default: deleteDocument } = await import("./delete"); + await deleteDocument.parseAsync(["12345", "--yes"], { from: "user" }); expect(confirmOrExit).toHaveBeenCalledWith( "Are you sure you want to delete document 12345? This cannot be undone.", @@ -50,8 +50,8 @@ describe("document delete", () => { it("cancels when user declines confirmation", async () => { vi.mocked(confirmOrExit).mockResolvedValue(false); - const { deleteDocument } = await import("./delete"); - await deleteDocument.run?.({ args: { document: "12345" } } as never); + const { default: deleteDocument } = await import("./delete"); + await deleteDocument.parseAsync(["12345"], { from: "user" }); expect(mockClient.deleteDocument).not.toHaveBeenCalled(); }); @@ -61,8 +61,8 @@ describe("document delete", () => { mockClient.deleteDocument.mockResolvedValue({ id: "1", title: "My Doc" }); await expectStdoutContaining(async () => { - const { deleteDocument } = await import("./delete"); - await deleteDocument.run?.({ args: { document: "12345", yes: true, json: "" } } as never); + const { default: deleteDocument } = await import("./delete"); + await deleteDocument.parseAsync(["12345", "--yes", "--json"], { from: "user" }); }, "My Doc"); }); }); diff --git a/apps/cli/src/commands/document/delete.ts b/apps/cli/src/commands/document/delete.ts index 34d15235..1c7f9b49 100644 --- a/apps/cli/src/commands/document/delete.ts +++ b/apps/cli/src/commands/document/delete.ts @@ -1,16 +1,22 @@ import { getClient } from "@repo/backlog-utils"; -import { confirmOrExit, outputArgs, outputResult } from "@repo/cli-utils"; -import { defineCommand } from "citty"; +import { confirmOrExit, outputResult } from "@repo/cli-utils"; import consola from "consola"; -import { type CommandUsage, ENV_AUTH, withUsage } from "../../lib/command-usage"; +import { BeeCommand, ENV_AUTH } from "../../lib/bee-command"; +import * as opt from "../../lib/common-options"; -const commandUsage: CommandUsage = { - long: `Delete a Backlog document. +const deleteDocument = new BeeCommand("delete") + .summary("Delete a document") + .description( + `Delete a Backlog document. This action is irreversible. You will be prompted for confirmation unless \`--yes\` is provided.`, - - examples: [ + ) + .argument("<document>", "Document ID") + .option("-y, --yes", "Skip confirmation prompt") + .addOption(opt.json()) + .envVars([...ENV_AUTH]) + .examples([ { description: "Delete a document (with confirmation)", command: "bee document delete 12345", @@ -19,53 +25,24 @@ This action is irreversible. You will be prompted for confirmation unless description: "Delete a document without confirmation", command: "bee document delete 12345 --yes", }, - ], - - annotations: { - environment: [...ENV_AUTH], - }, -}; - -const deleteDocument = withUsage( - defineCommand({ - meta: { - name: "delete", - description: "Delete a document", - }, - args: { - ...outputArgs, - document: { - type: "positional", - description: "Document ID", - valueHint: "<number>", - required: true, - }, - yes: { - type: "boolean", - alias: "y", - description: "Skip confirmation prompt", - }, - }, - async run({ args }) { - const confirmed = await confirmOrExit( - `Are you sure you want to delete document ${args.document}? This cannot be undone.`, - args.yes, - ); + ]) + .action(async (document, opts) => { + const confirmed = await confirmOrExit( + `Are you sure you want to delete document ${document}? This cannot be undone.`, + opts.yes, + ); - if (!confirmed) { - return; - } + if (!confirmed) { + return; + } - const { client } = await getClient(); + const { client } = await getClient(); - const doc = await client.deleteDocument(args.document); + const doc = await client.deleteDocument(document); - outputResult(doc, args, (data) => { - consola.success(`Deleted document ${data.title} (ID: ${data.id})`); - }); - }, - }), - commandUsage, -); + outputResult(doc, opts, (data) => { + consola.success(`Deleted document ${data.title} (ID: ${data.id})`); + }); + }); -export { commandUsage, deleteDocument }; +export default deleteDocument; diff --git a/apps/cli/src/commands/document/list.test.ts b/apps/cli/src/commands/document/list.test.ts index a225d27c..26f71b81 100644 --- a/apps/cli/src/commands/document/list.test.ts +++ b/apps/cli/src/commands/document/list.test.ts @@ -15,6 +15,11 @@ vi.mock("@repo/backlog-utils", async (importOriginal) => ({ vi.mock("consola", () => import("@repo/test-utils/mock-consola")); +vi.mock("@repo/cli-utils", async (importOriginal) => ({ + ...(await importOriginal()), + promptRequired: vi.fn((_, val) => Promise.resolve(val)), +})); + const sampleDocuments = [ { id: "doc-1", @@ -52,8 +57,8 @@ describe("document list", () => { it("displays document list in tabular format", async () => { mockClient.getDocuments.mockResolvedValue(sampleDocuments); - const { list } = await import("./list"); - await list.run?.({ args: { project: "PROJECT" } } as never); + const { default: list } = await import("./list"); + await list.parseAsync(["-p", "PROJECT"], { from: "user" }); expect(getClient).toHaveBeenCalled(); expect(mockClient.getDocuments).toHaveBeenCalled(); @@ -66,8 +71,8 @@ describe("document list", () => { it("shows message when no documents found", async () => { mockClient.getDocuments.mockResolvedValue([]); - const { list } = await import("./list"); - await list.run?.({ args: { project: "PROJECT" } } as never); + const { default: list } = await import("./list"); + await list.parseAsync(["-p", "PROJECT"], { from: "user" }); expect(consola.info).toHaveBeenCalledWith("No documents found."); }); @@ -75,8 +80,8 @@ describe("document list", () => { it("passes keyword query parameter", async () => { mockClient.getDocuments.mockResolvedValue([]); - const { list } = await import("./list"); - await list.run?.({ args: { project: "PROJECT", keyword: "meeting" } } as never); + const { default: list } = await import("./list"); + await list.parseAsync(["-p", "PROJECT", "-k", "meeting"], { from: "user" }); expect(mockClient.getDocuments).toHaveBeenCalledWith( expect.objectContaining({ keyword: "meeting" }), @@ -86,8 +91,10 @@ describe("document list", () => { it("passes sort and order parameters", async () => { mockClient.getDocuments.mockResolvedValue([]); - const { list } = await import("./list"); - await list.run?.({ args: { project: "PROJECT", sort: "created", order: "asc" } } as never); + const { default: list } = await import("./list"); + await list.parseAsync(["-p", "PROJECT", "--sort", "created", "--order", "asc"], { + from: "user", + }); expect(mockClient.getDocuments).toHaveBeenCalledWith( expect.objectContaining({ sort: "created", order: "asc" }), @@ -97,8 +104,8 @@ describe("document list", () => { it("passes count and offset parameters", async () => { mockClient.getDocuments.mockResolvedValue([]); - const { list } = await import("./list"); - await list.run?.({ args: { project: "PROJECT", count: "10", offset: "5" } } as never); + const { default: list } = await import("./list"); + await list.parseAsync(["-p", "PROJECT", "-L", "10", "--offset", "5"], { from: "user" }); expect(mockClient.getDocuments).toHaveBeenCalledWith( expect.objectContaining({ count: 10, offset: 5 }), @@ -109,8 +116,8 @@ describe("document list", () => { mockClient.getDocuments.mockResolvedValue(sampleDocuments); await expectStdoutContaining(async () => { - const { list } = await import("./list"); - await list.run?.({ args: { project: "PROJECT", json: "" } } as never); + const { default: list } = await import("./list"); + await list.parseAsync(["-p", "PROJECT", "--json"], { from: "user" }); }, "doc-1"); }); }); diff --git a/apps/cli/src/commands/document/list.ts b/apps/cli/src/commands/document/list.ts index aa7f263d..944b0d35 100644 --- a/apps/cli/src/commands/document/list.ts +++ b/apps/cli/src/commands/document/list.ts @@ -1,92 +1,66 @@ import { getClient, resolveProjectIds } from "@repo/backlog-utils"; -import { - type Row, - formatDate, - outputArgs, - outputResult, - printTable, - splitArg, -} from "@repo/cli-utils"; -import { defineCommand } from "citty"; +import { type Row, formatDate, outputResult, printTable, splitArg } from "@repo/cli-utils"; import consola from "consola"; import * as v from "valibot"; -import { type CommandUsage, ENV_AUTH, ENV_PROJECT, withUsage } from "../../lib/command-usage"; -import * as commonArgs from "../../lib/common-args"; +import { BeeCommand, ENV_AUTH, ENV_PROJECT } from "../../lib/bee-command"; +import * as opt from "../../lib/common-options"; +import { resolveOptions } from "../../lib/required-option"; -const commandUsage: CommandUsage = { - long: `List documents from a Backlog project. +const list = new BeeCommand("list") + .summary("List documents") + .description( + `List documents from a Backlog project. Use \`--sort\` to change the sort field and \`--keyword\` to search within document titles and content.`, - - examples: [ + ) + .addOption(opt.project()) + .addOption(opt.keyword()) + .option("--sort <field>", "Sort field {created|updated}") + .addOption(opt.order()) + .addOption(opt.count()) + .addOption(opt.offset()) + .addOption(opt.json()) + .envVars([...ENV_AUTH, ENV_PROJECT]) + .examples([ { description: "List documents in a project", command: "bee document list -p PROJECT" }, { description: "Search documents by keyword", command: 'bee document list -p PROJECT -k "meeting notes"', }, { description: "Output as JSON", command: "bee document list -p PROJECT --json" }, - ], - - annotations: { - environment: [...ENV_AUTH, ENV_PROJECT], - }, -}; - -const list = withUsage( - defineCommand({ - meta: { - name: "list", - description: "List documents", - }, - args: { - ...outputArgs, - project: { - ...commonArgs.project, - description: "Project ID or project key (comma-separated for multiple)", - }, - keyword: commonArgs.keyword, - sort: { - type: "string", - description: "Sort field", - valueHint: "{created|updated}", - }, - order: commonArgs.order, - count: commonArgs.count, - offset: commonArgs.offset, - }, - async run({ args }) { - const { client } = await getClient(); + ]) + .action(async (opts, cmd) => { + await resolveOptions(cmd); + const { client } = await getClient(); - const projectId = await resolveProjectIds(client, splitArg(args.project, v.string())); + const projectId = await resolveProjectIds(client, splitArg(opts.project, v.string())); - const documents = await client.getDocuments({ - projectId, - keyword: args.keyword, - sort: args.sort as "created" | "updated" | undefined, - order: args.order as "asc" | "desc" | undefined, - count: args.count ? Number(args.count) : undefined, - offset: args.offset ? Number(args.offset) : 0, - }); + const documents = await client.getDocuments({ + projectId, + keyword: opts.keyword, + sort: opts.sort, + order: opts.order, + count: opts.count ? Number(opts.count) : undefined, + offset: opts.offset ? Number(opts.offset) : 0, + }); - outputResult(documents, args, (data) => { - if (data.length === 0) { - consola.info("No documents found."); - return; - } + const json = opts.json === true ? "" : opts.json; + outputResult(documents, { json }, (data) => { + if (data.length === 0) { + consola.info("No documents found."); + return; + } - const rows: Row[] = data.map((doc) => [ - { header: "ID", value: doc.id }, - { header: "EMOJI", value: doc.emoji ?? "" }, - { header: "TITLE", value: doc.title }, - { header: "UPDATED", value: formatDate(doc.updated) }, - ]); + const rows: Row[] = data.map((doc) => [ + { header: "ID", value: doc.id }, + { header: "EMOJI", value: doc.emoji ?? "" }, + { header: "TITLE", value: doc.title }, + { header: "UPDATED", value: formatDate(doc.updated) }, + ]); - printTable(rows); - }); - }, - }), - commandUsage, -); + printTable(rows); + }); + }); -export { commandUsage, list }; +export default list; diff --git a/apps/cli/src/commands/document/tree.test.ts b/apps/cli/src/commands/document/tree.test.ts index d6552af6..6ffef126 100644 --- a/apps/cli/src/commands/document/tree.test.ts +++ b/apps/cli/src/commands/document/tree.test.ts @@ -42,8 +42,8 @@ describe("document tree", () => { it("displays tree structure", async () => { mockClient.getDocumentTree.mockResolvedValue(sampleTree); - const { tree } = await import("./tree"); - await tree.run?.({ args: { project: "PROJECT" } } as never); + const { default: tree } = await import("./tree"); + await tree.parseAsync(["PROJECT"], { from: "user" }); expect(mockClient.getDocumentTree).toHaveBeenCalledWith("PROJECT"); expect(consola.log).toHaveBeenCalledWith(expect.stringContaining("Getting Started")); @@ -54,8 +54,8 @@ describe("document tree", () => { it("displays emoji in tree nodes", async () => { mockClient.getDocumentTree.mockResolvedValue(sampleTree); - const { tree } = await import("./tree"); - await tree.run?.({ args: { project: "PROJECT" } } as never); + const { default: tree } = await import("./tree"); + await tree.parseAsync(["PROJECT"], { from: "user" }); expect(consola.log).toHaveBeenCalledWith(expect.stringContaining("\ud83d\ude80")); }); @@ -66,8 +66,8 @@ describe("document tree", () => { activeTree: { id: "root", children: [] }, }); - const { tree } = await import("./tree"); - await tree.run?.({ args: { project: "PROJECT" } } as never); + const { default: tree } = await import("./tree"); + await tree.parseAsync(["PROJECT"], { from: "user" }); expect(consola.info).toHaveBeenCalledWith("No documents found."); }); @@ -77,8 +77,8 @@ describe("document tree", () => { projectId: "100", }); - const { tree } = await import("./tree"); - await tree.run?.({ args: { project: "PROJECT" } } as never); + const { default: tree } = await import("./tree"); + await tree.parseAsync(["PROJECT"], { from: "user" }); expect(consola.info).toHaveBeenCalledWith("No documents found."); }); @@ -87,16 +87,16 @@ describe("document tree", () => { mockClient.getDocumentTree.mockResolvedValue(sampleTree); await expectStdoutContaining(async () => { - const { tree } = await import("./tree"); - await tree.run?.({ args: { project: "PROJECT", json: "" } } as never); + const { default: tree } = await import("./tree"); + await tree.parseAsync(["PROJECT", "--json"], { from: "user" }); }, "doc-1"); }); it("renders tree connectors correctly", async () => { mockClient.getDocumentTree.mockResolvedValue(sampleTree); - const { tree } = await import("./tree"); - await tree.run?.({ args: { project: "PROJECT" } } as never); + const { default: tree } = await import("./tree"); + await tree.parseAsync(["PROJECT"], { from: "user" }); // First child uses ├── connector expect(consola.log).toHaveBeenCalledWith(expect.stringContaining("\u251c\u2500\u2500")); diff --git a/apps/cli/src/commands/document/tree.ts b/apps/cli/src/commands/document/tree.ts index 9e4a6cd8..40f2a083 100644 --- a/apps/cli/src/commands/document/tree.ts +++ b/apps/cli/src/commands/document/tree.ts @@ -1,24 +1,9 @@ import { getClient } from "@repo/backlog-utils"; -import { outputArgs, outputResult } from "@repo/cli-utils"; +import { outputResult } from "@repo/cli-utils"; import { type Entity } from "backlog-js"; -import { defineCommand } from "citty"; import consola from "consola"; -import { type CommandUsage, ENV_AUTH, ENV_PROJECT, withUsage } from "../../lib/command-usage"; - -const commandUsage: CommandUsage = { - long: `Display the document tree structure of a Backlog project. - -Shows the hierarchical structure of documents with tree-style indentation.`, - - examples: [ - { description: "Show document tree", command: "bee document tree PROJECT" }, - { description: "Output as JSON", command: "bee document tree PROJECT --json" }, - ], - - annotations: { - environment: [...ENV_AUTH, ENV_PROJECT], - }, -}; +import { BeeCommand, ENV_AUTH, ENV_PROJECT } from "../../lib/bee-command"; +import * as opt from "../../lib/common-options"; const renderNode = ( node: Entity.Document.DocumentTreeNode, @@ -48,40 +33,36 @@ const renderTree = (children: Entity.Document.DocumentTreeNode[]): string[] => { return lines; }; -const tree = withUsage( - defineCommand({ - meta: { - name: "tree", - description: "Display document tree", - }, - args: { - ...outputArgs, - project: { - type: "positional", - description: "Project ID or project key", - required: true, - default: process.env.BACKLOG_PROJECT, - }, - }, - async run({ args }) { - const { client } = await getClient(); +const tree = new BeeCommand("tree") + .summary("Display document tree") + .description( + `Display the document tree structure of a Backlog project. + +Shows the hierarchical structure of documents with tree-style indentation.`, + ) + .argument("[project]", "Project ID or project key", process.env.BACKLOG_PROJECT) + .addOption(opt.json()) + .envVars([...ENV_AUTH, ENV_PROJECT]) + .examples([ + { description: "Show document tree", command: "bee document tree PROJECT" }, + { description: "Output as JSON", command: "bee document tree PROJECT --json" }, + ]) + .action(async (project, opts) => { + const { client } = await getClient(); - const docTree = await client.getDocumentTree(args.project); + const docTree = await client.getDocumentTree(project); - outputResult(docTree, args, (data) => { - if (!data.activeTree || data.activeTree.children.length === 0) { - consola.info("No documents found."); - return; - } + outputResult(docTree, opts, (data) => { + if (!data.activeTree || data.activeTree.children.length === 0) { + consola.info("No documents found."); + return; + } - const lines = renderTree(data.activeTree.children); - for (const line of lines) { - consola.log(line); - } - }); - }, - }), - commandUsage, -); + const lines = renderTree(data.activeTree.children); + for (const line of lines) { + consola.log(line); + } + }); + }); -export { commandUsage, tree }; +export default tree; diff --git a/apps/cli/src/commands/document/view.test.ts b/apps/cli/src/commands/document/view.test.ts index 822133af..ea607485 100644 --- a/apps/cli/src/commands/document/view.test.ts +++ b/apps/cli/src/commands/document/view.test.ts @@ -39,8 +39,8 @@ describe("document view", () => { it("displays document details", async () => { mockClient.getDocument.mockResolvedValue(sampleDocument); - const { view } = await import("./view"); - await view.run?.({ args: { document: "doc-1" } } as never); + const { default: view } = await import("./view"); + await view.parseAsync(["doc-1"], { from: "user" }); expect(mockClient.getDocument).toHaveBeenCalledWith("doc-1"); expect(consola.log).toHaveBeenCalledWith(expect.stringContaining("Meeting Notes")); @@ -52,8 +52,8 @@ describe("document view", () => { it("displays emoji when present", async () => { mockClient.getDocument.mockResolvedValue(sampleDocument); - const { view } = await import("./view"); - await view.run?.({ args: { document: "doc-1" } } as never); + const { default: view } = await import("./view"); + await view.parseAsync(["doc-1"], { from: "user" }); expect(consola.log).toHaveBeenCalledWith(expect.stringContaining("\ud83d\udcdd")); }); @@ -61,16 +61,16 @@ describe("document view", () => { it("displays body content", async () => { mockClient.getDocument.mockResolvedValue(sampleDocument); - const { view } = await import("./view"); - await view.run?.({ args: { document: "doc-1" } } as never); + const { default: view } = await import("./view"); + await view.parseAsync(["doc-1"], { from: "user" }); expect(consola.log).toHaveBeenCalledWith(expect.stringContaining("Body")); expect(consola.log).toHaveBeenCalledWith(expect.stringContaining("Some document content")); }); it("opens browser with --web flag", async () => { - const { view } = await import("./view"); - await view.run?.({ args: { document: "doc-1", project: "PROJECT", web: true } } as never); + const { default: view } = await import("./view"); + await view.parseAsync(["doc-1", "--web", "-p", "PROJECT"], { from: "user" }); expect(openOrPrintUrl).toHaveBeenCalledWith( "https://example.backlog.com/document/PROJECT/doc-1", @@ -84,16 +84,16 @@ describe("document view", () => { mockClient.getDocument.mockResolvedValue(sampleDocument); await expectStdoutContaining(async () => { - const { view } = await import("./view"); - await view.run?.({ args: { document: "doc-1", json: "" } } as never); + const { default: view } = await import("./view"); + await view.parseAsync(["doc-1", "--json"], { from: "user" }); }, "doc-1"); }); it("hides tags when none exist", async () => { mockClient.getDocument.mockResolvedValue({ ...sampleDocument, tags: [] }); - const { view } = await import("./view"); - await view.run?.({ args: { document: "doc-1" } } as never); + const { default: view } = await import("./view"); + await view.parseAsync(["doc-1"], { from: "user" }); expect(mockClient.getDocument).toHaveBeenCalledWith("doc-1"); const allCalls = vi.mocked(consola.log).mock.calls.map((c) => c[0]); @@ -101,11 +101,9 @@ describe("document view", () => { }); it("throws error when --web used without --project", async () => { - const { view } = await import("./view"); - await expect( - view.run?.({ - args: { document: "123", web: true }, - } as never), - ).rejects.toThrow("The --project flag is required when using --web."); + const { default: view } = await import("./view"); + await expect(view.parseAsync(["123", "--web"], { from: "user" })).rejects.toThrow( + "The --project flag is required when using --web.", + ); }); }); diff --git a/apps/cli/src/commands/document/view.ts b/apps/cli/src/commands/document/view.ts index 586a33c1..a86aee77 100644 --- a/apps/cli/src/commands/document/view.ts +++ b/apps/cli/src/commands/document/view.ts @@ -1,103 +1,74 @@ import { documentUrl, getClient, openOrPrintUrl } from "@repo/backlog-utils"; -import { - UserError, - formatDate, - outputArgs, - outputResult, - printDefinitionList, -} from "@repo/cli-utils"; -import { defineCommand } from "citty"; +import { UserError, formatDate, outputResult, printDefinitionList } from "@repo/cli-utils"; import consola from "consola"; -import { type CommandUsage, ENV_AUTH, ENV_PROJECT, withUsage } from "../../lib/command-usage"; -import * as commonArgs from "../../lib/common-args"; +import { BeeCommand, ENV_AUTH, ENV_PROJECT } from "../../lib/bee-command"; +import * as opt from "../../lib/common-options"; -const commandUsage: CommandUsage = { - long: `Display details of a Backlog document. +const view = new BeeCommand("view") + .summary("View a document") + .description( + `Display details of a Backlog document. Shows the document title, metadata, and body content. Use \`--web\` to open the document in your default browser instead. The \`--project\` flag is required when using \`--web\`.`, - - examples: [ + ) + .argument("<document>", "Document ID") + .option("-p, --project <id>", "Project ID or project key (required for --web)") + .addOption(opt.web("document")) + .addOption(opt.noBrowser()) + .addOption(opt.json()) + .envVars([...ENV_AUTH, ENV_PROJECT]) + .examples([ { description: "View document details", command: "bee document view 12345" }, { description: "Open document in browser", command: "bee document view 12345 --web -p PROJECT", }, { description: "Output as JSON", command: "bee document view 12345 --json" }, - ], - - annotations: { - environment: [...ENV_AUTH, ENV_PROJECT], - }, -}; - -const view = withUsage( - defineCommand({ - meta: { - name: "view", - description: "View a document", - }, - args: { - ...outputArgs, - document: { - type: "positional", - description: "Document ID", - valueHint: "<number>", - required: true, - }, - project: { - ...commonArgs.project, - description: "Project ID or project key (required for --web)", - }, - web: commonArgs.web("document"), - "no-browser": commonArgs.noBrowser, - }, - async run({ args }) { - const { client, host } = await getClient(); + ]) + .action(async (document, opts) => { + const { client, host } = await getClient(); - if (args.web || args["no-browser"]) { - if (!args.project) { - throw new UserError("The --project flag is required when using --web."); - } - const url = documentUrl(host, args.project, args.document); - await openOrPrintUrl(url, Boolean(args["no-browser"]), consola); - return; + if (opts.web || opts.browser === false) { + if (!opts.project) { + throw new UserError("The --project flag is required when using --web."); } + const url = documentUrl(host, opts.project, document); + await openOrPrintUrl(url, opts.browser === false, consola); + return; + } - const doc = await client.getDocument(args.document); + const doc = await client.getDocument(document); - outputResult(doc, args, (data) => { - consola.log(""); - consola.log(` ${data.title}`); - consola.log(""); - printDefinitionList([ - ["ID", data.id], - ["Emoji", data.emoji ?? undefined], - ["Tags", data.tags.length > 0 ? data.tags.map((t) => t.name).join(", ") : undefined], - ["Created by", data.createdUser?.name ?? "Unknown"], - ["Created", formatDate(data.created)], - ["Updated by", data.updatedUser?.name ?? "Unknown"], - ["Updated", formatDate(data.updated)], - ]); - - if (data.plain) { - consola.log(""); - consola.log(" Body:"); - consola.log( - data.plain - .split("\n") - .map((line) => ` ${line}`) - .join("\n"), - ); - } + outputResult(doc, opts, (data) => { + consola.log(""); + consola.log(` ${data.title}`); + consola.log(""); + printDefinitionList([ + ["ID", data.id], + ["Emoji", data.emoji ?? undefined], + ["Tags", data.tags.length > 0 ? data.tags.map((t) => t.name).join(", ") : undefined], + ["Created by", data.createdUser?.name ?? "Unknown"], + ["Created", formatDate(data.created)], + ["Updated by", data.updatedUser?.name ?? "Unknown"], + ["Updated", formatDate(data.updated)], + ]); + if (data.plain) { consola.log(""); - }); - }, - }), - commandUsage, -); + consola.log(" Body:"); + consola.log( + data.plain + .split("\n") + .map((line) => ` ${line}`) + .join("\n"), + ); + } + + consola.log(""); + }); + }); -export { commandUsage, view }; +export default view; diff --git a/apps/cli/src/commands/issue-type/create.test.ts b/apps/cli/src/commands/issue-type/create.test.ts index f8adbef8..282a7c5f 100644 --- a/apps/cli/src/commands/issue-type/create.test.ts +++ b/apps/cli/src/commands/issue-type/create.test.ts @@ -13,20 +13,19 @@ vi.mock("@repo/backlog-utils", () => ({ vi.mock("@repo/cli-utils", async (importOriginal) => ({ ...(await importOriginal()), - promptRequired: vi.fn(), + promptRequired: vi.fn((_, val) => Promise.resolve(val)), })); vi.mock("consola", () => import("@repo/test-utils/mock-consola")); describe("issue-type create", () => { it("creates an issue type with provided name and color", async () => { - vi.mocked(promptRequired).mockResolvedValueOnce("Enhancement"); mockClient.postIssueType.mockResolvedValue({ id: 1, name: "Enhancement", color: "#2779ca" }); - const { create } = await import("./create"); - await create.run?.({ - args: { project: "TEST", name: "Enhancement", color: "#2779ca" }, - } as never); + const { default: create } = await import("./create"); + await create.parseAsync(["-p", "TEST", "-n", "Enhancement", "--color", "#2779ca"], { + from: "user", + }); expect(mockClient.postIssueType).toHaveBeenCalledWith("TEST", { name: "Enhancement", @@ -36,24 +35,23 @@ describe("issue-type create", () => { }); it("prompts for name when not provided", async () => { - vi.mocked(promptRequired).mockResolvedValueOnce("Prompted Type"); + vi.mocked(promptRequired).mockResolvedValueOnce("TEST").mockResolvedValueOnce("Prompted Type"); mockClient.postIssueType.mockResolvedValue({ id: 2, name: "Prompted Type", color: "#2779ca" }); - const { create } = await import("./create"); - await create.run?.({ args: { project: "TEST", color: "#2779ca" } } as never); + const { default: create } = await import("./create"); + await create.parseAsync(["-p", "TEST", "--color", "#2779ca"], { from: "user" }); expect(promptRequired).toHaveBeenCalledWith("Issue type name:", undefined); }); it("outputs JSON when --json flag is set", async () => { - vi.mocked(promptRequired).mockResolvedValueOnce("Bug"); mockClient.postIssueType.mockResolvedValue({ id: 1, name: "Bug", color: "#e30000" }); await expectStdoutContaining(async () => { - const { create } = await import("./create"); - await create.run?.({ - args: { project: "TEST", name: "Bug", color: "#e30000", json: "" }, - } as never); + const { default: create } = await import("./create"); + await create.parseAsync(["-p", "TEST", "-n", "Bug", "--color", "#e30000", "--json"], { + from: "user", + }); }, "Bug"); }); }); diff --git a/apps/cli/src/commands/issue-type/create.ts b/apps/cli/src/commands/issue-type/create.ts index 4004cf7d..6397f910 100644 --- a/apps/cli/src/commands/issue-type/create.ts +++ b/apps/cli/src/commands/issue-type/create.ts @@ -1,17 +1,27 @@ import { getClient } from "@repo/backlog-utils"; -import { outputArgs, outputResult, promptRequired } from "@repo/cli-utils"; -import { defineCommand } from "citty"; +import { outputResult, promptRequired } from "@repo/cli-utils"; import consola from "consola"; -import { type CommandUsage, ENV_AUTH, ENV_PROJECT, withUsage } from "../../lib/command-usage"; -import * as commonArgs from "../../lib/common-args"; +import { BeeCommand, ENV_AUTH, ENV_PROJECT } from "../../lib/bee-command"; +import * as opt from "../../lib/common-options"; +import { resolveOptions } from "../../lib/required-option"; -const commandUsage: CommandUsage = { - long: `Create a new issue type in a Backlog project. +const create = new BeeCommand("create") + .summary("Create an issue type") + .description( + `Create a new issue type in a Backlog project. If \`--name\` is not provided, you will be prompted interactively. The \`--color\` flag must be one of the predefined Backlog colors.`, - - examples: [ + ) + .addOption(opt.project()) + .option("-n, --name <value>", "Issue type name") + .requiredOption( + "--color <value>", + "Display color {#e30000|#990000|#934981|#814fbc|#2779ca|#007e9a|#7ea800|#ff9200|#ff3265|#666665}", + ) + .addOption(opt.json()) + .envVars([...ENV_AUTH, ENV_PROJECT]) + .examples([ { description: "Create an issue type", command: 'bee issue-type create -p PROJECT -n "Enhancement" --color "#2779ca"', @@ -20,51 +30,21 @@ The \`--color\` flag must be one of the predefined Backlog colors.`, description: "Create interactively", command: 'bee issue-type create -p PROJECT --color "#2779ca"', }, - ], + ]) + .action(async (opts, cmd) => { + await resolveOptions(cmd); + const { client } = await getClient(); - annotations: { - environment: [...ENV_AUTH, ENV_PROJECT], - }, -}; + const name = await promptRequired("Issue type name:", opts.name); -const create = withUsage( - defineCommand({ - meta: { - name: "create", - description: "Create an issue type", - }, - args: { - ...outputArgs, - project: { ...commonArgs.project, required: true }, - name: { - type: "string", - alias: "n", - description: "Issue type name", - }, - color: { - type: "string", - description: "Display color", - valueHint: - "{#e30000|#990000|#934981|#814fbc|#2779ca|#007e9a|#7ea800|#ff9200|#ff3265|#666665}", - required: true, - }, - }, - async run({ args }) { - const { client } = await getClient(); + const issueType = await client.postIssueType(opts.project, { + name, + color: opts.color as never, + }); - const name = await promptRequired("Issue type name:", args.name); - - const issueType = await client.postIssueType(args.project, { - name, - color: args.color as never, - }); - - outputResult(issueType, args, (data) => { - consola.success(`Created issue type ${data.name} (ID: ${data.id})`); - }); - }, - }), - commandUsage, -); + outputResult(issueType, opts, (data) => { + consola.success(`Created issue type ${data.name} (ID: ${data.id})`); + }); + }); -export { commandUsage, create }; +export default create; diff --git a/apps/cli/src/commands/issue-type/delete.test.ts b/apps/cli/src/commands/issue-type/delete.test.ts index 4ea3c4b5..0ceb34b1 100644 --- a/apps/cli/src/commands/issue-type/delete.test.ts +++ b/apps/cli/src/commands/issue-type/delete.test.ts @@ -23,10 +23,10 @@ describe("issue-type delete", () => { vi.mocked(confirmOrExit).mockResolvedValue(true); mockClient.deleteIssueType.mockResolvedValue({ id: 1, name: "Bug", color: "#e30000" }); - const { deleteIssueType } = await import("./delete"); - await deleteIssueType.run?.({ - args: { issueType: "1", project: "TEST", "substitute-issue-type-id": "2" }, - } as never); + const { default: deleteIssueType } = await import("./delete"); + await deleteIssueType.parseAsync(["1", "-p", "TEST", "--substitute-issue-type-id", "2"], { + from: "user", + }); expect(confirmOrExit).toHaveBeenCalledWith( "Are you sure you want to delete issue type 1? This cannot be undone.", @@ -42,10 +42,11 @@ describe("issue-type delete", () => { vi.mocked(confirmOrExit).mockResolvedValue(true); mockClient.deleteIssueType.mockResolvedValue({ id: 1, name: "Bug", color: "#e30000" }); - const { deleteIssueType } = await import("./delete"); - await deleteIssueType.run?.({ - args: { issueType: "1", project: "TEST", "substitute-issue-type-id": "2", yes: true }, - } as never); + const { default: deleteIssueType } = await import("./delete"); + await deleteIssueType.parseAsync( + ["1", "-p", "TEST", "--substitute-issue-type-id", "2", "--yes"], + { from: "user" }, + ); expect(confirmOrExit).toHaveBeenCalledWith( "Are you sure you want to delete issue type 1? This cannot be undone.", @@ -56,10 +57,10 @@ describe("issue-type delete", () => { it("cancels when user declines confirmation", async () => { vi.mocked(confirmOrExit).mockResolvedValue(false); - const { deleteIssueType } = await import("./delete"); - await deleteIssueType.run?.({ - args: { issueType: "1", project: "TEST", "substitute-issue-type-id": "2" }, - } as never); + const { default: deleteIssueType } = await import("./delete"); + await deleteIssueType.parseAsync(["1", "-p", "TEST", "--substitute-issue-type-id", "2"], { + from: "user", + }); expect(mockClient.deleteIssueType).not.toHaveBeenCalled(); }); @@ -69,16 +70,11 @@ describe("issue-type delete", () => { mockClient.deleteIssueType.mockResolvedValue({ id: 1, name: "Bug", color: "#e30000" }); await expectStdoutContaining(async () => { - const { deleteIssueType } = await import("./delete"); - await deleteIssueType.run?.({ - args: { - issueType: "1", - project: "TEST", - "substitute-issue-type-id": "2", - yes: true, - json: "", - }, - } as never); + const { default: deleteIssueType } = await import("./delete"); + await deleteIssueType.parseAsync( + ["1", "-p", "TEST", "--substitute-issue-type-id", "2", "--yes", "--json"], + { from: "user" }, + ); }, "Bug"); }); }); diff --git a/apps/cli/src/commands/issue-type/delete.ts b/apps/cli/src/commands/issue-type/delete.ts index caf97330..d8568067 100644 --- a/apps/cli/src/commands/issue-type/delete.ts +++ b/apps/cli/src/commands/issue-type/delete.ts @@ -1,12 +1,14 @@ import { getClient } from "@repo/backlog-utils"; -import { confirmOrExit, outputArgs, outputResult } from "@repo/cli-utils"; -import { defineCommand } from "citty"; +import { confirmOrExit, outputResult } from "@repo/cli-utils"; import consola from "consola"; -import { type CommandUsage, ENV_AUTH, ENV_PROJECT, withUsage } from "../../lib/command-usage"; -import * as commonArgs from "../../lib/common-args"; +import { BeeCommand, ENV_AUTH, ENV_PROJECT } from "../../lib/bee-command"; +import * as opt from "../../lib/common-options"; +import { resolveOptions } from "../../lib/required-option"; -const commandUsage: CommandUsage = { - long: `Delete an issue type from a Backlog project. +const deleteIssueType = new BeeCommand("delete") + .summary("Delete an issue type") + .description( + `Delete an issue type from a Backlog project. When deleting an issue type, all issues of that type must be reassigned to another issue type. Use \`--substitute-issue-type-id\` to specify @@ -14,8 +16,17 @@ the replacement. This action is irreversible. You will be prompted for confirmation unless \`--yes\` is provided.`, - - examples: [ + ) + .argument("<issueType>", "Issue type ID") + .addOption(opt.project()) + .requiredOption( + "--substitute-issue-type-id <value>", + "Replacement issue type ID for affected issues", + ) + .option("-y, --yes", "Skip confirmation prompt") + .addOption(opt.json()) + .envVars([...ENV_AUTH, ENV_PROJECT]) + .examples([ { description: "Delete an issue type", command: "bee issue-type delete 12345 -p PROJECT --substitute-issue-type-id 67890", @@ -24,62 +35,27 @@ This action is irreversible. You will be prompted for confirmation unless description: "Delete without confirmation", command: "bee issue-type delete 12345 -p PROJECT --substitute-issue-type-id 67890 --yes", }, - ], - - annotations: { - environment: [...ENV_AUTH, ENV_PROJECT], - }, -}; - -const deleteIssueType = withUsage( - defineCommand({ - meta: { - name: "delete", - description: "Delete an issue type", - }, - args: { - ...outputArgs, - issueType: { - type: "positional", - description: "Issue type ID", - required: true, - valueHint: "<number>", - }, - project: { ...commonArgs.project, required: true }, - "substitute-issue-type-id": { - type: "string", - description: "Replacement issue type ID for affected issues", - valueHint: "<number>", - required: true, - }, - yes: { - type: "boolean", - alias: "y", - description: "Skip confirmation prompt", - }, - }, - async run({ args }) { - const confirmed = await confirmOrExit( - `Are you sure you want to delete issue type ${args.issueType}? This cannot be undone.`, - args.yes, - ); - - if (!confirmed) { - return; - } - - const { client } = await getClient(); - - const issueType = await client.deleteIssueType(args.project, Number(args.issueType), { - substituteIssueTypeId: Number(args["substitute-issue-type-id"]), - }); - - outputResult(issueType, args, (data) => { - consola.success(`Deleted issue type ${data.name} (ID: ${data.id})`); - }); - }, - }), - commandUsage, -); - -export { commandUsage, deleteIssueType }; + ]) + .action(async (issueType, opts, cmd) => { + await resolveOptions(cmd); + const confirmed = await confirmOrExit( + `Are you sure you want to delete issue type ${issueType}? This cannot be undone.`, + opts.yes, + ); + + if (!confirmed) { + return; + } + + const { client } = await getClient(); + + const result = await client.deleteIssueType(opts.project, Number(issueType), { + substituteIssueTypeId: Number(opts.substituteIssueTypeId), + }); + + outputResult(result, opts, (data) => { + consola.success(`Deleted issue type ${data.name} (ID: ${data.id})`); + }); + }); + +export default deleteIssueType; diff --git a/apps/cli/src/commands/issue-type/edit.test.ts b/apps/cli/src/commands/issue-type/edit.test.ts index e407bb45..d63e52ca 100644 --- a/apps/cli/src/commands/issue-type/edit.test.ts +++ b/apps/cli/src/commands/issue-type/edit.test.ts @@ -16,8 +16,8 @@ describe("issue-type edit", () => { it("updates issue type name", async () => { mockClient.patchIssueType.mockResolvedValue({ id: 1, name: "New Name", color: "#e30000" }); - const { edit } = await import("./edit"); - await edit.run?.({ args: { issueType: "1", project: "TEST", name: "New Name" } } as never); + const { default: edit } = await import("./edit"); + await edit.parseAsync(["1", "-p", "TEST", "-n", "New Name"], { from: "user" }); expect(mockClient.patchIssueType).toHaveBeenCalledWith( "TEST", @@ -30,8 +30,8 @@ describe("issue-type edit", () => { it("updates issue type color", async () => { mockClient.patchIssueType.mockResolvedValue({ id: 1, name: "Bug", color: "#e30000" }); - const { edit } = await import("./edit"); - await edit.run?.({ args: { issueType: "1", project: "TEST", color: "#e30000" } } as never); + const { default: edit } = await import("./edit"); + await edit.parseAsync(["1", "-p", "TEST", "--color", "#e30000"], { from: "user" }); expect(mockClient.patchIssueType).toHaveBeenCalledWith( "TEST", @@ -44,10 +44,8 @@ describe("issue-type edit", () => { mockClient.patchIssueType.mockResolvedValue({ id: 1, name: "Bug", color: "#e30000" }); await expectStdoutContaining(async () => { - const { edit } = await import("./edit"); - await edit.run?.({ - args: { issueType: "1", project: "TEST", name: "Bug", json: "" }, - } as never); + const { default: edit } = await import("./edit"); + await edit.parseAsync(["1", "-p", "TEST", "-n", "Bug", "--json"], { from: "user" }); }, "Bug"); }); }); diff --git a/apps/cli/src/commands/issue-type/edit.ts b/apps/cli/src/commands/issue-type/edit.ts index 713966da..b9c70ddf 100644 --- a/apps/cli/src/commands/issue-type/edit.ts +++ b/apps/cli/src/commands/issue-type/edit.ts @@ -1,17 +1,28 @@ import { getClient } from "@repo/backlog-utils"; -import { outputArgs, outputResult } from "@repo/cli-utils"; -import { defineCommand } from "citty"; +import { outputResult } from "@repo/cli-utils"; import consola from "consola"; -import { type CommandUsage, ENV_AUTH, ENV_PROJECT, withUsage } from "../../lib/command-usage"; -import * as commonArgs from "../../lib/common-args"; +import { BeeCommand, ENV_AUTH, ENV_PROJECT } from "../../lib/bee-command"; +import * as opt from "../../lib/common-options"; +import { resolveOptions } from "../../lib/required-option"; -const commandUsage: CommandUsage = { - long: `Update an existing issue type in a Backlog project. +const edit = new BeeCommand("edit") + .summary("Edit an issue type") + .description( + `Update an existing issue type in a Backlog project. Only the specified fields will be updated. Fields that are not provided will remain unchanged.`, - - examples: [ + ) + .argument("<issueType>", "Issue type ID") + .addOption(opt.project()) + .option("-n, --name <value>", "New name of the issue type") + .option( + "--color <value>", + "Change display color {#e30000|#990000|#934981|#814fbc|#2779ca|#007e9a|#7ea800|#ff9200|#ff3265|#666665}", + ) + .addOption(opt.json()) + .envVars([...ENV_AUTH, ENV_PROJECT]) + .examples([ { description: "Rename an issue type", command: 'bee issue-type edit 12345 -p PROJECT -n "New Name"', @@ -20,54 +31,19 @@ will remain unchanged.`, description: "Change issue type color", command: 'bee issue-type edit 12345 -p PROJECT --color "#e30000"', }, - ], - - annotations: { - environment: [...ENV_AUTH, ENV_PROJECT], - }, -}; - -const edit = withUsage( - defineCommand({ - meta: { - name: "edit", - description: "Edit an issue type", - }, - args: { - ...outputArgs, - issueType: { - type: "positional", - description: "Issue type ID", - required: true, - valueHint: "<number>", - }, - project: { ...commonArgs.project, required: true }, - name: { - type: "string", - alias: "n", - description: "New name of the issue type", - }, - color: { - type: "string", - description: "Change display color", - valueHint: - "{#e30000|#990000|#934981|#814fbc|#2779ca|#007e9a|#7ea800|#ff9200|#ff3265|#666665}", - }, - }, - async run({ args }) { - const { client } = await getClient(); - - const issueType = await client.patchIssueType(args.project, Number(args.issueType), { - name: args.name, - color: args.color as never, - }); - - outputResult(issueType, args, (data) => { - consola.success(`Updated issue type ${data.name} (ID: ${data.id})`); - }); - }, - }), - commandUsage, -); - -export { commandUsage, edit }; + ]) + .action(async (issueType, opts, cmd) => { + await resolveOptions(cmd); + const { client } = await getClient(); + + const result = await client.patchIssueType(opts.project, Number(issueType), { + name: opts.name, + color: opts.color as never, + }); + + outputResult(result, opts, (data) => { + consola.success(`Updated issue type ${data.name} (ID: ${data.id})`); + }); + }); + +export default edit; diff --git a/apps/cli/src/commands/issue-type/list.test.ts b/apps/cli/src/commands/issue-type/list.test.ts index c6d45d52..e715ba4f 100644 --- a/apps/cli/src/commands/issue-type/list.test.ts +++ b/apps/cli/src/commands/issue-type/list.test.ts @@ -22,8 +22,8 @@ describe("issue-type list", () => { it("displays issue type list in tabular format", async () => { mockClient.getIssueTypes.mockResolvedValue(sampleIssueTypes); - const { list } = await import("./list"); - await list.run?.({ args: { project: "TEST" } } as never); + const { default: list } = await import("./list"); + await list.parseAsync(["TEST"], { from: "user" }); expect(getClient).toHaveBeenCalled(); expect(mockClient.getIssueTypes).toHaveBeenCalledWith("TEST"); @@ -35,8 +35,8 @@ describe("issue-type list", () => { it("shows message when no issue types found", async () => { mockClient.getIssueTypes.mockResolvedValue([]); - const { list } = await import("./list"); - await list.run?.({ args: { project: "TEST" } } as never); + const { default: list } = await import("./list"); + await list.parseAsync(["TEST"], { from: "user" }); expect(consola.info).toHaveBeenCalledWith("No issue types found."); }); @@ -44,8 +44,8 @@ describe("issue-type list", () => { it("displays color column", async () => { mockClient.getIssueTypes.mockResolvedValue(sampleIssueTypes); - const { list } = await import("./list"); - await list.run?.({ args: { project: "TEST" } } as never); + const { default: list } = await import("./list"); + await list.parseAsync(["TEST"], { from: "user" }); expect(consola.log).toHaveBeenCalledWith(expect.stringContaining("#e30000")); }); @@ -54,8 +54,8 @@ describe("issue-type list", () => { mockClient.getIssueTypes.mockResolvedValue(sampleIssueTypes); await expectStdoutContaining(async () => { - const { list } = await import("./list"); - await list.run?.({ args: { project: "TEST", json: "" } } as never); + const { default: list } = await import("./list"); + await list.parseAsync(["TEST", "--json"], { from: "user" }); }, "Bug"); }); }); diff --git a/apps/cli/src/commands/issue-type/list.ts b/apps/cli/src/commands/issue-type/list.ts index 407c8331..c21e1144 100644 --- a/apps/cli/src/commands/issue-type/list.ts +++ b/apps/cli/src/commands/issue-type/list.ts @@ -1,57 +1,42 @@ import { getClient } from "@repo/backlog-utils"; -import { type Row, outputArgs, outputResult, printTable } from "@repo/cli-utils"; -import { defineCommand } from "citty"; +import { type Row, outputResult, printTable } from "@repo/cli-utils"; import consola from "consola"; -import { type CommandUsage, ENV_AUTH, ENV_PROJECT, withUsage } from "../../lib/command-usage"; -import * as commonArgs from "../../lib/common-args"; +import { BeeCommand, ENV_AUTH, ENV_PROJECT } from "../../lib/bee-command"; +import * as opt from "../../lib/common-options"; -const commandUsage: CommandUsage = { - long: `List issue types in a Backlog project. +const list = new BeeCommand("list") + .summary("List issue types") + .description( + `List issue types in a Backlog project. Issue types categorize issues and are displayed with their associated color.`, - - examples: [ + ) + .argument("[project]", "Project ID or project key") + .addOption(opt.json()) + .envVars([...ENV_AUTH, ENV_PROJECT]) + .examples([ { description: "List all issue types", command: "bee issue-type list PROJECT" }, { description: "Output as JSON", command: "bee issue-type list PROJECT --json" }, - ], - - annotations: { - environment: [...ENV_AUTH, ENV_PROJECT], - }, -}; - -const list = withUsage( - defineCommand({ - meta: { - name: "list", - description: "List issue types", - }, - args: { - ...outputArgs, - project: commonArgs.projectPositional, - }, - async run({ args }) { - const { client } = await getClient(); + ]) + .action(async (project, opts) => { + const { client } = await getClient(); - const issueTypes = await client.getIssueTypes(args.project); + const issueTypes = await client.getIssueTypes(project); - outputResult(issueTypes, args, (data) => { - if (data.length === 0) { - consola.info("No issue types found."); - return; - } + outputResult(issueTypes, opts, (data) => { + if (data.length === 0) { + consola.info("No issue types found."); + return; + } - const rows: Row[] = data.map((t) => [ - { header: "ID", value: String(t.id) }, - { header: "NAME", value: t.name }, - { header: "COLOR", value: t.color }, - ]); + const rows: Row[] = data.map((t) => [ + { header: "ID", value: String(t.id) }, + { header: "NAME", value: t.name }, + { header: "COLOR", value: t.color }, + ]); - printTable(rows); - }); - }, - }), - commandUsage, -); + printTable(rows); + }); + }); -export { commandUsage, list }; +export default list; diff --git a/apps/cli/src/commands/issue/close.ts b/apps/cli/src/commands/issue/close.ts index a3b916e5..0c6abd4c 100644 --- a/apps/cli/src/commands/issue/close.ts +++ b/apps/cli/src/commands/issue/close.ts @@ -1,4 +1,4 @@ -import { IssueStatusId, RESOLUTION_NAMES, ResolutionId, getClient } from "@repo/backlog-utils"; +import { IssueStatusId, ResolutionId, getClient } from "@repo/backlog-utils"; import { outputResult } from "@repo/cli-utils"; import consola from "consola"; import { BeeCommand, ENV_AUTH } from "../../lib/bee-command"; @@ -38,7 +38,7 @@ Optionally add a comment with \`--comment\`.`, ? (ResolutionId[opts.resolution] ?? Number(opts.resolution)) : ResolutionId.fixed; - const notifiedUserId = (opts.notify as number[]) ?? []; + const notifiedUserId = opts.notify ?? []; const issueData = await client.patchIssue(issue, { statusId: IssueStatusId.Closed, diff --git a/apps/cli/src/commands/issue/comment.ts b/apps/cli/src/commands/issue/comment.ts index ff58c7fe..83895014 100644 --- a/apps/cli/src/commands/issue/comment.ts +++ b/apps/cli/src/commands/issue/comment.ts @@ -132,7 +132,7 @@ Use \`--delete-last\` to delete your most recent comment.`, consola.error("Comment body is required. Use --body or pipe input."); return; } - const notifiedUserId = (opts.notify as number[]) ?? []; + const notifiedUserId = opts.notify ?? []; const result = await client.postIssueComments(issue, { content, diff --git a/apps/cli/src/commands/issue/count.ts b/apps/cli/src/commands/issue/count.ts index bd981616..99f736c2 100644 --- a/apps/cli/src/commands/issue/count.ts +++ b/apps/cli/src/commands/issue/count.ts @@ -45,22 +45,22 @@ by default, or a JSON object with \`--json\`.`, const projectId = opts.project ? await resolveProjectIds( client, - (opts.project as string) + opts.project .split(",") .map((s: string) => s.trim()) .filter(Boolean), ) : []; - const assigneeId = ((opts.assignee as string[]) ?? []).map(Number); + const assigneeId = (opts.assignee ?? []).map(Number); const statusId = opts.status - ? (opts.status as string) + ? opts.status .split(",") .map((s: string) => s.trim()) .filter(Boolean) .map(Number) : []; const priorityId = opts.priority - ? (opts.priority as string) + ? opts.priority .split(",") .map((s: string) => s.trim()) .filter(Boolean) @@ -80,13 +80,13 @@ by default, or a JSON object with \`--json\`.`, assigneeId, statusId, priorityId, - keyword: opts.keyword as string | undefined, - createdSince: opts.createdSince as string | undefined, - createdUntil: opts.createdUntil as string | undefined, - updatedSince: opts.updatedSince as string | undefined, - updatedUntil: opts.updatedUntil as string | undefined, - dueDateSince: opts.dueSince as string | undefined, - dueDateUntil: opts.dueUntil as string | undefined, + keyword: opts.keyword, + createdSince: opts.createdSince, + createdUntil: opts.createdUntil, + updatedSince: opts.updatedSince, + updatedUntil: opts.updatedUntil, + dueDateSince: opts.dueSince, + dueDateUntil: opts.dueUntil, }); outputResult(result, opts as { json?: string }, (data) => { diff --git a/apps/cli/src/commands/issue/create.ts b/apps/cli/src/commands/issue/create.ts index 20000a25..394f1ade 100644 --- a/apps/cli/src/commands/issue/create.ts +++ b/apps/cli/src/commands/issue/create.ts @@ -59,10 +59,10 @@ or \`low\`.`, .action(async (opts) => { const { client } = await getClient(); - const project = await promptRequired("Project:", opts.project as string | undefined); - const title = await promptRequired("Summary:", opts.title as string | undefined); - const issueTypeId = await promptRequired("Issue type ID:", opts.type as string | undefined); - const priority = await promptRequired("Priority:", opts.priority as string | undefined, { + const project = await promptRequired("Project:", opts.project); + const title = await promptRequired("Summary:", opts.title); + const issueTypeId = await promptRequired("Issue type ID:", opts.type); + const priority = await promptRequired("Priority:", opts.priority, { valueHint: `{${PRIORITY_NAMES.join("|")}}`, }); const priorityId = PriorityId[priority.toLowerCase()]; @@ -71,22 +71,20 @@ or \`low\`.`, } const [projectId] = await resolveProjectIds(client, [project]); - const assigneeId = opts.assignee - ? await resolveUserId(client, opts.assignee as string) - : undefined; - const notifiedUserId = (opts.notify as number[]) ?? []; - const attachmentId = (opts.attachment as number[]) ?? []; + const assigneeId = opts.assignee ? await resolveUserId(client, opts.assignee) : undefined; + const notifiedUserId = opts.notify ?? []; + const attachmentId = opts.attachment ?? []; const issue = await client.postIssue({ projectId, summary: title, issueTypeId: Number(issueTypeId), priorityId, - description: opts.description as string | undefined, + description: opts.description, assigneeId, parentIssueId: opts.parentIssue ? Number(opts.parentIssue) : undefined, - startDate: opts.startDate as string | undefined, - dueDate: opts.dueDate as string | undefined, + startDate: opts.startDate, + dueDate: opts.dueDate, estimatedHours: opts.estimatedHours ? Number(opts.estimatedHours) : undefined, actualHours: opts.actualHours ? Number(opts.actualHours) : undefined, notifiedUserId, diff --git a/apps/cli/src/commands/issue/edit.ts b/apps/cli/src/commands/issue/edit.ts index 03b33438..695055e4 100644 --- a/apps/cli/src/commands/issue/edit.ts +++ b/apps/cli/src/commands/issue/edit.ts @@ -47,8 +47,8 @@ will remain unchanged.`, .action(async (issue, opts) => { const { client } = await getClient(); - const notifiedUserId = (opts.notify as number[]) ?? []; - const attachmentId = (opts.attachment as number[]) ?? []; + const notifiedUserId = opts.notify ?? []; + const attachmentId = opts.attachment ?? []; let priorityId: number | undefined; if (opts.priority) { diff --git a/apps/cli/src/commands/issue/list.ts b/apps/cli/src/commands/issue/list.ts index c6c27ba4..0e4eedcb 100644 --- a/apps/cli/src/commands/issue/list.ts +++ b/apps/cli/src/commands/issue/list.ts @@ -1,9 +1,9 @@ import { PRIORITY_NAMES, PriorityId, getClient, resolveProjectIds } from "@repo/backlog-utils"; import { type Row, outputResult, printTable } from "@repo/cli-utils"; -import { type Option } from "backlog-js"; +import { type Option as BacklogOption } from "backlog-js"; import consola from "consola"; -import { BeeCommand, ENV_AUTH, ENV_PROJECT } from "../../lib/bee-command"; import { Option } from "commander"; +import { BeeCommand, ENV_AUTH, ENV_PROJECT } from "../../lib/bee-command"; import * as opt from "../../lib/common-options"; const list = new BeeCommand("list") @@ -53,22 +53,22 @@ Multiple project keys can be specified as a comma-separated list.`, const projectId = opts.project ? await resolveProjectIds( client, - (opts.project as string) + opts.project .split(",") .map((s: string) => s.trim()) .filter(Boolean), ) : []; - const assigneeId = ((opts.assignee as string[]) ?? []).map(Number); + const assigneeId = (opts.assignee ?? []).map(Number); const statusId = opts.status - ? (opts.status as string) + ? opts.status .split(",") .map((s: string) => s.trim()) .filter(Boolean) .map(Number) : []; const priorityId = opts.priority - ? (opts.priority as string) + ? opts.priority .split(",") .map((s: string) => s.trim()) .filter(Boolean) @@ -88,17 +88,17 @@ Multiple project keys can be specified as a comma-separated list.`, assigneeId, statusId, priorityId, - keyword: opts.keyword as string | undefined, - sort: opts.sort as Option.Issue.GetIssuesParams["sort"], - order: opts.order as "asc" | "desc" | undefined, + keyword: opts.keyword, + sort: opts.sort, + order: opts.order, count: opts.count ? Number(opts.count) : undefined, offset: opts.offset ? Number(opts.offset) : undefined, - createdSince: opts.createdSince as string | undefined, - createdUntil: opts.createdUntil as string | undefined, - updatedSince: opts.updatedSince as string | undefined, - updatedUntil: opts.updatedUntil as string | undefined, - dueDateSince: opts.dueSince as string | undefined, - dueDateUntil: opts.dueUntil as string | undefined, + createdSince: opts.createdSince, + createdUntil: opts.createdUntil, + updatedSince: opts.updatedSince, + updatedUntil: opts.updatedUntil, + dueDateSince: opts.dueSince, + dueDateUntil: opts.dueUntil, }); outputResult(issues, opts as { json?: string }, (data) => { diff --git a/apps/cli/src/commands/issue/reopen.ts b/apps/cli/src/commands/issue/reopen.ts index 4629adbe..350ae756 100644 --- a/apps/cli/src/commands/issue/reopen.ts +++ b/apps/cli/src/commands/issue/reopen.ts @@ -26,7 +26,7 @@ Optionally add a comment with \`--comment\`.`, .action(async (issue, opts) => { const { client } = await getClient(); - const notifiedUserId = (opts.notify as number[]) ?? []; + const notifiedUserId = opts.notify ?? []; const issueData = await client.patchIssue(issue, { statusId: IssueStatusId.Open, diff --git a/apps/cli/src/commands/milestone/create.test.ts b/apps/cli/src/commands/milestone/create.test.ts index 89fe96ca..68ff063f 100644 --- a/apps/cli/src/commands/milestone/create.test.ts +++ b/apps/cli/src/commands/milestone/create.test.ts @@ -13,18 +13,17 @@ vi.mock("@repo/backlog-utils", () => ({ vi.mock("@repo/cli-utils", async (importOriginal) => ({ ...(await importOriginal()), - promptRequired: vi.fn(), + promptRequired: vi.fn((_, val) => Promise.resolve(val)), })); vi.mock("consola", () => import("@repo/test-utils/mock-consola")); describe("milestone create", () => { it("creates a milestone with provided name", async () => { - vi.mocked(promptRequired).mockResolvedValueOnce("v1.0.0"); mockClient.postVersions.mockResolvedValue({ id: 1, name: "v1.0.0" }); - const { create } = await import("./create"); - await create.run?.({ args: { project: "TEST", name: "v1.0.0" } } as never); + const { default: create } = await import("./create"); + await create.parseAsync(["-p", "TEST", "-n", "v1.0.0"], { from: "user" }); expect(mockClient.postVersions).toHaveBeenCalledWith( "TEST", @@ -34,28 +33,34 @@ describe("milestone create", () => { }); it("prompts for name when not provided", async () => { - vi.mocked(promptRequired).mockResolvedValueOnce("Prompted Milestone"); + vi.mocked(promptRequired) + .mockResolvedValueOnce("TEST") + .mockResolvedValueOnce("Prompted Milestone"); mockClient.postVersions.mockResolvedValue({ id: 2, name: "Prompted Milestone" }); - const { create } = await import("./create"); - await create.run?.({ args: { project: "TEST" } } as never); + const { default: create } = await import("./create"); + await create.parseAsync(["-p", "TEST"], { from: "user" }); expect(promptRequired).toHaveBeenCalledWith("Milestone name:", undefined); }); it("passes date parameters", async () => { - vi.mocked(promptRequired).mockResolvedValueOnce("v1.0.0"); mockClient.postVersions.mockResolvedValue({ id: 1, name: "v1.0.0" }); - const { create } = await import("./create"); - await create.run?.({ - args: { - project: "TEST", - name: "v1.0.0", - "start-date": "2026-04-01", - "release-due-date": "2026-06-30", - }, - } as never); + const { default: create } = await import("./create"); + await create.parseAsync( + [ + "-p", + "TEST", + "-n", + "v1.0.0", + "--start-date", + "2026-04-01", + "--release-due-date", + "2026-06-30", + ], + { from: "user" }, + ); expect(mockClient.postVersions).toHaveBeenCalledWith( "TEST", @@ -67,12 +72,11 @@ describe("milestone create", () => { }); it("outputs JSON when --json flag is set", async () => { - vi.mocked(promptRequired).mockResolvedValueOnce("v1.0.0"); mockClient.postVersions.mockResolvedValue({ id: 1, name: "v1.0.0" }); await expectStdoutContaining(async () => { - const { create } = await import("./create"); - await create.run?.({ args: { project: "TEST", name: "v1.0.0", json: "" } } as never); + const { default: create } = await import("./create"); + await create.parseAsync(["-p", "TEST", "-n", "v1.0.0", "--json"], { from: "user" }); }, "v1.0.0"); }); }); diff --git a/apps/cli/src/commands/milestone/create.ts b/apps/cli/src/commands/milestone/create.ts index 5e6f185f..55d6fb9e 100644 --- a/apps/cli/src/commands/milestone/create.ts +++ b/apps/cli/src/commands/milestone/create.ts @@ -1,78 +1,49 @@ import { getClient } from "@repo/backlog-utils"; -import { outputArgs, outputResult, promptRequired } from "@repo/cli-utils"; -import { defineCommand } from "citty"; +import { outputResult, promptRequired } from "@repo/cli-utils"; import consola from "consola"; -import { type CommandUsage, ENV_AUTH, ENV_PROJECT, withUsage } from "../../lib/command-usage"; -import * as commonArgs from "../../lib/common-args"; +import { BeeCommand, ENV_AUTH, ENV_PROJECT } from "../../lib/bee-command"; +import * as opt from "../../lib/common-options"; +import { resolveOptions } from "../../lib/required-option"; -const commandUsage: CommandUsage = { - long: `Create a new milestone in a Backlog project. +const create = new BeeCommand("create") + .summary("Create a milestone") + .description( + `Create a new milestone in a Backlog project. If \`--name\` is not provided, you will be prompted interactively. Use \`--start-date\` and \`--release-due-date\` to set the milestone schedule.`, - - examples: [ + ) + .addOption(opt.project()) + .option("-n, --name <value>", "Milestone name") + .option("-d, --description <value>", "Milestone description") + .option("--start-date <yyyy-MM-dd>", "Start date") + .option("--release-due-date <yyyy-MM-dd>", "Release due date") + .addOption(opt.json()) + .envVars([...ENV_AUTH, ENV_PROJECT]) + .examples([ { description: "Create a milestone", command: 'bee milestone create -p PROJECT -n "v1.0.0"' }, { description: "Create with dates", command: 'bee milestone create -p PROJECT -n "v1.0.0" --start-date 2026-04-01 --release-due-date 2026-06-30', }, - ], - - annotations: { - environment: [...ENV_AUTH, ENV_PROJECT], - }, -}; - -const create = withUsage( - defineCommand({ - meta: { - name: "create", - description: "Create a milestone", - }, - args: { - ...outputArgs, - project: { ...commonArgs.project, required: true }, - name: { - type: "string", - alias: "n", - description: "Milestone name", - }, - description: { - type: "string", - alias: "d", - description: "Milestone description", - }, - "start-date": { - type: "string", - description: "Start date", - valueHint: "<yyyy-MM-dd>", - }, - "release-due-date": { - type: "string", - description: "Release due date", - valueHint: "<yyyy-MM-dd>", - }, - }, - async run({ args }) { - const { client } = await getClient(); - - const name = await promptRequired("Milestone name:", args.name); - - const milestone = await client.postVersions(args.project, { - name, - description: args.description, - startDate: args["start-date"], - releaseDueDate: args["release-due-date"], - }); - - outputResult(milestone, args, (data) => { - consola.success(`Created milestone ${data.name} (ID: ${data.id})`); - }); - }, - }), - commandUsage, -); - -export { commandUsage, create }; + ]) + .action(async (opts, cmd) => { + await resolveOptions(cmd); + const { client } = await getClient(); + + const name = await promptRequired("Milestone name:", opts.name); + + const milestone = await client.postVersions(opts.project, { + name, + description: opts.description, + startDate: opts.startDate, + releaseDueDate: opts.releaseDueDate, + }); + + outputResult(milestone, opts, (data) => { + consola.success(`Created milestone ${data.name} (ID: ${data.id})`); + }); + }); + +export default create; diff --git a/apps/cli/src/commands/milestone/delete.test.ts b/apps/cli/src/commands/milestone/delete.test.ts index 35822399..e6896b7d 100644 --- a/apps/cli/src/commands/milestone/delete.test.ts +++ b/apps/cli/src/commands/milestone/delete.test.ts @@ -23,8 +23,8 @@ describe("milestone delete", () => { vi.mocked(confirmOrExit).mockResolvedValue(true); mockClient.deleteVersions.mockResolvedValue({ id: 1, name: "v1.0.0" }); - const { deleteMilestone } = await import("./delete"); - await deleteMilestone.run?.({ args: { milestone: "1", project: "TEST" } } as never); + const { default: deleteMilestone } = await import("./delete"); + await deleteMilestone.parseAsync(["1", "-p", "TEST"], { from: "user" }); expect(confirmOrExit).toHaveBeenCalledWith( "Are you sure you want to delete milestone 1? This cannot be undone.", @@ -38,8 +38,8 @@ describe("milestone delete", () => { vi.mocked(confirmOrExit).mockResolvedValue(true); mockClient.deleteVersions.mockResolvedValue({ id: 1, name: "v1.0.0" }); - const { deleteMilestone } = await import("./delete"); - await deleteMilestone.run?.({ args: { milestone: "1", project: "TEST", yes: true } } as never); + const { default: deleteMilestone } = await import("./delete"); + await deleteMilestone.parseAsync(["1", "-p", "TEST", "--yes"], { from: "user" }); expect(confirmOrExit).toHaveBeenCalledWith( "Are you sure you want to delete milestone 1? This cannot be undone.", @@ -50,8 +50,8 @@ describe("milestone delete", () => { it("cancels when user declines confirmation", async () => { vi.mocked(confirmOrExit).mockResolvedValue(false); - const { deleteMilestone } = await import("./delete"); - await deleteMilestone.run?.({ args: { milestone: "1", project: "TEST" } } as never); + const { default: deleteMilestone } = await import("./delete"); + await deleteMilestone.parseAsync(["1", "-p", "TEST"], { from: "user" }); expect(mockClient.deleteVersions).not.toHaveBeenCalled(); }); @@ -61,10 +61,8 @@ describe("milestone delete", () => { mockClient.deleteVersions.mockResolvedValue({ id: 1, name: "v1.0.0" }); await expectStdoutContaining(async () => { - const { deleteMilestone } = await import("./delete"); - await deleteMilestone.run?.({ - args: { milestone: "1", project: "TEST", yes: true, json: "" }, - } as never); + const { default: deleteMilestone } = await import("./delete"); + await deleteMilestone.parseAsync(["1", "-p", "TEST", "--yes", "--json"], { from: "user" }); }, "v1.0.0"); }); }); diff --git a/apps/cli/src/commands/milestone/delete.ts b/apps/cli/src/commands/milestone/delete.ts index 85c1d69f..2ee6d8fb 100644 --- a/apps/cli/src/commands/milestone/delete.ts +++ b/apps/cli/src/commands/milestone/delete.ts @@ -1,17 +1,24 @@ import { getClient } from "@repo/backlog-utils"; -import { confirmOrExit, outputArgs, outputResult } from "@repo/cli-utils"; -import { defineCommand } from "citty"; +import { confirmOrExit, outputResult } from "@repo/cli-utils"; import consola from "consola"; -import { type CommandUsage, ENV_AUTH, ENV_PROJECT, withUsage } from "../../lib/command-usage"; -import * as commonArgs from "../../lib/common-args"; +import { BeeCommand, ENV_AUTH, ENV_PROJECT } from "../../lib/bee-command"; +import * as opt from "../../lib/common-options"; +import { resolveOptions } from "../../lib/required-option"; -const commandUsage: CommandUsage = { - long: `Delete a milestone from a Backlog project. +const deleteMilestone = new BeeCommand("delete") + .summary("Delete a milestone") + .description( + `Delete a milestone from a Backlog project. This action is irreversible. You will be prompted for confirmation unless \`--yes\` is provided.`, - - examples: [ + ) + .argument("<milestone>", "Milestone ID") + .addOption(opt.project()) + .option("-y, --yes", "Skip confirmation prompt") + .addOption(opt.json()) + .envVars([...ENV_AUTH, ENV_PROJECT]) + .examples([ { description: "Delete a milestone (with confirmation)", command: "bee milestone delete 12345 -p PROJECT", @@ -20,54 +27,25 @@ This action is irreversible. You will be prompted for confirmation unless description: "Delete without confirmation", command: "bee milestone delete 12345 -p PROJECT --yes", }, - ], - - annotations: { - environment: [...ENV_AUTH, ENV_PROJECT], - }, -}; - -const deleteMilestone = withUsage( - defineCommand({ - meta: { - name: "delete", - description: "Delete a milestone", - }, - args: { - ...outputArgs, - milestone: { - type: "positional", - description: "Milestone ID", - required: true, - valueHint: "<number>", - }, - project: { ...commonArgs.project, required: true }, - yes: { - type: "boolean", - alias: "y", - description: "Skip confirmation prompt", - }, - }, - async run({ args }) { - const confirmed = await confirmOrExit( - `Are you sure you want to delete milestone ${args.milestone}? This cannot be undone.`, - args.yes, - ); + ]) + .action(async (milestone, opts, cmd) => { + await resolveOptions(cmd); + const confirmed = await confirmOrExit( + `Are you sure you want to delete milestone ${milestone}? This cannot be undone.`, + opts.yes, + ); - if (!confirmed) { - return; - } + if (!confirmed) { + return; + } - const { client } = await getClient(); + const { client } = await getClient(); - const milestone = await client.deleteVersions(args.project, Number(args.milestone)); + const result = await client.deleteVersions(opts.project, Number(milestone)); - outputResult(milestone, args, (data) => { - consola.success(`Deleted milestone ${data.name} (ID: ${data.id})`); - }); - }, - }), - commandUsage, -); + outputResult(result, opts, (data) => { + consola.success(`Deleted milestone ${data.name} (ID: ${data.id})`); + }); + }); -export { commandUsage, deleteMilestone }; +export default deleteMilestone; diff --git a/apps/cli/src/commands/milestone/edit.test.ts b/apps/cli/src/commands/milestone/edit.test.ts index fa9bab92..a5563a88 100644 --- a/apps/cli/src/commands/milestone/edit.test.ts +++ b/apps/cli/src/commands/milestone/edit.test.ts @@ -16,8 +16,8 @@ describe("milestone edit", () => { it("updates milestone name", async () => { mockClient.patchVersions.mockResolvedValue({ id: 1, name: "v2.0.0" }); - const { edit } = await import("./edit"); - await edit.run?.({ args: { milestone: "1", project: "TEST", name: "v2.0.0" } } as never); + const { default: edit } = await import("./edit"); + await edit.parseAsync(["1", "-p", "TEST", "-n", "v2.0.0"], { from: "user" }); expect(mockClient.patchVersions).toHaveBeenCalledWith( "TEST", @@ -30,8 +30,8 @@ describe("milestone edit", () => { it("archives a milestone", async () => { mockClient.patchVersions.mockResolvedValue({ id: 1, name: "v1.0.0" }); - const { edit } = await import("./edit"); - await edit.run?.({ args: { milestone: "1", project: "TEST", archived: true } } as never); + const { default: edit } = await import("./edit"); + await edit.parseAsync(["1", "-p", "TEST", "-n", "v1.0.0", "--archived"], { from: "user" }); expect(mockClient.patchVersions).toHaveBeenCalledWith( "TEST", @@ -43,15 +43,21 @@ describe("milestone edit", () => { it("updates date fields", async () => { mockClient.patchVersions.mockResolvedValue({ id: 1, name: "v1.0.0" }); - const { edit } = await import("./edit"); - await edit.run?.({ - args: { - milestone: "1", - project: "TEST", - "start-date": "2026-07-01", - "release-due-date": "2026-12-31", - }, - } as never); + const { default: edit } = await import("./edit"); + await edit.parseAsync( + [ + "1", + "-p", + "TEST", + "-n", + "v1.0.0", + "--start-date", + "2026-07-01", + "--release-due-date", + "2026-12-31", + ], + { from: "user" }, + ); expect(mockClient.patchVersions).toHaveBeenCalledWith( "TEST", @@ -67,10 +73,8 @@ describe("milestone edit", () => { mockClient.patchVersions.mockResolvedValue({ id: 1, name: "v1.0.0" }); await expectStdoutContaining(async () => { - const { edit } = await import("./edit"); - await edit.run?.({ - args: { milestone: "1", project: "TEST", name: "v1.0.0", json: "" }, - } as never); + const { default: edit } = await import("./edit"); + await edit.parseAsync(["1", "-p", "TEST", "-n", "v1.0.0", "--json"], { from: "user" }); }, "v1.0.0"); }); }); diff --git a/apps/cli/src/commands/milestone/edit.ts b/apps/cli/src/commands/milestone/edit.ts index f89de2da..91f5984c 100644 --- a/apps/cli/src/commands/milestone/edit.ts +++ b/apps/cli/src/commands/milestone/edit.ts @@ -1,18 +1,29 @@ import { getClient } from "@repo/backlog-utils"; -import { outputArgs, outputResult } from "@repo/cli-utils"; -import { defineCommand } from "citty"; +import { outputResult } from "@repo/cli-utils"; import consola from "consola"; -import { type CommandUsage, ENV_AUTH, ENV_PROJECT, withUsage } from "../../lib/command-usage"; -import * as commonArgs from "../../lib/common-args"; +import { BeeCommand, ENV_AUTH, ENV_PROJECT } from "../../lib/bee-command"; +import * as opt from "../../lib/common-options"; +import { resolveOptions } from "../../lib/required-option"; -const commandUsage: CommandUsage = { - long: `Update an existing milestone in a Backlog project. +const edit = new BeeCommand("edit") + .summary("Edit a milestone") + .description( + `Update an existing milestone in a Backlog project. Only the specified fields will be updated. Fields that are not provided will remain unchanged. Use \`--archived\` to archive or \`--no-archived\` to unarchive a milestone.`, - - examples: [ + ) + .argument("<milestone>", "Milestone ID") + .addOption(opt.project()) + .requiredOption("-n, --name <value>", "New name of the milestone") + .option("-d, --description <value>", "New description of the milestone") + .option("--start-date <yyyy-MM-dd>", "New start date") + .option("--release-due-date <yyyy-MM-dd>", "New release due date") + .option("--archived", "Change whether the milestone is archived") + .addOption(opt.json()) + .envVars([...ENV_AUTH, ENV_PROJECT]) + .examples([ { description: "Rename a milestone", command: 'bee milestone edit 12345 -p PROJECT -n "v2.0.0"', @@ -25,71 +36,22 @@ unarchive a milestone.`, description: "Update release date", command: "bee milestone edit 12345 -p PROJECT --release-due-date 2026-12-31", }, - ], - - annotations: { - environment: [...ENV_AUTH, ENV_PROJECT], - }, -}; - -const edit = withUsage( - defineCommand({ - meta: { - name: "edit", - description: "Edit a milestone", - }, - args: { - ...outputArgs, - milestone: { - type: "positional", - description: "Milestone ID", - required: true, - valueHint: "<number>", - }, - project: { ...commonArgs.project, required: true }, - name: { - type: "string", - alias: "n", - description: "New name of the milestone", - required: true, - }, - description: { - type: "string", - alias: "d", - description: "New description of the milestone", - }, - "start-date": { - type: "string", - description: "New start date", - valueHint: "<yyyy-MM-dd>", - }, - "release-due-date": { - type: "string", - description: "New release due date", - valueHint: "<yyyy-MM-dd>", - }, - archived: { - type: "boolean", - description: "Change whether the milestone is archived", - }, - }, - async run({ args }) { - const { client } = await getClient(); - - const milestone = await client.patchVersions(args.project, Number(args.milestone), { - name: args.name, - description: args.description, - startDate: args["start-date"], - releaseDueDate: args["release-due-date"], - archived: args.archived, - }); - - outputResult(milestone, args, (data) => { - consola.success(`Updated milestone ${data.name} (ID: ${data.id})`); - }); - }, - }), - commandUsage, -); - -export { commandUsage, edit }; + ]) + .action(async (milestone, opts, cmd) => { + await resolveOptions(cmd); + const { client } = await getClient(); + + const result = await client.patchVersions(opts.project, Number(milestone), { + name: opts.name, + description: opts.description, + startDate: opts.startDate, + releaseDueDate: opts.releaseDueDate, + archived: opts.archived, + }); + + outputResult(result, opts, (data) => { + consola.success(`Updated milestone ${data.name} (ID: ${data.id})`); + }); + }); + +export default edit; diff --git a/apps/cli/src/commands/milestone/list.test.ts b/apps/cli/src/commands/milestone/list.test.ts index 922ab899..fba4b8f8 100644 --- a/apps/cli/src/commands/milestone/list.test.ts +++ b/apps/cli/src/commands/milestone/list.test.ts @@ -36,8 +36,8 @@ describe("milestone list", () => { it("displays milestone list in tabular format", async () => { mockClient.getVersions.mockResolvedValue(sampleMilestones); - const { list } = await import("./list"); - await list.run?.({ args: { project: "TEST" } } as never); + const { default: list } = await import("./list"); + await list.parseAsync(["TEST"], { from: "user" }); expect(getClient).toHaveBeenCalled(); expect(mockClient.getVersions).toHaveBeenCalledWith("TEST"); @@ -49,8 +49,8 @@ describe("milestone list", () => { it("shows message when no milestones found", async () => { mockClient.getVersions.mockResolvedValue([]); - const { list } = await import("./list"); - await list.run?.({ args: { project: "TEST" } } as never); + const { default: list } = await import("./list"); + await list.parseAsync(["TEST"], { from: "user" }); expect(consola.info).toHaveBeenCalledWith("No milestones found."); }); @@ -58,8 +58,8 @@ describe("milestone list", () => { it("displays archived status", async () => { mockClient.getVersions.mockResolvedValue(sampleMilestones); - const { list } = await import("./list"); - await list.run?.({ args: { project: "TEST" } } as never); + const { default: list } = await import("./list"); + await list.parseAsync(["TEST"], { from: "user" }); expect(consola.log).toHaveBeenCalledWith(expect.stringContaining("Yes")); }); @@ -68,8 +68,8 @@ describe("milestone list", () => { mockClient.getVersions.mockResolvedValue(sampleMilestones); await expectStdoutContaining(async () => { - const { list } = await import("./list"); - await list.run?.({ args: { project: "TEST", json: "" } } as never); + const { default: list } = await import("./list"); + await list.parseAsync(["TEST", "--json"], { from: "user" }); }, "v1.0.0"); }); }); diff --git a/apps/cli/src/commands/milestone/list.ts b/apps/cli/src/commands/milestone/list.ts index 7c39f919..19a0fc61 100644 --- a/apps/cli/src/commands/milestone/list.ts +++ b/apps/cli/src/commands/milestone/list.ts @@ -1,60 +1,45 @@ import { getClient } from "@repo/backlog-utils"; -import { type Row, outputArgs, outputResult, printTable } from "@repo/cli-utils"; -import { defineCommand } from "citty"; +import { type Row, outputResult, printTable } from "@repo/cli-utils"; import consola from "consola"; -import { type CommandUsage, ENV_AUTH, ENV_PROJECT, withUsage } from "../../lib/command-usage"; -import * as commonArgs from "../../lib/common-args"; +import { BeeCommand, ENV_AUTH, ENV_PROJECT } from "../../lib/bee-command"; +import * as opt from "../../lib/common-options"; -const commandUsage: CommandUsage = { - long: `List milestones in a Backlog project. +const list = new BeeCommand("list") + .summary("List milestones") + .description( + `List milestones in a Backlog project. Milestones (also known as versions) help track release schedules and group issues by development cycle.`, - - examples: [ + ) + .argument("[project]", "Project ID or project key") + .addOption(opt.json()) + .envVars([...ENV_AUTH, ENV_PROJECT]) + .examples([ { description: "List all milestones", command: "bee milestone list PROJECT" }, { description: "Output as JSON", command: "bee milestone list PROJECT --json" }, - ], - - annotations: { - environment: [...ENV_AUTH, ENV_PROJECT], - }, -}; - -const list = withUsage( - defineCommand({ - meta: { - name: "list", - description: "List milestones", - }, - args: { - ...outputArgs, - project: commonArgs.projectPositional, - }, - async run({ args }) { - const { client } = await getClient(); - - const milestones = await client.getVersions(args.project); - - outputResult(milestones, args, (data) => { - if (data.length === 0) { - consola.info("No milestones found."); - return; - } - - const rows: Row[] = data.map((m) => [ - { header: "ID", value: String(m.id) }, - { header: "NAME", value: m.name }, - { header: "START DATE", value: m.startDate?.slice(0, 10) ?? "" }, - { header: "RELEASE DUE DATE", value: m.releaseDueDate?.slice(0, 10) ?? "" }, - { header: "ARCHIVED", value: m.archived ? "Yes" : "No" }, - ]); - - printTable(rows); - }); - }, - }), - commandUsage, -); - -export { commandUsage, list }; + ]) + .action(async (project, opts) => { + const { client } = await getClient(); + + const milestones = await client.getVersions(project); + + outputResult(milestones, opts, (data) => { + if (data.length === 0) { + consola.info("No milestones found."); + return; + } + + const rows: Row[] = data.map((m) => [ + { header: "ID", value: String(m.id) }, + { header: "NAME", value: m.name }, + { header: "START DATE", value: m.startDate?.slice(0, 10) ?? "" }, + { header: "RELEASE DUE DATE", value: m.releaseDueDate?.slice(0, 10) ?? "" }, + { header: "ARCHIVED", value: m.archived ? "Yes" : "No" }, + ]); + + printTable(rows); + }); + }); + +export default list; diff --git a/apps/cli/src/commands/notification/count.test.ts b/apps/cli/src/commands/notification/count.test.ts index fd29efac..ae73bed8 100644 --- a/apps/cli/src/commands/notification/count.test.ts +++ b/apps/cli/src/commands/notification/count.test.ts @@ -17,8 +17,8 @@ describe("notification count", () => { it("counts all notifications when no flags are set", async () => { mockClient.getNotificationsCount.mockResolvedValue({ count: 42 }); - const { count } = await import("./count"); - await count.run?.({ args: {} } as never); + const { default: count } = await import("./count"); + await count.parseAsync([], { from: "user" }); expect(getClient).toHaveBeenCalled(); expect(mockClient.getNotificationsCount).toHaveBeenCalledWith({}); @@ -28,8 +28,8 @@ describe("notification count", () => { it("filters read notifications with --already-read read", async () => { mockClient.getNotificationsCount.mockResolvedValue({ count: 10 }); - const { count } = await import("./count"); - await count.run?.({ args: { "already-read": "read" } } as never); + const { default: count } = await import("./count"); + await count.parseAsync(["--already-read", "read"], { from: "user" }); expect(mockClient.getNotificationsCount).toHaveBeenCalledWith( expect.objectContaining({ alreadyRead: true }), @@ -39,8 +39,8 @@ describe("notification count", () => { it("filters unread notifications with --already-read unread", async () => { mockClient.getNotificationsCount.mockResolvedValue({ count: 3 }); - const { count } = await import("./count"); - await count.run?.({ args: { "already-read": "unread" } } as never); + const { default: count } = await import("./count"); + await count.parseAsync(["--already-read", "unread"], { from: "user" }); expect(mockClient.getNotificationsCount).toHaveBeenCalledWith( expect.objectContaining({ alreadyRead: false }), @@ -50,8 +50,8 @@ describe("notification count", () => { it("counts all with --already-read all", async () => { mockClient.getNotificationsCount.mockResolvedValue({ count: 50 }); - const { count } = await import("./count"); - await count.run?.({ args: { "already-read": "all" } } as never); + const { default: count } = await import("./count"); + await count.parseAsync(["--already-read", "all"], { from: "user" }); expect(mockClient.getNotificationsCount).toHaveBeenCalledWith({}); }); @@ -59,8 +59,8 @@ describe("notification count", () => { it("filters by resource-already-read", async () => { mockClient.getNotificationsCount.mockResolvedValue({ count: 5 }); - const { count } = await import("./count"); - await count.run?.({ args: { "resource-already-read": "read" } } as never); + const { default: count } = await import("./count"); + await count.parseAsync(["--resource-already-read", "read"], { from: "user" }); expect(mockClient.getNotificationsCount).toHaveBeenCalledWith( expect.objectContaining({ resourceAlreadyRead: true }), @@ -71,8 +71,8 @@ describe("notification count", () => { mockClient.getNotificationsCount.mockResolvedValue({ count: 42 }); await expectStdoutContaining(async () => { - const { count } = await import("./count"); - await count.run?.({ args: { json: "" } } as never); + const { default: count } = await import("./count"); + await count.parseAsync(["--json"], { from: "user" }); }, "42"); }); }); diff --git a/apps/cli/src/commands/notification/count.ts b/apps/cli/src/commands/notification/count.ts index 679af8d9..0bd17331 100644 --- a/apps/cli/src/commands/notification/count.ts +++ b/apps/cli/src/commands/notification/count.ts @@ -1,8 +1,8 @@ import { getClient } from "@repo/backlog-utils"; -import { outputArgs, outputResult } from "@repo/cli-utils"; -import { defineCommand } from "citty"; +import { outputResult } from "@repo/cli-utils"; import consola from "consola"; -import { type CommandUsage, ENV_AUTH, withUsage } from "../../lib/command-usage"; +import { BeeCommand, ENV_AUTH } from "../../lib/bee-command"; +import * as opt from "../../lib/common-options"; const parseReadFilter = (value: string | undefined): boolean | undefined => { if (value === undefined || value === "all") { @@ -11,16 +11,28 @@ const parseReadFilter = (value: string | undefined): boolean | undefined => { return value === "read"; }; -const commandUsage: CommandUsage = { - long: `Display the count of notifications for the authenticated user. +const count = new BeeCommand("count") + .summary("Count notifications") + .description( + `Display the count of notifications for the authenticated user. By default, returns the count of all notifications regardless of read status. Use \`--already-read\` and \`--resource-already-read\` to filter by read status. For details, see: https://developer.nulab.com/docs/backlog/api/2/count-notifications/`, - - examples: [ + ) + .option( + "--already-read <value>", + "Filter by read status. If omitted, count all. {read|unread|all}", + ) + .option( + "--resource-already-read <value>", + "Filter by resource read status. If omitted, count all. {read|unread|all}", + ) + .addOption(opt.json()) + .envVars([...ENV_AUTH]) + .examples([ { description: "Count all notifications", command: "bee notification count" }, { description: "Count only unread notifications", @@ -31,57 +43,29 @@ https://developer.nulab.com/docs/backlog/api/2/count-notifications/`, command: "bee notification count --already-read read", }, { description: "Output as JSON", command: "bee notification count --json" }, - ], + ]) + .action(async (opts) => { + const { client } = await getClient(); - annotations: { - environment: [...ENV_AUTH], - }, -}; + const alreadyRead = parseReadFilter(opts.alreadyRead); + const resourceAlreadyRead = parseReadFilter(opts.resourceAlreadyRead); -const count = withUsage( - defineCommand({ - meta: { - name: "count", - description: "Count notifications", - }, - args: { - ...outputArgs, - "already-read": { - type: "string", - description: "Filter by read status. If omitted, count all.", - valueHint: "{read|unread|all}", - }, - "resource-already-read": { - type: "string", - description: "Filter by resource read status. If omitted, count all.", - valueHint: "{read|unread|all}", - }, - }, - async run({ args }) { - const { client } = await getClient(); + const params: Record<string, boolean> = {}; + if (alreadyRead !== undefined) { + params.alreadyRead = alreadyRead; + } + if (resourceAlreadyRead !== undefined) { + params.resourceAlreadyRead = resourceAlreadyRead; + } - const alreadyRead = parseReadFilter(args["already-read"]); - const resourceAlreadyRead = parseReadFilter(args["resource-already-read"]); + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- backlog-js types require both fields but API accepts partial params + const result = await client.getNotificationsCount( + params as unknown as Parameters<typeof client.getNotificationsCount>[0], + ); - const params: Record<string, boolean> = {}; - if (alreadyRead !== undefined) { - params.alreadyRead = alreadyRead; - } - if (resourceAlreadyRead !== undefined) { - params.resourceAlreadyRead = resourceAlreadyRead; - } - - // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- backlog-js types require both fields but API accepts partial params - const result = await client.getNotificationsCount( - params as unknown as Parameters<typeof client.getNotificationsCount>[0], - ); - - outputResult(result, args, (data) => { - consola.log(String(data.count)); - }); - }, - }), - commandUsage, -); + outputResult(result, opts, (data) => { + consola.log(String(data.count)); + }); + }); -export { commandUsage, count }; +export default count; diff --git a/apps/cli/src/commands/notification/list.test.ts b/apps/cli/src/commands/notification/list.test.ts index f8e9e3f4..9de6470c 100644 --- a/apps/cli/src/commands/notification/list.test.ts +++ b/apps/cli/src/commands/notification/list.test.ts @@ -37,8 +37,8 @@ describe("notification list", () => { }, ]); - const { list } = await import("./list"); - await list.run?.({ args: {} } as never); + const { default: list } = await import("./list"); + await list.parseAsync([], { from: "user" }); expect(getClient).toHaveBeenCalled(); expect(mockClient.getNotifications).toHaveBeenCalled(); @@ -59,8 +59,8 @@ describe("notification list", () => { }, ]); - const { list } = await import("./list"); - await list.run?.({ args: {} } as never); + const { default: list } = await import("./list"); + await list.parseAsync([], { from: "user" }); expect(consola.log).toHaveBeenCalledWith(expect.stringContaining("*")); }); @@ -68,8 +68,8 @@ describe("notification list", () => { it("shows message when no notifications found", async () => { mockClient.getNotifications.mockResolvedValue([]); - const { list } = await import("./list"); - await list.run?.({ args: {} } as never); + const { default: list } = await import("./list"); + await list.parseAsync([], { from: "user" }); expect(consola.info).toHaveBeenCalledWith("No notifications found."); }); @@ -77,10 +77,10 @@ describe("notification list", () => { it("passes limit, min-id, max-id, and order parameters", async () => { mockClient.getNotifications.mockResolvedValue([]); - const { list } = await import("./list"); - await list.run?.({ - args: { count: "5", "min-id": "10", "max-id": "100", order: "asc" }, - } as never); + const { default: list } = await import("./list"); + await list.parseAsync(["--count", "5", "--min-id", "10", "--max-id", "100", "--order", "asc"], { + from: "user", + }); expect(mockClient.getNotifications).toHaveBeenCalledWith({ count: 5, @@ -104,8 +104,8 @@ describe("notification list", () => { ]); await expectStdoutContaining(async () => { - const { list } = await import("./list"); - await list.run?.({ args: { json: "" } } as never); + const { default: list } = await import("./list"); + await list.parseAsync(["--json"], { from: "user" }); }, "PROJ-1"); }); }); diff --git a/apps/cli/src/commands/notification/list.ts b/apps/cli/src/commands/notification/list.ts index 889c129d..decb00ac 100644 --- a/apps/cli/src/commands/notification/list.ts +++ b/apps/cli/src/commands/notification/list.ts @@ -1,18 +1,25 @@ import { NOTIFICATION_REASON_LABELS, getClient } from "@repo/backlog-utils"; -import { type Row, formatDate, outputArgs, outputResult, printTable } from "@repo/cli-utils"; -import { defineCommand } from "citty"; +import { type Row, formatDate, outputResult, printTable } from "@repo/cli-utils"; import consola from "consola"; -import { type CommandUsage, ENV_AUTH, withUsage } from "../../lib/command-usage"; -import * as commonArgs from "../../lib/common-args"; +import { BeeCommand, ENV_AUTH } from "../../lib/bee-command"; +import * as opt from "../../lib/common-options"; -const commandUsage: CommandUsage = { - long: `List notifications for the authenticated user. +const list = new BeeCommand("list") + .summary("List notifications") + .description( + `List notifications for the authenticated user. Unread notifications are marked with an asterisk (\`*\`). Use \`--count\` to control the number of notifications returned, and \`--min-id\` / \`--max-id\` for cursor-based pagination.`, - - examples: [ + ) + .addOption(opt.count()) + .addOption(opt.minId()) + .addOption(opt.maxId()) + .addOption(opt.order()) + .addOption(opt.json()) + .envVars([...ENV_AUTH]) + .examples([ { description: "List recent notifications", command: "bee notification list" }, { description: "List the last 5 notifications", command: "bee notification list --count 5" }, { @@ -20,57 +27,35 @@ for cursor-based pagination.`, command: "bee notification list --order asc", }, { description: "Output as JSON", command: "bee notification list --json" }, - ], - - annotations: { - environment: [...ENV_AUTH], - }, -}; - -const list = withUsage( - defineCommand({ - meta: { - name: "list", - description: "List notifications", - }, - args: { - ...outputArgs, - count: commonArgs.count, - "min-id": commonArgs.minId, - "max-id": commonArgs.maxId, - order: commonArgs.order, - }, - async run({ args }) { - const { client } = await getClient(); - - const notifications = await client.getNotifications({ - count: args.count ? Number(args.count) : undefined, - minId: args["min-id"] ? Number(args["min-id"]) : undefined, - maxId: args["max-id"] ? Number(args["max-id"]) : undefined, - order: args.order as "asc" | "desc" | undefined, - }); - - outputResult(notifications, args, (data) => { - if (data.length === 0) { - consola.info("No notifications found."); - return; - } - - const rows: Row[] = data.map((n) => [ - { header: "", value: n.alreadyRead ? " " : "*" }, - { header: "ID", value: String(n.id) }, - { header: "REASON", value: NOTIFICATION_REASON_LABELS[n.reason] ?? String(n.reason) }, - { header: "ISSUE", value: n.issue?.issueKey ?? "-" }, - { header: "SUMMARY", value: n.issue?.summary ?? "-" }, - { header: "SENDER", value: n.sender.name }, - { header: "DATE", value: formatDate(n.created) }, - ]); - - printTable(rows); - }); - }, - }), - commandUsage, -); - -export { commandUsage, list }; + ]) + .action(async (opts) => { + const { client } = await getClient(); + + const notifications = await client.getNotifications({ + count: opts.count ? Number(opts.count) : undefined, + minId: opts.minId ? Number(opts.minId) : undefined, + maxId: opts.maxId ? Number(opts.maxId) : undefined, + order: opts.order, + }); + + outputResult(notifications, opts, (data) => { + if (data.length === 0) { + consola.info("No notifications found."); + return; + } + + const rows: Row[] = data.map((n) => [ + { header: "", value: n.alreadyRead ? " " : "*" }, + { header: "ID", value: String(n.id) }, + { header: "REASON", value: NOTIFICATION_REASON_LABELS[n.reason] ?? String(n.reason) }, + { header: "ISSUE", value: n.issue?.issueKey ?? "-" }, + { header: "SUMMARY", value: n.issue?.summary ?? "-" }, + { header: "SENDER", value: n.sender.name }, + { header: "DATE", value: formatDate(n.created) }, + ]); + + printTable(rows); + }); + }); + +export default list; diff --git a/apps/cli/src/commands/notification/read-all.test.ts b/apps/cli/src/commands/notification/read-all.test.ts index c3ff5e03..7feedd2e 100644 --- a/apps/cli/src/commands/notification/read-all.test.ts +++ b/apps/cli/src/commands/notification/read-all.test.ts @@ -16,8 +16,8 @@ describe("notification read-all", () => { it("marks all notifications as read", async () => { mockClient.resetNotificationsMarkAsRead.mockResolvedValue({ count: 0 }); - const { readAll } = await import("./read-all"); - await readAll.run?.({ args: {} } as never); + const { default: readAll } = await import("./read-all"); + await readAll.parseAsync([], { from: "user" }); expect(getClient).toHaveBeenCalled(); expect(mockClient.resetNotificationsMarkAsRead).toHaveBeenCalled(); diff --git a/apps/cli/src/commands/notification/read-all.ts b/apps/cli/src/commands/notification/read-all.ts index ff0728c1..09f88db7 100644 --- a/apps/cli/src/commands/notification/read-all.ts +++ b/apps/cli/src/commands/notification/read-all.ts @@ -1,38 +1,22 @@ import { getClient } from "@repo/backlog-utils"; -import { defineCommand } from "citty"; import consola from "consola"; -import { type CommandUsage, ENV_AUTH, withUsage } from "../../lib/command-usage"; +import { BeeCommand, ENV_AUTH } from "../../lib/bee-command"; -const commandUsage: CommandUsage = { - long: `Mark all notifications as read. +const readAll = new BeeCommand("read-all") + .summary("Mark all notifications as read") + .description(`Mark all notifications as read. -This resets the unread notification count to zero.`, - - examples: [ +This resets the unread notification count to zero.`) + .envVars([...ENV_AUTH]) + .examples([ { description: "Mark all notifications as read", command: "bee notification read-all" }, - ], - - annotations: { - environment: [...ENV_AUTH], - }, -}; - -const readAll = withUsage( - defineCommand({ - meta: { - name: "read-all", - description: "Mark all notifications as read", - }, - args: {}, - async run() { - const { client } = await getClient(); + ]) + .action(async () => { + const { client } = await getClient(); - await client.resetNotificationsMarkAsRead(); + await client.resetNotificationsMarkAsRead(); - consola.success("Marked all notifications as read."); - }, - }), - commandUsage, -); + consola.success("Marked all notifications as read."); + }); -export { commandUsage, readAll }; +export default readAll; diff --git a/apps/cli/src/commands/notification/read.test.ts b/apps/cli/src/commands/notification/read.test.ts index 6e1b5ca9..ed50dc01 100644 --- a/apps/cli/src/commands/notification/read.test.ts +++ b/apps/cli/src/commands/notification/read.test.ts @@ -16,8 +16,8 @@ describe("notification read", () => { it("marks a notification as read", async () => { mockClient.markAsReadNotification.mockResolvedValue(undefined); - const { read } = await import("./read"); - await read.run?.({ args: { id: "12345" } } as never); + const { default: read } = await import("./read"); + await read.parseAsync(["12345"], { from: "user" }); expect(getClient).toHaveBeenCalled(); expect(mockClient.markAsReadNotification).toHaveBeenCalledWith(12_345); @@ -27,8 +27,8 @@ describe("notification read", () => { it("converts string ID to number", async () => { mockClient.markAsReadNotification.mockResolvedValue(undefined); - const { read } = await import("./read"); - await read.run?.({ args: { id: "99" } } as never); + const { default: read } = await import("./read"); + await read.parseAsync(["99"], { from: "user" }); expect(mockClient.markAsReadNotification).toHaveBeenCalledWith(99); }); diff --git a/apps/cli/src/commands/notification/read.ts b/apps/cli/src/commands/notification/read.ts index 0210a35b..88ce959a 100644 --- a/apps/cli/src/commands/notification/read.ts +++ b/apps/cli/src/commands/notification/read.ts @@ -1,46 +1,26 @@ import { getClient } from "@repo/backlog-utils"; -import { defineCommand } from "citty"; import consola from "consola"; -import { type CommandUsage, ENV_AUTH, withUsage } from "../../lib/command-usage"; +import { BeeCommand, ENV_AUTH } from "../../lib/bee-command"; -const commandUsage: CommandUsage = { - long: `Mark a notification as read. +const read = new BeeCommand("read") + .summary("Mark a notification as read") + .description( + `Mark a notification as read. Specify the notification ID to mark as read. Use \`bee notification list\` to find notification IDs.`, - - examples: [ + ) + .argument("<id>", "Notification ID") + .envVars([...ENV_AUTH]) + .examples([ { description: "Mark a notification as read", command: "bee notification read 12345" }, - ], - - annotations: { - environment: [...ENV_AUTH], - }, -}; - -const read = withUsage( - defineCommand({ - meta: { - name: "read", - description: "Mark a notification as read", - }, - args: { - id: { - type: "positional", - description: "Notification ID", - required: true, - valueHint: "<number>", - }, - }, - async run({ args }) { - const { client } = await getClient(); + ]) + .action(async (id) => { + const { client } = await getClient(); - await client.markAsReadNotification(Number(args.id)); + await client.markAsReadNotification(Number(id)); - consola.success(`Marked notification ${args.id} as read.`); - }, - }), - commandUsage, -); + consola.success(`Marked notification ${id} as read.`); + }); -export { commandUsage, read }; +export default read; diff --git a/apps/cli/src/commands/pr/comment.ts b/apps/cli/src/commands/pr/comment.ts index 3fb194df..463f1462 100644 --- a/apps/cli/src/commands/pr/comment.ts +++ b/apps/cli/src/commands/pr/comment.ts @@ -40,22 +40,17 @@ Use \`--edit-last\` to edit your most recent comment.`, command: 'bee pr comment 42 -p PROJECT -R repo --edit-last -b "Updated"', }, ]) - .action(async (number, _opts, cmd) => { - const opts = await resolveOptions(cmd); + .action(async (number, opts, cmd) => { + await resolveOptions(cmd); const { client } = await getClient(); const prNumber = Number(number); - const json = opts.json === true ? "" : (opts.json as string | undefined); + const json = opts.json === true ? "" : opts.json; if (opts.list) { - const comments = await client.getPullRequestComments( - opts.project as string, - opts.repo as string, - prNumber, - { - order: "asc", - }, - ); + const comments = await client.getPullRequestComments(opts.project, opts.repo, prNumber, { + order: "asc", + }); outputResult(comments, { json }, (data) => { const filtered = data.filter((c) => c.content); @@ -78,14 +73,9 @@ Use \`--edit-last\` to edit your most recent comment.`, if (opts.editLast) { const myself = await client.getMyself(); - const comments = await client.getPullRequestComments( - opts.project as string, - opts.repo as string, - prNumber, - { - order: "desc", - }, - ); + const comments = await client.getPullRequestComments(opts.project, opts.repo, prNumber, { + order: "desc", + }); const myComment = comments.find((c) => c.createdUser.id === myself.id); if (!myComment) { @@ -93,18 +83,18 @@ Use \`--edit-last\` to edit your most recent comment.`, return; } - const content = (await resolveStdinArg(opts.body as string | undefined)) ?? opts.body; + const content = (await resolveStdinArg(opts.body)) ?? opts.body; if (!content) { consola.error("Comment body is required. Use --body or pipe input."); return; } const result = await client.patchPullRequestComments( - opts.project as string, - opts.repo as string, + opts.project, + opts.repo, prNumber, myComment.id, - { content: content as string }, + { content }, ); outputResult(result, { json }, () => { @@ -114,22 +104,17 @@ Use \`--edit-last\` to edit your most recent comment.`, } // Default: add comment - const content = (await resolveStdinArg(opts.body as string | undefined)) ?? opts.body; + const content = (await resolveStdinArg(opts.body)) ?? opts.body; if (!content) { consola.error("Comment body is required. Use --body or pipe input."); return; } - const notifiedUserId = (opts.notify as number[]) ?? []; - - const result = await client.postPullRequestComments( - opts.project as string, - opts.repo as string, - prNumber, - { - content: content as string, - notifiedUserId, - }, - ); + const notifiedUserId = opts.notify ?? []; + + const result = await client.postPullRequestComments(opts.project, opts.repo, prNumber, { + content, + notifiedUserId, + }); outputResult(result, { json }, () => { consola.success(`Added comment to pull request #${number}`); diff --git a/apps/cli/src/commands/pr/comments.ts b/apps/cli/src/commands/pr/comments.ts index 18edd4b9..5f25a910 100644 --- a/apps/cli/src/commands/pr/comments.ts +++ b/apps/cli/src/commands/pr/comments.ts @@ -28,25 +28,20 @@ Displays all comments in chronological order with the author and date.`, }, { description: "Output as JSON", command: "bee pr comments 42 -p PROJECT -R repo --json" }, ]) - .action(async (number, _opts, cmd) => { - const opts = await resolveOptions(cmd); + .action(async (number, opts, cmd) => { + await resolveOptions(cmd); const { client } = await getClient(); const prNumber = Number(number); - const prComments = await client.getPullRequestComments( - opts.project as string, - opts.repo as string, - prNumber, - { - minId: opts.minId ? Number(opts.minId) : undefined, - maxId: opts.maxId ? Number(opts.maxId) : undefined, - order: (opts.order as "asc" | "desc") ?? "asc", - count: opts.count ? Number(opts.count) : undefined, - }, - ); + const prComments = await client.getPullRequestComments(opts.project, opts.repo, prNumber, { + minId: opts.minId ? Number(opts.minId) : undefined, + maxId: opts.maxId ? Number(opts.maxId) : undefined, + order: opts.order ?? "asc", + count: opts.count ? Number(opts.count) : undefined, + }); - const json = opts.json === true ? "" : (opts.json as string | undefined); + const json = opts.json === true ? "" : opts.json; outputResult(prComments, { json }, (data) => { const contentComments = data.filter((c) => c.content); diff --git a/apps/cli/src/commands/pr/count.ts b/apps/cli/src/commands/pr/count.ts index 3802647d..467a1204 100644 --- a/apps/cli/src/commands/pr/count.ts +++ b/apps/cli/src/commands/pr/count.ts @@ -30,16 +30,16 @@ by default, or a JSON object with \`--json\`.`, }, { description: "Output as JSON", command: "bee pr count -p PROJECT -R repo --json" }, ]) - .action(async (_opts, cmd) => { - const opts = await resolveOptions(cmd); + .action(async (opts, cmd) => { + await resolveOptions(cmd); const { client } = await getClient(); const statusId = opts.status - ? (opts.status as string) + ? opts.status .split(",") - .map((s) => s.trim()) + .map((s: string) => s.trim()) .filter(Boolean) - .map((name) => { + .map((name: string) => { const id = PrStatusName[name.toLowerCase()]; if (id === undefined) { throw new Error( @@ -50,20 +50,18 @@ by default, or a JSON object with \`--json\`.`, }) : undefined; - const assigneeId = ((opts.assignee as string[]) ?? []) - .map(Number) - .filter((n) => !Number.isNaN(n)); - const issueId = splitArg(opts.issue as string | undefined, v.number()); - const createdUserId = splitArg(opts.createdUser as string | undefined, v.number()); + const assigneeId = (opts.assignee ?? []).map(Number).filter((n: number) => !Number.isNaN(n)); + const issueId = splitArg(opts.issue, v.number()); + const createdUserId = splitArg(opts.createdUser, v.number()); - const result = await client.getPullRequestsCount(opts.project as string, opts.repo as string, { + const result = await client.getPullRequestsCount(opts.project, opts.repo, { statusId, assigneeId: assigneeId.length > 0 ? assigneeId : undefined, issueId: issueId.length > 0 ? issueId : undefined, createdUserId: createdUserId.length > 0 ? createdUserId : undefined, }); - const json = opts.json === true ? "" : (opts.json as string | undefined); + const json = opts.json === true ? "" : opts.json; outputResult(result, { json }, (data) => { consola.log(data.count); }); diff --git a/apps/cli/src/commands/pr/create.ts b/apps/cli/src/commands/pr/create.ts index 915c892a..d8986dcc 100644 --- a/apps/cli/src/commands/pr/create.ts +++ b/apps/cli/src/commands/pr/create.ts @@ -42,32 +42,30 @@ interactively, omitted required fields will be prompted.`, 'bee pr create -p PROJECT -R repo --base main --head feature -t "Title" -b "Desc" --issue 123', }, ]) - .action(async (_opts, cmd) => { - const opts = await resolveOptions(cmd); + .action(async (opts, cmd) => { + await resolveOptions(cmd); const { client } = await getClient(); - const base = await promptRequired("Base branch:", opts.base as string | undefined); - const head = await promptRequired("Head branch:", opts.head as string | undefined); - const summary = await promptRequired("Summary:", opts.title as string | undefined); - const description = await promptRequired("Body:", opts.body as string | undefined); + const base = await promptRequired("Base branch:", opts.base); + const head = await promptRequired("Head branch:", opts.head); + const summary = await promptRequired("Summary:", opts.title); + const description = await promptRequired("Body:", opts.body); - const assigneeId = opts.assignee - ? await resolveUserId(client, opts.assignee as string) - : undefined; - const notifiedUserId = (opts.notify as number[]) ?? []; - const attachmentId = (opts.attachment as number[]) ?? []; + const assigneeId = opts.assignee ? await resolveUserId(client, opts.assignee) : undefined; + const notifiedUserId = opts.notify ?? []; + const attachmentId = opts.attachment ?? []; let issueId: number | undefined; if (opts.issue) { if (Number.isNaN(Number(opts.issue))) { - const issue = await client.getIssue(opts.issue as string); + const issue = await client.getIssue(opts.issue); issueId = issue.id; } else { issueId = Number(opts.issue); } } - const pullRequest = await client.postPullRequest(opts.project as string, opts.repo as string, { + const pullRequest = await client.postPullRequest(opts.project, opts.repo, { summary, description, base, @@ -78,7 +76,7 @@ interactively, omitted required fields will be prompted.`, attachmentId, }); - const json = opts.json === true ? "" : (opts.json as string | undefined); + const json = opts.json === true ? "" : opts.json; outputResult(pullRequest, { json }, (data) => { consola.success(`Created pull request #${data.number}: ${data.summary}`); }); diff --git a/apps/cli/src/commands/pr/edit.ts b/apps/cli/src/commands/pr/edit.ts index b73ce527..dd05a943 100644 --- a/apps/cli/src/commands/pr/edit.ts +++ b/apps/cli/src/commands/pr/edit.ts @@ -38,41 +38,33 @@ will remain unchanged.`, command: 'bee pr edit 42 -p PROJECT -R repo -t "New title" --comment "Updated title"', }, ]) - .action(async (number, _opts, cmd) => { - const opts = await resolveOptions(cmd); + .action(async (number, opts, cmd) => { + await resolveOptions(cmd); const { client } = await getClient(); const prNumber = Number(number); - const notifiedUserId = (opts.notify as number[]) ?? []; + const notifiedUserId = opts.notify ?? []; let issueId: number | undefined; if (opts.issue) { if (Number.isNaN(Number(opts.issue))) { - const issue = await client.getIssue(opts.issue as string); + const issue = await client.getIssue(opts.issue); issueId = issue.id; } else { issueId = Number(opts.issue); } } - const pullRequest = await client.patchPullRequest( - opts.project as string, - opts.repo as string, - prNumber, - { - summary: opts.title as string | undefined, - description: opts.body as string | undefined, - issueId, - assigneeId: opts.assignee - ? await resolveUserId(client, opts.assignee as string) - : undefined, - // @ts-expect-error backlog-js types say string[] but Backlog API accepts a single string - comment: opts.comment ?? undefined, - notifiedUserId, - }, - ); + const pullRequest = await client.patchPullRequest(opts.project, opts.repo, prNumber, { + summary: opts.title, + description: opts.body, + issueId, + assigneeId: opts.assignee ? await resolveUserId(client, opts.assignee) : undefined, + comment: opts.comment ?? undefined, + notifiedUserId, + }); - const json = opts.json === true ? "" : (opts.json as string | undefined); + const json = opts.json === true ? "" : opts.json; outputResult(pullRequest, { json }, (data) => { consola.success(`Updated pull request #${data.number}: ${data.summary}`); }); diff --git a/apps/cli/src/commands/pr/list.ts b/apps/cli/src/commands/pr/list.ts index a47e1453..38b8143c 100644 --- a/apps/cli/src/commands/pr/list.ts +++ b/apps/cli/src/commands/pr/list.ts @@ -36,16 +36,16 @@ status (open, closed, merged).`, }, { description: "Output as JSON", command: "bee pr list -p PROJECT -R repo --json" }, ]) - .action(async (_opts, cmd) => { - const opts = await resolveOptions(cmd); + .action(async (opts, cmd) => { + await resolveOptions(cmd); const { client } = await getClient(); const statusId = opts.status - ? (opts.status as string) + ? opts.status .split(",") - .map((s) => s.trim()) + .map((s: string) => s.trim()) .filter(Boolean) - .map((name) => { + .map((name: string) => { const id = PrStatusName[name.toLowerCase()]; if (id === undefined) { throw new Error( @@ -56,13 +56,11 @@ status (open, closed, merged).`, }) : undefined; - const assigneeId = ((opts.assignee as string[]) ?? []) - .map(Number) - .filter((n) => !Number.isNaN(n)); - const issueId = splitArg(opts.issue as string | undefined, v.number()); - const createdUserId = splitArg(opts.createdUser as string | undefined, v.number()); + const assigneeId = (opts.assignee ?? []).map(Number).filter((n: number) => !Number.isNaN(n)); + const issueId = splitArg(opts.issue, v.number()); + const createdUserId = splitArg(opts.createdUser, v.number()); - const pullRequests = await client.getPullRequests(opts.project as string, opts.repo as string, { + const pullRequests = await client.getPullRequests(opts.project, opts.repo, { statusId, assigneeId: assigneeId.length > 0 ? assigneeId : undefined, issueId: issueId.length > 0 ? issueId : undefined, @@ -71,7 +69,7 @@ status (open, closed, merged).`, offset: opts.offset ? Number(opts.offset) : undefined, }); - const json = opts.json === true ? "" : (opts.json as string | undefined); + const json = opts.json === true ? "" : opts.json; outputResult(pullRequests, { json }, (data) => { if (data.length === 0) { consola.info("No pull requests found."); diff --git a/apps/cli/src/commands/pr/status.ts b/apps/cli/src/commands/pr/status.ts index eba6e35c..85e237b9 100644 --- a/apps/cli/src/commands/pr/status.ts +++ b/apps/cli/src/commands/pr/status.ts @@ -24,19 +24,19 @@ organized by their current status (Open, Closed, Merged).`, }, { description: "Output as JSON", command: "bee pr status -p PROJECT -R repo --json" }, ]) - .action(async (_opts, cmd) => { - const opts = await resolveOptions(cmd); + .action(async (opts, cmd) => { + await resolveOptions(cmd); const { client } = await getClient(); const me = await client.getMyself(); - const pullRequests = await client.getPullRequests(opts.project as string, opts.repo as string, { + const pullRequests = await client.getPullRequests(opts.project, opts.repo, { assigneeId: [me.id], statusId: [PrStatusId.Open, PrStatusId.Closed, PrStatusId.Merged], count: 100, }); - const json = opts.json === true ? "" : (opts.json as string | undefined); + const json = opts.json === true ? "" : opts.json; if (pullRequests.length === 0) { outputResult({ user: me, pullRequests: [] }, { json }, () => { diff --git a/apps/cli/src/commands/pr/view.ts b/apps/cli/src/commands/pr/view.ts index 33c61139..f8ff1440 100644 --- a/apps/cli/src/commands/pr/view.ts +++ b/apps/cli/src/commands/pr/view.ts @@ -30,25 +30,21 @@ Use \`--web\` to open the pull request in your default browser instead.`, }, { description: "Output as JSON", command: "bee pr view 42 -p PROJECT -R repo --json" }, ]) - .action(async (number, _opts, cmd) => { - const opts = await resolveOptions(cmd); + .action(async (number, opts, cmd) => { + await resolveOptions(cmd); const { client, host } = await getClient(); const prNumber = Number(number); if (opts.web || opts.browser === false) { - const url = pullRequestUrl(host, opts.project as string, opts.repo as string, prNumber); + const url = pullRequestUrl(host, opts.project, opts.repo, prNumber); await openOrPrintUrl(url, opts.browser === false, consola); return; } - const pullRequest = await client.getPullRequest( - opts.project as string, - opts.repo as string, - prNumber, - ); + const pullRequest = await client.getPullRequest(opts.project, opts.repo, prNumber); - const json = opts.json === true ? "" : (opts.json as string | undefined); + const json = opts.json === true ? "" : opts.json; outputResult(pullRequest, { json }, (data) => { consola.log(""); consola.log(` #${data.number}: ${data.summary}`); diff --git a/apps/cli/src/commands/project/activities.ts b/apps/cli/src/commands/project/activities.ts index 5c3795d5..2da817bd 100644 --- a/apps/cli/src/commands/project/activities.ts +++ b/apps/cli/src/commands/project/activities.ts @@ -68,8 +68,8 @@ https://developer.nulab.com/docs/backlog/api/2/get-project-recent-updates/#activ command: "bee project activities -p PROJECT_KEY --json", }, ]) - .action(async (_opts, cmd) => { - const opts = await resolveOptions(cmd); + .action(async (opts, cmd) => { + await resolveOptions(cmd); const { client } = await getClient(); @@ -77,13 +77,13 @@ https://developer.nulab.com/docs/backlog/api/2/get-project-recent-updates/#activ ? String(opts.activityType).split(",").map(Number) : undefined; - const activityList = await client.getProjectActivities(opts.project as string, { + const activityList = await client.getProjectActivities(opts.project, { activityTypeId, count: opts.count ? Number(opts.count) : undefined, - order: opts.order as "asc" | "desc" | undefined, + order: opts.order, }); - const jsonArg = opts.json === true ? "" : (opts.json as string | undefined); + const jsonArg = opts.json === true ? "" : opts.json; outputResult(activityList, { ...opts, json: jsonArg }, (data) => { if (data.length === 0) { consola.info("No activities found."); diff --git a/apps/cli/src/commands/project/add-user.ts b/apps/cli/src/commands/project/add-user.ts index fc73508a..37b1129c 100644 --- a/apps/cli/src/commands/project/add-user.ts +++ b/apps/cli/src/commands/project/add-user.ts @@ -3,8 +3,7 @@ import { UserError, outputResult } from "@repo/cli-utils"; import consola from "consola"; import { BeeCommand, ENV_AUTH, ENV_PROJECT } from "../../lib/bee-command"; import * as opt from "../../lib/common-options"; -import { resolveOptions } from "../../lib/required-option"; -import { RequiredOption } from "../../lib/required-option"; +import { RequiredOption, resolveOptions } from "../../lib/required-option"; const addUser = new BeeCommand("add-user") .summary("Add a user to a project") @@ -26,8 +25,8 @@ Requires Administrator or Project Administrator role.`, command: "bee project add-user -p PROJECT_KEY --user-id 12345", }, ]) - .action(async (_opts, cmd) => { - const opts = await resolveOptions(cmd); + .action(async (opts, cmd) => { + await resolveOptions(cmd); const userId = Number(opts.userId); if (Number.isNaN(userId)) { @@ -36,9 +35,9 @@ Requires Administrator or Project Administrator role.`, const { client } = await getClient(); - const user = await client.postProjectUser(opts.project as string, String(userId)); + const user = await client.postProjectUser(opts.project, String(userId)); - const jsonArg = opts.json === true ? "" : (opts.json as string | undefined); + const jsonArg = opts.json === true ? "" : opts.json; outputResult(user, { ...opts, json: jsonArg }, (data) => { consola.success(`Added user ${data.name} to project ${opts.project}.`); }); diff --git a/apps/cli/src/commands/project/delete.ts b/apps/cli/src/commands/project/delete.ts index 3b8cb9e8..4ea5b1e7 100644 --- a/apps/cli/src/commands/project/delete.ts +++ b/apps/cli/src/commands/project/delete.ts @@ -29,12 +29,12 @@ Requires Administrator role.`, command: "bee project delete -p PROJECT_KEY --yes", }, ]) - .action(async (_opts, cmd) => { - const opts = await resolveOptions(cmd); + .action(async (opts, cmd) => { + await resolveOptions(cmd); const confirmed = await confirmOrExit( `Are you sure you want to delete project ${opts.project}? This cannot be undone.`, - opts.yes as boolean | undefined, + opts.yes, ); if (!confirmed) { @@ -43,9 +43,9 @@ Requires Administrator role.`, const { client } = await getClient(); - const project = await client.deleteProject(opts.project as string); + const project = await client.deleteProject(opts.project); - const jsonArg = opts.json === true ? "" : (opts.json as string | undefined); + const jsonArg = opts.json === true ? "" : opts.json; outputResult(project, { ...opts, json: jsonArg }, (data) => { consola.success(`Deleted project ${data.projectKey}: ${data.name}`); }); diff --git a/apps/cli/src/commands/project/edit.ts b/apps/cli/src/commands/project/edit.ts index aa1325d2..bb39692d 100644 --- a/apps/cli/src/commands/project/edit.ts +++ b/apps/cli/src/commands/project/edit.ts @@ -40,24 +40,22 @@ will remain unchanged.`, command: "bee project edit -p PROJECT_KEY --text-formatting-rule markdown", }, ]) - .action(async (_opts, cmd) => { - const opts = await resolveOptions(cmd); + .action(async (opts, cmd) => { + await resolveOptions(cmd); const { client } = await getClient(); - const project = await client.patchProject(opts.project as string, { - name: opts.name as string | undefined, - key: opts.key as string | undefined, - chartEnabled: opts.chartEnabled as boolean | undefined, - subtaskingEnabled: opts.subtaskingEnabled as boolean | undefined, - projectLeaderCanEditProjectLeader: opts.projectLeaderCanEditProjectLeader as - | boolean - | undefined, - textFormattingRule: opts.textFormattingRule as "backlog" | "markdown" | undefined, - archived: opts.archived as boolean | undefined, + const project = await client.patchProject(opts.project, { + name: opts.name, + key: opts.key, + chartEnabled: opts.chartEnabled, + subtaskingEnabled: opts.subtaskingEnabled, + projectLeaderCanEditProjectLeader: opts.projectLeaderCanEditProjectLeader, + textFormattingRule: opts.textFormattingRule, + archived: opts.archived, }); - const jsonArg = opts.json === true ? "" : (opts.json as string | undefined); + const jsonArg = opts.json === true ? "" : opts.json; outputResult(project, { ...opts, json: jsonArg }, (data) => { consola.success(`Updated project ${data.projectKey}: ${data.name}`); }); diff --git a/apps/cli/src/commands/project/remove-user.ts b/apps/cli/src/commands/project/remove-user.ts index 58b67846..bb780b2b 100644 --- a/apps/cli/src/commands/project/remove-user.ts +++ b/apps/cli/src/commands/project/remove-user.ts @@ -3,8 +3,7 @@ import { UserError, outputResult } from "@repo/cli-utils"; import consola from "consola"; import { BeeCommand, ENV_AUTH, ENV_PROJECT } from "../../lib/bee-command"; import * as opt from "../../lib/common-options"; -import { resolveOptions } from "../../lib/required-option"; -import { RequiredOption } from "../../lib/required-option"; +import { RequiredOption, resolveOptions } from "../../lib/required-option"; const removeUser = new BeeCommand("remove-user") .summary("Remove a user from a project") @@ -26,8 +25,8 @@ Requires Administrator or Project Administrator role.`, command: "bee project remove-user -p PROJECT_KEY --user-id 12345", }, ]) - .action(async (_opts, cmd) => { - const opts = await resolveOptions(cmd); + .action(async (opts, cmd) => { + await resolveOptions(cmd); const userId = Number(opts.userId); if (Number.isNaN(userId)) { @@ -36,9 +35,9 @@ Requires Administrator or Project Administrator role.`, const { client } = await getClient(); - const user = await client.deleteProjectUsers(opts.project as string, { userId }); + const user = await client.deleteProjectUsers(opts.project, { userId }); - const jsonArg = opts.json === true ? "" : (opts.json as string | undefined); + const jsonArg = opts.json === true ? "" : opts.json; outputResult(user, { ...opts, json: jsonArg }, (data) => { consola.success(`Removed user ${data.name} from project ${opts.project}.`); }); diff --git a/apps/cli/src/commands/project/users.ts b/apps/cli/src/commands/project/users.ts index 910a6c47..a43c85bb 100644 --- a/apps/cli/src/commands/project/users.ts +++ b/apps/cli/src/commands/project/users.ts @@ -19,14 +19,14 @@ Displays each user's ID, user ID, name, and role within the project.`, { description: "List project members", command: "bee project users -p PROJECT_KEY" }, { description: "Output as JSON", command: "bee project users -p PROJECT_KEY --json" }, ]) - .action(async (_opts, cmd) => { - const opts = await resolveOptions(cmd); + .action(async (opts, cmd) => { + await resolveOptions(cmd); const { client } = await getClient(); - const members = await client.getProjectUsers(opts.project as string); + const members = await client.getProjectUsers(opts.project); - const jsonArg = opts.json === true ? "" : (opts.json as string | undefined); + const jsonArg = opts.json === true ? "" : opts.json; outputResult(members, { ...opts, json: jsonArg }, (data) => { if (data.length === 0) { consola.info("No users found."); diff --git a/apps/cli/src/commands/project/view.ts b/apps/cli/src/commands/project/view.ts index be880fa2..16a983d3 100644 --- a/apps/cli/src/commands/project/view.ts +++ b/apps/cli/src/commands/project/view.ts @@ -25,18 +25,18 @@ Use \`--web\` to open the project in your default browser instead.`, { description: "Open project in browser", command: "bee project view -p PROJECT_KEY --web" }, { description: "Output as JSON", command: "bee project view -p PROJECT_KEY --json" }, ]) - .action(async (_opts, cmd) => { - const opts = await resolveOptions(cmd); + .action(async (opts, cmd) => { + await resolveOptions(cmd); const { client, host } = await getClient(); if (opts.web || opts.browser === false) { - const url = projectUrl(host, opts.project as string); + const url = projectUrl(host, opts.project); await openOrPrintUrl(url, opts.browser === false, consola); return; } - const project = await client.getProject(opts.project as string); + const project = await client.getProject(opts.project); const jsonArg = opts.json === true ? "" : opts.json; outputResult(project, { ...opts, json: jsonArg }, (data) => { diff --git a/apps/cli/src/commands/repo/clone.test.ts b/apps/cli/src/commands/repo/clone.test.ts index 5ef86c34..0f40d6bc 100644 --- a/apps/cli/src/commands/repo/clone.test.ts +++ b/apps/cli/src/commands/repo/clone.test.ts @@ -41,8 +41,8 @@ describe("repo clone", () => { mockClient.getGitRepository.mockResolvedValue(sampleRepo); mockSpawn.mockReturnValue(createMockChildProcess()); - const { clone } = await import("./clone"); - await clone.run?.({ args: { project: "PROJ", repository: "api-server" } } as never); + const { default: clone } = await import("./clone"); + await clone.parseAsync(["api-server", "--project", "PROJ"], { from: "user" }); expect(mockClient.getGitRepository).toHaveBeenCalledWith("PROJ", "api-server"); expect(mockSpawn).toHaveBeenCalledWith( @@ -56,10 +56,10 @@ describe("repo clone", () => { mockClient.getGitRepository.mockResolvedValue(sampleRepo); mockSpawn.mockReturnValue(createMockChildProcess()); - const { clone } = await import("./clone"); - await clone.run?.({ - args: { project: "PROJ", repository: "api-server", directory: "./dest" }, - } as never); + const { default: clone } = await import("./clone"); + await clone.parseAsync(["api-server", "--project", "PROJ", "--directory", "./dest"], { + from: "user", + }); expect(mockSpawn).toHaveBeenCalledWith( "git", @@ -72,10 +72,8 @@ describe("repo clone", () => { mockClient.getGitRepository.mockResolvedValue(sampleRepo); mockSpawn.mockReturnValue(createMockChildProcess()); - const { clone } = await import("./clone"); - await clone.run?.({ - args: { project: "PROJ", repository: "api-server", http: true }, - } as never); + const { default: clone } = await import("./clone"); + await clone.parseAsync(["api-server", "--project", "PROJ", "--http"], { from: "user" }); expect(mockSpawn).toHaveBeenCalledWith( "git", @@ -88,10 +86,10 @@ describe("repo clone", () => { mockClient.getGitRepository.mockResolvedValue(sampleRepo); mockSpawn.mockReturnValue(createMockChildProcess(128)); - const { clone } = await import("./clone"); + const { default: clone } = await import("./clone"); await expect( - clone.run?.({ args: { project: "PROJ", repository: "api-server" } } as never), + clone.parseAsync(["api-server", "--project", "PROJ"], { from: "user" }), ).rejects.toThrow("git clone exited with code 128"); }); @@ -100,10 +98,8 @@ describe("repo clone", () => { const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); - const { clone } = await import("./clone"); - await clone.run?.({ - args: { project: "PROJ", repository: "api-server", json: "" }, - } as never); + const { default: clone } = await import("./clone"); + await clone.parseAsync(["api-server", "--project", "PROJ", "--json"], { from: "user" }); expect(writeSpy).toHaveBeenCalledWith(expect.stringContaining("api-server")); expect(mockSpawn).not.toHaveBeenCalled(); diff --git a/apps/cli/src/commands/repo/clone.ts b/apps/cli/src/commands/repo/clone.ts index 05bf2a30..759bd086 100644 --- a/apps/cli/src/commands/repo/clone.ts +++ b/apps/cli/src/commands/repo/clone.ts @@ -1,20 +1,41 @@ import { spawn } from "node:child_process"; import { getClient } from "@repo/backlog-utils"; -import { outputArgs, outputResult } from "@repo/cli-utils"; -import { defineCommand } from "citty"; +import { outputResult } from "@repo/cli-utils"; import consola from "consola"; -import { type CommandUsage, ENV_AUTH, ENV_PROJECT, withUsage } from "../../lib/command-usage"; +import { BeeCommand, ENV_AUTH, ENV_PROJECT } from "../../lib/bee-command"; +import * as opt from "../../lib/common-options"; +import { resolveOptions } from "../../lib/required-option"; -const commandUsage: CommandUsage = { - long: `Clone a Backlog Git repository. +const gitClone = (gitArgs: string[]): Promise<void> => + new Promise((resolve, reject) => { + const child = spawn("git", gitArgs, { stdio: "inherit" }); + child.on("close", (code) => { + if (code === 0) { + resolve(); + } else { + reject(new Error(`git clone exited with code ${code}`)); + } + }); + }); + +const clone = new BeeCommand("clone") + .summary("Clone a repository") + .description( + `Clone a Backlog Git repository. Fetches the repository metadata to obtain the clone URL, then runs \`git clone\` as a subprocess. By default the SSH URL is used; pass \`--http\` to clone over HTTPS instead. Use \`--directory\` to specify a custom destination directory.`, - - examples: [ + ) + .argument("<repository>", "Repository name or ID") + .addOption(opt.project()) + .option("-d, --directory <path>", "Directory to clone into") + .option("--http", "Clone using HTTP URL instead of SSH") + .addOption(opt.json()) + .envVars([...ENV_AUTH, ENV_PROJECT]) + .examples([ { description: "Clone a repository", command: "bee repo clone api-server -p PROJECT_KEY" }, { description: "Clone to a specific directory", @@ -24,77 +45,27 @@ Use \`--directory\` to specify a custom destination directory.`, description: "Clone using HTTP URL", command: "bee repo clone api-server -p PROJECT_KEY --http", }, - ], + ]) + .action(async (repository, opts, cmd) => { + await resolveOptions(cmd); + const { client } = await getClient(); - annotations: { - environment: [...ENV_AUTH, ENV_PROJECT], - }, -}; + const repo = await client.getGitRepository(opts.project, repository); -const gitClone = (gitArgs: string[]): Promise<void> => - new Promise((resolve, reject) => { - const child = spawn("git", gitArgs, { stdio: "inherit" }); - child.on("close", (code) => { - if (code === 0) { - resolve(); - } else { - reject(new Error(`git clone exited with code ${code}`)); - } - }); - }); - -const clone = withUsage( - defineCommand({ - meta: { - name: "clone", - description: "Clone a repository", - }, - args: { - ...outputArgs, - repository: { - type: "positional", - description: "Repository name or ID", - required: true, - }, - project: { - type: "string", - alias: "p", - description: "Project ID or project key", - required: true, - default: process.env.BACKLOG_PROJECT, - }, - directory: { - type: "string", - alias: "d", - description: "Directory to clone into", - }, - http: { - type: "boolean", - description: "Clone using HTTP URL instead of SSH", - }, - }, - async run({ args }) { - const { client } = await getClient(); - - const repo = await client.getGitRepository(args.project, args.repository); - - if (args.json !== undefined) { - outputResult(repo, args, () => {}); - return; - } + if (opts.json !== undefined) { + outputResult(repo, opts, () => {}); + return; + } - const cloneUrl = args.http ? repo.httpUrl : repo.sshUrl; - const gitArgs = ["clone", cloneUrl]; + const cloneUrl = opts.http ? repo.httpUrl : repo.sshUrl; + const gitArgs = ["clone", cloneUrl]; - if (args.directory) { - gitArgs.push(args.directory); - } + if (opts.directory) { + gitArgs.push(opts.directory); + } - consola.info(`Cloning into '${args.directory ?? repo.name}'...`); - await gitClone(gitArgs); - }, - }), - commandUsage, -); + consola.info(`Cloning into '${opts.directory ?? repo.name}'...`); + await gitClone(gitArgs); + }); -export { commandUsage, clone }; +export default clone; diff --git a/apps/cli/src/commands/repo/list.test.ts b/apps/cli/src/commands/repo/list.test.ts index 4daf8aaf..bfe1b0f9 100644 --- a/apps/cli/src/commands/repo/list.test.ts +++ b/apps/cli/src/commands/repo/list.test.ts @@ -44,8 +44,8 @@ describe("repo list", () => { it("displays repository list in tabular format", async () => { mockClient.getGitRepositories.mockResolvedValue(sampleRepos); - const { list } = await import("./list"); - await list.run?.({ args: { project: "PROJ" } } as never); + const { default: list } = await import("./list"); + await list.parseAsync(["--project", "PROJ"], { from: "user" }); expect(getClient).toHaveBeenCalled(); expect(mockClient.getGitRepositories).toHaveBeenCalledWith("PROJ"); @@ -57,8 +57,8 @@ describe("repo list", () => { it("shows message when no repositories found", async () => { mockClient.getGitRepositories.mockResolvedValue([]); - const { list } = await import("./list"); - await list.run?.({ args: { project: "PROJ" } } as never); + const { default: list } = await import("./list"); + await list.parseAsync(["--project", "PROJ"], { from: "user" }); expect(consola.info).toHaveBeenCalledWith("No repositories found."); }); @@ -67,8 +67,8 @@ describe("repo list", () => { mockClient.getGitRepositories.mockResolvedValue(sampleRepos); await expectStdoutContaining(async () => { - const { list } = await import("./list"); - await list.run?.({ args: { project: "PROJ", json: "" } } as never); + const { default: list } = await import("./list"); + await list.parseAsync(["--project", "PROJ", "--json"], { from: "user" }); }, "api-server"); }); }); diff --git a/apps/cli/src/commands/repo/list.ts b/apps/cli/src/commands/repo/list.ts index b84dd3cc..a4545382 100644 --- a/apps/cli/src/commands/repo/list.ts +++ b/apps/cli/src/commands/repo/list.ts @@ -1,57 +1,44 @@ import { getClient } from "@repo/backlog-utils"; -import { type Row, outputArgs, outputResult, printTable } from "@repo/cli-utils"; -import { defineCommand } from "citty"; +import { type Row, outputResult, printTable } from "@repo/cli-utils"; import consola from "consola"; -import { type CommandUsage, ENV_AUTH, ENV_PROJECT, withUsage } from "../../lib/command-usage"; -import * as commonArgs from "../../lib/common-args"; +import { BeeCommand, ENV_AUTH, ENV_PROJECT } from "../../lib/bee-command"; +import * as opt from "../../lib/common-options"; +import { resolveOptions } from "../../lib/required-option"; -const commandUsage: CommandUsage = { - long: `List Git repositories in a Backlog project. +const list = new BeeCommand("list") + .summary("List repositories in a project") + .description( + `List Git repositories in a Backlog project. By default, repositories are listed in the configured display order.`, - - examples: [ - { description: "List repositories in a project", command: "bee repo list PROJECT_KEY" }, - { description: "Output as JSON", command: "bee repo list PROJECT_KEY --json" }, - ], - - annotations: { - environment: [...ENV_AUTH, ENV_PROJECT], - }, -}; - -const list = withUsage( - defineCommand({ - meta: { - name: "list", - description: "List repositories in a project", - }, - args: { - ...outputArgs, - project: commonArgs.projectPositional, - }, - async run({ args }) { - const { client } = await getClient(); - - const repos = await client.getGitRepositories(args.project); - - outputResult(repos, args, (data) => { - if (data.length === 0) { - consola.info("No repositories found."); - return; - } - - const rows: Row[] = data.map((repo) => [ - { header: "NAME", value: repo.name }, - { header: "DESCRIPTION", value: repo.description ?? "" }, - { header: "LAST PUSHED", value: repo.pushedAt?.slice(0, 10) ?? "" }, - ]); - - printTable(rows); - }); - }, - }), - commandUsage, -); - -export { commandUsage, list }; + ) + .addOption(opt.project()) + .addOption(opt.json()) + .envVars([...ENV_AUTH, ENV_PROJECT]) + .examples([ + { description: "List repositories in a project", command: "bee repo list -p PROJECT_KEY" }, + { description: "Output as JSON", command: "bee repo list -p PROJECT_KEY --json" }, + ]) + .action(async (opts, cmd) => { + await resolveOptions(cmd); + const { client } = await getClient(); + + const repos = await client.getGitRepositories(opts.project); + + outputResult(repos, opts, (data) => { + if (data.length === 0) { + consola.info("No repositories found."); + return; + } + + const rows: Row[] = data.map((repo) => [ + { header: "NAME", value: repo.name }, + { header: "DESCRIPTION", value: repo.description ?? "" }, + { header: "LAST PUSHED", value: repo.pushedAt?.slice(0, 10) ?? "" }, + ]); + + printTable(rows); + }); + }); + +export default list; diff --git a/apps/cli/src/commands/repo/view.test.ts b/apps/cli/src/commands/repo/view.test.ts index da830320..2d72d536 100644 --- a/apps/cli/src/commands/repo/view.test.ts +++ b/apps/cli/src/commands/repo/view.test.ts @@ -38,8 +38,8 @@ describe("repo view", () => { it("displays repository details", async () => { mockClient.getGitRepository.mockResolvedValue(sampleRepo); - const { view } = await import("./view"); - await view.run?.({ args: { project: "PROJ", repository: "api-server" } } as never); + const { default: view } = await import("./view"); + await view.parseAsync(["api-server", "--project", "PROJ"], { from: "user" }); expect(mockClient.getGitRepository).toHaveBeenCalledWith("PROJ", "api-server"); expect(consola.log).toHaveBeenCalledWith(expect.stringContaining("api-server")); @@ -53,10 +53,8 @@ describe("repo view", () => { }); it("opens browser with --web flag", async () => { - const { view } = await import("./view"); - await view.run?.({ - args: { project: "PROJ", repository: "api-server", web: true }, - } as never); + const { default: view } = await import("./view"); + await view.parseAsync(["api-server", "--project", "PROJ", "--web"], { from: "user" }); expect(openOrPrintUrl).toHaveBeenCalledWith( "https://example.backlog.com/git/PROJ/api-server", @@ -70,8 +68,8 @@ describe("repo view", () => { mockClient.getGitRepository.mockResolvedValue(sampleRepo); await expectStdoutContaining(async () => { - const { view } = await import("./view"); - await view.run?.({ args: { project: "PROJ", repository: "api-server", json: "" } } as never); + const { default: view } = await import("./view"); + await view.parseAsync(["api-server", "--project", "PROJ", "--json"], { from: "user" }); }, "api-server"); }); }); diff --git a/apps/cli/src/commands/repo/view.ts b/apps/cli/src/commands/repo/view.ts index f802abc0..17997eeb 100644 --- a/apps/cli/src/commands/repo/view.ts +++ b/apps/cli/src/commands/repo/view.ts @@ -1,19 +1,27 @@ import { getClient, openOrPrintUrl, repositoryUrl } from "@repo/backlog-utils"; -import { formatDate, outputArgs, outputResult, printDefinitionList } from "@repo/cli-utils"; -import { defineCommand } from "citty"; +import { formatDate, outputResult, printDefinitionList } from "@repo/cli-utils"; import consola from "consola"; -import { type CommandUsage, ENV_AUTH, ENV_PROJECT, withUsage } from "../../lib/command-usage"; -import * as commonArgs from "../../lib/common-args"; +import { BeeCommand, ENV_AUTH, ENV_PROJECT } from "../../lib/bee-command"; +import * as opt from "../../lib/common-options"; +import { resolveOptions } from "../../lib/required-option"; -const commandUsage: CommandUsage = { - long: `Display details of a Git repository in a Backlog project. +const view = new BeeCommand("view") + .summary("View a repository") + .description( + `Display details of a Git repository in a Backlog project. Shows repository name, description, HTTP and SSH clone URLs, size, creation and update timestamps. Use \`--web\` to open the repository in your default browser instead.`, - - examples: [ + ) + .argument("<repository>", "Repository name or ID") + .addOption(opt.project()) + .addOption(opt.web("repository")) + .addOption(opt.noBrowser()) + .addOption(opt.json()) + .envVars([...ENV_AUTH, ENV_PROJECT]) + .examples([ { description: "View repository details", command: "bee repo view api-server -p PROJECT_KEY", @@ -23,58 +31,33 @@ Use \`--web\` to open the repository in your default browser instead.`, command: "bee repo view api-server -p PROJECT_KEY --web", }, { description: "Output as JSON", command: "bee repo view api-server -p PROJECT_KEY --json" }, - ], - - annotations: { - environment: [...ENV_AUTH, ENV_PROJECT], - }, -}; - -const view = withUsage( - defineCommand({ - meta: { - name: "view", - description: "View a repository", - }, - args: { - ...outputArgs, - repository: { - type: "positional", - description: "Repository name or ID", - required: true, - }, - project: { ...commonArgs.project, required: true }, - web: commonArgs.web("repository"), - "no-browser": commonArgs.noBrowser, - }, - async run({ args }) { - const { client, host } = await getClient(); - - if (args.web || args["no-browser"]) { - const url = repositoryUrl(host, args.project, args.repository); - await openOrPrintUrl(url, Boolean(args["no-browser"]), consola); - return; - } - - const repo = await client.getGitRepository(args.project, args.repository); - - outputResult(repo, args, (data) => { - consola.log(""); - consola.log(` ${data.name}`); - consola.log(""); - printDefinitionList([ - ["Description", data.description ?? ""], - ["HTTP URL", data.httpUrl], - ["SSH URL", data.sshUrl], - ["Created", formatDate(data.created)], - ["Updated", formatDate(data.updated)], - ["Last Pushed", data.pushedAt ? formatDate(data.pushedAt) : ""], - ]); - consola.log(""); - }); - }, - }), - commandUsage, -); - -export { commandUsage, view }; + ]) + .action(async (repository, opts, cmd) => { + await resolveOptions(cmd); + const { client, host } = await getClient(); + + if (opts.web || opts.browser === false) { + const url = repositoryUrl(host, opts.project, repository); + await openOrPrintUrl(url, opts.browser === false, consola); + return; + } + + const repo = await client.getGitRepository(opts.project, repository); + + outputResult(repo, opts, (data) => { + consola.log(""); + consola.log(` ${data.name}`); + consola.log(""); + printDefinitionList([ + ["Description", data.description ?? ""], + ["HTTP URL", data.httpUrl], + ["SSH URL", data.sshUrl], + ["Created", formatDate(data.created)], + ["Updated", formatDate(data.updated)], + ["Last Pushed", data.pushedAt ? formatDate(data.pushedAt) : ""], + ]); + consola.log(""); + }); + }); + +export default view; diff --git a/apps/cli/src/commands/space/activities.test.ts b/apps/cli/src/commands/space/activities.test.ts index 6c2af910..a4d389f7 100644 --- a/apps/cli/src/commands/space/activities.test.ts +++ b/apps/cli/src/commands/space/activities.test.ts @@ -34,8 +34,8 @@ describe("space activities", () => { }, ]); - const { activities } = await import("./activities"); - await activities.run?.({ args: {} } as never); + const { default: activities } = await import("./activities"); + await activities.parseAsync([], { from: "user" }); expect(mockClient.getSpaceActivities).toHaveBeenCalledWith(expect.any(Object)); expect(consola.log).toHaveBeenCalledWith(expect.stringContaining("2024-01-15")); @@ -48,8 +48,8 @@ describe("space activities", () => { it("shows message when no activities found", async () => { mockClient.getSpaceActivities.mockResolvedValue([]); - const { activities } = await import("./activities"); - await activities.run?.({ args: {} } as never); + const { default: activities } = await import("./activities"); + await activities.parseAsync([], { from: "user" }); expect(consola.info).toHaveBeenCalledWith("No activities found."); }); @@ -57,10 +57,8 @@ describe("space activities", () => { it("passes activity type filter as array of numbers", async () => { mockClient.getSpaceActivities.mockResolvedValue([]); - const { activities } = await import("./activities"); - await activities.run?.({ - args: { "activity-type": "1,2,3" }, - } as never); + const { default: activities } = await import("./activities"); + await activities.parseAsync(["--activity-type", "1,2,3"], { from: "user" }); expect(mockClient.getSpaceActivities).toHaveBeenCalledWith( expect.objectContaining({ @@ -72,10 +70,8 @@ describe("space activities", () => { it("passes count parameter", async () => { mockClient.getSpaceActivities.mockResolvedValue([]); - const { activities } = await import("./activities"); - await activities.run?.({ - args: { count: "50" }, - } as never); + const { default: activities } = await import("./activities"); + await activities.parseAsync(["--count", "50"], { from: "user" }); expect(mockClient.getSpaceActivities).toHaveBeenCalledWith( expect.objectContaining({ count: 50 }), @@ -95,8 +91,8 @@ describe("space activities", () => { ]); await expectStdoutContaining(async () => { - const { activities } = await import("./activities"); - await activities.run?.({ args: { json: "" } } as never); + const { default: activities } = await import("./activities"); + await activities.parseAsync(["--json"], { from: "user" }); }, "Test"); }); }); diff --git a/apps/cli/src/commands/space/activities.ts b/apps/cli/src/commands/space/activities.ts index 944fbab5..f665a0fa 100644 --- a/apps/cli/src/commands/space/activities.ts +++ b/apps/cli/src/commands/space/activities.ts @@ -1,17 +1,9 @@ import { ACTIVITY_LABELS, getClient } from "@repo/backlog-utils"; -import { - type Row, - formatDate, - outputArgs, - outputResult, - printTable, - splitArg, -} from "@repo/cli-utils"; -import { defineCommand } from "citty"; +import { type Row, formatDate, outputResult, printTable, splitArg } from "@repo/cli-utils"; import consola from "consola"; import * as v from "valibot"; -import { type CommandUsage, ENV_AUTH, withUsage } from "../../lib/command-usage"; -import * as commonArgs from "../../lib/common-args"; +import { BeeCommand, ENV_AUTH } from "../../lib/bee-command"; +import * as opt from "../../lib/common-options"; const getActivitySummary = (activity: { type: number; @@ -40,8 +32,10 @@ const getActivitySummary = (activity: { return ""; }; -const commandUsage: CommandUsage = { - long: `List recent activities across the Backlog space. +const activities = new BeeCommand("activities") + .summary("List space activities") + .description( + `List recent activities across the Backlog space. Shows the most recent updates across the entire space, including issue changes, wiki edits, git pushes, and other activities. Results are @@ -49,8 +43,15 @@ ordered by most recent first. Use \`--activity-type\` to filter by specific activity types (comma-separated IDs). Use \`--count\` to control how many activities are returned (default: 20, max: 100).`, - - examples: [ + ) + .option("--activity-type <ids>", "Filter by activity type IDs (comma-separated)") + .addOption(opt.count()) + .addOption(opt.order()) + .addOption(opt.minId()) + .addOption(opt.maxId()) + .addOption(opt.json()) + .envVars([...ENV_AUTH]) + .examples([ { description: "List space activities", command: "bee space activities" }, { description: "Show only issue-related activities", @@ -64,62 +65,35 @@ Use \`--count\` to control how many activities are returned (default: 20, max: 1 description: "Output as JSON", command: "bee space activities --json", }, - ], + ]) + .action(async (opts) => { + const { client } = await getClient(); - annotations: { - environment: [...ENV_AUTH], - }, -}; + const activityTypeId = splitArg(opts.activityType, v.number()); -const activities = withUsage( - defineCommand({ - meta: { - name: "activities", - description: "List space activities", - }, - args: { - ...outputArgs, - "activity-type": { - type: "string", - description: "Filter by activity type IDs (comma-separated)", - valueHint: "<1,2,3>", - }, - count: commonArgs.count, - order: commonArgs.order, - "min-id": commonArgs.minId, - "max-id": commonArgs.maxId, - }, - async run({ args }) { - const { client } = await getClient(); + const activityList = await client.getSpaceActivities({ + activityTypeId, + count: opts.count ? Number(opts.count) : undefined, + order: opts.order, + minId: opts.minId ? Number(opts.minId) : undefined, + maxId: opts.maxId ? Number(opts.maxId) : undefined, + }); - const activityTypeId = splitArg(args["activity-type"], v.number()); + outputResult(activityList, opts, (data) => { + if (data.length === 0) { + consola.info("No activities found."); + return; + } - const activityList = await client.getSpaceActivities({ - activityTypeId, - count: args.count ? Number(args.count) : undefined, - order: args.order as "asc" | "desc" | undefined, - minId: args["min-id"] ? Number(args["min-id"]) : undefined, - maxId: args["max-id"] ? Number(args["max-id"]) : undefined, - }); + const rows: Row[] = data.map((activity) => [ + { header: "DATE", value: formatDate(activity.created) }, + { header: "TYPE", value: ACTIVITY_LABELS[activity.type] ?? `Type ${activity.type}` }, + { header: "PROJECT", value: activity.project?.name ?? "" }, + { header: "SUMMARY", value: getActivitySummary(activity) }, + ]); - outputResult(activityList, args, (data) => { - if (data.length === 0) { - consola.info("No activities found."); - return; - } - - const rows: Row[] = data.map((activity) => [ - { header: "DATE", value: formatDate(activity.created) }, - { header: "TYPE", value: ACTIVITY_LABELS[activity.type] ?? `Type ${activity.type}` }, - { header: "PROJECT", value: activity.project?.name ?? "" }, - { header: "SUMMARY", value: getActivitySummary(activity) }, - ]); - - printTable(rows); - }); - }, - }), - commandUsage, -); + printTable(rows); + }); + }); -export { commandUsage, activities }; +export default activities; diff --git a/apps/cli/src/commands/space/disk-usage.test.ts b/apps/cli/src/commands/space/disk-usage.test.ts index 156d42f4..af2afd4e 100644 --- a/apps/cli/src/commands/space/disk-usage.test.ts +++ b/apps/cli/src/commands/space/disk-usage.test.ts @@ -26,8 +26,8 @@ describe("space disk-usage", () => { it("displays disk usage breakdown", async () => { mockClient.getSpaceDiskUsage.mockResolvedValue(sampleDiskUsage); - const { diskUsage } = await import("./disk-usage"); - await diskUsage.run?.({ args: {} } as never); + const { default: diskUsage } = await import("./disk-usage"); + await diskUsage.parseAsync([], { from: "user" }); expect(mockClient.getSpaceDiskUsage).toHaveBeenCalled(); expect(consola.log).toHaveBeenCalledWith(expect.stringContaining("Capacity")); @@ -41,8 +41,8 @@ describe("space disk-usage", () => { mockClient.getSpaceDiskUsage.mockResolvedValue(sampleDiskUsage); await expectStdoutContaining(async () => { - const { diskUsage } = await import("./disk-usage"); - await diskUsage.run?.({ args: { json: "" } } as never); + const { default: diskUsage } = await import("./disk-usage"); + await diskUsage.parseAsync(["--json"], { from: "user" }); }, "1073741824"); }); }); diff --git a/apps/cli/src/commands/space/disk-usage.ts b/apps/cli/src/commands/space/disk-usage.ts index 6e217253..1386f4f4 100644 --- a/apps/cli/src/commands/space/disk-usage.ts +++ b/apps/cli/src/commands/space/disk-usage.ts @@ -1,55 +1,41 @@ import { getClient } from "@repo/backlog-utils"; -import { formatSize, outputArgs, outputResult, printDefinitionList } from "@repo/cli-utils"; -import { defineCommand } from "citty"; +import { formatSize, outputResult, printDefinitionList } from "@repo/cli-utils"; import consola from "consola"; -import { type CommandUsage, ENV_AUTH, withUsage } from "../../lib/command-usage"; +import { BeeCommand, ENV_AUTH } from "../../lib/bee-command"; +import * as opt from "../../lib/common-options"; -const commandUsage: CommandUsage = { - long: `Display disk usage of the Backlog space. +const diskUsage = new BeeCommand("disk-usage") + .summary("Display space disk usage") + .description( + `Display disk usage of the Backlog space. Shows the total capacity and a breakdown of disk usage by category: issues, wikis, files, Subversion, Git, and Git LFS.`, - - examples: [ + ) + .addOption(opt.json()) + .envVars([...ENV_AUTH]) + .examples([ { description: "View disk usage", command: "bee space disk-usage" }, { description: "Output as JSON", command: "bee space disk-usage --json" }, - ], - - annotations: { - environment: [...ENV_AUTH], - }, -}; - -const diskUsage = withUsage( - defineCommand({ - meta: { - name: "disk-usage", - description: "Display space disk usage", - }, - args: { - ...outputArgs, - }, - async run({ args }) { - const { client } = await getClient(); - - const usage = await client.getSpaceDiskUsage(); - - outputResult(usage, args, (data) => { - consola.log(""); - printDefinitionList([ - ["Capacity", formatSize(data.capacity)], - ["Issue", formatSize(data.issue)], - ["Wiki", formatSize(data.wiki)], - ["File", formatSize(data.file)], - ["Subversion", formatSize(data.subversion)], - ["Git", formatSize(data.git)], - ["Git LFS", formatSize(data.gitLFS)], - ]); - consola.log(""); - }); - }, - }), - commandUsage, -); - -export { commandUsage, diskUsage }; + ]) + .action(async (opts) => { + const { client } = await getClient(); + + const usage = await client.getSpaceDiskUsage(); + + outputResult(usage, opts, (data) => { + consola.log(""); + printDefinitionList([ + ["Capacity", formatSize(data.capacity)], + ["Issue", formatSize(data.issue)], + ["Wiki", formatSize(data.wiki)], + ["File", formatSize(data.file)], + ["Subversion", formatSize(data.subversion)], + ["Git", formatSize(data.git)], + ["Git LFS", formatSize(data.gitLFS)], + ]); + consola.log(""); + }); + }); + +export default diskUsage; diff --git a/apps/cli/src/commands/space/info.test.ts b/apps/cli/src/commands/space/info.test.ts index c3800a75..cbfe7814 100644 --- a/apps/cli/src/commands/space/info.test.ts +++ b/apps/cli/src/commands/space/info.test.ts @@ -26,8 +26,8 @@ describe("space info", () => { it("displays space information", async () => { mockClient.getSpace.mockResolvedValue(sampleSpace); - const { info } = await import("./info"); - await info.run?.({ args: {} } as never); + const { default: info } = await import("./info"); + await info.parseAsync([], { from: "user" }); expect(mockClient.getSpace).toHaveBeenCalled(); expect(consola.log).toHaveBeenCalledWith(expect.stringContaining("Test Space")); @@ -40,8 +40,8 @@ describe("space info", () => { mockClient.getSpace.mockResolvedValue(sampleSpace); await expectStdoutContaining(async () => { - const { info } = await import("./info"); - await info.run?.({ args: { json: "" } } as never); + const { default: info } = await import("./info"); + await info.parseAsync(["--json"], { from: "user" }); }, "TESTSPACE"); }); }); diff --git a/apps/cli/src/commands/space/info.ts b/apps/cli/src/commands/space/info.ts index 096cc8de..1c31621f 100644 --- a/apps/cli/src/commands/space/info.ts +++ b/apps/cli/src/commands/space/info.ts @@ -1,57 +1,43 @@ import { getClient } from "@repo/backlog-utils"; -import { formatDate, outputArgs, outputResult, printDefinitionList } from "@repo/cli-utils"; -import { defineCommand } from "citty"; +import { formatDate, outputResult, printDefinitionList } from "@repo/cli-utils"; import consola from "consola"; -import { type CommandUsage, ENV_AUTH, withUsage } from "../../lib/command-usage"; +import { BeeCommand, ENV_AUTH } from "../../lib/bee-command"; +import * as opt from "../../lib/common-options"; -const commandUsage: CommandUsage = { - long: `Display information about the Backlog space. +const info = new BeeCommand("info") + .summary("Display space information") + .description( + `Display information about the Backlog space. Shows general details of the space including the space key, name, owner ID, language, timezone, and creation/update timestamps.`, - - examples: [ + ) + .addOption(opt.json()) + .envVars([...ENV_AUTH]) + .examples([ { description: "View space information", command: "bee space info" }, { description: "Output as JSON", command: "bee space info --json" }, - ], - - annotations: { - environment: [...ENV_AUTH], - }, -}; - -const info = withUsage( - defineCommand({ - meta: { - name: "info", - description: "Display space information", - }, - args: { - ...outputArgs, - }, - async run({ args }) { - const { client } = await getClient(); - - const spaceInfo = await client.getSpace(); - - outputResult(spaceInfo, args, (data) => { - consola.log(""); - consola.log(` ${data.name}`); - consola.log(""); - printDefinitionList([ - ["Space Key", data.spaceKey], - ["Name", data.name], - ["Owner ID", String(data.ownerId)], - ["Language", data.lang], - ["Timezone", data.timezone], - ["Created", data.created ? formatDate(data.created) : undefined], - ["Updated", data.updated ? formatDate(data.updated) : undefined], - ]); - consola.log(""); - }); - }, - }), - commandUsage, -); - -export { commandUsage, info }; + ]) + .action(async (opts) => { + const { client } = await getClient(); + + const spaceInfo = await client.getSpace(); + + outputResult(spaceInfo, opts, (data) => { + consola.log(""); + consola.log(` ${data.name}`); + consola.log(""); + printDefinitionList([ + ["Space Key", data.spaceKey], + ["Name", data.name], + ["Owner ID", String(data.ownerId)], + ["Language", data.lang], + ["Timezone", data.timezone], + ["Created", data.created ? formatDate(data.created) : undefined], + ["Updated", data.updated ? formatDate(data.updated) : undefined], + ]); + consola.log(""); + }); + }); + +export default info; diff --git a/apps/cli/src/commands/space/notification.test.ts b/apps/cli/src/commands/space/notification.test.ts index aab0601c..19b8b5c4 100644 --- a/apps/cli/src/commands/space/notification.test.ts +++ b/apps/cli/src/commands/space/notification.test.ts @@ -19,8 +19,8 @@ describe("space notification", () => { updated: "2024-03-01T09:00:00Z", }); - const { notification } = await import("./notification"); - await notification.run?.({ args: {} } as never); + const { default: notification } = await import("./notification"); + await notification.parseAsync([], { from: "user" }); expect(mockClient.getSpaceNotification).toHaveBeenCalled(); expect(consola.log).toHaveBeenCalledWith( @@ -35,8 +35,8 @@ describe("space notification", () => { updated: null, }); - const { notification } = await import("./notification"); - await notification.run?.({ args: {} } as never); + const { default: notification } = await import("./notification"); + await notification.parseAsync([], { from: "user" }); expect(consola.info).toHaveBeenCalledWith("No space notification set."); }); @@ -48,8 +48,8 @@ describe("space notification", () => { }); await expectStdoutContaining(async () => { - const { notification } = await import("./notification"); - await notification.run?.({ args: { json: "" } } as never); + const { default: notification } = await import("./notification"); + await notification.parseAsync(["--json"], { from: "user" }); }, "Important notice"); }); }); diff --git a/apps/cli/src/commands/space/notification.ts b/apps/cli/src/commands/space/notification.ts index 59dbbe66..f0fa2d56 100644 --- a/apps/cli/src/commands/space/notification.ts +++ b/apps/cli/src/commands/space/notification.ts @@ -1,55 +1,41 @@ import { getClient } from "@repo/backlog-utils"; -import { formatDate, outputArgs, outputResult, printDefinitionList } from "@repo/cli-utils"; -import { defineCommand } from "citty"; +import { formatDate, outputResult, printDefinitionList } from "@repo/cli-utils"; import consola from "consola"; -import { type CommandUsage, ENV_AUTH, withUsage } from "../../lib/command-usage"; +import { BeeCommand, ENV_AUTH } from "../../lib/bee-command"; +import * as opt from "../../lib/common-options"; -const commandUsage: CommandUsage = { - long: `Display the space notification. +const notification = new BeeCommand("notification") + .summary("Display the space notification") + .description( + `Display the space notification. Shows the notification message that is set for the entire Backlog space, along with the date it was last updated.`, - - examples: [ + ) + .addOption(opt.json()) + .envVars([...ENV_AUTH]) + .examples([ { description: "View space notification", command: "bee space notification" }, { description: "Output as JSON", command: "bee space notification --json" }, - ], - - annotations: { - environment: [...ENV_AUTH], - }, -}; - -const notification = withUsage( - defineCommand({ - meta: { - name: "notification", - description: "Display the space notification", - }, - args: { - ...outputArgs, - }, - async run({ args }) { - const { client } = await getClient(); - - const data = await client.getSpaceNotification(); - - outputResult(data, args, (result) => { - if (!result.content) { - consola.info("No space notification set."); - return; - } - - consola.log(""); - printDefinitionList([ - ["Content", result.content], - ["Updated", result.updated ? formatDate(result.updated) : undefined], - ]); - consola.log(""); - }); - }, - }), - commandUsage, -); - -export { commandUsage, notification }; + ]) + .action(async (opts) => { + const { client } = await getClient(); + + const data = await client.getSpaceNotification(); + + outputResult(data, opts, (result) => { + if (!result.content) { + consola.info("No space notification set."); + return; + } + + consola.log(""); + printDefinitionList([ + ["Content", result.content], + ["Updated", result.updated ? formatDate(result.updated) : undefined], + ]); + consola.log(""); + }); + }); + +export default notification; diff --git a/apps/cli/src/commands/status/create.test.ts b/apps/cli/src/commands/status/create.test.ts index 1ccb1c70..ea21e892 100644 --- a/apps/cli/src/commands/status/create.test.ts +++ b/apps/cli/src/commands/status/create.test.ts @@ -13,20 +13,19 @@ vi.mock("@repo/backlog-utils", () => ({ vi.mock("@repo/cli-utils", async (importOriginal) => ({ ...(await importOriginal()), - promptRequired: vi.fn(), + promptRequired: vi.fn((_, val) => Promise.resolve(val)), })); vi.mock("consola", () => import("@repo/test-utils/mock-consola")); describe("status create", () => { it("creates a status with provided name and color", async () => { - vi.mocked(promptRequired).mockResolvedValueOnce("In Review"); mockClient.postProjectStatus.mockResolvedValue({ id: 1, name: "In Review", color: "#2779ca" }); - const { create } = await import("./create"); - await create.run?.({ - args: { project: "TEST", name: "In Review", color: "#2779ca" }, - } as never); + const { default: create } = await import("./create"); + await create.parseAsync(["-p", "TEST", "-n", "In Review", "--color", "#2779ca"], { + from: "user", + }); expect(mockClient.postProjectStatus).toHaveBeenCalledWith("TEST", { name: "In Review", @@ -36,28 +35,29 @@ describe("status create", () => { }); it("prompts for name when not provided", async () => { - vi.mocked(promptRequired).mockResolvedValueOnce("Prompted Status"); + vi.mocked(promptRequired) + .mockResolvedValueOnce("TEST") + .mockResolvedValueOnce("Prompted Status"); mockClient.postProjectStatus.mockResolvedValue({ id: 2, name: "Prompted Status", color: "#2779ca", }); - const { create } = await import("./create"); - await create.run?.({ args: { project: "TEST", color: "#2779ca" } } as never); + const { default: create } = await import("./create"); + await create.parseAsync(["-p", "TEST", "--color", "#2779ca"], { from: "user" }); expect(promptRequired).toHaveBeenCalledWith("Status name:", undefined); }); it("outputs JSON when --json flag is set", async () => { - vi.mocked(promptRequired).mockResolvedValueOnce("Open"); mockClient.postProjectStatus.mockResolvedValue({ id: 1, name: "Open", color: "#e30000" }); await expectStdoutContaining(async () => { - const { create } = await import("./create"); - await create.run?.({ - args: { project: "TEST", name: "Open", color: "#e30000", json: "" }, - } as never); + const { default: create } = await import("./create"); + await create.parseAsync(["-p", "TEST", "-n", "Open", "--color", "#e30000", "--json"], { + from: "user", + }); }, "Open"); }); }); diff --git a/apps/cli/src/commands/status/create.ts b/apps/cli/src/commands/status/create.ts index 95ac3879..2c84ad34 100644 --- a/apps/cli/src/commands/status/create.ts +++ b/apps/cli/src/commands/status/create.ts @@ -1,17 +1,27 @@ import { getClient } from "@repo/backlog-utils"; -import { outputArgs, outputResult, promptRequired } from "@repo/cli-utils"; -import { defineCommand } from "citty"; +import { outputResult, promptRequired } from "@repo/cli-utils"; import consola from "consola"; -import { type CommandUsage, ENV_AUTH, ENV_PROJECT, withUsage } from "../../lib/command-usage"; -import * as commonArgs from "../../lib/common-args"; +import { BeeCommand, ENV_AUTH, ENV_PROJECT } from "../../lib/bee-command"; +import * as opt from "../../lib/common-options"; +import { resolveOptions } from "../../lib/required-option"; -const commandUsage: CommandUsage = { - long: `Create a new status in a Backlog project. +const create = new BeeCommand("create") + .summary("Create a status") + .description( + `Create a new status in a Backlog project. If \`--name\` is not provided, you will be prompted interactively. The \`--color\` flag must be one of the predefined Backlog colors.`, - - examples: [ + ) + .addOption(opt.project()) + .option("-n, --name <value>", "Status name") + .requiredOption( + "--color <value>", + "Display color {#ea2c00|#e87758|#e07b9a|#868cb7|#3b9dbd|#4caf93|#b0be3c|#eda62a|#f42858|#393939}", + ) + .addOption(opt.json()) + .envVars([...ENV_AUTH, ENV_PROJECT]) + .examples([ { description: "Create a status", command: 'bee status create -p PROJECT -n "In Review" --color "#3b9dbd"', @@ -20,51 +30,21 @@ The \`--color\` flag must be one of the predefined Backlog colors.`, description: "Create interactively", command: 'bee status create -p PROJECT --color "#3b9dbd"', }, - ], + ]) + .action(async (opts, cmd) => { + await resolveOptions(cmd); + const { client } = await getClient(); - annotations: { - environment: [...ENV_AUTH, ENV_PROJECT], - }, -}; + const name = await promptRequired("Status name:", opts.name); -const create = withUsage( - defineCommand({ - meta: { - name: "create", - description: "Create a status", - }, - args: { - ...outputArgs, - project: { ...commonArgs.project, required: true }, - name: { - type: "string", - alias: "n", - description: "Status name", - }, - color: { - type: "string", - description: "Display color", - valueHint: - "{#ea2c00|#e87758|#e07b9a|#868cb7|#3b9dbd|#4caf93|#b0be3c|#eda62a|#f42858|#393939}", - required: true, - }, - }, - async run({ args }) { - const { client } = await getClient(); + const status = await client.postProjectStatus(opts.project, { + name, + color: opts.color as never, + }); - const name = await promptRequired("Status name:", args.name); - - const status = await client.postProjectStatus(args.project, { - name, - color: args.color as never, - }); - - outputResult(status, args, (data) => { - consola.success(`Created status ${data.name} (ID: ${data.id})`); - }); - }, - }), - commandUsage, -); + outputResult(status, opts, (data) => { + consola.success(`Created status ${data.name} (ID: ${data.id})`); + }); + }); -export { commandUsage, create }; +export default create; diff --git a/apps/cli/src/commands/status/delete.test.ts b/apps/cli/src/commands/status/delete.test.ts index c5fbe35c..7cc5dba2 100644 --- a/apps/cli/src/commands/status/delete.test.ts +++ b/apps/cli/src/commands/status/delete.test.ts @@ -23,10 +23,10 @@ describe("status delete", () => { vi.mocked(confirmOrExit).mockResolvedValue(true); mockClient.deleteProjectStatus.mockResolvedValue({ id: 1, name: "Open", color: "#e30000" }); - const { deleteStatus } = await import("./delete"); - await deleteStatus.run?.({ - args: { status: "1", project: "TEST", "substitute-status-id": "2" }, - } as never); + const { default: deleteStatus } = await import("./delete"); + await deleteStatus.parseAsync(["1", "-p", "TEST", "--substitute-status-id", "2"], { + from: "user", + }); expect(confirmOrExit).toHaveBeenCalledWith( "Are you sure you want to delete status 1? This cannot be undone.", @@ -40,10 +40,10 @@ describe("status delete", () => { vi.mocked(confirmOrExit).mockResolvedValue(true); mockClient.deleteProjectStatus.mockResolvedValue({ id: 1, name: "Open", color: "#e30000" }); - const { deleteStatus } = await import("./delete"); - await deleteStatus.run?.({ - args: { status: "1", project: "TEST", "substitute-status-id": "2", yes: true }, - } as never); + const { default: deleteStatus } = await import("./delete"); + await deleteStatus.parseAsync(["1", "-p", "TEST", "--substitute-status-id", "2", "--yes"], { + from: "user", + }); expect(confirmOrExit).toHaveBeenCalledWith( "Are you sure you want to delete status 1? This cannot be undone.", @@ -54,10 +54,10 @@ describe("status delete", () => { it("cancels when user declines confirmation", async () => { vi.mocked(confirmOrExit).mockResolvedValue(false); - const { deleteStatus } = await import("./delete"); - await deleteStatus.run?.({ - args: { status: "1", project: "TEST", "substitute-status-id": "2" }, - } as never); + const { default: deleteStatus } = await import("./delete"); + await deleteStatus.parseAsync(["1", "-p", "TEST", "--substitute-status-id", "2"], { + from: "user", + }); expect(mockClient.deleteProjectStatus).not.toHaveBeenCalled(); }); @@ -67,16 +67,11 @@ describe("status delete", () => { mockClient.deleteProjectStatus.mockResolvedValue({ id: 1, name: "Open", color: "#e30000" }); await expectStdoutContaining(async () => { - const { deleteStatus } = await import("./delete"); - await deleteStatus.run?.({ - args: { - status: "1", - project: "TEST", - "substitute-status-id": "2", - yes: true, - json: "", - }, - } as never); + const { default: deleteStatus } = await import("./delete"); + await deleteStatus.parseAsync( + ["1", "-p", "TEST", "--substitute-status-id", "2", "--yes", "--json"], + { from: "user" }, + ); }, "Open"); }); }); diff --git a/apps/cli/src/commands/status/delete.ts b/apps/cli/src/commands/status/delete.ts index 98aa4361..4cb9377b 100644 --- a/apps/cli/src/commands/status/delete.ts +++ b/apps/cli/src/commands/status/delete.ts @@ -1,12 +1,14 @@ import { getClient } from "@repo/backlog-utils"; -import { confirmOrExit, outputArgs, outputResult } from "@repo/cli-utils"; -import { defineCommand } from "citty"; +import { confirmOrExit, outputResult } from "@repo/cli-utils"; import consola from "consola"; -import { type CommandUsage, ENV_AUTH, ENV_PROJECT, withUsage } from "../../lib/command-usage"; -import * as commonArgs from "../../lib/common-args"; +import { BeeCommand, ENV_AUTH, ENV_PROJECT } from "../../lib/bee-command"; +import * as opt from "../../lib/common-options"; +import { resolveOptions } from "../../lib/required-option"; -const commandUsage: CommandUsage = { - long: `Delete a status from a Backlog project. +const deleteStatus = new BeeCommand("delete") + .summary("Delete a status") + .description( + `Delete a status from a Backlog project. When deleting a status, all issues with that status must be reassigned to another status. Use \`--substitute-status-id\` to specify @@ -14,8 +16,14 @@ the replacement. This action is irreversible. You will be prompted for confirmation unless \`--yes\` is provided.`, - - examples: [ + ) + .argument("<status>", "Status ID") + .addOption(opt.project()) + .requiredOption("--substitute-status-id <value>", "Replacement status ID for affected issues") + .option("-y, --yes", "Skip confirmation prompt") + .addOption(opt.json()) + .envVars([...ENV_AUTH, ENV_PROJECT]) + .examples([ { description: "Delete a status", command: "bee status delete 12345 -p PROJECT --substitute-status-id 67890", @@ -24,64 +32,29 @@ This action is irreversible. You will be prompted for confirmation unless description: "Delete without confirmation", command: "bee status delete 12345 -p PROJECT --substitute-status-id 67890 --yes", }, - ], - - annotations: { - environment: [...ENV_AUTH, ENV_PROJECT], - }, -}; - -const deleteStatus = withUsage( - defineCommand({ - meta: { - name: "delete", - description: "Delete a status", - }, - args: { - ...outputArgs, - status: { - type: "positional", - description: "Status ID", - required: true, - valueHint: "<number>", - }, - project: { ...commonArgs.project, required: true }, - "substitute-status-id": { - type: "string", - description: "Replacement status ID for affected issues", - valueHint: "<number>", - required: true, - }, - yes: { - type: "boolean", - alias: "y", - description: "Skip confirmation prompt", - }, - }, - async run({ args }) { - const confirmed = await confirmOrExit( - `Are you sure you want to delete status ${args.status}? This cannot be undone.`, - args.yes, - ); - - if (!confirmed) { - return; - } - - const { client } = await getClient(); - - const status = await client.deleteProjectStatus( - args.project, - Number(args.status), - Number(args["substitute-status-id"]), - ); - - outputResult(status, args, (data) => { - consola.success(`Deleted status ${data.name} (ID: ${data.id})`); - }); - }, - }), - commandUsage, -); - -export { commandUsage, deleteStatus }; + ]) + .action(async (status, opts, cmd) => { + await resolveOptions(cmd); + const confirmed = await confirmOrExit( + `Are you sure you want to delete status ${status}? This cannot be undone.`, + opts.yes, + ); + + if (!confirmed) { + return; + } + + const { client } = await getClient(); + + const result = await client.deleteProjectStatus( + opts.project, + Number(status), + Number(opts.substituteStatusId), + ); + + outputResult(result, opts, (data) => { + consola.success(`Deleted status ${data.name} (ID: ${data.id})`); + }); + }); + +export default deleteStatus; diff --git a/apps/cli/src/commands/status/edit.test.ts b/apps/cli/src/commands/status/edit.test.ts index ee25d469..ebd92429 100644 --- a/apps/cli/src/commands/status/edit.test.ts +++ b/apps/cli/src/commands/status/edit.test.ts @@ -16,8 +16,8 @@ describe("status edit", () => { it("updates status name", async () => { mockClient.patchProjectStatus.mockResolvedValue({ id: 1, name: "New Name", color: "#e30000" }); - const { edit } = await import("./edit"); - await edit.run?.({ args: { status: "1", project: "TEST", name: "New Name" } } as never); + const { default: edit } = await import("./edit"); + await edit.parseAsync(["1", "-p", "TEST", "-n", "New Name"], { from: "user" }); expect(mockClient.patchProjectStatus).toHaveBeenCalledWith( "TEST", @@ -30,8 +30,8 @@ describe("status edit", () => { it("updates status color", async () => { mockClient.patchProjectStatus.mockResolvedValue({ id: 1, name: "Open", color: "#e30000" }); - const { edit } = await import("./edit"); - await edit.run?.({ args: { status: "1", project: "TEST", color: "#e30000" } } as never); + const { default: edit } = await import("./edit"); + await edit.parseAsync(["1", "-p", "TEST", "--color", "#e30000"], { from: "user" }); expect(mockClient.patchProjectStatus).toHaveBeenCalledWith( "TEST", @@ -44,8 +44,8 @@ describe("status edit", () => { mockClient.patchProjectStatus.mockResolvedValue({ id: 1, name: "Open", color: "#e30000" }); await expectStdoutContaining(async () => { - const { edit } = await import("./edit"); - await edit.run?.({ args: { status: "1", project: "TEST", name: "Open", json: "" } } as never); + const { default: edit } = await import("./edit"); + await edit.parseAsync(["1", "-p", "TEST", "-n", "Open", "--json"], { from: "user" }); }, "Open"); }); }); diff --git a/apps/cli/src/commands/status/edit.ts b/apps/cli/src/commands/status/edit.ts index 0970d2f0..0d487a1f 100644 --- a/apps/cli/src/commands/status/edit.ts +++ b/apps/cli/src/commands/status/edit.ts @@ -1,17 +1,28 @@ import { getClient } from "@repo/backlog-utils"; -import { outputArgs, outputResult } from "@repo/cli-utils"; -import { defineCommand } from "citty"; +import { outputResult } from "@repo/cli-utils"; import consola from "consola"; -import { type CommandUsage, ENV_AUTH, ENV_PROJECT, withUsage } from "../../lib/command-usage"; -import * as commonArgs from "../../lib/common-args"; +import { BeeCommand, ENV_AUTH, ENV_PROJECT } from "../../lib/bee-command"; +import * as opt from "../../lib/common-options"; +import { resolveOptions } from "../../lib/required-option"; -const commandUsage: CommandUsage = { - long: `Update an existing status in a Backlog project. +const edit = new BeeCommand("edit") + .summary("Edit a status") + .description( + `Update an existing status in a Backlog project. Only the specified fields will be updated. Fields that are not provided will remain unchanged.`, - - examples: [ + ) + .argument("<status>", "Status ID") + .addOption(opt.project()) + .option("-n, --name <value>", "New name of the status") + .option( + "--color <value>", + "Change display color {#ea2c00|#e87758|#e07b9a|#868cb7|#3b9dbd|#4caf93|#b0be3c|#eda62a|#f42858|#393939}", + ) + .addOption(opt.json()) + .envVars([...ENV_AUTH, ENV_PROJECT]) + .examples([ { description: "Rename a status", command: 'bee status edit 12345 -p PROJECT -n "New Name"', @@ -20,54 +31,19 @@ will remain unchanged.`, description: "Change status color", command: 'bee status edit 12345 -p PROJECT --color "#e30000"', }, - ], - - annotations: { - environment: [...ENV_AUTH, ENV_PROJECT], - }, -}; - -const edit = withUsage( - defineCommand({ - meta: { - name: "edit", - description: "Edit a status", - }, - args: { - ...outputArgs, - status: { - type: "positional", - description: "Status ID", - required: true, - valueHint: "<number>", - }, - project: { ...commonArgs.project, required: true }, - name: { - type: "string", - alias: "n", - description: "New name of the status", - }, - color: { - type: "string", - description: "Change display color", - valueHint: - "{#ea2c00|#e87758|#e07b9a|#868cb7|#3b9dbd|#4caf93|#b0be3c|#eda62a|#f42858|#393939}", - }, - }, - async run({ args }) { - const { client } = await getClient(); - - const status = await client.patchProjectStatus(args.project, Number(args.status), { - name: args.name, - color: args.color as never, - }); - - outputResult(status, args, (data) => { - consola.success(`Updated status ${data.name} (ID: ${data.id})`); - }); - }, - }), - commandUsage, -); - -export { commandUsage, edit }; + ]) + .action(async (status, opts, cmd) => { + await resolveOptions(cmd); + const { client } = await getClient(); + + const result = await client.patchProjectStatus(opts.project, Number(status), { + name: opts.name, + color: opts.color as never, + }); + + outputResult(result, opts, (data) => { + consola.success(`Updated status ${data.name} (ID: ${data.id})`); + }); + }); + +export default edit; diff --git a/apps/cli/src/commands/status/list.test.ts b/apps/cli/src/commands/status/list.test.ts index a74a4d62..af8fc15d 100644 --- a/apps/cli/src/commands/status/list.test.ts +++ b/apps/cli/src/commands/status/list.test.ts @@ -22,8 +22,8 @@ describe("status list", () => { it("displays status list in tabular format", async () => { mockClient.getProjectStatuses.mockResolvedValue(sampleStatuses); - const { list } = await import("./list"); - await list.run?.({ args: { project: "TEST" } } as never); + const { default: list } = await import("./list"); + await list.parseAsync(["TEST"], { from: "user" }); expect(getClient).toHaveBeenCalled(); expect(mockClient.getProjectStatuses).toHaveBeenCalledWith("TEST"); @@ -35,8 +35,8 @@ describe("status list", () => { it("shows message when no statuses found", async () => { mockClient.getProjectStatuses.mockResolvedValue([]); - const { list } = await import("./list"); - await list.run?.({ args: { project: "TEST" } } as never); + const { default: list } = await import("./list"); + await list.parseAsync(["TEST"], { from: "user" }); expect(consola.info).toHaveBeenCalledWith("No statuses found."); }); @@ -44,8 +44,8 @@ describe("status list", () => { it("displays color column", async () => { mockClient.getProjectStatuses.mockResolvedValue(sampleStatuses); - const { list } = await import("./list"); - await list.run?.({ args: { project: "TEST" } } as never); + const { default: list } = await import("./list"); + await list.parseAsync(["TEST"], { from: "user" }); expect(consola.log).toHaveBeenCalledWith(expect.stringContaining("#e30000")); }); @@ -54,8 +54,8 @@ describe("status list", () => { mockClient.getProjectStatuses.mockResolvedValue(sampleStatuses); await expectStdoutContaining(async () => { - const { list } = await import("./list"); - await list.run?.({ args: { project: "TEST", json: "" } } as never); + const { default: list } = await import("./list"); + await list.parseAsync(["TEST", "--json"], { from: "user" }); }, "Open"); }); }); diff --git a/apps/cli/src/commands/status/list.ts b/apps/cli/src/commands/status/list.ts index c591b406..ea332f4d 100644 --- a/apps/cli/src/commands/status/list.ts +++ b/apps/cli/src/commands/status/list.ts @@ -1,58 +1,43 @@ import { getClient } from "@repo/backlog-utils"; -import { type Row, outputArgs, outputResult, printTable } from "@repo/cli-utils"; -import { defineCommand } from "citty"; +import { type Row, outputResult, printTable } from "@repo/cli-utils"; import consola from "consola"; -import { type CommandUsage, ENV_AUTH, ENV_PROJECT, withUsage } from "../../lib/command-usage"; -import * as commonArgs from "../../lib/common-args"; +import { BeeCommand, ENV_AUTH, ENV_PROJECT } from "../../lib/bee-command"; +import * as opt from "../../lib/common-options"; -const commandUsage: CommandUsage = { - long: `List statuses in a Backlog project. +const list = new BeeCommand("list") + .summary("List statuses") + .description( + `List statuses in a Backlog project. Statuses define the workflow states that issues can move through. Each status is displayed with its associated color.`, - - examples: [ + ) + .argument("[project]", "Project ID or project key") + .addOption(opt.json()) + .envVars([...ENV_AUTH, ENV_PROJECT]) + .examples([ { description: "List all statuses", command: "bee status list PROJECT" }, { description: "Output as JSON", command: "bee status list PROJECT --json" }, - ], - - annotations: { - environment: [...ENV_AUTH, ENV_PROJECT], - }, -}; - -const list = withUsage( - defineCommand({ - meta: { - name: "list", - description: "List statuses", - }, - args: { - ...outputArgs, - project: commonArgs.projectPositional, - }, - async run({ args }) { - const { client } = await getClient(); + ]) + .action(async (project, opts) => { + const { client } = await getClient(); - const statuses = await client.getProjectStatuses(args.project); + const statuses = await client.getProjectStatuses(project); - outputResult(statuses, args, (data) => { - if (data.length === 0) { - consola.info("No statuses found."); - return; - } + outputResult(statuses, opts, (data) => { + if (data.length === 0) { + consola.info("No statuses found."); + return; + } - const rows: Row[] = data.map((s) => [ - { header: "ID", value: String(s.id) }, - { header: "NAME", value: s.name }, - { header: "COLOR", value: s.color }, - ]); + const rows: Row[] = data.map((s) => [ + { header: "ID", value: String(s.id) }, + { header: "NAME", value: s.name }, + { header: "COLOR", value: s.color }, + ]); - printTable(rows); - }); - }, - }), - commandUsage, -); + printTable(rows); + }); + }); -export { commandUsage, list }; +export default list; diff --git a/apps/cli/src/commands/user/activities.ts b/apps/cli/src/commands/user/activities.ts index 0b77e64b..2fe15bc5 100644 --- a/apps/cli/src/commands/user/activities.ts +++ b/apps/cli/src/commands/user/activities.ts @@ -83,7 +83,7 @@ https://developer.nulab.com/docs/backlog/api/2/get-user-recent-updates/#activity const activityList = await client.getUserActivities(Number(user), { activityTypeId, count: opts.count ? Number(opts.count) : undefined, - order: opts.order as "asc" | "desc" | undefined, + order: opts.order, minId: opts.minId ? Number(opts.minId) : undefined, maxId: opts.maxId ? Number(opts.maxId) : undefined, }); diff --git a/apps/cli/src/commands/webhook/create.ts b/apps/cli/src/commands/webhook/create.ts index d6f0d01e..5ce1088a 100644 --- a/apps/cli/src/commands/webhook/create.ts +++ b/apps/cli/src/commands/webhook/create.ts @@ -45,21 +45,21 @@ activity type IDs with \`--activity-type-ids\`.`, "bee webhook create -p PROJECT -n CI --hook-url https://example.com/hook --activity-type-ids 1 --activity-type-ids 2 --activity-type-ids 3", }, ]) - .action(async (_opts, cmd) => { - const opts = await resolveOptions(cmd); + .action(async (opts, cmd) => { + await resolveOptions(cmd); const { client } = await getClient(); - const name = await promptRequired("Webhook name:", opts.name as string | undefined); - const activityTypeIds: number[] = (opts.activityTypeIds as number[]) ?? []; + const name = await promptRequired("Webhook name:", opts.name); + const activityTypeIds: number[] = opts.activityTypeIds ?? []; - const webhook = await client.postWebhook(opts.project as string, { + const webhook = await client.postWebhook(opts.project, { name, - hookUrl: opts.hookUrl as string, - allEvent: opts.allEvent as boolean | undefined, + hookUrl: opts.hookUrl, + allEvent: opts.allEvent, activityTypeIds, }); - const json = opts.json === true ? "" : (opts.json as string | undefined); + const json = opts.json === true ? "" : opts.json; outputResult(webhook, { json }, (data) => { consola.success(`Created webhook ${data.name} (ID: ${data.id})`); }); diff --git a/apps/cli/src/commands/webhook/delete.ts b/apps/cli/src/commands/webhook/delete.ts index 4926b170..4d2d1ca9 100644 --- a/apps/cli/src/commands/webhook/delete.ts +++ b/apps/cli/src/commands/webhook/delete.ts @@ -28,12 +28,12 @@ This action is irreversible. You will be prompted for confirmation unless command: "bee webhook delete 12345 -p PROJECT --yes", }, ]) - .action(async (webhook, _opts, cmd) => { - const opts = await resolveOptions(cmd); + .action(async (webhook, opts, cmd) => { + await resolveOptions(cmd); const confirmed = await confirmOrExit( `Are you sure you want to delete webhook ${webhook}? This cannot be undone.`, - opts.yes as boolean | undefined, + opts.yes, ); if (!confirmed) { @@ -42,9 +42,9 @@ This action is irreversible. You will be prompted for confirmation unless const { client } = await getClient(); - const webhookData = await client.deleteWebhook(opts.project as string, webhook); + const webhookData = await client.deleteWebhook(opts.project, webhook); - const json = opts.json === true ? "" : (opts.json as string | undefined); + const json = opts.json === true ? "" : opts.json; outputResult(webhookData, { json }, (data) => { consola.success(`Deleted webhook ${data.name} (ID: ${data.id})`); }); diff --git a/apps/cli/src/commands/webhook/edit.ts b/apps/cli/src/commands/webhook/edit.ts index ff3227bc..0639b938 100644 --- a/apps/cli/src/commands/webhook/edit.ts +++ b/apps/cli/src/commands/webhook/edit.ts @@ -40,20 +40,20 @@ All fields are optional. Only the specified fields will be updated.`, command: "bee webhook edit 12345 -p PROJECT --all-event", }, ]) - .action(async (webhook, _opts, cmd) => { - const opts = await resolveOptions(cmd); + .action(async (webhook, opts, cmd) => { + await resolveOptions(cmd); const { client } = await getClient(); - const activityTypeIds: number[] = (opts.activityTypeIds as number[]) ?? []; + const activityTypeIds: number[] = opts.activityTypeIds ?? []; - const webhookData = await client.patchWebhook(opts.project as string, webhook, { - name: opts.name as string | undefined, - hookUrl: opts.hookUrl as string | undefined, - allEvent: opts.allEvent as boolean | undefined, + const webhookData = await client.patchWebhook(opts.project, webhook, { + name: opts.name, + hookUrl: opts.hookUrl, + allEvent: opts.allEvent, activityTypeIds, }); - const json = opts.json === true ? "" : (opts.json as string | undefined); + const json = opts.json === true ? "" : opts.json; outputResult(webhookData, { json }, (data) => { consola.success(`Updated webhook ${data.name} (ID: ${data.id})`); }); diff --git a/apps/cli/src/commands/webhook/list.ts b/apps/cli/src/commands/webhook/list.ts index 61ecbde1..ca458539 100644 --- a/apps/cli/src/commands/webhook/list.ts +++ b/apps/cli/src/commands/webhook/list.ts @@ -20,13 +20,13 @@ in a project.`, { description: "List all webhooks in a project", command: "bee webhook list -p PROJECT" }, { description: "Output as JSON", command: "bee webhook list -p PROJECT --json" }, ]) - .action(async (_opts, cmd) => { - const opts = await resolveOptions(cmd); + .action(async (opts, cmd) => { + await resolveOptions(cmd); const { client } = await getClient(); - const webhooks = await client.getWebhooks(opts.project as string); + const webhooks = await client.getWebhooks(opts.project); - const json = opts.json === true ? "" : (opts.json as string | undefined); + const json = opts.json === true ? "" : opts.json; outputResult(webhooks, { json }, (data) => { if (data.length === 0) { consola.info("No webhooks found."); diff --git a/apps/cli/src/commands/webhook/view.ts b/apps/cli/src/commands/webhook/view.ts index 142f33e6..143fa3fb 100644 --- a/apps/cli/src/commands/webhook/view.ts +++ b/apps/cli/src/commands/webhook/view.ts @@ -20,13 +20,13 @@ Shows the webhook name, ID, hook URL, description, and activity type IDs.`, { description: "View a webhook", command: "bee webhook view 12345 -p PROJECT" }, { description: "Output as JSON", command: "bee webhook view 12345 -p PROJECT --json" }, ]) - .action(async (webhook, _opts, cmd) => { - const opts = await resolveOptions(cmd); + .action(async (webhook, opts, cmd) => { + await resolveOptions(cmd); const { client } = await getClient(); - const webhookData = await client.getWebhook(opts.project as string, webhook); + const webhookData = await client.getWebhook(opts.project, webhook); - const json = opts.json === true ? "" : (opts.json as string | undefined); + const json = opts.json === true ? "" : opts.json; outputResult(webhookData, { json }, (data) => { consola.log(""); consola.log(` ${data.name}`); diff --git a/apps/cli/src/commands/wiki/count.ts b/apps/cli/src/commands/wiki/count.ts index 27c2c28b..a2ffa0dd 100644 --- a/apps/cli/src/commands/wiki/count.ts +++ b/apps/cli/src/commands/wiki/count.ts @@ -19,13 +19,13 @@ The count includes all wiki pages regardless of tag or keyword.`, { description: "Count wiki pages", command: "bee wiki count -p PROJECT" }, { description: "Output as JSON", command: "bee wiki count -p PROJECT --json" }, ]) - .action(async (_opts, cmd) => { - const opts = await resolveOptions(cmd); + .action(async (opts, cmd) => { + await resolveOptions(cmd); const { client } = await getClient(); - const result = await client.getWikisCount(opts.project as string); + const result = await client.getWikisCount(opts.project); - const json = opts.json === true ? "" : (opts.json as string | undefined); + const json = opts.json === true ? "" : opts.json; outputResult(result, { json }, (data) => { consola.log(String(data.count)); }); diff --git a/apps/cli/src/commands/wiki/history.ts b/apps/cli/src/commands/wiki/history.ts index 3dd5e3fa..d6df091b 100644 --- a/apps/cli/src/commands/wiki/history.ts +++ b/apps/cli/src/commands/wiki/history.ts @@ -33,7 +33,7 @@ Shows version number, updater, and update date for each revision.`, minId: opts.minId ? Number(opts.minId) : undefined, maxId: opts.maxId ? Number(opts.maxId) : undefined, count: opts.count ? Number(opts.count) : undefined, - order: opts.order as "asc" | "desc" | undefined, + order: opts.order, }); outputResult(histories, opts, (data) => { diff --git a/apps/cli/src/commands/wiki/list.ts b/apps/cli/src/commands/wiki/list.ts index 18773ecd..81b67178 100644 --- a/apps/cli/src/commands/wiki/list.ts +++ b/apps/cli/src/commands/wiki/list.ts @@ -24,16 +24,16 @@ Use \`--keyword\` to filter pages by name or content.`, }, { description: "Output as JSON", command: "bee wiki list -p PROJECT --json" }, ]) - .action(async (_opts, cmd) => { - const opts = await resolveOptions(cmd); + .action(async (opts, cmd) => { + await resolveOptions(cmd); const { client } = await getClient(); const wikis = await client.getWikis({ - projectIdOrKey: opts.project as string, - keyword: opts.keyword as string | undefined, + projectIdOrKey: opts.project, + keyword: opts.keyword, }); - const json = opts.json === true ? "" : (opts.json as string | undefined); + const json = opts.json === true ? "" : opts.json; outputResult(wikis, { json }, (data) => { if (data.length === 0) { consola.info("No wiki pages found."); diff --git a/apps/cli/src/commands/wiki/tags.ts b/apps/cli/src/commands/wiki/tags.ts index 89a0774a..6b76b697 100644 --- a/apps/cli/src/commands/wiki/tags.ts +++ b/apps/cli/src/commands/wiki/tags.ts @@ -19,13 +19,13 @@ Tags are labels attached to wiki pages for organization.`, { description: "List wiki tags", command: "bee wiki tags -p PROJECT" }, { description: "Output as JSON", command: "bee wiki tags -p PROJECT --json" }, ]) - .action(async (_opts, cmd) => { - const opts = await resolveOptions(cmd); + .action(async (opts, cmd) => { + await resolveOptions(cmd); const { client } = await getClient(); - const result = await client.getWikisTags(opts.project as string); + const result = await client.getWikisTags(opts.project); - const json = opts.json === true ? "" : (opts.json as string | undefined); + const json = opts.json === true ? "" : opts.json; outputResult(result, { json }, (data) => { if (data.length === 0) { consola.info("No wiki tags found."); diff --git a/apps/cli/src/lib/command-usage.ts b/apps/cli/src/lib/command-usage.ts deleted file mode 100644 index 328eb5e0..00000000 --- a/apps/cli/src/lib/command-usage.ts +++ /dev/null @@ -1,280 +0,0 @@ -import { type ArgsDef, type CommandDef, showUsage as cittyShowUsage } from "citty"; -import consola from "consola"; -import { colorize } from "consola/utils"; - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -type CommandUsage = { - long?: string; - examples?: { description: string; command: string }[]; - annotations?: { - arguments?: string; - environment?: [string, string][]; - }; -}; - -// --------------------------------------------------------------------------- -// Attach / retrieve usage on a command object (no global registry) -// --------------------------------------------------------------------------- - -const kUsage: unique symbol = Symbol("commandUsage"); - -type CommandDefWithUsage = CommandDef & { [kUsage]?: CommandUsage }; - -/** - * Attach a `CommandUsage` to a citty `CommandDef`. - * - * The usage object is stored directly on the command via a private Symbol, - * so no global registry is needed. - */ -const withUsage = <T extends ArgsDef>(cmd: CommandDef<T>, usage: CommandUsage): CommandDef<T> => { - // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- intentional: attaching metadata via symbol key - (cmd as CommandDefWithUsage)[kUsage] = usage; - return cmd; -}; - -/** - * Retrieve the `CommandUsage` previously attached via `withUsage`. - * Useful for documentation generation scripts that import command modules. - */ -const getCommandUsage = (cmd: CommandDef): CommandUsage | undefined => - // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- intentional: reading metadata via symbol key - (cmd as CommandDefWithUsage)[kUsage]; - -// --------------------------------------------------------------------------- -// Rendering -// --------------------------------------------------------------------------- - -type NormalizedArg = { - name: string; - type?: "boolean" | "string" | "positional"; - description?: string; - alias: string[]; - default?: string | boolean; - required?: boolean; - valueHint?: string; -}; - -/** - * Create a `showUsage`-compatible closure that renders gh-cli style help - * for the given `CommandUsage`. - */ -const renderCommandUsage = - (usage: CommandUsage) => - async (cmd: CommandDef, parent?: CommandDef): Promise<void> => { - const meta = await resolveValue(cmd.meta ?? {}); - const parentMeta = parent ? await resolveValue(parent.meta ?? {}) : undefined; - const args = normalizeArgs(await resolveValue(cmd.args ?? {})); - - const commandName = [parentMeta?.name, meta.name].filter(Boolean).join(" ") || process.argv[1]; - - const lines: string[] = []; - - // -- Description -------------------------------------------------------- - lines.push(usage.long ?? meta.description ?? ""); - lines.push(""); - - // -- USAGE -------------------------------------------------------------- - const usageParts: string[] = []; - const flags = args.filter((a) => a.type !== "positional"); - if (flags.length > 0) { - usageParts.push("[flags]"); - } - for (const arg of args) { - if (arg.type === "positional") { - const name = arg.name.toUpperCase(); - usageParts.push( - arg.required !== false && arg.default === undefined ? `<${name}>` : `[${name}]`, - ); - } - } - if (cmd.subCommands) { - const subs = await resolveValue(cmd.subCommands); - usageParts.push(Object.keys(subs).join("|")); - } - lines.push(colorize("bold", "USAGE")); - lines.push(` ${commandName} ${usageParts.join(" ")}`); - lines.push(""); - - // -- ARGUMENTS (positional) --------------------------------------------- - const positionals = args.filter((a) => a.type === "positional"); - if (positionals.length > 0) { - lines.push(colorize("bold", "ARGUMENTS")); - const rows = positionals.map((a) => { - const name = a.name.toUpperCase(); - const valueSuffix = a.valueHint ? ` ${a.valueHint}` : ""; - const hint = a.default ? ` (default: "${a.default}")` : ""; - return [` ${name}`, `${a.description ?? ""}${valueSuffix}${hint}`]; - }); - lines.push(...alignColumns(rows)); - lines.push(""); - } - - // -- FLAGS (non-positional) --------------------------------------------- - if (flags.length > 0) { - lines.push(colorize("bold", "FLAGS")); - lines.push(...formatFlags(flags)); - lines.push(""); - } - - // -- INHERITED FLAGS ---------------------------------------------------- - lines.push(colorize("bold", "INHERITED FLAGS")); - lines.push(" --help Show help for command"); - lines.push(""); - - // -- COMMANDS (subcommands) --------------------------------------------- - if (cmd.subCommands) { - const subs = await resolveValue(cmd.subCommands); - const rows: string[][] = []; - for (const [name, sub] of Object.entries(subs)) { - const subCmd = await resolveValue(sub); - const subMeta = await resolveValue(subCmd.meta ?? {}); - rows.push([` ${name}`, subMeta.description ?? ""]); - } - lines.push(colorize("bold", "COMMANDS")); - lines.push(...alignColumns(rows)); - lines.push(""); - } - - // -- annotations: arguments --------------------------------------------- - if (usage.annotations?.arguments) { - lines.push(colorize("bold", "ARGUMENTS")); - lines.push(indent(usage.annotations.arguments)); - lines.push(""); - } - - // -- EXAMPLES ----------------------------------------------------------- - if (usage.examples && usage.examples.length > 0) { - lines.push(colorize("bold", "EXAMPLES")); - for (const ex of usage.examples) { - lines.push(` # ${ex.description}`); - lines.push(` $ ${ex.command}`); - lines.push(""); - } - } - - // -- ENVIRONMENT VARIABLES ---------------------------------------------- - if (usage.annotations?.environment && usage.annotations.environment.length > 0) { - lines.push(colorize("bold", "ENVIRONMENT VARIABLES")); - lines.push( - ...alignColumns(usage.annotations.environment.map(([key, desc]) => [` ${key}`, desc])), - ); - lines.push(""); - } - - // -- LEARN MORE --------------------------------------------------------- - lines.push(colorize("bold", "LEARN MORE")); - lines.push(` Use \`${commandName} <command> --help\` for more information about a command.`); - - consola.log(`${lines.join("\n")}\n`); - }; - -/** - * `showUsage` replacement for `runMain`. - * - * If the resolved command has an attached `CommandUsage`, renders rich - * gh-cli style help via the closure created by `renderCommandUsage`. - * Otherwise falls back to citty's built-in `showUsage`. - */ -const showCommandUsage = async <T extends ArgsDef>( - cmd: CommandDef<T>, - parent?: CommandDef<T>, -): Promise<void> => { - // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- generic CommandDef<T> is structurally compatible for metadata read - const resolved = cmd as CommandDef; - const usage = getCommandUsage(resolved); - await (usage - ? // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- generic CommandDef<T> is structurally compatible for rendering - renderCommandUsage(usage)(resolved, parent as CommandDef) - : cittyShowUsage(cmd, parent)); -}; - -// --------------------------------------------------------------------------- -// Helpers (private) -// --------------------------------------------------------------------------- - -const resolveValue = async <T>( - val: T | (() => T) | (() => Promise<T>) | Promise<T>, -): Promise<Awaited<T>> => { - if (typeof val === "function") { - // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- generic function cast required - return await (val as () => T | Promise<T>)(); - } - return await val; -}; - -const toAliasArray = (alias: unknown): string[] => { - if (Array.isArray(alias)) { - return alias.map(String); - } - if (typeof alias === "string") { - return [alias]; - } - return []; -}; - -const normalizeArgs = (argsDef: Record<string, Record<string, unknown>>): NormalizedArg[] => - Object.entries(argsDef).map(([name, def]) => ({ - ...(def as Omit<NormalizedArg, "name" | "alias">), - name, - alias: toAliasArray(def.alias), - })); - -const formatFlags = (args: NormalizedArg[]): string[] => { - const rows: [string, string][] = args.map((arg) => { - const short = arg.alias.map((a) => `-${a}`).join(", "); - const long = `--${arg.name}`; - const flag = short ? `${short}, ${long}` : ` ${long}`; - - let hint = ""; - if (arg.type === "string") { - hint = arg.valueHint ? ` ${arg.valueHint}` : " string"; - } - - const defaultSuffix = arg.default === undefined ? "" : ` (default: "${arg.default}")`; - - return [` ${flag}${hint}`, `${arg.description ?? ""}${defaultSuffix}`]; - }); - - return alignColumns(rows); -}; - -const alignColumns = (rows: string[][]): string[] => { - if (rows.length === 0) { - return []; - } - const maxLen = Math.max(...rows.map(([col]) => col.length)); - return rows.map(([col1, col2]) => (col2 ? `${col1.padEnd(maxLen + 3)}${col2}` : col1)); -}; - -const indent = (text: string): string => - text - .split("\n") - .map((line) => ` ${line}`) - .join("\n"); - -// --------------------------------------------------------------------------- -// Shared environment variable entries -// --------------------------------------------------------------------------- - -const ENV_AUTH: [string, string][] = [ - ["BACKLOG_API_KEY", "Authenticate with an API key"], - ["BACKLOG_SPACE", "Default space hostname"], -]; - -const ENV_PROJECT: [string, string] = ["BACKLOG_PROJECT", "Default project ID or project key"]; - -const ENV_REPO: [string, string] = ["BACKLOG_REPO", "Default repository name"]; - -export { - type CommandUsage, - withUsage, - getCommandUsage, - renderCommandUsage, - showCommandUsage, - ENV_AUTH, - ENV_PROJECT, - ENV_REPO, -}; diff --git a/apps/cli/src/lib/common-args.ts b/apps/cli/src/lib/common-args.ts deleted file mode 100644 index e6ad120f..00000000 --- a/apps/cli/src/lib/common-args.ts +++ /dev/null @@ -1,172 +0,0 @@ -/** - * Shared argument definitions for CLI commands. - * - * Import as a namespace and spread into `args` to ensure consistent - * descriptions, aliases, and valueHints across commands. Override - * individual properties when needed (e.g. `{ ...commonArgs.project, required: true }`). - * - * @example - * ```ts - * import * as commonArgs from "../../lib/common-args"; - * - * args: { - * ...outputArgs, - * project: commonArgs.project, - * count: commonArgs.count, - * order: commonArgs.order, - * } - * ``` - */ - -// --------------------------------------------------------------------------- -// Project / Repository -// --------------------------------------------------------------------------- - -const project = { - type: "string" as const, - alias: "p", - description: "Project ID or project key", - default: process.env.BACKLOG_PROJECT, -}; - -const projectPositional = { - type: "positional", - description: "Project ID or project key", - required: true, - default: process.env.BACKLOG_PROJECT, -} as const; - -const repo = { - type: "string", - alias: "R", - description: "Repository name or ID", - default: process.env.BACKLOG_REPO, - required: true, -} as const; - -// --------------------------------------------------------------------------- -// Pagination -// --------------------------------------------------------------------------- - -const count = { - type: "string" as const, - alias: "L", - description: "Number of results (default: 20)", - valueHint: "<1-100>", -}; - -const offset = { - type: "string" as const, - description: "Offset for pagination", - valueHint: "<number>", -}; - -const order = { - type: "string" as const, - description: "Sort order", - valueHint: "{asc|desc}", -}; - -const minId = { - type: "string" as const, - description: "Minimum ID for cursor-based pagination", - valueHint: "<number>", -}; - -const maxId = { - type: "string" as const, - description: "Maximum ID for cursor-based pagination", - valueHint: "<number>", -}; - -// --------------------------------------------------------------------------- -// Common filters -// --------------------------------------------------------------------------- - -const keyword = { - type: "string" as const, - alias: "k", - description: "Keyword search", -}; - -const assignee = { - type: "string" as const, - alias: "a", - description: "Assignee user ID. Use @me for yourself.", -}; - -const assigneeList = { - type: "string" as const, - alias: "a", - description: "Assignee user ID (comma-separated for multiple). Use @me for yourself.", -}; - -// --------------------------------------------------------------------------- -// Issue -// --------------------------------------------------------------------------- - -const issue = { - type: "string" as const, - description: "Issue ID or issue key", - valueHint: "<PROJECT-123>", -}; - -// --------------------------------------------------------------------------- -// Mutation helpers -// --------------------------------------------------------------------------- - -const notify = { - type: "string" as const, - description: "User IDs to notify (comma-separated for multiple)", -}; - -const attachment = { - type: "string" as const, - description: "Attachment IDs (comma-separated for multiple)", -}; - -const comment = { - type: "string" as const, - alias: "c", - description: "Comment to add with the update", -}; - -// --------------------------------------------------------------------------- -// --web flag (factory — description varies by resource) -// --------------------------------------------------------------------------- - -const web = (resource: string) => ({ - type: "boolean" as const, - alias: "w", - description: `Open the ${resource} in the browser`, -}); - -// --------------------------------------------------------------------------- -// --no-browser flag (print URL instead of opening browser) -// --------------------------------------------------------------------------- - -const noBrowser = { - type: "boolean" as const, - alias: "n", - description: "Print the URL instead of opening the browser", -}; - -export { - project, - projectPositional, - repo, - issue, - count, - offset, - order, - minId, - maxId, - keyword, - assignee, - assigneeList, - notify, - attachment, - comment, - web, - noBrowser, -}; diff --git a/apps/cli/src/lib/required-option.test.ts b/apps/cli/src/lib/required-option.test.ts index 94890cea..14d254cd 100644 --- a/apps/cli/src/lib/required-option.test.ts +++ b/apps/cli/src/lib/required-option.test.ts @@ -35,10 +35,10 @@ describe("resolveOptions", () => { // parse with no args — title is undefined cmd.parse([], { from: "user" }); - const opts = await resolveOptions(cmd); + await resolveOptions(cmd); expect(promptRequired).toHaveBeenCalledWith("Issue title:", undefined); - expect(opts.title).toBe("prompted-value"); + expect(cmd.opts().title).toBe("prompted-value"); }); it("passes existing value to promptRequired", async () => { @@ -50,10 +50,10 @@ describe("resolveOptions", () => { .action(() => {}); cmd.parse(["--title", "existing"], { from: "user" }); - const opts = await resolveOptions(cmd); + await resolveOptions(cmd); expect(promptRequired).toHaveBeenCalledWith("Issue title:", "existing"); - expect(opts.title).toBe("existing"); + expect(cmd.opts().title).toBe("existing"); }); it("ignores non-RequiredOption options", async () => { diff --git a/apps/cli/src/lib/required-option.ts b/apps/cli/src/lib/required-option.ts index c86372f9..18172090 100644 --- a/apps/cli/src/lib/required-option.ts +++ b/apps/cli/src/lib/required-option.ts @@ -10,7 +10,7 @@ class RequiredOption extends Option { } } -const resolveOptions = async (cmd: Command): Promise<Record<string, unknown>> => { +const resolveOptions = async (cmd: Command): Promise<void> => { const opts = cmd.opts(); for (const opt of cmd.options) { if (opt instanceof RequiredOption) { @@ -18,7 +18,6 @@ const resolveOptions = async (cmd: Command): Promise<Record<string, unknown>> => opts[key] = await promptRequired(`${opt.promptLabel}:`, opts[key]); } } - return opts; }; export { RequiredOption, resolveOptions }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 06278384..0861b008 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,9 +50,6 @@ importers: backlog-js: specifier: ^0.16.0 version: 0.16.0 - citty: - specifier: ^0.2.1 - version: 0.2.1 commander: specifier: ^14.0.3 version: 14.0.3 @@ -1598,9 +1595,6 @@ packages: citty@0.1.6: resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==} - citty@0.2.1: - resolution: {integrity: sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg==} - cli-boxes@3.0.0: resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==} engines: {node: '>=10'} @@ -4672,8 +4666,6 @@ snapshots: dependencies: consola: 3.4.2 - citty@0.2.1: {} - cli-boxes@3.0.0: {} clsx@2.1.1: {} From 89df7873303a374a28d2d73b999abd369a59f3cd Mon Sep 17 00:00:00 2001 From: Ryoya Tamura <ryoya.tamura@nulab.com> Date: Sun, 8 Mar 2026 18:55:49 +0900 Subject: [PATCH 10/16] fix(cli): clean up unused import and nested ternary - Remove unused BacklogOption import from issue/list.ts - Simplify nested ternary in api.ts to typeof check Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --- apps/cli/src/commands/api.ts | 17 +++++++++++++---- apps/cli/src/commands/issue/list.ts | 1 - 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/apps/cli/src/commands/api.ts b/apps/cli/src/commands/api.ts index b19904e1..fbc717c9 100644 --- a/apps/cli/src/commands/api.ts +++ b/apps/cli/src/commands/api.ts @@ -30,8 +30,18 @@ PATCH, and DELETE requests, fields are sent as the request body.`, ) .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( + "-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]) @@ -71,8 +81,7 @@ PATCH, and DELETE requests, fields are sent as the request body.`, // Default to JSON output (api always returns JSON). // --json with field names filters the output via outputResult. - // commander gives `true` for bare --json, a string for --json fields - const jsonVal = opts.json === undefined ? "" : (opts.json === true ? "" : opts.json); + const jsonVal = typeof opts.json === "string" ? opts.json : ""; outputResult(data, { json: jsonVal }, () => {}); }); diff --git a/apps/cli/src/commands/issue/list.ts b/apps/cli/src/commands/issue/list.ts index 0e4eedcb..6d0d3f48 100644 --- a/apps/cli/src/commands/issue/list.ts +++ b/apps/cli/src/commands/issue/list.ts @@ -1,6 +1,5 @@ import { PRIORITY_NAMES, PriorityId, getClient, resolveProjectIds } from "@repo/backlog-utils"; import { type Row, outputResult, printTable } from "@repo/cli-utils"; -import { type Option as BacklogOption } from "backlog-js"; import consola from "consola"; import { Option } from "commander"; import { BeeCommand, ENV_AUTH, ENV_PROJECT } from "../../lib/bee-command"; From 53266ecc02a000b502188b5cb139279012234eaf Mon Sep 17 00:00:00 2001 From: Ryoya Tamura <ryoya.tamura@nulab.com> Date: Sun, 8 Mar 2026 19:00:11 +0900 Subject: [PATCH 11/16] chore: allow default exports in CLI commands directory Commander's addCommands() requires default exports from command modules. Disable no-default-export rule for apps/cli/src/commands/. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --- .oxlintrc.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.oxlintrc.json b/.oxlintrc.json index 0d429d21..cbf2e544 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -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" } } ], From 7abbf0b0a173bda13a1003eedf7700aac25dca73 Mon Sep 17 00:00:00 2001 From: Ryoya Tamura <ryoya.tamura@nulab.com> Date: Sun, 8 Mar 2026 19:03:34 +0900 Subject: [PATCH 12/16] style: fix formatting in team commands and lib files Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --- apps/cli/src/commands/team/create.ts | 2 +- apps/cli/src/commands/team/edit.ts | 2 +- apps/cli/src/lib/bee-command.ts | 8 ++++++-- apps/cli/src/lib/error.ts | 4 +++- 4 files changed, 11 insertions(+), 5 deletions(-) diff --git a/apps/cli/src/commands/team/create.ts b/apps/cli/src/commands/team/create.ts index 55a80318..04e947b7 100644 --- a/apps/cli/src/commands/team/create.ts +++ b/apps/cli/src/commands/team/create.ts @@ -32,7 +32,7 @@ when creating the team.`, const { client } = await getClient(); const name = await promptRequired("Team name:", opts.name); - const {members} = opts; + const { members } = opts; let t; try { diff --git a/apps/cli/src/commands/team/edit.ts b/apps/cli/src/commands/team/edit.ts index 486aaa71..c61a4867 100644 --- a/apps/cli/src/commands/team/edit.ts +++ b/apps/cli/src/commands/team/edit.ts @@ -40,7 +40,7 @@ the given user IDs.`, .action(async (team, opts) => { const { client } = await getClient(); - const {members} = opts; + const { members } = opts; let t; try { diff --git a/apps/cli/src/lib/bee-command.ts b/apps/cli/src/lib/bee-command.ts index 086ff6fd..871b462f 100644 --- a/apps/cli/src/lib/bee-command.ts +++ b/apps/cli/src/lib/bee-command.ts @@ -34,7 +34,9 @@ class BeeCommand extends Command { } private _renderExamples(): string { - if (this._examples.length === 0) {return "";} + if (this._examples.length === 0) { + return ""; + } const lines = this._examples.flatMap((ex) => [ ` # ${ex.description}`, ` $ ${ex.command}`, @@ -48,7 +50,9 @@ class BeeCommand extends Command { .filter((opt) => opt.envVar) .map((opt) => [opt.envVar!, opt.description ?? ""]); const vars = [...fromOptions, ...this._extraEnvVars]; - if (vars.length === 0) {return "";} + if (vars.length === 0) { + return ""; + } const maxLen = Math.max(...vars.map(([k]) => k.length)); const lines = vars.map(([k, d]) => ` ${k.padEnd(maxLen + 3)}${d}`); return `\n${colorize("bold", "ENVIRONMENT VARIABLES")}\n${lines.join("\n")}`; diff --git a/apps/cli/src/lib/error.ts b/apps/cli/src/lib/error.ts index ad26ef75..1f500185 100644 --- a/apps/cli/src/lib/error.ts +++ b/apps/cli/src/lib/error.ts @@ -5,7 +5,9 @@ import consola, { LogLevels } from "consola"; const handleError = (error: unknown): never | void => { if (error instanceof CommanderError) { - if (error.exitCode === 0) {return;} + if (error.exitCode === 0) { + return; + } process.exit(error.exitCode); } From aecbaf6c56ac1b6c19798197a3e148860b0ace85 Mon Sep 17 00:00:00 2001 From: Ryoya Tamura <ryoya.tamura@nulab.com> Date: Sun, 8 Mar 2026 19:28:38 +0900 Subject: [PATCH 13/16] fix(cli): use positional args for project parameter in 9 commands Commands like `project view`, `project users`, `project activities`, `project delete`, `project edit`, `repo list`, `wiki list`, `wiki count`, and `wiki tags` should accept project as a positional argument per the documented CLI interface, not as a --project/-p option. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --- .../src/commands/project/activities.test.ts | 10 +++++----- apps/cli/src/commands/project/activities.ts | 17 +++++++---------- apps/cli/src/commands/project/delete.test.ts | 8 ++++---- apps/cli/src/commands/project/delete.ts | 17 +++++++---------- apps/cli/src/commands/project/edit.test.ts | 8 ++++---- apps/cli/src/commands/project/edit.ts | 17 +++++++---------- apps/cli/src/commands/project/users.test.ts | 6 +++--- apps/cli/src/commands/project/users.ts | 13 +++++-------- apps/cli/src/commands/project/view.test.ts | 8 ++++---- apps/cli/src/commands/project/view.ts | 19 ++++++++----------- apps/cli/src/commands/repo/list.test.ts | 6 +++--- apps/cli/src/commands/repo/list.ts | 12 +++++------- apps/cli/src/commands/wiki/count.test.ts | 4 ++-- apps/cli/src/commands/wiki/count.ts | 12 +++++------- apps/cli/src/commands/wiki/list.test.ts | 8 ++++---- apps/cli/src/commands/wiki/list.ts | 14 ++++++-------- apps/cli/src/commands/wiki/tags.test.ts | 6 +++--- apps/cli/src/commands/wiki/tags.ts | 12 +++++------- 18 files changed, 87 insertions(+), 110 deletions(-) diff --git a/apps/cli/src/commands/project/activities.test.ts b/apps/cli/src/commands/project/activities.test.ts index 53479509..15a5f8d9 100644 --- a/apps/cli/src/commands/project/activities.test.ts +++ b/apps/cli/src/commands/project/activities.test.ts @@ -38,7 +38,7 @@ describe("project activities", () => { ]); const { default: activities } = await import("./activities"); - await activities.parseAsync(["--project", "PROJ1"], { from: "user" }); + await activities.parseAsync(["PROJ1"], { from: "user" }); expect(mockClient.getProjectActivities).toHaveBeenCalledWith("PROJ1", expect.any(Object)); expect(consola.log).toHaveBeenCalledWith(expect.stringContaining("2024-01-15")); @@ -51,7 +51,7 @@ describe("project activities", () => { mockClient.getProjectActivities.mockResolvedValue([]); const { default: activities } = await import("./activities"); - await activities.parseAsync(["--project", "PROJ1"], { from: "user" }); + await activities.parseAsync(["PROJ1"], { from: "user" }); expect(consola.info).toHaveBeenCalledWith("No activities found."); }); @@ -60,7 +60,7 @@ describe("project activities", () => { mockClient.getProjectActivities.mockResolvedValue([]); const { default: activities } = await import("./activities"); - await activities.parseAsync(["--project", "PROJ1", "--activity-type", "1,2,3"], { + await activities.parseAsync(["PROJ1", "--activity-type", "1,2,3"], { from: "user", }); @@ -76,7 +76,7 @@ describe("project activities", () => { mockClient.getProjectActivities.mockResolvedValue([]); const { default: activities } = await import("./activities"); - await activities.parseAsync(["--project", "PROJ1", "--count", "50"], { from: "user" }); + await activities.parseAsync(["PROJ1", "--count", "50"], { from: "user" }); expect(mockClient.getProjectActivities).toHaveBeenCalledWith( "PROJ1", @@ -97,7 +97,7 @@ describe("project activities", () => { await expectStdoutContaining(async () => { const { default: activities } = await import("./activities"); - await activities.parseAsync(["--project", "PROJ1", "--json"], { from: "user" }); + await activities.parseAsync(["PROJ1", "--json"], { from: "user" }); }, "Test"); }); }); diff --git a/apps/cli/src/commands/project/activities.ts b/apps/cli/src/commands/project/activities.ts index 2da817bd..48751f1b 100644 --- a/apps/cli/src/commands/project/activities.ts +++ b/apps/cli/src/commands/project/activities.ts @@ -4,7 +4,6 @@ import { Option } from "commander"; import consola from "consola"; import { BeeCommand, ENV_AUTH, ENV_PROJECT } from "../../lib/bee-command"; import * as opt from "../../lib/common-options"; -import { resolveOptions } from "../../lib/required-option"; const getActivitySummary = (activity: { type: number; @@ -47,37 +46,35 @@ Use \`--count\` to control how many activities are returned (default: 20, max: 1 For a list of activity type IDs, see: https://developer.nulab.com/docs/backlog/api/2/get-project-recent-updates/#activity-type`, ) - .addOption(opt.project()) + .argument("<project>", "Project ID or project key") .addOption(new Option("--activity-type <ids>", "Filter by activity type IDs (comma-separated)")) .addOption(opt.count()) .addOption(opt.order()) .addOption(opt.json()) .envVars([...ENV_AUTH, ENV_PROJECT]) .examples([ - { description: "List recent activities", command: "bee project activities -p PROJECT_KEY" }, + { description: "List recent activities", command: "bee project activities PROJECT_KEY" }, { description: "Show only issue-related activities", - command: "bee project activities -p PROJECT_KEY --activity-type 1,2,3", + command: "bee project activities PROJECT_KEY --activity-type 1,2,3", }, { description: "Show last 50 activities", - command: "bee project activities -p PROJECT_KEY --count 50", + command: "bee project activities PROJECT_KEY --count 50", }, { description: "Output as JSON", - command: "bee project activities -p PROJECT_KEY --json", + command: "bee project activities PROJECT_KEY --json", }, ]) - .action(async (opts, cmd) => { - await resolveOptions(cmd); - + .action(async (project, opts) => { const { client } = await getClient(); const activityTypeId = opts.activityType ? String(opts.activityType).split(",").map(Number) : undefined; - const activityList = await client.getProjectActivities(opts.project, { + const activityList = await client.getProjectActivities(project, { activityTypeId, count: opts.count ? Number(opts.count) : undefined, order: opts.order, diff --git a/apps/cli/src/commands/project/delete.test.ts b/apps/cli/src/commands/project/delete.test.ts index f5a2024d..67b3bfde 100644 --- a/apps/cli/src/commands/project/delete.test.ts +++ b/apps/cli/src/commands/project/delete.test.ts @@ -25,7 +25,7 @@ describe("project delete", () => { mockClient.deleteProject.mockResolvedValue({ projectKey: "TEST", name: "Test Project" }); const { default: deleteProject } = await import("./delete"); - await deleteProject.parseAsync(["--project", "TEST"], { from: "user" }); + await deleteProject.parseAsync(["TEST"], { from: "user" }); expect(confirmOrExit).toHaveBeenCalledWith( "Are you sure you want to delete project TEST? This cannot be undone.", @@ -40,7 +40,7 @@ describe("project delete", () => { mockClient.deleteProject.mockResolvedValue({ projectKey: "TEST", name: "Test Project" }); const { default: deleteProject } = await import("./delete"); - await deleteProject.parseAsync(["--project", "TEST", "--yes"], { from: "user" }); + await deleteProject.parseAsync(["TEST", "--yes"], { from: "user" }); expect(confirmOrExit).toHaveBeenCalledWith( "Are you sure you want to delete project TEST? This cannot be undone.", @@ -52,7 +52,7 @@ describe("project delete", () => { vi.mocked(confirmOrExit).mockResolvedValue(false); const { default: deleteProject } = await import("./delete"); - await deleteProject.parseAsync(["--project", "TEST"], { from: "user" }); + await deleteProject.parseAsync(["TEST"], { from: "user" }); expect(mockClient.deleteProject).not.toHaveBeenCalled(); }); @@ -63,7 +63,7 @@ describe("project delete", () => { await expectStdoutContaining(async () => { const { default: deleteProject } = await import("./delete"); - await deleteProject.parseAsync(["--project", "TEST", "--yes", "--json"], { from: "user" }); + await deleteProject.parseAsync(["TEST", "--yes", "--json"], { from: "user" }); }, "TEST"); }); }); diff --git a/apps/cli/src/commands/project/delete.ts b/apps/cli/src/commands/project/delete.ts index 4ea5b1e7..f3d47764 100644 --- a/apps/cli/src/commands/project/delete.ts +++ b/apps/cli/src/commands/project/delete.ts @@ -3,7 +3,6 @@ import { confirmOrExit, outputResult } from "@repo/cli-utils"; import consola from "consola"; import { BeeCommand, ENV_AUTH, ENV_PROJECT } from "../../lib/bee-command"; import * as opt from "../../lib/common-options"; -import { resolveOptions } from "../../lib/required-option"; const deleteProject = new BeeCommand("delete") .summary("Delete a project") @@ -15,25 +14,23 @@ This action is irreversible. You will be prompted for confirmation unless Requires Administrator role.`, ) - .addOption(opt.project()) + .argument("<project>", "Project ID or project key") .option("-y, --yes", "Skip confirmation prompt") .addOption(opt.json()) .envVars([...ENV_AUTH, ENV_PROJECT]) .examples([ { description: "Delete a project (with confirmation)", - command: "bee project delete -p PROJECT_KEY", + command: "bee project delete PROJECT_KEY", }, { description: "Delete a project without confirmation", - command: "bee project delete -p PROJECT_KEY --yes", + command: "bee project delete PROJECT_KEY --yes", }, ]) - .action(async (opts, cmd) => { - await resolveOptions(cmd); - + .action(async (project, opts) => { const confirmed = await confirmOrExit( - `Are you sure you want to delete project ${opts.project}? This cannot be undone.`, + `Are you sure you want to delete project ${project}? This cannot be undone.`, opts.yes, ); @@ -43,10 +40,10 @@ Requires Administrator role.`, const { client } = await getClient(); - const project = await client.deleteProject(opts.project); + const projectData = await client.deleteProject(project); const jsonArg = opts.json === true ? "" : opts.json; - outputResult(project, { ...opts, json: jsonArg }, (data) => { + outputResult(projectData, { ...opts, json: jsonArg }, (data) => { consola.success(`Deleted project ${data.projectKey}: ${data.name}`); }); }); diff --git a/apps/cli/src/commands/project/edit.test.ts b/apps/cli/src/commands/project/edit.test.ts index 252f1cf7..9966f64a 100644 --- a/apps/cli/src/commands/project/edit.test.ts +++ b/apps/cli/src/commands/project/edit.test.ts @@ -22,7 +22,7 @@ describe("project edit", () => { mockClient.patchProject.mockResolvedValue({ projectKey: "TEST", name: "New Name" }); const { default: edit } = await import("./edit"); - await edit.parseAsync(["--project", "TEST", "--name", "New Name"], { from: "user" }); + await edit.parseAsync(["TEST", "--name", "New Name"], { from: "user" }); expect(mockClient.patchProject).toHaveBeenCalledWith( "TEST", @@ -35,7 +35,7 @@ describe("project edit", () => { mockClient.patchProject.mockResolvedValue({ projectKey: "TEST", name: "Test" }); const { default: edit } = await import("./edit"); - await edit.parseAsync(["--project", "TEST", "--archived"], { from: "user" }); + await edit.parseAsync(["TEST", "--archived"], { from: "user" }); expect(mockClient.patchProject).toHaveBeenCalledWith( "TEST", @@ -47,7 +47,7 @@ describe("project edit", () => { mockClient.patchProject.mockResolvedValue({ projectKey: "TEST", name: "Test" }); const { default: edit } = await import("./edit"); - await edit.parseAsync(["--project", "TEST", "--text-formatting-rule", "markdown"], { + await edit.parseAsync(["TEST", "--text-formatting-rule", "markdown"], { from: "user", }); @@ -62,7 +62,7 @@ describe("project edit", () => { await expectStdoutContaining(async () => { const { default: edit } = await import("./edit"); - await edit.parseAsync(["--project", "TEST", "--name", "Test", "--json"], { from: "user" }); + await edit.parseAsync(["TEST", "--name", "Test", "--json"], { from: "user" }); }, "TEST"); }); }); diff --git a/apps/cli/src/commands/project/edit.ts b/apps/cli/src/commands/project/edit.ts index bb39692d..331b369b 100644 --- a/apps/cli/src/commands/project/edit.ts +++ b/apps/cli/src/commands/project/edit.ts @@ -3,7 +3,6 @@ import { outputResult } from "@repo/cli-utils"; import consola from "consola"; import { BeeCommand, ENV_AUTH, ENV_PROJECT } from "../../lib/bee-command"; import * as opt from "../../lib/common-options"; -import { resolveOptions } from "../../lib/required-option"; const edit = new BeeCommand("edit") .summary("Edit a project") @@ -13,7 +12,7 @@ const edit = new BeeCommand("edit") Only the specified fields will be updated. Fields that are not provided will remain unchanged.`, ) - .addOption(opt.project()) + .argument("<project>", "Project ID or project key") .option("-n, --name <name>", "New name of the project") .option("-k, --key <key>", "New key of the project") .option("--chart-enabled", "Change whether the chart is enabled") @@ -29,23 +28,21 @@ will remain unchanged.`, .examples([ { description: "Rename a project", - command: 'bee project edit -p PROJECT_KEY --name "New Name"', + command: 'bee project edit PROJECT_KEY --name "New Name"', }, { description: "Archive a project", - command: "bee project edit -p PROJECT_KEY --archived", + command: "bee project edit PROJECT_KEY --archived", }, { description: "Change formatting rule to markdown", - command: "bee project edit -p PROJECT_KEY --text-formatting-rule markdown", + command: "bee project edit PROJECT_KEY --text-formatting-rule markdown", }, ]) - .action(async (opts, cmd) => { - await resolveOptions(cmd); - + .action(async (project, opts) => { const { client } = await getClient(); - const project = await client.patchProject(opts.project, { + const projectData = await client.patchProject(project, { name: opts.name, key: opts.key, chartEnabled: opts.chartEnabled, @@ -56,7 +53,7 @@ will remain unchanged.`, }); const jsonArg = opts.json === true ? "" : opts.json; - outputResult(project, { ...opts, json: jsonArg }, (data) => { + outputResult(projectData, { ...opts, json: jsonArg }, (data) => { consola.success(`Updated project ${data.projectKey}: ${data.name}`); }); }); diff --git a/apps/cli/src/commands/project/users.test.ts b/apps/cli/src/commands/project/users.test.ts index 5ce53568..316d5b51 100644 --- a/apps/cli/src/commands/project/users.test.ts +++ b/apps/cli/src/commands/project/users.test.ts @@ -26,7 +26,7 @@ describe("project users", () => { ]); const { default: users } = await import("./users"); - await users.parseAsync(["--project", "PROJ1"], { from: "user" }); + await users.parseAsync(["PROJ1"], { from: "user" }); expect(mockClient.getProjectUsers).toHaveBeenCalledWith("PROJ1"); expect(consola.log).toHaveBeenCalledWith(expect.stringContaining("ID")); @@ -39,7 +39,7 @@ describe("project users", () => { mockClient.getProjectUsers.mockResolvedValue([]); const { default: users } = await import("./users"); - await users.parseAsync(["--project", "PROJ1"], { from: "user" }); + await users.parseAsync(["PROJ1"], { from: "user" }); expect(consola.info).toHaveBeenCalledWith("No users found."); }); @@ -51,7 +51,7 @@ describe("project users", () => { await expectStdoutContaining(async () => { const { default: users } = await import("./users"); - await users.parseAsync(["--project", "PROJ1", "--json"], { from: "user" }); + await users.parseAsync(["PROJ1", "--json"], { from: "user" }); }, "user1"); }); }); diff --git a/apps/cli/src/commands/project/users.ts b/apps/cli/src/commands/project/users.ts index a43c85bb..1fda7e53 100644 --- a/apps/cli/src/commands/project/users.ts +++ b/apps/cli/src/commands/project/users.ts @@ -3,7 +3,6 @@ import { type Row, outputResult, printTable } from "@repo/cli-utils"; import consola from "consola"; import { BeeCommand, ENV_AUTH, ENV_PROJECT } from "../../lib/bee-command"; import * as opt from "../../lib/common-options"; -import { resolveOptions } from "../../lib/required-option"; const users = new BeeCommand("users") .summary("List project users") @@ -12,19 +11,17 @@ const users = new BeeCommand("users") Displays each user's ID, user ID, name, and role within the project.`, ) - .addOption(opt.project()) + .argument("<project>", "Project ID or project key") .addOption(opt.json()) .envVars([...ENV_AUTH, ENV_PROJECT]) .examples([ - { description: "List project members", command: "bee project users -p PROJECT_KEY" }, - { description: "Output as JSON", command: "bee project users -p PROJECT_KEY --json" }, + { description: "List project members", command: "bee project users PROJECT_KEY" }, + { description: "Output as JSON", command: "bee project users PROJECT_KEY --json" }, ]) - .action(async (opts, cmd) => { - await resolveOptions(cmd); - + .action(async (project, opts) => { const { client } = await getClient(); - const members = await client.getProjectUsers(opts.project); + const members = await client.getProjectUsers(project); const jsonArg = opts.json === true ? "" : opts.json; outputResult(members, { ...opts, json: jsonArg }, (data) => { diff --git a/apps/cli/src/commands/project/view.test.ts b/apps/cli/src/commands/project/view.test.ts index eb351a65..520a9242 100644 --- a/apps/cli/src/commands/project/view.test.ts +++ b/apps/cli/src/commands/project/view.test.ts @@ -41,7 +41,7 @@ describe("project view", () => { mockClient.getProject.mockResolvedValue(sampleProject); const { default: view } = await import("./view"); - await view.parseAsync(["--project", "PROJ1"], { from: "user" }); + await view.parseAsync(["PROJ1"], { from: "user" }); expect(mockClient.getProject).toHaveBeenCalledWith("PROJ1"); expect(consola.log).toHaveBeenCalledWith(expect.stringContaining("Test Project")); @@ -54,14 +54,14 @@ describe("project view", () => { mockClient.getProject.mockResolvedValue({ ...sampleProject, archived: true }); const { default: view } = await import("./view"); - await view.parseAsync(["--project", "PROJ1"], { from: "user" }); + await view.parseAsync(["PROJ1"], { from: "user" }); expect(consola.log).toHaveBeenCalledWith(expect.stringContaining("Archived")); }); it("opens browser with --web flag", async () => { const { default: view } = await import("./view"); - await view.parseAsync(["--project", "PROJ1", "--web"], { from: "user" }); + await view.parseAsync(["PROJ1", "--web"], { from: "user" }); expect(openOrPrintUrl).toHaveBeenCalledWith( "https://example.backlog.com/projects/PROJ1", @@ -76,7 +76,7 @@ describe("project view", () => { await expectStdoutContaining(async () => { const { default: view } = await import("./view"); - await view.parseAsync(["--project", "PROJ1", "--json"], { from: "user" }); + await view.parseAsync(["PROJ1", "--json"], { from: "user" }); }, "PROJ1"); }); }); diff --git a/apps/cli/src/commands/project/view.ts b/apps/cli/src/commands/project/view.ts index 16a983d3..7f460dc2 100644 --- a/apps/cli/src/commands/project/view.ts +++ b/apps/cli/src/commands/project/view.ts @@ -3,7 +3,6 @@ import { outputResult, printDefinitionList } from "@repo/cli-utils"; import consola from "consola"; import { BeeCommand, ENV_AUTH, ENV_PROJECT } from "../../lib/bee-command"; import * as opt from "../../lib/common-options"; -import { resolveOptions } from "../../lib/required-option"; const view = new BeeCommand("view") .summary("View a project") @@ -15,31 +14,29 @@ and git/subversion integration status. Use \`--web\` to open the project in your default browser instead.`, ) - .addOption(opt.project()) + .argument("<project>", "Project ID or project key") .addOption(opt.web("project")) .addOption(opt.noBrowser()) .addOption(opt.json()) .envVars([...ENV_AUTH, ENV_PROJECT]) .examples([ - { description: "View project details", command: "bee project view -p PROJECT_KEY" }, - { description: "Open project in browser", command: "bee project view -p PROJECT_KEY --web" }, - { description: "Output as JSON", command: "bee project view -p PROJECT_KEY --json" }, + { description: "View project details", command: "bee project view PROJECT_KEY" }, + { description: "Open project in browser", command: "bee project view PROJECT_KEY --web" }, + { description: "Output as JSON", command: "bee project view PROJECT_KEY --json" }, ]) - .action(async (opts, cmd) => { - await resolveOptions(cmd); - + .action(async (project, opts) => { const { client, host } = await getClient(); if (opts.web || opts.browser === false) { - const url = projectUrl(host, opts.project); + const url = projectUrl(host, project); await openOrPrintUrl(url, opts.browser === false, consola); return; } - const project = await client.getProject(opts.project); + const projectData = await client.getProject(project); const jsonArg = opts.json === true ? "" : opts.json; - outputResult(project, { ...opts, json: jsonArg }, (data) => { + outputResult(projectData, { ...opts, json: jsonArg }, (data) => { consola.log(""); consola.log(` ${data.name} (${data.projectKey})`); consola.log(""); diff --git a/apps/cli/src/commands/repo/list.test.ts b/apps/cli/src/commands/repo/list.test.ts index bfe1b0f9..b29bac66 100644 --- a/apps/cli/src/commands/repo/list.test.ts +++ b/apps/cli/src/commands/repo/list.test.ts @@ -45,7 +45,7 @@ describe("repo list", () => { mockClient.getGitRepositories.mockResolvedValue(sampleRepos); const { default: list } = await import("./list"); - await list.parseAsync(["--project", "PROJ"], { from: "user" }); + await list.parseAsync(["PROJ"], { from: "user" }); expect(getClient).toHaveBeenCalled(); expect(mockClient.getGitRepositories).toHaveBeenCalledWith("PROJ"); @@ -58,7 +58,7 @@ describe("repo list", () => { mockClient.getGitRepositories.mockResolvedValue([]); const { default: list } = await import("./list"); - await list.parseAsync(["--project", "PROJ"], { from: "user" }); + await list.parseAsync(["PROJ"], { from: "user" }); expect(consola.info).toHaveBeenCalledWith("No repositories found."); }); @@ -68,7 +68,7 @@ describe("repo list", () => { await expectStdoutContaining(async () => { const { default: list } = await import("./list"); - await list.parseAsync(["--project", "PROJ", "--json"], { from: "user" }); + await list.parseAsync(["PROJ", "--json"], { from: "user" }); }, "api-server"); }); }); diff --git a/apps/cli/src/commands/repo/list.ts b/apps/cli/src/commands/repo/list.ts index a4545382..8f33be64 100644 --- a/apps/cli/src/commands/repo/list.ts +++ b/apps/cli/src/commands/repo/list.ts @@ -3,7 +3,6 @@ import { type Row, outputResult, printTable } from "@repo/cli-utils"; import consola from "consola"; import { BeeCommand, ENV_AUTH, ENV_PROJECT } from "../../lib/bee-command"; import * as opt from "../../lib/common-options"; -import { resolveOptions } from "../../lib/required-option"; const list = new BeeCommand("list") .summary("List repositories in a project") @@ -12,18 +11,17 @@ const list = new BeeCommand("list") By default, repositories are listed in the configured display order.`, ) - .addOption(opt.project()) + .argument("<project>", "Project ID or project key") .addOption(opt.json()) .envVars([...ENV_AUTH, ENV_PROJECT]) .examples([ - { description: "List repositories in a project", command: "bee repo list -p PROJECT_KEY" }, - { description: "Output as JSON", command: "bee repo list -p PROJECT_KEY --json" }, + { description: "List repositories in a project", command: "bee repo list PROJECT_KEY" }, + { description: "Output as JSON", command: "bee repo list PROJECT_KEY --json" }, ]) - .action(async (opts, cmd) => { - await resolveOptions(cmd); + .action(async (project, opts) => { const { client } = await getClient(); - const repos = await client.getGitRepositories(opts.project); + const repos = await client.getGitRepositories(project); outputResult(repos, opts, (data) => { if (data.length === 0) { diff --git a/apps/cli/src/commands/wiki/count.test.ts b/apps/cli/src/commands/wiki/count.test.ts index dbb11f84..384591b7 100644 --- a/apps/cli/src/commands/wiki/count.test.ts +++ b/apps/cli/src/commands/wiki/count.test.ts @@ -23,7 +23,7 @@ describe("wiki count", () => { mockClient.getWikisCount.mockResolvedValue({ count: 42 }); const { default: count } = await import("./count"); - await count.parseAsync(["-p", "TEST"], { from: "user" }); + await count.parseAsync(["TEST"], { from: "user" }); expect(getClient).toHaveBeenCalled(); expect(mockClient.getWikisCount).toHaveBeenCalledWith("TEST"); @@ -35,7 +35,7 @@ describe("wiki count", () => { await expectStdoutContaining(async () => { const { default: count } = await import("./count"); - await count.parseAsync(["-p", "TEST", "--json"], { from: "user" }); + await count.parseAsync(["TEST", "--json"], { from: "user" }); }, "42"); }); }); diff --git a/apps/cli/src/commands/wiki/count.ts b/apps/cli/src/commands/wiki/count.ts index a2ffa0dd..508c45a0 100644 --- a/apps/cli/src/commands/wiki/count.ts +++ b/apps/cli/src/commands/wiki/count.ts @@ -3,7 +3,6 @@ import { outputResult } from "@repo/cli-utils"; import consola from "consola"; import { BeeCommand, ENV_AUTH, ENV_PROJECT } from "../../lib/bee-command"; import * as opt from "../../lib/common-options"; -import { resolveOptions } from "../../lib/required-option"; const count = new BeeCommand("count") .summary("Count wiki pages") @@ -12,18 +11,17 @@ const count = new BeeCommand("count") The count includes all wiki pages regardless of tag or keyword.`, ) - .addOption(opt.project()) + .argument("<project>", "Project ID or project key") .addOption(opt.json()) .envVars([...ENV_AUTH, ENV_PROJECT]) .examples([ - { description: "Count wiki pages", command: "bee wiki count -p PROJECT" }, - { description: "Output as JSON", command: "bee wiki count -p PROJECT --json" }, + { description: "Count wiki pages", command: "bee wiki count PROJECT" }, + { description: "Output as JSON", command: "bee wiki count PROJECT --json" }, ]) - .action(async (opts, cmd) => { - await resolveOptions(cmd); + .action(async (project, opts) => { const { client } = await getClient(); - const result = await client.getWikisCount(opts.project); + const result = await client.getWikisCount(project); const json = opts.json === true ? "" : opts.json; outputResult(result, { json }, (data) => { diff --git a/apps/cli/src/commands/wiki/list.test.ts b/apps/cli/src/commands/wiki/list.test.ts index 97fe655c..5774a276 100644 --- a/apps/cli/src/commands/wiki/list.test.ts +++ b/apps/cli/src/commands/wiki/list.test.ts @@ -26,7 +26,7 @@ describe("wiki list", () => { ]); const { default: list } = await import("./list"); - await list.parseAsync(["-p", "TEST"], { from: "user" }); + await list.parseAsync(["TEST"], { from: "user" }); expect(getClient).toHaveBeenCalled(); expect(mockClient.getWikis).toHaveBeenCalledWith({ @@ -42,7 +42,7 @@ describe("wiki list", () => { mockClient.getWikis.mockResolvedValue([]); const { default: list } = await import("./list"); - await list.parseAsync(["-p", "TEST"], { from: "user" }); + await list.parseAsync(["TEST"], { from: "user" }); expect(consola.info).toHaveBeenCalledWith("No wiki pages found."); }); @@ -51,7 +51,7 @@ describe("wiki list", () => { mockClient.getWikis.mockResolvedValue([]); const { default: list } = await import("./list"); - await list.parseAsync(["-p", "TEST", "--keyword", "setup"], { from: "user" }); + await list.parseAsync(["TEST", "--keyword", "setup"], { from: "user" }); expect(mockClient.getWikis).toHaveBeenCalledWith({ projectIdOrKey: "TEST", @@ -66,7 +66,7 @@ describe("wiki list", () => { await expectStdoutContaining(async () => { const { default: list } = await import("./list"); - await list.parseAsync(["-p", "TEST", "--json"], { from: "user" }); + await list.parseAsync(["TEST", "--json"], { from: "user" }); }, "Home"); }); }); diff --git a/apps/cli/src/commands/wiki/list.ts b/apps/cli/src/commands/wiki/list.ts index 81b67178..a037edc6 100644 --- a/apps/cli/src/commands/wiki/list.ts +++ b/apps/cli/src/commands/wiki/list.ts @@ -3,7 +3,6 @@ import { type Row, outputResult, printTable } from "@repo/cli-utils"; import consola from "consola"; import { BeeCommand, ENV_AUTH, ENV_PROJECT } from "../../lib/bee-command"; import * as opt from "../../lib/common-options"; -import { resolveOptions } from "../../lib/required-option"; const list = new BeeCommand("list") .summary("List wiki pages") @@ -12,24 +11,23 @@ const list = new BeeCommand("list") Use \`--keyword\` to filter pages by name or content.`, ) - .addOption(opt.project()) + .argument("<project>", "Project ID or project key") .addOption(opt.keyword()) .addOption(opt.json()) .envVars([...ENV_AUTH, ENV_PROJECT]) .examples([ - { description: "List all wiki pages in a project", command: "bee wiki list -p PROJECT" }, + { description: "List all wiki pages in a project", command: "bee wiki list PROJECT" }, { description: "Search wiki pages by keyword", - command: 'bee wiki list -p PROJECT --keyword "setup"', + command: 'bee wiki list PROJECT --keyword "setup"', }, - { description: "Output as JSON", command: "bee wiki list -p PROJECT --json" }, + { description: "Output as JSON", command: "bee wiki list PROJECT --json" }, ]) - .action(async (opts, cmd) => { - await resolveOptions(cmd); + .action(async (project, opts) => { const { client } = await getClient(); const wikis = await client.getWikis({ - projectIdOrKey: opts.project, + projectIdOrKey: project, keyword: opts.keyword, }); diff --git a/apps/cli/src/commands/wiki/tags.test.ts b/apps/cli/src/commands/wiki/tags.test.ts index 38a0f008..6834f715 100644 --- a/apps/cli/src/commands/wiki/tags.test.ts +++ b/apps/cli/src/commands/wiki/tags.test.ts @@ -26,7 +26,7 @@ describe("wiki tags", () => { ]); const { default: tags } = await import("./tags"); - await tags.parseAsync(["-p", "TEST"], { from: "user" }); + await tags.parseAsync(["TEST"], { from: "user" }); expect(getClient).toHaveBeenCalled(); expect(mockClient.getWikisTags).toHaveBeenCalledWith("TEST"); @@ -38,7 +38,7 @@ describe("wiki tags", () => { mockClient.getWikisTags.mockResolvedValue([]); const { default: tags } = await import("./tags"); - await tags.parseAsync(["-p", "TEST"], { from: "user" }); + await tags.parseAsync(["TEST"], { from: "user" }); expect(consola.info).toHaveBeenCalledWith("No wiki tags found."); }); @@ -48,7 +48,7 @@ describe("wiki tags", () => { await expectStdoutContaining(async () => { const { default: tags } = await import("./tags"); - await tags.parseAsync(["-p", "TEST", "--json"], { from: "user" }); + await tags.parseAsync(["TEST", "--json"], { from: "user" }); }, "guide"); }); }); diff --git a/apps/cli/src/commands/wiki/tags.ts b/apps/cli/src/commands/wiki/tags.ts index 6b76b697..2aa9b35b 100644 --- a/apps/cli/src/commands/wiki/tags.ts +++ b/apps/cli/src/commands/wiki/tags.ts @@ -3,7 +3,6 @@ import { outputResult } from "@repo/cli-utils"; import consola from "consola"; import { BeeCommand, ENV_AUTH, ENV_PROJECT } from "../../lib/bee-command"; import * as opt from "../../lib/common-options"; -import { resolveOptions } from "../../lib/required-option"; const tags = new BeeCommand("tags") .summary("List wiki tags") @@ -12,18 +11,17 @@ const tags = new BeeCommand("tags") Tags are labels attached to wiki pages for organization.`, ) - .addOption(opt.project()) + .argument("<project>", "Project ID or project key") .addOption(opt.json()) .envVars([...ENV_AUTH, ENV_PROJECT]) .examples([ - { description: "List wiki tags", command: "bee wiki tags -p PROJECT" }, - { description: "Output as JSON", command: "bee wiki tags -p PROJECT --json" }, + { description: "List wiki tags", command: "bee wiki tags PROJECT" }, + { description: "Output as JSON", command: "bee wiki tags PROJECT --json" }, ]) - .action(async (opts, cmd) => { - await resolveOptions(cmd); + .action(async (project, opts) => { const { client } = await getClient(); - const result = await client.getWikisTags(opts.project); + const result = await client.getWikisTags(project); const json = opts.json === true ? "" : opts.json; outputResult(result, { json }, (data) => { From 2e71d7fabff06832603ef5398a91fcca6724e28f Mon Sep 17 00:00:00 2001 From: Ryoya Tamura <ryoya.tamura@nulab.com> Date: Sun, 8 Mar 2026 19:46:18 +0900 Subject: [PATCH 14/16] fix(cli): resolve @me in list/count commands, unify activity-type, inline completions - Use resolveUserId for @me in issue list, issue count, issue edit, and pr list commands (previously passed NaN to API) - Unify user activities --activity-type to comma-separated format, matching space and project activities commands - Inline shell completion scripts as string literals so they work in the bundled dist output (previously failed with ENOENT) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --- apps/cli/src/commands/completion.ts | 113 ++++++++++++++---- apps/cli/src/commands/issue/count.test.ts | 18 ++- apps/cli/src/commands/issue/count.ts | 12 +- apps/cli/src/commands/issue/edit.test.ts | 14 +++ apps/cli/src/commands/issue/edit.ts | 4 +- apps/cli/src/commands/issue/list.test.ts | 13 ++ apps/cli/src/commands/issue/list.ts | 12 +- apps/cli/src/commands/pr/list.test.ts | 17 +++ apps/cli/src/commands/pr/list.ts | 6 +- apps/cli/src/commands/user/activities.test.ts | 5 +- apps/cli/src/commands/user/activities.ts | 17 +-- 11 files changed, 184 insertions(+), 47 deletions(-) diff --git a/apps/cli/src/commands/completion.ts b/apps/cli/src/commands/completion.ts index a2c64f89..93f0bf22 100644 --- a/apps/cli/src/commands/completion.ts +++ b/apps/cli/src/commands/completion.ts @@ -1,19 +1,94 @@ -import { readFileSync } from "node:fs"; -import { resolve } from "node:path"; -import { fileURLToPath } from "node:url"; import { UserError } from "@repo/cli-utils"; import { BeeCommand } from "../lib/bee-command"; -const __dirname = fileURLToPath(new URL(".", import.meta.url)); +const COMPLETION_SCRIPTS: Record<string, string> = { + bash: `# bash completion for bee +# Add to ~/.bashrc: +# eval "$(bee completion bash)" -const shellExtensions: Record<string, string> = { - bash: "sh", - zsh: "zsh", - fish: "fish", -}; +_bee_completions() { + local cur="\${COMP_WORDS[COMP_CWORD]}" + local commands="auth project issue document notification pr repo team user webhook wiki category milestone issue-type space status star watching dashboard browse api completion" + + if [ "\${COMP_CWORD}" -eq 1 ]; then + COMPREPLY=( $(compgen -W "\${commands}" -- "\${cur}") ) + fi +} + +complete -F _bee_completions bee +`, + zsh: `#compdef bee +# zsh completion for bee +# Add to ~/.zshrc: +# eval "$(bee completion zsh)" + +_bee() { + local -a commands + commands=( + 'auth:auth commands' \\ + 'project:project commands' \\ + 'issue:issue commands' \\ + 'document:document commands' \\ + 'notification:notification commands' \\ + 'pr:pr commands' \\ + 'repo:repo commands' \\ + 'team:team commands' \\ + 'user:user commands' \\ + 'webhook:webhook commands' \\ + 'wiki:wiki commands' \\ + 'category:category commands' \\ + 'milestone:milestone commands' \\ + 'issue-type:issue-type commands' \\ + 'space:space commands' \\ + 'status:status commands' \\ + 'star:star commands' \\ + 'watching:watching commands' \\ + 'dashboard:dashboard commands' \\ + 'browse:browse commands' \\ + 'api:api commands' \\ + 'completion:completion commands' + ) -const loadCompletionScript = (shell: string): string => - readFileSync(resolve(__dirname, `completions/${shell}.${shellExtensions[shell]}`), "utf8"); + _arguments '1: :->command' '*:: :->args' + + case $state in + command) + _describe 'command' commands + ;; + esac +} + +compdef _bee bee +`, + fish: `# fish completion for bee +# Add to ~/.config/fish/completions/bee.fish: +# bee completion fish > ~/.config/fish/completions/bee.fish + +complete -c bee -e +complete -c bee -n "__fish_use_subcommand" -a "auth" -d "auth commands" +complete -c bee -n "__fish_use_subcommand" -a "project" -d "project commands" +complete -c bee -n "__fish_use_subcommand" -a "issue" -d "issue commands" +complete -c bee -n "__fish_use_subcommand" -a "document" -d "document commands" +complete -c bee -n "__fish_use_subcommand" -a "notification" -d "notification commands" +complete -c bee -n "__fish_use_subcommand" -a "pr" -d "pr commands" +complete -c bee -n "__fish_use_subcommand" -a "repo" -d "repo commands" +complete -c bee -n "__fish_use_subcommand" -a "team" -d "team commands" +complete -c bee -n "__fish_use_subcommand" -a "user" -d "user commands" +complete -c bee -n "__fish_use_subcommand" -a "webhook" -d "webhook commands" +complete -c bee -n "__fish_use_subcommand" -a "wiki" -d "wiki commands" +complete -c bee -n "__fish_use_subcommand" -a "category" -d "category commands" +complete -c bee -n "__fish_use_subcommand" -a "milestone" -d "milestone commands" +complete -c bee -n "__fish_use_subcommand" -a "issue-type" -d "issue-type commands" +complete -c bee -n "__fish_use_subcommand" -a "space" -d "space commands" +complete -c bee -n "__fish_use_subcommand" -a "status" -d "status commands" +complete -c bee -n "__fish_use_subcommand" -a "star" -d "star commands" +complete -c bee -n "__fish_use_subcommand" -a "watching" -d "watching commands" +complete -c bee -n "__fish_use_subcommand" -a "dashboard" -d "dashboard commands" +complete -c bee -n "__fish_use_subcommand" -a "browse" -d "browse commands" +complete -c bee -n "__fish_use_subcommand" -a "api" -d "api commands" +complete -c bee -n "__fish_use_subcommand" -a "completion" -d "completion commands" +`, +}; const completion = new BeeCommand("completion") .summary("Generate shell completion scripts") @@ -39,17 +114,13 @@ Follow the instructions in the output for your specific shell.`, }, ]) .action((shell: string) => { - switch (shell) { - case "bash": - case "zsh": - case "fish": { - process.stdout.write(loadCompletionScript(shell)); - break; - } - default: { - throw new UserError(`Unsupported shell: "${shell}". Supported shells: bash, zsh, fish.`); - } + const script = COMPLETION_SCRIPTS[shell]; + if (!script) { + throw new UserError( + `Unsupported shell: "${shell}". Supported shells: ${Object.keys(COMPLETION_SCRIPTS).join(", ")}.`, + ); } + process.stdout.write(script); }); export default completion; diff --git a/apps/cli/src/commands/issue/count.test.ts b/apps/cli/src/commands/issue/count.test.ts index eca66fa7..567286b0 100644 --- a/apps/cli/src/commands/issue/count.test.ts +++ b/apps/cli/src/commands/issue/count.test.ts @@ -4,13 +4,13 @@ import { expectStdoutContaining } from "@repo/test-utils"; const mockClient = { getIssuesCount: vi.fn(), + getMyself: vi.fn().mockResolvedValue({ id: 99 }), }; -vi.mock("@repo/backlog-utils", () => ({ +vi.mock("@repo/backlog-utils", async (importOriginal) => ({ + ...(await importOriginal()), getClient: vi.fn(() => Promise.resolve({ client: mockClient, host: "example.backlog.com" })), resolveProjectIds: vi.fn((_: unknown, ids: string[]) => Promise.resolve(ids)), - PRIORITY_NAMES: ["high", "normal", "low"], - PriorityId: { high: 2, normal: 3, low: 4 }, })); vi.mock("@repo/cli-utils", async (importOriginal) => ({ @@ -50,6 +50,18 @@ describe("issue count", () => { }, "42"); }); + it("resolves @me to current user ID for assignee", async () => { + mockClient.getIssuesCount.mockResolvedValue({ count: 1 }); + + const { default: count } = await import("./count"); + await count.parseAsync(["--project", "TEST", "--assignee", "@me"], { from: "user" }); + + expect(mockClient.getMyself).toHaveBeenCalled(); + expect(mockClient.getIssuesCount).toHaveBeenCalledWith( + expect.objectContaining({ assigneeId: [99] }), + ); + }); + it("throws error for unknown priority name", async () => { const { default: count } = await import("./count"); await expect( diff --git a/apps/cli/src/commands/issue/count.ts b/apps/cli/src/commands/issue/count.ts index 99f736c2..a6dbd956 100644 --- a/apps/cli/src/commands/issue/count.ts +++ b/apps/cli/src/commands/issue/count.ts @@ -1,4 +1,10 @@ -import { PRIORITY_NAMES, PriorityId, getClient, resolveProjectIds } from "@repo/backlog-utils"; +import { + PRIORITY_NAMES, + PriorityId, + getClient, + resolveProjectIds, + resolveUserId, +} from "@repo/backlog-utils"; import { outputResult } from "@repo/cli-utils"; import consola from "consola"; import { BeeCommand, ENV_AUTH, ENV_PROJECT } from "../../lib/bee-command"; @@ -51,7 +57,9 @@ by default, or a JSON object with \`--json\`.`, .filter(Boolean), ) : []; - const assigneeId = (opts.assignee ?? []).map(Number); + const assigneeId = await Promise.all( + (opts.assignee ?? []).map((id: string) => resolveUserId(client, id)), + ); const statusId = opts.status ? opts.status .split(",") diff --git a/apps/cli/src/commands/issue/edit.test.ts b/apps/cli/src/commands/issue/edit.test.ts index 90d40137..f7bda4d5 100644 --- a/apps/cli/src/commands/issue/edit.test.ts +++ b/apps/cli/src/commands/issue/edit.test.ts @@ -4,6 +4,7 @@ import { expectStdoutContaining } from "@repo/test-utils"; const mockClient = { patchIssue: vi.fn(), + getMyself: vi.fn().mockResolvedValue({ id: 99 }), }; vi.mock("@repo/backlog-utils", async (importOriginal) => ({ @@ -41,6 +42,19 @@ describe("issue edit", () => { ); }); + it("resolves @me to current user ID for assignee", async () => { + mockClient.patchIssue.mockResolvedValue({ issueKey: "TEST-1", summary: "Title" }); + + const { default: edit } = await import("./edit"); + await edit.parseAsync(["TEST-1", "--assignee", "@me"], { from: "user" }); + + expect(mockClient.getMyself).toHaveBeenCalled(); + expect(mockClient.patchIssue).toHaveBeenCalledWith( + "TEST-1", + expect.objectContaining({ assigneeId: 99 }), + ); + }); + it("passes comment with the update", async () => { mockClient.patchIssue.mockResolvedValue({ issueKey: "TEST-1", summary: "Title" }); diff --git a/apps/cli/src/commands/issue/edit.ts b/apps/cli/src/commands/issue/edit.ts index 695055e4..a39ff77f 100644 --- a/apps/cli/src/commands/issue/edit.ts +++ b/apps/cli/src/commands/issue/edit.ts @@ -1,4 +1,4 @@ -import { PRIORITY_NAMES, PriorityId, getClient } from "@repo/backlog-utils"; +import { PRIORITY_NAMES, PriorityId, getClient, resolveUserId } from "@repo/backlog-utils"; import { outputResult } from "@repo/cli-utils"; import consola from "consola"; import { BeeCommand, ENV_AUTH } from "../../lib/bee-command"; @@ -66,7 +66,7 @@ will remain unchanged.`, statusId: opts.status ? Number(opts.status) : undefined, priorityId, issueTypeId: opts.type ? Number(opts.type) : undefined, - assigneeId: opts.assignee ? Number(opts.assignee) : undefined, + assigneeId: opts.assignee ? await resolveUserId(client, opts.assignee) : undefined, resolutionId: opts.resolution ? Number(opts.resolution) : undefined, parentIssueId: opts.parentIssue ? Number(opts.parentIssue) : undefined, startDate: opts.startDate, diff --git a/apps/cli/src/commands/issue/list.test.ts b/apps/cli/src/commands/issue/list.test.ts index 4d9bc723..a08c8d8b 100644 --- a/apps/cli/src/commands/issue/list.test.ts +++ b/apps/cli/src/commands/issue/list.test.ts @@ -5,6 +5,7 @@ import { expectStdoutContaining } from "@repo/test-utils"; const mockClient = { getIssues: vi.fn(), + getMyself: vi.fn().mockResolvedValue({ id: 99 }), getProjects: vi.fn().mockResolvedValue([{ id: 123, projectKey: "PROJ" }]), }; @@ -88,6 +89,18 @@ describe("issue list", () => { ); }); + it("resolves @me to current user ID for assignee", async () => { + mockClient.getIssues.mockResolvedValue([]); + + const { default: list } = await import("./list"); + await list.parseAsync(["--assignee", "@me"], { from: "user" }); + + expect(mockClient.getMyself).toHaveBeenCalled(); + expect(mockClient.getIssues).toHaveBeenCalledWith( + expect.objectContaining({ assigneeId: [99] }), + ); + }); + it("passes keyword query parameter", async () => { mockClient.getIssues.mockResolvedValue([]); diff --git a/apps/cli/src/commands/issue/list.ts b/apps/cli/src/commands/issue/list.ts index 6d0d3f48..781741d9 100644 --- a/apps/cli/src/commands/issue/list.ts +++ b/apps/cli/src/commands/issue/list.ts @@ -1,4 +1,10 @@ -import { PRIORITY_NAMES, PriorityId, getClient, resolveProjectIds } from "@repo/backlog-utils"; +import { + PRIORITY_NAMES, + PriorityId, + getClient, + resolveProjectIds, + resolveUserId, +} from "@repo/backlog-utils"; import { type Row, outputResult, printTable } from "@repo/cli-utils"; import consola from "consola"; import { Option } from "commander"; @@ -58,7 +64,9 @@ Multiple project keys can be specified as a comma-separated list.`, .filter(Boolean), ) : []; - const assigneeId = (opts.assignee ?? []).map(Number); + const assigneeId = await Promise.all( + (opts.assignee ?? []).map((id: string) => resolveUserId(client, id)), + ); const statusId = opts.status ? opts.status .split(",") diff --git a/apps/cli/src/commands/pr/list.test.ts b/apps/cli/src/commands/pr/list.test.ts index 37c693cd..5ab3baac 100644 --- a/apps/cli/src/commands/pr/list.test.ts +++ b/apps/cli/src/commands/pr/list.test.ts @@ -5,6 +5,7 @@ import { expectStdoutContaining } from "@repo/test-utils"; const mockClient = { getPullRequests: vi.fn(), + getMyself: vi.fn().mockResolvedValue({ id: 99 }), }; vi.mock("@repo/backlog-utils", async (importOriginal) => ({ @@ -100,6 +101,22 @@ describe("pr list", () => { ); }); + it("resolves @me to current user ID for assignee", async () => { + mockClient.getPullRequests.mockResolvedValue([]); + + const { default: list } = await import("./list"); + await list.parseAsync(["--project", "PROJ", "--repo", "repo", "--assignee", "@me"], { + from: "user", + }); + + expect(mockClient.getMyself).toHaveBeenCalled(); + expect(mockClient.getPullRequests).toHaveBeenCalledWith( + "PROJ", + "repo", + expect.objectContaining({ assigneeId: [99] }), + ); + }); + it("outputs JSON when --json flag is set", async () => { mockClient.getPullRequests.mockResolvedValue(samplePullRequests); diff --git a/apps/cli/src/commands/pr/list.ts b/apps/cli/src/commands/pr/list.ts index 38b8143c..a8471542 100644 --- a/apps/cli/src/commands/pr/list.ts +++ b/apps/cli/src/commands/pr/list.ts @@ -1,4 +1,4 @@ -import { PR_STATUS_NAMES, PrStatusName, getClient } from "@repo/backlog-utils"; +import { PR_STATUS_NAMES, PrStatusName, getClient, resolveUserId } from "@repo/backlog-utils"; import { type Row, outputResult, printTable, splitArg } from "@repo/cli-utils"; import consola from "consola"; import * as v from "valibot"; @@ -56,7 +56,9 @@ status (open, closed, merged).`, }) : undefined; - const assigneeId = (opts.assignee ?? []).map(Number).filter((n: number) => !Number.isNaN(n)); + const assigneeId = await Promise.all( + (opts.assignee ?? []).map((id: string) => resolveUserId(client, id)), + ); const issueId = splitArg(opts.issue, v.number()); const createdUserId = splitArg(opts.createdUser, v.number()); diff --git a/apps/cli/src/commands/user/activities.test.ts b/apps/cli/src/commands/user/activities.test.ts index c2ba3970..767edc44 100644 --- a/apps/cli/src/commands/user/activities.test.ts +++ b/apps/cli/src/commands/user/activities.test.ts @@ -58,10 +58,7 @@ describe("user activities", () => { mockClient.getUserActivities.mockResolvedValue([]); const { default: activities } = await import("./activities"); - await activities.parseAsync( - ["12345", "--activity-type", "1", "--activity-type", "2", "--activity-type", "3"], - { from: "user" }, - ); + await activities.parseAsync(["12345", "--activity-type", "1,2,3"], { from: "user" }); expect(mockClient.getUserActivities).toHaveBeenCalledWith( 12_345, diff --git a/apps/cli/src/commands/user/activities.ts b/apps/cli/src/commands/user/activities.ts index 2fe15bc5..7da7a3e9 100644 --- a/apps/cli/src/commands/user/activities.ts +++ b/apps/cli/src/commands/user/activities.ts @@ -1,9 +1,9 @@ import { ACTIVITY_LABELS, getClient } from "@repo/backlog-utils"; -import { type Row, formatDate, outputResult, printTable } from "@repo/cli-utils"; +import { type Row, formatDate, outputResult, printTable, splitArg } from "@repo/cli-utils"; import consola from "consola"; +import * as v from "valibot"; import { BeeCommand, ENV_AUTH } from "../../lib/bee-command"; import * as opt from "../../lib/common-options"; -import { collectNum } from "../../lib/common-options"; const getActivitySummary = (activity: { type: number; @@ -41,19 +41,14 @@ Shows the most recent updates performed by the specified user, including issue changes, wiki edits, git pushes, and other activities. Results are ordered by most recent first. -Use \`--activity-type\` to filter by specific activity types (repeatable IDs). +Use \`--activity-type\` to filter by specific activity types (comma-separated IDs). Use \`--count\` to control how many activities are returned (default: 20, max: 100). For a list of activity type IDs, see: https://developer.nulab.com/docs/backlog/api/2/get-user-recent-updates/#activity-type`, ) .argument("<user>", "User ID") - .option( - "--activity-type <id>", - "Filter by activity type IDs (repeatable)", - collectNum, - [] as number[], - ) + .option("--activity-type <ids>", "Filter by activity type IDs (comma-separated)") .addOption(opt.count()) .addOption(opt.order()) .addOption(opt.minId()) @@ -64,7 +59,7 @@ https://developer.nulab.com/docs/backlog/api/2/get-user-recent-updates/#activity { description: "List user activities", command: "bee user activities 12345" }, { description: "Show only issue-related activities", - command: "bee user activities 12345 --activity-type 1 --activity-type 2 --activity-type 3", + command: "bee user activities 12345 --activity-type 1,2,3", }, { description: "Show last 50 activities", @@ -78,7 +73,7 @@ https://developer.nulab.com/docs/backlog/api/2/get-user-recent-updates/#activity .action(async (user, opts) => { const { client } = await getClient(); - const activityTypeId: number[] = opts.activityType; + const activityTypeId = splitArg(opts.activityType, v.number()); const activityList = await client.getUserActivities(Number(user), { activityTypeId, From 8f12267d89841b923d4b9c7701058488d1d87323 Mon Sep 17 00:00:00 2001 From: Ryoya Tamura <ryoya.tamura@nulab.com> Date: Sun, 8 Mar 2026 22:36:03 +0900 Subject: [PATCH 15/16] refactor(cli): replace comma-separated options with commander repeatable options Replace splitArg/split(",") patterns with commander's native argParser for repeatable options. This is the idiomatic commander.js approach: --activity-type 1 --activity-type 2 (instead of --activity-type 1,2) --status open --status merged (instead of --status open,merged) Also replace `as number[]` with `satisfies number[]` throughout, and add @me support to pr count command's assignee option. Note: -p/--project with .env("BACKLOG_PROJECT") retains comma-separated parsing since env vars provide a single string value. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --- apps/cli/src/commands/issue/count.ts | 42 +++++++---------- apps/cli/src/commands/issue/list.ts | 40 ++++++---------- apps/cli/src/commands/pr/count.test.ts | 6 +-- apps/cli/src/commands/pr/count.ts | 46 +++++++++---------- apps/cli/src/commands/pr/list.ts | 40 +++++++--------- .../src/commands/project/activities.test.ts | 7 +-- apps/cli/src/commands/project/activities.ts | 18 +++++--- .../cli/src/commands/space/activities.test.ts | 5 +- apps/cli/src/commands/space/activities.ts | 17 ++++--- apps/cli/src/commands/team/create.ts | 7 ++- apps/cli/src/commands/team/edit.ts | 2 +- apps/cli/src/commands/user/activities.test.ts | 5 +- apps/cli/src/commands/user/activities.ts | 17 ++++--- apps/cli/src/commands/webhook/create.ts | 2 +- apps/cli/src/commands/webhook/edit.ts | 2 +- 15 files changed, 126 insertions(+), 130 deletions(-) diff --git a/apps/cli/src/commands/issue/count.ts b/apps/cli/src/commands/issue/count.ts index a6dbd956..d679d1d3 100644 --- a/apps/cli/src/commands/issue/count.ts +++ b/apps/cli/src/commands/issue/count.ts @@ -7,9 +7,19 @@ import { } from "@repo/backlog-utils"; import { outputResult } from "@repo/cli-utils"; import consola from "consola"; -import { BeeCommand, ENV_AUTH, ENV_PROJECT } from "../../lib/bee-command"; import { Option } from "commander"; +import { BeeCommand, ENV_AUTH, ENV_PROJECT } from "../../lib/bee-command"; import * as opt from "../../lib/common-options"; +import { collect, collectNum } from "../../lib/common-options"; + +const resolvePriorityIds = (priorities: string[]): number[] => + priorities.map((name) => { + const id = PriorityId[name.toLowerCase()]; + if (id === undefined) { + throw new Error(`Unknown priority "${name}". Valid values: ${PRIORITY_NAMES.join(", ")}`); + } + return id; + }); const count = new BeeCommand("count") .summary("Count issues") @@ -26,8 +36,8 @@ by default, or a JSON object with \`--json\`.`, ).env("BACKLOG_PROJECT"), ) .addOption(opt.assigneeList()) - .option("-S, --status <id>", "Status ID (comma-separated for multiple)") - .option("-P, --priority <name>", `Priority name (comma-separated for multiple)`) + .option("-S, --status <id>", "Status ID (repeatable)", collectNum, [] satisfies number[]) + .option("-P, --priority <name>", "Priority name (repeatable)", collect, [] satisfies string[]) .addOption(opt.keyword()) .option("--created-since <date>", "Show issues created on or after this date") .option("--created-until <date>", "Show issues created on or before this date") @@ -60,28 +70,8 @@ by default, or a JSON object with \`--json\`.`, const assigneeId = await Promise.all( (opts.assignee ?? []).map((id: string) => resolveUserId(client, id)), ); - const statusId = opts.status - ? opts.status - .split(",") - .map((s: string) => s.trim()) - .filter(Boolean) - .map(Number) - : []; - const priorityId = opts.priority - ? opts.priority - .split(",") - .map((s: string) => s.trim()) - .filter(Boolean) - .map((name: string) => { - const id = PriorityId[name.toLowerCase()]; - if (id === undefined) { - throw new Error( - `Unknown priority "${name}". Valid values: ${PRIORITY_NAMES.join(", ")}`, - ); - } - return id; - }) - : []; + const statusId: number[] = opts.status; + const priorityId = opts.priority.length > 0 ? resolvePriorityIds(opts.priority) : []; const result = await client.getIssuesCount({ projectId, @@ -97,7 +87,7 @@ by default, or a JSON object with \`--json\`.`, dueDateUntil: opts.dueUntil, }); - outputResult(result, opts as { json?: string }, (data) => { + outputResult(result, { json: opts.json }, (data) => { consola.log(data.count); }); }); diff --git a/apps/cli/src/commands/issue/list.ts b/apps/cli/src/commands/issue/list.ts index 781741d9..bf839b62 100644 --- a/apps/cli/src/commands/issue/list.ts +++ b/apps/cli/src/commands/issue/list.ts @@ -10,6 +10,16 @@ import consola from "consola"; import { Option } from "commander"; import { BeeCommand, ENV_AUTH, ENV_PROJECT } from "../../lib/bee-command"; import * as opt from "../../lib/common-options"; +import { collect, collectNum } from "../../lib/common-options"; + +const resolvePriorityIds = (priorities: string[]): number[] => + priorities.map((name) => { + const id = PriorityId[name.toLowerCase()]; + if (id === undefined) { + throw new Error(`Unknown priority "${name}". Valid values: ${PRIORITY_NAMES.join(", ")}`); + } + return id; + }); const list = new BeeCommand("list") .summary("List issues") @@ -28,8 +38,8 @@ Multiple project keys can be specified as a comma-separated list.`, ).env("BACKLOG_PROJECT"), ) .addOption(opt.assigneeList()) - .option("-S, --status <id>", "Status ID (comma-separated for multiple)") - .option("-P, --priority <name>", `Priority name (comma-separated for multiple)`) + .option("-S, --status <id>", "Status ID (repeatable)", collectNum, [] satisfies number[]) + .option("-P, --priority <name>", "Priority name (repeatable)", collect, [] satisfies string[]) .addOption(opt.keyword()) .option("--created-since <date>", "Show issues created on or after this date") .option("--created-until <date>", "Show issues created on or before this date") @@ -67,28 +77,8 @@ Multiple project keys can be specified as a comma-separated list.`, const assigneeId = await Promise.all( (opts.assignee ?? []).map((id: string) => resolveUserId(client, id)), ); - const statusId = opts.status - ? opts.status - .split(",") - .map((s: string) => s.trim()) - .filter(Boolean) - .map(Number) - : []; - const priorityId = opts.priority - ? opts.priority - .split(",") - .map((s: string) => s.trim()) - .filter(Boolean) - .map((name: string) => { - const id = PriorityId[name.toLowerCase()]; - if (id === undefined) { - throw new Error( - `Unknown priority "${name}". Valid values: ${PRIORITY_NAMES.join(", ")}`, - ); - } - return id; - }) - : []; + const statusId: number[] = opts.status; + const priorityId = opts.priority.length > 0 ? resolvePriorityIds(opts.priority) : []; const issues = await client.getIssues({ projectId, @@ -108,7 +98,7 @@ Multiple project keys can be specified as a comma-separated list.`, dueDateUntil: opts.dueUntil, }); - outputResult(issues, opts as { json?: string }, (data) => { + outputResult(issues, { json: opts.json }, (data) => { if (data.length === 0) { consola.info("No issues found."); return; diff --git a/apps/cli/src/commands/pr/count.test.ts b/apps/cli/src/commands/pr/count.test.ts index c3f6d6eb..110fb3c3 100644 --- a/apps/cli/src/commands/pr/count.test.ts +++ b/apps/cli/src/commands/pr/count.test.ts @@ -4,12 +4,12 @@ import { expectStdoutContaining } from "@repo/test-utils"; const mockClient = { getPullRequestsCount: vi.fn(), + getMyself: vi.fn().mockResolvedValue({ id: 99 }), }; -vi.mock("@repo/backlog-utils", () => ({ +vi.mock("@repo/backlog-utils", async (importOriginal) => ({ + ...(await importOriginal()), getClient: vi.fn(() => Promise.resolve({ client: mockClient, host: "example.backlog.com" })), - PR_STATUS_NAMES: ["open", "closed", "merged"], - PrStatusName: { open: 1, closed: 2, merged: 3 }, })); vi.mock("@repo/cli-utils", async (importOriginal) => ({ diff --git a/apps/cli/src/commands/pr/count.ts b/apps/cli/src/commands/pr/count.ts index 467a1204..eb16e337 100644 --- a/apps/cli/src/commands/pr/count.ts +++ b/apps/cli/src/commands/pr/count.ts @@ -1,11 +1,20 @@ -import { PR_STATUS_NAMES, PrStatusName, getClient } from "@repo/backlog-utils"; -import { outputResult, splitArg } from "@repo/cli-utils"; +import { PR_STATUS_NAMES, PrStatusName, getClient, resolveUserId } from "@repo/backlog-utils"; +import { outputResult } from "@repo/cli-utils"; import consola from "consola"; -import * as v from "valibot"; import { BeeCommand, ENV_AUTH, ENV_PROJECT, ENV_REPO } from "../../lib/bee-command"; import * as opt from "../../lib/common-options"; +import { collect, collectNum } from "../../lib/common-options"; import { resolveOptions } from "../../lib/required-option"; +const resolveStatusIds = (statuses: string[]): number[] => + statuses.map((name) => { + const id = PrStatusName[name.toLowerCase()]; + if (id === undefined) { + throw new Error(`Unknown status "${name}". Valid values: ${PR_STATUS_NAMES.join(", ")}`); + } + return id; + }); + const count = new BeeCommand("count") .summary("Count pull requests") .description( @@ -16,10 +25,10 @@ by default, or a JSON object with \`--json\`.`, ) .addOption(opt.project()) .addOption(opt.repo()) - .option("-S, --status <name>", "Status name (comma-separated for multiple)") + .option("-S, --status <name>", "Status name (repeatable)", collect, [] satisfies string[]) .addOption(opt.assigneeList()) - .option("--issue <ids>", "Issue ID (comma-separated for multiple)") - .option("--created-user <ids>", "Created user ID (comma-separated for multiple)") + .option("--issue <id>", "Issue ID (repeatable)", collectNum, [] satisfies number[]) + .option("--created-user <id>", "Created user ID (repeatable)", collectNum, [] satisfies number[]) .addOption(opt.json()) .envVars([...ENV_AUTH, ENV_PROJECT, ENV_REPO]) .examples([ @@ -34,25 +43,12 @@ by default, or a JSON object with \`--json\`.`, await resolveOptions(cmd); const { client } = await getClient(); - const statusId = opts.status - ? opts.status - .split(",") - .map((s: string) => s.trim()) - .filter(Boolean) - .map((name: string) => { - const id = PrStatusName[name.toLowerCase()]; - if (id === undefined) { - throw new Error( - `Unknown status "${name}". Valid values: ${PR_STATUS_NAMES.join(", ")}`, - ); - } - return id; - }) - : undefined; - - const assigneeId = (opts.assignee ?? []).map(Number).filter((n: number) => !Number.isNaN(n)); - const issueId = splitArg(opts.issue, v.number()); - const createdUserId = splitArg(opts.createdUser, v.number()); + const statusId = opts.status.length > 0 ? resolveStatusIds(opts.status) : undefined; + const assigneeId = await Promise.all( + (opts.assignee ?? []).map((id: string) => resolveUserId(client, id)), + ); + const issueId: number[] = opts.issue; + const createdUserId: number[] = opts.createdUser; const result = await client.getPullRequestsCount(opts.project, opts.repo, { statusId, diff --git a/apps/cli/src/commands/pr/list.ts b/apps/cli/src/commands/pr/list.ts index a8471542..ad5519d8 100644 --- a/apps/cli/src/commands/pr/list.ts +++ b/apps/cli/src/commands/pr/list.ts @@ -1,11 +1,20 @@ import { PR_STATUS_NAMES, PrStatusName, getClient, resolveUserId } from "@repo/backlog-utils"; -import { type Row, outputResult, printTable, splitArg } from "@repo/cli-utils"; +import { type Row, outputResult, printTable } from "@repo/cli-utils"; import consola from "consola"; -import * as v from "valibot"; import { BeeCommand, ENV_AUTH, ENV_PROJECT, ENV_REPO } from "../../lib/bee-command"; import * as opt from "../../lib/common-options"; +import { collect, collectNum } from "../../lib/common-options"; import { resolveOptions } from "../../lib/required-option"; +const resolveStatusIds = (statuses: string[]): number[] => + statuses.map((name) => { + const id = PrStatusName[name.toLowerCase()]; + if (id === undefined) { + throw new Error(`Unknown status "${name}". Valid values: ${PR_STATUS_NAMES.join(", ")}`); + } + return id; + }); + const list = new BeeCommand("list") .summary("List pull requests") .description( @@ -16,10 +25,10 @@ status (open, closed, merged).`, ) .addOption(opt.project()) .addOption(opt.repo()) - .option("-S, --status <name>", "Status name (comma-separated for multiple)") + .option("-S, --status <name>", "Status name (repeatable)", collect, [] satisfies string[]) .addOption(opt.assigneeList()) - .option("--issue <ids>", "Issue ID (comma-separated for multiple)") - .option("--created-user <ids>", "Created user ID (comma-separated for multiple)") + .option("--issue <id>", "Issue ID (repeatable)", collectNum, [] satisfies number[]) + .option("--created-user <id>", "Created user ID (repeatable)", collectNum, [] satisfies number[]) .addOption(opt.count()) .addOption(opt.offset()) .addOption(opt.json()) @@ -40,27 +49,12 @@ status (open, closed, merged).`, await resolveOptions(cmd); const { client } = await getClient(); - const statusId = opts.status - ? opts.status - .split(",") - .map((s: string) => s.trim()) - .filter(Boolean) - .map((name: string) => { - const id = PrStatusName[name.toLowerCase()]; - if (id === undefined) { - throw new Error( - `Unknown status "${name}". Valid values: ${PR_STATUS_NAMES.join(", ")}`, - ); - } - return id; - }) - : undefined; - + const statusId = opts.status.length > 0 ? resolveStatusIds(opts.status) : undefined; const assigneeId = await Promise.all( (opts.assignee ?? []).map((id: string) => resolveUserId(client, id)), ); - const issueId = splitArg(opts.issue, v.number()); - const createdUserId = splitArg(opts.createdUser, v.number()); + const issueId: number[] = opts.issue; + const createdUserId: number[] = opts.createdUser; const pullRequests = await client.getPullRequests(opts.project, opts.repo, { statusId, diff --git a/apps/cli/src/commands/project/activities.test.ts b/apps/cli/src/commands/project/activities.test.ts index 15a5f8d9..6d069b04 100644 --- a/apps/cli/src/commands/project/activities.test.ts +++ b/apps/cli/src/commands/project/activities.test.ts @@ -60,9 +60,10 @@ describe("project activities", () => { mockClient.getProjectActivities.mockResolvedValue([]); const { default: activities } = await import("./activities"); - await activities.parseAsync(["PROJ1", "--activity-type", "1,2,3"], { - from: "user", - }); + await activities.parseAsync( + ["PROJ1", "--activity-type", "1", "--activity-type", "2", "--activity-type", "3"], + { from: "user" }, + ); expect(mockClient.getProjectActivities).toHaveBeenCalledWith( "PROJ1", diff --git a/apps/cli/src/commands/project/activities.ts b/apps/cli/src/commands/project/activities.ts index 48751f1b..2490a652 100644 --- a/apps/cli/src/commands/project/activities.ts +++ b/apps/cli/src/commands/project/activities.ts @@ -1,9 +1,9 @@ import { ACTIVITY_LABELS, getClient } from "@repo/backlog-utils"; import { type Row, formatDate, outputResult, printTable } from "@repo/cli-utils"; -import { Option } from "commander"; import consola from "consola"; import { BeeCommand, ENV_AUTH, ENV_PROJECT } from "../../lib/bee-command"; import * as opt from "../../lib/common-options"; +import { collectNum } from "../../lib/common-options"; const getActivitySummary = (activity: { type: number; @@ -40,14 +40,19 @@ const activities = new BeeCommand("activities") Shows the most recent updates including issue changes, wiki edits, git pushes, and other project activities. Results are ordered by most recent first. -Use \`--activity-type\` to filter by specific activity types (comma-separated IDs). +Use \`--activity-type\` to filter by specific activity types (repeatable). Use \`--count\` to control how many activities are returned (default: 20, max: 100). For a list of activity type IDs, see: https://developer.nulab.com/docs/backlog/api/2/get-project-recent-updates/#activity-type`, ) .argument("<project>", "Project ID or project key") - .addOption(new Option("--activity-type <ids>", "Filter by activity type IDs (comma-separated)")) + .option( + "--activity-type <id>", + "Filter by activity type IDs (repeatable)", + collectNum, + [] satisfies number[], + ) .addOption(opt.count()) .addOption(opt.order()) .addOption(opt.json()) @@ -56,7 +61,8 @@ https://developer.nulab.com/docs/backlog/api/2/get-project-recent-updates/#activ { description: "List recent activities", command: "bee project activities PROJECT_KEY" }, { description: "Show only issue-related activities", - command: "bee project activities PROJECT_KEY --activity-type 1,2,3", + command: + "bee project activities PROJECT_KEY --activity-type 1 --activity-type 2 --activity-type 3", }, { description: "Show last 50 activities", @@ -70,9 +76,7 @@ https://developer.nulab.com/docs/backlog/api/2/get-project-recent-updates/#activ .action(async (project, opts) => { const { client } = await getClient(); - const activityTypeId = opts.activityType - ? String(opts.activityType).split(",").map(Number) - : undefined; + const activityTypeId: number[] = opts.activityType; const activityList = await client.getProjectActivities(project, { activityTypeId, diff --git a/apps/cli/src/commands/space/activities.test.ts b/apps/cli/src/commands/space/activities.test.ts index a4d389f7..669ab50c 100644 --- a/apps/cli/src/commands/space/activities.test.ts +++ b/apps/cli/src/commands/space/activities.test.ts @@ -58,7 +58,10 @@ describe("space activities", () => { mockClient.getSpaceActivities.mockResolvedValue([]); const { default: activities } = await import("./activities"); - await activities.parseAsync(["--activity-type", "1,2,3"], { from: "user" }); + await activities.parseAsync( + ["--activity-type", "1", "--activity-type", "2", "--activity-type", "3"], + { from: "user" }, + ); expect(mockClient.getSpaceActivities).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/apps/cli/src/commands/space/activities.ts b/apps/cli/src/commands/space/activities.ts index f665a0fa..4ab66072 100644 --- a/apps/cli/src/commands/space/activities.ts +++ b/apps/cli/src/commands/space/activities.ts @@ -1,9 +1,9 @@ import { ACTIVITY_LABELS, getClient } from "@repo/backlog-utils"; -import { type Row, formatDate, outputResult, printTable, splitArg } from "@repo/cli-utils"; +import { type Row, formatDate, outputResult, printTable } from "@repo/cli-utils"; import consola from "consola"; -import * as v from "valibot"; import { BeeCommand, ENV_AUTH } from "../../lib/bee-command"; import * as opt from "../../lib/common-options"; +import { collectNum } from "../../lib/common-options"; const getActivitySummary = (activity: { type: number; @@ -41,10 +41,15 @@ Shows the most recent updates across the entire space, including issue changes, wiki edits, git pushes, and other activities. Results are ordered by most recent first. -Use \`--activity-type\` to filter by specific activity types (comma-separated IDs). +Use \`--activity-type\` to filter by specific activity types (repeatable). Use \`--count\` to control how many activities are returned (default: 20, max: 100).`, ) - .option("--activity-type <ids>", "Filter by activity type IDs (comma-separated)") + .option( + "--activity-type <id>", + "Filter by activity type IDs (repeatable)", + collectNum, + [] satisfies number[], + ) .addOption(opt.count()) .addOption(opt.order()) .addOption(opt.minId()) @@ -55,7 +60,7 @@ Use \`--count\` to control how many activities are returned (default: 20, max: 1 { description: "List space activities", command: "bee space activities" }, { description: "Show only issue-related activities", - command: "bee space activities --activity-type 1,2,3", + command: "bee space activities --activity-type 1 --activity-type 2 --activity-type 3", }, { description: "Show last 50 activities", @@ -69,7 +74,7 @@ Use \`--count\` to control how many activities are returned (default: 20, max: 1 .action(async (opts) => { const { client } = await getClient(); - const activityTypeId = splitArg(opts.activityType, v.number()); + const activityTypeId: number[] = opts.activityType; const activityList = await client.getSpaceActivities({ activityTypeId, diff --git a/apps/cli/src/commands/team/create.ts b/apps/cli/src/commands/team/create.ts index 04e947b7..10f10a72 100644 --- a/apps/cli/src/commands/team/create.ts +++ b/apps/cli/src/commands/team/create.ts @@ -17,7 +17,12 @@ Optionally specify \`--members\` with user IDs (repeatable) to add members when creating the team.`, ) .option("-n, --name <name>", "Team name") - .option("--members <id>", "User IDs to add as members (repeatable)", collectNum, [] as number[]) + .option( + "--members <id>", + "User IDs to add as members (repeatable)", + collectNum, + [] satisfies number[], + ) .addOption(opt.json()) .envVars([...ENV_AUTH]) .examples([ diff --git a/apps/cli/src/commands/team/edit.ts b/apps/cli/src/commands/team/edit.ts index c61a4867..31e448f3 100644 --- a/apps/cli/src/commands/team/edit.ts +++ b/apps/cli/src/commands/team/edit.ts @@ -23,7 +23,7 @@ the given user IDs.`, "--members <id>", "Replace members with user IDs (repeatable)", collectNum, - [] as number[], + [] satisfies number[], ) .addOption(opt.json()) .envVars([...ENV_AUTH]) diff --git a/apps/cli/src/commands/user/activities.test.ts b/apps/cli/src/commands/user/activities.test.ts index 767edc44..c2ba3970 100644 --- a/apps/cli/src/commands/user/activities.test.ts +++ b/apps/cli/src/commands/user/activities.test.ts @@ -58,7 +58,10 @@ describe("user activities", () => { mockClient.getUserActivities.mockResolvedValue([]); const { default: activities } = await import("./activities"); - await activities.parseAsync(["12345", "--activity-type", "1,2,3"], { from: "user" }); + await activities.parseAsync( + ["12345", "--activity-type", "1", "--activity-type", "2", "--activity-type", "3"], + { from: "user" }, + ); expect(mockClient.getUserActivities).toHaveBeenCalledWith( 12_345, diff --git a/apps/cli/src/commands/user/activities.ts b/apps/cli/src/commands/user/activities.ts index 7da7a3e9..9fff71d1 100644 --- a/apps/cli/src/commands/user/activities.ts +++ b/apps/cli/src/commands/user/activities.ts @@ -1,9 +1,9 @@ import { ACTIVITY_LABELS, getClient } from "@repo/backlog-utils"; -import { type Row, formatDate, outputResult, printTable, splitArg } from "@repo/cli-utils"; +import { type Row, formatDate, outputResult, printTable } from "@repo/cli-utils"; import consola from "consola"; -import * as v from "valibot"; import { BeeCommand, ENV_AUTH } from "../../lib/bee-command"; import * as opt from "../../lib/common-options"; +import { collectNum } from "../../lib/common-options"; const getActivitySummary = (activity: { type: number; @@ -41,14 +41,19 @@ Shows the most recent updates performed by the specified user, including issue changes, wiki edits, git pushes, and other activities. Results are ordered by most recent first. -Use \`--activity-type\` to filter by specific activity types (comma-separated IDs). +Use \`--activity-type\` to filter by specific activity types (repeatable). Use \`--count\` to control how many activities are returned (default: 20, max: 100). For a list of activity type IDs, see: https://developer.nulab.com/docs/backlog/api/2/get-user-recent-updates/#activity-type`, ) .argument("<user>", "User ID") - .option("--activity-type <ids>", "Filter by activity type IDs (comma-separated)") + .option( + "--activity-type <id>", + "Filter by activity type IDs (repeatable)", + collectNum, + [] satisfies number[], + ) .addOption(opt.count()) .addOption(opt.order()) .addOption(opt.minId()) @@ -59,7 +64,7 @@ https://developer.nulab.com/docs/backlog/api/2/get-user-recent-updates/#activity { description: "List user activities", command: "bee user activities 12345" }, { description: "Show only issue-related activities", - command: "bee user activities 12345 --activity-type 1,2,3", + command: "bee user activities 12345 --activity-type 1 --activity-type 2 --activity-type 3", }, { description: "Show last 50 activities", @@ -73,7 +78,7 @@ https://developer.nulab.com/docs/backlog/api/2/get-user-recent-updates/#activity .action(async (user, opts) => { const { client } = await getClient(); - const activityTypeId = splitArg(opts.activityType, v.number()); + const activityTypeId: number[] = opts.activityType; const activityList = await client.getUserActivities(Number(user), { activityTypeId, diff --git a/apps/cli/src/commands/webhook/create.ts b/apps/cli/src/commands/webhook/create.ts index 5ce1088a..d93bd44d 100644 --- a/apps/cli/src/commands/webhook/create.ts +++ b/apps/cli/src/commands/webhook/create.ts @@ -25,7 +25,7 @@ activity type IDs with \`--activity-type-ids\`.`, "--activity-type-ids <id>", "Activity type IDs to subscribe to (repeatable)", collectNum, - [] as number[], + [] satisfies number[], ) .addOption(opt.json()) .envVars([...ENV_AUTH, ENV_PROJECT]) diff --git a/apps/cli/src/commands/webhook/edit.ts b/apps/cli/src/commands/webhook/edit.ts index 0639b938..9e30500a 100644 --- a/apps/cli/src/commands/webhook/edit.ts +++ b/apps/cli/src/commands/webhook/edit.ts @@ -22,7 +22,7 @@ All fields are optional. Only the specified fields will be updated.`, "--activity-type-ids <id>", "New activity type IDs to subscribe to (repeatable)", collectNum, - [] as number[], + [] satisfies number[], ) .addOption(opt.json()) .envVars([...ENV_AUTH, ENV_PROJECT]) From 70f93380c13383cd7fe0e0d508965ab4cb9d01df Mon Sep 17 00:00:00 2001 From: Ryoya Tamura <ryoya.tamura@nulab.com> Date: Sun, 8 Mar 2026 22:41:14 +0900 Subject: [PATCH 16/16] chore(cli): delete inlined completion scripts and remove unnecessary type cast - Remove completions/bash.sh, zsh.zsh, fish.fish (now inlined in completion.ts) - Remove `as { json?: string }` cast in dashboard.ts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --- apps/cli/src/commands/completions/bash.sh | 14 ------- apps/cli/src/commands/completions/fish.fish | 27 ------------- apps/cli/src/commands/completions/zsh.zsh | 42 --------------------- apps/cli/src/commands/dashboard.ts | 2 +- 4 files changed, 1 insertion(+), 84 deletions(-) delete mode 100644 apps/cli/src/commands/completions/bash.sh delete mode 100644 apps/cli/src/commands/completions/fish.fish delete mode 100644 apps/cli/src/commands/completions/zsh.zsh diff --git a/apps/cli/src/commands/completions/bash.sh b/apps/cli/src/commands/completions/bash.sh deleted file mode 100644 index a22dfa45..00000000 --- a/apps/cli/src/commands/completions/bash.sh +++ /dev/null @@ -1,14 +0,0 @@ -# bash completion for bee -# Add to ~/.bashrc: -# eval "$(bee completion bash)" - -_bee_completions() { - local cur="${COMP_WORDS[COMP_CWORD]}" - local commands="auth project issue document notification pr repo team user webhook wiki category milestone issue-type space status star watching dashboard browse api completion" - - if [ "${COMP_CWORD}" -eq 1 ]; then - COMPREPLY=( $(compgen -W "${commands}" -- "${cur}") ) - fi -} - -complete -F _bee_completions bee diff --git a/apps/cli/src/commands/completions/fish.fish b/apps/cli/src/commands/completions/fish.fish deleted file mode 100644 index 4de845e7..00000000 --- a/apps/cli/src/commands/completions/fish.fish +++ /dev/null @@ -1,27 +0,0 @@ -# fish completion for bee -# Add to ~/.config/fish/completions/bee.fish: -# bee completion fish > ~/.config/fish/completions/bee.fish - -complete -c bee -e -complete -c bee -n "__fish_use_subcommand" -a "auth" -d "auth commands" -complete -c bee -n "__fish_use_subcommand" -a "project" -d "project commands" -complete -c bee -n "__fish_use_subcommand" -a "issue" -d "issue commands" -complete -c bee -n "__fish_use_subcommand" -a "document" -d "document commands" -complete -c bee -n "__fish_use_subcommand" -a "notification" -d "notification commands" -complete -c bee -n "__fish_use_subcommand" -a "pr" -d "pr commands" -complete -c bee -n "__fish_use_subcommand" -a "repo" -d "repo commands" -complete -c bee -n "__fish_use_subcommand" -a "team" -d "team commands" -complete -c bee -n "__fish_use_subcommand" -a "user" -d "user commands" -complete -c bee -n "__fish_use_subcommand" -a "webhook" -d "webhook commands" -complete -c bee -n "__fish_use_subcommand" -a "wiki" -d "wiki commands" -complete -c bee -n "__fish_use_subcommand" -a "category" -d "category commands" -complete -c bee -n "__fish_use_subcommand" -a "milestone" -d "milestone commands" -complete -c bee -n "__fish_use_subcommand" -a "issue-type" -d "issue-type commands" -complete -c bee -n "__fish_use_subcommand" -a "space" -d "space commands" -complete -c bee -n "__fish_use_subcommand" -a "status" -d "status commands" -complete -c bee -n "__fish_use_subcommand" -a "star" -d "star commands" -complete -c bee -n "__fish_use_subcommand" -a "watching" -d "watching commands" -complete -c bee -n "__fish_use_subcommand" -a "dashboard" -d "dashboard commands" -complete -c bee -n "__fish_use_subcommand" -a "browse" -d "browse commands" -complete -c bee -n "__fish_use_subcommand" -a "api" -d "api commands" -complete -c bee -n "__fish_use_subcommand" -a "completion" -d "completion commands" diff --git a/apps/cli/src/commands/completions/zsh.zsh b/apps/cli/src/commands/completions/zsh.zsh deleted file mode 100644 index 8ef20bbd..00000000 --- a/apps/cli/src/commands/completions/zsh.zsh +++ /dev/null @@ -1,42 +0,0 @@ -#compdef bee -# zsh completion for bee -# Add to ~/.zshrc: -# eval "$(bee completion zsh)" - -_bee() { - local -a commands - commands=( - 'auth:auth commands' \ - 'project:project commands' \ - 'issue:issue commands' \ - 'document:document commands' \ - 'notification:notification commands' \ - 'pr:pr commands' \ - 'repo:repo commands' \ - 'team:team commands' \ - 'user:user commands' \ - 'webhook:webhook commands' \ - 'wiki:wiki commands' \ - 'category:category commands' \ - 'milestone:milestone commands' \ - 'issue-type:issue-type commands' \ - 'space:space commands' \ - 'status:status commands' \ - 'star:star commands' \ - 'watching:watching commands' \ - 'dashboard:dashboard commands' \ - 'browse:browse commands' \ - 'api:api commands' \ - 'completion:completion commands' - ) - - _arguments '1: :->command' '*:: :->args' - - case $state in - command) - _describe 'command' commands - ;; - esac -} - -compdef _bee bee diff --git a/apps/cli/src/commands/dashboard.ts b/apps/cli/src/commands/dashboard.ts index b630f5a0..f75d88ee 100644 --- a/apps/cli/src/commands/dashboard.ts +++ b/apps/cli/src/commands/dashboard.ts @@ -49,7 +49,7 @@ Use \`--web\` to open the Backlog dashboard in your browser instead.`, // commander gives `true` for bare --json, a string for --json fields const jsonVal = opts.json === true ? "" : opts.json; - outputResult(result, { json: jsonVal } as { json?: string }, (data) => { + outputResult(result, { json: jsonVal }, (data) => { consola.log(""); consola.log(` ${data.myself.name} (${host})`);