From 4a59b7786be7a98492358b3cc837c1f7cbe40e72 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Fri, 6 Feb 2026 00:09:48 -0500 Subject: [PATCH] fix: CLI harden update restart imports and fix nested bundle version resolution --- src/cli/update-cli.ts | 4 +- src/version.test.ts | 86 +++++++++++++++++++++++++++++++++++++++++++ src/version.ts | 64 +++++++++++++++++++++++--------- 3 files changed, 134 insertions(+), 20 deletions(-) create mode 100644 src/version.test.ts diff --git a/src/cli/update-cli.ts b/src/cli/update-cli.ts index 45fe7ddf2..3e6929f73 100644 --- a/src/cli/update-cli.ts +++ b/src/cli/update-cli.ts @@ -8,6 +8,7 @@ import { checkShellCompletionStatus, ensureCompletionCacheExists, } from "../commands/doctor-completion.js"; +import { doctorCommand } from "../commands/doctor.js"; import { formatUpdateAvailableHint, formatUpdateOneLiner, @@ -56,6 +57,7 @@ import { theme } from "../terminal/theme.js"; import { replaceCliName, resolveCliName } from "./cli-name.js"; import { formatCliCommand } from "./command-format.js"; import { installCompletion } from "./completion-cli.js"; +import { runDaemonRestart } from "./daemon-cli.js"; import { formatHelpExamples } from "./help-format.js"; export type UpdateCommandOptions = { @@ -1064,14 +1066,12 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { defaultRuntime.log(theme.heading("Restarting service...")); } try { - const { runDaemonRestart } = await import("./daemon-cli.js"); const restarted = await runDaemonRestart(); if (!opts.json && restarted) { defaultRuntime.log(theme.success("Daemon restarted successfully.")); defaultRuntime.log(""); process.env.OPENCLAW_UPDATE_IN_PROGRESS = "1"; try { - const { doctorCommand } = await import("../commands/doctor.js"); const interactiveDoctor = Boolean(process.stdin.isTTY) && !opts.json && opts.yes !== true; await doctorCommand(defaultRuntime, { nonInteractive: !interactiveDoctor, diff --git a/src/version.test.ts b/src/version.test.ts new file mode 100644 index 000000000..8806d00de --- /dev/null +++ b/src/version.test.ts @@ -0,0 +1,86 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { describe, expect, it } from "vitest"; +import { + readVersionFromBuildInfoForModuleUrl, + readVersionFromPackageJsonForModuleUrl, + resolveVersionFromModuleUrl, +} from "./version.js"; + +async function withTempDir(run: (dir: string) => Promise): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-version-")); + try { + return await run(dir); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +} + +function moduleUrlFrom(root: string, relativePath: string): string { + return pathToFileURL(path.join(root, relativePath)).href; +} + +describe("version resolution", () => { + it("resolves package version from nested dist/plugin-sdk module URL", async () => { + await withTempDir(async (root) => { + await fs.mkdir(path.join(root, "dist", "plugin-sdk"), { recursive: true }); + await fs.writeFile( + path.join(root, "package.json"), + JSON.stringify({ name: "openclaw", version: "1.2.3" }), + "utf-8", + ); + + const moduleUrl = moduleUrlFrom(root, "dist/plugin-sdk/index.js"); + expect(readVersionFromPackageJsonForModuleUrl(moduleUrl)).toBe("1.2.3"); + expect(resolveVersionFromModuleUrl(moduleUrl)).toBe("1.2.3"); + }); + }); + + it("ignores unrelated nearby package.json files", async () => { + await withTempDir(async (root) => { + await fs.mkdir(path.join(root, "dist", "plugin-sdk"), { recursive: true }); + await fs.writeFile( + path.join(root, "package.json"), + JSON.stringify({ name: "openclaw", version: "2.3.4" }), + "utf-8", + ); + await fs.writeFile( + path.join(root, "dist", "package.json"), + JSON.stringify({ name: "other-package", version: "9.9.9" }), + "utf-8", + ); + + const moduleUrl = moduleUrlFrom(root, "dist/plugin-sdk/index.js"); + expect(readVersionFromPackageJsonForModuleUrl(moduleUrl)).toBe("2.3.4"); + }); + }); + + it("falls back to build-info when package metadata is unavailable", async () => { + await withTempDir(async (root) => { + await fs.mkdir(path.join(root, "dist", "plugin-sdk"), { recursive: true }); + await fs.writeFile( + path.join(root, "build-info.json"), + JSON.stringify({ version: "4.5.6" }), + "utf-8", + ); + + const moduleUrl = moduleUrlFrom(root, "dist/plugin-sdk/index.js"); + expect(readVersionFromPackageJsonForModuleUrl(moduleUrl)).toBeNull(); + expect(readVersionFromBuildInfoForModuleUrl(moduleUrl)).toBe("4.5.6"); + expect(resolveVersionFromModuleUrl(moduleUrl)).toBe("4.5.6"); + }); + }); + + it("returns null when no version metadata exists", async () => { + await withTempDir(async (root) => { + await fs.mkdir(path.join(root, "dist", "plugin-sdk"), { recursive: true }); + + const moduleUrl = moduleUrlFrom(root, "dist/plugin-sdk/index.js"); + expect(readVersionFromPackageJsonForModuleUrl(moduleUrl)).toBeNull(); + expect(readVersionFromBuildInfoForModuleUrl(moduleUrl)).toBeNull(); + expect(resolveVersionFromModuleUrl(moduleUrl)).toBeNull(); + }); + }); +}); diff --git a/src/version.ts b/src/version.ts index 04bb50204..bf2d1e44e 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,29 +1,41 @@ import { createRequire } from "node:module"; declare const __OPENCLAW_VERSION__: string | undefined; +const CORE_PACKAGE_NAME = "openclaw"; -function readVersionFromPackageJson(): string | null { - try { - const require = createRequire(import.meta.url); - const pkg = require("../package.json") as { version?: string }; - return pkg.version ?? null; - } catch { - return null; - } -} +const PACKAGE_JSON_CANDIDATES = [ + "../package.json", + "../../package.json", + "../../../package.json", + "./package.json", +] as const; -function readVersionFromBuildInfo(): string | null { +const BUILD_INFO_CANDIDATES = [ + "../build-info.json", + "../../build-info.json", + "./build-info.json", +] as const; + +function readVersionFromJsonCandidates( + moduleUrl: string, + candidates: readonly string[], + opts: { requirePackageName?: boolean } = {}, +): string | null { try { - const require = createRequire(import.meta.url); - const candidates = ["../build-info.json", "./build-info.json"]; + const require = createRequire(moduleUrl); for (const candidate of candidates) { try { - const info = require(candidate) as { version?: string }; - if (info.version) { - return info.version; + const parsed = require(candidate) as { name?: string; version?: string }; + const version = parsed.version?.trim(); + if (!version) { + continue; } + if (opts.requirePackageName && parsed.name !== CORE_PACKAGE_NAME) { + continue; + } + return version; } catch { - // ignore missing candidate + // ignore missing or unreadable candidate } } return null; @@ -32,12 +44,28 @@ function readVersionFromBuildInfo(): string | null { } } +export function readVersionFromPackageJsonForModuleUrl(moduleUrl: string): string | null { + return readVersionFromJsonCandidates(moduleUrl, PACKAGE_JSON_CANDIDATES, { + requirePackageName: true, + }); +} + +export function readVersionFromBuildInfoForModuleUrl(moduleUrl: string): string | null { + return readVersionFromJsonCandidates(moduleUrl, BUILD_INFO_CANDIDATES); +} + +export function resolveVersionFromModuleUrl(moduleUrl: string): string | null { + return ( + readVersionFromPackageJsonForModuleUrl(moduleUrl) || + readVersionFromBuildInfoForModuleUrl(moduleUrl) + ); +} + // Single source of truth for the current OpenClaw version. // - Embedded/bundled builds: injected define or env var. // - Dev/npm builds: package.json. export const VERSION = (typeof __OPENCLAW_VERSION__ === "string" && __OPENCLAW_VERSION__) || process.env.OPENCLAW_BUNDLED_VERSION || - readVersionFromPackageJson() || - readVersionFromBuildInfo() || + resolveVersionFromModuleUrl(import.meta.url) || "0.0.0";