Skip to content

Commit f442a35

Browse files
committed
feat(update): add core auto-updater and dry-run preview
1 parent 13690d4 commit f442a35

15 files changed

Lines changed: 672 additions & 44 deletions

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ Docs: https://docs.openclaw.ai
66

77
### Changes
88

9+
- Update/Core: add an optional built-in auto-updater for package installs (`update.auto.*`), default-off, with stable rollout delay+jitter and beta hourly cadence.
10+
- CLI/Update: add `openclaw update --dry-run` to preview channel/tag/target/restart actions without mutating config, installing, syncing plugins, or restarting.
911
- Skills: remove bundled `food-order` skill from this repo; manage/install it from ClawHub instead.
1012
- Docs/Subagents: make thread-bound session guidance channel-first instead of Discord-specific, and list thread-supporting channels explicitly. (#23589) Thanks @osolmaz.
1113
- Channels/Config: unify channel preview streaming config handling with a shared resolver and canonical migration path.

docs/cli/update.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ openclaw update wizard
2121
openclaw update --channel beta
2222
openclaw update --channel dev
2323
openclaw update --tag beta
24+
openclaw update --dry-run
2425
openclaw update --no-restart
2526
openclaw update --json
2627
openclaw --update
@@ -31,6 +32,7 @@ openclaw --update
3132
- `--no-restart`: skip restarting the Gateway service after a successful update.
3233
- `--channel <stable|beta|dev>`: set the update channel (git + npm; persisted in config).
3334
- `--tag <dist-tag|version>`: override the npm dist-tag or version for this update only.
35+
- `--dry-run`: preview planned update actions (channel/tag/target/restart flow) without writing config, installing, syncing plugins, or restarting.
3436
- `--json`: print machine-readable `UpdateRunResult` JSON.
3537
- `--timeout <seconds>`: per-step timeout (default is 1200s).
3638

@@ -66,6 +68,8 @@ install method aligned:
6668
updates it, and installs the global CLI from that checkout.
6769
- `stable`/`beta` → installs from npm using the matching dist-tag.
6870

71+
The Gateway core auto-updater (when enabled via config) reuses this same update path.
72+
6973
## Git checkout flow
7074

7175
Channels:

docs/install/updating.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,32 @@ See [Development channels](/install/development-channels) for channel semantics
7171

7272
Note: on npm installs, the gateway logs an update hint on startup (checks the current channel tag). Disable via `update.checkOnStart: false`.
7373

74+
### Core auto-updater (optional)
75+
76+
Auto-updater is **off by default** and is a core Gateway feature (not a plugin).
77+
78+
```json
79+
{
80+
"update": {
81+
"channel": "stable",
82+
"auto": {
83+
"enabled": true,
84+
"stableDelayHours": 6,
85+
"stableJitterHours": 12,
86+
"betaCheckIntervalHours": 1
87+
}
88+
}
89+
}
90+
```
91+
92+
Behavior:
93+
94+
- `stable`: when a new version is seen, OpenClaw waits `stableDelayHours` and then applies a deterministic per-install jitter in `stableJitterHours` (spread rollout).
95+
- `beta`: checks on `betaCheckIntervalHours` cadence (default: hourly) and applies when an update is available.
96+
- `dev`: no automatic apply; use manual `openclaw update`.
97+
98+
Use `openclaw update --dry-run` to preview update actions before enabling automation.
99+
74100
Then:
75101

76102
```bash

src/cli/update-cli.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,23 @@ describe("update-cli", () => {
374374
expect(defaultRuntime.log).toHaveBeenCalled();
375375
});
376376

377+
it("updateCommand --dry-run previews without mutating", async () => {
378+
vi.mocked(defaultRuntime.log).mockClear();
379+
serviceLoaded.mockResolvedValue(true);
380+
381+
await updateCommand({ dryRun: true, channel: "beta" });
382+
383+
expect(writeConfigFile).not.toHaveBeenCalled();
384+
expect(runGatewayUpdate).not.toHaveBeenCalled();
385+
expect(runDaemonInstall).not.toHaveBeenCalled();
386+
expect(runRestartScript).not.toHaveBeenCalled();
387+
expect(runDaemonRestart).not.toHaveBeenCalled();
388+
389+
const logs = vi.mocked(defaultRuntime.log).mock.calls.map((call) => String(call[0]));
390+
expect(logs.join("\n")).toContain("Update dry-run");
391+
expect(logs.join("\n")).toContain("No changes were applied.");
392+
});
393+
377394
it("updateStatusCommand prints table output", async () => {
378395
await updateStatusCommand({ json: false });
379396

@@ -704,6 +721,16 @@ describe("update-cli", () => {
704721
expect(vi.mocked(runGatewayUpdate).mock.calls.length > 0).toBe(shouldRunUpdate);
705722
});
706723

724+
it("dry-run bypasses downgrade confirmation checks in non-interactive mode", async () => {
725+
await setupNonInteractiveDowngrade();
726+
vi.mocked(defaultRuntime.exit).mockClear();
727+
728+
await updateCommand({ dryRun: true });
729+
730+
expect(vi.mocked(defaultRuntime.exit).mock.calls.some((call) => call[0] === 1)).toBe(false);
731+
expect(runGatewayUpdate).not.toHaveBeenCalled();
732+
});
733+
707734
it("updateWizardCommand requires a TTY", async () => {
708735
setTty(false);
709736
vi.mocked(defaultRuntime.error).mockClear();

src/cli/update-cli.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export function registerUpdateCli(program: Command) {
3737
.description("Update OpenClaw and inspect update channel status")
3838
.option("--json", "Output result as JSON", false)
3939
.option("--no-restart", "Skip restarting the gateway service after a successful update")
40+
.option("--dry-run", "Preview update actions without making changes", false)
4041
.option("--channel <stable|beta|dev>", "Persist update channel (git + npm)")
4142
.option("--tag <dist-tag|version>", "Override npm dist-tag or version for this update")
4243
.option("--timeout <seconds>", "Timeout for each update step in seconds (default: 1200)")
@@ -47,6 +48,7 @@ export function registerUpdateCli(program: Command) {
4748
["openclaw update --channel beta", "Switch to beta channel (git + npm)"],
4849
["openclaw update --channel dev", "Switch to dev channel (git + npm)"],
4950
["openclaw update --tag beta", "One-off update to a dist-tag or version"],
51+
["openclaw update --dry-run", "Preview actions without changing anything"],
5052
["openclaw update --no-restart", "Update without restarting the service"],
5153
["openclaw update --json", "Output result as JSON"],
5254
["openclaw update --yes", "Non-interactive (accept downgrade prompts)"],
@@ -69,6 +71,7 @@ ${theme.heading("Switch channels:")}
6971
${theme.heading("Non-interactive:")}
7072
- Use --yes to accept downgrade prompts
7173
- Combine with --channel/--tag/--restart/--json/--timeout as needed
74+
- Use --dry-run to preview actions without writing config/installing/restarting
7275
7376
${theme.heading("Examples:")}
7477
${fmtExamples}
@@ -86,6 +89,7 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/update", "docs.openclaw.ai/cli/up
8689
await updateCommand({
8790
json: Boolean(opts.json),
8891
restart: Boolean(opts.restart),
92+
dryRun: Boolean(opts.dryRun),
8993
channel: opts.channel as string | undefined,
9094
tag: opts.tag as string | undefined,
9195
timeout: opts.timeout as string | undefined,

src/cli/update-cli/shared.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { pathExists } from "../../utils.js";
2323
export type UpdateCommandOptions = {
2424
json?: boolean;
2525
restart?: boolean;
26+
dryRun?: boolean;
2627
channel?: string;
2728
tag?: string;
2829
timeout?: string;

src/cli/update-cli/update-command.ts

Lines changed: 158 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,65 @@ function formatCommandFailure(stdout: string, stderr: string): string {
114114
return detail.split("\n").slice(-3).join("\n");
115115
}
116116

117+
type UpdateDryRunPreview = {
118+
dryRun: true;
119+
root: string;
120+
installKind: "git" | "package" | "unknown";
121+
mode: UpdateRunResult["mode"];
122+
updateInstallKind: "git" | "package" | "unknown";
123+
switchToGit: boolean;
124+
switchToPackage: boolean;
125+
restart: boolean;
126+
requestedChannel: "stable" | "beta" | "dev" | null;
127+
storedChannel: "stable" | "beta" | "dev" | null;
128+
effectiveChannel: "stable" | "beta" | "dev";
129+
tag: string;
130+
currentVersion: string | null;
131+
targetVersion: string | null;
132+
downgradeRisk: boolean;
133+
actions: string[];
134+
notes: string[];
135+
};
136+
137+
function printDryRunPreview(preview: UpdateDryRunPreview, jsonMode: boolean): void {
138+
if (jsonMode) {
139+
defaultRuntime.log(JSON.stringify(preview, null, 2));
140+
return;
141+
}
142+
143+
defaultRuntime.log(theme.heading("Update dry-run"));
144+
defaultRuntime.log(theme.muted("No changes were applied."));
145+
defaultRuntime.log("");
146+
defaultRuntime.log(` Root: ${theme.muted(preview.root)}`);
147+
defaultRuntime.log(` Install kind: ${theme.muted(preview.installKind)}`);
148+
defaultRuntime.log(` Mode: ${theme.muted(preview.mode)}`);
149+
defaultRuntime.log(` Channel: ${theme.muted(preview.effectiveChannel)}`);
150+
defaultRuntime.log(` Tag/spec: ${theme.muted(preview.tag)}`);
151+
if (preview.currentVersion) {
152+
defaultRuntime.log(` Current version: ${theme.muted(preview.currentVersion)}`);
153+
}
154+
if (preview.targetVersion) {
155+
defaultRuntime.log(` Target version: ${theme.muted(preview.targetVersion)}`);
156+
}
157+
if (preview.downgradeRisk) {
158+
defaultRuntime.log(theme.warn(" Downgrade confirmation would be required in a real run."));
159+
}
160+
161+
defaultRuntime.log("");
162+
defaultRuntime.log(theme.heading("Planned actions:"));
163+
for (const action of preview.actions) {
164+
defaultRuntime.log(` - ${action}`);
165+
}
166+
167+
if (preview.notes.length > 0) {
168+
defaultRuntime.log("");
169+
defaultRuntime.log(theme.heading("Notes:"));
170+
for (const note of preview.notes) {
171+
defaultRuntime.log(` - ${theme.muted(note)}`);
172+
}
173+
}
174+
}
175+
117176
async function refreshGatewayServiceEnv(params: {
118177
result: UpdateRunResult;
119178
jsonMode: boolean;
@@ -613,11 +672,14 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
613672

614673
const explicitTag = normalizeTag(opts.tag);
615674
let tag = explicitTag ?? channelToNpmTag(channel);
675+
let currentVersion: string | null = null;
676+
let targetVersion: string | null = null;
677+
let downgradeRisk = false;
678+
let fallbackToLatest = false;
616679

617680
if (updateInstallKind !== "git") {
618-
const currentVersion = switchToPackage ? null : await readPackageVersion(root);
619-
let fallbackToLatest = false;
620-
const targetVersion = explicitTag
681+
currentVersion = switchToPackage ? null : await readPackageVersion(root);
682+
targetVersion = explicitTag
621683
? await resolveTargetVersion(tag, timeoutMs)
622684
: await resolveNpmChannelTag({ channel, timeoutMs }).then((resolved) => {
623685
tag = resolved.tag;
@@ -626,38 +688,106 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
626688
});
627689
const cmp =
628690
currentVersion && targetVersion ? compareSemverStrings(currentVersion, targetVersion) : null;
629-
const needsConfirm =
691+
downgradeRisk =
630692
!fallbackToLatest &&
631693
currentVersion != null &&
632694
(targetVersion == null || (cmp != null && cmp > 0));
695+
}
633696

634-
if (needsConfirm && !opts.yes) {
635-
if (!process.stdin.isTTY || opts.json) {
636-
defaultRuntime.error(
637-
[
638-
"Downgrade confirmation required.",
639-
"Downgrading can break configuration. Re-run in a TTY to confirm.",
640-
].join("\n"),
641-
);
642-
defaultRuntime.exit(1);
643-
return;
644-
}
645-
646-
const targetLabel = targetVersion ?? `${tag} (unknown)`;
647-
const message = `Downgrading from ${currentVersion} to ${targetLabel} can break configuration. Continue?`;
648-
const ok = await confirm({
649-
message: stylePromptMessage(message),
650-
initialValue: false,
697+
if (opts.dryRun) {
698+
let mode: UpdateRunResult["mode"] = "unknown";
699+
if (updateInstallKind === "git") {
700+
mode = "git";
701+
} else if (updateInstallKind === "package") {
702+
mode = await resolveGlobalManager({
703+
root,
704+
installKind,
705+
timeoutMs: timeoutMs ?? 20 * 60_000,
651706
});
652-
if (isCancel(ok) || !ok) {
653-
if (!opts.json) {
654-
defaultRuntime.log(theme.muted("Update cancelled."));
655-
}
656-
defaultRuntime.exit(0);
657-
return;
707+
}
708+
709+
const actions: string[] = [];
710+
if (requestedChannel && requestedChannel !== storedChannel) {
711+
actions.push(`Persist update.channel=${requestedChannel} in config`);
712+
}
713+
if (switchToGit) {
714+
actions.push("Switch install mode from package to git checkout (dev channel)");
715+
} else if (switchToPackage) {
716+
actions.push(`Switch install mode from git to package manager (${mode})`);
717+
} else if (updateInstallKind === "git") {
718+
actions.push(`Run git update flow on channel ${channel} (fetch/rebase/build/doctor)`);
719+
} else {
720+
actions.push(`Run global package manager update with spec openclaw@${tag}`);
721+
}
722+
actions.push("Run plugin update sync after core update");
723+
actions.push("Refresh shell completion cache (if needed)");
724+
actions.push(
725+
shouldRestart
726+
? "Restart gateway service and run doctor checks"
727+
: "Skip restart (because --no-restart is set)",
728+
);
729+
730+
const notes: string[] = [];
731+
if (opts.tag && updateInstallKind === "git") {
732+
notes.push("--tag applies to npm installs only; git updates ignore it.");
733+
}
734+
if (fallbackToLatest) {
735+
notes.push("Beta channel resolves to latest for this run (fallback).");
736+
}
737+
738+
printDryRunPreview(
739+
{
740+
dryRun: true,
741+
root,
742+
installKind,
743+
mode,
744+
updateInstallKind,
745+
switchToGit,
746+
switchToPackage,
747+
restart: shouldRestart,
748+
requestedChannel,
749+
storedChannel,
750+
effectiveChannel: channel,
751+
tag,
752+
currentVersion,
753+
targetVersion,
754+
downgradeRisk,
755+
actions,
756+
notes,
757+
},
758+
Boolean(opts.json),
759+
);
760+
return;
761+
}
762+
763+
if (downgradeRisk && !opts.yes) {
764+
if (!process.stdin.isTTY || opts.json) {
765+
defaultRuntime.error(
766+
[
767+
"Downgrade confirmation required.",
768+
"Downgrading can break configuration. Re-run in a TTY to confirm.",
769+
].join("\n"),
770+
);
771+
defaultRuntime.exit(1);
772+
return;
773+
}
774+
775+
const targetLabel = targetVersion ?? `${tag} (unknown)`;
776+
const message = `Downgrading from ${currentVersion} to ${targetLabel} can break configuration. Continue?`;
777+
const ok = await confirm({
778+
message: stylePromptMessage(message),
779+
initialValue: false,
780+
});
781+
if (isCancel(ok) || !ok) {
782+
if (!opts.json) {
783+
defaultRuntime.log(theme.muted("Update cancelled."));
658784
}
785+
defaultRuntime.exit(0);
786+
return;
659787
}
660-
} else if (opts.tag && !opts.json) {
788+
}
789+
790+
if (updateInstallKind === "git" && opts.tag && !opts.json) {
661791
defaultRuntime.log(
662792
theme.muted("Note: --tag applies to npm installs only; git updates ignore it."),
663793
);

src/config/schema.help.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ export const FIELD_HELP: Record<string, string> = {
55
"meta.lastTouchedAt": "ISO timestamp of the last config write (auto-set).",
66
"update.channel": 'Update channel for git + npm installs ("stable", "beta", or "dev").',
77
"update.checkOnStart": "Check for npm updates when the gateway starts (default: true).",
8+
"update.auto.enabled": "Enable background auto-update for package installs (default: false).",
9+
"update.auto.stableDelayHours":
10+
"Minimum delay before stable-channel auto-apply starts (default: 6).",
11+
"update.auto.stableJitterHours":
12+
"Extra stable-channel rollout spread window in hours (default: 12).",
13+
"update.auto.betaCheckIntervalHours": "How often beta-channel checks run in hours (default: 1).",
814
"gateway.remote.url": "Remote Gateway WebSocket URL (ws:// or wss://).",
915
"gateway.remote.tlsFingerprint":
1016
"Expected sha256 TLS fingerprint for the remote gateway (pin to avoid MITM).",

src/config/schema.labels.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ export const FIELD_LABELS: Record<string, string> = {
55
"meta.lastTouchedAt": "Config Last Touched At",
66
"update.channel": "Update Channel",
77
"update.checkOnStart": "Update Check on Start",
8+
"update.auto.enabled": "Auto Update Enabled",
9+
"update.auto.stableDelayHours": "Auto Update Stable Delay (hours)",
10+
"update.auto.stableJitterHours": "Auto Update Stable Jitter (hours)",
11+
"update.auto.betaCheckIntervalHours": "Auto Update Beta Check Interval (hours)",
812
"diagnostics.enabled": "Diagnostics Enabled",
913
"diagnostics.flags": "Diagnostics Flags",
1014
"diagnostics.otel.enabled": "OpenTelemetry Enabled",

0 commit comments

Comments
 (0)