// A CredentialStore over pi's auth.json, locked the way pi locks it, so berry and pi share logins and // never double-refresh a rotating oauth token. import { mkdir, readFile, stat, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import { dirname, join } from "node:path"; import lockfile from "proper-lockfile"; import type { Credential, CredentialStore } from "@earendil-works/pi-ai"; type AuthData = Record; export const PI_AUTH_PATH = join(homedir(), ".pi", "agent", "auth.json"); /** pi's config values: commands are rejected; `$VAR`/`${VAR}` interpolate, `$$` and `$!` escape. */ export function resolveConfigValue( config: string, env?: Record, ): string | undefined { if (config.startsWith("!")) { throw new Error( "Command-based API keys are disabled; use a literal key or an environment variable.", ); } let resolved = ""; for ( const [text, braced, bare] of config.matchAll( /\$[$!]|\$\{([A-Za-z_]\w*)\}|\$([A-Za-z_]\w*)|[^$]+|\$/g, ) ) { const name = braced ?? bare; if (name === undefined) { resolved += text.length === 2 && text[0] === "$" ? text[1] : text; continue; } const value = env?.[name] || Deno.env.get(name); if (!value) return undefined; resolved += value; } return resolved; } export function piAuthStore(path = PI_AUTH_PATH): CredentialStore { async function locked( use: ( data: AuthData, ) => { result: T; next?: AuthData } | Promise<{ result: T; next?: AuthData }>, signal?: AbortSignal, ): Promise { signal?.throwIfAborted(); await mkdir(dirname(path), { recursive: true, mode: 0o700 }); await writeFile(path, "{}", { mode: 0o600, flag: "wx" }).catch((error) => { if (error.code !== "EEXIST") throw error; }); const release = await lockfile.lock(path, { realpath: false, stale: 30_000, retries: { retries: 30, factor: 1.5, minTimeout: 10, maxTimeout: 1_000 }, }); try { signal?.throwIfAborted(); const text = (await readFile(path, "utf8")).replace(/^\uFEFF/, ""); const { result, next } = await use(text.trim() ? JSON.parse(text) : {}); signal?.throwIfAborted(); if (next) await writeFile(path, JSON.stringify(next, null, 2), { mode: 0o600 }); return result; } finally { await release().catch(() => {}); } } // reads take the lock only when the file changed, as pi's do: listing models reads every provider at once let cached: { revision: string; data: Promise } | undefined; async function current(signal?: AbortSignal): Promise { const info = await stat(path).catch(() => undefined); const revision = info ? `${info.mtimeMs}:${info.size}` : "absent"; if (cached?.revision !== revision) { const data = locked((data) => ({ result: data }), signal); cached = { revision, data }; data.catch(() => cached?.data === data && (cached = undefined)); } return await cached.data; } return { read: async (provider, options) => { const credential = (await current(options?.signal))[provider]; if (credential?.type !== "api_key" || credential.key === undefined) return credential; return { ...credential, key: resolveConfigValue(credential.key, credential.env) }; }, list: async (options) => Object.entries(await current(options?.signal)).map(([providerId, { type }]) => ({ providerId, type, })), modify: (provider, change, options) => locked(async (data) => { const next = await change(data[provider]); return next === undefined ? { result: data[provider] } : { result: next, next: { ...data, [provider]: next } }; }, options?.signal), delete: (provider, options) => locked((data) => { const { [provider]: _, ...rest } = data; return { result: undefined, next: rest }; }, options?.signal), }; }