import fs from "node:fs/promises"; import path from "node:path"; import { runCommandWithTimeout } from "../process/exec.js"; import { parseSemver } from "./runtime-guard.js"; export type PackageManager = "pnpm" | "bun" | "npm" | "unknown"; export type GitUpdateStatus = { root: string; 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 UpdateCheckResult = { root: string | null; installKind: "git" | "package" | "unknown"; packageManager: PackageManager; git?: GitUpdateStatus; deps?: DepsStatus; registry?: RegistryStatus; }; async function exists(p: string): Promise { try { await fs.access(p); return true; } catch { return false; } } async function detectPackageManager(root: string): Promise { 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 { 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, 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 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, 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, }; } async function fetchWithTimeout( url: string, timeoutMs: number, ): Promise { 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 { const timeoutMs = params?.timeoutMs ?? 3500; try { const res = await fetchWithTimeout( "https://registry.npmjs.org/clawdbot/latest", timeoutMs, ); if (!res.ok) { return { latestVersion: null, error: `HTTP ${res.status}` }; } const json = (await res.json()) as { version?: unknown }; const latestVersion = typeof json?.version === "string" ? json.version : null; return { latestVersion }; } catch (err) { return { latestVersion: null, error: String(err) }; } } 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 { 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, }; }