import { execFile, spawn } from "node:child_process"; import { closeSync, mkdirSync, openSync, existsSync } 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"; let mode = normalizeMode(env.AUTO_UPDATE_MODE); if (mode === "npm") { const isGitRepo = existsSync(path.join(process.cwd(), ".git")); const currentDir = typeof __dirname !== "undefined" ? __dirname : process.cwd(); const isGlobalNodeModules = currentDir.includes("node_modules"); // If we are not in a global node_modules directory, we are likely a local source install/build. // Even if .git is missing (downloaded zip), we should treat it as source. if (isGitRepo || !isGlobalNodeModules) { mode = "source" as any; } } return { 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 === ("source" as any)) { return { supported: false, reason: "Manual 'git pull && npm install && npm run build' is required for source installations.", composeCommand: null, }; } 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 --legacy-peer-deps`, "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, }; }