Spaces:
Paused
Paused
| import { spawnSync } from "node:child_process"; | |
| import fs from "node:fs/promises"; | |
| import os from "node:os"; | |
| import path from "node:path"; | |
| import type { UpdateStepProgress, UpdateStepResult } from "../../infra/update-runner.js"; | |
| import { resolveStateDir } from "../../config/paths.js"; | |
| import { resolveOpenClawPackageRoot } from "../../infra/openclaw-root.js"; | |
| import { trimLogTail } from "../../infra/restart-sentinel.js"; | |
| import { parseSemver } from "../../infra/runtime-guard.js"; | |
| import { fetchNpmTagVersion } from "../../infra/update-check.js"; | |
| import { | |
| detectGlobalInstallManagerByPresence, | |
| detectGlobalInstallManagerForRoot, | |
| type GlobalInstallManager, | |
| } from "../../infra/update-global.js"; | |
| import { runCommandWithTimeout } from "../../process/exec.js"; | |
| import { defaultRuntime } from "../../runtime.js"; | |
| import { theme } from "../../terminal/theme.js"; | |
| import { pathExists } from "../../utils.js"; | |
| export type UpdateCommandOptions = { | |
| json?: boolean; | |
| restart?: boolean; | |
| channel?: string; | |
| tag?: string; | |
| timeout?: string; | |
| yes?: boolean; | |
| }; | |
| export type UpdateStatusOptions = { | |
| json?: boolean; | |
| timeout?: string; | |
| }; | |
| export type UpdateWizardOptions = { | |
| timeout?: string; | |
| }; | |
| const OPENCLAW_REPO_URL = "https://github.com/openclaw/openclaw.git"; | |
| const MAX_LOG_CHARS = 8000; | |
| export const DEFAULT_PACKAGE_NAME = "openclaw"; | |
| const CORE_PACKAGE_NAMES = new Set([DEFAULT_PACKAGE_NAME]); | |
| export function normalizeTag(value?: string | null): string | null { | |
| if (!value) { | |
| return null; | |
| } | |
| const trimmed = value.trim(); | |
| if (!trimmed) { | |
| return null; | |
| } | |
| if (trimmed.startsWith("openclaw@")) { | |
| return trimmed.slice("openclaw@".length); | |
| } | |
| if (trimmed.startsWith(`${DEFAULT_PACKAGE_NAME}@`)) { | |
| return trimmed.slice(`${DEFAULT_PACKAGE_NAME}@`.length); | |
| } | |
| return trimmed; | |
| } | |
| export function normalizeVersionTag(tag: string): string | null { | |
| const trimmed = tag.trim(); | |
| if (!trimmed) { | |
| return null; | |
| } | |
| const cleaned = trimmed.startsWith("v") ? trimmed.slice(1) : trimmed; | |
| return parseSemver(cleaned) ? cleaned : null; | |
| } | |
| export async function readPackageVersion(root: string): Promise<string | null> { | |
| try { | |
| const raw = await fs.readFile(path.join(root, "package.json"), "utf-8"); | |
| const parsed = JSON.parse(raw) as { version?: string }; | |
| return typeof parsed.version === "string" ? parsed.version : null; | |
| } catch { | |
| return null; | |
| } | |
| } | |
| export async function resolveTargetVersion( | |
| tag: string, | |
| timeoutMs?: number, | |
| ): Promise<string | null> { | |
| const direct = normalizeVersionTag(tag); | |
| if (direct) { | |
| return direct; | |
| } | |
| const res = await fetchNpmTagVersion({ tag, timeoutMs }); | |
| return res.version ?? null; | |
| } | |
| export async function isGitCheckout(root: string): Promise<boolean> { | |
| try { | |
| await fs.stat(path.join(root, ".git")); | |
| return true; | |
| } catch { | |
| return false; | |
| } | |
| } | |
| export async function readPackageName(root: string): Promise<string | null> { | |
| try { | |
| const raw = await fs.readFile(path.join(root, "package.json"), "utf-8"); | |
| const parsed = JSON.parse(raw) as { name?: string }; | |
| const name = parsed?.name?.trim(); | |
| return name ? name : null; | |
| } catch { | |
| return null; | |
| } | |
| } | |
| export async function isCorePackage(root: string): Promise<boolean> { | |
| const name = await readPackageName(root); | |
| return Boolean(name && CORE_PACKAGE_NAMES.has(name)); | |
| } | |
| export async function isEmptyDir(targetPath: string): Promise<boolean> { | |
| try { | |
| const entries = await fs.readdir(targetPath); | |
| return entries.length === 0; | |
| } catch { | |
| return false; | |
| } | |
| } | |
| export function resolveGitInstallDir(): string { | |
| const override = process.env.OPENCLAW_GIT_DIR?.trim(); | |
| if (override) { | |
| return path.resolve(override); | |
| } | |
| return resolveDefaultGitDir(); | |
| } | |
| function resolveDefaultGitDir(): string { | |
| return resolveStateDir(process.env, os.homedir); | |
| } | |
| export function resolveNodeRunner(): string { | |
| const base = path.basename(process.execPath).toLowerCase(); | |
| if (base === "node" || base === "node.exe") { | |
| return process.execPath; | |
| } | |
| return "node"; | |
| } | |
| export async function resolveUpdateRoot(): Promise<string> { | |
| return ( | |
| (await resolveOpenClawPackageRoot({ | |
| moduleUrl: import.meta.url, | |
| argv1: process.argv[1], | |
| cwd: process.cwd(), | |
| })) ?? process.cwd() | |
| ); | |
| } | |
| export async function runUpdateStep(params: { | |
| name: string; | |
| argv: string[]; | |
| cwd?: string; | |
| timeoutMs: number; | |
| progress?: UpdateStepProgress; | |
| }): Promise<UpdateStepResult> { | |
| const command = params.argv.join(" "); | |
| params.progress?.onStepStart?.({ | |
| name: params.name, | |
| command, | |
| index: 0, | |
| total: 0, | |
| }); | |
| const started = Date.now(); | |
| const res = await runCommandWithTimeout(params.argv, { | |
| cwd: params.cwd, | |
| timeoutMs: params.timeoutMs, | |
| }); | |
| const durationMs = Date.now() - started; | |
| const stderrTail = trimLogTail(res.stderr, MAX_LOG_CHARS); | |
| params.progress?.onStepComplete?.({ | |
| name: params.name, | |
| command, | |
| index: 0, | |
| total: 0, | |
| durationMs, | |
| exitCode: res.code, | |
| stderrTail, | |
| }); | |
| return { | |
| name: params.name, | |
| command, | |
| cwd: params.cwd ?? process.cwd(), | |
| durationMs, | |
| exitCode: res.code, | |
| stdoutTail: trimLogTail(res.stdout, MAX_LOG_CHARS), | |
| stderrTail, | |
| }; | |
| } | |
| export async function ensureGitCheckout(params: { | |
| dir: string; | |
| timeoutMs: number; | |
| progress?: UpdateStepProgress; | |
| }): Promise<UpdateStepResult | null> { | |
| const dirExists = await pathExists(params.dir); | |
| if (!dirExists) { | |
| return await runUpdateStep({ | |
| name: "git clone", | |
| argv: ["git", "clone", OPENCLAW_REPO_URL, params.dir], | |
| timeoutMs: params.timeoutMs, | |
| progress: params.progress, | |
| }); | |
| } | |
| if (!(await isGitCheckout(params.dir))) { | |
| const empty = await isEmptyDir(params.dir); | |
| if (!empty) { | |
| throw new Error( | |
| `OPENCLAW_GIT_DIR points at a non-git directory: ${params.dir}. Set OPENCLAW_GIT_DIR to an empty folder or an openclaw checkout.`, | |
| ); | |
| } | |
| return await runUpdateStep({ | |
| name: "git clone", | |
| argv: ["git", "clone", OPENCLAW_REPO_URL, params.dir], | |
| cwd: params.dir, | |
| timeoutMs: params.timeoutMs, | |
| progress: params.progress, | |
| }); | |
| } | |
| if (!(await isCorePackage(params.dir))) { | |
| throw new Error(`OPENCLAW_GIT_DIR does not look like a core checkout: ${params.dir}.`); | |
| } | |
| return null; | |
| } | |
| export async function resolveGlobalManager(params: { | |
| root: string; | |
| installKind: "git" | "package" | "unknown"; | |
| timeoutMs: number; | |
| }): Promise<GlobalInstallManager> { | |
| const runCommand = async (argv: string[], options: { timeoutMs: number }) => { | |
| const res = await runCommandWithTimeout(argv, options); | |
| return { stdout: res.stdout, stderr: res.stderr, code: res.code }; | |
| }; | |
| if (params.installKind === "package") { | |
| const detected = await detectGlobalInstallManagerForRoot( | |
| runCommand, | |
| params.root, | |
| params.timeoutMs, | |
| ); | |
| if (detected) { | |
| return detected; | |
| } | |
| } | |
| const byPresence = await detectGlobalInstallManagerByPresence(runCommand, params.timeoutMs); | |
| return byPresence ?? "npm"; | |
| } | |
| export async function tryWriteCompletionCache(root: string, jsonMode: boolean): Promise<void> { | |
| const binPath = path.join(root, "openclaw.mjs"); | |
| if (!(await pathExists(binPath))) { | |
| return; | |
| } | |
| const result = spawnSync(resolveNodeRunner(), [binPath, "completion", "--write-state"], { | |
| cwd: root, | |
| env: process.env, | |
| encoding: "utf-8", | |
| }); | |
| if (result.error) { | |
| if (!jsonMode) { | |
| defaultRuntime.log(theme.warn(`Completion cache update failed: ${String(result.error)}`)); | |
| } | |
| return; | |
| } | |
| if (result.status !== 0 && !jsonMode) { | |
| const stderr = (result.stderr ?? "").toString().trim(); | |
| const detail = stderr ? ` (${stderr})` : ""; | |
| defaultRuntime.log(theme.warn(`Completion cache update failed${detail}.`)); | |
| } | |
| } | |