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
10 changes: 4 additions & 6 deletions src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ describe("root CLI preAction", () => {
errorSpy.mockRestore();
});

it("triggers auto-login for init before setup runs", async () => {
it("leaves init auth handling to the init command", async () => {
const errorSpy = spyOn(console, "error").mockImplementation(() => {});
const container = createLoginDeps({ hasValidToken: false });
const createContainer = mock(() => Promise.resolve(container));
Expand All @@ -257,11 +257,9 @@ describe("root CLI preAction", () => {
await program.parseAsync(["node", "githits", "init"]);

expect(ran).toBe(true);
expect(createContainer).toHaveBeenCalledTimes(1);
expect(loginFlow).toHaveBeenCalledWith({}, container);
expect(errorSpy.mock.calls.map((call) => call[0])).toEqual([
"Authentication complete. Continuing setup...",
]);
expect(createContainer).not.toHaveBeenCalled();
expect(loginFlow).not.toHaveBeenCalled();
expect(errorSpy).not.toHaveBeenCalled();
errorSpy.mockRestore();
});

Expand Down
26 changes: 21 additions & 5 deletions src/commands/auth-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,8 @@ export async function authStatusAction(
if (!auth) {
console.log("Not authenticated.\n");
console.log(` Environment: ${mcpUrl}\n`);
console.log("");
console.log("To authenticate:");
console.log(" githits login");
console.log(` Storage: ${authStorage.getStorageLocation()}\n`);
printAuthTroubleshooting();
return;
}

Expand All @@ -81,8 +80,8 @@ export async function authStatusAction(
console.log(
` Expired: ${new Date(auth.expiresAt).toLocaleDateString()}\n`,
);
console.log("");
console.log("Run `githits login` to re-authenticate.");
console.log(` Storage: ${authStorage.getStorageLocation()}\n`);
printAuthTroubleshooting("expired");
return;
}

Expand All @@ -92,6 +91,23 @@ export async function authStatusAction(
console.log(`\n Storage: ${authStorage.getStorageLocation()}`);
}

function printAuthTroubleshooting(reason: "missing" | "expired" = "missing") {
const loginCommand =
reason === "expired" ? "githits login --force" : "githits login";
console.log("Recovery steps:");
console.log(` ${loginCommand}`);
if (reason === "missing") {
console.log(" githits login --force # if a previous login is stale");
}
console.log("For CI/automation, set GITHITS_API_TOKEN.");
console.log(
"If your system keychain is locked or unavailable, unlock it and retry.",
);
console.log(
"As a last resort, set GITHITS_AUTH_STORAGE=file to use plaintext file storage.",
);
}

const STATUS_DESCRIPTION = `Show current authentication status.

Displays details about the stored token including environment
Expand Down
27 changes: 27 additions & 0 deletions src/commands/example.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,4 +81,31 @@ describe("exampleAction", () => {
),
).rejects.toThrow(AuthRequiredError);
});

it("prints JSON auth error for --json when auth is missing", async () => {
const errorSpy = spyOn(console, "error").mockImplementation(() => {});
const exitSpy = spyOn(process, "exit").mockImplementation(() => {
throw new Error("process.exit");
});

await expect(
exampleAction(
"test",
{ json: true },
createDeps({ hasValidToken: false }),
),
).rejects.toThrow("process.exit");

const output = errorSpy.mock.calls[0]?.[0] as string;
expect(JSON.parse(output)).toEqual({
error:
"Authentication required. Run `githits login`, then retry this command.",
code: "AUTH_REQUIRED",
retryable: false,
});
expect(exitSpy).toHaveBeenCalledWith(1);

errorSpy.mockRestore();
exitSpy.mockRestore();
});
});
30 changes: 29 additions & 1 deletion src/commands/example.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { type Command, Option } from "commander";
import type { GitHitsService } from "../services/githits-service.js";
import { AuthenticationError } from "../services/githits-service.js";
import { extractSolutionId } from "../shared/extract-solution-id.js";
import { AuthRequiredError, requireAuth } from "../shared/require-auth.js";

Expand All @@ -21,6 +22,15 @@ export async function exampleAction(
options: ExampleOptions,
deps: ExampleDependencies,
): Promise<void> {
if (!deps.hasValidToken && options.json) {
printExampleError(
"Authentication required. Run `githits login`, then retry this command.",
"AUTH_REQUIRED",
true,
);
process.exit(1);
}

requireAuth(deps);

try {
Expand All @@ -41,13 +51,31 @@ export async function exampleAction(
console.log(result);
}
} catch (error) {
console.error(
if (error instanceof AuthenticationError) {
printExampleError(
"Authentication required. Run `githits login`, then retry this command.",
"AUTH_REQUIRED",
options.json ?? false,
);
process.exit(1);
}
printExampleError(
`Failed to get example: ${error instanceof Error ? error.message : error}`,
"UNKNOWN",
options.json ?? false,
);
process.exit(1);
}
}

function printExampleError(error: string, code: string, json: boolean): void {
if (json) {
console.error(JSON.stringify({ error, code, retryable: false }));
return;
}
console.error(error);
}

const EXAMPLE_DESCRIPTION = `Get verified, canonical code examples from global open source.

For dependency, package, or repository source search, use \`githits search\` instead.
Expand Down
51 changes: 51 additions & 0 deletions src/commands/init/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -841,9 +841,60 @@ describe("initAction", () => {

const logCalls = getLogOutput();
expect(logCalls.some((msg) => msg.includes("Login failed"))).toBe(true);
expect(
logCalls.some((msg) => msg.includes("GitHits tools will require auth")),
).toBe(true);
expect(fs.atomicWriteFile).toHaveBeenCalled();
});

it("does not claim GitHits is ready after continuing without auth", async () => {
const fs = createFsWithDetection(["/home/test/.cursor"], {
"/home/test/.cursor/mcp.json": JSON.stringify({
mcpServers: {
GitHits: {
command: "npx",
args: ["-y", "githits@latest", "mcp", "start"],
},
},
}),
});
const promptService = createMockPromptService({
confirm3: mock(() => Promise.resolve("yes" as ConfirmChoice)),
});
const createLoginDeps = mock(() =>
Promise.resolve({
authService: createMockAuthService({
discoverEndpoints: mock(() =>
Promise.reject(new Error("Network error")),
),
}),
authStorage: createMockAuthStorage(),
browserService: createMockBrowserService(),
mcpUrl: "https://mcp.githits.com",
}),
);

await initAction(
{},
{
fileSystemService: fs,
promptService,
execService: createMockExecService(),
createLoginDeps,
},
);

const logCalls = getLogOutput();
expect(
logCalls.some((msg) =>
msg.includes("authentication is still required"),
),
).toBe(true);
expect(
logCalls.some((msg) => msg.includes("Done! GitHits is ready")),
).toBe(false);
});

it("cancels setup when login fails and user declines to continue", async () => {
const fs = createFsWithDetection(["/home/test/.cursor"]);
const promptService = createMockPromptService({
Expand Down
26 changes: 26 additions & 0 deletions src/commands/init/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ export async function initAction(
const useColors = shouldUseColors();
const { fileSystemService, promptService, execService, createLoginDeps } =
deps;
let continuedWithoutAuth = false;

// Header
console.log(
Expand All @@ -109,6 +110,7 @@ export async function initAction(
console.log(
` ${warning(`Login failed: ${loginResult.message}`, useColors)}\n`,
);
printAuthRecoveryHint();
if (!options.yes) {
try {
const choice = await promptService.confirm3(
Expand All @@ -128,6 +130,7 @@ export async function initAction(
throw err;
}
}
continuedWithoutAuth = true;
console.log(" Continuing without authentication...\n");
}
}
Expand Down Expand Up @@ -168,6 +171,13 @@ export async function initAction(

// All detected agents already configured
if (scan.needsSetup.length === 0) {
if (continuedWithoutAuth) {
console.log(
" MCP is already configured, but authentication is still required.",
);
console.log(" Run `githits login` before using GitHits tools.\n");
return;
}
console.log(
" All detected agents are already configured. Nothing to do.\n",
);
Expand Down Expand Up @@ -267,6 +277,9 @@ export async function initAction(

if (failed > 0) {
console.log(" Setup completed with errors.");
} else if (continuedWithoutAuth && (configured > 0 || alreadyDone > 0)) {
console.log(" MCP is configured, but authentication is still required.");
console.log(" Run `githits login` before using GitHits tools.");
} else if (configured > 0 || alreadyDone > 0) {
console.log(" Done! GitHits is ready.");
} else if (skipped > 0) {
Expand All @@ -290,6 +303,19 @@ export async function initAction(
console.log();
}

function printAuthRecoveryHint(): void {
console.log(
" You can still configure MCP, but GitHits tools will require auth.",
);
console.log(" Recovery steps:");
console.log(" githits auth status");
console.log(" githits login --force");
console.log(" For CI or locked-down machines, set GITHITS_API_TOKEN.");
console.log(
" If your system keychain is unavailable, set GITHITS_AUTH_STORAGE=file after accepting plaintext storage.\n",
);
}

const INIT_DESCRIPTION = `Set up GitHits MCP server for your coding agents.

Authenticates with your GitHits account, then scans for available agents
Expand Down
80 changes: 80 additions & 0 deletions src/commands/login.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,86 @@ describe("loginFlow", () => {
consoleSpy.mockRestore();
});

it("prints manual URL when browser opening fails", async () => {
const writes: string[] = [];
const browserService = createMockBrowserService({
open: mock(() => Promise.reject(new Error("no display"))),
});

const result = await loginFlow(
{ port: 8080 },
{
authService: createMockAuthService(),
authStorage: createMockAuthStorage(),
browserService,
mcpUrl,
},
{ write: (message: string) => writes.push(message) },
);

expect(result.status).toBe("success");
expect(writes).toContain(
"Could not open browser automatically: no display\n",
);
expect(writes).toContain("Open this URL in your browser:\n");
expect(writes).toContain(" http://example.com/auth\n");
});

it("closes callback server when authentication wait fails", async () => {
const close = mock(() => Promise.resolve());
const authService = createMockAuthService({
startCallbackServer: mock(() =>
Promise.resolve({
result: Promise.reject(new Error("callback failed")),
close,
}),
),
});

const result = await loginFlow(
{ port: 8080 },
{
authService,
authStorage: createMockAuthStorage(),
browserService: createMockBrowserService(),
mcpUrl,
},
silentLoginOutput,
);

expect(result.status).toBe("failed");
expect(result.message).toBe("callback failed.");
expect(close).toHaveBeenCalledTimes(1);
});

it("does not open browser when callback server cannot start", async () => {
const browserService = createMockBrowserService();
const authService = createMockAuthService({
startCallbackServer: mock(() =>
Promise.reject(
new Error("Failed to start callback server: EADDRINUSE"),
),
),
});

const result = await loginFlow(
{ port: 8080 },
{
authService,
authStorage: createMockAuthStorage(),
browserService,
mcpUrl,
},
silentLoginOutput,
);

expect(result).toEqual({
status: "failed",
message: "Failed to start callback server: EADDRINUSE",
});
expect(browserService.open).not.toHaveBeenCalled();
});

it("returns already_authenticated when valid tokens exist", async () => {
const consoleSpy = spyOn(console, "log").mockImplementation(() => {});

Expand Down
Loading
Loading