feat: warn when openclaw-studio CLI is outdated

This commit is contained in:
George Pickett
2026-03-05 17:28:01 -08:00
parent d2c10958bc
commit 367fa18cd2
6 changed files with 351 additions and 1 deletions
+1
View File
@@ -20,6 +20,7 @@ export function detectInstallContext(
env?: NodeJS.ProcessEnv
) => { url: string; token: string } | null;
runCommand?: InstallContextCommandRunner;
fetch?: typeof fetch;
}
): Promise<StudioInstallContext>;
+294 -1
View File
@@ -8,6 +8,11 @@ const { readOpenclawGatewayDefaults } = require("./studio-settings");
const execFileAsync = promisify(execFile);
const OPENCLAW_PROBE_TIMEOUT_MS = 1_500;
const TAILSCALE_PROBE_TIMEOUT_MS = 1_200;
const STUDIO_CLI_PROBE_TIMEOUT_MS = 1_200;
const STUDIO_CLI_LATEST_TIMEOUT_MS = 2_500;
const STUDIO_CLI_LATEST_CACHE_TTL_MS = 1000 * 60 * 60 * 12;
const STUDIO_CLI_LATEST_ERROR_CACHE_TTL_MS = 1000 * 60 * 10;
const STUDIO_CLI_PACKAGE_NAME = "openclaw-studio";
const normalizeErrorCode = (error) => {
if (!error || typeof error !== "object") return "";
@@ -27,6 +32,69 @@ const normalizeJsonValue = (value) => {
return value;
};
const coerceString = (value) => {
return typeof value === "string" ? value.trim() : "";
};
const parseSemver = (value) => {
const normalized = coerceString(value);
const match = normalized.match(/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+.*)?$/);
if (!match) return null;
const major = Number(match[1]);
const minor = Number(match[2]);
const patch = Number(match[3]);
if (!Number.isFinite(major) || !Number.isFinite(minor) || !Number.isFinite(patch)) {
return null;
}
return {
major,
minor,
patch,
prerelease: match[4] ? match[4].split(".").filter(Boolean) : [],
};
};
const comparePrereleaseSegment = (left, right) => {
const leftNumeric = /^\d+$/.test(left);
const rightNumeric = /^\d+$/.test(right);
if (leftNumeric && rightNumeric) {
const leftNumber = Number(left);
const rightNumber = Number(right);
if (leftNumber !== rightNumber) return leftNumber > rightNumber ? 1 : -1;
return 0;
}
if (leftNumeric && !rightNumeric) return -1;
if (!leftNumeric && rightNumeric) return 1;
if (left === right) return 0;
return left > right ? 1 : -1;
};
const comparePrerelease = (left, right) => {
if (left.length === 0 && right.length === 0) return 0;
if (left.length === 0) return 1;
if (right.length === 0) return -1;
const maxLength = Math.max(left.length, right.length);
for (let index = 0; index < maxLength; index += 1) {
const leftSegment = left[index];
const rightSegment = right[index];
if (leftSegment === undefined) return -1;
if (rightSegment === undefined) return 1;
const segmentComparison = comparePrereleaseSegment(leftSegment, rightSegment);
if (segmentComparison !== 0) return segmentComparison;
}
return 0;
};
const compareSemverVersions = (currentVersion, latestVersion) => {
const current = parseSemver(currentVersion);
const latest = parseSemver(latestVersion);
if (!current || !latest) return null;
if (current.major !== latest.major) return current.major > latest.major ? 1 : -1;
if (current.minor !== latest.minor) return current.minor > latest.minor ? 1 : -1;
if (current.patch !== latest.patch) return current.patch > latest.patch ? 1 : -1;
return comparePrerelease(current.prerelease, latest.prerelease);
};
const runJsonCommand = async (command, args, timeoutMs, runner = execFileAsync) => {
try {
const { stdout } = await runner(command, args, {
@@ -66,6 +134,228 @@ const runJsonCommand = async (command, args, timeoutMs, runner = execFileAsync)
}
};
const runTextCommand = async (command, args, timeoutMs, runner = execFileAsync) => {
try {
const { stdout } = await runner(command, args, {
timeout: timeoutMs,
maxBuffer: 1024 * 1024,
windowsHide: true,
encoding: "utf8",
});
return {
available: true,
ok: true,
stdout: coerceString(stdout),
error: null,
};
} catch (error) {
const code = normalizeErrorCode(error);
const message = normalizeErrorMessage(error);
const timedOut =
code === "ETIMEDOUT" ||
code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER" ||
message.toLowerCase().includes("timed out");
if (code === "ENOENT") {
return {
available: false,
ok: false,
stdout: "",
error: "cli_not_found",
};
}
return {
available: true,
ok: false,
stdout: "",
error: timedOut ? "probe_timeout" : message || "probe_failed",
};
}
};
let studioCliLatestCache = {
latestVersion: null,
checkedAt: null,
checkedAtMs: 0,
error: null,
};
const readStudioCliLatestCache = (nowMs = Date.now()) => {
if (!studioCliLatestCache.checkedAtMs) return null;
const ttlMs = studioCliLatestCache.error
? STUDIO_CLI_LATEST_ERROR_CACHE_TTL_MS
: STUDIO_CLI_LATEST_CACHE_TTL_MS;
if (nowMs - studioCliLatestCache.checkedAtMs > ttlMs) return null;
return {
latestVersion: studioCliLatestCache.latestVersion,
checkedAt: studioCliLatestCache.checkedAt,
error: studioCliLatestCache.error,
};
};
const writeStudioCliLatestCache = (input) => {
const checkedAt = input.checkedAt || new Date().toISOString();
const checkedAtMs = Date.parse(checkedAt);
studioCliLatestCache = {
latestVersion: input.latestVersion || null,
checkedAt,
checkedAtMs: Number.isFinite(checkedAtMs) ? checkedAtMs : Date.now(),
error: input.error || null,
};
return {
latestVersion: studioCliLatestCache.latestVersion,
checkedAt: studioCliLatestCache.checkedAt,
error: studioCliLatestCache.error,
};
};
const fetchLatestStudioCliVersion = async (fetchImpl = fetch) => {
const cached = readStudioCliLatestCache();
if (cached) return cached;
if (typeof fetchImpl !== "function") {
return writeStudioCliLatestCache({
latestVersion: null,
error: "version_check_unavailable",
});
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), STUDIO_CLI_LATEST_TIMEOUT_MS);
try {
const response = await fetchImpl(
`https://registry.npmjs.org/${encodeURIComponent(STUDIO_CLI_PACKAGE_NAME)}/latest`,
{
signal: controller.signal,
headers: { accept: "application/json" },
}
);
if (!response.ok) {
return writeStudioCliLatestCache({
latestVersion: null,
error: `registry_http_${response.status}`,
});
}
const payload = await response.json();
const latestVersion = coerceString(payload && payload.version);
if (!latestVersion) {
return writeStudioCliLatestCache({
latestVersion: null,
error: "invalid_registry_payload",
});
}
return writeStudioCliLatestCache({
latestVersion,
checkedAt: new Date().toISOString(),
error: null,
});
} catch (error) {
const message = normalizeErrorMessage(error).toLowerCase();
const timeoutLike =
message.includes("timed out") ||
message.includes("aborted") ||
message.includes("aborterror");
return writeStudioCliLatestCache({
latestVersion: null,
error: timeoutLike ? "version_check_timeout" : "version_check_failed",
});
} finally {
clearTimeout(timeout);
}
};
const resolveVersionFromOutput = (stdout) => {
const firstLine = coerceString(stdout).split(/\r?\n/, 1)[0] || "";
const match = firstLine.match(/v?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?/);
return match ? coerceString(match[0]) : "";
};
const probeStudioCliVersion = async (runner = execFileAsync) => {
const probe = await runTextCommand(
"openclaw-studio",
["--version"],
STUDIO_CLI_PROBE_TIMEOUT_MS,
runner
);
if (!probe.available) {
return {
installed: false,
currentVersion: null,
error: null,
};
}
if (!probe.ok) {
return {
installed: true,
currentVersion: null,
error: probe.error || "version_probe_failed",
};
}
const currentVersion = resolveVersionFromOutput(probe.stdout);
if (!currentVersion) {
return {
installed: true,
currentVersion: null,
error: "version_parse_failed",
};
}
return {
installed: true,
currentVersion,
error: null,
};
};
const probeStudioCli = async (env = process.env, runner = execFileAsync, fetchImpl = fetch) => {
const current = await probeStudioCliVersion(runner);
if (!current.installed) {
return {
installed: false,
currentVersion: null,
latestVersion: null,
updateAvailable: false,
checkedAt: null,
checkError: null,
};
}
if (env && String(env.NODE_ENV || "").trim() === "test") {
return {
installed: true,
currentVersion: current.currentVersion,
latestVersion: null,
updateAvailable: false,
checkedAt: null,
checkError: current.error,
};
}
if (!current.currentVersion) {
return {
installed: true,
currentVersion: null,
latestVersion: null,
updateAvailable: false,
checkedAt: null,
checkError: current.error || "version_probe_failed",
};
}
const latest = await fetchLatestStudioCliVersion(fetchImpl);
const versionComparison = latest.latestVersion
? compareSemverVersions(current.currentVersion, latest.latestVersion)
: null;
return {
installed: true,
currentVersion: current.currentVersion,
latestVersion: latest.latestVersion,
updateAvailable: versionComparison === -1,
checkedAt: latest.checkedAt,
checkError:
current.error ||
latest.error ||
(latest.latestVersion && versionComparison === null ? "version_compare_failed" : null),
};
};
const normalizeDnsName = (value) => {
const trimmed = String(value ?? "").trim();
if (!trimmed) return null;
@@ -138,14 +428,16 @@ async function detectInstallContext(env = process.env, options = {}) {
const readOpenclawGatewayDefaultsImpl =
options.readOpenclawGatewayDefaults || readOpenclawGatewayDefaults;
const runCommand = options.runCommand || execFileAsync;
const fetchImpl = options.fetch || fetch;
const configuredHosts = Array.from(
new Set(resolveHostsImpl(env).map((value) => String(value ?? "").trim()).filter(Boolean))
);
const publicHosts = configuredHosts.filter((host) => isPublicHostImpl(host));
const localDefaults = readOpenclawGatewayDefaultsImpl(env);
const [localGatewayProbe, tailscale] = await Promise.all([
const [localGatewayProbe, tailscale, studioCli] = await Promise.all([
probeLocalGateway(runCommand),
probeTailscale(env, runCommand),
probeStudioCli(env, runCommand, fetchImpl),
]);
return {
@@ -167,6 +459,7 @@ async function detectInstallContext(env = process.env, options = {}) {
probeHealthy: localGatewayProbe.probeHealthy,
issues: localGatewayProbe.issues,
},
studioCli,
tailscale,
};
}
+15
View File
@@ -396,6 +396,13 @@ const AgentStudioPage = () => {
[faviconSeed]
);
const errorMessage = state.error ?? gatewayError ?? gatewayModelsError;
const studioCliUpdateWarning = useMemo(() => {
const studioCli = installContext.studioCli;
if (!studioCli.installed || !studioCli.updateAvailable) return null;
const current = studioCli.currentVersion?.trim() || "current";
const latest = studioCli.latestVersion?.trim() || "latest";
return `openclaw-studio CLI ${current} is installed on this host, but ${latest} is available. Run npx -y openclaw-studio@latest to update.`;
}, [installContext]);
const runningAgentCount = useMemo(
() => agents.filter((agent) => agent.status === "running").length,
[agents]
@@ -1490,6 +1497,14 @@ const AgentStudioPage = () => {
</div>
) : null}
{studioCliUpdateWarning ? (
<div className="w-full">
<div className="ui-alert-danger rounded-md px-4 py-2 text-sm">
{studioCliUpdateWarning}
</div>
</div>
) : null}
{errorMessage ? (
<div className="w-full">
<div className="ui-alert-danger rounded-md px-4 py-2 text-sm">
@@ -126,6 +126,13 @@ export const GatewayConnectScreen = ({
selectedScenario,
]
);
const studioCliUpdateWarning = useMemo(() => {
const studioCli = installContext.studioCli;
if (!studioCli.installed || !studioCli.updateAvailable) return null;
const current = studioCli.currentVersion?.trim() || "current";
const latest = studioCli.latestVersion?.trim() || "latest";
return `openclaw-studio CLI ${current} is installed on this host, but ${latest} is available. Run npx -y openclaw-studio@latest to update.`;
}, [installContext]);
const statusCopy = useMemo(() => {
if (status === "connected") {
return "Studio is connected to OpenClaw.";
@@ -488,6 +495,12 @@ export const GatewayConnectScreen = ({
</div>
</div>
{studioCliUpdateWarning ? (
<div className="ui-alert-danger rounded-md px-4 py-2 text-sm">
{studioCliUpdateWarning}
</div>
) : null}
{warnings.length > 0 ? (
<div className="space-y-2">
{warnings.map((warning) => (
+16
View File
@@ -19,6 +19,14 @@ export type StudioInstallContext = {
probeHealthy: boolean;
issues: string[];
};
studioCli: {
installed: boolean;
currentVersion: string | null;
latestVersion: string | null;
updateAvailable: boolean;
checkedAt: string | null;
checkError: string | null;
};
tailscale: {
installed: boolean;
loggedIn: boolean;
@@ -58,6 +66,14 @@ export const defaultStudioInstallContext = (): StudioInstallContext => ({
probeHealthy: false,
issues: [],
},
studioCli: {
installed: false,
currentVersion: null,
latestVersion: null,
updateAvailable: false,
checkedAt: null,
checkError: null,
},
tailscale: {
installed: false,
loggedIn: false,
+12
View File
@@ -45,6 +45,8 @@ describe("server install context detector", () => {
expect(context.localGateway.defaultsDetected).toBe(true);
expect(context.localGateway.hasToken).toBe(true);
expect(context.localGateway.probeHealthy).toBe(true);
expect(context.studioCli.installed).toBe(false);
expect(context.studioCli.updateAvailable).toBe(false);
expect(context.tailscale.loggedIn).toBe(true);
expect(context.tailscale.dnsName).toBe("studio-host.tailnet.ts.net");
});
@@ -72,6 +74,8 @@ describe("server install context detector", () => {
expect(context.localGateway.cliAvailable).toBe(false);
expect(context.localGateway.probeHealthy).toBe(false);
expect(context.localGateway.issues).toContain("cli_not_found");
expect(context.studioCli.installed).toBe(false);
expect(context.studioCli.updateAvailable).toBe(false);
expect(context.tailscale.installed).toBe(false);
expect(context.tailscale.loggedIn).toBe(false);
});
@@ -100,6 +104,14 @@ describe("server install context detector", () => {
probeHealthy: true,
issues: [],
},
studioCli: {
installed: false,
currentVersion: null,
latestVersion: null,
updateAvailable: false,
checkedAt: null,
checkError: null,
},
tailscale: {
installed: false,
loggedIn: false,