diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 00000000..ed56fe26 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,70 @@ +import { Command } from "@cliffy/command" +import { CompletionsCommand } from "@cliffy/command/completions" +import denoConfig from "../deno.json" with { type: "json" } +import { authCommand } from "./commands/auth/auth.ts" +import { issueCommand } from "./commands/issue/issue.ts" +import { teamCommand } from "./commands/team/team.ts" +import { projectCommand } from "./commands/project/project.ts" +import { projectUpdateCommand } from "./commands/project-update/project-update.ts" +import { cycleCommand } from "./commands/cycle/cycle.ts" +import { milestoneCommand } from "./commands/milestone/milestone.ts" +import { initiativeCommand } from "./commands/initiative/initiative.ts" +import { initiativeUpdateCommand } from "./commands/initiative-update/initiative-update.ts" +import { labelCommand } from "./commands/label/label.ts" +import { documentCommand } from "./commands/document/document.ts" +import { configCommand } from "./commands/config.ts" +import { schemaCommand } from "./commands/schema.ts" +import { apiCommand } from "./commands/api.ts" +import { setCliWorkspace } from "./config.ts" + +// Import config and credentials setup +import "./config.ts" +import "./credentials.ts" + +// The root command. Kept in this internal module (rather than the package entry +// point src/main.ts) so its complex inferred cliffy type stays out of the +// published public API and doesn't trip the no-slow-types check. +export const cli = new Command() + .name("linear") + .version(denoConfig.version) + .description( + `Handy linear commands from the command line. + +Environment Variables: + LINEAR_DEBUG=1 Show full error details including stack traces`, + ) + .globalOption( + "--workspace ", + "Target workspace (uses credentials)", + ) + .globalAction((options) => { + setCliWorkspace(options.workspace) + }) + .action(() => { + console.log("Use --help to see available commands") + }) + .command("auth", authCommand) + .command("issue", issueCommand) + .alias("i") + .command("team", teamCommand) + .alias("t") + .command("project", projectCommand) + .alias("p") + .command("project-update", projectUpdateCommand) + .alias("pu") + .command("cycle", cycleCommand) + .alias("cy") + .command("milestone", milestoneCommand) + .alias("m") + .command("initiative", initiativeCommand) + .alias("init") + .command("initiative-update", initiativeUpdateCommand) + .alias("iu") + .command("label", labelCommand) + .alias("l") + .command("document", documentCommand) + .command("completions", new CompletionsCommand()) + .command("config", configCommand) + .alias("configure") + .command("schema", schemaCommand) + .command("api", apiCommand) diff --git a/src/commands/team/team-autolinks.ts b/src/commands/team/team-autolinks.ts index 61276fad..8cf3e367 100644 --- a/src/commands/team/team-autolinks.ts +++ b/src/commands/team/team-autolinks.ts @@ -15,7 +15,7 @@ export const autolinksCommand = new Command() if (!teamId) { throw new ValidationError( "Could not determine team id from directory name", - { suggestion: "Run `linear configure` to set a team." }, + { suggestion: "Run `linear config` to set a team." }, ) } diff --git a/src/commands/team/team-id.ts b/src/commands/team/team-id.ts index 1d69588f..97e7ea90 100644 --- a/src/commands/team/team-id.ts +++ b/src/commands/team/team-id.ts @@ -13,7 +13,7 @@ export const idCommand = new Command() } else { throw new ValidationError( "No team id configured", - { suggestion: "Run `linear configure` to set a team." }, + { suggestion: "Run `linear config` to set a team." }, ) } } catch (error) { diff --git a/src/main.ts b/src/main.ts index 2167f24d..1d363201 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,67 +1,5 @@ -import { Command } from "@cliffy/command" -import { CompletionsCommand } from "@cliffy/command/completions" -import denoConfig from "../deno.json" with { type: "json" } -import { authCommand } from "./commands/auth/auth.ts" -import { issueCommand } from "./commands/issue/issue.ts" -import { teamCommand } from "./commands/team/team.ts" -import { projectCommand } from "./commands/project/project.ts" -import { projectUpdateCommand } from "./commands/project-update/project-update.ts" -import { cycleCommand } from "./commands/cycle/cycle.ts" -import { milestoneCommand } from "./commands/milestone/milestone.ts" -import { initiativeCommand } from "./commands/initiative/initiative.ts" -import { initiativeUpdateCommand } from "./commands/initiative-update/initiative-update.ts" -import { labelCommand } from "./commands/label/label.ts" -import { documentCommand } from "./commands/document/document.ts" -import { configCommand } from "./commands/config.ts" -import { schemaCommand } from "./commands/schema.ts" -import { apiCommand } from "./commands/api.ts" -import { setCliWorkspace } from "./config.ts" +import { cli } from "./cli.ts" -// Import config and credentials setup -import "./config.ts" -import "./credentials.ts" - -await new Command() - .name("linear") - .version(denoConfig.version) - .description( - `Handy linear commands from the command line. - -Environment Variables: - LINEAR_DEBUG=1 Show full error details including stack traces`, - ) - .globalOption( - "--workspace ", - "Target workspace (uses credentials)", - ) - .globalAction((options) => { - setCliWorkspace(options.workspace) - }) - .action(() => { - console.log("Use --help to see available commands") - }) - .command("auth", authCommand) - .command("issue", issueCommand) - .alias("i") - .command("team", teamCommand) - .alias("t") - .command("project", projectCommand) - .alias("p") - .command("project-update", projectUpdateCommand) - .alias("pu") - .command("cycle", cycleCommand) - .alias("cy") - .command("milestone", milestoneCommand) - .alias("m") - .command("initiative", initiativeCommand) - .alias("init") - .command("initiative-update", initiativeUpdateCommand) - .alias("iu") - .command("label", labelCommand) - .alias("l") - .command("document", documentCommand) - .command("completions", new CompletionsCommand()) - .command("config", configCommand) - .command("schema", schemaCommand) - .command("api", apiCommand) - .parse(Deno.args) +if (import.meta.main) { + await cli.parse(Deno.args) +} diff --git a/src/utils/linear.ts b/src/utils/linear.ts index 81f1350b..e69f73a4 100644 --- a/src/utils/linear.ts +++ b/src/utils/linear.ts @@ -101,8 +101,9 @@ export async function getIssueIdentifier( return normalizeIssueIdentifier(`${teamId}-${providedId}`) } - throw new Error( - "an integer id was provided, but no team is set. run `linear configure`", + throw new ValidationError( + "an integer id was provided, but no team is set", + { suggestion: "Run `linear config` to set a team." }, ) } diff --git a/test/commands/team/__snapshots__/team-autolinks.test.ts.snap b/test/commands/team/__snapshots__/team-autolinks.test.ts.snap new file mode 100644 index 00000000..df25eee5 --- /dev/null +++ b/test/commands/team/__snapshots__/team-autolinks.test.ts.snap @@ -0,0 +1,28 @@ +export const snapshot = {}; + +snapshot[`Team Autolinks Command - Help Text 1`] = ` +stdout: +" +Usage: autolinks + +Description: + + Configure GitHub repository autolinks for Linear issues with this team prefix + +Options: + + -h, --help - Show this help. + +" +stderr: +"" +`; + +snapshot[`Team Autolinks Command - No Team Configured 1`] = ` +stdout: +"" +stderr: +"✗ Failed to configure autolinks: Could not determine team id from directory name + Run \`linear config\` to set a team. +" +`; diff --git a/test/commands/team/__snapshots__/team-id.test.ts.snap b/test/commands/team/__snapshots__/team-id.test.ts.snap new file mode 100644 index 00000000..359a42ed --- /dev/null +++ b/test/commands/team/__snapshots__/team-id.test.ts.snap @@ -0,0 +1,28 @@ +export const snapshot = {}; + +snapshot[`Team Id Command - Help Text 1`] = ` +stdout: +" +Usage: id + +Description: + + Print the configured team id + +Options: + + -h, --help - Show this help. + +" +stderr: +"" +`; + +snapshot[`Team Id Command - No Team Configured 1`] = ` +stdout: +"" +stderr: +"✗ Failed to get team id: No team id configured + Run \`linear config\` to set a team. +" +`; diff --git a/test/commands/team/team-autolinks.test.ts b/test/commands/team/team-autolinks.test.ts new file mode 100644 index 00000000..f4b484d1 --- /dev/null +++ b/test/commands/team/team-autolinks.test.ts @@ -0,0 +1,39 @@ +import { snapshotTest as cliffySnapshotTest } from "@cliffy/testing" +import { autolinksCommand } from "../../../src/commands/team/team-autolinks.ts" + +// Common Deno args for permissions +const denoArgs = ["--allow-all", "--quiet"] + +// Test help output +await cliffySnapshotTest({ + name: "Team Autolinks Command - Help Text", + meta: import.meta, + colors: false, + args: ["--help"], + denoArgs, + async fn() { + await autolinksCommand.parse() + }, +}) + +// Regression test for #245: with no team configured, the suggestion must point +// at the real `linear config` command, not the non-existent `linear configure`. +await cliffySnapshotTest({ + name: "Team Autolinks Command - No Team Configured", + meta: import.meta, + colors: false, + args: [], + denoArgs, + canFail: true, + async fn() { + // An empty team id is falsy, so getTeamKey() resolves to undefined even + // though the repo's .linear.toml sets one — this exercises the error path + // before any network call is attempted. + Deno.env.set("LINEAR_TEAM_ID", "") + try { + await autolinksCommand.parse() + } finally { + Deno.env.delete("LINEAR_TEAM_ID") + } + }, +}) diff --git a/test/commands/team/team-id.test.ts b/test/commands/team/team-id.test.ts new file mode 100644 index 00000000..332742f7 --- /dev/null +++ b/test/commands/team/team-id.test.ts @@ -0,0 +1,38 @@ +import { snapshotTest as cliffySnapshotTest } from "@cliffy/testing" +import { idCommand } from "../../../src/commands/team/team-id.ts" + +// Common Deno args for permissions +const denoArgs = ["--allow-all", "--quiet"] + +// Test help output +await cliffySnapshotTest({ + name: "Team Id Command - Help Text", + meta: import.meta, + colors: false, + args: ["--help"], + denoArgs, + async fn() { + await idCommand.parse() + }, +}) + +// Regression test for #245: with no team configured, the suggestion must point +// at the real `linear config` command, not the non-existent `linear configure`. +await cliffySnapshotTest({ + name: "Team Id Command - No Team Configured", + meta: import.meta, + colors: false, + args: [], + denoArgs, + canFail: true, + async fn() { + // An empty team id is falsy, so getTeamKey() resolves to undefined even + // though the repo's .linear.toml sets one — this exercises the error path. + Deno.env.set("LINEAR_TEAM_ID", "") + try { + await idCommand.parse() + } finally { + Deno.env.delete("LINEAR_TEAM_ID") + } + }, +}) diff --git a/test/main_test.ts b/test/main_test.ts index b40bf3ad..829b23ae 100644 --- a/test/main_test.ts +++ b/test/main_test.ts @@ -1,5 +1,14 @@ -import { assertStringIncludes } from "@std/assert" +import { assertEquals, assertStringIncludes } from "@std/assert" import { getGraphQLClient } from "../src/utils/graphql.ts" +import { cli } from "../src/cli.ts" +import { configCommand } from "../src/commands/config.ts" + +// Regression guard for #245: `configure` is a natural name users (and the +// CLI's own help text) reach for, so it resolves to the canonical `config` +// command instead of erroring with "Unknown command". +Deno.test("cli - `configure` is an alias for the config command", () => { + assertEquals(cli.getCommand("configure"), configCommand) +}) // Mock fetch function for testing const originalFetch = globalThis.fetch diff --git a/test/utils/linear.test.ts b/test/utils/linear.test.ts index 15237840..09932c00 100644 --- a/test/utils/linear.test.ts +++ b/test/utils/linear.test.ts @@ -1,4 +1,4 @@ -import { assertEquals, assertRejects } from "@std/assert" +import { assertEquals, assertRejects, assertStringIncludes } from "@std/assert" import { getIssueIdentifier, isLinearUuid, @@ -23,6 +23,26 @@ Deno.test("getIssueId - handles integer-only IDs with team prefix", async () => Deno.env.delete("LINEAR_TEAM_ID") }) +Deno.test("getIssueId - integer-only id without a team points at `linear config`", async () => { + // An empty team id is falsy, so getTeamKey() resolves to undefined even + // though the repo's .linear.toml sets one — this exercises the no-team branch. + Deno.env.set("LINEAR_TEAM_ID", "") + + try { + const error = await assertRejects( + () => getIssueIdentifier("123"), + ValidationError, + "no team is set", + ) + // Regression guard for #245: the suggestion must name the real command + // (`config`), never the non-existent `configure`. + assertStringIncludes(error.suggestion ?? "", "linear config") + assertEquals(error.suggestion?.includes("configure"), false) + } finally { + Deno.env.delete("LINEAR_TEAM_ID") + } +}) + Deno.test("getIssueId - rejects invalid integer patterns", async () => { Deno.env.set("LINEAR_TEAM_ID", "TEST")