mirror of
https://github.com/grp06/openclaw-studio.git
synced 2026-08-14 08:52:03 +00:00
feat: warn when openclaw-studio CLI is outdated
This commit is contained in:
Vendored
+1
@@ -20,6 +20,7 @@ export function detectInstallContext(
|
||||
env?: NodeJS.ProcessEnv
|
||||
) => { url: string; token: string } | null;
|
||||
runCommand?: InstallContextCommandRunner;
|
||||
fetch?: typeof fetch;
|
||||
}
|
||||
): Promise<StudioInstallContext>;
|
||||
|
||||
|
||||
+294
-1
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user