import fs from "node:fs/promises"; import path from "node:path"; import { runCommandWithTimeout } from "../process/exec.js"; import { fetchWithTimeout } from "../utils/fetch-timeout.js"; import { detectPackageManager as detectPackageManagerImpl } from "./detect-package-manager.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; }; export function formatGitInstallLabel(update: UpdateCheckResult): string | null { if (update.installKind !== "git") { return null; } const shortSha = update.git?.sha ? update.git.sha.slice(0, 8) : null; const branch = update.git?.branch && update.git.branch !== "HEAD" ? update.git.branch : null; const tag = update.git?.tag ?? null; const parts = [ branch ?? (tag ? "detached" : "git"), tag ? `tag ${tag}` : null, shortSha ? `@ ${shortSha}` : null, ].filter(Boolean); return parts.join(" ยท "); } async function exists(p: string): Promise { try { await fs.access(p); return true; } catch { return false; } } async function detectPackageManager(root: string): Promise { return (await detectPackageManagerImpl(root)) ?? "unknown"; } async function detectGitRoot(root: string): Promise { 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 { 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", "--", ":!dist/control-ui/"], { 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 { 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 { 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, }; } export async function fetchNpmLatestVersion(params?: { timeoutMs?: number; }): Promise { 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 { const timeoutMs = params?.timeoutMs ?? 3500; const tag = params.tag; try { const res = await fetchWithTimeout( `https://registry.npmjs.org/openclaw/${encodeURIComponent(tag)}`, {}, Math.max(250, 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 = parseComparableSemver(a); const pb = parseComparableSemver(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 comparePrerelease(pa.prerelease, pb.prerelease); } type ComparableSemver = { major: number; minor: number; patch: number; prerelease: string[] | null; }; function parseComparableSemver(version: string | null): ComparableSemver | null { if (!version) { return null; } const normalized = normalizeLegacyDotBetaVersion(version.trim()); const match = /^v?([0-9]+)\.([0-9]+)\.([0-9]+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec( normalized, ); if (!match) { return null; } const [, major, minor, patch, prereleaseRaw] = match; if (!major || !minor || !patch) { return null; } return { major: Number.parseInt(major, 10), minor: Number.parseInt(minor, 10), patch: Number.parseInt(patch, 10), prerelease: prereleaseRaw ? prereleaseRaw.split(".").filter(Boolean) : null, }; } function normalizeLegacyDotBetaVersion(version: string): string { const trimmed = version.trim(); const dotBetaMatch = /^([vV]?[0-9]+\.[0-9]+\.[0-9]+)\.beta(?:\.([0-9A-Za-z.-]+))?$/.exec(trimmed); if (!dotBetaMatch) { return trimmed; } const base = dotBetaMatch[1]; const suffix = dotBetaMatch[2]; return suffix ? `${base}-beta.${suffix}` : `${base}-beta`; } function comparePrerelease(a: string[] | null, b: string[] | null): number { if (!a?.length && !b?.length) { return 0; } if (!a?.length) { return 1; } if (!b?.length) { return -1; } const max = Math.max(a.length, b.length); for (let i = 0; i < max; i += 1) { const ai = a[i]; const bi = b[i]; if (ai == null && bi == null) { return 0; } if (ai == null) { return -1; } if (bi == null) { return 1; } if (ai === bi) { continue; } const aiNumeric = /^[0-9]+$/.test(ai); const biNumeric = /^[0-9]+$/.test(bi); if (aiNumeric && biNumeric) { const aiNum = Number.parseInt(ai, 10); const biNum = Number.parseInt(bi, 10); return aiNum < biNum ? -1 : 1; } if (aiNumeric && !biNumeric) { return -1; } if (!aiNumeric && biNumeric) { return 1; } return ai < bi ? -1 : 1; } return 0; } export async function checkUpdateStatus(params: { root: string | null; timeoutMs?: number; fetchGit?: boolean; includeRegistry?: boolean; }): Promise { 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, }; }