mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
pi auth login <provider> logs in from the shell without starting a session, like pi mcp login. Account sign-in is the default and takes the provider's browser method; when the provider has another method, it prints a hint to rerun with --no-browser. --no-browser skips the browser method and uses the other one, such as device code, asking only when there are several. --api-key saves an API key instead and cannot be combined with --no-browser. After logging in, it fetches the provider's model catalog, and for Radius it points the Radius MCP server in the global mcp.json at the login without asking. pi auth logout <provider> removes the stored credentials like /logout and succeeds when nothing is stored. For MCP servers that use provider credentials, pi mcp login and logout now refer to these commands instead of /login and /logout. /login shares the check for the Radius MCP server entry and the web URL helpers with the new command.
99 lines
3.9 KiB
TypeScript
99 lines
3.9 KiB
TypeScript
import { type Credential, InMemoryModelsStore } from "@earendil-works/pi-ai";
|
|
import { describe, expect, test, vi } from "vitest";
|
|
import { runAuthLogout } from "../src/cli/auth-logout.ts";
|
|
import { AuthStorage } from "../src/core/auth-storage.ts";
|
|
import { CredentialSynchronizationError, ModelRuntime } from "../src/core/model-runtime.ts";
|
|
|
|
async function setup(data: Record<string, Credential>) {
|
|
const credentials = AuthStorage.inMemory(data);
|
|
const modelRuntime = await ModelRuntime.create({
|
|
credentials,
|
|
modelsPath: null,
|
|
modelsStore: new InMemoryModelsStore(),
|
|
allowModelNetwork: false,
|
|
});
|
|
const lines: string[] = [];
|
|
const errors: string[] = [];
|
|
const run = (args: string[]) =>
|
|
runAuthLogout(args, {
|
|
modelRuntime,
|
|
terminal: { write: (line) => lines.push(line), error: (line) => errors.push(line) },
|
|
signal: new AbortController().signal,
|
|
});
|
|
return { credentials, modelRuntime, lines, errors, run };
|
|
}
|
|
|
|
const oauthCredential = {
|
|
type: "oauth",
|
|
access: "access",
|
|
refresh: "refresh",
|
|
expires: Date.now() + 3_600_000,
|
|
} as const;
|
|
|
|
describe("pi auth logout", () => {
|
|
test("requires exactly one provider", async () => {
|
|
const { errors, run } = await setup({});
|
|
for (const args of [[], ["--api-key"], ["radius", "extra"]]) {
|
|
errors.length = 0;
|
|
expect(await run(args)).toBe(1);
|
|
expect(errors).toEqual(["Usage: pi auth logout <provider>"]);
|
|
}
|
|
});
|
|
|
|
test("removes a stored account login", async () => {
|
|
const { credentials, lines, run } = await setup({ radius: oauthCredential });
|
|
expect(await run(["radius"])).toBe(0);
|
|
expect(lines).toEqual(["Logged out of Radius."]);
|
|
expect(await credentials.read("radius")).toBeUndefined();
|
|
});
|
|
|
|
test("removes a stored API key by provider name and keeps other credentials", async () => {
|
|
const { credentials, lines, run } = await setup({
|
|
openai: { type: "api_key", key: "sk-test" },
|
|
radius: oauthCredential,
|
|
});
|
|
expect(await run(["OpenAI"])).toBe(0);
|
|
expect(lines).toEqual([
|
|
"Removed stored API key for OpenAI. Environment variables and models.json config are unchanged.",
|
|
]);
|
|
expect(await credentials.read("openai")).toBeUndefined();
|
|
expect(await credentials.read("radius")).toMatchObject({ type: "oauth" });
|
|
});
|
|
|
|
test("succeeds when nothing is stored, so it can be repeated", async () => {
|
|
const { lines, run } = await setup({});
|
|
expect(await run(["radius"])).toBe(0);
|
|
expect(lines).toEqual([
|
|
"No stored credentials for Radius. Environment variables and models.json config are unchanged.",
|
|
]);
|
|
});
|
|
|
|
test("removes credentials of providers that are not loaded, matched by id", async () => {
|
|
const { credentials, lines, run } = await setup({ "extension-provider": oauthCredential });
|
|
expect(await run(["extension-provider"])).toBe(0);
|
|
expect(lines).toEqual(["Logged out of extension-provider."]);
|
|
expect(await credentials.read("extension-provider")).toBeUndefined();
|
|
});
|
|
|
|
test("reports credential removal failures", async () => {
|
|
const { modelRuntime, errors, run } = await setup({ radius: oauthCredential });
|
|
vi.spyOn(modelRuntime, "logout").mockRejectedValue(new Error("storage failed"));
|
|
expect(await run(["radius"])).toBe(1);
|
|
expect(errors).toEqual(["Logout failed: storage failed"]);
|
|
});
|
|
|
|
test("reports committed credential removal separately from a failed logout", async () => {
|
|
const { modelRuntime, errors, run } = await setup({ radius: oauthCredential });
|
|
vi.spyOn(modelRuntime, "logout").mockRejectedValue(
|
|
new CredentialSynchronizationError("radius", "logout", undefined, { cause: new Error("snapshot failed") }),
|
|
);
|
|
expect(await run(["radius"])).toBe(1);
|
|
expect(errors[0]).toContain("Credentials removed for Radius, but local model state could not be synchronized:");
|
|
});
|
|
|
|
test("rejects an unknown provider and lists the stored credentials", async () => {
|
|
const { errors, run } = await setup({ radius: oauthCredential });
|
|
expect(await run(["nope"])).toBe(1);
|
|
expect(errors).toEqual(['Unknown provider "nope".', "Stored credentials: radius"]);
|
|
});
|
|
});
|