feat: global auto-update setting for CLI startup updates (#11326)

* feat: global auto-update setting for CLI startup updates

* patches

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
This commit is contained in:
Bee
2026-06-08 14:22:41 -07:00
committed by GitHub
co-authored by Saoud Rizwan
parent 9d59de4a4c
commit 797ea1f607
11 changed files with 257 additions and 8 deletions
+82 -1
View File
@@ -1,8 +1,10 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
autoUpdateOnStartup,
checkForUpdates,
getInstallationInfo,
PackageManager,
withMinimumReleaseAgeBypass,
@@ -10,6 +12,9 @@ import {
const originalArgv = [...process.argv];
const originalWrapperPath = process.env.CLINE_WRAPPER_PATH;
const originalGlobalSettingsPath = process.env.CLINE_GLOBAL_SETTINGS_PATH;
const originalIsDev = process.env.IS_DEV;
const originalNoAutoUpdate = process.env.CLINE_NO_AUTO_UPDATE;
const tempDirs: string[] = [];
function createFile(path: string): string {
@@ -32,6 +37,22 @@ describe("getInstallationInfo", () => {
} else {
process.env.CLINE_WRAPPER_PATH = originalWrapperPath;
}
if (originalGlobalSettingsPath === undefined) {
delete process.env.CLINE_GLOBAL_SETTINGS_PATH;
} else {
process.env.CLINE_GLOBAL_SETTINGS_PATH = originalGlobalSettingsPath;
}
if (originalIsDev === undefined) {
delete process.env.IS_DEV;
} else {
process.env.IS_DEV = originalIsDev;
}
if (originalNoAutoUpdate === undefined) {
delete process.env.CLINE_NO_AUTO_UPDATE;
} else {
process.env.CLINE_NO_AUTO_UPDATE = originalNoAutoUpdate;
}
vi.restoreAllMocks();
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
@@ -72,6 +93,66 @@ describe("getInstallationInfo", () => {
});
});
describe("auto update settings", () => {
afterEach(() => {
process.argv = [...originalArgv];
if (originalWrapperPath === undefined) {
delete process.env.CLINE_WRAPPER_PATH;
} else {
process.env.CLINE_WRAPPER_PATH = originalWrapperPath;
}
if (originalGlobalSettingsPath === undefined) {
delete process.env.CLINE_GLOBAL_SETTINGS_PATH;
} else {
process.env.CLINE_GLOBAL_SETTINGS_PATH = originalGlobalSettingsPath;
}
if (originalIsDev === undefined) {
delete process.env.IS_DEV;
} else {
process.env.IS_DEV = originalIsDev;
}
if (originalNoAutoUpdate === undefined) {
delete process.env.CLINE_NO_AUTO_UPDATE;
} else {
process.env.CLINE_NO_AUTO_UPDATE = originalNoAutoUpdate;
}
vi.restoreAllMocks();
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
it("skips startup auto update when disabled globally", () => {
const settingsPath = createTempFile("data/global-settings.json");
writeFileSync(settingsPath, JSON.stringify({ autoUpdateEnabled: false }));
process.env.CLINE_GLOBAL_SETTINGS_PATH = settingsPath;
delete process.env.IS_DEV;
delete process.env.CLINE_NO_AUTO_UPDATE;
const fetchSpy = vi
.spyOn(globalThis, "fetch")
.mockRejectedValue(new Error("should not fetch"));
autoUpdateOnStartup();
expect(fetchSpy).not.toHaveBeenCalled();
});
it("still lets manual update checks run when startup auto update is disabled", async () => {
const settingsPath = createTempFile("data/global-settings.json");
writeFileSync(settingsPath, JSON.stringify({ autoUpdateEnabled: false }));
process.env.CLINE_GLOBAL_SETTINGS_PATH = settingsPath;
delete process.env.CLINE_NO_AUTO_UPDATE;
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: async () => ({ version: "0.0.0" }),
} as Response);
await checkForUpdates({ includeKanban: false });
expect(fetchSpy).toHaveBeenCalled();
});
});
describe("withMinimumReleaseAgeBypass", () => {
it("adds the package-manager-specific cooldown bypass", () => {
expect(
+2
View File
@@ -2,6 +2,7 @@ import { type ChildProcess, spawn } from "node:child_process";
import { realpathSync } from "node:fs";
import {
clearHubDiscovery,
isAutoUpdateEnabledGlobally,
probeHubServer,
readHubDiscovery,
resolveSharedHubOwnerContext,
@@ -340,6 +341,7 @@ async function restartHubServerIfRunning(): Promise<void> {
export function autoUpdateOnStartup(): void {
if (process.env.IS_DEV === "true") return;
if (process.env.CLINE_NO_AUTO_UPDATE === "1") return;
if (!isAutoUpdateEnabledGlobally()) return;
const { packageName, packageManager, updateCommand } =
getInstallationInfo(version);
+15
View File
@@ -1,3 +1,4 @@
import { readGlobalSettings, setAutoUpdateEnabledGlobally } from "@cline/core";
import { useTerminalDimensions } from "@opentui/react";
import type { ChoiceContext } from "@opentui-ui/dialog";
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
@@ -368,6 +369,9 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
const [autoApprove, setAutoApprove] = useState(
config.toolPolicies["*"]?.autoApprove !== false,
);
const [autoUpdateEnabled, setAutoUpdateEnabled] = useState(
() => readGlobalSettings().autoUpdateEnabled,
);
const [verbose, setVerbose] = useState(config.verbose);
const [compactionMode, setCompactionMode] = useState(
props.currentCompactionMode,
@@ -445,6 +449,7 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
id: "auto-approve",
label: "Auto-approve all",
});
r.push({ kind: "toggle", id: "auto-update", label: "Auto update" });
r.push({ kind: "toggle", id: "verbose", label: "Verbose" });
} else {
const activeItems = resolveActiveConfigItems(configData, activeTab);
@@ -584,6 +589,13 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
setAutoApprove(!autoApprove);
props.onToggleAutoApprove();
break;
case "auto-update":
setAutoUpdateEnabled((previous) => {
const next = !previous;
setAutoUpdateEnabledGlobally(next);
return next;
});
break;
case "compaction": {
const nextMode = getNextCliCompactionMode(compactionMode);
setCompactionMode(nextMode);
@@ -789,6 +801,9 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
} else if (row.id === "auto-approve") {
value = autoApprove ? "● on" : "○ off";
valueColor = autoApprove ? palette.success : "gray";
} else if (row.id === "auto-update") {
value = autoUpdateEnabled ? "● on" : "○ off";
valueColor = autoUpdateEnabled ? palette.success : "gray";
} else if (row.id === "compaction") {
value = formatCliCompactionMode(compactionMode);
valueColor = COMPACTION_MODE_COLORS[compactionMode];
@@ -15,6 +15,7 @@ import {
resolveLocalClineAuthToken,
saveLocalProviderOAuthCredentials,
saveLocalProviderSettings,
setAutoUpdateEnabledGlobally,
setDisabledPlugin,
setDisabledTools,
setTelemetryOptOutGlobally,
@@ -155,6 +156,13 @@ export async function handleDesktopCommand(
setTelemetryOptOutGlobally(args.telemetry_opt_out);
return readGlobalSettings();
}
if (command === "set_auto_update_enabled") {
if (typeof args?.auto_update_enabled !== "boolean") {
throw new Error("auto_update_enabled must be a boolean");
}
setAutoUpdateEnabledGlobally(args.auto_update_enabled);
return readGlobalSettings();
}
if (command === "list_connector_channels") {
return connectorChannelsPayload();
}
@@ -43,6 +43,7 @@ export type SettingsSection = (typeof navCategories)[number];
type Theme = "dark" | "light";
type GlobalSettingsResponse = {
telemetryOptOut: boolean;
autoUpdateEnabled: boolean;
};
const PROVIDER_CATALOG_CACHE_TTL_MS = 60_000;
@@ -525,20 +526,29 @@ function GeneralSettingsContent({
const [telemetryLoading, setTelemetryLoading] = useState(true);
const [telemetrySaving, setTelemetrySaving] = useState(false);
const [telemetryError, setTelemetryError] = useState<string | null>(null);
const [autoUpdateEnabled, setAutoUpdateEnabled] = useState(true);
const [autoUpdateLoading, setAutoUpdateLoading] = useState(true);
const [autoUpdateSaving, setAutoUpdateSaving] = useState(false);
const [autoUpdateError, setAutoUpdateError] = useState<string | null>(null);
const loadGlobalSettings = useCallback(async () => {
setTelemetryLoading(true);
setTelemetryError(null);
setAutoUpdateLoading(true);
setAutoUpdateError(null);
try {
const settings = await desktopClient.invoke<GlobalSettingsResponse>(
"get_global_settings",
);
setTelemetryOptOut(settings.telemetryOptOut);
setAutoUpdateEnabled(settings.autoUpdateEnabled);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setTelemetryError(message);
setAutoUpdateError(message);
} finally {
setTelemetryLoading(false);
setAutoUpdateLoading(false);
}
}, []);
@@ -571,6 +581,28 @@ function GeneralSettingsContent({
}
};
const updateAutoUpdateEnabled = async (nextValue: boolean) => {
const previousValue = autoUpdateEnabled;
setAutoUpdateEnabled(nextValue);
setAutoUpdateSaving(true);
setAutoUpdateError(null);
try {
const settings = await desktopClient.invoke<GlobalSettingsResponse>(
"set_auto_update_enabled",
{
auto_update_enabled: nextValue,
},
);
setAutoUpdateEnabled(settings.autoUpdateEnabled);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setAutoUpdateEnabled(previousValue);
setAutoUpdateError(message);
} finally {
setAutoUpdateSaving(false);
}
};
return (
<ScrollArea className="h-full">
<div className="mx-auto max-w-3xl px-8 py-6">
@@ -605,6 +637,29 @@ function GeneralSettingsContent({
</div>
</div>
</section>
<section className="mt-4 rounded-lg border border-border p-5">
<div className="flex items-center justify-between gap-5 max-[720px]:flex-col max-[720px]:items-stretch">
<div>
<p className="text-sm font-medium text-foreground">Auto update</p>
<p className="mt-1 text-xs text-muted-foreground">
Automatically install CLI updates on startup.
</p>
{autoUpdateError ? (
<p className="mt-2 text-xs text-destructive">
Failed to update auto update setting: {autoUpdateError}
</p>
) : null}
</div>
<Switch
aria-label="Auto update"
checked={autoUpdateEnabled}
disabled={autoUpdateLoading || autoUpdateSaving}
onCheckedChange={(checked) =>
void updateAutoUpdateEnabled(checked)
}
/>
</div>
</section>
<section className="mt-4 rounded-lg border border-border p-5">
<div className="flex items-center justify-between gap-5 max-[720px]:flex-col max-[720px]:items-stretch">
<div>
+2
View File
@@ -421,12 +421,14 @@ export {
filterDisabledTools,
filterExtensionToolRegistrations,
GlobalSettingsSchema,
isAutoUpdateEnabledGlobally,
isPluginDisabledGlobally,
isTelemetryOptedOutGlobally,
isToolDisabledGlobally,
readGlobalSettings,
resolveDisabledPluginPaths,
resolveDisabledToolNames,
setAutoUpdateEnabledGlobally,
setDisabledPlugin,
setDisabledTools,
setTelemetryOptOutGlobally,
@@ -6,6 +6,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import {
GlobalSettingsSchema,
readGlobalSettings,
setAutoUpdateEnabledGlobally,
setDisabledPlugin,
setDisabledTools,
setTelemetryOptOutGlobally,
@@ -26,6 +27,7 @@ describe("global-settings", () => {
disabledPlugins: ["/plugins/example.js", "/plugins/example.js"],
}),
).toEqual({
autoUpdateEnabled: true,
disabledPlugins: ["/plugins/example.js"],
disabledTools: ["editor", "read_files"],
telemetryOptOut: false,
@@ -35,8 +37,9 @@ describe("global-settings", () => {
disabledTools: [],
telemetryOptOut: true,
}),
).toEqual({ telemetryOptOut: true });
).toEqual({ autoUpdateEnabled: true, telemetryOptOut: true });
expect(GlobalSettingsSchema.parse({ disabledTools: [] })).toEqual({
autoUpdateEnabled: true,
telemetryOptOut: false,
});
expect(
@@ -45,6 +48,7 @@ describe("global-settings", () => {
extra: true,
}),
).toEqual({
autoUpdateEnabled: true,
disabledTools: ["read_files"],
telemetryOptOut: false,
});
@@ -55,8 +59,17 @@ describe("global-settings", () => {
telemetryOptOut: true,
}),
).toEqual({
autoUpdateEnabled: true,
telemetryOptOut: true,
});
expect(
GlobalSettingsSchema.parse({
autoUpdateEnabled: false,
}),
).toEqual({
autoUpdateEnabled: false,
telemetryOptOut: false,
});
});
it("uses the schema when reading and writing settings", async () => {
@@ -71,10 +84,12 @@ describe("global-settings", () => {
});
expect(readGlobalSettings()).toEqual({
autoUpdateEnabled: true,
disabledTools: ["editor", "read_files"],
telemetryOptOut: false,
});
expect(JSON.parse(await readFile(settingsPath, "utf8"))).toEqual({
autoUpdateEnabled: true,
disabledTools: ["editor", "read_files"],
telemetryOptOut: false,
});
@@ -88,6 +103,7 @@ describe("global-settings", () => {
}),
);
expect(readGlobalSettings()).toEqual({
autoUpdateEnabled: true,
disabledTools: ["read_files"],
telemetryOptOut: true,
});
@@ -100,7 +116,10 @@ describe("global-settings", () => {
telemetryOptOut: true,
}),
);
expect(readGlobalSettings()).toEqual({ telemetryOptOut: true });
expect(readGlobalSettings()).toEqual({
autoUpdateEnabled: true,
telemetryOptOut: true,
});
} finally {
await rm(root, { recursive: true, force: true });
}
@@ -117,11 +136,13 @@ describe("global-settings", () => {
setDisabledTools(["editor"], false);
expect(readGlobalSettings()).toEqual({
autoUpdateEnabled: true,
disabledPlugins: ["/plugins/example.js"],
disabledTools: ["read_files"],
telemetryOptOut: false,
});
expect(JSON.parse(await readFile(settingsPath, "utf8"))).toEqual({
autoUpdateEnabled: true,
disabledPlugins: ["/plugins/example.js"],
disabledTools: ["read_files"],
telemetryOptOut: false,
@@ -147,7 +168,32 @@ describe("global-settings", () => {
expect(captureRequired).toHaveBeenCalledTimes(1);
expect(captureRequired).toHaveBeenCalledWith("user.opt_out", undefined);
expect(readGlobalSettings()).toEqual({ telemetryOptOut: false });
expect(readGlobalSettings()).toEqual({
autoUpdateEnabled: true,
telemetryOptOut: false,
});
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("preserves other settings when auto update is changed", async () => {
const root = await mkdtemp(join(tmpdir(), "core-global-settings-"));
try {
const settingsPath = join(root, "global-settings.json");
process.env.CLINE_GLOBAL_SETTINGS_PATH = settingsPath;
writeGlobalSettings({
disabledTools: ["editor"],
telemetryOptOut: true,
});
setAutoUpdateEnabledGlobally(false);
expect(readGlobalSettings()).toEqual({
autoUpdateEnabled: false,
disabledTools: ["editor"],
telemetryOptOut: true,
});
} finally {
await rm(root, { recursive: true, force: true });
}
@@ -165,6 +211,7 @@ describe("global-settings", () => {
writeGlobalSettings({ disabledTools: ["read_files"] });
expect(readGlobalSettings()).toEqual({
autoUpdateEnabled: true,
disabledTools: ["read_files"],
telemetryOptOut: false,
});
@@ -187,6 +234,7 @@ describe("global-settings", () => {
);
expect(readGlobalSettings()).toEqual({
autoUpdateEnabled: true,
disabledTools: ["read_files"],
telemetryOptOut: false,
});
@@ -205,6 +253,7 @@ describe("global-settings", () => {
process.env.CLINE_GLOBAL_SETTINGS_PATH = pathA;
writeGlobalSettings({ disabledTools: ["editor"] });
expect(readGlobalSettings()).toEqual({
autoUpdateEnabled: true,
disabledTools: ["editor"],
telemetryOptOut: false,
});
@@ -212,12 +261,14 @@ describe("global-settings", () => {
process.env.CLINE_GLOBAL_SETTINGS_PATH = pathB;
writeGlobalSettings({ disabledTools: ["read_files"] });
expect(readGlobalSettings()).toEqual({
autoUpdateEnabled: true,
disabledTools: ["read_files"],
telemetryOptOut: false,
});
process.env.CLINE_GLOBAL_SETTINGS_PATH = pathA;
expect(readGlobalSettings()).toEqual({
autoUpdateEnabled: true,
disabledTools: ["editor"],
telemetryOptOut: false,
});
@@ -233,8 +284,14 @@ describe("global-settings", () => {
const settingsPath = join(root, "missing-global-settings.json");
process.env.CLINE_GLOBAL_SETTINGS_PATH = settingsPath;
expect(readGlobalSettings()).toEqual({ telemetryOptOut: false });
expect(readGlobalSettings()).toEqual({ telemetryOptOut: false });
expect(readGlobalSettings()).toEqual({
autoUpdateEnabled: true,
telemetryOptOut: false,
});
expect(readGlobalSettings()).toEqual({
autoUpdateEnabled: true,
telemetryOptOut: false,
});
} finally {
await rm(root, { recursive: true, force: true });
}
@@ -272,7 +329,10 @@ describe("global-settings", () => {
const settingsPath = join(root, "global-settings.json");
process.env.CLINE_GLOBAL_SETTINGS_PATH = settingsPath;
expect(readGlobalSettings()).toEqual({ telemetryOptOut: false });
expect(readGlobalSettings()).toEqual({
autoUpdateEnabled: true,
telemetryOptOut: false,
});
await writeFile(
settingsPath,
@@ -280,6 +340,7 @@ describe("global-settings", () => {
);
expect(readGlobalSettings()).toEqual({
autoUpdateEnabled: true,
disabledTools: ["editor"],
telemetryOptOut: false,
});
@@ -32,6 +32,7 @@ const GlobalSettingsStringListSchema = z
export const GlobalSettingsSchema = z
.object({
telemetryOptOut: z.boolean().default(false).catch(false),
autoUpdateEnabled: z.boolean().default(true).catch(true),
disabledTools: GlobalSettingsStringListSchema.optional(),
disabledPlugins: GlobalSettingsStringListSchema.optional(),
})
@@ -39,9 +40,11 @@ export const GlobalSettingsSchema = z
.transform((settings) => {
const normalized: {
telemetryOptOut: boolean;
autoUpdateEnabled: boolean;
disabledTools?: string[];
disabledPlugins?: string[];
} = {
autoUpdateEnabled: settings.autoUpdateEnabled,
telemetryOptOut: settings.telemetryOptOut,
};
if (settings.disabledTools?.length) {
@@ -160,6 +163,23 @@ export function setTelemetryOptOutGlobally(
);
}
export function isAutoUpdateEnabledGlobally(): boolean {
return readGlobalSettings().autoUpdateEnabled;
}
export function setAutoUpdateEnabledGlobally(
autoUpdateEnabled: boolean,
options: WriteGlobalSettingsOptions = {},
): void {
writeGlobalSettings(
{
...readGlobalSettings(),
autoUpdateEnabled,
},
options,
);
}
export function resolveDisabledToolNames(
disabledToolNames?: ReadonlyArray<string>,
): Set<string> {
@@ -106,6 +106,7 @@ describe("plugin uninstall service", () => {
expect(result.installPath).toBe(installPath);
expect(existsSync(installPath)).toBe(false);
expect(readGlobalSettings()).toEqual({
autoUpdateEnabled: true,
disabledPlugins: ["/tmp/other-plugin.ts"],
telemetryOptOut: false,
});
@@ -146,6 +147,7 @@ describe("plugin uninstall service", () => {
await expect(uninstallPlugin({ path: pluginPath })).rejects.toThrow();
expect(existsSync(pluginPath)).toBe(true);
expect(readGlobalSettings()).toEqual({
autoUpdateEnabled: true,
disabledPlugins: [pluginPath],
telemetryOptOut: false,
});
@@ -287,6 +287,7 @@ Use this skill.`,
await readFile(process.env.CLINE_GLOBAL_SETTINGS_PATH, "utf8"),
),
).toEqual({
autoUpdateEnabled: true,
disabledTools: ["plugin-tool"],
telemetryOptOut: false,
});
@@ -297,6 +298,6 @@ Use this skill.`,
JSON.parse(
await readFile(process.env.CLINE_GLOBAL_SETTINGS_PATH, "utf8"),
),
).toEqual({ telemetryOptOut: false });
).toEqual({ autoUpdateEnabled: true, telemetryOptOut: false });
});
});
+2
View File
@@ -109,12 +109,14 @@ export {
filterDisabledTools,
filterExtensionToolRegistrations,
GlobalSettingsSchema,
isAutoUpdateEnabledGlobally,
isPluginDisabledGlobally,
isTelemetryOptedOutGlobally,
isToolDisabledGlobally,
readGlobalSettings,
resolveDisabledPluginPaths,
resolveDisabledToolNames,
setAutoUpdateEnabledGlobally,
setDisabledPlugin,
setDisabledTools,
setTelemetryOptOutGlobally,