IKHSAN
ENID Baca dalam Bahasa Indonesia

homeio

Public

Forked from doctor-io/homeio

Branch
main
Latest commit
958d74616 October 2026

update-service.ts

Open raw
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<LatestVersion> | 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<LatestVersion> {
  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<SystemUpdateStatus> {
  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<SystemUpdateApplyAcceptedResponse> {
  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,
  };
}
bytes
12,567
lines
373
Type
ts
Latest commit
16 October 2026