wiwiway / src /lib /system /autoUpdate.ts
Claude Code
Sync gateway codebase without binaries
116b4cb
Raw
History Blame Contribute Delete
8.55 kB
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,
};
}