diff --git a/Dockerfile b/Dockerfile index cadeb913..f7ad521d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,13 +1,17 @@ FROM node:22-bookworm-slim AS builder WORKDIR /app +RUN apt-get update \ + && apt-get install -y --no-install-recommends libsecret-1-0 \ + && rm -rf /var/lib/apt/lists/* + COPY package*.json ./ COPY scripts/postinstall.mjs ./scripts/postinstall.mjs COPY scripts/native-binary-compat.mjs ./scripts/native-binary-compat.mjs RUN if [ -f package-lock.json ]; then npm ci --no-audit --no-fund; else npm install --no-audit --no-fund; fi COPY . ./ -RUN mkdir -p /app/data && npm run build +RUN mkdir -p /app/data && npm run build -- --webpack FROM node:22-bookworm-slim AS runner-base WORKDIR /app @@ -25,6 +29,9 @@ ENV NODE_OPTIONS="--max-old-space-size=256" # Data directory inside Docker — must match the volume mount in docker-compose.yml ENV DATA_DIR=/app/data +RUN apt-get update \ + && apt-get install -y --no-install-recommends libsecret-1-0 \ + && rm -rf /var/lib/apt/lists/* RUN mkdir -p /app/data COPY --from=builder /app/public ./public @@ -53,7 +60,7 @@ FROM runner-base AS runner-cli # Install system dependencies required by openclaw (git+ssh references). RUN apt-get update \ - && apt-get install -y --no-install-recommends git ca-certificates \ + && apt-get install -y --no-install-recommends git ca-certificates docker.io docker-compose \ && rm -rf /var/lib/apt/lists/* \ && git config --system url."https://github.com/".insteadOf "ssh://git@github.com/" diff --git a/docker-compose.yml b/docker-compose.yml index e1ee53f4..8bc975b3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -59,6 +59,11 @@ services: ports: - "${DASHBOARD_PORT:-${PORT:-20128}}:${DASHBOARD_PORT:-${PORT:-20128}}" - "${API_PORT:-20129}:${API_PORT:-20129}" + volumes: + - omniroute-data:/app/data + - /var/run/docker.sock:/var/run/docker.sock + - /usr/libexec/docker/cli-plugins:/usr/libexec/docker/cli-plugins:ro + - ${AUTO_UPDATE_HOST_REPO_DIR:-.}:/workspace/omniroute:rw profiles: - cli diff --git a/src/app/(dashboard)/dashboard/HomePageClient.tsx b/src/app/(dashboard)/dashboard/HomePageClient.tsx index 8936fbcc..41545ad7 100644 --- a/src/app/(dashboard)/dashboard/HomePageClient.tsx +++ b/src/app/(dashboard)/dashboard/HomePageClient.tsx @@ -8,10 +8,30 @@ import Image from "next/image"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { Card, CardSkeleton, Button, Modal } from "@/shared/components"; +import ProviderIcon from "@/shared/components/ProviderIcon"; import { AI_PROVIDERS, FREE_PROVIDERS, OAUTH_PROVIDERS } from "@/shared/constants/providers"; import { useNotificationStore } from "@/store/notificationStore"; import { copyToClipboard } from "@/shared/utils/clipboard"; +type UpdateStep = { + step: string; + status: string; + message: string; +}; + +const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +function mergeUpdateStep(steps: UpdateStep[], nextStep: UpdateStep) { + const idx = steps.findIndex((step) => step.step === nextStep.step); + if (idx === -1) { + return [...steps, nextStep]; + } + + const next = [...steps]; + next[idx] = nextStep; + return next; +} + export default function HomePageClient({ machineId }) { const t = useTranslations("home"); const tc = useTranslations("common"); @@ -25,6 +45,8 @@ export default function HomePageClient({ machineId }) { const [versionInfo, setVersionInfo] = useState(null); const [updating, setUpdating] = useState(false); + const [updateSteps, setUpdateSteps] = useState([]); + const [updatePhase, setUpdatePhase] = useState<"idle" | "running" | "done" | "failed">("idle"); useEffect(() => { if (typeof window !== "undefined") { @@ -88,7 +110,6 @@ export default function HomePageClient({ machineId }) { const providerKeys = new Set([providerId, providerInfo.alias].filter(Boolean)); const providerModels = models.filter((m) => providerKeys.has(m.provider)); - // Determine auth type const authType = FREE_PROVIDERS[providerId] ? "free" : OAUTH_PROVIDERS[providerId] @@ -107,7 +128,6 @@ export default function HomePageClient({ machineId }) { }); }, [providerConnections, models]); - // Models for selected provider const selectedProviderModels = useMemo(() => { if (!selectedProvider) return []; const providerKeys = new Set( @@ -131,27 +151,267 @@ export default function HomePageClient({ machineId }) { }, ]; + const pollBackgroundUpdate = useCallback( + async ({ + channel, + message, + targetVersion, + }: { + channel: string; + message: string; + targetVersion: string; + }) => { + const notify = useNotificationStore.getState(); + const initialSteps = + channel === "docker-compose" + ? [ + { + step: "install", + status: "done", + message: message || `Queued update to v${targetVersion}.`, + }, + { + step: "rebuild", + status: "running", + message: "Docker image is rebuilding in the background.", + }, + { + step: "restart", + status: "pending", + message: "Waiting for OmniRoute to restart with the new version.", + }, + ] + : [ + { + step: "install", + status: "running", + message: message || `Installing v${targetVersion}.`, + }, + { + step: "restart", + status: "pending", + message: "Waiting for OmniRoute to restart with the new version.", + }, + ]; + + setUpdateSteps(initialSteps); + + const maxAttempts = channel === "docker-compose" ? 72 : 36; + + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + await wait(5000); + + try { + const versionRes = await fetch("/api/system/version", { cache: "no-store" }); + if (!versionRes.ok) { + throw new Error(`Version check returned ${versionRes.status}`); + } + + const latestInfo = await versionRes.json(); + setVersionInfo(latestInfo); + + if (latestInfo.current === targetVersion) { + setUpdateSteps((prev) => { + let next = prev.map((step) => { + if (step.step === "install" || step.step === "rebuild" || step.step === "restart") { + return { ...step, status: "done" }; + } + return step; + }); + + next = mergeUpdateStep(next, { + step: "complete", + status: "done", + message: `OmniRoute is now running v${targetVersion}.`, + }); + + return next; + }); + setUpdating(false); + setUpdatePhase("done"); + notify.success(`OmniRoute updated to v${targetVersion}.`); + await fetchData(); + return; + } + + setUpdateSteps((prev) => { + let next = prev; + if (channel === "docker-compose") { + next = mergeUpdateStep(next, { + step: "rebuild", + status: "running", + message: `Docker image is still rebuilding for v${targetVersion}.`, + }); + } else { + next = mergeUpdateStep(next, { + step: "install", + status: "running", + message: `Installing v${targetVersion} in the background.`, + }); + } + + next = mergeUpdateStep(next, { + step: "restart", + status: "pending", + message: `Waiting for OmniRoute to come back on v${targetVersion}.`, + }); + + return next; + }); + } catch { + setUpdateSteps((prev) => { + let next = prev; + if (channel === "docker-compose") { + next = mergeUpdateStep(next, { + step: "rebuild", + status: "running", + message: "Docker rebuild is still in progress.", + }); + } else { + next = mergeUpdateStep(next, { + step: "install", + status: "running", + message: `Installing v${targetVersion} in the background.`, + }); + } + + next = mergeUpdateStep(next, { + step: "restart", + status: "running", + message: "Service restart in progress. Waiting for OmniRoute to come back online...", + }); + + return next; + }); + } + } + + setUpdateSteps((prev) => + mergeUpdateStep(prev, { + step: "error", + status: "failed", + message: `Update started, but v${targetVersion} did not become available before timeout. Refresh the page or check server logs.`, + }) + ); + setUpdating(false); + setUpdatePhase("failed"); + notify.error(`Update to v${targetVersion} timed out.`); + }, + [fetchData] + ); + const handleUpdate = async () => { const notify = useNotificationStore.getState(); setUpdating(true); + setUpdatePhase("running"); + setUpdateSteps([]); + try { - notify.info(t("updateStarted") || "Update process started..."); const res = await fetch("/api/system/version", { method: "POST" }); - const data = await res.json(); - if (res.ok && data.success) { - notify.success( - data.message || "Update initiated successfully. The system will restart shortly." - ); - } else { - notify.error(data.error || "Failed to start update."); + + // If response is JSON (error/already up to date) + const contentType = res.headers.get("content-type") || ""; + if (contentType.includes("application/json")) { + const data = await res.json(); + if (!res.ok || !data.success) { + notify.error(data.error || "Failed to start update."); + setUpdating(false); + setUpdatePhase("idle"); + return; + } + + notify.success(data.message || "Update started."); + await pollBackgroundUpdate({ + channel: data.channel || "docker-compose", + message: data.message || "", + targetVersion: data.to || data.latest, + }); + return; + } + + // SSE stream — read progress events + if (!res.body) { + notify.error("No response stream received."); setUpdating(false); + setUpdatePhase("idle"); + return; + } + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + if (!line.startsWith("data: ")) continue; + try { + const event = JSON.parse(line.slice(6)); + + setUpdateSteps((prev) => { + return mergeUpdateStep(prev, event); + }); + + if (event.step === "complete") { + setUpdatePhase("done"); + setUpdating(false); + notify.success(event.message || "Update complete!"); + } else if (event.step === "error") { + setUpdatePhase("failed"); + notify.error(event.message || "Update failed."); + setUpdating(false); + } + } catch { + // ignore parse errors + } + } } } catch { - notify.error("Network error while trying to update."); + setUpdatePhase("failed"); + setUpdateSteps((prev) => [ + ...prev, + { + step: "error", + status: "failed", + message: "Network error — connection lost during update.", + }, + ]); setUpdating(false); } }; + // Auto-reload after successful update (service restarts, need new page) + useEffect(() => { + if (updatePhase !== "done") return; + const timer = setTimeout(() => { + window.location.reload(); + }, 8000); + return () => clearTimeout(timer); + }, [updatePhase]); + + const stepIcons: Record = { + install: "download", + rebuild: "build", + restart: "restart_alt", + complete: "check_circle", + error: "error", + }; + + const stepLabels: Record = { + install: "Install Package", + rebuild: "Rebuild Native Modules", + restart: "Restart Service", + complete: "Complete", + error: "Error", + }; + const showUpdateOverlay = updatePhase !== "idle"; + if (loading) { return (
@@ -165,8 +425,122 @@ export default function HomePageClient({ machineId }) { return (
+ {/* Update Progress Overlay */} + {showUpdateOverlay && ( +
+
+
+ + progress_activity + +
+

+ {updatePhase === "done" + ? "Update Complete!" + : updatePhase === "failed" + ? "Update Failed" + : "Updating OmniRoute..."} +

+

+ {updatePhase === "done" + ? "The page will reload automatically in a few seconds." + : updatePhase === "failed" + ? "Please try again or update manually via the CLI." + : "Do not close this page. The system will restart automatically."} +

+
+
+ + {/* Step list */} +
+ {updateSteps + .filter((s) => s.step !== "complete" && s.step !== "error") + .map((s) => ( +
+ {s.status === "running" ? ( + + progress_activity + + ) : s.status === "done" ? ( + + check_circle + + ) : s.status === "failed" ? ( + + error + + ) : ( + + warning + + )} +
+

{stepLabels[s.step] || s.step}

+

{s.message}

+
+
+ ))} + + {/* Error message */} + {updateSteps.find((s) => s.step === "error") && ( +
+

+ {updateSteps.find((s) => s.step === "error")?.message} +

+
+ )} + + {/* Completion message */} + {updatePhase === "done" && ( +
+

+ check_circle + {updateSteps.find((s) => s.step === "complete")?.message || "Update complete!"} +

+

Reloading page automatically...

+
+ )} +
+ + {/* Actions */} + {(updatePhase === "failed" || updatePhase === "done") && ( +
+ + {updatePhase === "failed" && ( + + )} +
+ )} +
+
+ )} + {/* Update Notification Banner */} - {versionInfo?.updateAvailable && ( + {versionInfo?.updateAvailable && !showUpdateOverlay && (
system_update_alt @@ -184,7 +558,7 @@ export default function HomePageClient({ machineId }) { disabled={updating} className="shrink-0 ml-4 font-semibold" > - {updating ? t("updating") || "Updating..." : t("updateNow") || "Update Now"} + {t("updateNow") || "Update Now"}
)} @@ -356,7 +730,6 @@ HomePageClient.propTypes = { }; function ProviderOverviewCard({ item, metrics, onClick }) { - const [imgError, setImgError] = useState(false); const t = useTranslations("home"); const tc = useTranslations("common"); @@ -380,24 +753,7 @@ function ProviderOverviewCard({ item, metrics, onClick }) { className="size-8 rounded-lg flex items-center justify-center shrink-0" style={{ backgroundColor: `${item.provider.color || "#888"}15` }} > - {imgError ? ( - - {item.provider.textIcon || item.provider.id.slice(0, 2).toUpperCase()} - - ) : ( - {item.provider.name} setImgError(true)} - /> - )} +
diff --git a/src/app/api/system/version/route.ts b/src/app/api/system/version/route.ts index 817a2f76..3317df7a 100644 --- a/src/app/api/system/version/route.ts +++ b/src/app/api/system/version/route.ts @@ -1,6 +1,6 @@ /** * GET /api/system/version — Returns current version and latest available on npm - * POST /api/system/update — Triggers npm install -g omniroute@latest + pm2 restart + * POST /api/system/version — Triggers a deployment-aware background update * * Security: Requires admin authentication (same as other management routes). * Safety: Update only runs if a newer version is available on npm. @@ -9,12 +9,16 @@ import { NextRequest, NextResponse } from "next/server"; import { execFile } from "child_process"; import { promisify } from "util"; import { isAuthenticated } from "@/shared/utils/apiAuth"; +import { + getAutoUpdateConfig, + launchAutoUpdate, + validateAutoUpdateRuntime, +} from "@/lib/system/autoUpdate"; const execFileAsync = promisify(execFile); export const dynamic = "force-dynamic"; -/** Fetch latest version from npm registry (no install, just metadata) */ async function getLatestNpmVersion(): Promise { try { const { stdout } = await execFileAsync("npm", ["info", "omniroute", "version", "--json"], { @@ -27,17 +31,14 @@ async function getLatestNpmVersion(): Promise { } } -/** Current installed version from package.json */ function getCurrentVersion(): string { try { - return require("../../../../../package.json").version as string; } catch { return "unknown"; } } -/** Compare semver strings — returns true if a > b */ function isNewer(a: string | null, b: string): boolean { if (!a) return false; const parse = (v: string) => v.split(".").map(Number); @@ -49,24 +50,28 @@ function isNewer(a: string | null, b: string): boolean { } export async function GET(req: NextRequest) { - if (!isAuthenticated(req)) { + if (!(await isAuthenticated(req))) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } const current = getCurrentVersion(); const latest = await getLatestNpmVersion(); const updateAvailable = isNewer(latest, current); + const config = getAutoUpdateConfig(); + const validation = await validateAutoUpdateRuntime(config); return NextResponse.json({ current, latest: latest ?? "unavailable", updateAvailable, - channel: "npm", + channel: config.mode, + autoUpdateSupported: validation.supported, + autoUpdateError: validation.reason, }); } export async function POST(req: NextRequest) { - if (!isAuthenticated(req)) { + if (!(await isAuthenticated(req))) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } @@ -89,27 +94,30 @@ export async function POST(req: NextRequest) { }); } - // Run update in background — client gets immediate acknowledgment - const install = async () => { - try { - await execFileAsync("npm", ["install", "-g", `omniroute@${latest}`, "--ignore-scripts"], { - timeout: 300000, // 5 minutes - }); - // Restart PM2 — non-fatal if pm2 not available (Docker/manual setups) - await execFileAsync("pm2", ["restart", "omniroute"]).catch(() => null); - console.log(`[AutoUpdate] Successfully updated to v${latest}`); - } catch (err) { - console.error(`[AutoUpdate] Update failed:`, err); - } - }; + const launched = await launchAutoUpdate({ latest }); + if (!launched.started) { + return NextResponse.json( + { + success: false, + error: launched.error || "Failed to start auto-update.", + channel: launched.channel, + logPath: launched.logPath, + }, + { status: 503 } + ); + } - // Fire-and-forget - install(); + const message = + launched.channel === "docker-compose" + ? `Update to v${latest} started. Docker rebuild is running in the background.` + : `Update to v${latest} started. Restarting in ~30 seconds.`; return NextResponse.json({ success: true, - message: `Update to v${latest} started. Restarting in ~30 seconds.`, + message, from: current, to: latest, + channel: launched.channel, + logPath: launched.logPath, }); } diff --git a/src/lib/system/autoUpdate.ts b/src/lib/system/autoUpdate.ts new file mode 100644 index 00000000..dd1e943f --- /dev/null +++ b/src/lib/system/autoUpdate.ts @@ -0,0 +1,258 @@ +import { execFile, spawn } from "node:child_process"; +import { closeSync, mkdirSync, openSync } from "node:fs"; +import { access } from "node:fs/promises"; +import path from "node:path"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +type ComposeCommand = "docker compose" | "docker-compose"; +export type AutoUpdateMode = "npm" | "docker-compose"; + +type ExecFileLike = typeof execFileAsync; +type SpawnLike = typeof spawn; + +export type AutoUpdateConfig = { + mode: AutoUpdateMode; + repoDir: string; + composeFile: string; + composeProfile: string; + composeService: string; + gitRemote: string; + patchCommits: string[]; + logPath: string; +}; + +export type AutoUpdateValidation = { + supported: boolean; + reason: string | null; + composeCommand: ComposeCommand | null; +}; + +export type AutoUpdateLaunchResult = { + started: boolean; + channel: AutoUpdateMode; + logPath: string; + composeCommand: ComposeCommand | null; + error?: string; +}; + +function normalizeMode(raw: string | undefined): AutoUpdateMode { + return raw === "docker-compose" ? "docker-compose" : "npm"; +} + +async function pathExists(targetPath: string): Promise { + try { + await access(targetPath); + return true; + } catch { + return false; + } +} + +function shellQuote(value: string): string { + return `'${value.replace(/'/g, `'"'"'`)}'`; +} + +function parsePatchCommits(raw: string | undefined): string[] { + return (raw || "").split(/[\s,]+/).map((value) => value.trim()).filter(Boolean); +} + +export function getAutoUpdateConfig(env: NodeJS.ProcessEnv = process.env): AutoUpdateConfig { + const dataDir = env.DATA_DIR || "/tmp/omniroute"; + const repoDir = env.AUTO_UPDATE_REPO_DIR || "/workspace/omniroute"; + + return { + mode: normalizeMode(env.AUTO_UPDATE_MODE), + repoDir, + composeFile: env.AUTO_UPDATE_COMPOSE_FILE || path.join(repoDir, "docker-compose.yml"), + composeProfile: env.AUTO_UPDATE_COMPOSE_PROFILE || "cli", + composeService: env.AUTO_UPDATE_SERVICE || "omniroute-cli", + gitRemote: env.AUTO_UPDATE_GIT_REMOTE || "origin", + patchCommits: parsePatchCommits(env.AUTO_UPDATE_PATCH_COMMITS), + logPath: env.AUTO_UPDATE_LOG_PATH || path.join(dataDir, "logs", "auto-update.log"), + }; +} + +export async function detectComposeCommand( + execFileImpl: ExecFileLike = execFileAsync +): Promise { + try { + await execFileImpl("docker", ["compose", "version"], { timeout: 10_000 }); + return "docker compose"; + } catch { + // Fall through. + } + + try { + await execFileImpl("docker-compose", ["version"], { timeout: 10_000 }); + return "docker-compose"; + } catch { + return null; + } +} + +export async function validateAutoUpdateRuntime( + config: AutoUpdateConfig, + execFileImpl: ExecFileLike = execFileAsync, + existsImpl: (targetPath: string) => Promise = pathExists +): Promise { + if (config.mode !== "docker-compose") { + return { supported: true, reason: null, composeCommand: null }; + } + + if (!(await existsImpl(config.repoDir))) { + return { + supported: false, + reason: `Repository directory not found: ${config.repoDir}`, + composeCommand: null, + }; + } + + if (!(await existsImpl(config.composeFile))) { + return { + supported: false, + reason: `Compose file not found: ${config.composeFile}`, + composeCommand: null, + }; + } + + if (!(await existsImpl("/var/run/docker.sock"))) { + return { + supported: false, + reason: "Docker socket is not mounted into the OmniRoute container.", + composeCommand: null, + }; + } + + try { + await execFileImpl("git", ["--version"], { timeout: 10_000 }); + } catch { + return { + supported: false, + reason: "git is not available inside the OmniRoute container.", + composeCommand: null, + }; + } + + const composeCommand = await detectComposeCommand(execFileImpl); + if (!composeCommand) { + return { + supported: false, + reason: "Neither docker compose nor docker-compose is available inside the OmniRoute container.", + composeCommand: null, + }; + } + + return { supported: true, reason: null, composeCommand }; +} + +export function buildNpmUpdateScript(latest: string): string { + return [ + "set -eu", + `npm install -g omniroute@${latest} --ignore-scripts`, + "if command -v pm2 >/dev/null 2>&1; then", + " pm2 restart omniroute || true", + "fi", + `echo \"[AutoUpdate] Successfully updated to v${latest}.\"`, + ].join("\n"); +} + +export function buildDockerComposeUpdateScript({ + latest, + config, + composeCommand, +}: { + latest: string; + config: AutoUpdateConfig; + composeCommand: ComposeCommand; +}): string { + const targetTag = latest.startsWith("v") ? latest : `v${latest}`; + const composeInvocation = + composeCommand === "docker compose" + ? 'docker compose -f "$COMPOSE_FILE" up -d --build "$SERVICE"' + : 'docker-compose -f "$COMPOSE_FILE" up -d --build "$SERVICE"'; + const patchLines = config.patchCommits.length + ? [`git cherry-pick --keep-redundant-commits ${config.patchCommits.map(shellQuote).join(' ')}`] + : []; + + return [ + "set -eu", + 'export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:$PATH"', + `REPO_DIR=${shellQuote(config.repoDir)}`, + `COMPOSE_FILE=${shellQuote(config.composeFile)}`, + `PROFILE=${shellQuote(config.composeProfile)}`, + `SERVICE=${shellQuote(config.composeService)}`, + `REMOTE=${shellQuote(config.gitRemote)}`, + `TARGET_TAG=${shellQuote(targetTag)}`, + 'cd "$REPO_DIR"', + 'git config --global --add safe.directory "$REPO_DIR" >/dev/null 2>&1 || true', + 'if [ -n "$(git status --porcelain)" ]; then', + ' echo "[AutoUpdate] Refusing update: git worktree has local changes." >&2', + ' exit 1', + 'fi', + 'git fetch --tags "$REMOTE"', + 'if ! git rev-parse -q --verify "refs/tags/$TARGET_TAG" >/dev/null 2>&1; then', + ' echo "[AutoUpdate] Tag $TARGET_TAG not found on remote $REMOTE." >&2', + ' exit 1', + 'fi', + 'backup_branch="autoupdate/pre-${TARGET_TAG#v}-$(date +%Y%m%d-%H%M%S)"', + 'git branch "$backup_branch" >/dev/null 2>&1 || true', + 'git checkout -B "autoupdate/${TARGET_TAG#v}" "$TARGET_TAG"', + ...patchLines, + 'export COMPOSE_PROFILES="$PROFILE"', + composeInvocation, + `echo "[AutoUpdate] Successfully switched to ${targetTag} via ${composeCommand}."`, + ].join("\n"); +} + +export async function launchAutoUpdate({ + latest, + env = process.env, + execFileImpl = execFileAsync, + spawnImpl = spawn, +}: { + latest: string; + env?: NodeJS.ProcessEnv; + execFileImpl?: ExecFileLike; + spawnImpl?: SpawnLike; +}): Promise { + const config = getAutoUpdateConfig(env); + const validation = await validateAutoUpdateRuntime(config, execFileImpl); + + if (!validation.supported) { + return { + started: false, + channel: config.mode, + logPath: config.logPath, + composeCommand: validation.composeCommand, + error: validation.reason || "Auto-update runtime is not available.", + }; + } + + const script = + config.mode === "docker-compose" + ? buildDockerComposeUpdateScript({ + latest, + config, + composeCommand: validation.composeCommand || "docker-compose", + }) + : buildNpmUpdateScript(latest); + + mkdirSync(path.dirname(config.logPath), { recursive: true }); + const logFd = openSync(config.logPath, "a"); + const child = spawnImpl("sh", ["-lc", script], { + detached: true, + stdio: ["ignore", logFd, logFd], + env: { ...process.env, ...env }, + }); + closeSync(logFd); + child.unref(); + + return { + started: true, + channel: config.mode, + logPath: config.logPath, + composeCommand: validation.composeCommand, + }; +} diff --git a/tests/unit/auto-update-runtime.test.mjs b/tests/unit/auto-update-runtime.test.mjs new file mode 100644 index 00000000..f4ab00aa --- /dev/null +++ b/tests/unit/auto-update-runtime.test.mjs @@ -0,0 +1,104 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +const autoUpdate = await import("../../src/lib/system/autoUpdate.ts"); + +describe("getAutoUpdateConfig", () => { + it("defaults to npm mode", () => { + const config = autoUpdate.getAutoUpdateConfig({ DATA_DIR: "/tmp/omniroute" }); + assert.equal(config.mode, "npm"); + assert.equal(config.repoDir, "/workspace/omniroute"); + assert.equal(config.composeProfile, "cli"); + }); + + it("reads docker-compose settings from env", () => { + const config = autoUpdate.getAutoUpdateConfig({ + DATA_DIR: "/tmp/custom-data", + AUTO_UPDATE_MODE: "docker-compose", + AUTO_UPDATE_REPO_DIR: "/srv/omniroute", + AUTO_UPDATE_COMPOSE_FILE: "/srv/omniroute/docker-compose.yml", + AUTO_UPDATE_COMPOSE_PROFILE: "base", + AUTO_UPDATE_SERVICE: "omniroute-base", + AUTO_UPDATE_GIT_REMOTE: "upstream", + AUTO_UPDATE_PATCH_COMMITS: "abc123 def456,ghi789", + AUTO_UPDATE_LOG_PATH: "/tmp/update.log", + }); + + assert.equal(config.mode, "docker-compose"); + assert.equal(config.repoDir, "/srv/omniroute"); + assert.equal(config.composeFile, "/srv/omniroute/docker-compose.yml"); + assert.equal(config.composeProfile, "base"); + assert.equal(config.composeService, "omniroute-base"); + assert.equal(config.gitRemote, "upstream"); + assert.deepEqual(config.patchCommits, ["abc123", "def456", "ghi789"]); + assert.equal(config.logPath, "/tmp/update.log"); + }); +}); + +describe("validateAutoUpdateRuntime", () => { + it("reports missing docker socket for docker-compose mode", async () => { + const config = autoUpdate.getAutoUpdateConfig({ + AUTO_UPDATE_MODE: "docker-compose", + AUTO_UPDATE_REPO_DIR: "/repo", + AUTO_UPDATE_COMPOSE_FILE: "/repo/docker-compose.yml", + }); + + const result = await autoUpdate.validateAutoUpdateRuntime( + config, + async () => ({ stdout: "git version 2.0.0", stderr: "" }), + async (targetPath) => targetPath !== "/var/run/docker.sock" + ); + + assert.equal(result.supported, false); + assert.match(result.reason, /Docker socket/); + }); + + it("detects docker-compose command availability", async () => { + const config = autoUpdate.getAutoUpdateConfig({ + AUTO_UPDATE_MODE: "docker-compose", + AUTO_UPDATE_REPO_DIR: "/repo", + AUTO_UPDATE_COMPOSE_FILE: "/repo/docker-compose.yml", + }); + + const result = await autoUpdate.validateAutoUpdateRuntime( + config, + async (file, args) => { + if (file === "git") return { stdout: "git version 2.0.0", stderr: "" }; + if (file === "docker" && args?.[0] === "compose") { + return { stdout: "Docker Compose version v2.0.0", stderr: "" }; + } + throw new Error(`unexpected command: ${file}`); + }, + async () => true + ); + + assert.equal(result.supported, true); + assert.equal(result.composeCommand, "docker compose"); + }); +}); + +describe("buildDockerComposeUpdateScript", () => { + it("includes git checkout and compose rebuild steps", () => { + const config = autoUpdate.getAutoUpdateConfig({ + AUTO_UPDATE_MODE: "docker-compose", + AUTO_UPDATE_REPO_DIR: "/repo", + AUTO_UPDATE_COMPOSE_FILE: "/repo/docker-compose.yml", + AUTO_UPDATE_COMPOSE_PROFILE: "cli", + AUTO_UPDATE_SERVICE: "omniroute-cli", + AUTO_UPDATE_GIT_REMOTE: "origin", + AUTO_UPDATE_PATCH_COMMITS: "1501a87 e569e1c", + }); + + const script = autoUpdate.buildDockerComposeUpdateScript({ + latest: "3.2.6", + config, + composeCommand: "docker compose", + }); + + assert.match(script, /git fetch --tags/); + assert.match(script, /git config --global --add safe\.directory/); + assert.match(script, /git checkout -B "autoupdate\/3\.2\.6" "v3\.2\.6"/); + assert.match(script, /git cherry-pick --keep-redundant-commits '1501a87' 'e569e1c'/); + assert.match(script, /docker compose -f "\$COMPOSE_FILE" up -d --build "\$SERVICE"/); + }); +});