feat: add update wizard and guard elevated defaults
This commit is contained in:
@@ -14,6 +14,7 @@ Docs: https://docs.clawd.bot
|
|||||||
- Onboarding: remove the run setup-token auth option (paste setup-token or reuse CLI creds instead).
|
- Onboarding: remove the run setup-token auth option (paste setup-token or reuse CLI creds instead).
|
||||||
- Signal: add typing indicators and DM read receipts via signal-cli.
|
- Signal: add typing indicators and DM read receipts via signal-cli.
|
||||||
- MSTeams: add file uploads, adaptive cards, and attachment handling improvements. (#1410) Thanks @Evizero.
|
- MSTeams: add file uploads, adaptive cards, and attachment handling improvements. (#1410) Thanks @Evizero.
|
||||||
|
- CLI: add `clawdbot update wizard` for interactive channel selection and restart prompts. https://docs.clawd.bot/cli/update
|
||||||
|
|
||||||
### Breaking
|
### Breaking
|
||||||
- **BREAKING:** Envelope and system event timestamps now default to host-local time (was UTC) so agents don’t have to constantly convert.
|
- **BREAKING:** Envelope and system event timestamps now default to host-local time (was UTC) so agents don’t have to constantly convert.
|
||||||
@@ -31,6 +32,7 @@ Docs: https://docs.clawd.bot
|
|||||||
- macOS: include Textual syntax highlighting resources in packaged app to prevent chat crashes. (#1362)
|
- macOS: include Textual syntax highlighting resources in packaged app to prevent chat crashes. (#1362)
|
||||||
- Cron: cap reminder context history to 10 messages and honor `contextMessages`. (#1103) Thanks @mkbehr.
|
- Cron: cap reminder context history to 10 messages and honor `contextMessages`. (#1103) Thanks @mkbehr.
|
||||||
- Exec approvals: treat main as the default agent + migrate legacy default allowlists. (#1417) Thanks @czekaj.
|
- Exec approvals: treat main as the default agent + migrate legacy default allowlists. (#1417) Thanks @czekaj.
|
||||||
|
- Exec: avoid defaulting to elevated mode when elevated is not allowed.
|
||||||
- UI: refresh debug panel on route-driven tab changes. (#1373) Thanks @yazinsai.
|
- UI: refresh debug panel on route-driven tab changes. (#1373) Thanks @yazinsai.
|
||||||
|
|
||||||
## 2026.1.21
|
## 2026.1.21
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ If you installed via **npm/pnpm** (global install, no git metadata), updates hap
|
|||||||
```bash
|
```bash
|
||||||
clawdbot update
|
clawdbot update
|
||||||
clawdbot update status
|
clawdbot update status
|
||||||
|
clawdbot update wizard
|
||||||
clawdbot update --channel beta
|
clawdbot update --channel beta
|
||||||
clawdbot update --channel dev
|
clawdbot update --channel dev
|
||||||
clawdbot update --tag beta
|
clawdbot update --tag beta
|
||||||
@@ -48,6 +49,11 @@ Options:
|
|||||||
- `--json`: print machine-readable status JSON.
|
- `--json`: print machine-readable status JSON.
|
||||||
- `--timeout <seconds>`: timeout for checks (default is 3s).
|
- `--timeout <seconds>`: timeout for checks (default is 3s).
|
||||||
|
|
||||||
|
## `update wizard`
|
||||||
|
|
||||||
|
Interactive flow to pick an update channel and confirm whether to restart the Gateway
|
||||||
|
after updating. If you select `dev` without a git checkout, it offers to create one.
|
||||||
|
|
||||||
## What it does
|
## What it does
|
||||||
|
|
||||||
When you switch channels explicitly (`--channel ...`), Clawdbot also keeps the
|
When you switch channels explicitly (`--channel ...`), Clawdbot also keeps the
|
||||||
|
|||||||
@@ -5,6 +5,18 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
|
|
||||||
import type { UpdateRunResult } from "../infra/update-runner.js";
|
import type { UpdateRunResult } from "../infra/update-runner.js";
|
||||||
|
|
||||||
|
const confirm = vi.fn();
|
||||||
|
const select = vi.fn();
|
||||||
|
const spinner = vi.fn(() => ({ start: vi.fn(), stop: vi.fn() }));
|
||||||
|
const isCancel = (value: unknown) => value === "cancel";
|
||||||
|
|
||||||
|
vi.mock("@clack/prompts", () => ({
|
||||||
|
confirm,
|
||||||
|
select,
|
||||||
|
isCancel,
|
||||||
|
spinner,
|
||||||
|
}));
|
||||||
|
|
||||||
// Mock the update-runner module
|
// Mock the update-runner module
|
||||||
vi.mock("../infra/update-runner.js", () => ({
|
vi.mock("../infra/update-runner.js", () => ({
|
||||||
runGatewayUpdate: vi.fn(),
|
runGatewayUpdate: vi.fn(),
|
||||||
@@ -128,9 +140,11 @@ describe("update-cli", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("exports updateCommand and registerUpdateCli", async () => {
|
it("exports updateCommand and registerUpdateCli", async () => {
|
||||||
const { updateCommand, registerUpdateCli } = await import("./update-cli.js");
|
const { updateCommand, registerUpdateCli, updateWizardCommand } =
|
||||||
|
await import("./update-cli.js");
|
||||||
expect(typeof updateCommand).toBe("function");
|
expect(typeof updateCommand).toBe("function");
|
||||||
expect(typeof registerUpdateCli).toBe("function");
|
expect(typeof registerUpdateCli).toBe("function");
|
||||||
|
expect(typeof updateWizardCommand).toBe("function");
|
||||||
}, 20_000);
|
}, 20_000);
|
||||||
|
|
||||||
it("updateCommand runs update and outputs result", async () => {
|
it("updateCommand runs update and outputs result", async () => {
|
||||||
@@ -585,4 +599,61 @@ describe("update-cli", () => {
|
|||||||
await fs.rm(tempDir, { recursive: true, force: true });
|
await fs.rm(tempDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("updateWizardCommand requires a TTY", async () => {
|
||||||
|
const { defaultRuntime } = await import("../runtime.js");
|
||||||
|
const { updateWizardCommand } = await import("./update-cli.js");
|
||||||
|
|
||||||
|
setTty(false);
|
||||||
|
vi.mocked(defaultRuntime.error).mockClear();
|
||||||
|
vi.mocked(defaultRuntime.exit).mockClear();
|
||||||
|
|
||||||
|
await updateWizardCommand({});
|
||||||
|
|
||||||
|
expect(defaultRuntime.error).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("Update wizard requires a TTY"),
|
||||||
|
);
|
||||||
|
expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updateWizardCommand offers dev checkout and forwards selections", async () => {
|
||||||
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-update-wizard-"));
|
||||||
|
const previousGitDir = process.env.CLAWDBOT_GIT_DIR;
|
||||||
|
try {
|
||||||
|
setTty(true);
|
||||||
|
process.env.CLAWDBOT_GIT_DIR = tempDir;
|
||||||
|
|
||||||
|
const { checkUpdateStatus } = await import("../infra/update-check.js");
|
||||||
|
const { runGatewayUpdate } = await import("../infra/update-runner.js");
|
||||||
|
const { updateWizardCommand } = await import("./update-cli.js");
|
||||||
|
|
||||||
|
vi.mocked(checkUpdateStatus).mockResolvedValue({
|
||||||
|
root: "/test/path",
|
||||||
|
installKind: "package",
|
||||||
|
packageManager: "npm",
|
||||||
|
deps: {
|
||||||
|
manager: "npm",
|
||||||
|
status: "ok",
|
||||||
|
lockfilePath: null,
|
||||||
|
markerPath: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
select.mockResolvedValue("dev");
|
||||||
|
confirm.mockResolvedValueOnce(true).mockResolvedValueOnce(false);
|
||||||
|
vi.mocked(runGatewayUpdate).mockResolvedValue({
|
||||||
|
status: "ok",
|
||||||
|
mode: "git",
|
||||||
|
steps: [],
|
||||||
|
durationMs: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
await updateWizardCommand({});
|
||||||
|
|
||||||
|
const call = vi.mocked(runGatewayUpdate).mock.calls[0]?.[0];
|
||||||
|
expect(call?.channel).toBe("dev");
|
||||||
|
} finally {
|
||||||
|
process.env.CLAWDBOT_GIT_DIR = previousGitDir;
|
||||||
|
await fs.rm(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { confirm, isCancel, spinner } from "@clack/prompts";
|
import { confirm, isCancel, select, spinner } from "@clack/prompts";
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
@@ -39,7 +39,7 @@ import { trimLogTail } from "../infra/restart-sentinel.js";
|
|||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
import { formatDocsLink } from "../terminal/links.js";
|
import { formatDocsLink } from "../terminal/links.js";
|
||||||
import { formatCliCommand } from "./command-format.js";
|
import { formatCliCommand } from "./command-format.js";
|
||||||
import { stylePromptMessage } from "../terminal/prompt-style.js";
|
import { stylePromptHint, stylePromptMessage } from "../terminal/prompt-style.js";
|
||||||
import { theme } from "../terminal/theme.js";
|
import { theme } from "../terminal/theme.js";
|
||||||
import { renderTable } from "../terminal/table.js";
|
import { renderTable } from "../terminal/table.js";
|
||||||
import { formatHelpExamples } from "./help-format.js";
|
import { formatHelpExamples } from "./help-format.js";
|
||||||
@@ -63,6 +63,9 @@ export type UpdateStatusOptions = {
|
|||||||
json?: boolean;
|
json?: boolean;
|
||||||
timeout?: string;
|
timeout?: string;
|
||||||
};
|
};
|
||||||
|
export type UpdateWizardOptions = {
|
||||||
|
timeout?: string;
|
||||||
|
};
|
||||||
|
|
||||||
const STEP_LABELS: Record<string, string> = {
|
const STEP_LABELS: Record<string, string> = {
|
||||||
"clean check": "Working directory is clean",
|
"clean check": "Working directory is clean",
|
||||||
@@ -481,6 +484,15 @@ function formatStepStatus(exitCode: number | null): string {
|
|||||||
return theme.error("\u2717");
|
return theme.error("\u2717");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const selectStyled = <T>(params: Parameters<typeof select<T>>[0]) =>
|
||||||
|
select({
|
||||||
|
...params,
|
||||||
|
message: stylePromptMessage(params.message),
|
||||||
|
options: params.options.map((opt) =>
|
||||||
|
opt.hint === undefined ? opt : { ...opt, hint: stylePromptHint(opt.hint) },
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
type PrintResultOptions = UpdateCommandOptions & {
|
type PrintResultOptions = UpdateCommandOptions & {
|
||||||
hideSteps?: boolean;
|
hideSteps?: boolean;
|
||||||
};
|
};
|
||||||
@@ -940,6 +952,142 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateWizardCommand(opts: UpdateWizardOptions = {}): Promise<void> {
|
||||||
|
if (!process.stdin.isTTY) {
|
||||||
|
defaultRuntime.error(
|
||||||
|
"Update wizard requires a TTY. Use `clawdbot update --channel <stable|beta|dev>` instead.",
|
||||||
|
);
|
||||||
|
defaultRuntime.exit(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeoutMs = opts.timeout ? Number.parseInt(opts.timeout, 10) * 1000 : undefined;
|
||||||
|
if (timeoutMs !== undefined && (Number.isNaN(timeoutMs) || timeoutMs <= 0)) {
|
||||||
|
defaultRuntime.error("--timeout must be a positive integer (seconds)");
|
||||||
|
defaultRuntime.exit(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const root =
|
||||||
|
(await resolveClawdbotPackageRoot({
|
||||||
|
moduleUrl: import.meta.url,
|
||||||
|
argv1: process.argv[1],
|
||||||
|
cwd: process.cwd(),
|
||||||
|
})) ?? process.cwd();
|
||||||
|
|
||||||
|
const [updateStatus, configSnapshot] = await Promise.all([
|
||||||
|
checkUpdateStatus({
|
||||||
|
root,
|
||||||
|
timeoutMs: timeoutMs ?? 3500,
|
||||||
|
fetchGit: false,
|
||||||
|
includeRegistry: false,
|
||||||
|
}),
|
||||||
|
readConfigFileSnapshot(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const configChannel = configSnapshot.valid
|
||||||
|
? normalizeUpdateChannel(configSnapshot.config.update?.channel)
|
||||||
|
: null;
|
||||||
|
const channelInfo = resolveEffectiveUpdateChannel({
|
||||||
|
configChannel,
|
||||||
|
installKind: updateStatus.installKind,
|
||||||
|
git: updateStatus.git
|
||||||
|
? { tag: updateStatus.git.tag, branch: updateStatus.git.branch }
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
const channelLabel = formatUpdateChannelLabel({
|
||||||
|
channel: channelInfo.channel,
|
||||||
|
source: channelInfo.source,
|
||||||
|
gitTag: updateStatus.git?.tag ?? null,
|
||||||
|
gitBranch: updateStatus.git?.branch ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const pickedChannel = await selectStyled({
|
||||||
|
message: "Update channel",
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
value: "keep",
|
||||||
|
label: `Keep current (${channelInfo.channel})`,
|
||||||
|
hint: channelLabel,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "stable",
|
||||||
|
label: "Stable",
|
||||||
|
hint: "Tagged releases (npm latest)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "beta",
|
||||||
|
label: "Beta",
|
||||||
|
hint: "Prereleases (npm beta)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "dev",
|
||||||
|
label: "Dev",
|
||||||
|
hint: "Git main",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
initialValue: "keep",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isCancel(pickedChannel)) {
|
||||||
|
defaultRuntime.log(theme.muted("Update cancelled."));
|
||||||
|
defaultRuntime.exit(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestedChannel = pickedChannel === "keep" ? null : pickedChannel;
|
||||||
|
|
||||||
|
if (requestedChannel === "dev" && updateStatus.installKind !== "git") {
|
||||||
|
const gitDir = resolveGitInstallDir();
|
||||||
|
const hasGit = await isGitCheckout(gitDir);
|
||||||
|
if (!hasGit) {
|
||||||
|
const dirExists = await pathExists(gitDir);
|
||||||
|
if (dirExists) {
|
||||||
|
const empty = await isEmptyDir(gitDir);
|
||||||
|
if (!empty) {
|
||||||
|
defaultRuntime.error(
|
||||||
|
`CLAWDBOT_GIT_DIR points at a non-git directory: ${gitDir}. Set CLAWDBOT_GIT_DIR to an empty folder or a clawdbot checkout.`,
|
||||||
|
);
|
||||||
|
defaultRuntime.exit(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const ok = await confirm({
|
||||||
|
message: stylePromptMessage(
|
||||||
|
`Create a git checkout at ${gitDir}? (override via CLAWDBOT_GIT_DIR)`,
|
||||||
|
),
|
||||||
|
initialValue: true,
|
||||||
|
});
|
||||||
|
if (isCancel(ok) || ok === false) {
|
||||||
|
defaultRuntime.log(theme.muted("Update cancelled."));
|
||||||
|
defaultRuntime.exit(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const restart = await confirm({
|
||||||
|
message: stylePromptMessage("Restart the gateway service after update?"),
|
||||||
|
initialValue: false,
|
||||||
|
});
|
||||||
|
if (isCancel(restart)) {
|
||||||
|
defaultRuntime.log(theme.muted("Update cancelled."));
|
||||||
|
defaultRuntime.exit(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateCommand({
|
||||||
|
channel: requestedChannel ?? undefined,
|
||||||
|
restart: Boolean(restart),
|
||||||
|
timeout: opts.timeout,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
defaultRuntime.error(String(err));
|
||||||
|
defaultRuntime.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function registerUpdateCli(program: Command) {
|
export function registerUpdateCli(program: Command) {
|
||||||
const update = program
|
const update = program
|
||||||
.command("update")
|
.command("update")
|
||||||
@@ -959,6 +1107,7 @@ export function registerUpdateCli(program: Command) {
|
|||||||
["clawdbot update --restart", "Update and restart the service"],
|
["clawdbot update --restart", "Update and restart the service"],
|
||||||
["clawdbot update --json", "Output result as JSON"],
|
["clawdbot update --json", "Output result as JSON"],
|
||||||
["clawdbot update --yes", "Non-interactive (accept downgrade prompts)"],
|
["clawdbot update --yes", "Non-interactive (accept downgrade prompts)"],
|
||||||
|
["clawdbot update wizard", "Interactive update wizard"],
|
||||||
["clawdbot --update", "Shorthand for clawdbot update"],
|
["clawdbot --update", "Shorthand for clawdbot update"],
|
||||||
] as const;
|
] as const;
|
||||||
const fmtExamples = examples
|
const fmtExamples = examples
|
||||||
@@ -1005,6 +1154,23 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/update", "docs.clawd.bot/cli/upda
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
update
|
||||||
|
.command("wizard")
|
||||||
|
.description("Interactive update wizard")
|
||||||
|
.option("--timeout <seconds>", "Timeout for each update step in seconds (default: 1200)")
|
||||||
|
.addHelpText(
|
||||||
|
"after",
|
||||||
|
`\n${theme.muted("Docs:")} ${formatDocsLink("/cli/update", "docs.clawd.bot/cli/update")}\n`,
|
||||||
|
)
|
||||||
|
.action(async (opts) => {
|
||||||
|
try {
|
||||||
|
await updateWizardCommand({ timeout: opts.timeout as string | undefined });
|
||||||
|
} catch (err) {
|
||||||
|
defaultRuntime.error(String(err));
|
||||||
|
defaultRuntime.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
update
|
update
|
||||||
.command("status")
|
.command("status")
|
||||||
.description("Show update channel and version status")
|
.description("Show update channel and version status")
|
||||||
|
|||||||
Reference in New Issue
Block a user