Skip to content
Open
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
420 changes: 59 additions & 361 deletions src/cli/commands/auth-command.test.ts

Large diffs are not rendered by default.

65 changes: 13 additions & 52 deletions src/cli/commands/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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.")
Expand All @@ -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,
Expand All @@ -102,27 +88,26 @@ 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;
}

const s = spinner();
s.start("Checking authentication...");

let identity: Awaited<ReturnType<typeof fetchPoeAuthIdentity>>;
try {
identity = await fetchPoeAuthIdentity({
await checkPoeAuth({
apiKey,
baseUrl: container.env.poeApiBaseUrl,
baseUrl: container.env.poeBaseUrl,
httpClient: container.httpClient
});
} catch (error) {
s.stop("Authentication failed");
throw error;
}

s.stop(`Logged in as ${identity.name} (@${identity.handle})`);
s.stop("Logged in");
resources.context.finalize();
} catch (error) {
if (error instanceof Error) {
Expand All @@ -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(
Expand Down Expand Up @@ -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();
}

Expand All @@ -208,27 +193,3 @@ async function resolveAuthCredential(
}
return container.readApiKey(options);
}

async function executeWhoami(program: Command, container: CliContainer): Promise<void> {
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
})
);
}
40 changes: 23 additions & 17 deletions src/cli/commands/doctor-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => ""
};
}
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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({
Expand Down
19 changes: 6 additions & 13 deletions src/cli/commands/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -100,21 +100,18 @@ async function resolvePoeCredential(container: CliContainer): Promise<string | n
}
}

async function checkAuth(
container: CliContainer,
credential: string | null
): Promise<DoctorCheck> {
async function checkAuth(container: CliContainer, credential: string | null): Promise<DoctorCheck> {
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",
Expand Down Expand Up @@ -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,
Expand Down
17 changes: 16 additions & 1 deletion src/cli/program.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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({
Expand Down
1 change: 0 additions & 1 deletion src/cli/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,6 @@ const ROOT_HELP_PRIMARY_COMMANDS: readonly string[] = [
"ralph",
"usage",
"dashboard",
"whoami",
"version",
"help"
];
Expand Down
43 changes: 43 additions & 0 deletions src/sdk/auth-check.test.ts
Original file line number Diff line number Diff line change
@@ -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"
});
});
});
31 changes: 31 additions & 0 deletions src/sdk/auth-check.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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();
}
Loading