diff --git a/lib/cache-lock.ts b/lib/cache-lock.ts index 79c949a..af10783 100644 --- a/lib/cache-lock.ts +++ b/lib/cache-lock.ts @@ -1,6 +1,151 @@ import fs from "node:fs/promises" import path from "node:path" -import lockfile from "proper-lockfile" + +type LockFunction = (file: string, options?: Record) => Promise<() => Promise> + +type RetryOptions = { + retries: number + minTimeout: number + maxTimeout: number +} + +let cachedLockFunction: LockFunction | undefined + +function resolveLockFunction(value: unknown, visited = new Set()): LockFunction | undefined { + if (typeof value === "function") { + return value as LockFunction + } + if (!value || typeof value !== "object") { + return undefined + } + if (visited.has(value)) { + return undefined + } + visited.add(value) + + const record = value as Record + const direct = resolveLockFunction(record.lock, visited) + if (direct) return direct + + const viaDefault = resolveLockFunction(record.default, visited) + if (viaDefault) return viaDefault + + const viaModule = resolveLockFunction(record.module, visited) + if (viaModule) return viaModule + + const viaExports = resolveLockFunction(record.exports, visited) + if (viaExports) return viaExports + + return undefined +} + +function isFsErrorCode(error: unknown, code: string): boolean { + return typeof error === "object" && error !== null && "code" in error && error.code === code +} + +function toRetryOptions(value: unknown): RetryOptions { + const defaultOptions: RetryOptions = { + retries: 0, + minTimeout: 50, + maxTimeout: 200 + } + if (!value || typeof value !== "object") { + return defaultOptions + } + + const record = value as Record + const retries = + typeof record.retries === "number" && Number.isFinite(record.retries) + ? Math.max(0, Math.floor(record.retries)) + : defaultOptions.retries + const minTimeout = + typeof record.minTimeout === "number" && Number.isFinite(record.minTimeout) + ? Math.max(1, Math.floor(record.minTimeout)) + : defaultOptions.minTimeout + const maxTimeout = + typeof record.maxTimeout === "number" && Number.isFinite(record.maxTimeout) + ? Math.max(minTimeout, Math.floor(record.maxTimeout)) + : defaultOptions.maxTimeout + + return { + retries, + minTimeout, + maxTimeout + } +} + +async function sleep(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)) +} + +async function lockWithDirectoryFallback( + targetPath: string, + options?: Record +): Promise<() => Promise> { + const lockDir = `${targetPath}.lock` + const retry = toRetryOptions(options?.retries) + const staleMs = typeof options?.stale === "number" && Number.isFinite(options.stale) ? Math.max(1, options.stale) : 0 + + for (let attempt = 0; ; attempt += 1) { + try { + await fs.mkdir(lockDir) + return async () => { + try { + await fs.rm(lockDir, { recursive: true, force: true }) + } catch (error) { + if (!isFsErrorCode(error, "ENOENT")) { + throw error + } + } + } + } catch (error) { + if (!isFsErrorCode(error, "EEXIST")) { + throw error + } + + if (staleMs > 0) { + try { + const stat = await fs.stat(lockDir) + if (Date.now() - stat.mtimeMs > staleMs) { + await fs.rm(lockDir, { recursive: true, force: true }) + continue + } + } catch (staleError) { + if (!isFsErrorCode(staleError, "ENOENT")) { + throw staleError + } + continue + } + } + + if (attempt >= retry.retries) { + throw error + } + + const timeout = Math.min(retry.maxTimeout, retry.minTimeout * 2 ** attempt) + await sleep(timeout) + } + } +} + +async function resolveImportedLockFunction(specifier: string): Promise { + try { + return resolveLockFunction(await import(specifier)) + } catch { + return undefined + } +} + +async function getLockFunction(): Promise { + if (cachedLockFunction) return cachedLockFunction + + cachedLockFunction = + (await resolveImportedLockFunction("proper-lockfile")) ?? + (await resolveImportedLockFunction("proper-lockfile/index.js")) ?? + lockWithDirectoryFallback + + return cachedLockFunction +} const LOCK_RETRIES = { retries: 20, @@ -41,7 +186,8 @@ export async function withLockedDirectory( options: LockTargetOptions = {} ): Promise { await fs.mkdir(directoryPath, { recursive: true }) - const release = await lockfile.lock(directoryPath, resolveLockOptions(options)) + const lock = await getLockFunction() + const release = await lock(directoryPath, resolveLockOptions(options)) try { return await fn() } finally { @@ -57,7 +203,8 @@ export async function withLockedFile( await fs.mkdir(path.dirname(filePath), { recursive: true }) const lockTargetPath = lockTargetPathForFile(filePath) await ensureLockTargetFile(lockTargetPath) - const release = await lockfile.lock(lockTargetPath, resolveLockOptions(options)) + const lock = await getLockFunction() + const release = await lock(lockTargetPath, resolveLockOptions(options)) try { return await fn() } finally { diff --git a/lib/codex-native/oauth-auth-methods.ts b/lib/codex-native/oauth-auth-methods.ts index ed7837d..dfdefc7 100644 --- a/lib/codex-native/oauth-auth-methods.ts +++ b/lib/codex-native/oauth-auth-methods.ts @@ -1,7 +1,6 @@ import type { CodexSpoofMode } from "../config.js" import type { OpenAIAuthMode } from "../types.js" import { resolveRequestUserAgent } from "./client-identity.js" -import { resolveCodexOriginator } from "./originator.js" import { buildAuthorizeUrl, CLIENT_ID, @@ -13,10 +12,11 @@ import { OAUTH_DEVICE_AUTH_TIMEOUT_MS, OAUTH_HTTP_TIMEOUT_MS, OAUTH_POLLING_SAFETY_MARGIN_MS, - sleep, type PkceCodes, + sleep, type TokenResponse } from "./oauth-utils.js" +import { resolveCodexOriginator } from "./originator.js" type OAuthSuccess = { type: "success" @@ -101,6 +101,9 @@ function toOAuthSuccess(tokens: TokenResponse): OAuthSuccess { export function createBrowserOAuthAuthorize(deps: BrowserAuthorizeDeps) { return async (inputs?: Record): Promise => { + const shouldUseInteractiveMenu = + process.env.OPENCODE_NO_BROWSER !== "1" && process.stdin.isTTY && process.stdout.isTTY + const runSingleBrowserOAuthInline = async (): Promise => { let redirectUri: string try { @@ -184,7 +187,7 @@ export function createBrowserOAuthAuthorize(deps: BrowserAuthorizeDeps) { } } - if (inputs && process.env.OPENCODE_NO_BROWSER !== "1" && process.stdin.isTTY && process.stdout.isTTY) { + if (shouldUseInteractiveMenu) { return runInteractiveBrowserAuthLoop() }