Add Docker-aware dashboard auto-update flow
This commit is contained in:
+9
-2
@@ -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/"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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"/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user