Files
OmniRoute/src/lib/system/autoUpdate.ts
Diego Rodrigues de Sa e Souza 9e3126828e fix(auto-update): skip synthetic Next.js standalone package.json without name field in resolveProjectRoot (#8956) (#9354)
A Next.js standalone build writes a synthetic .build/next/package.json
({"type":"commonjs"}) that lacks a "name" field. The resolveProjectRoot()
walk-up was stopping at this marker instead of continuing to the real repo
root, making PROJECT_ROOT point at .build/next where no .git exists, which
caused the source-mode validation to report "Not a git repository."

Fix: only accept a package.json as a project-root marker when its parsed
content has a non-empty "name" field. Keep .git as a hard marker.
Add isValidPackageMarker() helper for testability.

Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
2026-08-05 16:07:31 -03:00

422 lines
13 KiB
TypeScript

import { execFile, spawn } from "node:child_process";
import { closeSync, mkdirSync, openSync, existsSync, readFileSync } from "node:fs";
import { access } from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
import { homedir } from "node:os";
const execFileAsync = promisify(execFile);
/**
* Check whether a directory's package.json is a valid project-root marker by
* requiring a non-empty `name` field. The Next.js standalone build writes a
* synthetic `.build/next/package.json` = `{"type":"commonjs"}` that should not
* be mistaken for the real project root.
*
* Swallows read / parse errors (missing file, invalid JSON) and returns false
* so the walk-up continues.
*
* @internal — exported for testability.
*/
export function isValidPackageMarker(dir: string): boolean {
try {
const content = readFileSync(path.join(dir, "package.json"), "utf-8");
const pkg = JSON.parse(content);
return typeof pkg.name === "string" && pkg.name.length > 0;
} catch {
return false;
}
}
/** @internal — exported for testability. */
export function resolveProjectRoot(
fallback: string,
startDir: string = typeof __dirname !== "undefined" ? __dirname : process.cwd()
): string {
let dir = path.resolve(startDir);
while (true) {
if (existsSync(path.join(dir, ".git"))) return dir;
if (existsSync(path.join(dir, "package.json")) && isValidPackageMarker(dir)) return dir;
const parent = path.dirname(dir);
if (parent === dir) break;
dir = parent;
}
return fallback;
}
const FALLBACK_CWD = process.env.HOME || homedir() || "/tmp";
export const PROJECT_ROOT: string = resolveProjectRoot(FALLBACK_CWD);
type ComposeCommand = "docker compose" | "docker-compose";
export type AutoUpdateMode = "npm" | "docker-compose" | "source";
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 {
if (raw === "docker-compose" || raw === "source") {
return raw;
}
return "npm";
}
/**
* Match `node_modules` as a real path **segment**, not a substring — a folder literally named
* "my-node_modules-backup" must NOT count as an installed-package location. Operates on a local
* filesystem path (`__dirname`), never untrusted input; the pattern is linear (ReDoS-safe).
*
* @internal — exported for testability.
*/
export function isUnderNodeModules(dir: string): boolean {
return /(^|[/\\])node_modules([/\\]|$)/.test(dir);
}
/**
* Decide the effective auto-update channel when the operator left it at the default ("npm").
*
* - A source checkout (`.git` present) always self-updates via git, even if it also lives under a
* `node_modules` path.
* - Otherwise "npm" mode only makes sense when the running module sits under a real
* `node_modules/` path segment (a global or local package install). Anything else — a downloaded
* build/zip with no `.git` — is treated as source.
*
* Behavior matches the previous inline heuristic for every realistic path; it only tightens the
* pathological case where "node_modules" appeared as a substring but not a path segment (Bug 1,
* security-report v3.8.15). Pure + injectable so the branch logic is unit-testable.
*
* @internal — exported for testability.
*/
export function resolveAutoUpdateMode(
rawMode: AutoUpdateMode,
detection: { isGitRepo: boolean; currentDir: string }
): AutoUpdateMode {
if (rawMode !== "npm") return rawMode;
if (detection.isGitRepo) return "source";
return isUnderNodeModules(detection.currentDir) ? "npm" : "source";
}
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(PROJECT_ROOT, ".git"));
const currentDir = typeof __dirname !== "undefined" ? __dirname : PROJECT_ROOT;
mode = resolveAutoUpdateMode(mode, { isGitRepo, currentDir });
}
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") {
const gitDir = path.join(PROJECT_ROOT, ".git");
if (!(await existsImpl(gitDir))) {
return {
supported: false,
reason: "Not a git repository. Download source or use npm install -g.",
composeCommand: null,
};
}
try {
await execFileImpl("git", ["--version"], { timeout: 10_000 });
} catch {
return {
supported: false,
reason: "git is not available. Install git to enable auto-update.",
composeCommand: null,
};
}
return {
supported: true,
reason: null,
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 async function ensureGitTagExists(
targetTag: string,
execFileImpl: ExecFileLike = execFileAsync,
cwd = PROJECT_ROOT
): Promise<void> {
try {
await execFileImpl("git", ["rev-parse", "-q", "--verify", `refs/tags/${targetTag}`], {
timeout: 10_000,
cwd,
});
} catch {
throw new Error(`Git tag not found: ${targetTag}`);
}
}
export function buildNpmUpdateScript(latest: string): string {
return [
"set -eu",
// --include=optional keeps the optionalDependencies (better-sqlite3, keytar,
// tls-client, and the llmlingua SLM stack) installed on every update so an
// `omit=optional` config / .npmrc cannot silently drop them.
`npm install -g omniroute@${latest} --include=optional --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 buildSourceUpdateScript(latest: string, gitRemote = "origin"): string {
const targetTag = latest.startsWith("v") ? latest : `v${latest}`;
return [
"set -eu",
"git stash --include-untracked 2>/dev/null || true",
`git fetch --tags ${shellQuote(gitRemote)}`,
`if ! git rev-parse -q --verify "refs/tags/${targetTag}" >/dev/null 2>&1; then`,
` echo "[AutoUpdate] Tag ${targetTag} not found." >&2`,
" exit 1",
"fi",
'backup_branch="pre-update/$(git rev-parse --short HEAD)-$(date +%Y%m%d-%H%M%S)"',
'git branch "$backup_branch" 2>/dev/null || true',
`git checkout "${targetTag}"`,
"npm install --include=optional --legacy-peer-deps",
"node scripts/dev/sync-env.mjs 2>/dev/null || true",
"npm run build",
"if command -v pm2 >/dev/null 2>&1; then",
" pm2 restart omniroute --update-env || true",
"fi",
`echo "[AutoUpdate] Successfully updated to ${targetTag}."`,
].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,
existsImpl = pathExists,
}: {
latest: string;
env?: NodeJS.ProcessEnv;
execFileImpl?: ExecFileLike;
spawnImpl?: SpawnLike;
existsImpl?: (targetPath: string) => Promise<boolean>;
}): Promise<AutoUpdateLaunchResult> {
const config = getAutoUpdateConfig(env);
const validation = await validateAutoUpdateRuntime(config, execFileImpl, existsImpl);
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",
})
: config.mode === "source"
? buildSourceUpdateScript(latest, config.gitRemote)
: 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,
};
}