diff --git a/src/commands/auth-status.test.ts b/src/commands/auth-status.test.ts index 78552f39..2c9ad5e4 100644 --- a/src/commands/auth-status.test.ts +++ b/src/commands/auth-status.test.ts @@ -1,11 +1,12 @@ import { describe, expect, it, mock, spyOn } from "bun:test"; +import { AuthRequiredError } from "@githits/mcp/internal"; import { createMockAuthService, createMockAuthStorage, createValidTokenData, defaultClientRegistration, } from "../services/test-helpers.js"; -import { authStatusAction } from "./auth-status.js"; +import { authStatusAction, authTokenAction } from "./auth-status.js"; describe("authStatusAction", () => { const mcpUrl = "https://mcp.githits.com"; @@ -143,3 +144,134 @@ describe("authStatusAction", () => { consoleSpy.mockRestore(); }); }); + +describe("authTokenAction", () => { + const mcpUrl = "https://mcp.githits.com"; + + function createDeps( + overrides: { + authStorage?: ReturnType; + authService?: ReturnType; + envApiToken?: string; + } = {}, + ) { + return { + authStorage: overrides.authStorage ?? createMockAuthStorage(), + authService: overrides.authService ?? createMockAuthService(), + mcpUrl, + envApiToken: overrides.envApiToken ?? undefined, + }; + } + + function createOutput() { + const chunks: string[] = []; + return { + output: { write: mock((text: string) => chunks.push(text)) }, + chunks, + }; + } + + it("prints the env token without reading local storage", async () => { + const authStorage = createMockAuthStorage(); + const { output, chunks } = createOutput(); + + await authTokenAction( + createDeps({ authStorage, envApiToken: "ghi-env-token" }), + output, + ); + + expect(chunks).toEqual(["ghi-env-token\n"]); + expect(authStorage.loadTokens).not.toHaveBeenCalled(); + }); + + it("prints a stored unexpired token", async () => { + const authStorage = createMockAuthStorage({ + loadTokens: mock(() => + Promise.resolve( + createValidTokenData({ + accessToken: "ghi-stored-token", + expiresAt: new Date(Date.now() + 3600_000).toISOString(), + }), + ), + ), + }); + const authService = createMockAuthService(); + const { output, chunks } = createOutput(); + + await authTokenAction(createDeps({ authStorage, authService }), output); + + expect(chunks).toEqual(["ghi-stored-token\n"]); + expect(authService.refreshAccessToken).not.toHaveBeenCalled(); + }); + + it("prints a stored token without expiry", async () => { + const authStorage = createMockAuthStorage({ + loadTokens: mock(() => + Promise.resolve( + createValidTokenData({ + accessToken: "ghi-never-expiring", + expiresAt: null, + }), + ), + ), + }); + const { output, chunks } = createOutput(); + + await authTokenAction(createDeps({ authStorage }), output); + + expect(chunks).toEqual(["ghi-never-expiring\n"]); + }); + + it("refreshes and prints an expired token", async () => { + const expiredToken = createValidTokenData({ + accessToken: "ghi-expired-token", + expiresAt: new Date(Date.now() - 3600_000).toISOString(), + }); + const authStorage = createMockAuthStorage({ + loadTokens: mock(() => Promise.resolve(expiredToken)), + loadClient: mock(() => Promise.resolve(defaultClientRegistration)), + }); + const authService = createMockAuthService({ + refreshAccessToken: mock(() => + Promise.resolve({ + accessToken: "ghi-refreshed-token", + refreshToken: "refresh-token", + expiresIn: 3600, + }), + ), + }); + const { output, chunks } = createOutput(); + + await authTokenAction(createDeps({ authStorage, authService }), output); + + expect(chunks).toEqual(["ghi-refreshed-token\n"]); + expect(authService.refreshAccessToken).toHaveBeenCalledTimes(1); + }); + + it("throws AuthRequiredError when unauthenticated", async () => { + const { output } = createOutput(); + + await expect(authTokenAction(createDeps(), output)).rejects.toBeInstanceOf( + AuthRequiredError, + ); + expect(output.write).not.toHaveBeenCalled(); + }); + + it("throws AuthRequiredError when expired token cannot refresh", async () => { + const authStorage = createMockAuthStorage({ + loadTokens: mock(() => + Promise.resolve( + createValidTokenData({ + expiresAt: new Date(Date.now() - 3600_000).toISOString(), + }), + ), + ), + }); + const { output } = createOutput(); + + await expect( + authTokenAction(createDeps({ authStorage }), output), + ).rejects.toBeInstanceOf(AuthRequiredError); + expect(output.write).not.toHaveBeenCalled(); + }); +}); diff --git a/src/commands/auth-status.ts b/src/commands/auth-status.ts index a495115c..1ed6803b 100644 --- a/src/commands/auth-status.ts +++ b/src/commands/auth-status.ts @@ -1,3 +1,4 @@ +import { AuthRequiredError } from "@githits/mcp/internal"; import type { Command } from "commander"; import { createAuthStatusDependencies } from "../container.js"; import type { AuthService } from "../services/auth-service.js"; @@ -11,6 +12,10 @@ export interface AuthStatusDependencies { envApiToken: string | undefined; } +export interface AuthTokenOutput { + write(text: string): void; +} + /** * Display token expiry information. */ @@ -92,6 +97,47 @@ export async function authStatusAction( console.log(`\n Storage: ${authStorage.getStorageLocation()}`); } +/** + * Print the currently usable access token for command substitution. + */ +export async function authTokenAction( + deps: AuthStatusDependencies, + output: AuthTokenOutput = process.stdout, +): Promise { + const { authStorage, authService, mcpUrl, envApiToken } = deps; + + if (envApiToken) { + output.write(`${envApiToken}\n`); + return; + } + + const auth = await authStorage.loadTokens(mcpUrl); + if (!auth) { + throw new AuthRequiredError( + "Authentication required to print token.", + mcpUrl, + ); + } + + if (auth.expiresAt && new Date(auth.expiresAt) <= new Date()) { + const refreshed = await refreshExpiredToken( + authService, + authStorage, + mcpUrl, + ); + if (!refreshed) { + throw new AuthRequiredError( + "Authentication required to print token.", + mcpUrl, + ); + } + output.write(`${refreshed}\n`); + return; + } + + output.write(`${auth.accessToken}\n`); +} + function printAuthTroubleshooting(reason: "missing" | "expired" = "missing") { const loginCommand = reason === "expired" ? "githits login --force" : "githits login"; @@ -115,6 +161,13 @@ Displays details about the stored token including environment and expiration. If GITHITS_API_TOKEN is set, reports that source without reading local OAuth storage. Useful for debugging authentication issues.`; +const TOKEN_DESCRIPTION = `Print the current access token. + +Writes only the bearer token to stdout so command substitution stays clean. +If the stored token is expired, refreshes it using the saved OAuth client. +Fails non-zero instead of starting an interactive login when no token is available. +If GITHITS_API_TOKEN is set, prints that value without reading local OAuth storage.`; + /** * Register the auth status command on the given program. * Uses lazy container creation so \`--help\` doesn't trigger auth. @@ -128,4 +181,13 @@ export function registerAuthStatusCommand(program: Command) { const deps = await createAuthStatusDependencies(); await authStatusAction(deps); }); + + program + .command("token") + .summary("Print current access token") + .description(TOKEN_DESCRIPTION) + .action(async () => { + const deps = await createAuthStatusDependencies(); + await authTokenAction(deps); + }); } diff --git a/src/commands/index.ts b/src/commands/index.ts index 52db97d2..6179aa32 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -1,6 +1,7 @@ export { type AuthStatusDependencies, authStatusAction, + authTokenAction, registerAuthStatusCommand, } from "./auth-status.js"; export { registerCodeCommandGroup } from "./code/index.js"; diff --git a/src/shared/auto-login.test.ts b/src/shared/auto-login.test.ts index 79ac646f..0d5192ef 100644 --- a/src/shared/auto-login.test.ts +++ b/src/shared/auto-login.test.ts @@ -143,6 +143,7 @@ describe("isAutoLoginEligibleCommand", () => { ["login"], ["logout"], ["auth", "status"], + ["auth", "token"], ["mcp"], ["mcp", "start"], ]) {