mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
parent
58fc0431a7
commit
a261366bde
10 changed files with 631 additions and 162 deletions
|
|
@ -2,6 +2,10 @@
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Added `pi auth check` provider/model auth preflight with optional credential output ([#7152](https://github.com/earendil-works/pi/issues/7152)).
|
||||
|
||||
## [0.84.0] - 2026-08-06
|
||||
|
||||
### New Features
|
||||
|
|
|
|||
|
|
@ -248,7 +248,7 @@ ${chalk.bold("Commands:")}
|
|||
${APP_NAME} update [source|self|pi] Update pi, extensions, or model catalogs
|
||||
${APP_NAME} list List installed extensions from settings
|
||||
${APP_NAME} config [-l] Open TUI to enable/disable package resources (Tab switches scope)
|
||||
${APP_NAME} auth <command> Print credentials for external clients
|
||||
${APP_NAME} auth <command> Print credentials or check provider readiness
|
||||
${APP_NAME} <command> --help Show help for install/remove/uninstall/update/list/config/auth
|
||||
|
||||
${chalk.bold("Options:")}
|
||||
|
|
@ -299,10 +299,10 @@ Extensions can register additional flags (e.g., --plan from plan-mode extension)
|
|||
|
||||
${chalk.bold("Examples:")}
|
||||
# Print a provider API key for an external client
|
||||
${APP_NAME} auth print-api-key --provider openai --model gpt-5.5
|
||||
${APP_NAME} auth print-api-key --provider openai
|
||||
|
||||
# Print an OAuth bearer token for an external client (refreshes if expired)
|
||||
${APP_NAME} auth print-bearer-token --provider openai-codex --model gpt-5.5
|
||||
${APP_NAME} auth print-bearer-token --provider openai-codex
|
||||
|
||||
# Interactive mode
|
||||
${APP_NAME}
|
||||
|
|
|
|||
73
packages/coding-agent/src/cli/auth-check.ts
Normal file
73
packages/coding-agent/src/cli/auth-check.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import type { CredentialStore } from "@earendil-works/pi-ai";
|
||||
import { resolveCliModel } from "../core/model-resolver.ts";
|
||||
import { ModelRuntime } from "../core/model-runtime.ts";
|
||||
import { InMemoryCodingAgentModelsStore } from "../core/models-store.ts";
|
||||
import type { Args } from "./args.ts";
|
||||
import { AuthCommandError, getAuthCredential, validateAuthCommandArgs } from "./auth-command.ts";
|
||||
|
||||
export type AuthCheckStatus = "ready" | "not_ready" | "invalid";
|
||||
export type AuthCheckReason =
|
||||
| "provider_not_found"
|
||||
| "credentials_not_configured"
|
||||
| "credential_not_available"
|
||||
| "invalid_state";
|
||||
|
||||
export interface AuthCheckResult {
|
||||
status: AuthCheckStatus;
|
||||
provider: string;
|
||||
reason?: AuthCheckReason;
|
||||
authType?: "api_key" | "oauth";
|
||||
}
|
||||
|
||||
export async function checkProviderAuth(
|
||||
args: Args,
|
||||
modelRuntime: ModelRuntime,
|
||||
options: { refresh: boolean } = { refresh: false },
|
||||
): Promise<AuthCheckResult> {
|
||||
const { provider: cliProvider, model: cliModel } = validateAuthCommandArgs(args, "check");
|
||||
let provider = cliProvider;
|
||||
if (cliModel) {
|
||||
const resolved = resolveCliModel({ cliProvider, cliModel, modelRuntime });
|
||||
if (resolved.error || !resolved.model) {
|
||||
throw new AuthCommandError(resolved.error ?? `Unable to resolve model "${cliModel}"`);
|
||||
}
|
||||
provider = resolved.model.provider;
|
||||
}
|
||||
if (!provider) throw new AuthCommandError("Unable to resolve an auth provider");
|
||||
if (modelRuntime.getError()) {
|
||||
return { status: "invalid", provider, reason: "invalid_state" };
|
||||
}
|
||||
if (!modelRuntime.getProvider(provider)) {
|
||||
return { status: "not_ready", provider, reason: "provider_not_found" };
|
||||
}
|
||||
try {
|
||||
const auth = await modelRuntime.checkAuth(provider);
|
||||
if (!auth) return { status: "not_ready", provider, reason: "credentials_not_configured" };
|
||||
if (options.refresh && !(await modelRuntime.getAuth(provider))) {
|
||||
return { status: "not_ready", provider, reason: "credentials_not_configured" };
|
||||
}
|
||||
return { status: "ready", provider, authType: auth.type };
|
||||
} catch {
|
||||
return { status: "invalid", provider, reason: "invalid_state" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getProviderCredential(
|
||||
providerId: string,
|
||||
modelRuntime: ModelRuntime,
|
||||
credentials: CredentialStore,
|
||||
options: { refresh: boolean },
|
||||
): Promise<string | undefined> {
|
||||
const credential = await credentials.read(providerId);
|
||||
if (!options.refresh && credential?.type === "oauth") return credential.access;
|
||||
return getAuthCredential(await modelRuntime.getAuth(providerId));
|
||||
}
|
||||
|
||||
export async function createAuthCheckModelRuntime(credentials: CredentialStore): Promise<ModelRuntime> {
|
||||
return ModelRuntime.create({
|
||||
credentials,
|
||||
modelsStore: new InMemoryCodingAgentModelsStore(),
|
||||
allowModelNetwork: false,
|
||||
refreshOnCreate: false,
|
||||
});
|
||||
}
|
||||
125
packages/coding-agent/src/cli/auth-command.ts
Normal file
125
packages/coding-agent/src/cli/auth-command.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import type { AuthResult } from "@earendil-works/pi-ai";
|
||||
import type { Args } from "./args.ts";
|
||||
|
||||
export type AuthCommandKind = "check" | "api_key" | "bearer_token";
|
||||
|
||||
export interface AuthCommand {
|
||||
kind: AuthCommandKind;
|
||||
args: string[];
|
||||
json: boolean;
|
||||
credentials: boolean;
|
||||
noRefresh: boolean;
|
||||
minExpiryMs?: number;
|
||||
}
|
||||
|
||||
export class AuthCommandError extends Error {}
|
||||
|
||||
const AUTH_COMMAND_USAGE: Record<AuthCommandKind, string> = {
|
||||
check: "pi auth check --provider <provider> [--json] [--credentials] [--no-refresh]",
|
||||
api_key: "pi auth print-api-key --provider <provider> [--model <model>]",
|
||||
bearer_token: "pi auth print-bearer-token --provider <provider> [--model <model>] [--min-expiry <duration>]",
|
||||
};
|
||||
|
||||
export function getAuthCommandName(kind: AuthCommandKind): string {
|
||||
return kind === "check" ? "auth check" : kind === "api_key" ? "auth print-api-key" : "auth print-bearer-token";
|
||||
}
|
||||
|
||||
export function getAuthCommandUsage(kind: AuthCommandKind): string {
|
||||
return AUTH_COMMAND_USAGE[kind];
|
||||
}
|
||||
|
||||
export function isAuthCommandHelp(args: string[]): boolean {
|
||||
return (
|
||||
args[0] === "auth" &&
|
||||
(args[1] === undefined || args[1] === "help" || args.includes("--help") || args.includes("-h"))
|
||||
);
|
||||
}
|
||||
|
||||
export function printAuthCommandHelp(): void {
|
||||
console.log(`Usage:
|
||||
pi auth print-api-key [--provider <provider>] [--model <model>]
|
||||
pi auth print-bearer-token [--provider <provider>] [--model <model>] [--min-expiry <duration>]
|
||||
pi auth check [--provider <provider>] [--model <model>] [--json] [--credentials] [--no-refresh]
|
||||
|
||||
Auth commands require at least one of --provider or --model. Checks refresh expired OAuth credentials by default; --no-refresh prevents this. --credentials emits the credential, or includes it in JSON output.`);
|
||||
}
|
||||
|
||||
export function parseAuthCommand(args: string[]): AuthCommand | undefined {
|
||||
if (args[0] !== "auth") return undefined;
|
||||
|
||||
const kind =
|
||||
args[1] === "check"
|
||||
? "check"
|
||||
: args[1] === "print-api-key"
|
||||
? "api_key"
|
||||
: args[1] === "print-bearer-token"
|
||||
? "bearer_token"
|
||||
: undefined;
|
||||
if (!kind) {
|
||||
throw new AuthCommandError(
|
||||
`Unknown auth command "${args[1] ?? ""}". Use "pi auth print-api-key", "pi auth print-bearer-token", or "pi auth check".`,
|
||||
);
|
||||
}
|
||||
|
||||
const commandArgs: string[] = [];
|
||||
let json = false;
|
||||
let credentials = false;
|
||||
let noRefresh = false;
|
||||
let minExpiryMs: number | undefined;
|
||||
for (let index = 2; index < args.length; index++) {
|
||||
const arg = args[index];
|
||||
if (arg === "--min-expiry") {
|
||||
if (kind !== "bearer_token")
|
||||
throw new AuthCommandError("--min-expiry is only supported by print-bearer-token");
|
||||
const value = args[++index];
|
||||
const match = value ? /^(\d+)(ms|s|m|h)$/iu.exec(value) : undefined;
|
||||
if (!match) throw new AuthCommandError("--min-expiry must use a duration such as 30m or 1h");
|
||||
const amount = Number(match[1]);
|
||||
const unit = match[2];
|
||||
minExpiryMs = amount * (unit === "ms" ? 1 : unit === "s" ? 1_000 : unit === "m" ? 60_000 : 3_600_000);
|
||||
continue;
|
||||
}
|
||||
if (arg === "--json" || arg === "--credentials" || arg === "--no-refresh") {
|
||||
if (kind !== "check") throw new AuthCommandError(`${arg} is only supported by auth check`);
|
||||
if (arg === "--json") json = true;
|
||||
else if (arg === "--credentials") credentials = true;
|
||||
else noRefresh = true;
|
||||
continue;
|
||||
}
|
||||
commandArgs.push(arg);
|
||||
}
|
||||
|
||||
return minExpiryMs === undefined
|
||||
? { kind, args: commandArgs, json, credentials, noRefresh }
|
||||
: { kind, args: commandArgs, json, credentials, noRefresh, minExpiryMs };
|
||||
}
|
||||
|
||||
export function validateAuthCommandArgs(args: Args, kind: AuthCommandKind): { provider?: string; model?: string } {
|
||||
const provider = args.provider?.trim() || undefined;
|
||||
const model = args.model?.trim() || undefined;
|
||||
if (args.unknownFlags.size > 0) {
|
||||
const option = args.unknownFlags.keys().next().value;
|
||||
throw new AuthCommandError(`Unknown option --${option} for "${getAuthCommandName(kind)}".`);
|
||||
}
|
||||
if (args.apiKey !== undefined || args.messages.length > 0 || args.fileArgs.length > 0) {
|
||||
throw new AuthCommandError("Auth commands only accept --provider and --model");
|
||||
}
|
||||
if (kind === "check") {
|
||||
if (!provider && !model) {
|
||||
throw new AuthCommandError("Auth checks require --provider <provider> or --model <model>");
|
||||
}
|
||||
return { provider, model };
|
||||
}
|
||||
if (!provider && !model) {
|
||||
throw new AuthCommandError("Credential printing requires --provider <provider> or --model <model>");
|
||||
}
|
||||
return { provider, model };
|
||||
}
|
||||
|
||||
export function getAuthCredential(auth: AuthResult | undefined): string | undefined {
|
||||
if (auth?.auth.apiKey) return auth.auth.apiKey;
|
||||
const authorization = Object.entries(auth?.auth.headers ?? {}).find(
|
||||
([name]) => name.toLowerCase() === "authorization",
|
||||
)?.[1];
|
||||
return typeof authorization === "string" ? /^Bearer\s+(.+)$/iu.exec(authorization)?.[1] : undefined;
|
||||
}
|
||||
|
|
@ -2,81 +2,14 @@ import type { Api, CredentialInfo, Model } from "@earendil-works/pi-ai";
|
|||
import { resolveCliModel } from "../core/model-resolver.ts";
|
||||
import type { ModelRuntime } from "../core/model-runtime.ts";
|
||||
import type { Args } from "./args.ts";
|
||||
|
||||
export type CredentialPrintKind = "api_key" | "bearer_token";
|
||||
import { AuthCommandError, type AuthCommandKind, getAuthCredential, validateAuthCommandArgs } from "./auth-command.ts";
|
||||
|
||||
const DEFAULT_BEARER_TOKEN_MIN_EXPIRY_MS = 30 * 60_000;
|
||||
|
||||
export interface CredentialPrintCommand {
|
||||
kind: CredentialPrintKind;
|
||||
args: string[];
|
||||
minExpiryMs?: number;
|
||||
}
|
||||
|
||||
export class CredentialPrintError extends Error {}
|
||||
|
||||
export function isCredentialPrintHelp(args: string[]): boolean {
|
||||
return (
|
||||
args[0] === "auth" && (args[1] === undefined || args[1] === "help" || args[1] === "--help" || args[1] === "-h")
|
||||
);
|
||||
}
|
||||
|
||||
export function printCredentialPrintHelp(): void {
|
||||
console.log(`Usage:
|
||||
pi auth print-api-key --model <model> [--provider <provider>]
|
||||
pi auth print-bearer-token --model <model> [--provider <provider>] [--min-expiry <duration>]
|
||||
|
||||
Prints the configured credential alone on stdout. Provider inference uses configured credentials; specify --provider to select explicitly. Bearer tokens have a 30-minute minimum expiry by default. --min-expiry accepts ms, s, m, or h (for example, 30m).`);
|
||||
}
|
||||
|
||||
/** Parse the small, extensible `pi auth` command surface before normal startup. */
|
||||
export function parseCredentialPrintCommand(args: string[]): CredentialPrintCommand | undefined {
|
||||
if (args[0] !== "auth") return undefined;
|
||||
|
||||
const kind = args[1] === "print-api-key" ? "api_key" : args[1] === "print-bearer-token" ? "bearer_token" : undefined;
|
||||
if (!kind) {
|
||||
throw new CredentialPrintError(
|
||||
`Unknown auth command "${args[1] ?? ""}". Use "pi auth print-api-key" or "pi auth print-bearer-token".`,
|
||||
);
|
||||
}
|
||||
|
||||
const commandArgs: string[] = [];
|
||||
let minExpiryMs: number | undefined;
|
||||
for (let index = 2; index < args.length; index++) {
|
||||
if (args[index] !== "--min-expiry") {
|
||||
commandArgs.push(args[index]);
|
||||
continue;
|
||||
}
|
||||
if (kind !== "bearer_token") {
|
||||
throw new CredentialPrintError("--min-expiry is only supported by print-bearer-token");
|
||||
}
|
||||
const value = args[++index];
|
||||
const match = value ? /^(\d+)(ms|s|m|h)$/iu.exec(value) : undefined;
|
||||
if (!match) {
|
||||
throw new CredentialPrintError("--min-expiry must use a duration such as 30m or 1h");
|
||||
}
|
||||
const amount = Number(match[1]);
|
||||
const unit = match[2];
|
||||
minExpiryMs = amount * (unit === "ms" ? 1 : unit === "s" ? 1_000 : unit === "m" ? 60_000 : 3_600_000);
|
||||
}
|
||||
|
||||
return minExpiryMs === undefined ? { kind, args: commandArgs } : { kind, args: commandArgs, minExpiryMs };
|
||||
}
|
||||
|
||||
export function validateCredentialPrintArgs(args: Args): void {
|
||||
if (!args.model?.trim()) {
|
||||
throw new CredentialPrintError("Credential printing requires --model <model>");
|
||||
}
|
||||
if (args.apiKey !== undefined) {
|
||||
throw new CredentialPrintError("Credential printing reads configured credentials; --api-key is not supported");
|
||||
}
|
||||
if (args.messages.length > 0 || args.fileArgs.length > 0 || args.unknownFlags.size > 0) {
|
||||
throw new CredentialPrintError("Credential printing only accepts --provider and --model");
|
||||
}
|
||||
}
|
||||
type CredentialPrintKind = Exclude<AuthCommandKind, "check">;
|
||||
|
||||
/**
|
||||
* Resolve one request credential for a specific provider/model pair.
|
||||
* Resolve one configured provider credential.
|
||||
*
|
||||
* This intentionally calls ModelRuntime.getAuth(), which refreshes and persists
|
||||
* OAuth credentials with less than five minutes remaining through the normal request-auth path.
|
||||
|
|
@ -88,64 +21,67 @@ export async function resolveCredentialForPrint(
|
|||
minExpiryMs?: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<string> {
|
||||
validateCredentialPrintArgs(args);
|
||||
|
||||
const { provider: cliProvider, model: cliModel } = validateAuthCommandArgs(args, kind);
|
||||
const credentialTypes = new Map<string, CredentialInfo["type"]>(
|
||||
(await modelRuntime.listCredentials({ signal })).map((credential) => [credential.providerId, credential.type]),
|
||||
);
|
||||
const models: Model<Api>[] = [];
|
||||
if (args.provider) {
|
||||
const resolved = resolveCliModel({ cliProvider: args.provider, cliModel: args.model, modelRuntime });
|
||||
if (resolved.error || !resolved.model) {
|
||||
throw new CredentialPrintError(resolved.error ?? "Unable to resolve the requested provider/model");
|
||||
const providers: Array<{ id: string; model?: Model<Api> }> = [];
|
||||
if (cliProvider) {
|
||||
const provider = modelRuntime.getProvider(cliProvider);
|
||||
if (!provider) {
|
||||
throw new AuthCommandError(`Unknown provider "${cliProvider}". Use --list-models to see available providers.`);
|
||||
}
|
||||
if (cliModel) {
|
||||
const resolved = resolveCliModel({ cliProvider: provider.id, cliModel, modelRuntime });
|
||||
if (resolved.error || !resolved.model) {
|
||||
throw new AuthCommandError(resolved.error ?? "Unable to resolve the requested provider/model");
|
||||
}
|
||||
providers.push({ id: provider.id, model: resolved.model });
|
||||
} else {
|
||||
providers.push({ id: provider.id });
|
||||
}
|
||||
models.push(resolved.model);
|
||||
} else {
|
||||
for (const provider of modelRuntime.getProviders()) {
|
||||
if (!credentialTypes.has(provider.id)) continue;
|
||||
const resolved = resolveCliModel({ cliProvider: provider.id, cliModel: args.model, modelRuntime });
|
||||
const resolved = resolveCliModel({ cliProvider: provider.id, cliModel: cliModel!, modelRuntime });
|
||||
if (resolved.model && !resolved.error && !resolved.warning?.includes("Using custom model id")) {
|
||||
models.push(resolved.model);
|
||||
providers.push({ id: provider.id, model: resolved.model });
|
||||
}
|
||||
}
|
||||
if (models.length === 0) {
|
||||
throw new CredentialPrintError(`Model "${args.model}" not found. Use --list-models to see available models.`);
|
||||
if (providers.length === 0) {
|
||||
throw new AuthCommandError(`Model "${cliModel}" not found. Use --list-models to see available models.`);
|
||||
}
|
||||
}
|
||||
|
||||
const credentials: Array<{ providerId: string; value: string }> = [];
|
||||
for (const model of models) {
|
||||
const type = credentialTypes.get(model.provider);
|
||||
for (const provider of providers) {
|
||||
const type = credentialTypes.get(provider.id);
|
||||
if (kind === "api_key" && type === "oauth") continue;
|
||||
if (kind === "bearer_token" && type !== "oauth") continue;
|
||||
|
||||
const auth = await modelRuntime.getAuth(model, {
|
||||
const authOptions = {
|
||||
...(kind === "bearer_token" ? { minOAuthValidityMs: minExpiryMs ?? DEFAULT_BEARER_TOKEN_MIN_EXPIRY_MS } : {}),
|
||||
signal,
|
||||
});
|
||||
const authorization = Object.entries(auth?.auth.headers ?? {}).find(
|
||||
([name]) => name.toLowerCase() === "authorization",
|
||||
)?.[1];
|
||||
const bearerToken = typeof authorization === "string" ? /^Bearer\s+(.+)$/iu.exec(authorization)?.[1] : undefined;
|
||||
const value = kind === "bearer_token" ? (auth?.auth.apiKey ?? bearerToken) : auth?.auth.apiKey;
|
||||
if (value) credentials.push({ providerId: model.provider, value });
|
||||
};
|
||||
const auth = provider.model
|
||||
? await modelRuntime.getAuth(provider.model, authOptions)
|
||||
: await modelRuntime.getAuth(provider.id, authOptions);
|
||||
const value = getAuthCredential(auth);
|
||||
if (value) credentials.push({ providerId: provider.id, value });
|
||||
}
|
||||
|
||||
if (credentials.length === 1) return credentials[0].value;
|
||||
if (credentials.length === 0) {
|
||||
const providerId = models[0]?.provider;
|
||||
const providerId = providers[0]?.id;
|
||||
const type = providerId ? credentialTypes.get(providerId) : undefined;
|
||||
if (args.provider && kind === "api_key" && type === "oauth") {
|
||||
throw new CredentialPrintError(`Provider "${providerId}" is configured with OAuth, not an API key`);
|
||||
if (cliProvider && kind === "api_key" && type === "oauth") {
|
||||
throw new AuthCommandError(`Provider "${providerId}" is configured with OAuth, not an API key`);
|
||||
}
|
||||
if (args.provider && kind === "bearer_token" && type !== "oauth") {
|
||||
throw new CredentialPrintError(`Provider "${providerId}" is not configured with an OAuth bearer token`);
|
||||
if (cliProvider && kind === "bearer_token" && type !== "oauth") {
|
||||
throw new AuthCommandError(`Provider "${providerId}" is not configured with an OAuth bearer token`);
|
||||
}
|
||||
throw new CredentialPrintError(
|
||||
`No usable ${kind === "api_key" ? "API key" : "OAuth bearer token"} is configured`,
|
||||
);
|
||||
throw new AuthCommandError(`No usable ${kind === "api_key" ? "API key" : "OAuth bearer token"} is configured`);
|
||||
}
|
||||
throw new CredentialPrintError(
|
||||
`Model "${args.model}" has multiple configured providers (${credentials.map(({ providerId }) => providerId).join(", ")}). Specify --provider.`,
|
||||
throw new AuthCommandError(
|
||||
`Multiple configured providers matched (${credentials.map(({ providerId }) => providerId).join(", ")}). Specify --provider.`,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import { setTimeout as sleep } from "timers/promises";
|
|||
import { getAgentDir } from "../config.ts";
|
||||
import { raceWithAbortSignal } from "../utils/abort.ts";
|
||||
import { getFileRevision, normalizePath } from "../utils/paths.ts";
|
||||
import { resolveConfigValue } from "./resolve-config-value.ts";
|
||||
import { isCommandConfigValue, resolveConfigValue } from "./resolve-config-value.ts";
|
||||
|
||||
type AuthStorageData = Record<string, Credential>;
|
||||
|
||||
|
|
@ -201,6 +201,95 @@ export class FileAuthStorageBackend implements AuthStorageBackend {
|
|||
}
|
||||
}
|
||||
|
||||
export class ReadOnlyAuthStorage implements CredentialStore {
|
||||
private readonly authPath: string;
|
||||
private data: AuthStorageData | undefined;
|
||||
|
||||
constructor(authPath: string = join(getAgentDir(), "auth.json")) {
|
||||
this.authPath = normalizePath(authPath);
|
||||
}
|
||||
|
||||
private load(): AuthStorageData {
|
||||
if (this.data) return this.data;
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(readFileSync(this.authPath, "utf-8"));
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
this.data = {};
|
||||
return this.data;
|
||||
}
|
||||
throw new Error(`Failed to read auth.json: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
||||
throw new Error("Invalid auth.json: expected an object");
|
||||
}
|
||||
for (const [providerId, credential] of Object.entries(parsed)) {
|
||||
if (typeof credential !== "object" || credential === null || Array.isArray(credential)) {
|
||||
throw new Error(`Invalid auth.json credential for provider "${providerId}"`);
|
||||
}
|
||||
const value = credential as Record<string, unknown>;
|
||||
if (value.type === "api_key") {
|
||||
const validKey = value.key === undefined || typeof value.key === "string";
|
||||
const validEnv =
|
||||
value.env === undefined ||
|
||||
(typeof value.env === "object" &&
|
||||
value.env !== null &&
|
||||
!Array.isArray(value.env) &&
|
||||
Object.values(value.env).every((entry) => typeof entry === "string"));
|
||||
if (validKey && validEnv) continue;
|
||||
} else if (
|
||||
value.type === "oauth" &&
|
||||
typeof value.access === "string" &&
|
||||
typeof value.refresh === "string" &&
|
||||
typeof value.expires === "number" &&
|
||||
Number.isFinite(value.expires)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
throw new Error(`Invalid auth.json credential for provider "${providerId}"`);
|
||||
}
|
||||
|
||||
this.data = parsed as AuthStorageData;
|
||||
return this.data;
|
||||
}
|
||||
|
||||
async read(providerId: string, options?: AuthOperationOptions): Promise<Credential | undefined> {
|
||||
options?.signal?.throwIfAborted();
|
||||
const credential = this.load()[providerId];
|
||||
options?.signal?.throwIfAborted();
|
||||
if (!credential) return undefined;
|
||||
if (credential.type !== "api_key" || !credential.key || isCommandConfigValue(credential.key)) {
|
||||
return structuredClone(credential);
|
||||
}
|
||||
return { ...credential, key: resolveConfigValue(credential.key, credential.env) };
|
||||
}
|
||||
|
||||
async list(options?: AuthOperationOptions): Promise<readonly CredentialInfo[]> {
|
||||
options?.signal?.throwIfAborted();
|
||||
const credentials = Object.entries(this.load()).map(([providerId, credential]) => ({
|
||||
providerId,
|
||||
type: credential.type,
|
||||
}));
|
||||
options?.signal?.throwIfAborted();
|
||||
return credentials;
|
||||
}
|
||||
|
||||
async modify(
|
||||
_providerId: string,
|
||||
_fn: (current: Credential | undefined) => Promise<Credential | undefined>,
|
||||
_options?: AuthOperationOptions,
|
||||
): Promise<Credential | undefined> {
|
||||
throw new Error("Read-only credential storage cannot modify auth.json");
|
||||
}
|
||||
|
||||
async delete(_providerId: string, _options?: AuthOperationOptions): Promise<void> {
|
||||
throw new Error("Read-only credential storage cannot modify auth.json");
|
||||
}
|
||||
}
|
||||
|
||||
export class InMemoryAuthStorageBackend implements AuthStorageBackend {
|
||||
private value: string | undefined;
|
||||
private asyncChain: Promise<unknown> = Promise.resolve();
|
||||
|
|
|
|||
|
|
@ -77,6 +77,8 @@ export interface CreateModelRuntimeOptions {
|
|||
catalogBaseUrl?: string;
|
||||
/** Optional caller cancellation for initial cache restoration and availability checks. */
|
||||
signal?: AbortSignal;
|
||||
/** Skip initial catalog and availability refresh. Static models remain available. */
|
||||
refreshOnCreate?: boolean;
|
||||
}
|
||||
|
||||
export interface ModelRuntimeAuthOverrides extends AuthOperationOptions {
|
||||
|
|
@ -205,7 +207,9 @@ export class ModelRuntime implements Models {
|
|||
: controller.signal
|
||||
: options.signal;
|
||||
try {
|
||||
await runtime.refresh({ allowNetwork: refreshFromNetwork, signal });
|
||||
if (options.refreshOnCreate !== false) {
|
||||
await runtime.refresh({ allowNetwork: refreshFromNetwork, signal });
|
||||
}
|
||||
} finally {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,21 +10,29 @@ import { type ImageContent, modelsAreEqual } from "@earendil-works/pi-ai";
|
|||
import chalk from "chalk";
|
||||
import { type Args, type Mode, parseArgs, printHelp } from "./cli/args.ts";
|
||||
import {
|
||||
type CredentialPrintCommand,
|
||||
CredentialPrintError,
|
||||
isCredentialPrintHelp,
|
||||
parseCredentialPrintCommand,
|
||||
printCredentialPrintHelp,
|
||||
resolveCredentialForPrint,
|
||||
validateCredentialPrintArgs,
|
||||
} from "./cli/credential-print.ts";
|
||||
type AuthCheckResult,
|
||||
checkProviderAuth,
|
||||
createAuthCheckModelRuntime,
|
||||
getProviderCredential,
|
||||
} from "./cli/auth-check.ts";
|
||||
import {
|
||||
type AuthCommand,
|
||||
AuthCommandError,
|
||||
getAuthCommandName,
|
||||
getAuthCommandUsage,
|
||||
isAuthCommandHelp,
|
||||
parseAuthCommand,
|
||||
printAuthCommandHelp,
|
||||
validateAuthCommandArgs,
|
||||
} from "./cli/auth-command.ts";
|
||||
import { resolveCredentialForPrint } from "./cli/credential-print.ts";
|
||||
import { processFileArguments } from "./cli/file-processor.ts";
|
||||
import { buildInitialMessage } from "./cli/initial-message.ts";
|
||||
import { listModels } from "./cli/list-models.ts";
|
||||
import { createProjectTrustContext } from "./cli/project-trust.ts";
|
||||
import { selectSession } from "./cli/session-picker.ts";
|
||||
import { shouldRunFirstTimeSetup, showFirstTimeSetup, showStartupSelector } from "./cli/startup-ui.ts";
|
||||
import { ENV_SESSION_DIR, expandTildePath, getAgentDir, getPackageDir, VERSION } from "./config.ts";
|
||||
import { APP_NAME, ENV_SESSION_DIR, expandTildePath, getAgentDir, getPackageDir, VERSION } from "./config.ts";
|
||||
import { type CreateAgentSessionRuntimeFactory, createAgentSessionRuntime } from "./core/agent-session-runtime.ts";
|
||||
import {
|
||||
type AgentSessionRuntimeDiagnostic,
|
||||
|
|
@ -32,6 +40,7 @@ import {
|
|||
createAgentSessionServices,
|
||||
} from "./core/agent-session-services.ts";
|
||||
import { formatNoModelsAvailableMessage } from "./core/auth-guidance.ts";
|
||||
import { AuthStorage, ReadOnlyAuthStorage } from "./core/auth-storage.ts";
|
||||
import { exportFromFile } from "./core/export-html/index.ts";
|
||||
import type { InlineExtension } from "./core/extensions/types.ts";
|
||||
import { applyHttpProxySettings, configureHttpDispatcher } from "./core/http-dispatcher.ts";
|
||||
|
|
@ -127,17 +136,17 @@ function isPlainRuntimeMetadataCommand(parsed: Args): boolean {
|
|||
return !parsed.print && parsed.mode === undefined && (parsed.help === true || parsed.listModels !== undefined);
|
||||
}
|
||||
|
||||
async function runCredentialPrintCommand(args: string[]): Promise<boolean> {
|
||||
if (isCredentialPrintHelp(args)) {
|
||||
printCredentialPrintHelp();
|
||||
async function runAuthCommand(args: string[]): Promise<boolean> {
|
||||
if (isAuthCommandHelp(args)) {
|
||||
printAuthCommandHelp();
|
||||
return true;
|
||||
}
|
||||
|
||||
let command: CredentialPrintCommand | undefined;
|
||||
let command: AuthCommand | undefined;
|
||||
try {
|
||||
command = parseCredentialPrintCommand(args);
|
||||
command = parseAuthCommand(args);
|
||||
} catch (error) {
|
||||
const message = error instanceof CredentialPrintError ? error.message : "Failed to parse auth command";
|
||||
const message = error instanceof AuthCommandError ? error.message : "Failed to parse auth command";
|
||||
console.error(chalk.red(`Error: ${message}`));
|
||||
process.exitCode = 1;
|
||||
return true;
|
||||
|
|
@ -145,30 +154,62 @@ async function runCredentialPrintCommand(args: string[]): Promise<boolean> {
|
|||
if (!command) return false;
|
||||
|
||||
const parsed = parseArgs(command.args);
|
||||
if (parsed.diagnostics.length > 0) {
|
||||
for (const diagnostic of parsed.diagnostics) {
|
||||
console.error(chalk.red(`Error: ${diagnostic.message}`));
|
||||
}
|
||||
if (parsed.unknownFlags.size > 0) {
|
||||
const option = parsed.unknownFlags.keys().next().value;
|
||||
console.error(chalk.red(`Unknown option --${option} for "${getAuthCommandName(command.kind)}".`));
|
||||
console.error(chalk.dim(`Use "${APP_NAME} --help" or "${getAuthCommandUsage(command.kind)}".`));
|
||||
process.exitCode = 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
validateCredentialPrintArgs(parsed);
|
||||
const signal = AbortSignal.timeout(15_000);
|
||||
const modelRuntime = await ModelRuntime.create({ allowModelNetwork: false, signal });
|
||||
const credential = await resolveCredentialForPrint(
|
||||
parsed,
|
||||
modelRuntime,
|
||||
command.kind,
|
||||
command.minExpiryMs,
|
||||
signal,
|
||||
);
|
||||
process.stdout.write(`${credential}\n`);
|
||||
if (parsed.diagnostics.length > 0) {
|
||||
throw new AuthCommandError(parsed.diagnostics.map((diagnostic) => diagnostic.message).join("\n"));
|
||||
}
|
||||
if (command.kind !== "check") {
|
||||
const signal = AbortSignal.timeout(15_000);
|
||||
const modelRuntime = await ModelRuntime.create({ allowModelNetwork: false, signal });
|
||||
const credential = await resolveCredentialForPrint(
|
||||
parsed,
|
||||
modelRuntime,
|
||||
command.kind,
|
||||
command.minExpiryMs,
|
||||
signal,
|
||||
);
|
||||
process.stdout.write(`${credential}\n`);
|
||||
return true;
|
||||
}
|
||||
|
||||
const requestedAuth = validateAuthCommandArgs(parsed, command.kind);
|
||||
let result: AuthCheckResult;
|
||||
let credential: string | undefined;
|
||||
try {
|
||||
const credentials = command.noRefresh ? new ReadOnlyAuthStorage() : AuthStorage.create();
|
||||
const modelRuntime = await createAuthCheckModelRuntime(credentials);
|
||||
result = await checkProviderAuth(parsed, modelRuntime, { refresh: !command.noRefresh });
|
||||
if (command.credentials && result.status === "ready") {
|
||||
credential = await getProviderCredential(result.provider, modelRuntime, credentials, {
|
||||
refresh: !command.noRefresh,
|
||||
});
|
||||
if (!credential) {
|
||||
result = { status: "not_ready", provider: result.provider, reason: "credential_not_available" };
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
result = {
|
||||
status: "invalid",
|
||||
provider: requestedAuth.provider ?? requestedAuth.model!,
|
||||
reason: "invalid_state",
|
||||
};
|
||||
}
|
||||
const output = command.json
|
||||
? JSON.stringify({ ...result, ...(credential ? { credentials: credential } : {}) })
|
||||
: (credential ?? result.status);
|
||||
process.stdout.write(`${output}\n`);
|
||||
process.exitCode = result.status === "ready" ? 0 : result.status === "not_ready" ? 1 : 2;
|
||||
} catch (error) {
|
||||
const message = error instanceof CredentialPrintError ? error.message : "Failed to resolve credential";
|
||||
const message = error instanceof AuthCommandError ? error.message : "Failed to resolve credential";
|
||||
console.error(chalk.red(`Error: ${message}`));
|
||||
process.exitCode = 1;
|
||||
process.exitCode = command.kind === "check" ? 2 : 1;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
|
@ -534,6 +575,10 @@ export async function main(args: string[], options?: MainOptions) {
|
|||
process.env.PI_SKIP_VERSION_CHECK = "1";
|
||||
}
|
||||
|
||||
if (await runAuthCommand(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.platform === "win32") {
|
||||
cleanupWindowsSelfUpdateQuarantine(getPackageDir());
|
||||
}
|
||||
|
|
@ -561,10 +606,6 @@ export async function main(args: string[], options?: MainOptions) {
|
|||
return;
|
||||
}
|
||||
|
||||
if (await runCredentialPrintCommand(args)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = parseArgs(args);
|
||||
if (parsed.diagnostics.length > 0) {
|
||||
for (const d of parsed.diagnostics) {
|
||||
|
|
|
|||
171
packages/coding-agent/test/auth-check.test.ts
Normal file
171
packages/coding-agent/test/auth-check.test.ts
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { InMemoryModelsStore } from "@earendil-works/pi-ai";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { parseArgs } from "../src/cli/args.ts";
|
||||
import { checkProviderAuth, createAuthCheckModelRuntime, getProviderCredential } from "../src/cli/auth-check.ts";
|
||||
import { parseAuthCommand } from "../src/cli/auth-command.ts";
|
||||
import { AuthStorage, ReadOnlyAuthStorage } from "../src/core/auth-storage.ts";
|
||||
import { ModelRuntime } from "../src/core/model-runtime.ts";
|
||||
|
||||
const tempDir = join(tmpdir(), `pi-test-auth-check-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
|
||||
async function createRuntime(credentials: AuthStorage | ReadOnlyAuthStorage): Promise<ModelRuntime> {
|
||||
return ModelRuntime.create({
|
||||
credentials,
|
||||
modelsPath: null,
|
||||
modelsStore: new InMemoryModelsStore(),
|
||||
allowModelNetwork: false,
|
||||
refreshOnCreate: false,
|
||||
});
|
||||
}
|
||||
|
||||
describe("auth check command", () => {
|
||||
beforeEach(() => {
|
||||
if (existsSync(tempDir)) rmSync(tempDir, { recursive: true });
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (existsSync(tempDir)) rmSync(tempDir, { recursive: true });
|
||||
});
|
||||
|
||||
test("reports a configured provider as ready", async () => {
|
||||
const runtime = await createRuntime(AuthStorage.inMemory({ openai: { type: "api_key", key: "test-key" } }));
|
||||
|
||||
await expect(checkProviderAuth(parseArgs(["--provider", "openai"]), runtime)).resolves.toEqual({
|
||||
status: "ready",
|
||||
provider: "openai",
|
||||
authType: "api_key",
|
||||
});
|
||||
});
|
||||
|
||||
test("resolves the provider from --model", async () => {
|
||||
const runtime = await createRuntime(AuthStorage.inMemory({ openai: { type: "api_key", key: "test-key" } }));
|
||||
|
||||
await expect(checkProviderAuth(parseArgs(["--model", "openai/gpt-5.5"]), runtime)).resolves.toEqual({
|
||||
status: "ready",
|
||||
provider: "openai",
|
||||
authType: "api_key",
|
||||
});
|
||||
await expect(
|
||||
checkProviderAuth(parseArgs(["--provider", "openai", "--model", "gpt-5.5"]), runtime),
|
||||
).resolves.toMatchObject({ status: "ready", provider: "openai" });
|
||||
});
|
||||
|
||||
test("reads credentials without refreshing OAuth when requested", async () => {
|
||||
const apiCredentials = AuthStorage.inMemory({ openai: { type: "api_key", key: "test-key" } });
|
||||
const apiRuntime = await createRuntime(apiCredentials);
|
||||
await expect(getProviderCredential("openai", apiRuntime, apiCredentials, { refresh: false })).resolves.toBe(
|
||||
"test-key",
|
||||
);
|
||||
|
||||
const credentials = AuthStorage.inMemory({
|
||||
"openai-codex": { type: "oauth", access: "old-token", refresh: "refresh-token", expires: 0 },
|
||||
});
|
||||
const oauthRuntime = await createRuntime(credentials);
|
||||
const oauth = oauthRuntime.getProvider("openai-codex")?.auth.oauth;
|
||||
if (!oauth) throw new Error("OpenAI Codex OAuth provider is not registered");
|
||||
const refresh = vi.fn(oauth.refresh);
|
||||
oauth.refresh = refresh;
|
||||
|
||||
await expect(getProviderCredential("openai-codex", oauthRuntime, credentials, { refresh: false })).resolves.toBe(
|
||||
"old-token",
|
||||
);
|
||||
expect(refresh).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("refreshes OAuth by default", async () => {
|
||||
const credentials = AuthStorage.inMemory({
|
||||
"openai-codex": { type: "oauth", access: "old-token", refresh: "refresh-token", expires: 0 },
|
||||
});
|
||||
const runtime = await createRuntime(credentials);
|
||||
const oauth = runtime.getProvider("openai-codex")?.auth.oauth;
|
||||
if (!oauth) throw new Error("OpenAI Codex OAuth provider is not registered");
|
||||
const refresh = vi.fn(async () => ({
|
||||
type: "oauth" as const,
|
||||
access: "fresh-token",
|
||||
refresh: "refresh-token",
|
||||
expires: Date.now() + 60 * 60 * 1000,
|
||||
}));
|
||||
oauth.refresh = refresh;
|
||||
|
||||
await expect(
|
||||
checkProviderAuth(parseArgs(["--provider", "openai-codex"]), runtime, { refresh: true }),
|
||||
).resolves.toMatchObject({
|
||||
status: "ready",
|
||||
});
|
||||
expect(refresh).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("reports an unknown provider as not ready", async () => {
|
||||
const runtime = await createRuntime(AuthStorage.inMemory());
|
||||
|
||||
await expect(checkProviderAuth(parseArgs(["--provider", "not-installed"]), runtime)).resolves.toEqual({
|
||||
status: "not_ready",
|
||||
provider: "not-installed",
|
||||
reason: "provider_not_found",
|
||||
});
|
||||
});
|
||||
|
||||
test("does not treat an unresolved stored environment reference as configured", async () => {
|
||||
const authPath = join(tempDir, "auth.json");
|
||||
writeFileSync(authPath, JSON.stringify({ openai: { type: "api_key", key: "$MISSING_AUTH_CHECK_KEY" } }), "utf-8");
|
||||
const runtime = await createRuntime(new ReadOnlyAuthStorage(authPath));
|
||||
|
||||
await expect(checkProviderAuth(parseArgs(["--provider", "openai"]), runtime)).resolves.toEqual({
|
||||
status: "not_ready",
|
||||
provider: "openai",
|
||||
reason: "credentials_not_configured",
|
||||
});
|
||||
});
|
||||
|
||||
test("reports malformed auth state as invalid", async () => {
|
||||
const authPath = join(tempDir, "auth.json");
|
||||
writeFileSync(authPath, "{invalid-json", "utf-8");
|
||||
const runtime = await createRuntime(new ReadOnlyAuthStorage(authPath));
|
||||
|
||||
await expect(checkProviderAuth(parseArgs(["--provider", "openai"]), runtime)).resolves.toEqual({
|
||||
status: "invalid",
|
||||
provider: "openai",
|
||||
reason: "invalid_state",
|
||||
});
|
||||
});
|
||||
|
||||
test("does not create an auth file or its parent directory", async () => {
|
||||
const authPath = join(tempDir, "agent", "auth.json");
|
||||
const runtime = await createRuntime(new ReadOnlyAuthStorage(authPath));
|
||||
|
||||
await expect(checkProviderAuth(parseArgs(["--provider", "openai"]), runtime)).resolves.toMatchObject({
|
||||
status: "not_ready",
|
||||
reason: "credentials_not_configured",
|
||||
});
|
||||
expect(existsSync(authPath)).toBe(false);
|
||||
expect(existsSync(join(tempDir, "agent"))).toBe(false);
|
||||
});
|
||||
|
||||
test("accepts optional JSON output, credential output, and --no-refresh", () => {
|
||||
expect(parseAuthCommand(["auth", "check", "--provider", "openai"])).toEqual({
|
||||
kind: "check",
|
||||
args: ["--provider", "openai"],
|
||||
json: false,
|
||||
credentials: false,
|
||||
noRefresh: false,
|
||||
});
|
||||
expect(
|
||||
parseAuthCommand(["auth", "check", "--json", "--credentials", "--no-refresh", "--provider", "openai"]),
|
||||
).toEqual({
|
||||
kind: "check",
|
||||
args: ["--provider", "openai"],
|
||||
json: true,
|
||||
credentials: true,
|
||||
noRefresh: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("creates an auth-check runtime without catalog storage", async () => {
|
||||
const runtime = await createAuthCheckModelRuntime(AuthStorage.inMemory());
|
||||
expect(runtime.getProvider("openai")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,14 +1,11 @@
|
|||
import { InMemoryModelsStore } from "@earendil-works/pi-ai";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { parseArgs } from "../src/cli/args.ts";
|
||||
import {
|
||||
CredentialPrintError,
|
||||
isCredentialPrintHelp,
|
||||
parseCredentialPrintCommand,
|
||||
resolveCredentialForPrint,
|
||||
} from "../src/cli/credential-print.ts";
|
||||
import { AuthCommandError, isAuthCommandHelp, parseAuthCommand } from "../src/cli/auth-command.ts";
|
||||
import { resolveCredentialForPrint } from "../src/cli/credential-print.ts";
|
||||
import { AuthStorage } from "../src/core/auth-storage.ts";
|
||||
import { ModelRuntime } from "../src/core/model-runtime.ts";
|
||||
import { main } from "../src/main.ts";
|
||||
|
||||
async function createRuntime(credentials: AuthStorage): Promise<ModelRuntime> {
|
||||
return ModelRuntime.create({
|
||||
|
|
@ -22,7 +19,7 @@ async function createRuntime(credentials: AuthStorage): Promise<ModelRuntime> {
|
|||
describe("credential print commands", () => {
|
||||
test("prints a resolved API key", async () => {
|
||||
const runtime = await createRuntime(AuthStorage.inMemory({ openai: { type: "api_key", key: "test-api-key" } }));
|
||||
const args = parseArgs(["--model", "gpt-5.5"]);
|
||||
const args = parseArgs(["--provider", "openai"]);
|
||||
|
||||
await expect(resolveCredentialForPrint(args, runtime, "api_key")).resolves.toBe("test-api-key");
|
||||
});
|
||||
|
|
@ -38,7 +35,7 @@ describe("credential print commands", () => {
|
|||
},
|
||||
}),
|
||||
);
|
||||
const args = parseArgs(["--provider", "kimi-coding", "--model", "kimi-for-coding"]);
|
||||
const args = parseArgs(["--provider", "kimi-coding"]);
|
||||
|
||||
await expect(resolveCredentialForPrint(args, runtime, "bearer_token")).resolves.toBe("header-test-token");
|
||||
});
|
||||
|
|
@ -62,13 +59,31 @@ describe("credential print commands", () => {
|
|||
const oauth = runtime.getProvider("openai-codex")?.auth.oauth;
|
||||
if (!oauth) throw new Error("OpenAI Codex OAuth provider is not registered");
|
||||
oauth.refresh = refresh;
|
||||
const args = parseArgs(["--provider", "openai-codex", "--model", "gpt-5.5"]);
|
||||
const args = parseArgs(["--provider", "openai-codex"]);
|
||||
|
||||
await expect(resolveCredentialForPrint(args, runtime, "bearer_token")).resolves.toBe("fresh-test-token");
|
||||
expect(refresh).toHaveBeenCalledOnce();
|
||||
expect(await storage.read("openai-codex")).toMatchObject({ access: "fresh-test-token" });
|
||||
});
|
||||
|
||||
test("reports unknown auth options like package commands", async () => {
|
||||
const originalExitCode = process.exitCode;
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
try {
|
||||
process.exitCode = undefined;
|
||||
await main(["auth", "check", "--provider", "openai-codex", "--credentails"]);
|
||||
const stderr = errorSpy.mock.calls.map(([message]) => String(message)).join("\n");
|
||||
expect(stderr).toContain('Unknown option --credentails for "auth check".');
|
||||
expect(stderr).toContain(
|
||||
'Use "pi --help" or "pi auth check --provider <provider> [--json] [--credentials] [--no-refresh]".',
|
||||
);
|
||||
expect(process.exitCode).toBe(1);
|
||||
} finally {
|
||||
process.exitCode = originalExitCode;
|
||||
errorSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test("parses credential commands and rejects invalid arguments or credential types", async () => {
|
||||
const runtime = await createRuntime(
|
||||
AuthStorage.inMemory({
|
||||
|
|
@ -81,24 +96,35 @@ describe("credential print commands", () => {
|
|||
}),
|
||||
);
|
||||
|
||||
expect(parseCredentialPrintCommand(["auth", "print-api-key", "--provider", "openai"])).toEqual({
|
||||
expect(parseAuthCommand(["auth", "print-api-key", "--provider", "openai"])).toEqual({
|
||||
kind: "api_key",
|
||||
args: ["--provider", "openai"],
|
||||
json: false,
|
||||
credentials: false,
|
||||
noRefresh: false,
|
||||
});
|
||||
expect(parseCredentialPrintCommand(["auth", "print-bearer-token"])).toMatchObject({ kind: "bearer_token" });
|
||||
expect(parseCredentialPrintCommand(["auth", "print-bearer-token", "--min-expiry", "30m"])).toEqual({
|
||||
expect(parseAuthCommand(["auth", "print-bearer-token"])).toMatchObject({ kind: "bearer_token" });
|
||||
expect(parseAuthCommand(["auth", "print-bearer-token", "--min-expiry", "30m"])).toEqual({
|
||||
kind: "bearer_token",
|
||||
args: [],
|
||||
json: false,
|
||||
credentials: false,
|
||||
noRefresh: false,
|
||||
minExpiryMs: 30 * 60_000,
|
||||
});
|
||||
expect(() => parseCredentialPrintCommand(["auth", "print-api-key", "--min-expiry", "30m"])).toThrow(
|
||||
expect(() => parseAuthCommand(["auth", "print-api-key", "--min-expiry", "30m"])).toThrow(
|
||||
"only supported by print-bearer-token",
|
||||
);
|
||||
expect(isCredentialPrintHelp(["auth", "--help"])).toBe(true);
|
||||
expect(() => parseCredentialPrintCommand(["auth", "unknown"])).toThrow(CredentialPrintError);
|
||||
await expect(resolveCredentialForPrint(parseArgs([]), runtime, "api_key")).rejects.toThrow("requires --model");
|
||||
expect(isAuthCommandHelp(["auth", "--help"])).toBe(true);
|
||||
expect(isAuthCommandHelp(["auth", "print-api-key", "--help"])).toBe(true);
|
||||
expect(isAuthCommandHelp(["auth", "print-bearer-token", "-h"])).toBe(true);
|
||||
expect(isAuthCommandHelp(["auth", "check", "--help"])).toBe(true);
|
||||
expect(() => parseAuthCommand(["auth", "unknown"])).toThrow(AuthCommandError);
|
||||
await expect(resolveCredentialForPrint(parseArgs([]), runtime, "api_key")).rejects.toThrow(
|
||||
"requires --provider <provider> or --model <model>",
|
||||
);
|
||||
await expect(
|
||||
resolveCredentialForPrint(parseArgs(["--provider", "openai-codex", "--model", "gpt-5.5"]), runtime, "api_key"),
|
||||
resolveCredentialForPrint(parseArgs(["--provider", "openai-codex"]), runtime, "api_key"),
|
||||
).rejects.toThrow("configured with OAuth");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue