diff --git a/src/cli/commands/auth-command.test.ts b/src/cli/commands/auth-command.test.ts index da98c4933..ce910c21a 100644 --- a/src/cli/commands/auth-command.test.ts +++ b/src/cli/commands/auth-command.test.ts @@ -36,18 +36,8 @@ async function storeApiKey(fs: FileSystem, apiKey: string): Promise { await container.writeApiKey(apiKey); } -function createWhoamiResponse(overrides?: Partial<{ - user_id: number; - handle: string; - name: string; - profile_picture: string; -}>) { - return { - user_id: overrides?.user_id ?? 12345, - handle: overrides?.handle ?? "testuser", - name: overrides?.name ?? "Test User", - profile_picture: overrides?.profile_picture ?? "https://example.com/pic.jpg" - }; +function createBalanceResponse(balance = 8_432) { + return { current_point_balance: balance }; } describe("auth command", () => { @@ -65,7 +55,9 @@ describe("auth command", () => { spinnerStopMessages.length = 0; spinnerMock.mockReturnValue({ start: vi.fn(), - stop: (msg: string) => { spinnerStopMessages.push(msg); } + stop: (msg: string) => { + spinnerStopMessages.push(msg); + } }); }); @@ -77,13 +69,13 @@ describe("auth command", () => { } }); - it("shows logged-in identity from whoami endpoint", async () => { + it("shows logged in after checking the usage endpoint", async () => { await storeApiKey(fs, "test-key"); (httpClient as ReturnType).mockResolvedValue({ ok: true, status: 200, - json: async () => createWhoamiResponse({ name: "Kamil Jopek", handle: "kamil" }) + json: async () => createBalanceResponse() }); const program = createProgram({ @@ -98,22 +90,22 @@ describe("auth command", () => { await program.parseAsync(["node", "cli", "auth", "status"]); expect(httpClient).toHaveBeenCalledWith( - expect.stringContaining("/whoami"), + expect.stringContaining("/usage/current_balance"), expect.objectContaining({ - method: "POST", + method: "GET", headers: expect.objectContaining({ Authorization: "Bearer test-key" }) }) ); - expect(spinnerStopMessages.some((m) => m.includes("Logged in as Kamil Jopek (@kamil)"))).toBe(true); + expect(spinnerStopMessages).toContain("Logged in"); }); - it("shows logged-in identity from POE_API_KEY without stored credentials", async () => { + it("checks POE_API_KEY without stored credentials", async () => { (httpClient as ReturnType).mockResolvedValue({ ok: true, status: 200, - json: async () => createWhoamiResponse({ name: "Environment User", handle: "environment" }) + json: async () => createBalanceResponse(500) }); const program = createProgram({ @@ -128,10 +120,12 @@ describe("auth command", () => { await program.parseAsync(["node", "cli", "auth", "status"]); expect(httpClient).toHaveBeenCalledWith( - expect.stringContaining("/whoami"), - expect.objectContaining({ headers: expect.objectContaining({ Authorization: "Bearer environment-key" }) }) + expect.stringContaining("/usage/current_balance"), + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: "Bearer environment-key" }) + }) ); - expect(spinnerStopMessages.some((message) => message.includes("Environment User (@environment)"))).toBe(true); + expect(spinnerStopMessages).toContain("Logged in"); }); it("shows not logged in when no API key exists", async () => { @@ -186,7 +180,7 @@ describe("auth command", () => { await expect(fs.readdir(`${homeDir}/.poe-code`)).resolves.toEqual(["credentials.enc"]); }); - it("throws ApiError when whoami request fails", async () => { + it("throws ApiError when the credential check fails", async () => { await storeApiKey(fs, "test-key"); (httpClient as ReturnType).mockResolvedValue({ @@ -204,9 +198,9 @@ describe("auth command", () => { }); vi.spyOn(program, "optsWithGlobals").mockReturnValue({ yes: false, dryRun: false } as any); - await expect( - program.parseAsync(["node", "cli", "auth", "status"]) - ).rejects.toBeInstanceOf(ApiError); + await expect(program.parseAsync(["node", "cli", "auth", "status"])).rejects.toBeInstanceOf( + ApiError + ); }); it("runs status when auth is invoked without subcommand", async () => { @@ -215,7 +209,7 @@ describe("auth command", () => { (httpClient as ReturnType).mockResolvedValue({ ok: true, status: 200, - json: async () => createWhoamiResponse({ name: "Test User", handle: "testuser" }) + json: async () => createBalanceResponse() }); const program = createProgram({ @@ -230,10 +224,10 @@ describe("auth command", () => { await program.parseAsync(["node", "cli", "auth"]); expect(httpClient).toHaveBeenCalledWith( - expect.stringContaining("/whoami"), + expect.stringContaining("/usage/current_balance"), expect.any(Object) ); - expect(spinnerStopMessages.some((m) => m.includes("Logged in as Test User (@testuser)"))).toBe(true); + expect(spinnerStopMessages).toContain("Logged in"); }); it("shows feedback outro after status output", async () => { @@ -242,7 +236,7 @@ describe("auth command", () => { (httpClient as ReturnType).mockResolvedValue({ ok: true, status: 200, - json: async () => createWhoamiResponse() + json: async () => createBalanceResponse() }); const program = createProgram({ @@ -280,7 +274,7 @@ describe("auth command", () => { (httpClient as ReturnType).mockResolvedValue({ ok: true, status: 200, - json: async () => createWhoamiResponse({ user_id: 7, name: "Kamil Jopek", handle: "kamil" }) + json: async () => createBalanceResponse() }); const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); @@ -299,16 +293,20 @@ describe("auth command", () => { const written = stdoutSpy.mock.calls.map((call) => call[0]).join(""); stdoutSpy.mockRestore(); - expect(JSON.parse(written)).toEqual({ - loggedIn: true, - identity: createWhoamiResponse({ user_id: 7, name: "Kamil Jopek", handle: "kamil" }) - }); + expect(JSON.parse(written)).toEqual({ loggedIn: true }); expect(logs).toEqual([]); expect(spinnerStopMessages).toEqual([]); }); - it("reports logged-out state as JSON with auth status --json", async () => { - const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + it("reports logged in when whoami rejects a valid external user's key", async () => { + await storeApiKey(fs, "valid-external-key"); + + (httpClient as ReturnType).mockImplementation(async (url: string, init) => { + if (url !== "https://api.poe.com/usage/current_balance" || init?.method !== "GET") { + throw new Error(`Unexpected request: ${init?.method} ${url}`); + } + return { ok: true, status: 200, json: async () => createBalanceResponse(1_250) }; + }); const program = createProgram({ fs, @@ -319,18 +317,13 @@ describe("auth command", () => { }); vi.spyOn(program, "optsWithGlobals").mockReturnValue({ yes: false, dryRun: false } as any); - await program.parseAsync(["node", "cli", "auth", "status", "--json"]); - - const written = stdoutSpy.mock.calls.map((call) => call[0]).join(""); - stdoutSpy.mockRestore(); + await program.parseAsync(["node", "cli", "auth", "status"]); - expect(httpClient).not.toHaveBeenCalled(); - expect(JSON.parse(written)).toEqual({ loggedIn: false }); - expect(logs).toEqual([]); + expect(httpClient).toHaveBeenCalledOnce(); + expect(spinnerStopMessages).toContain("Logged in"); }); - it("does not fetch identity for auth status --json while previewing", async () => { - await storeApiKey(fs, "test-key"); + it("reports logged-out state as JSON with auth status --json", async () => { const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); const program = createProgram({ @@ -338,30 +331,22 @@ describe("auth command", () => { prompts: vi.fn(), env: { cwd, homeDir }, httpClient, - logger: (message) => logs.push(message), - exitOverride: true + logger: (message) => logs.push(message) }); - vi.spyOn(program, "optsWithGlobals").mockReturnValue({ yes: false, dryRun: true } as any); + vi.spyOn(program, "optsWithGlobals").mockReturnValue({ yes: false, dryRun: false } as any); - await program.parseAsync(["node", "cli", "--dry-run", "auth", "status", "--json"]); + await program.parseAsync(["node", "cli", "auth", "status", "--json"]); const written = stdoutSpy.mock.calls.map((call) => call[0]).join(""); stdoutSpy.mockRestore(); expect(httpClient).not.toHaveBeenCalled(); - expect(JSON.parse(written)).toEqual({ loggedIn: true, dryRun: true }); + expect(JSON.parse(written)).toEqual({ loggedIn: false }); expect(logs).toEqual([]); }); - it("accepts --json on auth whoami and prints the identity", async () => { - await storeApiKey(fs, "stored-key"); - - (httpClient as ReturnType).mockResolvedValue({ - ok: true, - status: 200, - json: async () => createWhoamiResponse({ handle: "kamil" }) - }); - + it("does not fetch identity for auth status --json while previewing", async () => { + await storeApiKey(fs, "test-key"); const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); const program = createProgram({ @@ -372,13 +357,16 @@ describe("auth command", () => { logger: (message) => logs.push(message), exitOverride: true }); + vi.spyOn(program, "optsWithGlobals").mockReturnValue({ yes: false, dryRun: true } as any); - await program.parseAsync(["node", "cli", "auth", "whoami", "--json"]); + await program.parseAsync(["node", "cli", "--dry-run", "auth", "status", "--json"]); const written = stdoutSpy.mock.calls.map((call) => call[0]).join(""); stdoutSpy.mockRestore(); - expect(JSON.parse(written)).toEqual(createWhoamiResponse({ handle: "kamil" })); + expect(httpClient).not.toHaveBeenCalled(); + expect(JSON.parse(written)).toEqual({ loggedIn: true, dryRun: true }); + expect(logs).toEqual([]); }); it("documents --json on auth status help", async () => { @@ -390,8 +378,8 @@ describe("auth command", () => { logger: (message) => logs.push(message) }); - const statusCommand = program - .commands.find((command) => command.name() === "auth") + const statusCommand = program.commands + .find((command) => command.name() === "auth") ?.commands.find((command) => command.name() === "status"); expect(statusCommand?.helpInformation()).toContain("--json"); @@ -525,8 +513,8 @@ describe("auth command", () => { logger: (message) => logs.push(message) }); - const apiKeyCommand = program - .commands.find((command) => command.name() === "auth") + const apiKeyCommand = program.commands + .find((command) => command.name() === "auth") ?.commands.find((command) => command.name() === "api-key"); expect(apiKeyCommand?.description().toLowerCase()).toContain("danger"); @@ -542,8 +530,8 @@ describe("auth command", () => { logger: (message) => logs.push(message) }); - const authLogout = program - .commands.find((command) => command.name() === "auth") + const authLogout = program.commands + .find((command) => command.name() === "auth") ?.commands.find((command) => command.name() === "logout"); const rootLogout = program.commands.find((command) => command.name() === "logout"); @@ -563,8 +551,8 @@ describe("auth command", () => { logger: (message) => logs.push(message) }); - const loginCommand = program - .commands.find((command) => command.name() === "auth") + const loginCommand = program.commands + .find((command) => command.name() === "auth") ?.commands.find((command) => command.name() === "login"); const help = loginCommand?.helpInformation() ?? ""; @@ -587,294 +575,4 @@ describe("auth command", () => { expect(process.exitCode).toBe(1); process.exitCode = 0; }); - - it("prints whoami identity as JSON using stored API key", async () => { - await storeApiKey(fs, "stored-key"); - - (httpClient as ReturnType).mockResolvedValue({ - ok: true, - status: 200, - json: async () => - createWhoamiResponse({ - user_id: 42, - handle: "kamil", - name: "Kamil Jopek", - profile_picture: "https://example.com/k.jpg" - }) - }); - - const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); - - const program = createProgram({ - fs, - prompts: vi.fn(), - env: { cwd, homeDir }, - httpClient, - logger: (message) => logs.push(message) - }); - - await program.parseAsync(["node", "cli", "auth", "whoami"]); - - expect(httpClient).toHaveBeenCalledWith( - expect.stringContaining("/whoami"), - expect.objectContaining({ - method: "POST", - headers: expect.objectContaining({ Authorization: "Bearer stored-key" }) - }) - ); - - const written = stdoutSpy.mock.calls.map((c) => c[0]).join(""); - expect(written.endsWith("\n")).toBe(true); - const parsed = JSON.parse(written); - expect(parsed).toEqual({ - user_id: 42, - handle: "kamil", - name: "Kamil Jopek", - profile_picture: "https://example.com/k.jpg" - }); - stdoutSpy.mockRestore(); - }); - - it("does not request identity while previewing auth whoami", async () => { - const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); - const program = createProgram({ - fs, - prompts: vi.fn(), - env: { cwd, homeDir, variables: { POE_API_KEY: "env-key" } }, - httpClient, - logger: (message) => logs.push(message) - }); - - await program.parseAsync(["node", "cli", "--dry-run", "auth", "whoami"]); - - expect(httpClient).not.toHaveBeenCalled(); - expect(stdoutSpy).not.toHaveBeenCalled(); - expect(logs).toContain("Dry run: would fetch identity from Poe API."); - stdoutSpy.mockRestore(); - }); - - it("sets exit code 1 when dry-run whoami has no API key", async () => { - const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); - const program = createProgram({ - fs, - prompts: vi.fn(), - env: { cwd, homeDir, variables: {} }, - httpClient, - logger: (message) => logs.push(message) - }); - - process.exitCode = 0; - await program.parseAsync(["node", "cli", "--dry-run", "auth", "whoami"]); - - expect(httpClient).not.toHaveBeenCalled(); - expect(stdoutSpy).not.toHaveBeenCalled(); - expect(logs).not.toContain("Dry run: would fetch identity from Poe API."); - expect(process.exitCode).toBe(1); - process.exitCode = 0; - stdoutSpy.mockRestore(); - }); - - it("prefers POE_API_KEY env var over stored key for whoami", async () => { - await storeApiKey(fs, "stored-key"); - - (httpClient as ReturnType).mockResolvedValue({ - ok: true, - status: 200, - json: async () => createWhoamiResponse({ name: "Env User", handle: "env" }) - }); - - const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); - - const program = createProgram({ - fs, - prompts: vi.fn(), - env: { - cwd, - homeDir, - variables: { POE_API_KEY: "env-key" } - }, - httpClient, - logger: (message) => logs.push(message) - }); - - await program.parseAsync(["node", "cli", "auth", "whoami"]); - - expect(httpClient).toHaveBeenCalledWith( - expect.stringContaining("/whoami"), - expect.objectContaining({ - headers: expect.objectContaining({ Authorization: "Bearer env-key" }) - }) - ); - stdoutSpy.mockRestore(); - }); - - it("uses POE_API_KEY env var when no stored key exists", async () => { - (httpClient as ReturnType).mockResolvedValue({ - ok: true, - status: 200, - json: async () => createWhoamiResponse({ name: "Env Only", handle: "envonly" }) - }); - - const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); - - const program = createProgram({ - fs, - prompts: vi.fn(), - env: { - cwd, - homeDir, - variables: { POE_API_KEY: "env-only-key" } - }, - httpClient, - logger: (message) => logs.push(message) - }); - - await program.parseAsync(["node", "cli", "auth", "whoami"]); - - expect(httpClient).toHaveBeenCalledWith( - expect.stringContaining("/whoami"), - expect.objectContaining({ - headers: expect.objectContaining({ Authorization: "Bearer env-only-key" }) - }) - ); - stdoutSpy.mockRestore(); - }); - - it("uses configured Poe API base URL for whoami", async () => { - await storeApiKey(fs, "stored-key"); - - (httpClient as ReturnType).mockResolvedValue({ - ok: true, - status: 200, - json: async () => createWhoamiResponse({ name: "Proxy User", handle: "proxy" }) - }); - - const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); - - const program = createProgram({ - fs, - prompts: vi.fn(), - env: { - cwd, - homeDir, - variables: { POE_BASE_URL: "https://proxy.example.com" } - }, - httpClient, - logger: (message) => logs.push(message) - }); - - await program.parseAsync(["node", "cli", "auth", "whoami"]); - - expect(httpClient).toHaveBeenCalledWith( - "https://proxy.example.com/v1/whoami", - expect.objectContaining({ - headers: expect.objectContaining({ Authorization: "Bearer stored-key" }) - }) - ); - stdoutSpy.mockRestore(); - }); - - it("sets exit code 1 when no API key is available for whoami", async () => { - const program = createProgram({ - fs, - prompts: vi.fn(), - env: { cwd, homeDir, variables: {} }, - httpClient, - logger: (message) => logs.push(message) - }); - - process.exitCode = 0; - await program.parseAsync(["node", "cli", "auth", "whoami"]); - - expect(httpClient).not.toHaveBeenCalled(); - expect(process.exitCode).toBe(1); - process.exitCode = 0; - }); - - it("throws ApiError when whoami request fails", async () => { - await storeApiKey(fs, "stored-key"); - - (httpClient as ReturnType).mockResolvedValue({ - ok: false, - status: 401, - json: async () => ({}) - }); - - const program = createProgram({ - fs, - prompts: vi.fn(), - env: { cwd, homeDir }, - httpClient, - logger: (message) => logs.push(message) - }); - - await expect( - program.parseAsync(["node", "cli", "auth", "whoami"]) - ).rejects.toBeInstanceOf(ApiError); - }); - - it("registers whoami at the root next to login and logout", () => { - const program = createProgram({ - fs, - prompts: vi.fn(), - env: { cwd, homeDir }, - httpClient, - logger: (message) => logs.push(message) - }); - - const whoami = program.commands.find((command) => command.name() === "whoami"); - - expect(whoami?.description()).toBe( - "Print Poe account identity as JSON (uses POE_API_KEY if set)." - ); - }); - - it("prints whoami identity as JSON from the root command", async () => { - await storeApiKey(fs, "stored-key"); - - (httpClient as ReturnType).mockResolvedValue({ - ok: true, - status: 200, - json: async () => createWhoamiResponse({ user_id: 42, handle: "kamil" }) - }); - - const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); - - const program = createProgram({ - fs, - prompts: vi.fn(), - env: { cwd, homeDir }, - httpClient, - logger: (message) => logs.push(message) - }); - - await program.parseAsync(["node", "cli", "whoami"]); - - expect(httpClient).toHaveBeenCalledWith( - expect.stringContaining("/whoami"), - expect.objectContaining({ - headers: expect.objectContaining({ Authorization: "Bearer stored-key" }) - }) - ); - expect(JSON.parse(stdoutSpy.mock.calls.map((call) => call[0]).join(""))).toMatchObject({ - user_id: 42, - handle: "kamil" - }); - stdoutSpy.mockRestore(); - }); - - it("honours --dry-run on root whoami", async () => { - const program = createProgram({ - fs, - prompts: vi.fn(), - env: { cwd, homeDir, variables: { POE_API_KEY: "env-key" } }, - httpClient, - logger: (message) => logs.push(message) - }); - - await program.parseAsync(["node", "cli", "--dry-run", "whoami"]); - - expect(httpClient).not.toHaveBeenCalled(); - expect(logs).toContain("Dry run: would fetch identity from Poe API."); - }); }); diff --git a/src/cli/commands/auth.ts b/src/cli/commands/auth.ts index 24e9632df..4f72767d4 100644 --- a/src/cli/commands/auth.ts +++ b/src/cli/commands/auth.ts @@ -4,7 +4,7 @@ import { spinner } from "toolcraft-design"; import { apiKeyFlagDescription, createExecutionResources, resolveCommandFlags } from "./shared.js"; import { executeLogin, type LoginCommandOptions } from "./login.js"; import { executeLogout, logoutScopeDescription } from "./logout.js"; -import { fetchPoeAuthIdentity } from "../../sdk/credentials.js"; +import { checkPoeAuth } from "../../sdk/auth-check.js"; import { jsonOptionDescription, writeJson, type JsonCommandOptions } from "./json-output.js"; interface ApiKeyCommandOptions { @@ -44,9 +44,6 @@ export function registerAuthCommand(program: Command, container: CliContainer): await executeApiKey(program, container, options); }); - registerWhoamiCommand(auth, program, container); - registerWhoamiCommand(program, program, container); - auth .command("login") .description("Store a Poe API key for reuse across commands.") @@ -65,17 +62,6 @@ export function registerAuthCommand(program: Command, container: CliContainer): }); } -/** Declared once for both `poe-code whoami` and `poe-code auth whoami`, which must not drift. */ -function registerWhoamiCommand(parent: Command, program: Command, container: CliContainer): void { - parent - .command("whoami") - .description("Print Poe account identity as JSON (uses POE_API_KEY if set).") - .option("--json", "Accepted for consistency with other commands; whoami always prints JSON.") - .action(async () => { - await executeWhoami(program, container); - }); -} - async function executeStatus( program: Command, container: CliContainer, @@ -102,7 +88,7 @@ async function executeStatus( } if (flags.dryRun) { - resources.logger.dryRun("Dry run: would fetch identity from Poe API."); + resources.logger.dryRun("Dry run: would check authentication with the Poe API."); resources.context.finalize(); return; } @@ -110,11 +96,10 @@ async function executeStatus( const s = spinner(); s.start("Checking authentication..."); - let identity: Awaited>; try { - identity = await fetchPoeAuthIdentity({ + await checkPoeAuth({ apiKey, - baseUrl: container.env.poeApiBaseUrl, + baseUrl: container.env.poeBaseUrl, httpClient: container.httpClient }); } catch (error) { @@ -122,7 +107,7 @@ async function executeStatus( throw error; } - s.stop(`Logged in as ${identity.name} (@${identity.handle})`); + s.stop("Logged in"); resources.context.finalize(); } catch (error) { if (error instanceof Error) { @@ -149,14 +134,12 @@ async function writeStatusJson( return; } - writeJson({ - loggedIn: true, - identity: await fetchPoeAuthIdentity({ - apiKey, - baseUrl: container.env.poeApiBaseUrl, - httpClient: container.httpClient - }) + await checkPoeAuth({ + apiKey, + baseUrl: container.env.poeBaseUrl, + httpClient: container.httpClient }); + writeJson({ loggedIn: true }); } async function executeApiKey( @@ -189,7 +172,9 @@ async function executeApiKey( } process.stdout.write(`${maskApiKey(apiKey)}\n`); - resources.logger.warn("Masked to the last 4 characters. Re-run with --reveal to print the full secret."); + resources.logger.warn( + "Masked to the last 4 characters. Re-run with --reveal to print the full secret." + ); resources.context.finalize(); } @@ -208,27 +193,3 @@ async function resolveAuthCredential( } return container.readApiKey(options); } - -async function executeWhoami(program: Command, container: CliContainer): Promise { - const flags = resolveCommandFlags(program); - const apiKey = await resolveAuthCredential(container, { readOnly: flags.dryRun }); - if (!apiKey) { - process.exitCode = 1; - return; - } - - if (flags.dryRun) { - const resources = createExecutionResources(container, flags, "auth:whoami"); - resources.logger.dryRun("Dry run: would fetch identity from Poe API."); - resources.context.finalize(); - return; - } - - writeJson( - await fetchPoeAuthIdentity({ - apiKey, - baseUrl: container.env.poeApiBaseUrl, - httpClient: container.httpClient - }) - ); -} diff --git a/src/cli/commands/doctor-command.test.ts b/src/cli/commands/doctor-command.test.ts index d6b2445a7..0f43c693e 100644 --- a/src/cli/commands/doctor-command.test.ts +++ b/src/cli/commands/doctor-command.test.ts @@ -65,24 +65,18 @@ function createModelsResponse(models: Array<{ id: string; owned_by: string }>) { } interface RouterOptions { - whoami?: { ok: boolean; status?: number; body?: unknown }; + balance?: { ok: boolean; status?: number; body?: unknown }; models?: { ok: boolean; status?: number; body?: unknown }; } function createHttpClient(options: RouterOptions): HttpClient { return vi.fn(async (url: string) => { - if (url.includes("/whoami")) { - const whoami = options.whoami ?? { ok: true }; + if (url.includes("/usage/current_balance")) { + const balance = options.balance ?? { ok: true }; return { - ok: whoami.ok, - status: whoami.status ?? (whoami.ok ? 200 : 401), - json: async () => - whoami.body ?? { - user_id: 1, - handle: "kamil", - name: "Kamil Jopek", - profile_picture: "https://example.com/pic.jpg" - }, + ok: balance.ok, + status: balance.status ?? (balance.ok ? 200 : 401), + json: async () => balance.body ?? { current_point_balance: 8_432 }, text: async () => "" }; } @@ -133,10 +127,7 @@ describe("doctor command", () => { process.exitCode = originalExitCode; }); - function createDoctorProgram(input: { - httpClient: HttpClient; - commandRunner?: CommandRunner; - }) { + function createDoctorProgram(input: { httpClient: HttpClient; commandRunner?: CommandRunner }) { const program = createProgram({ fs, prompts: vi.fn(), @@ -176,7 +167,7 @@ describe("doctor command", () => { await program.parseAsync(["node", "cli", "doctor"]); const output = logs.join("\n"); - expect(output).toContain("Logged in as Kamil Jopek (@kamil)"); + expect(output).toContain("Logged in"); expect(output).toContain("claude-code"); expect(output).toContain("1 model available"); expect(output).toContain("claude"); @@ -211,6 +202,21 @@ describe("doctor command", () => { expect(process.exitCode).toBe(1); }); + it("fails auth when the balance endpoint rejects the stored key", async () => { + await storeTestApiKey(fs, homeDir, "revoked-key"); + + const program = createDoctorProgram({ + httpClient: createHttpClient({ balance: { ok: false, status: 401 } }) + }); + + await program.parseAsync(["node", "cli", "doctor"]); + + const output = logs.join("\n"); + expect(output).toContain("Failed to check authentication (HTTP 401)"); + expect(output).toContain("poe-code login"); + expect(process.exitCode).toBe(1); + }); + it("does not flag models of agents configured against another provider", async () => { await storeTestApiKey(fs, homeDir, "sk-test"); await saveConfiguredService({ diff --git a/src/cli/commands/doctor.ts b/src/cli/commands/doctor.ts index 6a1def3b5..faa84b64a 100644 --- a/src/cli/commands/doctor.ts +++ b/src/cli/commands/doctor.ts @@ -10,7 +10,7 @@ import { type ExecutionResources } from "./shared.js"; import { loadConfiguredServices } from "../../services/config.js"; -import { fetchPoeAuthIdentity } from "../../sdk/credentials.js"; +import { checkPoeAuth as checkPoeCredential } from "../../sdk/auth-check.js"; import { createBinaryExistsCheck } from "../../utils/command-checks.js"; interface DoctorCheck { @@ -100,21 +100,18 @@ async function resolvePoeCredential(container: CliContainer): Promise { +async function checkAuth(container: CliContainer, credential: string | null): Promise { if (!credential) { return { name: "auth", ok: false, detail: 'Not logged in. Run "poe-code login".' }; } try { - const identity = await fetchPoeAuthIdentity({ + await checkPoeCredential({ apiKey: credential, - baseUrl: container.env.poeApiBaseUrl, + baseUrl: container.env.poeBaseUrl, httpClient: container.httpClient }); - return { name: "auth", ok: true, detail: `Logged in as ${identity.name} (@${identity.handle})` }; + return { name: "auth", ok: true, detail: "Logged in" }; } catch (error) { return { name: "auth", @@ -196,11 +193,7 @@ async function checkRuntimes( if (!binaryName) { continue; } - const check = createBinaryExistsCheck( - binaryName, - `${service}-binary`, - `${binaryName} on PATH` - ); + const check = createBinaryExistsCheck(binaryName, `${service}-binary`, `${binaryName} on PATH`); try { await check.run({ isDryRun: false, diff --git a/src/cli/program.test.ts b/src/cli/program.test.ts index e286c25f1..19080948c 100644 --- a/src/cli/program.test.ts +++ b/src/cli/program.test.ts @@ -178,7 +178,7 @@ describe("createProgram", () => { expect(missing).toEqual([]); }); - it.each(["help", "whoami", "version", "dashboard"])( + it.each(["help", "version", "dashboard"])( "lists the conventional %s command in root help", (name) => { const program = createProgram({ @@ -195,6 +195,21 @@ describe("createProgram", () => { } ); + it("does not expose the employee-only whoami commands", () => { + const program = createProgram({ + fs: createMemFs(homeDir), + prompts: async () => ({}), + env: { cwd: "/repo", homeDir }, + logger: () => {}, + exitOverride: true, + suppressCommanderOutput: true + }); + + expect(program.commands.some((command) => command.name() === "whoami")).toBe(false); + const auth = program.commands.find((command) => command.name() === "auth"); + expect(auth?.commands.some((command) => command.name() === "whoami")).toBe(false); + }); + it("groups less-common commands under an Advanced heading in root help", () => { const fs = createMemFs(homeDir); const program = createProgram({ diff --git a/src/cli/program.ts b/src/cli/program.ts index b5f83359b..6b45a2937 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -107,7 +107,6 @@ const ROOT_HELP_PRIMARY_COMMANDS: readonly string[] = [ "ralph", "usage", "dashboard", - "whoami", "version", "help" ]; diff --git a/src/sdk/auth-check.test.ts b/src/sdk/auth-check.test.ts new file mode 100644 index 000000000..83f65a706 --- /dev/null +++ b/src/sdk/auth-check.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it, vi } from "vitest"; +import { ApiError } from "../cli/errors.js"; +import type { HttpClient } from "../cli/http.js"; +import { checkPoeAuth } from "./auth-check.js"; + +describe("checkPoeAuth", () => { + it("checks a custom base path with a trailing slash", async () => { + const httpClient = vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => ({ current_point_balance: 8_432 }) + })) as unknown as HttpClient; + + await expect( + checkPoeAuth({ apiKey: "test-key", baseUrl: "https://example.com/proxy/", httpClient }) + ).resolves.toBeUndefined(); + expect(httpClient).toHaveBeenCalledWith("https://example.com/proxy/usage/current_balance", { + method: "GET", + headers: { Authorization: "Bearer test-key" } + }); + }); + + it("preserves HTTP failure metadata", async () => { + const httpClient = vi.fn(async () => ({ + ok: false, + status: 401, + json: async () => ({ code: "invalid_api_key" }) + })) as unknown as HttpClient; + + const error = await checkPoeAuth({ + apiKey: "revoked-key", + baseUrl: "https://api.poe.com", + httpClient + }).catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(ApiError); + expect(error).toMatchObject({ + message: "Failed to check authentication (HTTP 401)", + httpStatus: 401, + endpoint: "/usage/current_balance" + }); + }); +}); diff --git a/src/sdk/auth-check.ts b/src/sdk/auth-check.ts new file mode 100644 index 000000000..bd90b2bd9 --- /dev/null +++ b/src/sdk/auth-check.ts @@ -0,0 +1,31 @@ +import { ApiError } from "../cli/errors.js"; +import type { HttpClient } from "../cli/http.js"; + +export interface CheckPoeAuthOptions { + apiKey: string; + baseUrl: string; + httpClient: HttpClient; +} + +export async function checkPoeAuth(options: CheckPoeAuthOptions): Promise { + const response = await options.httpClient(createCurrentBalanceUrl(options.baseUrl), { + method: "GET", + headers: { + Authorization: `Bearer ${options.apiKey}` + } + }); + + if (!response.ok) { + throw new ApiError(`Failed to check authentication (HTTP ${response.status})`, { + httpStatus: response.status, + endpoint: "/usage/current_balance" + }); + } +} + +function createCurrentBalanceUrl(baseUrl: string): string { + const url = new URL(baseUrl); + const path = url.pathname.endsWith("/") ? url.pathname.slice(0, -1) : url.pathname; + url.pathname = `${path}/usage/current_balance`; + return url.toString(); +}