import "server-only"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; import path from "node:path"; import { existsSync } from "node:fs"; import { readFile, stat } from "node:fs/promises"; import { logServerAction, withServerTiming } from "@/lib/server/logging/logger"; import type { SystemUpdateApplyAcceptedResponse, SystemUpdateStatus, } from "@/lib/shared/contracts/system"; const execFileAsync = promisify(execFile); const DEFAULT_REPO_URL = process.env.HOMEIO_REPO_URL ?? "https://github.com/doctor-io/homeio.git"; const UPDATE_LOG_PATH = "/var/log/homeio-self-update.log"; const RELEASE_TAG = /^v?\d{1,3}\.\d{1,3}\.\d{1,4}(?:-[0-9A-Za-z.-]{1,32})?$/; const GITHUB_API_HEADERS = { Accept: "application/vnd.github+json", "User-Agent": "homeio-update-check", }; // GitHub allows 60 unauthenticated API calls an hour per IP, and every desktop // and Settings load asks for the update status. A release a day late is fine; // a Settings page that fails because GitHub rate-limited us is not. const LATEST_VERSION_TTL_MS = 60 * 60 * 1000; // After a failed check with nothing known yet, wait before asking again: // retrying on every load keeps a rate-limited IP rate-limited. const FAILED_CHECK_BACKOFF_MS = 5 * 60 * 1000; type LatestVersion = { version: string | null; /** When GitHub was actually asked, which is not now when the answer is cached. */ checkedAt: string; }; type LatestVersionCache = LatestVersion & { /** The branch followed, or "release": switching invalidates the answer. */ source: string; expiresAt: number; }; let latestVersionCache: LatestVersionCache | null = null; let latestVersionInFlight: Promise | null = null; let latestVersionFailure: { source: string; error: unknown; retryAt: number } | null = null; /** * A server that sets HOMEIO_REPO_BRANCH follows that branch, as before. * Everyone else follows published releases, so an update is only offered once * its GitHub release and Docker image exist, not the moment a PR lands on main. */ function configuredRepoBranch() { return process.env.HOMEIO_REPO_BRANCH?.trim() || null; } function parseGitHubRepository(repositoryUrl: string) { const url = new URL(repositoryUrl.replace(/\.git$/i, "")); if (url.hostname !== "github.com") { throw new Error("Unsupported repository host for Homeio update checks"); } const [owner, repo] = url.pathname.replace(/^\//, "").split("/"); if (!owner || !repo) { throw new Error("Invalid Homeio repository URL"); } return { owner, repo }; } function parseVersionParts(version: string) { return version .trim() .replace(/^v/i, "") .split(".") .map((part) => { const numeric = Number.parseInt(part, 10); return Number.isFinite(numeric) ? numeric : 0; }); } export function compareVersions(left: string, right: string) { const leftParts = parseVersionParts(left); const rightParts = parseVersionParts(right); const length = Math.max(leftParts.length, rightParts.length); for (let index = 0; index < length; index += 1) { const leftValue = leftParts[index] ?? 0; const rightValue = rightParts[index] ?? 0; if (leftValue > rightValue) return 1; if (leftValue < rightValue) return -1; } return 0; } async function readCurrentVersion() { const packageJsonPath = path.join(process.cwd(), "package.json"); const raw = await readFile(packageJsonPath, "utf8"); const parsed = JSON.parse(raw) as { version?: string }; return parsed.version?.trim() || "unknown"; } export function buildRemotePackageJsonUrl( repositoryUrl = DEFAULT_REPO_URL, branch = configuredRepoBranch() ?? "main", ) { const { owner, repo } = parseGitHubRepository(repositoryUrl); return `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/package.json`; } export function buildRemotePackageJsonApiUrl( repositoryUrl = DEFAULT_REPO_URL, branch = configuredRepoBranch() ?? "main", ) { const { owner, repo } = parseGitHubRepository(repositoryUrl); return `https://api.github.com/repos/${owner}/${repo}/contents/package.json?ref=${encodeURIComponent(branch)}`; } export function buildLatestReleaseApiUrl(repositoryUrl = DEFAULT_REPO_URL) { const { owner, repo } = parseGitHubRepository(repositoryUrl); return `https://api.github.com/repos/${owner}/${repo}/releases/latest`; } async function fetchBranchVersion(branch: string) { const response = await fetch(buildRemotePackageJsonApiUrl(DEFAULT_REPO_URL, branch), { cache: "no-store", headers: GITHUB_API_HEADERS, }); if (!response.ok) { throw new Error(`Failed to fetch Homeio version metadata (${response.status})`); } const parsed = (await response.json()) as { content?: string; encoding?: string }; if (parsed.encoding !== "base64" || typeof parsed.content !== "string") { throw new Error("Remote Homeio package metadata did not include decodable content"); } const packageJson = JSON.parse(Buffer.from(parsed.content, "base64").toString("utf8")) as { version?: string; }; if (typeof packageJson.version !== "string" || packageJson.version.trim().length === 0) { throw new Error("Remote Homeio package metadata did not include a version"); } return packageJson.version.trim(); } /** The tag of the release GitHub marks as Latest, or null before the first release. */ async function fetchLatestReleaseTag() { const response = await fetch(buildLatestReleaseApiUrl(), { cache: "no-store", headers: GITHUB_API_HEADERS, }); if (response.status === 404) return null; if (!response.ok) { throw new Error(`Failed to fetch the latest Homeio release (${response.status})`); } const parsed = (await response.json()) as { tag_name?: unknown }; const tag = typeof parsed.tag_name === "string" ? parsed.tag_name.trim() : ""; // The tag becomes a git ref for update.sh, so only accept a version tag. if (!RELEASE_TAG.test(tag)) { throw new Error("The latest Homeio release does not have a version tag"); } return tag; } async function fetchLatestVersion() { const branch = configuredRepoBranch(); if (branch) return fetchBranchVersion(branch); const tag = await fetchLatestReleaseTag(); return tag ? tag.replace(/^v/i, "") : null; } /** * The latest version, remembered for an hour. When GitHub cannot be reached the * last known answer is served instead of an error, unless the caller asked for * a fresh check (the "Check now" button), which must report the failure. */ async function getLatestVersion({ refresh = false } = {}): Promise { const source = configuredRepoBranch() ?? "release"; const cached = latestVersionCache?.source === source ? latestVersionCache : null; if (!refresh && cached && cached.expiresAt > Date.now()) return cached; const failure = latestVersionFailure?.source === source ? latestVersionFailure : null; if (!refresh && !cached && failure && failure.retryAt > Date.now()) throw failure.error; // Desktops opening together share one GitHub call. if (!refresh && latestVersionInFlight) return latestVersionInFlight; const lookup = fetchLatestVersion() .then((version) => { latestVersionFailure = null; latestVersionCache = { source, version, checkedAt: new Date().toISOString(), expiresAt: Date.now() + LATEST_VERSION_TTL_MS, }; return latestVersionCache; }) .catch((error: unknown) => { if (!cached) latestVersionFailure = { source, error, retryAt: Date.now() + FAILED_CHECK_BACKOFF_MS }; if (refresh || !cached) throw error; logServerAction({ level: "warn", layer: "service", action: "system.updates.latest.stale", status: "error", error, message: "Could not check GitHub for updates; serving the last known version", }); return cached; }) .finally(() => { if (latestVersionInFlight === lookup) latestVersionInFlight = null; }); if (!refresh) latestVersionInFlight = lookup; return lookup; } export function resetUpdateStatusCacheForTests() { latestVersionCache = null; latestVersionInFlight = null; latestVersionFailure = null; } export async function getSystemUpdateStatus( options: { refresh?: boolean } = {}, ): Promise { return withServerTiming( { layer: "service", action: "system.updates.status.get", }, async () => { const currentVersion = await readCurrentVersion(); const { version: latestVersion, checkedAt } = await getLatestVersion(options); return { currentVersion, latestVersion, updateAvailable: latestVersion !== null && compareVersions(latestVersion, currentVersion) > 0, checkedAt, canSelfUpdate: !isContainerRuntime(), }; }, ); } function shellEscape(value: string) { return `'${value.replace(/'/g, `'"'"'`)}'`; } export function isContainerRuntime(): boolean { if (process.env.HOMEIO_CONTAINER === "true" || process.env.DOCKER === "true") { return true; } try { if (existsSync("/.dockerenv")) { return true; } } catch { // ignore } return false; } export async function scheduleSystemUpdate(): Promise { if (isContainerRuntime()) { throw new Error( "Homeio is running in a Docker container. In-app self-update via systemd is disabled. Please update your container using: docker compose pull && docker compose up -d", ); } const updateScriptPath = path.join(process.cwd(), "scripts", "update.sh"); try { await stat(updateScriptPath); } catch { throw new Error(`Update script not found at ${updateScriptPath}. Cannot schedule Homeio update.`); } try { await execFileAsync("which", ["systemd-run"]); } catch { throw new Error( "systemd-run is not available on this host. Automated in-app update requires systemd. Please update Homeio manually or via git pull.", ); } // Guard against concurrent updates. Two simultaneous update runs would race // on the same git working tree, npm install, and migration steps. try { const { stdout } = await execFileAsync("systemctl", [ "list-units", "--state=activating,active", "--no-pager", "--no-legend", "homeio-self-update-*.service", ]); if (stdout.trim().length > 0) { throw new Error("A system update is already in progress. Wait for it to complete before starting another."); } } catch (error) { if (error instanceof Error && error.message.startsWith("A system update")) { throw error; } // systemctl failure is non-fatal — proceed with scheduling } // update.sh fetches this ref: the configured branch, or the exact tag of the // release the update check announced. const targetRef = configuredRepoBranch() ?? (await fetchLatestReleaseTag()); if (!targetRef) { throw new Error("There is no published Homeio release to update to yet."); } const unitName = `homeio-self-update-${Date.now()}`; const command = [ "mkdir -p /var/log", "sleep 2", `${shellEscape(updateScriptPath)} >> ${shellEscape(UPDATE_LOG_PATH)} 2>&1`, ].join("; "); logServerAction({ layer: "service", action: "system.updates.apply.schedule", status: "info", message: "Scheduling Homeio update", meta: { unitName, updateScriptPath, targetRef, }, }); // systemd-run starts a transient unit with a minimal environment. Without // HOME, `go build` fails ("GOCACHE is not defined and neither $XDG_CACHE_HOME // nor $HOME are defined"); without /usr/local/go on PATH the build can't find // the toolchain at all. Set both explicitly so update.sh runs the same way it // would in an interactive root shell. await execFileAsync("systemd-run", [ "--quiet", "--no-block", `--unit=${unitName}`, "--collect", "--property=Type=exec", "--property=KillMode=control-group", "--property=TimeoutStopSec=30s", "--property=SendSIGKILL=yes", "--setenv=HOME=/root", "--setenv=PATH=/usr/local/go/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", // update.sh defaults to main. Without this it would install whatever main // holds rather than the version the update check announced. `--setenv=HOMEIO_REPO_BRANCH=${targetRef}`, `--setenv=HOMEIO_REPO_URL=${DEFAULT_REPO_URL}`, "bash", "-lc", command, ]); return { action: "update", accepted: true, }; }