import { randomUUID } from "node:crypto"; import { promises as fs } from "node:fs"; import { homedir } from "node:os"; import { basename, dirname, join } from "node:path"; import { getAgentDir } from "@earendil-works/pi-coding-agent"; import { ANTIGRAVITY_CLIENT_ID, ANTIGRAVITY_CLIENT_SECRET } from "@cortexkit/antigravity-auth-core"; import { DEFAULT_PROJECT_ID, PROVIDER_ID } from "./constants"; import type { GoogleOAuthClientCredentials, OpencodeAntigravityAccount, OpencodeAntigravityImportResult, OpencodeAntigravityStorage, PiAuthCredential, PiAuthData } from "./types"; export const PI_AUTH_PATH = join(getAgentDir(), "auth.json"); function testPiAuthPath(): string | undefined { return process.env.NODE_ENV === "test" ? process.env.PI_TOOLS_SUITE_TEST_AUTH_PATH : undefined; } export function splitRefresh(refresh: string): { refreshToken: string; projectId?: string; managedProjectId?: string } { const [refreshToken = "", projectId = "", managedProjectId = ""] = refresh.split("|"); return { refreshToken: refreshToken || refresh, projectId: projectId || undefined, managedProjectId: managedProjectId || undefined, }; } export function joinRefresh(refreshToken: string, projectId?: string, managedProjectId?: string): string { const base = `${refreshToken}|${projectId ?? ""}`; return managedProjectId ? `${base}|${managedProjectId}` : base; } export function getEffectiveProjectId(refresh: string): string | undefined { const { projectId, managedProjectId } = splitRefresh(refresh); return projectId || managedProjectId; } export function encodeApiKey(access: string, projectId?: string): string { return projectId ? `${access}|${projectId}` : access; } export function decodeApiKey(apiKey: string): { access: string; projectId?: string } { const [access = apiKey, projectId = ""] = apiKey.split("|"); return { access, projectId: projectId || undefined }; } export function getDefaultOpencodeAccountsPath(): string { const configDir = process.env.OPENCODE_CONFIG_DIR ?? join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "opencode"); return join(configDir, "antigravity-accounts.json"); } export async function importDefaultOpencodeAntigravityAccount(options: { overwrite?: boolean } = {}): Promise { return importOpencodeAntigravityAccount({ sourcePath: getDefaultOpencodeAccountsPath(), authPath: getPiAuthPath(), overwrite: options.overwrite }); } export function getPiAuthPath(): string { return testPiAuthPath() ?? join(getAgentDir(), "auth.json"); } export async function readJsonFile(path: string, fallback: T): Promise { try { return JSON.parse(await fs.readFile(path, "utf8")) as T; } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return fallback; throw error; } } export async function writeJsonFileSecure(path: string, data: unknown): Promise { const directory = dirname(path); const temporaryPath = join(directory, `.${basename(path)}.${process.pid}.${randomUUID()}.tmp`); await fs.mkdir(directory, { recursive: true }); try { await fs.writeFile(temporaryPath, `${JSON.stringify(data, null, 2)}\n`, { encoding: "utf8", flag: "wx", mode: 0o600, }); await fs.rename(temporaryPath, path); await fs.chmod(path, 0o600).catch(() => undefined); } catch (error) { await fs.rm(temporaryPath, { force: true }).catch(() => undefined); throw error; } } export function getStoredAccounts(credential?: PiAuthCredential): OpencodeAntigravityAccount[] { return Array.isArray(credential?.accounts) ? credential.accounts.filter((account) => account.enabled !== false && getAccountRefreshToken(account)) : []; } export function getAccountRefreshToken(account: OpencodeAntigravityAccount): string | undefined { if (account.refreshToken) return account.refreshToken; if (!account.refresh) return undefined; const refresh = splitRefresh(account.refresh); return refresh.refreshToken || undefined; } function stringProperty(source: unknown, keys: string[]): string | undefined { if (!source || typeof source !== "object") return undefined; const record = source as Record; for (const key of keys) { const value = record[key]; if (typeof value === "string" && value) return value; } return undefined; } export function getGoogleOAuthClientCredentials(...sources: Array): GoogleOAuthClientCredentials | undefined { for (const source of sources) { const nested = source && typeof source === "object" ? (source as Record).oauthClient : undefined; const nestedClientId = stringProperty(nested, ["clientId", "client_id", "id"]); const nestedClientSecret = stringProperty(nested, ["clientSecret", "client_secret", "secret"]); const clientId = nestedClientId ?? stringProperty(source, ["clientId", "client_id", "googleClientId", "google_client_id", "oauthClientId", "oauth_client_id"]); const clientSecret = nestedClientSecret ?? stringProperty(source, ["clientSecret", "client_secret", "googleClientSecret", "google_client_secret", "oauthClientSecret", "oauth_client_secret"]); if (clientId) return { clientId, ...(clientSecret ? { clientSecret } : {}) }; } const clientId = process.env.PI_ANTIGRAVITY_GOOGLE_CLIENT_ID; const clientSecret = process.env.PI_ANTIGRAVITY_GOOGLE_CLIENT_SECRET; if (clientId) return { clientId, ...(clientSecret ? { clientSecret } : {}) }; return { clientId: ANTIGRAVITY_CLIENT_ID, clientSecret: ANTIGRAVITY_CLIENT_SECRET }; } export function clampAccountIndex(index: unknown, accountCount: number): number { if (!Number.isInteger(index) || accountCount <= 0) return 0; return Math.max(0, Math.min(index as number, accountCount - 1)); } export function getAccountProjectId(account: OpencodeAntigravityAccount): string { if (account.projectId || account.managedProjectId) return account.projectId || account.managedProjectId || DEFAULT_PROJECT_ID; if (account.refresh) { const refresh = splitRefresh(account.refresh); return refresh.projectId || refresh.managedProjectId || DEFAULT_PROJECT_ID; } return DEFAULT_PROJECT_ID; } export function accountFromCredential(credential?: PiAuthCredential): OpencodeAntigravityAccount | undefined { if (credential?.type !== "oauth" || !credential.refresh) return undefined; const refresh = splitRefresh(credential.refresh); if (!refresh.refreshToken) return undefined; return { email: credential.email, refreshToken: refresh.refreshToken, projectId: refresh.projectId || refresh.managedProjectId || DEFAULT_PROJECT_ID, managedProjectId: refresh.managedProjectId, ...getGoogleOAuthClientCredentials(credential), enabled: true, }; } export function findMatchingAccountIndex(accounts: OpencodeAntigravityAccount[], account: OpencodeAntigravityAccount): number { const email = account.email?.toLowerCase(); if (email) { const byEmail = accounts.findIndex((existing) => existing.email?.toLowerCase() === email); if (byEmail >= 0) return byEmail; } if (account.refreshToken) { return accounts.findIndex((existing) => existing.refreshToken === account.refreshToken); } return -1; } function selectOpencodeAccount( storage: OpencodeAntigravityStorage, options: { accountIndex?: number; email?: string }, ): { account: OpencodeAntigravityAccount; index: number; count: number } | undefined { const accounts = storage.accounts?.filter((account) => account && getAccountRefreshToken(account)) ?? []; if (accounts.length === 0) return undefined; if (options.email) { const email = options.email.toLowerCase(); const index = accounts.findIndex((account) => account.email?.toLowerCase() === email); return index >= 0 ? { account: accounts[index], index, count: accounts.length } : undefined; } if (typeof options.accountIndex === "number") { const index = options.accountIndex; return index >= 0 && index < accounts.length ? { account: accounts[index], index, count: accounts.length } : undefined; } const activeIndex = Number.isInteger(storage.activeIndex) ? Math.max(0, Math.min(storage.activeIndex ?? 0, accounts.length - 1)) : 0; const activeAccount = accounts[activeIndex]; if (activeAccount?.enabled !== false) return { account: activeAccount, index: activeIndex, count: accounts.length }; const firstEnabledIndex = accounts.findIndex((account) => account.enabled !== false); return firstEnabledIndex >= 0 ? { account: accounts[firstEnabledIndex], index: firstEnabledIndex, count: accounts.length } : undefined; } export async function importOpencodeAntigravityAccount(options: { sourcePath?: string; authPath?: string; overwrite?: boolean; accountIndex?: number; email?: string; } = {}): Promise { const sourcePath = options.sourcePath ?? getDefaultOpencodeAccountsPath(); const authPath = options.authPath ?? getPiAuthPath(); const storage = await readJsonFile(sourcePath, null); if (!storage || !Array.isArray(storage.accounts)) { return { imported: false, reason: "source-missing-or-invalid", sourcePath, authPath }; } const selected = selectOpencodeAccount(storage, options); if (!selected) { return { imported: false, reason: "matching-account-not-found", sourcePath, authPath, accountCount: storage.accounts.length }; } const piAuth = await readJsonFile(authPath, {}); const existing = piAuth[PROVIDER_ID]; const refresh = joinRefresh( selected.account.refreshToken!, selected.account.projectId || selected.account.managedProjectId || DEFAULT_PROJECT_ID, selected.account.managedProjectId, ); if (existing?.type === "oauth" && existing.refresh === refresh) { return { imported: false, reason: "already-imported", sourcePath, authPath, email: selected.account.email, accountIndex: selected.index, accountCount: selected.count, }; } if (existing && !options.overwrite) { return { imported: false, reason: "auth-exists-use-force", sourcePath, authPath, email: selected.account.email, accountIndex: selected.index, accountCount: selected.count, }; } const oauthClient = getGoogleOAuthClientCredentials(selected.account, existing); piAuth[PROVIDER_ID] = { ...existing, type: "oauth", refresh, access: "", expires: 0, email: selected.account.email, ...(oauthClient ? { oauthClient } : {}), accounts: storage.accounts.filter((account) => account.enabled !== false && getAccountRefreshToken(account)), activeIndex: selected.index, }; await writeJsonFileSecure(authPath, piAuth); return { imported: true, sourcePath, authPath, email: selected.account.email, accountIndex: selected.index, accountCount: selected.count, overwroteExisting: !!existing, }; }