refactor(cli): split update command modules
This commit is contained in:
289
src/cli/update-cli/shared.ts
Normal file
289
src/cli/update-cli/shared.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
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}.`));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user