mirror of
https://github.com/Heretek-AI/ompweb.git
synced 2026-11-10 13:51:30 -10:00
* fix: refresh OMP version after CLI updates * fix: coalesce concurrent OMP version probes * fix: cache OMP version by executable identity * test: preserve exact mtime in replacement regression * fix: retain known OMP version after transport errors * fix: keep cached version visible when refresh fails --------- Co-authored-by: Khaled Ahmed <[email protected]>
122 lines
4.3 KiB
TypeScript
122 lines
4.3 KiB
TypeScript
import { execFile } from "child_process";
|
|
import { existsSync, realpathSync, statSync } from "fs";
|
|
import { homedir } from "os";
|
|
import { delimiter, join } from "path";
|
|
|
|
/**
|
|
* Locating and probing the user's installed `omp` CLI. omp-web never embeds
|
|
* the (Bun-only) @oh-my-pi SDK — every live-agent capability goes through the
|
|
* omp binary, so its absence is a first-class, user-visible state.
|
|
*/
|
|
|
|
let cachedBin: string | null = null;
|
|
let binMissAt = 0;
|
|
let cachedVersion: { fingerprint: string; value: string; expiresAt: number } | null = null;
|
|
let versionMiss: { fingerprint: string | null; retryAt: number } | null = null;
|
|
let versionProbe: Promise<string | null> | null = null;
|
|
|
|
const BIN_NAME = process.platform === "win32" ? "omp.exe" : "omp";
|
|
// Retry missing binaries and failed version probes after a short backoff so
|
|
// a later install or repair is picked up without a web server restart.
|
|
const MISS_TTL_MS = 30_000;
|
|
// Launchers can stay unchanged while their underlying package is updated.
|
|
const VERSION_TTL_MS = 5 * 60_000;
|
|
|
|
function probeOmpBin(): string | null {
|
|
const override = process.env.OMP_WEB_OMP_BIN;
|
|
if (override) return existsSync(override) ? override : null;
|
|
for (const dir of (process.env.PATH ?? "").split(delimiter)) {
|
|
if (!dir) continue;
|
|
const candidate = join(dir, BIN_NAME);
|
|
if (existsSync(candidate)) return candidate;
|
|
}
|
|
// GUI-launched processes often miss homebrew/bun dirs in PATH; probe the
|
|
// usual install locations before giving up.
|
|
const fallbackDirs = [
|
|
"/opt/homebrew/bin",
|
|
"/usr/local/bin",
|
|
join(homedir(), ".bun", "bin"),
|
|
join(homedir(), ".local", "bin"),
|
|
];
|
|
for (const dir of fallbackDirs) {
|
|
const candidate = join(dir, BIN_NAME);
|
|
if (existsSync(candidate)) return candidate;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** Resolve the omp binary: OMP_WEB_OMP_BIN override, then PATH lookup. Returns
|
|
* null when omp is not installed. A hit is cached for the process lifetime; a
|
|
* miss is re-probed after MISS_TTL_MS. */
|
|
export function resolveOmpBin(): string | null {
|
|
// A global Bun/npm update can replace or remove its launcher while this
|
|
// Next.js process is still alive. Never keep returning a stale cache entry.
|
|
if (cachedBin && existsSync(cachedBin)) return cachedBin;
|
|
cachedBin = null;
|
|
if (Date.now() - binMissAt < MISS_TTL_MS) return null;
|
|
const found = probeOmpBin();
|
|
if (found) {
|
|
cachedBin = found;
|
|
binMissAt = 0;
|
|
return found;
|
|
}
|
|
binMissAt = Date.now();
|
|
return null;
|
|
}
|
|
|
|
/** Return the installed CLI version, using file metadata to invalidate cached
|
|
* results without launching omp on every visit. An expiry covers opaque
|
|
* launchers; concurrent callers share a probe. */
|
|
export function getOmpVersion(): Promise<string | null> {
|
|
versionProbe ??= probeOmpVersion().finally(() => {
|
|
versionProbe = null;
|
|
});
|
|
return versionProbe;
|
|
}
|
|
|
|
function versionFingerprint(bin: string): string | null {
|
|
try {
|
|
const target = realpathSync(bin);
|
|
const stat = statSync(target, { bigint: true });
|
|
return [target, stat.dev, stat.ino, stat.size, stat.mtimeNs, stat.ctimeNs].join(":");
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function probeOmpVersion(): Promise<string | null> {
|
|
const bin = resolveOmpBin();
|
|
const fingerprint = bin ? versionFingerprint(bin) : null;
|
|
const now = Date.now();
|
|
if (fingerprint && cachedVersion?.fingerprint === fingerprint && now < cachedVersion.expiresAt) {
|
|
return cachedVersion.value;
|
|
}
|
|
if (versionMiss?.fingerprint === fingerprint && now < versionMiss.retryAt) return null;
|
|
cachedVersion = null;
|
|
if (!bin) {
|
|
versionMiss = { fingerprint, retryAt: now + MISS_TTL_MS };
|
|
return null;
|
|
}
|
|
try {
|
|
const output = await new Promise<string>((resolve, reject) => {
|
|
execFile(bin, ["--version"], { timeout: 10_000, windowsHide: true }, (error, stdout) => {
|
|
if (error) reject(error);
|
|
else resolve(stdout);
|
|
});
|
|
});
|
|
const version = output.trim();
|
|
if (version) {
|
|
versionMiss = null;
|
|
// Do not cache a probe across an executable replacement.
|
|
if (fingerprint && versionFingerprint(bin) === fingerprint) {
|
|
cachedVersion = { fingerprint, value: version, expiresAt: Date.now() + VERSION_TTL_MS };
|
|
}
|
|
return version;
|
|
}
|
|
} catch {
|
|
// Fall through to the miss path: retry after the TTL.
|
|
}
|
|
versionMiss = { fingerprint, retryAt: Date.now() + MISS_TTL_MS };
|
|
return null;
|
|
}
|