refactor(src): split oversized modules
This commit is contained in:
317
src/agents/auth-profiles/store.ts
Normal file
317
src/agents/auth-profiles/store.ts
Normal file
@@ -0,0 +1,317 @@
|
||||
import fs from "node:fs";
|
||||
import type { OAuthCredentials } from "@mariozechner/pi-ai";
|
||||
import lockfile from "proper-lockfile";
|
||||
import { resolveOAuthPath } from "../../config/paths.js";
|
||||
import { loadJsonFile, saveJsonFile } from "../../infra/json-file.js";
|
||||
import {
|
||||
AUTH_STORE_LOCK_OPTIONS,
|
||||
AUTH_STORE_VERSION,
|
||||
log,
|
||||
} from "./constants.js";
|
||||
import { syncExternalCliCredentials } from "./external-cli-sync.js";
|
||||
import {
|
||||
ensureAuthStoreFile,
|
||||
resolveAuthStorePath,
|
||||
resolveLegacyAuthStorePath,
|
||||
} from "./paths.js";
|
||||
import type {
|
||||
AuthProfileCredential,
|
||||
AuthProfileStore,
|
||||
ProfileUsageStats,
|
||||
} from "./types.js";
|
||||
|
||||
type LegacyAuthStore = Record<string, AuthProfileCredential>;
|
||||
|
||||
function _syncAuthProfileStore(
|
||||
target: AuthProfileStore,
|
||||
source: AuthProfileStore,
|
||||
): void {
|
||||
target.version = source.version;
|
||||
target.profiles = source.profiles;
|
||||
target.order = source.order;
|
||||
target.lastGood = source.lastGood;
|
||||
target.usageStats = source.usageStats;
|
||||
}
|
||||
|
||||
export async function updateAuthProfileStoreWithLock(params: {
|
||||
agentDir?: string;
|
||||
updater: (store: AuthProfileStore) => boolean;
|
||||
}): Promise<AuthProfileStore | null> {
|
||||
const authPath = resolveAuthStorePath(params.agentDir);
|
||||
ensureAuthStoreFile(authPath);
|
||||
|
||||
let release: (() => Promise<void>) | undefined;
|
||||
try {
|
||||
release = await lockfile.lock(authPath, AUTH_STORE_LOCK_OPTIONS);
|
||||
const store = ensureAuthProfileStore(params.agentDir);
|
||||
const shouldSave = params.updater(store);
|
||||
if (shouldSave) {
|
||||
saveAuthProfileStore(store, params.agentDir);
|
||||
}
|
||||
return store;
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
if (release) {
|
||||
try {
|
||||
await release();
|
||||
} catch {
|
||||
// ignore unlock errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function coerceLegacyStore(raw: unknown): LegacyAuthStore | null {
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
const record = raw as Record<string, unknown>;
|
||||
if ("profiles" in record) return null;
|
||||
const entries: LegacyAuthStore = {};
|
||||
for (const [key, value] of Object.entries(record)) {
|
||||
if (!value || typeof value !== "object") continue;
|
||||
const typed = value as Partial<AuthProfileCredential>;
|
||||
if (
|
||||
typed.type !== "api_key" &&
|
||||
typed.type !== "oauth" &&
|
||||
typed.type !== "token"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
entries[key] = {
|
||||
...typed,
|
||||
provider: String(typed.provider ?? key),
|
||||
} as AuthProfileCredential;
|
||||
}
|
||||
return Object.keys(entries).length > 0 ? entries : null;
|
||||
}
|
||||
|
||||
function coerceAuthStore(raw: unknown): AuthProfileStore | null {
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
const record = raw as Record<string, unknown>;
|
||||
if (!record.profiles || typeof record.profiles !== "object") return null;
|
||||
const profiles = record.profiles as Record<string, unknown>;
|
||||
const normalized: Record<string, AuthProfileCredential> = {};
|
||||
for (const [key, value] of Object.entries(profiles)) {
|
||||
if (!value || typeof value !== "object") continue;
|
||||
const typed = value as Partial<AuthProfileCredential>;
|
||||
if (
|
||||
typed.type !== "api_key" &&
|
||||
typed.type !== "oauth" &&
|
||||
typed.type !== "token"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (!typed.provider) continue;
|
||||
normalized[key] = typed as AuthProfileCredential;
|
||||
}
|
||||
const order =
|
||||
record.order && typeof record.order === "object"
|
||||
? Object.entries(record.order as Record<string, unknown>).reduce(
|
||||
(acc, [provider, value]) => {
|
||||
if (!Array.isArray(value)) return acc;
|
||||
const list = value
|
||||
.map((entry) => (typeof entry === "string" ? entry.trim() : ""))
|
||||
.filter(Boolean);
|
||||
if (list.length === 0) return acc;
|
||||
acc[provider] = list;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string[]>,
|
||||
)
|
||||
: undefined;
|
||||
return {
|
||||
version: Number(record.version ?? AUTH_STORE_VERSION),
|
||||
profiles: normalized,
|
||||
order,
|
||||
lastGood:
|
||||
record.lastGood && typeof record.lastGood === "object"
|
||||
? (record.lastGood as Record<string, string>)
|
||||
: undefined,
|
||||
usageStats:
|
||||
record.usageStats && typeof record.usageStats === "object"
|
||||
? (record.usageStats as Record<string, ProfileUsageStats>)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function mergeOAuthFileIntoStore(store: AuthProfileStore): boolean {
|
||||
const oauthPath = resolveOAuthPath();
|
||||
const oauthRaw = loadJsonFile(oauthPath);
|
||||
if (!oauthRaw || typeof oauthRaw !== "object") return false;
|
||||
const oauthEntries = oauthRaw as Record<string, OAuthCredentials>;
|
||||
let mutated = false;
|
||||
for (const [provider, creds] of Object.entries(oauthEntries)) {
|
||||
if (!creds || typeof creds !== "object") continue;
|
||||
const profileId = `${provider}:default`;
|
||||
if (store.profiles[profileId]) continue;
|
||||
store.profiles[profileId] = {
|
||||
type: "oauth",
|
||||
provider,
|
||||
...creds,
|
||||
};
|
||||
mutated = true;
|
||||
}
|
||||
return mutated;
|
||||
}
|
||||
|
||||
export function loadAuthProfileStore(): AuthProfileStore {
|
||||
const authPath = resolveAuthStorePath();
|
||||
const raw = loadJsonFile(authPath);
|
||||
const asStore = coerceAuthStore(raw);
|
||||
if (asStore) {
|
||||
// Sync from external CLI tools on every load
|
||||
const synced = syncExternalCliCredentials(asStore);
|
||||
if (synced) {
|
||||
saveJsonFile(authPath, asStore);
|
||||
}
|
||||
return asStore;
|
||||
}
|
||||
|
||||
const legacyRaw = loadJsonFile(resolveLegacyAuthStorePath());
|
||||
const legacy = coerceLegacyStore(legacyRaw);
|
||||
if (legacy) {
|
||||
const store: AuthProfileStore = {
|
||||
version: AUTH_STORE_VERSION,
|
||||
profiles: {},
|
||||
};
|
||||
for (const [provider, cred] of Object.entries(legacy)) {
|
||||
const profileId = `${provider}:default`;
|
||||
if (cred.type === "api_key") {
|
||||
store.profiles[profileId] = {
|
||||
type: "api_key",
|
||||
provider: String(cred.provider ?? provider),
|
||||
key: cred.key,
|
||||
...(cred.email ? { email: cred.email } : {}),
|
||||
};
|
||||
} else if (cred.type === "token") {
|
||||
store.profiles[profileId] = {
|
||||
type: "token",
|
||||
provider: String(cred.provider ?? provider),
|
||||
token: cred.token,
|
||||
...(typeof cred.expires === "number"
|
||||
? { expires: cred.expires }
|
||||
: {}),
|
||||
...(cred.email ? { email: cred.email } : {}),
|
||||
};
|
||||
} else {
|
||||
store.profiles[profileId] = {
|
||||
type: "oauth",
|
||||
provider: String(cred.provider ?? provider),
|
||||
access: cred.access,
|
||||
refresh: cred.refresh,
|
||||
expires: cred.expires,
|
||||
...(cred.enterpriseUrl ? { enterpriseUrl: cred.enterpriseUrl } : {}),
|
||||
...(cred.projectId ? { projectId: cred.projectId } : {}),
|
||||
...(cred.accountId ? { accountId: cred.accountId } : {}),
|
||||
...(cred.email ? { email: cred.email } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
syncExternalCliCredentials(store);
|
||||
return store;
|
||||
}
|
||||
|
||||
const store: AuthProfileStore = { version: AUTH_STORE_VERSION, profiles: {} };
|
||||
syncExternalCliCredentials(store);
|
||||
return store;
|
||||
}
|
||||
|
||||
export function ensureAuthProfileStore(
|
||||
agentDir?: string,
|
||||
options?: { allowKeychainPrompt?: boolean },
|
||||
): AuthProfileStore {
|
||||
const authPath = resolveAuthStorePath(agentDir);
|
||||
const raw = loadJsonFile(authPath);
|
||||
const asStore = coerceAuthStore(raw);
|
||||
if (asStore) {
|
||||
// Sync from external CLI tools on every load
|
||||
const synced = syncExternalCliCredentials(asStore, options);
|
||||
if (synced) {
|
||||
saveJsonFile(authPath, asStore);
|
||||
}
|
||||
return asStore;
|
||||
}
|
||||
|
||||
const legacyRaw = loadJsonFile(resolveLegacyAuthStorePath(agentDir));
|
||||
const legacy = coerceLegacyStore(legacyRaw);
|
||||
const store: AuthProfileStore = {
|
||||
version: AUTH_STORE_VERSION,
|
||||
profiles: {},
|
||||
};
|
||||
if (legacy) {
|
||||
for (const [provider, cred] of Object.entries(legacy)) {
|
||||
const profileId = `${provider}:default`;
|
||||
if (cred.type === "api_key") {
|
||||
store.profiles[profileId] = {
|
||||
type: "api_key",
|
||||
provider: String(cred.provider ?? provider),
|
||||
key: cred.key,
|
||||
...(cred.email ? { email: cred.email } : {}),
|
||||
};
|
||||
} else if (cred.type === "token") {
|
||||
store.profiles[profileId] = {
|
||||
type: "token",
|
||||
provider: String(cred.provider ?? provider),
|
||||
token: cred.token,
|
||||
...(typeof cred.expires === "number"
|
||||
? { expires: cred.expires }
|
||||
: {}),
|
||||
...(cred.email ? { email: cred.email } : {}),
|
||||
};
|
||||
} else {
|
||||
store.profiles[profileId] = {
|
||||
type: "oauth",
|
||||
provider: String(cred.provider ?? provider),
|
||||
access: cred.access,
|
||||
refresh: cred.refresh,
|
||||
expires: cred.expires,
|
||||
...(cred.enterpriseUrl ? { enterpriseUrl: cred.enterpriseUrl } : {}),
|
||||
...(cred.projectId ? { projectId: cred.projectId } : {}),
|
||||
...(cred.accountId ? { accountId: cred.accountId } : {}),
|
||||
...(cred.email ? { email: cred.email } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const mergedOAuth = mergeOAuthFileIntoStore(store);
|
||||
const syncedCli = syncExternalCliCredentials(store, options);
|
||||
const shouldWrite = legacy !== null || mergedOAuth || syncedCli;
|
||||
if (shouldWrite) {
|
||||
saveJsonFile(authPath, store);
|
||||
}
|
||||
|
||||
// PR #368: legacy auth.json could get re-migrated from other agent dirs,
|
||||
// overwriting fresh OAuth creds with stale tokens (fixes #363). Delete only
|
||||
// after we've successfully written auth-profiles.json.
|
||||
if (shouldWrite && legacy !== null) {
|
||||
const legacyPath = resolveLegacyAuthStorePath(agentDir);
|
||||
try {
|
||||
fs.unlinkSync(legacyPath);
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException)?.code !== "ENOENT") {
|
||||
log.warn("failed to delete legacy auth.json after migration", {
|
||||
err,
|
||||
legacyPath,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return store;
|
||||
}
|
||||
|
||||
export function saveAuthProfileStore(
|
||||
store: AuthProfileStore,
|
||||
agentDir?: string,
|
||||
): void {
|
||||
const authPath = resolveAuthStorePath(agentDir);
|
||||
const payload = {
|
||||
version: AUTH_STORE_VERSION,
|
||||
profiles: store.profiles,
|
||||
order: store.order ?? undefined,
|
||||
lastGood: store.lastGood ?? undefined,
|
||||
usageStats: store.usageStats ?? undefined,
|
||||
} satisfies AuthProfileStore;
|
||||
saveJsonFile(authPath, payload);
|
||||
}
|
||||
Reference in New Issue
Block a user