Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 133 additions & 1 deletion src/commands/auth-status.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -143,3 +144,134 @@ describe("authStatusAction", () => {
consoleSpy.mockRestore();
});
});

describe("authTokenAction", () => {
const mcpUrl = "https://mcp.githits.com";

function createDeps(
overrides: {
authStorage?: ReturnType<typeof createMockAuthStorage>;
authService?: ReturnType<typeof createMockAuthService>;
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();
});
});
62 changes: 62 additions & 0 deletions src/commands/auth-status.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -11,6 +12,10 @@ export interface AuthStatusDependencies {
envApiToken: string | undefined;
}

export interface AuthTokenOutput {
write(text: string): void;
}

/**
* Display token expiry information.
*/
Expand Down Expand Up @@ -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<void> {
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";
Expand All @@ -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.
Expand All @@ -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);
});
}
1 change: 1 addition & 0 deletions src/commands/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export {
type AuthStatusDependencies,
authStatusAction,
authTokenAction,
registerAuthStatusCommand,
} from "./auth-status.js";
export { registerCodeCommandGroup } from "./code/index.js";
Expand Down
1 change: 1 addition & 0 deletions src/shared/auto-login.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ describe("isAutoLoginEligibleCommand", () => {
["login"],
["logout"],
["auth", "status"],
["auth", "token"],
["mcp"],
["mcp", "start"],
]) {
Expand Down