Files
clawdbot/src/infra/update-check.ts
2026-01-20 16:36:04 +00:00

394 lines
11 KiB
TypeScript

import fs from "node:fs/promises";
import path from "node:path";
import { runCommandWithTimeout } from "../process/exec.js";
import { parseSemver } from "./runtime-guard.js";
import { channelToNpmTag, type UpdateChannel } from "./update-channels.js";
export type PackageManager = "pnpm" | "bun" | "npm" | "unknown";
export type GitUpdateStatus = {
root: string;
sha: string | null;
tag: string | null;
branch: string | null;
upstream: string | null;
dirty: boolean | null;
ahead: number | null;
behind: number | null;
fetchOk: boolean | null;
error?: string;
};
export type DepsStatus = {
manager: PackageManager;
status: "ok" | "missing" | "stale" | "unknown";
lockfilePath: string | null;
markerPath: string | null;
reason?: string;
};
export type RegistryStatus = {
latestVersion: string | null;
error?: string;
};
export type NpmTagStatus = {
tag: string;
version: string | null;
error?: string;
};
export type UpdateCheckResult = {
root: string | null;
installKind: "git" | "package" | "unknown";
packageManager: PackageManager;
git?: GitUpdateStatus;
deps?: DepsStatus;
registry?: RegistryStatus;
};
async function exists(p: string): Promise<boolean> {
try {
await fs.access(p);
return true;
} catch {
return false;
}
}
async function detectPackageManager(root: string): Promise<PackageManager> {
try {
const raw = await fs.readFile(path.join(root, "package.json"), "utf-8");
const parsed = JSON.parse(raw) as { packageManager?: string };
const pm = parsed?.packageManager?.split("@")[0]?.trim();
if (pm === "pnpm" || pm === "bun" || pm === "npm") return pm;
} catch {
// ignore
}
const files = await fs.readdir(root).catch((): string[] => []);
if (files.includes("pnpm-lock.yaml")) return "pnpm";
if (files.includes("bun.lockb")) return "bun";
if (files.includes("package-lock.json")) return "npm";
return "unknown";
}
async function detectGitRoot(root: string): Promise<string | null> {
const res = await runCommandWithTimeout(["git", "-C", root, "rev-parse", "--show-toplevel"], {
timeoutMs: 4000,
}).catch(() => null);
if (!res || res.code !== 0) return null;
const top = res.stdout.trim();
return top ? path.resolve(top) : null;
}
export async function checkGitUpdateStatus(params: {
root: string;
timeoutMs?: number;
fetch?: boolean;
}): Promise<GitUpdateStatus> {
const timeoutMs = params.timeoutMs ?? 6000;
const root = path.resolve(params.root);
const base: GitUpdateStatus = {
root,
sha: null,
tag: null,
branch: null,
upstream: null,
dirty: null,
ahead: null,
behind: null,
fetchOk: null,
};
const branchRes = await runCommandWithTimeout(
["git", "-C", root, "rev-parse", "--abbrev-ref", "HEAD"],
{ timeoutMs },
).catch(() => null);
if (!branchRes || branchRes.code !== 0) {
return { ...base, error: branchRes?.stderr?.trim() || "git unavailable" };
}
const branch = branchRes.stdout.trim() || null;
const shaRes = await runCommandWithTimeout(["git", "-C", root, "rev-parse", "HEAD"], {
timeoutMs,
}).catch(() => null);
const sha = shaRes && shaRes.code === 0 ? shaRes.stdout.trim() : null;
const tagRes = await runCommandWithTimeout(
["git", "-C", root, "describe", "--tags", "--exact-match"],
{ timeoutMs },
).catch(() => null);
const tag = tagRes && tagRes.code === 0 ? tagRes.stdout.trim() : null;
const upstreamRes = await runCommandWithTimeout(
["git", "-C", root, "rev-parse", "--abbrev-ref", "@{upstream}"],
{ timeoutMs },
).catch(() => null);
const upstream = upstreamRes && upstreamRes.code === 0 ? upstreamRes.stdout.trim() : null;
const dirtyRes = await runCommandWithTimeout(["git", "-C", root, "status", "--porcelain"], {
timeoutMs,
}).catch(() => null);
const dirty = dirtyRes && dirtyRes.code === 0 ? dirtyRes.stdout.trim().length > 0 : null;
const fetchOk = params.fetch
? await runCommandWithTimeout(["git", "-C", root, "fetch", "--quiet", "--prune"], { timeoutMs })
.then((r) => r.code === 0)
.catch(() => false)
: null;
const counts =
upstream && upstream.length > 0
? await runCommandWithTimeout(
["git", "-C", root, "rev-list", "--left-right", "--count", `HEAD...${upstream}`],
{ timeoutMs },
).catch(() => null)
: null;
const parseCounts = (raw: string): { ahead: number; behind: number } | null => {
const parts = raw.trim().split(/\s+/);
if (parts.length < 2) return null;
const ahead = Number.parseInt(parts[0] ?? "", 10);
const behind = Number.parseInt(parts[1] ?? "", 10);
if (!Number.isFinite(ahead) || !Number.isFinite(behind)) return null;
return { ahead, behind };
};
const parsed = counts && counts.code === 0 ? parseCounts(counts.stdout) : null;
return {
root,
sha,
tag,
branch,
upstream,
dirty,
ahead: parsed?.ahead ?? null,
behind: parsed?.behind ?? null,
fetchOk,
};
}
async function statMtimeMs(p: string): Promise<number | null> {
try {
const st = await fs.stat(p);
return st.mtimeMs;
} catch {
return null;
}
}
function resolveDepsMarker(params: { root: string; manager: PackageManager }): {
lockfilePath: string | null;
markerPath: string | null;
} {
const root = params.root;
if (params.manager === "pnpm") {
return {
lockfilePath: path.join(root, "pnpm-lock.yaml"),
markerPath: path.join(root, "node_modules", ".modules.yaml"),
};
}
if (params.manager === "bun") {
return {
lockfilePath: path.join(root, "bun.lockb"),
markerPath: path.join(root, "node_modules"),
};
}
if (params.manager === "npm") {
return {
lockfilePath: path.join(root, "package-lock.json"),
markerPath: path.join(root, "node_modules"),
};
}
return { lockfilePath: null, markerPath: null };
}
export async function checkDepsStatus(params: {
root: string;
manager: PackageManager;
}): Promise<DepsStatus> {
const root = path.resolve(params.root);
const { lockfilePath, markerPath } = resolveDepsMarker({
root,
manager: params.manager,
});
if (!lockfilePath || !markerPath) {
return {
manager: params.manager,
status: "unknown",
lockfilePath,
markerPath,
reason: "unknown package manager",
};
}
const lockExists = await exists(lockfilePath);
const markerExists = await exists(markerPath);
if (!lockExists) {
return {
manager: params.manager,
status: "unknown",
lockfilePath,
markerPath,
reason: "lockfile missing",
};
}
if (!markerExists) {
return {
manager: params.manager,
status: "missing",
lockfilePath,
markerPath,
reason: "node_modules marker missing",
};
}
const lockMtime = await statMtimeMs(lockfilePath);
const markerMtime = await statMtimeMs(markerPath);
if (!lockMtime || !markerMtime) {
return {
manager: params.manager,
status: "unknown",
lockfilePath,
markerPath,
};
}
if (lockMtime > markerMtime + 1000) {
return {
manager: params.manager,
status: "stale",
lockfilePath,
markerPath,
reason: "lockfile newer than install marker",
};
}
return {
manager: params.manager,
status: "ok",
lockfilePath,
markerPath,
};
}
async function fetchWithTimeout(url: string, timeoutMs: number): Promise<Response> {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), Math.max(250, timeoutMs));
try {
return await fetch(url, { signal: ctrl.signal });
} finally {
clearTimeout(t);
}
}
export async function fetchNpmLatestVersion(params?: {
timeoutMs?: number;
}): Promise<RegistryStatus> {
const res = await fetchNpmTagVersion({ tag: "latest", timeoutMs: params?.timeoutMs });
return {
latestVersion: res.version,
error: res.error,
};
}
export async function fetchNpmTagVersion(params: {
tag: string;
timeoutMs?: number;
}): Promise<NpmTagStatus> {
const timeoutMs = params?.timeoutMs ?? 3500;
const tag = params.tag;
try {
const res = await fetchWithTimeout(
`https://registry.npmjs.org/clawdbot/${encodeURIComponent(tag)}`,
timeoutMs,
);
if (!res.ok) {
return { tag, version: null, error: `HTTP ${res.status}` };
}
const json = (await res.json()) as { version?: unknown };
const version = typeof json?.version === "string" ? json.version : null;
return { tag, version };
} catch (err) {
return { tag, version: null, error: String(err) };
}
}
export async function resolveNpmChannelTag(params: {
channel: UpdateChannel;
timeoutMs?: number;
}): Promise<{ tag: string; version: string | null }> {
const channelTag = channelToNpmTag(params.channel);
const channelStatus = await fetchNpmTagVersion({ tag: channelTag, timeoutMs: params.timeoutMs });
if (params.channel !== "beta") {
return { tag: channelTag, version: channelStatus.version };
}
const latestStatus = await fetchNpmTagVersion({ tag: "latest", timeoutMs: params.timeoutMs });
if (!latestStatus.version) {
return { tag: channelTag, version: channelStatus.version };
}
if (!channelStatus.version) {
return { tag: "latest", version: latestStatus.version };
}
const cmp = compareSemverStrings(channelStatus.version, latestStatus.version);
if (cmp != null && cmp < 0) {
return { tag: "latest", version: latestStatus.version };
}
return { tag: channelTag, version: channelStatus.version };
}
export function compareSemverStrings(a: string | null, b: string | null): number | null {
const pa = parseSemver(a);
const pb = parseSemver(b);
if (!pa || !pb) return null;
if (pa.major !== pb.major) return pa.major < pb.major ? -1 : 1;
if (pa.minor !== pb.minor) return pa.minor < pb.minor ? -1 : 1;
if (pa.patch !== pb.patch) return pa.patch < pb.patch ? -1 : 1;
return 0;
}
export async function checkUpdateStatus(params: {
root: string | null;
timeoutMs?: number;
fetchGit?: boolean;
includeRegistry?: boolean;
}): Promise<UpdateCheckResult> {
const timeoutMs = params.timeoutMs ?? 6000;
const root = params.root ? path.resolve(params.root) : null;
if (!root) {
return {
root: null,
installKind: "unknown",
packageManager: "unknown",
registry: params.includeRegistry ? await fetchNpmLatestVersion({ timeoutMs }) : undefined,
};
}
const pm = await detectPackageManager(root);
const gitRoot = await detectGitRoot(root);
const isGit = gitRoot && path.resolve(gitRoot) === root;
const installKind: UpdateCheckResult["installKind"] = isGit ? "git" : "package";
const git = isGit
? await checkGitUpdateStatus({
root,
timeoutMs,
fetch: Boolean(params.fetchGit),
})
: undefined;
const deps = await checkDepsStatus({ root, manager: pm });
const registry = params.includeRegistry ? await fetchNpmLatestVersion({ timeoutMs }) : undefined;
return {
root,
installKind,
packageManager: pm,
git,
deps,
registry,
};
}