feat: add update channel support
This commit is contained in:
@@ -15,6 +15,8 @@ If you installed via **npm/pnpm** (global install, no git metadata), use the pac
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
clawdbot update
|
clawdbot update
|
||||||
|
clawdbot update --channel beta
|
||||||
|
clawdbot update --tag beta
|
||||||
clawdbot update --restart
|
clawdbot update --restart
|
||||||
clawdbot update --json
|
clawdbot update --json
|
||||||
clawdbot --update
|
clawdbot --update
|
||||||
@@ -23,9 +25,13 @@ clawdbot --update
|
|||||||
## Options
|
## Options
|
||||||
|
|
||||||
- `--restart`: restart the Gateway daemon after a successful update.
|
- `--restart`: restart the Gateway daemon after a successful update.
|
||||||
|
- `--channel <stable|beta>`: set the update channel for npm installs (persisted in config).
|
||||||
|
- `--tag <dist-tag|version>`: override the npm dist-tag or version for this update only.
|
||||||
- `--json`: print machine-readable `UpdateRunResult` JSON.
|
- `--json`: print machine-readable `UpdateRunResult` JSON.
|
||||||
- `--timeout <seconds>`: per-step timeout (default is 1200s).
|
- `--timeout <seconds>`: per-step timeout (default is 1200s).
|
||||||
|
|
||||||
|
Note: downgrades require confirmation because older versions can break configuration.
|
||||||
|
|
||||||
## What it does (git checkout)
|
## What it does (git checkout)
|
||||||
|
|
||||||
High-level:
|
High-level:
|
||||||
|
|||||||
@@ -50,6 +50,20 @@ pnpm add -g clawdbot@latest
|
|||||||
```
|
```
|
||||||
We do **not** recommend Bun for the Gateway runtime (WhatsApp/Telegram bugs).
|
We do **not** recommend Bun for the Gateway runtime (WhatsApp/Telegram bugs).
|
||||||
|
|
||||||
|
To stay on the beta channel for CLI updates:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot update --channel beta
|
||||||
|
```
|
||||||
|
|
||||||
|
Switch back to stable later:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot update --channel stable
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `--tag <dist-tag|version>` for a one-off install tag/version.
|
||||||
|
|
||||||
Then:
|
Then:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -75,7 +89,7 @@ It runs a safe-ish update flow:
|
|||||||
- Fetches + rebases against the configured upstream.
|
- Fetches + rebases against the configured upstream.
|
||||||
- Installs deps, builds, builds the Control UI, and runs `clawdbot doctor`.
|
- Installs deps, builds, builds the Control UI, and runs `clawdbot doctor`.
|
||||||
|
|
||||||
If you installed via **npm/pnpm** (no git metadata), `clawdbot update` will skip. Use “Update (global install)” instead.
|
If you installed via **npm/pnpm** (no git metadata), `clawdbot update` will try to update via your package manager. If it can’t detect the install, use “Update (global install)” instead.
|
||||||
|
|
||||||
## Update (Control UI / RPC)
|
## Update (Control UI / RPC)
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import type { UpdateRunResult } from "../infra/update-runner.js";
|
import type { UpdateRunResult } from "../infra/update-runner.js";
|
||||||
|
|
||||||
@@ -7,6 +10,25 @@ vi.mock("../infra/update-runner.js", () => ({
|
|||||||
runGatewayUpdate: vi.fn(),
|
runGatewayUpdate: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("../infra/clawdbot-root.js", () => ({
|
||||||
|
resolveClawdbotPackageRoot: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../config/config.js", () => ({
|
||||||
|
readConfigFileSnapshot: vi.fn(),
|
||||||
|
writeConfigFile: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../infra/update-check.js", async () => {
|
||||||
|
const actual = await vi.importActual<typeof import("../infra/update-check.js")>(
|
||||||
|
"../infra/update-check.js",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
fetchNpmTagVersion: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// Mock doctor (heavy module; should not run in unit tests)
|
// Mock doctor (heavy module; should not run in unit tests)
|
||||||
vi.mock("../commands/doctor.js", () => ({
|
vi.mock("../commands/doctor.js", () => ({
|
||||||
doctorCommand: vi.fn(),
|
doctorCommand: vi.fn(),
|
||||||
@@ -26,6 +48,41 @@ vi.mock("../runtime.js", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
describe("update-cli", () => {
|
describe("update-cli", () => {
|
||||||
|
const baseSnapshot = {
|
||||||
|
valid: true,
|
||||||
|
config: {},
|
||||||
|
issues: [],
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const setTty = (value: boolean | undefined) => {
|
||||||
|
Object.defineProperty(process.stdin, "isTTY", {
|
||||||
|
value,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const setStdoutTty = (value: boolean | undefined) => {
|
||||||
|
Object.defineProperty(process.stdout, "isTTY", {
|
||||||
|
value,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
const { resolveClawdbotPackageRoot } = await import("../infra/clawdbot-root.js");
|
||||||
|
const { readConfigFileSnapshot } = await import("../config/config.js");
|
||||||
|
const { fetchNpmTagVersion } = await import("../infra/update-check.js");
|
||||||
|
vi.mocked(resolveClawdbotPackageRoot).mockResolvedValue(process.cwd());
|
||||||
|
vi.mocked(readConfigFileSnapshot).mockResolvedValue(baseSnapshot);
|
||||||
|
vi.mocked(fetchNpmTagVersion).mockResolvedValue({
|
||||||
|
tag: "latest",
|
||||||
|
version: "9999.0.0",
|
||||||
|
});
|
||||||
|
setTty(false);
|
||||||
|
setStdoutTty(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("exports updateCommand and registerUpdateCli", async () => {
|
it("exports updateCommand and registerUpdateCli", async () => {
|
||||||
const { updateCommand, registerUpdateCli } = await import("./update-cli.js");
|
const { updateCommand, registerUpdateCli } = await import("./update-cli.js");
|
||||||
expect(typeof updateCommand).toBe("function");
|
expect(typeof updateCommand).toBe("function");
|
||||||
@@ -63,6 +120,62 @@ describe("update-cli", () => {
|
|||||||
expect(defaultRuntime.log).toHaveBeenCalled();
|
expect(defaultRuntime.log).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("defaults to stable channel when unset", async () => {
|
||||||
|
const { runGatewayUpdate } = await import("../infra/update-runner.js");
|
||||||
|
const { updateCommand } = await import("./update-cli.js");
|
||||||
|
|
||||||
|
vi.mocked(runGatewayUpdate).mockResolvedValue({
|
||||||
|
status: "ok",
|
||||||
|
mode: "git",
|
||||||
|
steps: [],
|
||||||
|
durationMs: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
await updateCommand({});
|
||||||
|
|
||||||
|
const call = vi.mocked(runGatewayUpdate).mock.calls[0]?.[0];
|
||||||
|
expect(call?.tag).toBe("latest");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses stored beta channel when configured", async () => {
|
||||||
|
const { readConfigFileSnapshot } = await import("../config/config.js");
|
||||||
|
const { runGatewayUpdate } = await import("../infra/update-runner.js");
|
||||||
|
const { updateCommand } = await import("./update-cli.js");
|
||||||
|
|
||||||
|
vi.mocked(readConfigFileSnapshot).mockResolvedValue({
|
||||||
|
...baseSnapshot,
|
||||||
|
config: { update: { channel: "beta" } },
|
||||||
|
});
|
||||||
|
vi.mocked(runGatewayUpdate).mockResolvedValue({
|
||||||
|
status: "ok",
|
||||||
|
mode: "git",
|
||||||
|
steps: [],
|
||||||
|
durationMs: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
await updateCommand({});
|
||||||
|
|
||||||
|
const call = vi.mocked(runGatewayUpdate).mock.calls[0]?.[0];
|
||||||
|
expect(call?.tag).toBe("beta");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("honors --tag override", async () => {
|
||||||
|
const { runGatewayUpdate } = await import("../infra/update-runner.js");
|
||||||
|
const { updateCommand } = await import("./update-cli.js");
|
||||||
|
|
||||||
|
vi.mocked(runGatewayUpdate).mockResolvedValue({
|
||||||
|
status: "ok",
|
||||||
|
mode: "git",
|
||||||
|
steps: [],
|
||||||
|
durationMs: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
await updateCommand({ tag: "next" });
|
||||||
|
|
||||||
|
const call = vi.mocked(runGatewayUpdate).mock.calls[0]?.[0];
|
||||||
|
expect(call?.tag).toBe("next");
|
||||||
|
});
|
||||||
|
|
||||||
it("updateCommand outputs JSON when --json is set", async () => {
|
it("updateCommand outputs JSON when --json is set", async () => {
|
||||||
const { runGatewayUpdate } = await import("../infra/update-runner.js");
|
const { runGatewayUpdate } = await import("../infra/update-runner.js");
|
||||||
const { defaultRuntime } = await import("../runtime.js");
|
const { defaultRuntime } = await import("../runtime.js");
|
||||||
@@ -168,4 +281,67 @@ describe("update-cli", () => {
|
|||||||
expect(defaultRuntime.error).toHaveBeenCalledWith(expect.stringContaining("timeout"));
|
expect(defaultRuntime.error).toHaveBeenCalledWith(expect.stringContaining("timeout"));
|
||||||
expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
|
expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("persists update channel when --channel is set", async () => {
|
||||||
|
const { writeConfigFile } = await import("../config/config.js");
|
||||||
|
const { runGatewayUpdate } = await import("../infra/update-runner.js");
|
||||||
|
const { updateCommand } = await import("./update-cli.js");
|
||||||
|
|
||||||
|
const mockResult: UpdateRunResult = {
|
||||||
|
status: "ok",
|
||||||
|
mode: "git",
|
||||||
|
steps: [],
|
||||||
|
durationMs: 100,
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(runGatewayUpdate).mockResolvedValue(mockResult);
|
||||||
|
|
||||||
|
await updateCommand({ channel: "beta" });
|
||||||
|
|
||||||
|
expect(writeConfigFile).toHaveBeenCalled();
|
||||||
|
const call = vi.mocked(writeConfigFile).mock.calls[0]?.[0] as {
|
||||||
|
update?: { channel?: string };
|
||||||
|
};
|
||||||
|
expect(call?.update?.channel).toBe("beta");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires confirmation on downgrade when non-interactive", async () => {
|
||||||
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-update-"));
|
||||||
|
try {
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(tempDir, "package.json"),
|
||||||
|
JSON.stringify({ name: "clawdbot", version: "2.0.0" }),
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
|
||||||
|
const { resolveClawdbotPackageRoot } = await import("../infra/clawdbot-root.js");
|
||||||
|
const { fetchNpmTagVersion } = await import("../infra/update-check.js");
|
||||||
|
const { runGatewayUpdate } = await import("../infra/update-runner.js");
|
||||||
|
const { defaultRuntime } = await import("../runtime.js");
|
||||||
|
const { updateCommand } = await import("./update-cli.js");
|
||||||
|
|
||||||
|
vi.mocked(resolveClawdbotPackageRoot).mockResolvedValue(tempDir);
|
||||||
|
vi.mocked(fetchNpmTagVersion).mockResolvedValue({
|
||||||
|
tag: "latest",
|
||||||
|
version: "0.0.1",
|
||||||
|
});
|
||||||
|
vi.mocked(runGatewayUpdate).mockResolvedValue({
|
||||||
|
status: "ok",
|
||||||
|
mode: "npm",
|
||||||
|
steps: [],
|
||||||
|
durationMs: 100,
|
||||||
|
});
|
||||||
|
vi.mocked(defaultRuntime.error).mockClear();
|
||||||
|
vi.mocked(defaultRuntime.exit).mockClear();
|
||||||
|
|
||||||
|
await updateCommand({});
|
||||||
|
|
||||||
|
expect(defaultRuntime.error).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("Downgrade confirmation required."),
|
||||||
|
);
|
||||||
|
expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
|
||||||
|
} finally {
|
||||||
|
await fs.rm(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import { spinner } from "@clack/prompts";
|
import { confirm, isCancel, spinner } from "@clack/prompts";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
import type { Command } from "commander";
|
import type { Command } from "commander";
|
||||||
|
|
||||||
|
import { readConfigFileSnapshot, writeConfigFile } from "../config/config.js";
|
||||||
import { resolveClawdbotPackageRoot } from "../infra/clawdbot-root.js";
|
import { resolveClawdbotPackageRoot } from "../infra/clawdbot-root.js";
|
||||||
|
import { compareSemverStrings, fetchNpmTagVersion } from "../infra/update-check.js";
|
||||||
|
import { parseSemver } from "../infra/runtime-guard.js";
|
||||||
import {
|
import {
|
||||||
runGatewayUpdate,
|
runGatewayUpdate,
|
||||||
type UpdateRunResult,
|
type UpdateRunResult,
|
||||||
@@ -10,11 +15,14 @@ import {
|
|||||||
} from "../infra/update-runner.js";
|
} from "../infra/update-runner.js";
|
||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
import { formatDocsLink } from "../terminal/links.js";
|
import { formatDocsLink } from "../terminal/links.js";
|
||||||
|
import { stylePromptMessage } from "../terminal/prompt-style.js";
|
||||||
import { theme } from "../terminal/theme.js";
|
import { theme } from "../terminal/theme.js";
|
||||||
|
|
||||||
export type UpdateCommandOptions = {
|
export type UpdateCommandOptions = {
|
||||||
json?: boolean;
|
json?: boolean;
|
||||||
restart?: boolean;
|
restart?: boolean;
|
||||||
|
channel?: string;
|
||||||
|
tag?: string;
|
||||||
timeout?: string;
|
timeout?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -31,6 +39,61 @@ const STEP_LABELS: Record<string, string> = {
|
|||||||
"global update": "Updating via package manager",
|
"global update": "Updating via package manager",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type UpdateChannel = "stable" | "beta";
|
||||||
|
|
||||||
|
const DEFAULT_UPDATE_CHANNEL: UpdateChannel = "stable";
|
||||||
|
|
||||||
|
function normalizeChannel(value?: string | null): UpdateChannel | null {
|
||||||
|
if (!value) return null;
|
||||||
|
const normalized = value.trim().toLowerCase();
|
||||||
|
if (normalized === "stable" || normalized === "beta") return normalized;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTag(value?: string | null): string | null {
|
||||||
|
if (!value) return null;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
return trimmed.startsWith("clawdbot@") ? trimmed.slice("clawdbot@".length) : trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function channelToTag(channel: UpdateChannel): string {
|
||||||
|
return channel === "beta" ? "beta" : "latest";
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeVersionTag(tag: string): string | null {
|
||||||
|
const trimmed = tag.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
const cleaned = trimmed.startsWith("v") ? trimmed.slice(1) : trimmed;
|
||||||
|
return parseSemver(cleaned) ? cleaned : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readPackageVersion(root: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const raw = await fs.readFile(path.join(root, "package.json"), "utf-8");
|
||||||
|
const parsed = JSON.parse(raw) as { version?: string };
|
||||||
|
return typeof parsed.version === "string" ? parsed.version : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveTargetVersion(tag: string, timeoutMs?: number): Promise<string | null> {
|
||||||
|
const direct = normalizeVersionTag(tag);
|
||||||
|
if (direct) return direct;
|
||||||
|
const res = await fetchNpmTagVersion({ tag, timeoutMs });
|
||||||
|
return res.version ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isGitCheckout(root: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await fs.stat(path.join(root, ".git"));
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getStepLabel(step: UpdateStepInfo): string {
|
function getStepLabel(step: UpdateStepInfo): string {
|
||||||
return STEP_LABELS[step.name] ?? step.name;
|
return STEP_LABELS[step.name] ?? step.name;
|
||||||
}
|
}
|
||||||
@@ -164,13 +227,6 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const showProgress = !opts.json && process.stdout.isTTY;
|
|
||||||
|
|
||||||
if (!opts.json) {
|
|
||||||
defaultRuntime.log(theme.heading("Updating Clawdbot..."));
|
|
||||||
defaultRuntime.log("");
|
|
||||||
}
|
|
||||||
|
|
||||||
const root =
|
const root =
|
||||||
(await resolveClawdbotPackageRoot({
|
(await resolveClawdbotPackageRoot({
|
||||||
moduleUrl: import.meta.url,
|
moduleUrl: import.meta.url,
|
||||||
@@ -178,6 +234,91 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
|
|||||||
cwd: process.cwd(),
|
cwd: process.cwd(),
|
||||||
})) ?? process.cwd();
|
})) ?? process.cwd();
|
||||||
|
|
||||||
|
const configSnapshot = await readConfigFileSnapshot();
|
||||||
|
const storedChannel = configSnapshot.valid
|
||||||
|
? normalizeChannel(configSnapshot.config.update?.channel)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const requestedChannel = normalizeChannel(opts.channel);
|
||||||
|
if (opts.channel && !requestedChannel) {
|
||||||
|
defaultRuntime.error(`--channel must be "stable" or "beta" (got "${opts.channel}")`);
|
||||||
|
defaultRuntime.exit(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (opts.channel && !configSnapshot.valid) {
|
||||||
|
const issues = configSnapshot.issues.map((issue) => `- ${issue.path}: ${issue.message}`);
|
||||||
|
defaultRuntime.error(
|
||||||
|
["Config is invalid; cannot set update channel.", ...issues].join("\n"),
|
||||||
|
);
|
||||||
|
defaultRuntime.exit(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const channel = requestedChannel ?? storedChannel ?? DEFAULT_UPDATE_CHANNEL;
|
||||||
|
const tag = normalizeTag(opts.tag) ?? channelToTag(channel);
|
||||||
|
|
||||||
|
const gitCheckout = await isGitCheckout(root);
|
||||||
|
if (!gitCheckout) {
|
||||||
|
const currentVersion = await readPackageVersion(root);
|
||||||
|
const targetVersion = await resolveTargetVersion(tag, timeoutMs);
|
||||||
|
const cmp =
|
||||||
|
currentVersion && targetVersion ? compareSemverStrings(currentVersion, targetVersion) : null;
|
||||||
|
const needsConfirm =
|
||||||
|
currentVersion != null && (targetVersion == null || (cmp != null && cmp > 0));
|
||||||
|
|
||||||
|
if (needsConfirm) {
|
||||||
|
if (!process.stdin.isTTY || opts.json) {
|
||||||
|
defaultRuntime.error(
|
||||||
|
[
|
||||||
|
"Downgrade confirmation required.",
|
||||||
|
"Downgrading can break configuration. Re-run in a TTY to confirm.",
|
||||||
|
].join("\n"),
|
||||||
|
);
|
||||||
|
defaultRuntime.exit(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetLabel = targetVersion ?? `${tag} (unknown)`;
|
||||||
|
const message = `Downgrading from ${currentVersion} to ${targetLabel} can break configuration. Continue?`;
|
||||||
|
const ok = await confirm({
|
||||||
|
message: stylePromptMessage(message),
|
||||||
|
initialValue: false,
|
||||||
|
});
|
||||||
|
if (isCancel(ok) || ok === false) {
|
||||||
|
if (!opts.json) {
|
||||||
|
defaultRuntime.log(theme.muted("Update cancelled."));
|
||||||
|
}
|
||||||
|
defaultRuntime.exit(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if ((opts.channel || opts.tag) && !opts.json) {
|
||||||
|
defaultRuntime.log(
|
||||||
|
theme.muted("Note: --channel/--tag apply to npm installs only; git updates ignore them."),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestedChannel && configSnapshot.valid) {
|
||||||
|
const next = {
|
||||||
|
...configSnapshot.config,
|
||||||
|
update: {
|
||||||
|
...configSnapshot.config.update,
|
||||||
|
channel: requestedChannel,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await writeConfigFile(next);
|
||||||
|
if (!opts.json) {
|
||||||
|
defaultRuntime.log(theme.muted(`Update channel set to ${requestedChannel}.`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const showProgress = !opts.json && process.stdout.isTTY;
|
||||||
|
|
||||||
|
if (!opts.json) {
|
||||||
|
defaultRuntime.log(theme.heading("Updating Clawdbot..."));
|
||||||
|
defaultRuntime.log("");
|
||||||
|
}
|
||||||
|
|
||||||
const { progress, stop } = createUpdateProgress(showProgress);
|
const { progress, stop } = createUpdateProgress(showProgress);
|
||||||
|
|
||||||
const result = await runGatewayUpdate({
|
const result = await runGatewayUpdate({
|
||||||
@@ -185,6 +326,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
|
|||||||
argv1: process.argv[1],
|
argv1: process.argv[1],
|
||||||
timeoutMs,
|
timeoutMs,
|
||||||
progress,
|
progress,
|
||||||
|
tag,
|
||||||
});
|
});
|
||||||
|
|
||||||
stop();
|
stop();
|
||||||
@@ -270,6 +412,8 @@ export function registerUpdateCli(program: Command) {
|
|||||||
.description("Update Clawdbot to the latest version")
|
.description("Update Clawdbot to the latest version")
|
||||||
.option("--json", "Output result as JSON", false)
|
.option("--json", "Output result as JSON", false)
|
||||||
.option("--restart", "Restart the gateway daemon after a successful update", false)
|
.option("--restart", "Restart the gateway daemon after a successful update", false)
|
||||||
|
.option("--channel <stable|beta>", "Persist update channel (npm installs only)")
|
||||||
|
.option("--tag <dist-tag|version>", "Override npm dist-tag or version for this update")
|
||||||
.option("--timeout <seconds>", "Timeout for each update step in seconds (default: 1200)")
|
.option("--timeout <seconds>", "Timeout for each update step in seconds (default: 1200)")
|
||||||
.addHelpText(
|
.addHelpText(
|
||||||
"after",
|
"after",
|
||||||
@@ -277,6 +421,8 @@ export function registerUpdateCli(program: Command) {
|
|||||||
`
|
`
|
||||||
Examples:
|
Examples:
|
||||||
clawdbot update # Update a source checkout (git)
|
clawdbot update # Update a source checkout (git)
|
||||||
|
clawdbot update --channel beta # Switch to the beta channel (npm installs)
|
||||||
|
clawdbot update --tag beta # One-off update to a dist-tag or version
|
||||||
clawdbot update --restart # Update and restart the daemon
|
clawdbot update --restart # Update and restart the daemon
|
||||||
clawdbot update --json # Output result as JSON
|
clawdbot update --json # Output result as JSON
|
||||||
clawdbot --update # Shorthand for clawdbot update
|
clawdbot --update # Shorthand for clawdbot update
|
||||||
@@ -284,6 +430,7 @@ Examples:
|
|||||||
Notes:
|
Notes:
|
||||||
- For git installs: fetches, rebases, installs deps, builds, and runs doctor
|
- For git installs: fetches, rebases, installs deps, builds, and runs doctor
|
||||||
- For global installs: auto-updates via detected package manager when possible (see docs/install/updating.md)
|
- For global installs: auto-updates via detected package manager when possible (see docs/install/updating.md)
|
||||||
|
- Downgrades require confirmation (can break configuration)
|
||||||
- Skips update if the working directory has uncommitted changes
|
- Skips update if the working directory has uncommitted changes
|
||||||
|
|
||||||
${theme.muted("Docs:")} ${formatDocsLink("/cli/update", "docs.clawd.bot/cli/update")}`,
|
${theme.muted("Docs:")} ${formatDocsLink("/cli/update", "docs.clawd.bot/cli/update")}`,
|
||||||
@@ -293,6 +440,8 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/update", "docs.clawd.bot/cli/upda
|
|||||||
await updateCommand({
|
await updateCommand({
|
||||||
json: Boolean(opts.json),
|
json: Boolean(opts.json),
|
||||||
restart: Boolean(opts.restart),
|
restart: Boolean(opts.restart),
|
||||||
|
channel: opts.channel as string | undefined,
|
||||||
|
tag: opts.tag as string | undefined,
|
||||||
timeout: opts.timeout as string | undefined,
|
timeout: opts.timeout as string | undefined,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export type ChannelUiMetadata = {
|
|||||||
|
|
||||||
const GROUP_LABELS: Record<string, string> = {
|
const GROUP_LABELS: Record<string, string> = {
|
||||||
wizard: "Wizard",
|
wizard: "Wizard",
|
||||||
|
update: "Update",
|
||||||
logging: "Logging",
|
logging: "Logging",
|
||||||
gateway: "Gateway",
|
gateway: "Gateway",
|
||||||
agents: "Agents",
|
agents: "Agents",
|
||||||
@@ -71,6 +72,7 @@ const GROUP_LABELS: Record<string, string> = {
|
|||||||
|
|
||||||
const GROUP_ORDER: Record<string, number> = {
|
const GROUP_ORDER: Record<string, number> = {
|
||||||
wizard: 20,
|
wizard: 20,
|
||||||
|
update: 25,
|
||||||
gateway: 30,
|
gateway: 30,
|
||||||
agents: 40,
|
agents: 40,
|
||||||
tools: 50,
|
tools: 50,
|
||||||
@@ -95,6 +97,7 @@ const GROUP_ORDER: Record<string, number> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const FIELD_LABELS: Record<string, string> = {
|
const FIELD_LABELS: Record<string, string> = {
|
||||||
|
"update.channel": "Update Channel",
|
||||||
"gateway.remote.url": "Remote Gateway URL",
|
"gateway.remote.url": "Remote Gateway URL",
|
||||||
"gateway.remote.sshTarget": "Remote Gateway SSH Target",
|
"gateway.remote.sshTarget": "Remote Gateway SSH Target",
|
||||||
"gateway.remote.sshIdentity": "Remote Gateway SSH Identity",
|
"gateway.remote.sshIdentity": "Remote Gateway SSH Identity",
|
||||||
@@ -273,6 +276,7 @@ const FIELD_LABELS: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const FIELD_HELP: Record<string, string> = {
|
const FIELD_HELP: Record<string, string> = {
|
||||||
|
"update.channel": 'Update channel for npm installs ("stable" or "beta").',
|
||||||
"gateway.remote.url": "Remote Gateway WebSocket URL (ws:// or wss://).",
|
"gateway.remote.url": "Remote Gateway WebSocket URL (ws:// or wss://).",
|
||||||
"gateway.remote.sshTarget":
|
"gateway.remote.sshTarget":
|
||||||
"Remote gateway over SSH (tunnels the gateway port to localhost). Format: user@host or user@host:port.",
|
"Remote gateway over SSH (tunnels the gateway port to localhost). Format: user@host or user@host:port.",
|
||||||
|
|||||||
@@ -49,6 +49,10 @@ export type ClawdbotConfig = {
|
|||||||
lastRunMode?: "local" | "remote";
|
lastRunMode?: "local" | "remote";
|
||||||
};
|
};
|
||||||
logging?: LoggingConfig;
|
logging?: LoggingConfig;
|
||||||
|
update?: {
|
||||||
|
/** Update channel for npm installs ("stable" or "beta"). */
|
||||||
|
channel?: "stable" | "beta";
|
||||||
|
};
|
||||||
browser?: BrowserConfig;
|
browser?: BrowserConfig;
|
||||||
ui?: {
|
ui?: {
|
||||||
/** Accent color for Clawdbot UI chrome (hex). */
|
/** Accent color for Clawdbot UI chrome (hex). */
|
||||||
|
|||||||
@@ -61,6 +61,11 @@ export const ClawdbotSchema = z
|
|||||||
redactPatterns: z.array(z.string()).optional(),
|
redactPatterns: z.array(z.string()).optional(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
|
update: z
|
||||||
|
.object({
|
||||||
|
channel: z.union([z.literal("stable"), z.literal("beta")]).optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
browser: z
|
browser: z
|
||||||
.object({
|
.object({
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
|
|||||||
@@ -30,6 +30,12 @@ export type RegistryStatus = {
|
|||||||
error?: string;
|
error?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type NpmTagStatus = {
|
||||||
|
tag: string;
|
||||||
|
version: string | null;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type UpdateCheckResult = {
|
export type UpdateCheckResult = {
|
||||||
root: string | null;
|
root: string | null;
|
||||||
installKind: "git" | "package" | "unknown";
|
installKind: "git" | "package" | "unknown";
|
||||||
@@ -263,17 +269,32 @@ async function fetchWithTimeout(url: string, timeoutMs: number): Promise<Respons
|
|||||||
export async function fetchNpmLatestVersion(params?: {
|
export async function fetchNpmLatestVersion(params?: {
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
}): Promise<RegistryStatus> {
|
}): Promise<RegistryStatus> {
|
||||||
|
const res = await fetchNpmTagVersion({ tag: "latest", timeoutMs: params?.timeoutMs });
|
||||||
|
return {
|
||||||
|
latestVersion: res.version,
|
||||||
|
error: res.error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchNpmTagVersion(params: {
|
||||||
|
tag: string;
|
||||||
|
timeoutMs?: number;
|
||||||
|
}): Promise<NpmTagStatus> {
|
||||||
const timeoutMs = params?.timeoutMs ?? 3500;
|
const timeoutMs = params?.timeoutMs ?? 3500;
|
||||||
|
const tag = params.tag;
|
||||||
try {
|
try {
|
||||||
const res = await fetchWithTimeout("https://registry.npmjs.org/clawdbot/latest", timeoutMs);
|
const res = await fetchWithTimeout(
|
||||||
|
`https://registry.npmjs.org/clawdbot/${encodeURIComponent(tag)}`,
|
||||||
|
timeoutMs,
|
||||||
|
);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
return { latestVersion: null, error: `HTTP ${res.status}` };
|
return { tag, version: null, error: `HTTP ${res.status}` };
|
||||||
}
|
}
|
||||||
const json = (await res.json()) as { version?: unknown };
|
const json = (await res.json()) as { version?: unknown };
|
||||||
const latestVersion = typeof json?.version === "string" ? json.version : null;
|
const version = typeof json?.version === "string" ? json.version : null;
|
||||||
return { latestVersion };
|
return { tag, version };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return { latestVersion: null, error: String(err) };
|
return { tag, version: null, error: String(err) };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -159,6 +159,54 @@ describe("runGatewayUpdate", () => {
|
|||||||
expect(calls.some((call) => call === "npm i -g clawdbot@latest")).toBe(true);
|
expect(calls.some((call) => call === "npm i -g clawdbot@latest")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("updates global npm installs with tag override", async () => {
|
||||||
|
const nodeModules = path.join(tempDir, "node_modules");
|
||||||
|
const pkgRoot = path.join(nodeModules, "clawdbot");
|
||||||
|
await fs.mkdir(pkgRoot, { recursive: true });
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(pkgRoot, "package.json"),
|
||||||
|
JSON.stringify({ name: "clawdbot", version: "1.0.0" }),
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
|
||||||
|
const calls: string[] = [];
|
||||||
|
const runCommand = async (argv: string[]) => {
|
||||||
|
const key = argv.join(" ");
|
||||||
|
calls.push(key);
|
||||||
|
if (key === `git -C ${pkgRoot} rev-parse --show-toplevel`) {
|
||||||
|
return { stdout: "", stderr: "not a git repository", code: 128 };
|
||||||
|
}
|
||||||
|
if (key === "npm root -g") {
|
||||||
|
return { stdout: nodeModules, stderr: "", code: 0 };
|
||||||
|
}
|
||||||
|
if (key === "npm i -g clawdbot@beta") {
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(pkgRoot, "package.json"),
|
||||||
|
JSON.stringify({ name: "clawdbot", version: "2.0.0" }),
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
return { stdout: "ok", stderr: "", code: 0 };
|
||||||
|
}
|
||||||
|
if (key === "pnpm root -g") {
|
||||||
|
return { stdout: "", stderr: "", code: 1 };
|
||||||
|
}
|
||||||
|
return { stdout: "", stderr: "", code: 0 };
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await runGatewayUpdate({
|
||||||
|
cwd: pkgRoot,
|
||||||
|
runCommand: async (argv, _options) => runCommand(argv),
|
||||||
|
timeoutMs: 5000,
|
||||||
|
tag: "beta",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.status).toBe("ok");
|
||||||
|
expect(result.mode).toBe("npm");
|
||||||
|
expect(result.before?.version).toBe("1.0.0");
|
||||||
|
expect(result.after?.version).toBe("2.0.0");
|
||||||
|
expect(calls.some((call) => call === "npm i -g clawdbot@beta")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("updates global bun installs when detected", async () => {
|
it("updates global bun installs when detected", async () => {
|
||||||
const oldBunInstall = process.env.BUN_INSTALL;
|
const oldBunInstall = process.env.BUN_INSTALL;
|
||||||
const bunInstall = path.join(tempDir, "bun-install");
|
const bunInstall = path.join(tempDir, "bun-install");
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ export type UpdateStepProgress = {
|
|||||||
type UpdateRunnerOptions = {
|
type UpdateRunnerOptions = {
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
argv1?: string;
|
argv1?: string;
|
||||||
|
tag?: string;
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
runCommand?: CommandRunner;
|
runCommand?: CommandRunner;
|
||||||
progress?: UpdateStepProgress;
|
progress?: UpdateStepProgress;
|
||||||
@@ -267,10 +268,17 @@ function managerInstallArgs(manager: "pnpm" | "bun" | "npm") {
|
|||||||
return ["npm", "install"];
|
return ["npm", "install"];
|
||||||
}
|
}
|
||||||
|
|
||||||
function globalUpdateArgs(manager: "pnpm" | "npm" | "bun") {
|
function normalizeTag(tag?: string) {
|
||||||
if (manager === "pnpm") return ["pnpm", "add", "-g", "clawdbot@latest"];
|
const trimmed = tag?.trim();
|
||||||
if (manager === "bun") return ["bun", "add", "-g", "clawdbot@latest"];
|
if (!trimmed) return "latest";
|
||||||
return ["npm", "i", "-g", "clawdbot@latest"];
|
return trimmed.startsWith("clawdbot@") ? trimmed.slice("clawdbot@".length) : trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function globalUpdateArgs(manager: "pnpm" | "npm" | "bun", tag?: string) {
|
||||||
|
const spec = `clawdbot@${normalizeTag(tag)}`;
|
||||||
|
if (manager === "pnpm") return ["pnpm", "add", "-g", spec];
|
||||||
|
if (manager === "bun") return ["bun", "add", "-g", spec];
|
||||||
|
return ["npm", "i", "-g", spec];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Total number of visible steps in a successful git update flow
|
// Total number of visible steps in a successful git update flow
|
||||||
@@ -472,7 +480,7 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise<
|
|||||||
const updateStep = await runStep({
|
const updateStep = await runStep({
|
||||||
runCommand,
|
runCommand,
|
||||||
name: "global update",
|
name: "global update",
|
||||||
argv: globalUpdateArgs(globalManager),
|
argv: globalUpdateArgs(globalManager, opts.tag),
|
||||||
cwd: pkgRoot,
|
cwd: pkgRoot,
|
||||||
timeoutMs,
|
timeoutMs,
|
||||||
progress,
|
progress,
|
||||||
|
|||||||
Reference in New Issue
Block a user