Add Docker-aware dashboard auto-update flow

This commit is contained in:
cai kerui
2026-03-29 20:25:14 +09:00
parent 3cccc480fb
commit f0daad10ce
6 changed files with 796 additions and 58 deletions
+9 -2
View File
@@ -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/"
+5
View File
@@ -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
+388 -32
View File
@@ -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<any>(null);
const [updating, setUpdating] = useState(false);
const [updateSteps, setUpdateSteps] = useState<UpdateStep[]>([]);
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<string, string> = {
install: "download",
rebuild: "build",
restart: "restart_alt",
complete: "check_circle",
error: "error",
};
const stepLabels: Record<string, string> = {
install: "Install Package",
rebuild: "Rebuild Native Modules",
restart: "Restart Service",
complete: "Complete",
error: "Error",
};
const showUpdateOverlay = updatePhase !== "idle";
if (loading) {
return (
<div className="flex flex-col gap-8">
@@ -165,8 +425,122 @@ export default function HomePageClient({ machineId }) {
return (
<div className="flex flex-col gap-8">
{/* Update Progress Overlay */}
{showUpdateOverlay && (
<div className="fixed inset-0 z-[999] bg-black/60 backdrop-blur-sm flex items-center justify-center p-4">
<div className="bg-bg-main border border-border rounded-2xl shadow-2xl max-w-md w-full p-6">
<div className="flex items-center gap-3 mb-5">
<span className="material-symbols-outlined text-primary text-[28px] animate-spin">
progress_activity
</span>
<div>
<h3 className="text-lg font-bold">
{updatePhase === "done"
? "Update Complete!"
: updatePhase === "failed"
? "Update Failed"
: "Updating OmniRoute..."}
</h3>
<p className="text-xs text-text-muted mt-0.5">
{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."}
</p>
</div>
</div>
{/* Step list */}
<div className="flex flex-col gap-2">
{updateSteps
.filter((s) => s.step !== "complete" && s.step !== "error")
.map((s) => (
<div
key={s.step}
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg border transition-all ${
s.status === "running"
? "border-primary/40 bg-primary/5"
: s.status === "done"
? "border-green-500/30 bg-green-500/5"
: s.status === "failed"
? "border-red-500/30 bg-red-500/5"
: "border-border bg-bg-subtle"
}`}
>
{s.status === "running" ? (
<span className="material-symbols-outlined text-primary text-[18px] animate-spin">
progress_activity
</span>
) : s.status === "done" ? (
<span className="material-symbols-outlined text-green-500 text-[18px]">
check_circle
</span>
) : s.status === "failed" ? (
<span className="material-symbols-outlined text-red-500 text-[18px]">
error
</span>
) : (
<span className="material-symbols-outlined text-yellow-500 text-[18px]">
warning
</span>
)}
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{stepLabels[s.step] || s.step}</p>
<p className="text-xs text-text-muted truncate">{s.message}</p>
</div>
</div>
))}
{/* Error message */}
{updateSteps.find((s) => s.step === "error") && (
<div className="mt-1 px-3 py-2.5 rounded-lg border border-red-500/30 bg-red-500/5 text-red-500">
<p className="text-xs font-mono break-all">
{updateSteps.find((s) => s.step === "error")?.message}
</p>
</div>
)}
{/* Completion message */}
{updatePhase === "done" && (
<div className="mt-1 px-3 py-2.5 rounded-lg border border-green-500/30 bg-green-500/5">
<p className="text-sm font-semibold text-green-500 flex items-center gap-2">
<span className="material-symbols-outlined text-[18px]">check_circle</span>
{updateSteps.find((s) => s.step === "complete")?.message || "Update complete!"}
</p>
<p className="text-xs text-text-muted mt-1">Reloading page automatically...</p>
</div>
)}
</div>
{/* Actions */}
{(updatePhase === "failed" || updatePhase === "done") && (
<div className="flex gap-2 mt-4">
<Button
size="sm"
fullWidth
onClick={() => {
setUpdating(false);
setUpdatePhase("idle");
setUpdateSteps([]);
if (updatePhase === "done") window.location.reload();
}}
>
{updatePhase === "done" ? "Reload Now" : "Close"}
</Button>
{updatePhase === "failed" && (
<Button size="sm" variant="secondary" fullWidth onClick={handleUpdate}>
Retry
</Button>
)}
</div>
)}
</div>
</div>
)}
{/* Update Notification Banner */}
{versionInfo?.updateAvailable && (
{versionInfo?.updateAvailable && !showUpdateOverlay && (
<div className="bg-primary/10 border border-primary/20 text-primary px-5 py-4 rounded-xl flex items-center justify-between min-h-[64px]">
<div className="flex items-center gap-4">
<span className="material-symbols-outlined text-[24px]">system_update_alt</span>
@@ -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"}
</Button>
</div>
)}
@@ -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 ? (
<span
className="text-[10px] font-bold"
style={{ color: item.provider.color || "#888" }}
>
{item.provider.textIcon || item.provider.id.slice(0, 2).toUpperCase()}
</span>
) : (
<Image
src={`/providers/${item.provider.id}.png`}
alt={item.provider.name}
width={26}
height={26}
className="object-contain rounded-lg"
sizes="26px"
onError={() => setImgError(true)}
/>
)}
<ProviderIcon providerId={item.provider.id} size={26} type="color" />
</div>
<div className="min-w-0 flex-1">
+32 -24
View File
@@ -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<string | null> {
try {
const { stdout } = await execFileAsync("npm", ["info", "omniroute", "version", "--json"], {
@@ -27,17 +31,14 @@ async function getLatestNpmVersion(): Promise<string | null> {
}
}
/** 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,
});
}
+258
View File
@@ -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<boolean> {
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<ComposeCommand | null> {
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<boolean> = pathExists
): Promise<AutoUpdateValidation> {
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<AutoUpdateLaunchResult> {
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,
};
}
+104
View File
@@ -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"/);
});
});