From 98c9176552bbdb1da6cc999e15fb48ebe755d12f Mon Sep 17 00:00:00 2001 From: Charles Phillips Date: Fri, 7 Aug 2026 04:06:11 -0700 Subject: [PATCH 1/2] feat: add configurable GraphQL request timeouts Refs #157 Follow-up to #158 --- README.md | 7 ++ src/client/graphql-client.ts | 12 +- src/commands/auth.ts | 11 +- src/common/auth.ts | 17 +++ src/common/context.ts | 16 ++- src/common/number-options.ts | 18 +++ src/common/usage.ts | 3 + src/main.ts | 6 + tests/unit/client/graphql-client.test.ts | 35 +++++- tests/unit/commands/auth.test.ts | 142 +++++++++++++++++++++-- tests/unit/common/auth.test.ts | 32 ++++- tests/unit/common/context.test.ts | 35 +++++- tests/unit/common/number-options.test.ts | 19 +++ tests/unit/common/usage.test.ts | 3 + 14 files changed, 339 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index d664ffee..26a63cd6 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,13 @@ LINEAR_API_TOKEN= linearis issues list # via environment variable Token resolution order: `--api-token` flag → `LINEAR_API_TOKEN` env → `~/.linearis/token` → `~/.linear_api_token` (deprecated). +GraphQL requests time out after 30 seconds by default. Override the timeout for one command with `--graphql-timeout-ms`, or set `LINEAR_GRAPHQL_TIMEOUT_MS` for all commands in an environment. The flag takes precedence over the environment variable. + +```bash +linearis --graphql-timeout-ms 5000 issues list +LINEAR_GRAPHQL_TIMEOUT_MS=5000 linearis auth status +``` + ## Usage All output is JSON. Start with discovery, then act. diff --git a/src/client/graphql-client.ts b/src/client/graphql-client.ts index dcc65503..9fdd466f 100644 --- a/src/client/graphql-client.ts +++ b/src/client/graphql-client.ts @@ -7,7 +7,15 @@ import { withRetry } from "../common/retry.js"; const LINEAR_GRAPHQL_ENDPOINT = "https://api.linear.app/graphql"; /** Default timeout for GraphQL API requests (30 seconds) */ -const REQUEST_TIMEOUT_MS = 30_000; +const DEFAULT_GRAPHQL_TIMEOUT_MS = 30_000; + +let graphqlRequestTimeoutMs = DEFAULT_GRAPHQL_TIMEOUT_MS; + +export function setGraphqlRequestTimeoutMs( + timeoutMs = DEFAULT_GRAPHQL_TIMEOUT_MS, +): void { + graphqlRequestTimeoutMs = timeoutMs; +} /** * Variable-less operations generate `Exact<{ [key: string]: never }>` for their @@ -131,7 +139,7 @@ export class GraphQLClient { const timeoutController = new AbortController(); const timeoutHandle = setTimeout(() => { timeoutController.abort(); - }, REQUEST_TIMEOUT_MS); + }, graphqlRequestTimeoutMs); try { // `TVariables extends Record` lets `variables` widen diff --git a/src/commands/auth.ts b/src/commands/auth.ts index 329babfd..f3864158 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -6,7 +6,11 @@ import { resolveApiToken, type TokenSource, } from "../common/auth.js"; -import { createGraphQLClient, getRootOpts } from "../common/context.js"; +import { + configureGraphqlRequestTimeout, + createGraphQLClient, + getRootOpts, +} from "../common/context.js"; import { handleCommand, outputSuccess } from "../common/output.js"; import { clearToken, saveToken } from "../common/token-storage.js"; import { type DomainMeta, formatDomainUsage } from "../common/usage.js"; @@ -29,6 +33,7 @@ export const AUTH_META: DomainMeta = { "linearis requires a Linear API token for all operations.", "the auth command guides you through creating and storing a token.", "tokens are encrypted and stored in ~/.linearis/token.", + "graphql timeout order: --graphql-timeout-ms flag, LINEAR_GRAPHQL_TIMEOUT_MS env, default 30000ms.", "token resolution order: --api-token flag, LINEAR_API_TOKEN env,", "~/.linearis/token (encrypted), ~/.linear_api_token (deprecated).", ].join("\n"), @@ -119,9 +124,10 @@ export function setupAuthCommands(program: Command): void { .option("--force", "reauthenticate even if already authenticated") .action(async (options: { force?: boolean }, command: Command) => { try { + const rootOpts = getRootOpts(command) as CommandOptions; + configureGraphqlRequestTimeout(rootOpts); if (!options.force) { try { - const rootOpts = getRootOpts(command) as CommandOptions; const { token, source } = resolveApiToken(rootOpts); try { const viewer = await validateApiToken(token); @@ -217,6 +223,7 @@ export function setupAuthCommands(program: Command): void { return; } + configureGraphqlRequestTimeout(rootOpts); try { const viewer = await validateApiToken(token); outputSuccess({ diff --git a/src/common/auth.ts b/src/common/auth.ts index 2ad6e666..e2ee1b5a 100644 --- a/src/common/auth.ts +++ b/src/common/auth.ts @@ -1,12 +1,14 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { parseGraphqlTimeoutOption } from "./number-options.js"; import { getStoredToken } from "./token-storage.js"; export interface CommandOptions { apiToken?: string; compact?: boolean; fields?: string[]; + graphqlTimeoutMs?: number; } export type TokenSource = "flag" | "env" | "stored" | "legacy"; @@ -55,3 +57,18 @@ export function getApiToken(options: CommandOptions): string { const { token } = resolveApiToken(options); return token; } + +export function resolveGraphqlTimeoutMs( + options: CommandOptions, +): number | undefined { + if (options.graphqlTimeoutMs !== undefined) { + return options.graphqlTimeoutMs; + } + + const environmentValue = process.env["LINEAR_GRAPHQL_TIMEOUT_MS"]; + if (environmentValue) { + return parseGraphqlTimeoutOption(environmentValue); + } + + return undefined; +} diff --git a/src/common/context.ts b/src/common/context.ts index 082dc52a..5973e362 100644 --- a/src/common/context.ts +++ b/src/common/context.ts @@ -1,6 +1,13 @@ import type { Command } from "commander"; -import { GraphQLClient } from "../client/graphql-client.js"; -import { type CommandOptions, getApiToken } from "./auth.js"; +import { + GraphQLClient, + setGraphqlRequestTimeoutMs, +} from "../client/graphql-client.js"; +import { + type CommandOptions, + getApiToken, + resolveGraphqlTimeoutMs, +} from "./auth.js"; export type { CommandOptions }; @@ -8,8 +15,13 @@ export interface CommandContext { gql: GraphQLClient; } +export function configureGraphqlRequestTimeout(options: CommandOptions): void { + setGraphqlRequestTimeoutMs(resolveGraphqlTimeoutMs(options)); +} + export function createContext(options: CommandOptions): CommandContext { const token = getApiToken(options); + configureGraphqlRequestTimeout(options); return { gql: new GraphQLClient(token), }; diff --git a/src/common/number-options.ts b/src/common/number-options.ts index 176e7012..e43809b9 100644 --- a/src/common/number-options.ts +++ b/src/common/number-options.ts @@ -29,3 +29,21 @@ export function parseEstimateOption(raw: string): number { return value; } + +export function parseGraphqlTimeoutOption(raw: string): number { + const value = parseStrictNonNegativeInteger(raw); + if (value === null || value < 1) { + throw invalidParameterError( + "--graphql-timeout-ms", + "must be a positive integer", + ); + } + if (value > 2_147_483_647) { + throw invalidParameterError( + "--graphql-timeout-ms", + "must not exceed 2147483647", + ); + } + + return value; +} diff --git a/src/common/usage.ts b/src/common/usage.ts index 12b2e5fd..008ff222 100644 --- a/src/common/usage.ts +++ b/src/common/usage.ts @@ -17,6 +17,9 @@ export function formatOverview(version: string, metas: DomainMeta[]): string { lines.push( "auth: linearis auth login | --api-token | LINEAR_API_TOKEN | ~/.linearis/token", ); + lines.push( + "graphql timeout: --graphql-timeout-ms | LINEAR_GRAPHQL_TIMEOUT_MS | default 30000", + ); lines.push("output: JSON"); lines.push("ids: UUID or human-readable (team key, issue ABC-123, name)"); lines.push(""); diff --git a/src/main.ts b/src/main.ts index 0455281a..84a6d9ef 100644 --- a/src/main.ts +++ b/src/main.ts @@ -33,6 +33,7 @@ import { interceptParseErrors, } from "./common/cli-errors.js"; import { getRootOpts } from "./common/context.js"; +import { parseGraphqlTimeoutOption } from "./common/number-options.js"; import { parseFieldsList, setOutputOptions } from "./common/output.js"; import { maybeNotifyUpdate } from "./common/update-notifier.js"; import { @@ -46,6 +47,11 @@ program .description("CLI for Linear.app with JSON output") .version(pkg.version) .option("--api-token ", "Linear API token") + .option( + "--graphql-timeout-ms ", + "GraphQL request timeout in milliseconds", + parseGraphqlTimeoutOption, + ) .option("--compact", "emit single-line JSON (no indentation)") .option( "--fields ", diff --git a/tests/unit/client/graphql-client.test.ts b/tests/unit/client/graphql-client.test.ts index 58f23820..8e0f35e7 100644 --- a/tests/unit/client/graphql-client.test.ts +++ b/tests/unit/client/graphql-client.test.ts @@ -1,6 +1,9 @@ import type { TypedDocumentNode } from "@graphql-typed-document-node/core"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { GraphQLClient } from "../../../src/client/graphql-client.js"; +import { + GraphQLClient, + setGraphqlRequestTimeoutMs, +} from "../../../src/client/graphql-client.js"; import { AuthenticationError } from "../../../src/common/errors.js"; // A stand-in document for the error-path tests. Typing its variables as @@ -39,6 +42,7 @@ describe("GraphQLClient", () => { let mockFetch: ReturnType; beforeEach(() => { + setGraphqlRequestTimeoutMs(); mockFetch = vi.fn(); vi.stubGlobal("fetch", mockFetch); }); @@ -222,6 +226,35 @@ describe("GraphQLClient", () => { } }); + it("uses the configured request timeout", async () => { + vi.useFakeTimers(); + try { + mockFetch.mockImplementation( + (_url: string, options: { signal?: AbortSignal }) => + new Promise((_, reject) => { + options.signal?.addEventListener("abort", () => { + reject(new Error("This operation was aborted")); + }); + }), + ); + + setGraphqlRequestTimeoutMs(1250); + const client = new GraphQLClient("good-token"); + const promise = client.request(fakeDocument()); + const rejection = expect(promise).rejects.toThrow("Request timed out"); + + await vi.advanceTimersByTimeAsync(1249); + expect(mockFetch.mock.calls[0]?.[1].signal.aborted).toBe(false); + await vi.advanceTimersByTimeAsync(1); + expect(mockFetch.mock.calls[0]?.[1].signal.aborted).toBe(true); + + await vi.runAllTimersAsync(); + await rejection; + } finally { + vi.useRealTimers(); + } + }); + it("retries on 429 and succeeds on next attempt", async () => { mockFetch .mockResolvedValueOnce(fakeResponse({ ok: false, status: 429 }, {})) diff --git a/tests/unit/commands/auth.test.ts b/tests/unit/commands/auth.test.ts index 888ccfea..99dfa3c3 100644 --- a/tests/unit/commands/auth.test.ts +++ b/tests/unit/commands/auth.test.ts @@ -1,5 +1,5 @@ import { Command } from "commander"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; // Mock all external dependencies before importing the module under test vi.mock("node:child_process", () => ({ @@ -22,19 +22,37 @@ vi.mock("../../../src/services/auth-service.js", () => ({ validateToken: vi.fn(), })); -vi.mock("../../../src/common/context.js", () => ({ - createGraphQLClient: vi.fn(() => ({})), - getRootOpts: vi.fn(() => ({ apiToken: "test-token" })), -})); +vi.mock("../../../src/common/context.js", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + configureGraphqlRequestTimeout: vi.fn((options) => + actual.configureGraphqlRequestTimeout(options), + ), + createGraphQLClient: vi.fn(() => ({})), + getRootOpts: vi.fn(() => ({ apiToken: "test-token" })), + }; +}); vi.mock("../../../src/common/auth.js", async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, resolveApiToken: vi.fn() }; + return { + ...actual, + resolveApiToken: vi.fn(), + resolveGraphqlTimeoutMs: vi.fn(actual.resolveGraphqlTimeoutMs), + }; }); +import { createInterface } from "node:readline"; import { setupAuthCommands } from "../../../src/commands/auth.js"; import { resolveApiToken } from "../../../src/common/auth.js"; +import { + configureGraphqlRequestTimeout, + createGraphQLClient, + getRootOpts, +} from "../../../src/common/context.js"; import { clearToken, saveToken } from "../../../src/common/token-storage.js"; import { validateToken } from "../../../src/services/auth-service.js"; @@ -44,6 +62,16 @@ const mockViewer = { email: "test@example.com", }; +const originalGraphqlTimeoutEnv = process.env["LINEAR_GRAPHQL_TIMEOUT_MS"]; + +afterEach(() => { + if (originalGraphqlTimeoutEnv === undefined) { + delete process.env["LINEAR_GRAPHQL_TIMEOUT_MS"]; + } else { + process.env["LINEAR_GRAPHQL_TIMEOUT_MS"] = originalGraphqlTimeoutEnv; + } +}); + function createProgram(): Command { const program = new Command(); program.option("--api-token "); @@ -57,6 +85,7 @@ describe("auth login", () => { beforeEach(() => { vi.clearAllMocks(); + vi.mocked(getRootOpts).mockReturnValue({ apiToken: "test-token" }); // Prevent process.exit from actually exiting exitSpy = vi .spyOn(process, "exit") @@ -85,6 +114,7 @@ describe("auth login", () => { expect(stderrSpy).toHaveBeenCalledWith( expect.stringContaining("Already authenticated as Test User"), ); + expect(createGraphQLClient).toHaveBeenCalledWith("existing-token"); expect(saveToken).not.toHaveBeenCalled(); }); @@ -122,6 +152,48 @@ describe("auth login", () => { expect(saveToken).toHaveBeenCalledWith("test-token"); }); + it.each([ + ["abc", "must be a positive integer"], + ["0", "must be a positive integer"], + ["2147483648", "must not exceed 2147483647"], + ])( + "reports invalid timeout env %s instead of treating the existing token as invalid", + async (raw, reason) => { + process.env["LINEAR_GRAPHQL_TIMEOUT_MS"] = raw; + vi.mocked(resolveApiToken).mockReturnValue({ + token: "existing-token", + source: "stored", + }); + vi.mocked(validateToken).mockResolvedValue(mockViewer); + + const program = createProgram(); + await program.parseAsync(["node", "test", "auth", "login"]); + + expect(stderrSpy).toHaveBeenCalledWith( + `Authentication failed: Invalid --graphql-timeout-ms: ${reason}`, + ); + expect(exitSpy).toHaveBeenCalledWith(1); + expect(createInterface).not.toHaveBeenCalled(); + expect(validateToken).not.toHaveBeenCalled(); + expect(saveToken).not.toHaveBeenCalled(); + }, + ); + + it("reports invalid timeout configuration before prompting without an existing token", async () => { + process.env["LINEAR_GRAPHQL_TIMEOUT_MS"] = "abc"; + + const program = createProgram(); + await program.parseAsync(["node", "test", "auth", "login"]); + + expect(stderrSpy).toHaveBeenCalledWith( + "Authentication failed: Invalid --graphql-timeout-ms: must be a positive integer", + ); + expect(exitSpy).toHaveBeenCalledWith(1); + expect(createInterface).not.toHaveBeenCalled(); + expect(validateToken).not.toHaveBeenCalled(); + expect(saveToken).not.toHaveBeenCalled(); + }); + it("bypasses existing token check with --force", async () => { vi.mocked(resolveApiToken).mockReturnValue({ token: "existing-token", @@ -168,11 +240,17 @@ describe("auth login", () => { describe("auth status", () => { let stdoutSpy: ReturnType; + let stderrSpy: ReturnType; + let exitSpy: ReturnType; beforeEach(() => { vi.clearAllMocks(); + vi.mocked(getRootOpts).mockReturnValue({ apiToken: "test-token" }); stdoutSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - vi.spyOn(process, "exit").mockImplementation(() => undefined as never); + stderrSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + exitSpy = vi + .spyOn(process, "exit") + .mockImplementation(() => undefined as never); }); it("reports authenticated with user info when token is valid", async () => { @@ -193,6 +271,27 @@ describe("auth status", () => { }); }); + it("configures the global timeout before token validation", async () => { + vi.mocked(getRootOpts).mockReturnValue({ + apiToken: "test-token", + graphqlTimeoutMs: 5000, + }); + vi.mocked(resolveApiToken).mockReturnValue({ + token: "valid-token", + source: "stored", + }); + vi.mocked(validateToken).mockResolvedValue(mockViewer); + + const program = createProgram(); + await program.parseAsync(["node", "test", "auth", "status"]); + + expect(configureGraphqlRequestTimeout).toHaveBeenCalledWith({ + apiToken: "test-token", + graphqlTimeoutMs: 5000, + }); + expect(createGraphQLClient).toHaveBeenCalledWith("valid-token"); + }); + it("reports unauthenticated when no token is found", async () => { vi.mocked(resolveApiToken).mockImplementation(() => { throw new Error("No API token found"); @@ -229,6 +328,35 @@ describe("auth status", () => { }); }); + it.each([ + ["abc", "must be a positive integer"], + ["0", "must be a positive integer"], + ["2147483648", "must not exceed 2147483647"], + ])( + "reports invalid timeout env %s instead of treating the token as invalid", + async (raw, reason) => { + process.env["LINEAR_GRAPHQL_TIMEOUT_MS"] = raw; + vi.mocked(resolveApiToken).mockReturnValue({ + token: "valid-token", + source: "stored", + }); + + const program = createProgram(); + await program.parseAsync(["node", "test", "auth", "status"]); + + expect(stderrSpy).toHaveBeenCalledWith( + JSON.stringify( + { error: `Invalid --graphql-timeout-ms: ${reason}` }, + null, + 2, + ), + ); + expect(exitSpy).toHaveBeenCalledWith(1); + expect(stdoutSpy).not.toHaveBeenCalled(); + expect(validateToken).not.toHaveBeenCalled(); + }, + ); + it("maps all token sources to human-readable labels", async () => { vi.mocked(validateToken).mockResolvedValue(mockViewer); diff --git a/tests/unit/common/auth.test.ts b/tests/unit/common/auth.test.ts index 07f96272..8a6b185e 100644 --- a/tests/unit/common/auth.test.ts +++ b/tests/unit/common/auth.test.ts @@ -10,7 +10,10 @@ vi.mock("../../../src/common/token-storage.js", () => ({ getStoredToken: vi.fn(), })); -import { getApiToken } from "../../../src/common/auth.js"; +import { + getApiToken, + resolveGraphqlTimeoutMs, +} from "../../../src/common/auth.js"; import { getStoredToken } from "../../../src/common/token-storage.js"; describe("getApiToken", () => { @@ -71,3 +74,30 @@ describe("getApiToken", () => { expect(() => getApiToken({})).toThrow("No API token found"); }); }); + +describe("resolveGraphqlTimeoutMs", () => { + const originalEnv = process.env["LINEAR_GRAPHQL_TIMEOUT_MS"]; + + afterEach(() => { + if (originalEnv === undefined) { + delete process.env["LINEAR_GRAPHQL_TIMEOUT_MS"]; + } else { + process.env["LINEAR_GRAPHQL_TIMEOUT_MS"] = originalEnv; + } + }); + + it("prefers the CLI option over the environment", () => { + process.env["LINEAR_GRAPHQL_TIMEOUT_MS"] = "9000"; + expect(resolveGraphqlTimeoutMs({ graphqlTimeoutMs: 5000 })).toBe(5000); + }); + + it("parses the environment value when no CLI option is set", () => { + process.env["LINEAR_GRAPHQL_TIMEOUT_MS"] = "9000"; + expect(resolveGraphqlTimeoutMs({})).toBe(9000); + }); + + it("uses the client default when neither source is set", () => { + delete process.env["LINEAR_GRAPHQL_TIMEOUT_MS"]; + expect(resolveGraphqlTimeoutMs({})).toBeUndefined(); + }); +}); diff --git a/tests/unit/common/context.test.ts b/tests/unit/common/context.test.ts index 53d5efcd..6055862e 100644 --- a/tests/unit/common/context.test.ts +++ b/tests/unit/common/context.test.ts @@ -1,6 +1,22 @@ import { Command } from "commander"; -import { describe, expect, it } from "vitest"; -import { getRootOpts } from "../../../src/common/context.js"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("../../../src/common/auth.js", async (importOriginal) => { + const actual = + await importOriginal(); + + return { + ...actual, + getApiToken: vi.fn(actual.getApiToken), + resolveGraphqlTimeoutMs: vi.fn(actual.resolveGraphqlTimeoutMs), + }; +}); + +import { + getApiToken, + resolveGraphqlTimeoutMs, +} from "../../../src/common/auth.js"; +import { createContext, getRootOpts } from "../../../src/common/context.js"; describe("getRootOpts", () => { it("returns root options for nested commands", () => { @@ -33,3 +49,18 @@ describe("getRootOpts", () => { ); }); }); + +describe("createContext", () => { + it("preserves missing-token errors before timeout configuration errors", () => { + const tokenError = new Error("No API token found"); + vi.mocked(getApiToken).mockImplementationOnce(() => { + throw tokenError; + }); + vi.mocked(resolveGraphqlTimeoutMs).mockImplementationOnce(() => { + throw new Error("Invalid --graphql-timeout-ms"); + }); + + expect(() => createContext({})).toThrow(tokenError); + expect(resolveGraphqlTimeoutMs).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/common/number-options.test.ts b/tests/unit/common/number-options.test.ts index e63a3c10..38ff7d95 100644 --- a/tests/unit/common/number-options.test.ts +++ b/tests/unit/common/number-options.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { parseEstimateOption, + parseGraphqlTimeoutOption, parsePriorityOption, } from "../../../src/common/number-options.js"; @@ -62,3 +63,21 @@ describe("parseEstimateOption", () => { ); }); }); + +describe("parseGraphqlTimeoutOption", () => { + it("parses a positive integer timeout", () => { + expect(parseGraphqlTimeoutOption("5000")).toBe(5000); + }); + + it.each(["0", "-1", "1.5", "abc"])("rejects invalid timeout %s", (raw) => { + expect(() => parseGraphqlTimeoutOption(raw)).toThrow( + "Invalid --graphql-timeout-ms: must be a positive integer", + ); + }); + + it("rejects timeout values that Node would clamp to one millisecond", () => { + expect(() => parseGraphqlTimeoutOption("2147483648")).toThrow( + "Invalid --graphql-timeout-ms: must not exceed 2147483647", + ); + }); +}); diff --git a/tests/unit/common/usage.test.ts b/tests/unit/common/usage.test.ts index a392d449..91ee7b5a 100644 --- a/tests/unit/common/usage.test.ts +++ b/tests/unit/common/usage.test.ts @@ -35,6 +35,9 @@ describe("formatOverview", () => { expect(result).toContain( "auth: linearis auth login | --api-token | LINEAR_API_TOKEN | ~/.linearis/token", ); + expect(result).toContain( + "graphql timeout: --graphql-timeout-ms | LINEAR_GRAPHQL_TIMEOUT_MS | default 30000", + ); expect(result).toContain("output: JSON"); expect(result).toContain("ids: UUID or human-readable"); expect(result).toContain("domains:"); From c1d06ff542b5d42c80dd79c67ee452e09e30f2f8 Mon Sep 17 00:00:00 2001 From: Charles Phillips Date: Fri, 7 Aug 2026 04:10:22 -0700 Subject: [PATCH 2/2] fix(auth): narrow existing-token lookup catch Once token lookup succeeds, downstream failures must reach the outer authentication error handler instead of being mistaken for a missing token. This prevents interactive login from continuing after a downstream failure. --- src/commands/auth.ts | 14 +++++++++----- tests/unit/commands/auth.test.ts | 23 +++++++++++++++++++++++ 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/src/commands/auth.ts b/src/commands/auth.ts index f3864158..bb857e0b 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -127,12 +127,18 @@ export function setupAuthCommands(program: Command): void { const rootOpts = getRootOpts(command) as CommandOptions; configureGraphqlRequestTimeout(rootOpts); if (!options.force) { + let existingToken: ReturnType | undefined; try { - const { token, source } = resolveApiToken(rootOpts); + existingToken = resolveApiToken(rootOpts); + } catch { + // No token found anywhere, proceed with login + } + + if (existingToken) { try { - const viewer = await validateApiToken(token); + const viewer = await validateApiToken(existingToken.token); console.error( - `Already authenticated as ${viewer.name} (${viewer.email}) via ${SOURCE_LABELS[source]}.`, + `Already authenticated as ${viewer.name} (${viewer.email}) via ${SOURCE_LABELS[existingToken.source]}.`, ); console.error("Run with --force to reauthenticate."); return; @@ -142,8 +148,6 @@ export function setupAuthCommands(program: Command): void { "Existing token is invalid. Starting new authentication...", ); } - } catch { - // No token found anywhere, proceed with login } } diff --git a/tests/unit/commands/auth.test.ts b/tests/unit/commands/auth.test.ts index 99dfa3c3..1355628a 100644 --- a/tests/unit/commands/auth.test.ts +++ b/tests/unit/commands/auth.test.ts @@ -152,6 +152,29 @@ describe("auth login", () => { expect(saveToken).toHaveBeenCalledWith("test-token"); }); + it("does not swallow failures after an existing token is resolved", async () => { + vi.mocked(resolveApiToken).mockReturnValue({ + token: "existing-token", + source: "stored", + }); + vi.mocked(validateToken).mockRejectedValue(new Error("Invalid token")); + stderrSpy + .mockImplementationOnce(() => { + throw new Error("stderr unavailable"); + }) + .mockImplementation(() => {}); + + const program = createProgram(); + await program.parseAsync(["node", "test", "auth", "login"]); + + expect(stderrSpy).toHaveBeenCalledWith( + "Authentication failed: stderr unavailable", + ); + expect(exitSpy).toHaveBeenCalledWith(1); + expect(createInterface).not.toHaveBeenCalled(); + expect(saveToken).not.toHaveBeenCalled(); + }); + it.each([ ["abc", "must be a positive integer"], ["0", "must be a positive integer"],