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
|
||||
clawdbot update
|
||||
clawdbot update --channel beta
|
||||
clawdbot update --tag beta
|
||||
clawdbot update --restart
|
||||
clawdbot update --json
|
||||
clawdbot --update
|
||||
@@ -23,9 +25,13 @@ clawdbot --update
|
||||
## Options
|
||||
|
||||
- `--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.
|
||||
- `--timeout <seconds>`: per-step timeout (default is 1200s).
|
||||
|
||||
Note: downgrades require confirmation because older versions can break configuration.
|
||||
|
||||
## What it does (git checkout)
|
||||
|
||||
High-level:
|
||||
|
||||
@@ -50,6 +50,20 @@ pnpm add -g clawdbot@latest
|
||||
```
|
||||
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:
|
||||
|
||||
```bash
|
||||
@@ -75,7 +89,7 @@ It runs a safe-ish update flow:
|
||||
- Fetches + rebases against the configured upstream.
|
||||
- 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)
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -7,6 +10,25 @@ vi.mock("../infra/update-runner.js", () => ({
|
||||
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)
|
||||
vi.mock("../commands/doctor.js", () => ({
|
||||
doctorCommand: vi.fn(),
|
||||
@@ -26,6 +48,41 @@ vi.mock("../runtime.js", () => ({
|
||||
}));
|
||||
|
||||
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 () => {
|
||||
const { updateCommand, registerUpdateCli } = await import("./update-cli.js");
|
||||
expect(typeof updateCommand).toBe("function");
|
||||
@@ -63,6 +120,62 @@ describe("update-cli", () => {
|
||||
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 () => {
|
||||
const { runGatewayUpdate } = await import("../infra/update-runner.js");
|
||||
const { defaultRuntime } = await import("../runtime.js");
|
||||
@@ -168,4 +281,67 @@ describe("update-cli", () => {
|
||||
expect(defaultRuntime.error).toHaveBeenCalledWith(expect.stringContaining("timeout"));
|
||||
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 { readConfigFileSnapshot, writeConfigFile } from "../config/config.js";
|
||||
import { resolveClawdbotPackageRoot } from "../infra/clawdbot-root.js";
|
||||
import { compareSemverStrings, fetchNpmTagVersion } from "../infra/update-check.js";
|
||||
import { parseSemver } from "../infra/runtime-guard.js";
|
||||
import {
|
||||
runGatewayUpdate,
|
||||
type UpdateRunResult,
|
||||
@@ -10,11 +15,14 @@ import {
|
||||
} from "../infra/update-runner.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { formatDocsLink } from "../terminal/links.js";
|
||||
import { stylePromptMessage } from "../terminal/prompt-style.js";
|
||||
import { theme } from "../terminal/theme.js";
|
||||
|
||||
export type UpdateCommandOptions = {
|
||||
json?: boolean;
|
||||
restart?: boolean;
|
||||
channel?: string;
|
||||
tag?: string;
|
||||
timeout?: string;
|
||||
};
|
||||
|
||||
@@ -31,6 +39,61 @@ const STEP_LABELS: Record<string, string> = {
|
||||
"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 {
|
||||
return STEP_LABELS[step.name] ?? step.name;
|
||||
}
|
||||
@@ -164,13 +227,6 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
const showProgress = !opts.json && process.stdout.isTTY;
|
||||
|
||||
if (!opts.json) {
|
||||
defaultRuntime.log(theme.heading("Updating Clawdbot..."));
|
||||
defaultRuntime.log("");
|
||||
}
|
||||
|
||||
const root =
|
||||
(await resolveClawdbotPackageRoot({
|
||||
moduleUrl: import.meta.url,
|
||||
@@ -178,6 +234,91 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
|
||||
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 result = await runGatewayUpdate({
|
||||
@@ -185,6 +326,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
|
||||
argv1: process.argv[1],
|
||||
timeoutMs,
|
||||
progress,
|
||||
tag,
|
||||
});
|
||||
|
||||
stop();
|
||||
@@ -270,6 +412,8 @@ export function registerUpdateCli(program: Command) {
|
||||
.description("Update Clawdbot to the latest version")
|
||||
.option("--json", "Output result as JSON", 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)")
|
||||
.addHelpText(
|
||||
"after",
|
||||
@@ -277,6 +421,8 @@ export function registerUpdateCli(program: Command) {
|
||||
`
|
||||
Examples:
|
||||
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 --json # Output result as JSON
|
||||
clawdbot --update # Shorthand for clawdbot update
|
||||
@@ -284,6 +430,7 @@ Examples:
|
||||
Notes:
|
||||
- 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)
|
||||
- Downgrades require confirmation (can break configuration)
|
||||
- Skips update if the working directory has uncommitted changes
|
||||
|
||||
${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({
|
||||
json: Boolean(opts.json),
|
||||
restart: Boolean(opts.restart),
|
||||
channel: opts.channel as string | undefined,
|
||||
tag: opts.tag as string | undefined,
|
||||
timeout: opts.timeout as string | undefined,
|
||||
});
|
||||
} catch (err) {
|
||||
|
||||
@@ -46,6 +46,7 @@ export type ChannelUiMetadata = {
|
||||
|
||||
const GROUP_LABELS: Record<string, string> = {
|
||||
wizard: "Wizard",
|
||||
update: "Update",
|
||||
logging: "Logging",
|
||||
gateway: "Gateway",
|
||||
agents: "Agents",
|
||||
@@ -71,6 +72,7 @@ const GROUP_LABELS: Record<string, string> = {
|
||||
|
||||
const GROUP_ORDER: Record<string, number> = {
|
||||
wizard: 20,
|
||||
update: 25,
|
||||
gateway: 30,
|
||||
agents: 40,
|
||||
tools: 50,
|
||||
@@ -95,6 +97,7 @@ const GROUP_ORDER: Record<string, number> = {
|
||||
};
|
||||
|
||||
const FIELD_LABELS: Record<string, string> = {
|
||||
"update.channel": "Update Channel",
|
||||
"gateway.remote.url": "Remote Gateway URL",
|
||||
"gateway.remote.sshTarget": "Remote Gateway SSH Target",
|
||||
"gateway.remote.sshIdentity": "Remote Gateway SSH Identity",
|
||||
@@ -273,6 +276,7 @@ const FIELD_LABELS: 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.sshTarget":
|
||||
"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";
|
||||
};
|
||||
logging?: LoggingConfig;
|
||||
update?: {
|
||||
/** Update channel for npm installs ("stable" or "beta"). */
|
||||
channel?: "stable" | "beta";
|
||||
};
|
||||
browser?: BrowserConfig;
|
||||
ui?: {
|
||||
/** Accent color for Clawdbot UI chrome (hex). */
|
||||
|
||||
@@ -61,6 +61,11 @@ export const ClawdbotSchema = z
|
||||
redactPatterns: z.array(z.string()).optional(),
|
||||
})
|
||||
.optional(),
|
||||
update: z
|
||||
.object({
|
||||
channel: z.union([z.literal("stable"), z.literal("beta")]).optional(),
|
||||
})
|
||||
.optional(),
|
||||
browser: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
|
||||
@@ -30,6 +30,12 @@ export type RegistryStatus = {
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type NpmTagStatus = {
|
||||
tag: string;
|
||||
version: string | null;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type UpdateCheckResult = {
|
||||
root: string | null;
|
||||
installKind: "git" | "package" | "unknown";
|
||||
@@ -263,17 +269,32 @@ async function fetchWithTimeout(url: string, timeoutMs: number): Promise<Respons
|
||||
export async function fetchNpmLatestVersion(params?: {
|
||||
timeoutMs?: number;
|
||||
}): 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 tag = params.tag;
|
||||
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) {
|
||||
return { latestVersion: null, error: `HTTP ${res.status}` };
|
||||
return { tag, version: null, error: `HTTP ${res.status}` };
|
||||
}
|
||||
const json = (await res.json()) as { version?: unknown };
|
||||
const latestVersion = typeof json?.version === "string" ? json.version : null;
|
||||
return { latestVersion };
|
||||
const version = typeof json?.version === "string" ? json.version : null;
|
||||
return { tag, version };
|
||||
} 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);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const oldBunInstall = process.env.BUN_INSTALL;
|
||||
const bunInstall = path.join(tempDir, "bun-install");
|
||||
|
||||
@@ -52,6 +52,7 @@ export type UpdateStepProgress = {
|
||||
type UpdateRunnerOptions = {
|
||||
cwd?: string;
|
||||
argv1?: string;
|
||||
tag?: string;
|
||||
timeoutMs?: number;
|
||||
runCommand?: CommandRunner;
|
||||
progress?: UpdateStepProgress;
|
||||
@@ -267,10 +268,17 @@ function managerInstallArgs(manager: "pnpm" | "bun" | "npm") {
|
||||
return ["npm", "install"];
|
||||
}
|
||||
|
||||
function globalUpdateArgs(manager: "pnpm" | "npm" | "bun") {
|
||||
if (manager === "pnpm") return ["pnpm", "add", "-g", "clawdbot@latest"];
|
||||
if (manager === "bun") return ["bun", "add", "-g", "clawdbot@latest"];
|
||||
return ["npm", "i", "-g", "clawdbot@latest"];
|
||||
function normalizeTag(tag?: string) {
|
||||
const trimmed = tag?.trim();
|
||||
if (!trimmed) return "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
|
||||
@@ -472,7 +480,7 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise<
|
||||
const updateStep = await runStep({
|
||||
runCommand,
|
||||
name: "global update",
|
||||
argv: globalUpdateArgs(globalManager),
|
||||
argv: globalUpdateArgs(globalManager, opts.tag),
|
||||
cwd: pkgRoot,
|
||||
timeoutMs,
|
||||
progress,
|
||||
|
||||
Reference in New Issue
Block a user