diff --git a/index.ts b/index.ts index 915b2ed8..64042f54 100644 --- a/index.ts +++ b/index.ts @@ -124,6 +124,7 @@ import { } from "./lib/logger.js"; import { createQuotaMonitor } from "./lib/quota-notifications.js"; import { checkAndNotify } from "./lib/auto-update-checker.js"; +import { describePluginOrigin, getPluginOrigin, recordPluginOrigin } from "./lib/plugin-origin.js"; import { handleContextOverflow } from "./lib/context-overflow.js"; import { AccountManager, @@ -397,6 +398,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { let accountManagerPromise: Promise | null = null; let loaderMutex: Promise | null = null; let startupPrewarmTriggered = false; + let startupOriginRecorded = false; let startupPreflightShown = false; let beginnerSafeModeEnabled = false; const MIN_BACKOFF_MS = 100; @@ -2325,10 +2327,10 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { }); } + const underTestRunner = + process.env.VITEST === "true" || process.env.NODE_ENV === "test"; const prewarmEnabled = - process.env.CODEX_AUTH_PREWARM !== "0" && - process.env.VITEST !== "true" && - process.env.NODE_ENV !== "test"; + process.env.CODEX_AUTH_PREWARM !== "0" && !underTestRunner; if (!startupPrewarmTriggered && prewarmEnabled && getRequestTransformMode(pluginConfig) === "legacy") { startupPrewarmTriggered = true; @@ -2346,9 +2348,25 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { ) : null; + const pluginOrigin = getPluginOrigin(); + if (pluginOrigin && !startupOriginRecorded) { + startupOriginRecorded = true; + if (pluginOrigin.isLocalCheckout) { + logInfo(`Running from ${describePluginOrigin(pluginOrigin)}`); + } + if (!underTestRunner) { + recordPluginOrigin(pluginOrigin).catch((err) => { + logDebug(`Failed to record plugin origin: ${err instanceof Error ? err.message : String(err)}`); + }); + } + } + checkAndNotify(async (message, variant) => { await showToast(message, variant); - }, { autoUpdate: autoUpdateEnabled }).catch((err) => { + }, { + autoUpdate: autoUpdateEnabled, + localCheckout: pluginOrigin?.isLocalCheckout ?? false, + }).catch((err) => { logDebug(`Update check failed: ${err instanceof Error ? err.message : String(err)}`); }); await runStartupPreflight(); diff --git a/lib/auto-update-checker.ts b/lib/auto-update-checker.ts index 3ced6d10..6f0744a0 100644 --- a/lib/auto-update-checker.ts +++ b/lib/auto-update-checker.ts @@ -104,6 +104,7 @@ export interface UpdateCheckResult { export interface CheckAndNotifyOptions { autoUpdate?: boolean; scheduleCacheClear?: () => boolean; + localCheckout?: boolean; } function getManagedPackageNames(): string[] { @@ -234,6 +235,14 @@ export async function checkAndNotify( options: CheckAndNotifyOptions = {}, ): Promise { try { + // The published version says nothing about a build loaded from a checkout, + // and evicting the cache would not update it. Offering either is noise at + // best and an invitation to overwrite the checkout at worst. + if (options.localCheckout) { + log.debug("Skipping the update check for a plugin loaded from a local checkout"); + return; + } + const result = await checkForUpdates(); if (result.hasUpdate && result.latestVersion) { diff --git a/lib/plugin-origin.ts b/lib/plugin-origin.ts new file mode 100644 index 00000000..183ce8f4 --- /dev/null +++ b/lib/plugin-origin.ts @@ -0,0 +1,379 @@ +/** + * Where this plugin is running from, and where it has run from before. + * + * OpenCode can load the plugin from the published package or from a checkout + * a developer points it at, and the two are indistinguishable at runtime + * unless the plugin asks. Knowing which one is live decides whether offering + * an update makes sense, and a record of previous origins is what turns "my + * edits stopped taking effect" into a diagnosable event. + */ + +import { existsSync, readFileSync } from "node:fs"; +import { mkdir, rm, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { dirname, isAbsolute, join, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { lock } from "proper-lockfile"; +import { createLogger } from "./logger.js"; +import { renameWithWindowsRetry } from "./storage/atomic-write.js"; + +const log = createLogger("plugin-origin"); + +/** + * The origin history file name, shared with the installer. + * + * The installer is plain JS that must run before anything is built, so it + * cannot import this module and declares the same name itself. A test pins the + * two together, because a silent disagreement would leave each side reporting + * confidently about a file the other never writes. + */ +export const HISTORY_FILE_NAME = "oc-codex-multi-auth-origin.json"; +const HISTORY_VERSION = 1; +const MAX_SIGHTINGS = 10; +const PACKAGE_ROOT_LOOKUP_DEPTH = 3; + +/** The lease covers one small read and one small write, so it is short. */ +const HISTORY_LOCK_STALE_MS = 10_000; +const HISTORY_LOCK_UPDATE_MS = 2_000; +const HISTORY_LOCK_RETRIES = { + retries: 10, + factor: 1.8, + minTimeout: 50, + maxTimeout: 500, + randomize: true, +} as const; + +const MANAGED_PACKAGE_NAMES = new Set(["oc-codex-multi-auth", "oc-chatgpt-multi-auth"]); + +function isManagedPackageName(name: string): boolean { + return MANAGED_PACKAGE_NAMES.has(name.toLowerCase()); +} + +function defaultCacheDirectory(home: string = homedir()): string { + return join(home, ".cache", "opencode"); +} + +export interface PluginOrigin { + name: string; + version: string; + root: string; + isLocalCheckout: boolean; +} + +export interface PluginOriginSighting extends PluginOrigin { + firstSeen: string; + lastSeen: string; +} + +export interface PluginOriginHistory { + version: number; + sightings: PluginOriginSighting[]; +} + +interface PackageManifest { + name?: unknown; + version?: unknown; +} + +function emptyHistory(): PluginOriginHistory { + return { version: HISTORY_VERSION, sightings: [] }; +} + +function readPackageManifest(directory: string): PackageManifest | null { + try { + const parsed: unknown = JSON.parse(readFileSync(join(directory, "package.json"), "utf8")); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null; + return parsed as PackageManifest; + } catch { + return null; + } +} + +function manifestName(manifest: PackageManifest | null): string | null { + const name = manifest?.name; + return typeof name === "string" && name.trim() ? name.trim() : null; +} + +/** Built output lives inside the package, so the root is at or above it. */ +export function findPackageRoot(startDirectory: string): string | null { + let current = resolve(startDirectory); + for (let depth = 0; depth <= PACKAGE_ROOT_LOOKUP_DEPTH; depth += 1) { + if (manifestName(readPackageManifest(current))) return current; + const parent = dirname(current); + if (parent === current) return null; + current = parent; + } + return null; +} + +function pathSegments(path: string): string[] { + return path.replaceAll("\\", "/").replace(/\/+$/, "").split("/").filter(Boolean); +} + +function foldPathCase(value: string, platform: NodeJS.Platform): string { + return platform === "win32" || platform === "darwin" ? value.toLowerCase() : value; +} + +function isInsideDirectory( + candidate: string, + directory: string, + platform: NodeJS.Platform, +): boolean { + const relativePath = relative( + foldPathCase(resolve(directory), platform), + foldPathCase(resolve(candidate), platform), + ); + return relativePath !== "" && !relativePath.startsWith("..") && !isAbsolute(relativePath); +} + +/** + * How two spellings of a root are told apart. Windows reaches one directory + * under several of them, and so does a default-case-insensitive APFS volume, + * so comparing verbatim there would let `C:\Repo` and `c:\repo` - or `/Repo` + * and `/repo` - occupy two slots in a bounded history and evict a genuinely + * different origin between them. + */ +function rootComparisonKey(root: string, platform: NodeJS.Platform = process.platform): string { + const normalized = root.replaceAll("\\", "/").replace(/\/+$/, ""); + return foldPathCase(normalized, platform); +} + +/** + * A root a package manager chose, as opposed to one a human did. The version + * suffix is what separates OpenCode's plugin cache from a monorepo that merely + * keeps its packages in a `packages/` directory. + */ +export function isPackageManagerRoot( + root: string, + platform: NodeJS.Platform = process.platform, + cacheDirectory: string = defaultCacheDirectory(), +): boolean { + const segments = pathSegments(root).map((segment) => foldPathCase(segment, platform)); + if ( + segments.some( + (segment, index) => + segment === "node_modules" || + (segments[index - 1] === "packages" && segment.includes("@")), + ) + ) { + return true; + } + return isInsideDirectory(root, cacheDirectory, platform); +} + +export function resolvePluginOrigin(moduleUrl: string): PluginOrigin | null { + let moduleDirectory: string; + try { + moduleDirectory = dirname(fileURLToPath(moduleUrl)); + } catch { + return null; + } + + const root = findPackageRoot(moduleDirectory); + if (!root) return null; + + const manifest = readPackageManifest(root); + const name = manifestName(manifest); + if (!name || !isManagedPackageName(name)) return null; + const version = typeof manifest?.version === "string" ? manifest.version : "0.0.0"; + + return { name, version, root, isLocalCheckout: !isPackageManagerRoot(root) }; +} + +let cachedOrigin: PluginOrigin | null | undefined; + +/** The origin of this running build. Fixed for the life of the process. */ +export function getPluginOrigin(): PluginOrigin | null { + if (cachedOrigin === undefined) cachedOrigin = resolvePluginOrigin(import.meta.url); + return cachedOrigin; +} + +export function getPluginOriginHistoryPath(home: string = homedir()): string { + return join(home, ".opencode", HISTORY_FILE_NAME); +} + +function isSighting(value: unknown): value is PluginOriginSighting { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const candidate = value as Record; + return ( + typeof candidate.name === "string" && + typeof candidate.version === "string" && + typeof candidate.root === "string" && + typeof candidate.isLocalCheckout === "boolean" && + typeof candidate.firstSeen === "string" && + typeof candidate.lastSeen === "string" + ); +} + +export function readPluginOriginHistory( + historyPath: string = getPluginOriginHistoryPath(), +): PluginOriginHistory { + try { + const parsed: unknown = JSON.parse(readFileSync(historyPath, "utf8")); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return emptyHistory(); + const sightings = (parsed as { sightings?: unknown }).sightings; + if (!Array.isArray(sightings)) return emptyHistory(); + return { version: HISTORY_VERSION, sightings: sightings.filter(isSighting) }; + } catch { + return emptyHistory(); + } +} + +function byLastSeen(left: PluginOriginSighting, right: PluginOriginSighting): number { + return (Date.parse(left.lastSeen) || 0) - (Date.parse(right.lastSeen) || 0); +} + +/** + * History is appended to rather than overwritten, so one origin replacing + * another leaves both on record. A last-writer-wins marker would instead + * confirm whatever state a clobber left behind. + */ +export function withSighting( + history: PluginOriginHistory, + origin: PluginOrigin, + seenAt: string, + platform: NodeJS.Platform = process.platform, +): PluginOriginHistory { + // Matched on the comparison key, recorded as the origin spells it: a reader + // is shown the path they configured, not a folded version of it. + const key = rootComparisonKey(origin.root, platform); + const sameRoot = (sighting: PluginOriginSighting) => + rootComparisonKey(sighting.root, platform) === key; + const previous = history.sightings.find(sameRoot); + const others = history.sightings.filter((sighting) => !sameRoot(sighting)); + const current: PluginOriginSighting = { + ...origin, + firstSeen: previous?.firstSeen ?? seenAt, + lastSeen: seenAt, + }; + return { + version: HISTORY_VERSION, + sightings: [...others, current].slice(-MAX_SIGHTINGS), + }; +} + +/** + * Replaces the history, unless ownership was lost while the new copy was being + * written. Only the rename is visible to anybody else, so that is where the + * question has to be asked: checking earlier leaves the whole of `writeFile` + * as a window in which the lease can be reclaimed and newer history written, + * which this rename would then replace with an older snapshot. + * + * Returns whether the replacement happened. + */ +async function writeHistory( + historyPath: string, + history: PluginOriginHistory, + stillOwned: () => boolean, +): Promise { + const temporaryPath = `${historyPath}.${process.pid}.tmp`; + await writeFile(temporaryPath, `${JSON.stringify(history, null, 2)}\n`, "utf-8"); + try { + if (!stillOwned()) return false; + await renameWithWindowsRetry(temporaryPath, historyPath); + return true; + } finally { + if (existsSync(temporaryPath)) { + await rm(temporaryPath, { force: true }).catch(() => undefined); + } + } +} + +/** + * Records this origin without dropping anybody else's. + * + * Every OpenCode process records at startup, and a machine running many of + * them starts several at once, so read-modify-write on a shared file is a real + * race rather than a theoretical one. The reader that matters here asks which + * origins have been seen, so a lost sighting is a lost answer: the very + * handover this file exists to report - a checkout replaced by the installed + * package - is two different origins written at close to the same time. + * + * The history is therefore re-read INSIDE the lease, so each writer merges + * into what is actually on disk. A writer that cannot take the lease records + * nothing rather than overwriting blind; it is about to be started again, and + * a missing sighting costs a later startup while a clobbered one costs the + * only evidence there was. A lease reclaimed mid-write is the same situation + * arriving later, and is answered the same way. + */ +export async function recordPluginOrigin( + origin: PluginOrigin, + historyPath: string = getPluginOriginHistoryPath(), + now: () => Date = () => new Date(), +): Promise { + await mkdir(dirname(historyPath), { recursive: true }); + + let compromised: Error | undefined; + let release: (() => Promise) | null = null; + try { + release = await lock(historyPath, { + realpath: false, + lockfilePath: `${historyPath}.lock`, + stale: HISTORY_LOCK_STALE_MS, + update: HISTORY_LOCK_UPDATE_MS, + retries: HISTORY_LOCK_RETRIES, + // proper-lockfile's default rethrows from the timer that refreshes the + // lease, so it lands outside every promise chain and ends the process + // hosting this plugin. A stalled event loop is enough to trigger it. + // Nothing here is worth an editor closing, so the loss is recorded and + // the write is abandoned instead. + onCompromised: (error: Error) => { + compromised = error; + log.warn("The plugin origin history lease was reclaimed", { + path: `${historyPath}.lock`, + error: error.message, + }); + }, + }); + } catch (error) { + log.debug("Skipped recording the plugin origin; another process holds the history", { + error: error instanceof Error ? error.message : String(error), + }); + return readPluginOriginHistory(historyPath); + } + + try { + const next = withSighting(readPluginOriginHistory(historyPath), origin, now().toISOString()); + // Whoever reclaimed the lease owns the file now, so writing what we read + // before they did would drop their sighting - the clobber the lease + // exists to prevent. Asked again at the rename, since the lease can be + // lost at any point up to it. + if (compromised) return readPluginOriginHistory(historyPath); + const written = await writeHistory(historyPath, next, () => !compromised); + return written ? next : readPluginOriginHistory(historyPath); + } finally { + await release().catch(() => undefined); + } +} + +/** + * The checkout this plugin used to run from and no longer does. Reported only + * when the current origin is package-manager output, since a move between two + * checkouts is an ordinary thing to do deliberately. + */ +export function findReplacedLocalCheckout( + origin: PluginOrigin, + history: PluginOriginHistory, + platform: NodeJS.Platform = process.platform, +): PluginOriginSighting | null { + if (origin.isLocalCheckout) return null; + const currentKey = rootComparisonKey(origin.root, platform); + return ( + history.sightings + .filter( + (sighting) => + isManagedPackageName(sighting.name) && + sighting.isLocalCheckout && + rootComparisonKey(sighting.root, platform) !== currentKey, + ) + .sort(byLastSeen) + .at(-1) ?? null + ); +} + +export function describePluginOrigin(origin: PluginOrigin | null): string { + if (!origin) return "unknown"; + return origin.isLocalCheckout + ? `local checkout at ${origin.root} (v${origin.version})` + : `installed package v${origin.version}`; +} diff --git a/lib/tools/codex-doctor.ts b/lib/tools/codex-doctor.ts index 082d4c9e..83ca9660 100644 --- a/lib/tools/codex-doctor.ts +++ b/lib/tools/codex-doctor.ts @@ -37,6 +37,12 @@ import { renderJsonOutput, type RoutingVisibilitySnapshot, } from "../runtime.js"; +import { + describePluginOrigin, + findReplacedLocalCheckout, + getPluginOrigin, + readPluginOriginHistory, +} from "../plugin-origin.js"; import { findAccountIndexByIdentity, type RefreshAccountIdentity, @@ -153,6 +159,18 @@ export function createCodexDoctorTool(ctx: ToolContext): ToolDefinition { .join(", ")}).`, }); } + const origin = getPluginOrigin(); + const replacedCheckout = origin + ? findReplacedLocalCheckout(origin, readPluginOriginHistory()) + : null; + if (replacedCheckout) { + findings.push({ + severity: "warning", + code: "plugin-origin-replaced", + summary: `This plugin now loads from the installed package, but ran from ${replacedCheckout.root} until ${replacedCheckout.lastSeen}.`, + action: `Point the OpenCode plugin entry back at ${replacedCheckout.root} if your own build should still be loaded.`, + }); + } findings.push(...extraFindings); return { storage, activeIndex, snapshots, runtime, summary, findings, nextAction }; @@ -367,6 +385,7 @@ export function createCodexDoctorTool(ctx: ToolContext): ToolDefinition { technicalSnapshot: deep ? { storagePath: getStoragePath(), + pluginOrigin: getPluginOrigin(), runtimeFailures: { failedRequests: runtime.failedRequests, rateLimitedResponses: runtime.rateLimitedResponses, @@ -460,6 +479,14 @@ export function createCodexDoctorTool(ctx: ToolContext): ToolDefinition { lines.push( formatUiKeyValue(ui, "Storage", getStoragePath(), "muted"), ); + lines.push( + formatUiKeyValue( + ui, + "Running from", + describePluginOrigin(getPluginOrigin()), + "muted", + ), + ); lines.push( formatUiKeyValue( ui, @@ -520,6 +547,7 @@ export function createCodexDoctorTool(ctx: ToolContext): ToolDefinition { lines.push(""); lines.push("Technical snapshot:"); lines.push(` Storage: ${getStoragePath()}`); + lines.push(` Running from: ${describePluginOrigin(getPluginOrigin())}`); lines.push( ` Runtime failures: failed=${runtime.failedRequests}, rateLimited=${runtime.rateLimitedResponses}, authRefreshFailed=${runtime.authRefreshFailures}, server=${runtime.serverErrors}, network=${runtime.networkErrors}`, ); diff --git a/lib/tools/codex-status.ts b/lib/tools/codex-status.ts index 5567140f..32da3708 100644 --- a/lib/tools/codex-status.ts +++ b/lib/tools/codex-status.ts @@ -22,6 +22,7 @@ import { formatUiSection, } from "../ui/format.js"; import { normalizeToolOutputFormat, renderJsonOutput } from "../runtime.js"; +import { describePluginOrigin, getPluginOrigin } from "../plugin-origin.js"; import { formatPlanType } from "../auth/plan-tier.js"; import type { ToolContext } from "./index.js"; @@ -131,6 +132,7 @@ export function createCodexStatusTool(ctx: ToolContext): ToolDefinition { if (outputFormat === "json") { return renderJsonOutput({ totalAccounts: storage.accounts.length, + pluginOrigin: getPluginOrigin(), selectionView: { modelFamily: explainabilityFamily, effectiveModel: explainabilityModel ?? null, @@ -197,6 +199,12 @@ export function createCodexStatusTool(ctx: ToolContext): ToolDefinition { const lines: string[] = [ ...formatUiHeader(ui, "Account status"), formatUiKeyValue(ui, "Total", String(storage.accounts.length)), + formatUiKeyValue( + ui, + "Running from", + describePluginOrigin(getPluginOrigin()), + "muted", + ), formatUiKeyValue( ui, "Selection view", @@ -333,6 +341,7 @@ export function createCodexStatusTool(ctx: ToolContext): ToolDefinition { const lines: string[] = [ `Account Status (${storage.accounts.length} total):`, + `Running from: ${describePluginOrigin(getPluginOrigin())}`, "", ...buildTableHeader(statusTableOptions), ]; diff --git a/scripts/install-oc-codex-multi-auth-core.js b/scripts/install-oc-codex-multi-auth-core.js index 59c4ac2f..282cc4c7 100644 --- a/scripts/install-oc-codex-multi-auth-core.js +++ b/scripts/install-oc-codex-multi-auth-core.js @@ -7,6 +7,7 @@ import { fileURLToPath, pathToFileURL } from "node:url"; const PACKAGE_NAME = "oc-codex-multi-auth"; const LEGACY_PACKAGE_NAMES = ["oc-chatgpt-multi-auth"]; +const ORIGIN_HISTORY_FILE_NAME = "oc-codex-multi-auth-origin.json"; const WINDOWS_RENAME_RETRY_ATTEMPTS = 5; const WINDOWS_RENAME_RETRY_BASE_DELAY_MS = 10; const STALE_MANAGED_MODEL_KEYS = new Set([ @@ -151,6 +152,7 @@ function buildPaths(homeDir) { ]), cacheBunLock: join(cacheDir, "bun.lock"), cachePackageJson: join(cacheDir, "package.json"), + originHistoryPath: join(homeDir, ".opencode", ORIGIN_HISTORY_FILE_NAME), modernTemplatePath, legacyTemplatePath, }; @@ -467,6 +469,50 @@ function normalizePluginList(list, onNotice, options = {}) { return checkoutRegistered || keptPublishedName ? kept : [...kept, PACKAGE_NAME]; } +function readLocalCheckoutSightings(historyPath) { + try { + const parsed = JSON.parse(readFileSync(historyPath, "utf8")); + const sightings = parsed?.sightings; + if (!Array.isArray(sightings)) return []; + return sightings.filter( + (sighting) => + sighting && + typeof sighting === "object" && + sighting.isLocalCheckout === true && + typeof sighting.root === "string" && + typeof sighting.lastSeen === "string" && + getManagedPackageNames().includes(sighting.name), + ); + } catch { + return []; + } +} + +/** + * A checkout the plugin has run from that the finished config does not + * register. Reported rather than restored: config history is evidence of what + * happened, not authority over what the user wants registered now. + */ +function findUnregisteredLocalCheckout(pluginList, historyPath, options = {}) { + const entries = Array.isArray(pluginList) ? pluginList : []; + if (entries.some((entry) => classifyPluginEntry(entry, options).kind === LOCAL_CHECKOUT_ENTRY)) { + return null; + } + const latest = readLocalCheckoutSightings(historyPath) + .sort((left, right) => (Date.parse(left.lastSeen) || 0) - (Date.parse(right.lastSeen) || 0)) + .at(-1); + if (!latest) return null; + // The directory has to still hold the package that was recorded there. A + // path gets reused - a checkout deleted and something else cloned into its + // place - and a recorded path that now declares another project would + // otherwise be offered as somewhere to point OpenCode back at. + const declaredName = resolveDeclaredPackageName(latest.root); + if (!declaredName || declaredName.toLowerCase() !== String(latest.name).toLowerCase()) { + return null; + } + return latest; +} + function mergeTuiConfig(existingConfig, onNotice, options = {}) { const existing = isPlainObject(existingConfig) ? { ...existingConfig } : {}; const next = { ...existing }; @@ -1859,6 +1905,17 @@ export async function runInstaller(argv = process.argv.slice(2), options = {}) { const nextTuiConfig = mergeTuiConfig(existingTuiConfig, log, normalizeOptions); + const unregisteredCheckout = findUnregisteredLocalCheckout(nextConfig.plugin, paths.originHistoryPath, { + baseDirectory: paths.configDir, + cacheDirectory: paths.cacheDir, + }); + if (unregisteredCheckout) { + log( + `Note: this plugin last loaded from a checkout at ${unregisteredCheckout.root} on ${unregisteredCheckout.lastSeen}, ` + + `which ${paths.configPath} does not register. Point the plugin entry back at that path if OpenCode should keep loading your own build.`, + ); + } + const configChanged = existingConfig === undefined || formatJson(existingConfig) !== formatJson(nextConfig); const tuiConfigChanged = existingTuiConfig === undefined || formatJson(existingTuiConfig) !== formatJson(nextTuiConfig); let wrote = false; @@ -1921,10 +1978,12 @@ export async function runInstaller(argv = process.argv.slice(2), options = {}) { } export const __test = { + ORIGIN_HISTORY_FILE_NAME, buildPaths, backupConfig, classifyPluginEntry, copyFileWithWindowsRetry, + findUnregisteredLocalCheckout, formatConfigDiff, formatRedactedConfigDiff, mergeFullTemplate, diff --git a/test/auto-update-checker.test.ts b/test/auto-update-checker.test.ts index eb68d2e1..b3c9e053 100644 --- a/test/auto-update-checker.test.ts +++ b/test/auto-update-checker.test.ts @@ -304,6 +304,21 @@ describe("auto-update-checker", () => { await expect(checkAndNotify(showToast)).resolves.toBeUndefined(); expect(showToast).not.toHaveBeenCalled(); }); + + it("offers nothing for a build loaded from a local checkout", async () => { + vi.mocked(globalThis.fetch).mockResolvedValue({ + ok: true, + json: async () => ({ version: "5.0.0" }), + } as Response); + const showToast = vi.fn().mockResolvedValue(undefined); + const scheduleCacheClear = vi.fn(() => true); + + await checkAndNotify(showToast, { localCheckout: true, scheduleCacheClear }); + + expect(globalThis.fetch).not.toHaveBeenCalled(); + expect(showToast).not.toHaveBeenCalled(); + expect(scheduleCacheClear).not.toHaveBeenCalled(); + }); }); describe("clearUpdateCache", () => { diff --git a/test/install-oc-codex-multi-auth.test.ts b/test/install-oc-codex-multi-auth.test.ts index b24dce7c..c28ba31c 100644 --- a/test/install-oc-codex-multi-auth.test.ts +++ b/test/install-oc-codex-multi-auth.test.ts @@ -1155,6 +1155,107 @@ describe("install-oc-codex-multi-auth script", () => { await expect(readdir(configDir)).resolves.toEqual(["opencode.json", "tui.json"]); }); + async function writeOriginHistory(home: string, sightings: unknown[]) { + const historyPath = join(home, ".opencode", "oc-codex-multi-auth-origin.json"); + await mkdir(join(home, ".opencode"), { recursive: true }); + await writeFile(historyPath, JSON.stringify({ version: 1, sightings }), "utf-8"); + return historyPath; + } + + function sightingFor(root: string) { + return { + name: "oc-codex-multi-auth", + version: "6.21.0", + root, + isLocalCheckout: true, + firstSeen: "2026-01-01T00:00:00.000Z", + lastSeen: "2026-02-01T00:00:00.000Z", + }; + } + + it("reports a checkout the plugin ran from that the config no longer registers", async () => { + vi.resetModules(); + tempHome = await createTempHome(); + const { __test } = await import("../scripts/install-oc-codex-multi-auth-core.js"); + const checkout = await createCheckout(tempHome, "oc-codex-multi-auth", "oc-codex-multi-auth"); + const historyPath = await writeOriginHistory(tempHome, [sightingFor(checkout)]); + + expect(__test.findUnregisteredLocalCheckout(["oc-codex-multi-auth"], historyPath)).toMatchObject({ + root: checkout, + }); + expect(__test.findUnregisteredLocalCheckout([checkout], historyPath)).toBeNull(); + }); + + it("stays silent when the recorded checkout is gone or was never recorded", async () => { + vi.resetModules(); + tempHome = await createTempHome(); + const { __test } = await import("../scripts/install-oc-codex-multi-auth-core.js"); + const absent = join(tempHome, "deleted-checkout"); + const historyPath = await writeOriginHistory(tempHome, [sightingFor(absent)]); + + expect(__test.findUnregisteredLocalCheckout(["oc-codex-multi-auth"], historyPath)).toBeNull(); + expect( + __test.findUnregisteredLocalCheckout( + ["oc-codex-multi-auth"], + join(tempHome, ".opencode", "absent.json"), + ), + ).toBeNull(); + }); + + it("stays silent when the recorded directory now holds a different package", async () => { + vi.resetModules(); + tempHome = await createTempHome(); + const { __test } = await import("../scripts/install-oc-codex-multi-auth-core.js"); + const reusedRoot = await createCheckout(tempHome, "oc-codex-multi-auth", "reused-root"); + const historyPath = await writeOriginHistory(tempHome, [sightingFor(reusedRoot)]); + + expect( + __test.findUnregisteredLocalCheckout(["oc-codex-multi-auth"], historyPath)?.root, + ).toBe(reusedRoot); + + // Same path, cloned over with something else since it was recorded. + await writeFile( + join(reusedRoot, "package.json"), + JSON.stringify({ name: "some-unrelated-project", version: "1.0.0" }), + "utf-8", + ); + + expect( + __test.findUnregisteredLocalCheckout(["oc-codex-multi-auth"], historyPath), + ).toBeNull(); + }); + + it("names the replaced checkout without restoring it to the config", async () => { + vi.resetModules(); + tempHome = await createTempHome(); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const { runInstaller } = await import("../scripts/install-oc-codex-multi-auth-core.js"); + const checkout = await createCheckout(tempHome, "oc-codex-multi-auth", "oc-codex-multi-auth"); + await writeOriginHistory(tempHome, [sightingFor(checkout)]); + const configDir = join(tempHome, ".config", "opencode"); + const configPath = join(configDir, "opencode.json"); + + await mkdir(configDir, { recursive: true }); + await writeFile( + configPath, + JSON.stringify({ plugin: ["oc-codex-multi-auth"] }, null, 2), + "utf-8", + ); + + await expect( + runInstaller(["install", "--plugin-only", "--no-cache-clear"], { + env: { ...process.env, HOME: tempHome, USERPROFILE: tempHome }, + }), + ).resolves.toMatchObject({ exitCode: 0 }); + + const stdout = logSpy.mock.calls.map((call) => String(call[0])).join("\n"); + expect(stdout).toContain(checkout); + expect(stdout).toContain("last loaded from a checkout"); + + const saved = JSON.parse(await readFile(configPath, "utf-8")) as { plugin: string[] }; + expect(saved.plugin).toEqual(["oc-codex-multi-auth"]); + }); + it("keeps a local checkout when a catalog mode rewrites provider.openai", async () => { vi.resetModules(); tempHome = await createTempHome(); diff --git a/test/plugin-origin.test.ts b/test/plugin-origin.test.ts new file mode 100644 index 00000000..58d888b5 --- /dev/null +++ b/test/plugin-origin.test.ts @@ -0,0 +1,407 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; + +import { + HISTORY_FILE_NAME, + describePluginOrigin, + findReplacedLocalCheckout, + getPluginOriginHistoryPath, + isPackageManagerRoot, + readPluginOriginHistory, + recordPluginOrigin, + resolvePluginOrigin, + withSighting, + type PluginOrigin, + type PluginOriginHistory, +} from "../lib/plugin-origin.js"; + +async function createTempRoot() { + return mkdtemp(join(tmpdir(), "oc-codex-origin-")); +} + +async function createPackage(root: string, name: string, version: string) { + await mkdir(root, { recursive: true }); + await writeFile(join(root, "package.json"), JSON.stringify({ name, version }), "utf-8"); + return root; +} + +function localCheckout(root: string): PluginOrigin { + return { name: "oc-codex-multi-auth", version: "1.0.0", root, isLocalCheckout: true }; +} + +function installedPackage(root: string): PluginOrigin { + return { name: "oc-codex-multi-auth", version: "2.0.0", root, isLocalCheckout: false }; +} + +function historyOf(...sightings: PluginOriginHistory["sightings"]): PluginOriginHistory { + return { version: 1, sightings }; +} + +describe("plugin-origin", () => { + let tempRoot: string | null = null; + + afterEach(async () => { + vi.doUnmock("proper-lockfile"); + vi.doUnmock("node:fs/promises"); + vi.resetModules(); + if (tempRoot) { + await rm(tempRoot, { recursive: true, force: true }); + tempRoot = null; + } + }); + + describe("resolvePluginOrigin", () => { + it("reads the name and version off the nearest enclosing package", async () => { + tempRoot = await createTempRoot(); + const packageRoot = await createPackage( + join(tempRoot, "checkout"), + "oc-codex-multi-auth", + "6.21.0", + ); + const moduleUrl = pathToFileURL(join(packageRoot, "dist", "lib", "plugin-origin.js")).href; + + expect(resolvePluginOrigin(moduleUrl)).toEqual({ + name: "oc-codex-multi-auth", + version: "6.21.0", + root: packageRoot, + isLocalCheckout: true, + }); + }); + + it("reports a package-manager root as not a local checkout", async () => { + tempRoot = await createTempRoot(); + const packageRoot = await createPackage( + join(tempRoot, "node_modules", "oc-codex-multi-auth"), + "oc-codex-multi-auth", + "6.21.0", + ); + const moduleUrl = pathToFileURL(join(packageRoot, "dist", "index.js")).href; + + expect(resolvePluginOrigin(moduleUrl)?.isLocalCheckout).toBe(false); + }); + + it("returns null when no enclosing package declares a name", async () => { + tempRoot = await createTempRoot(); + const orphan = join(tempRoot, "a", "b", "c"); + await mkdir(orphan, { recursive: true }); + + expect(resolvePluginOrigin(pathToFileURL(join(orphan, "index.js")).href)).toBeNull(); + expect(resolvePluginOrigin("not-a-url")).toBeNull(); + }); + }); + + describe("isPackageManagerRoot", () => { + it.each([ + ["/home/dev/node_modules/oc-codex-multi-auth", true], + ["/home/dev/.cache/opencode/packages/oc-codex-multi-auth@latest", true], + ["/home/dev/src/oc-codex-multi-auth", false], + ["/home/dev/workspace/packages/oc-codex-multi-auth", false], + ])("classifies %s", (root, expected) => { + expect(isPackageManagerRoot(root)).toBe(expected); + }); + }); + + describe("withSighting", () => { + it("keeps the original firstSeen when the same root is seen again", () => { + const origin = localCheckout("/src/plugin"); + const first = withSighting(historyOf(), origin, "2026-01-01T00:00:00.000Z"); + const second = withSighting(first, origin, "2026-02-01T00:00:00.000Z"); + + expect(second.sightings).toEqual([ + { ...origin, firstSeen: "2026-01-01T00:00:00.000Z", lastSeen: "2026-02-01T00:00:00.000Z" }, + ]); + }); + + it("appends a new root instead of replacing the previous one", () => { + const checkout = localCheckout("/src/plugin"); + const installed = installedPackage("/cache/node_modules/oc-codex-multi-auth"); + const history = withSighting( + withSighting(historyOf(), checkout, "2026-01-01T00:00:00.000Z"), + installed, + "2026-03-01T00:00:00.000Z", + ); + + expect(history.sightings.map((sighting) => sighting.root)).toEqual([ + checkout.root, + installed.root, + ]); + }); + + it("caps the recorded history", () => { + let history = historyOf(); + for (let index = 0; index < 25; index += 1) { + history = withSighting( + history, + localCheckout(`/src/plugin-${index}`), + `2026-01-01T00:00:${String(index).padStart(2, "0")}.000Z`, + ); + } + + expect(history.sightings).toHaveLength(10); + expect(history.sightings.at(-1)?.root).toBe("/src/plugin-24"); + }); + }); + + describe("readPluginOriginHistory", () => { + it("returns an empty history for a missing or unreadable file", async () => { + tempRoot = await createTempRoot(); + + expect(readPluginOriginHistory(join(tempRoot, "absent.json")).sightings).toEqual([]); + + const malformed = join(tempRoot, "malformed.json"); + await writeFile(malformed, "{ not json", "utf-8"); + expect(readPluginOriginHistory(malformed).sightings).toEqual([]); + }); + + it("drops entries that do not carry a full sighting", async () => { + tempRoot = await createTempRoot(); + const historyPath = join(tempRoot, "origin.json"); + const valid = { + ...localCheckout("/src/plugin"), + firstSeen: "2026-01-01T00:00:00.000Z", + lastSeen: "2026-01-01T00:00:00.000Z", + }; + await writeFile( + historyPath, + JSON.stringify({ version: 1, sightings: [valid, { root: "/src/other" }, null, 42] }), + "utf-8", + ); + + expect(readPluginOriginHistory(historyPath).sightings).toEqual([valid]); + }); + }); + + describe("recordPluginOrigin", () => { + it("persists a sighting that reads back unchanged", async () => { + tempRoot = await createTempRoot(); + const historyPath = join(tempRoot, "state", "origin.json"); + const origin = localCheckout("/src/plugin"); + + const written = await recordPluginOrigin( + origin, + historyPath, + () => new Date("2026-05-05T10:00:00.000Z"), + ); + + expect(written.sightings).toEqual([ + { ...origin, firstSeen: "2026-05-05T10:00:00.000Z", lastSeen: "2026-05-05T10:00:00.000Z" }, + ]); + expect(readPluginOriginHistory(historyPath)).toEqual(written); + await expect(readFile(historyPath, "utf-8")).resolves.toContain("\n"); + }); + + it("leaves no temporary file behind", async () => { + tempRoot = await createTempRoot(); + const historyPath = join(tempRoot, "origin.json"); + + await recordPluginOrigin(localCheckout("/src/plugin"), historyPath); + + await expect(readFile(`${historyPath}.${process.pid}.tmp`, "utf-8")).rejects.toMatchObject({ + code: "ENOENT", + }); + }); + }); + + describe("findReplacedLocalCheckout", () => { + const checkoutSighting = { + ...localCheckout("/src/plugin"), + firstSeen: "2026-01-01T00:00:00.000Z", + lastSeen: "2026-02-01T00:00:00.000Z", + }; + + it("reports the checkout that an installed package took over from", () => { + const installed = installedPackage("/cache/node_modules/oc-codex-multi-auth"); + + expect(findReplacedLocalCheckout(installed, historyOf(checkoutSighting))).toEqual( + checkoutSighting, + ); + }); + + it("stays silent while a checkout is the live origin", () => { + const other = localCheckout("/src/another-plugin"); + + expect(findReplacedLocalCheckout(other, historyOf(checkoutSighting))).toBeNull(); + }); + + it("ignores a checkout of a different package", () => { + const installed = installedPackage("/cache/node_modules/oc-codex-multi-auth"); + const unrelated = { ...checkoutSighting, name: "some-other-plugin" }; + + expect(findReplacedLocalCheckout(installed, historyOf(unrelated))).toBeNull(); + }); + + it("reports the most recently seen checkout when several are on record", () => { + const older = { ...checkoutSighting, root: "/src/old", lastSeen: "2026-01-15T00:00:00.000Z" }; + const newer = { ...checkoutSighting, root: "/src/new", lastSeen: "2026-04-01T00:00:00.000Z" }; + const installed = installedPackage("/cache/node_modules/oc-codex-multi-auth"); + + expect(findReplacedLocalCheckout(installed, historyOf(older, newer))?.root).toBe("/src/new"); + }); + }); + + it("describes each origin in terms a reader can act on", () => { + expect(describePluginOrigin(localCheckout("/src/plugin"))).toBe( + "local checkout at /src/plugin (v1.0.0)", + ); + expect(describePluginOrigin(installedPackage("/cache/pkg"))).toBe("installed package v2.0.0"); + expect(describePluginOrigin(null)).toBe("unknown"); + }); + + it("keeps the history beside the other plugin state", () => { + expect(getPluginOriginHistoryPath("/home/dev")).toBe( + join("/home", "dev", ".opencode", "oc-codex-multi-auth-origin.json"), + ); + }); + + // The installer runs before anything is built and so cannot import this + // module; it spells the same file name itself. Each side would otherwise go + // on reporting confidently about a file the other never writes. + it("agrees with the installer about where the history lives", async () => { + const { __test } = await import("../scripts/install-oc-codex-multi-auth-core.js"); + + expect(__test.ORIGIN_HISTORY_FILE_NAME).toBe(HISTORY_FILE_NAME); + expect(__test.buildPaths("/home/dev").originHistoryPath).toBe( + getPluginOriginHistoryPath("/home/dev"), + ); + }); + + it("keeps every concurrently recorded origin", async () => { + tempRoot = await createTempRoot(); + const historyPath = join(tempRoot, ".opencode", "oc-codex-multi-auth-origin.json"); + const origins = Array.from({ length: 8 }, (_unused, index) => + localCheckout(join(tempRoot ?? "", `checkout-${index}`)), + ); + + await Promise.all(origins.map((origin) => recordPluginOrigin(origin, historyPath))); + + const recorded = readPluginOriginHistory(historyPath).sightings.map( + (sighting) => sighting.root, + ); + expect(new Set(recorded)).toEqual(new Set(origins.map((origin) => origin.root))); + }); + + it("gives up the write when the lease is reclaimed, rather than ending the process", async () => { + tempRoot = await createTempRoot(); + const historyPath = join(tempRoot, ".opencode", "oc-codex-multi-auth-origin.json"); + let leaseOptions: { onCompromised?: (error: Error) => void } | undefined; + + vi.resetModules(); + vi.doMock("proper-lockfile", () => ({ + lock: async (_target: string, options: { onCompromised?: (error: Error) => void }) => { + leaseOptions = options; + options.onCompromised?.(new Error("lock file was removed")); + return async () => { + throw new Error("Lock is already released"); + }; + }, + })); + const origin = localCheckout(join(tempRoot, "checkout")); + const module = await import("../lib/plugin-origin.js"); + + await expect(module.recordPluginOrigin(origin, historyPath)).resolves.toEqual(historyOf()); + expect(module.readPluginOriginHistory(historyPath).sightings).toEqual([]); + // Supplying a handler at all is the fix: proper-lockfile's own default is + // `(err) => { throw err }`, raised from the timer that refreshes the lease + // and therefore reachable by no caller and fatal to the host process. + const onCompromised = leaseOptions?.onCompromised; + expect(typeof onCompromised).toBe("function"); + expect(() => onCompromised?.(new Error("reclaimed again"))).not.toThrow(); + }); + + it("leaves newer history alone when the lease is lost while the copy is written", async () => { + tempRoot = await createTempRoot(); + const historyPath = join(tempRoot, ".opencode", "oc-codex-multi-auth-origin.json"); + const newOwner = { + ...localCheckout(join(tempRoot, "written-by-the-new-owner")), + firstSeen: "2026-01-01T00:00:00.000Z", + lastSeen: "2026-01-01T00:00:00.000Z", + }; + await mkdir(join(tempRoot, ".opencode"), { recursive: true }); + await writeFile(historyPath, JSON.stringify(historyOf(newOwner)), "utf-8"); + + let leaseOptions: { onCompromised?: (error: Error) => void } | undefined; + vi.resetModules(); + vi.doMock("proper-lockfile", () => ({ + lock: async (_target: string, options: { onCompromised?: (error: Error) => void }) => { + leaseOptions = options; + return async () => {}; + }, + })); + // Reclaimed precisely inside the window the fix closes: after the merge + // has been decided, while the replacement copy is still being written. + vi.doMock("node:fs/promises", async () => { + const actual = await vi.importActual("node:fs/promises"); + return { + ...actual, + writeFile: async (...args: Parameters) => { + leaseOptions?.onCompromised?.(new Error("lease reclaimed mid-write")); + return actual.writeFile(...args); + }, + }; + }); + const module = await import("../lib/plugin-origin.js"); + const origin = localCheckout(join(tempRoot, "losing-writer")); + + await module.recordPluginOrigin(origin, historyPath); + + const roots = module.readPluginOriginHistory(historyPath).sightings.map((s) => s.root); + expect(roots).toEqual([newOwner.root]); + expect(roots).not.toContain(origin.root); + }); + + describe("Windows path casing", () => { + it("recognizes package-manager output under any casing, but only on Windows", () => { + const cacheRoot = "C:/Users/dev/.cache/opencode/NODE_MODULES/oc-codex-multi-auth"; + + expect(isPackageManagerRoot(cacheRoot, "win32")).toBe(true); + expect(isPackageManagerRoot(cacheRoot, "linux")).toBe(false); + expect(isPackageManagerRoot("/home/dev/src/oc-codex-multi-auth", "win32")).toBe(false); + }); + + it("keeps one sighting for one directory spelled two ways", () => { + const first = withSighting( + historyOf(), + localCheckout("C:\\Repo\\plugin"), + "2026-01-01T00:00:00.000Z", + "win32", + ); + const second = withSighting( + first, + localCheckout("c:\\repo\\plugin"), + "2026-02-01T00:00:00.000Z", + "win32", + ); + + expect(second.sightings).toHaveLength(1); + expect(second.sightings[0]?.firstSeen).toBe("2026-01-01T00:00:00.000Z"); + expect(second.sightings[0]?.lastSeen).toBe("2026-02-01T00:00:00.000Z"); + // Recorded as this run spelled it, so a reader is shown a real path. + expect(second.sightings[0]?.root).toBe("c:\\repo\\plugin"); + + expect( + withSighting(first, localCheckout("c:\\repo\\plugin"), "2026-02-01T00:00:00.000Z", "linux") + .sightings, + ).toHaveLength(2); + }); + + it("does not call a differently cased spelling of the same root a replacement", () => { + const checkout = { + ...localCheckout("C:\\Repo\\plugin"), + firstSeen: "2026-01-01T00:00:00.000Z", + lastSeen: "2026-01-01T00:00:00.000Z", + }; + const installed = { + ...installedPackage("c:\\repo\\plugin"), + isLocalCheckout: false, + }; + + expect(findReplacedLocalCheckout(installed, historyOf(checkout), "win32")).toBeNull(); + expect(findReplacedLocalCheckout(installed, historyOf(checkout), "linux")?.root).toBe( + "C:\\Repo\\plugin", + ); + }); + }); +});