| 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<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"; |
| |
| 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<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 === ("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<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, |
| }; |
| } |
| |