feat: add dev update channel

This commit is contained in:
Peter Steinberger
2026-01-20 13:33:31 +00:00
parent cc24ede586
commit 4ebf55f1db
14 changed files with 378 additions and 123 deletions

View File

@@ -71,6 +71,15 @@ clawdbot agent --message "Ship checklist" --thinking high
Upgrading? [Updating guide](https://docs.clawd.bot/install/updating) (and run `clawdbot doctor`). Upgrading? [Updating guide](https://docs.clawd.bot/install/updating) (and run `clawdbot doctor`).
## Development channels
- **stable**: tagged releases (`vYYYY.M.D` or `vYYYY.M.D-<patch>`), npm dist-tag `latest`.
- **beta**: prerelease tags (`vYYYY.M.D-beta.N`), npm dist-tag `beta` (macOS app may be missing).
- **dev**: moving head of `main`, npm dist-tag `dev` (when published).
Switch channels (git + npm): `clawdbot update --channel stable|beta|dev`.
Details: [Development channels](https://docs.clawd.bot/install/development-channels).
## From source (development) ## From source (development)
Prefer `pnpm` for builds from source. Bun is optional for running TypeScript directly. Prefer `pnpm` for builds from source. Bun is optional for running TypeScript directly.

View File

@@ -16,6 +16,7 @@ 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 --channel beta
clawdbot update --channel dev
clawdbot update --tag beta clawdbot update --tag beta
clawdbot update --restart clawdbot update --restart
clawdbot update --json clawdbot update --json
@@ -25,7 +26,7 @@ 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). - `--channel <stable|beta|dev>`: set the update channel (git + npm; persisted in config).
- `--tag <dist-tag|version>`: override the npm dist-tag or version for this update only. - `--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).
@@ -34,13 +35,20 @@ Note: downgrades require confirmation because older versions can break configura
## What it does (git checkout) ## What it does (git checkout)
Channels:
- `stable`: checkout the latest non-beta tag, then build + doctor.
- `beta`: checkout the latest `-beta` tag, then build + doctor.
- `dev`: checkout `main`, then fetch + rebase.
High-level: High-level:
1. Requires a clean worktree (no uncommitted changes). 1. Requires a clean worktree (no uncommitted changes).
2. Fetches and rebases against `@{upstream}`. 2. Switches to the selected channel (tag or branch).
3. Installs deps (pnpm preferred; npm fallback). 3. Fetches and rebases against `@{upstream}` (dev only).
4. Builds + builds the Control UI. 4. Installs deps (pnpm preferred; npm fallback).
5. Runs `clawdbot doctor` as the final “safe update” check. 5. Builds + builds the Control UI.
6. Runs `clawdbot doctor` as the final “safe update” check.
## `--update` shorthand ## `--update` shorthand
@@ -49,5 +57,6 @@ High-level:
## See also ## See also
- `clawdbot doctor` (offers to run update first on git checkouts) - `clawdbot doctor` (offers to run update first on git checkouts)
- [Development channels](/install/development-channels)
- [Updating](/install/updating) - [Updating](/install/updating)
- [CLI reference](/cli) - [CLI reference](/cli)

View File

@@ -793,6 +793,7 @@
"install/index", "install/index",
"install/installer", "install/installer",
"install/updating", "install/updating",
"install/development-channels",
"install/uninstall", "install/uninstall",
"install/ansible", "install/ansible",
"install/nix", "install/nix",

View File

@@ -0,0 +1,56 @@
---
summary: "Stable, beta, and dev channels: semantics, switching, and tagging"
read_when:
- You want to switch between stable/beta/dev
- You are tagging or publishing prereleases
---
# Development channels
Clawdbot ships three update channels:
- **stable**: tagged releases (`vYYYY.M.D` or `vYYYY.M.D-<patch>`). npm dist-tag: `latest`.
- **beta**: prerelease tags (`vYYYY.M.D-beta.N`). npm dist-tag: `beta`.
- **dev**: moving head of `main` (git). npm dist-tag: `dev` (when published).
## Switching channels
Git checkout:
```bash
clawdbot update --channel stable
clawdbot update --channel beta
clawdbot update --channel dev
```
- `stable`/`beta` check out the latest matching tag.
- `dev` switches to `main` and rebases on the upstream.
npm/pnpm global install:
```bash
clawdbot update --channel stable
clawdbot update --channel beta
clawdbot update --channel dev
```
This updates via the corresponding npm dist-tag (`latest`, `beta`, `dev`).
Tip: if you want stable + dev in parallel, keep two clones and point your gateway at the stable one.
## Tagging best practices
- Stable: tag each release (`vYYYY.M.D` or `vYYYY.M.D-<patch>`).
- Beta: use `vYYYY.M.D-beta.N` (increment `N`).
- Keep tags immutable: never move or reuse a tag.
- Publish dist-tags alongside git tags:
- `latest` → stable
- `beta` → prerelease
- `dev` → main snapshot (optional)
## macOS app availability
Beta and dev builds may **not** include a macOS app release. Thats OK:
- The git tag and npm dist-tag can still be published.
- Call out “no macOS build for this beta” in release notes or changelog.

View File

@@ -50,20 +50,18 @@ 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: To switch update channels (git + npm installs):
```bash ```bash
clawdbot update --channel beta clawdbot update --channel beta
``` clawdbot update --channel dev
Switch back to stable later:
```bash
clawdbot update --channel stable clawdbot update --channel stable
``` ```
Use `--tag <dist-tag|version>` for a one-off install tag/version. Use `--tag <dist-tag|version>` for a one-off install tag/version.
See [Development channels](/install/development-channels) for channel semantics and release notes.
Note: on npm installs, the gateway logs an update hint on startup (checks the current channel tag). Disable via `update.checkOnStart: false`. Note: on npm installs, the gateway logs an update hint on startup (checks the current channel tag). Disable via `update.checkOnStart: false`.
Then: Then:
@@ -88,7 +86,8 @@ clawdbot update --restart
It runs a safe-ish update flow: It runs a safe-ish update flow:
- Requires a clean worktree. - Requires a clean worktree.
- Fetches + rebases against the configured upstream. - Switches to the selected channel (tag or branch).
- Fetches + rebases against the configured upstream (dev channel).
- 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 try to update via your package manager. If it cant detect the install, 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 cant detect the install, use “Update (global install)” instead.

View File

@@ -120,7 +120,7 @@ describe("update-cli", () => {
expect(defaultRuntime.log).toHaveBeenCalled(); expect(defaultRuntime.log).toHaveBeenCalled();
}); });
it("defaults to stable channel when unset", async () => { it("defaults to dev channel for git installs when unset", async () => {
const { runGatewayUpdate } = await import("../infra/update-runner.js"); const { runGatewayUpdate } = await import("../infra/update-runner.js");
const { updateCommand } = await import("./update-cli.js"); const { updateCommand } = await import("./update-cli.js");
@@ -134,7 +134,38 @@ describe("update-cli", () => {
await updateCommand({}); await updateCommand({});
const call = vi.mocked(runGatewayUpdate).mock.calls[0]?.[0]; const call = vi.mocked(runGatewayUpdate).mock.calls[0]?.[0];
expect(call?.tag).toBe("latest"); expect(call?.channel).toBe("dev");
});
it("defaults to stable channel for package installs when unset", 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: "1.0.0" }),
"utf-8",
);
const { resolveClawdbotPackageRoot } = await import("../infra/clawdbot-root.js");
const { runGatewayUpdate } = await import("../infra/update-runner.js");
const { updateCommand } = await import("./update-cli.js");
vi.mocked(resolveClawdbotPackageRoot).mockResolvedValue(tempDir);
vi.mocked(runGatewayUpdate).mockResolvedValue({
status: "ok",
mode: "npm",
steps: [],
durationMs: 100,
});
await updateCommand({});
const call = vi.mocked(runGatewayUpdate).mock.calls[0]?.[0];
expect(call?.channel).toBe("stable");
expect(call?.tag).toBe("latest");
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
}); });
it("uses stored beta channel when configured", async () => { it("uses stored beta channel when configured", async () => {
@@ -156,24 +187,37 @@ describe("update-cli", () => {
await updateCommand({}); await updateCommand({});
const call = vi.mocked(runGatewayUpdate).mock.calls[0]?.[0]; const call = vi.mocked(runGatewayUpdate).mock.calls[0]?.[0];
expect(call?.tag).toBe("beta"); expect(call?.channel).toBe("beta");
}); });
it("honors --tag override", async () => { it("honors --tag override", async () => {
const { runGatewayUpdate } = await import("../infra/update-runner.js"); const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-update-"));
const { updateCommand } = await import("./update-cli.js"); try {
await fs.writeFile(
path.join(tempDir, "package.json"),
JSON.stringify({ name: "clawdbot", version: "1.0.0" }),
"utf-8",
);
vi.mocked(runGatewayUpdate).mockResolvedValue({ const { resolveClawdbotPackageRoot } = await import("../infra/clawdbot-root.js");
status: "ok", const { runGatewayUpdate } = await import("../infra/update-runner.js");
mode: "git", const { updateCommand } = await import("./update-cli.js");
steps: [],
durationMs: 100,
});
await updateCommand({ tag: "next" }); vi.mocked(resolveClawdbotPackageRoot).mockResolvedValue(tempDir);
vi.mocked(runGatewayUpdate).mockResolvedValue({
status: "ok",
mode: "npm",
steps: [],
durationMs: 100,
});
const call = vi.mocked(runGatewayUpdate).mock.calls[0]?.[0]; await updateCommand({ tag: "next" });
expect(call?.tag).toBe("next");
const call = vi.mocked(runGatewayUpdate).mock.calls[0]?.[0];
expect(call?.tag).toBe("next");
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
}); });
it("updateCommand outputs JSON when --json is set", async () => { it("updateCommand outputs JSON when --json is set", async () => {

View File

@@ -13,6 +13,13 @@ import {
type UpdateStepInfo, type UpdateStepInfo,
type UpdateStepProgress, type UpdateStepProgress,
} from "../infra/update-runner.js"; } from "../infra/update-runner.js";
import {
channelToNpmTag,
DEFAULT_GIT_CHANNEL,
DEFAULT_PACKAGE_CHANNEL,
normalizeUpdateChannel,
type UpdateChannel,
} from "../infra/update-channels.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";
@@ -40,9 +47,6 @@ 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";
const UPDATE_QUIPS = [ const UPDATE_QUIPS = [
"Leveled up! New skills unlocked. You're welcome.", "Leveled up! New skills unlocked. You're welcome.",
"Fresh code, same lobster. Miss me?", "Fresh code, same lobster. Miss me?",
@@ -66,13 +70,6 @@ const UPDATE_QUIPS = [
"Version bump! Same chaos energy, fewer crashes (probably).", "Version bump! Same chaos energy, fewer crashes (probably).",
]; ];
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 { function normalizeTag(value?: string | null): string | null {
if (!value) return null; if (!value) return null;
const trimmed = value.trim(); const trimmed = value.trim();
@@ -80,10 +77,6 @@ function normalizeTag(value?: string | null): string | null {
return trimmed.startsWith("clawdbot@") ? trimmed.slice("clawdbot@".length) : trimmed; return trimmed.startsWith("clawdbot@") ? trimmed.slice("clawdbot@".length) : trimmed;
} }
function channelToTag(channel: UpdateChannel): string {
return channel === "beta" ? "beta" : "latest";
}
function pickUpdateQuip(): string { function pickUpdateQuip(): string {
return UPDATE_QUIPS[Math.floor(Math.random() * UPDATE_QUIPS.length)] ?? "Update complete."; return UPDATE_QUIPS[Math.floor(Math.random() * UPDATE_QUIPS.length)] ?? "Update complete.";
} }
@@ -263,12 +256,12 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
const configSnapshot = await readConfigFileSnapshot(); const configSnapshot = await readConfigFileSnapshot();
const storedChannel = configSnapshot.valid const storedChannel = configSnapshot.valid
? normalizeChannel(configSnapshot.config.update?.channel) ? normalizeUpdateChannel(configSnapshot.config.update?.channel)
: null; : null;
const requestedChannel = normalizeChannel(opts.channel); const requestedChannel = normalizeUpdateChannel(opts.channel);
if (opts.channel && !requestedChannel) { if (opts.channel && !requestedChannel) {
defaultRuntime.error(`--channel must be "stable" or "beta" (got "${opts.channel}")`); defaultRuntime.error(`--channel must be "stable", "beta", or "dev" (got "${opts.channel}")`);
defaultRuntime.exit(1); defaultRuntime.exit(1);
return; return;
} }
@@ -279,10 +272,10 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
return; return;
} }
const channel = requestedChannel ?? storedChannel ?? DEFAULT_UPDATE_CHANNEL;
const tag = normalizeTag(opts.tag) ?? channelToTag(channel);
const gitCheckout = await isGitCheckout(root); const gitCheckout = await isGitCheckout(root);
const defaultChannel = gitCheckout ? DEFAULT_GIT_CHANNEL : DEFAULT_PACKAGE_CHANNEL;
const channel = requestedChannel ?? storedChannel ?? defaultChannel;
const tag = normalizeTag(opts.tag) ?? channelToNpmTag(channel);
if (!gitCheckout) { if (!gitCheckout) {
const currentVersion = await readPackageVersion(root); const currentVersion = await readPackageVersion(root);
const targetVersion = await resolveTargetVersion(tag, timeoutMs); const targetVersion = await resolveTargetVersion(tag, timeoutMs);
@@ -317,9 +310,9 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
return; return;
} }
} }
} else if ((opts.channel || opts.tag) && !opts.json) { } else if (opts.tag && !opts.json) {
defaultRuntime.log( defaultRuntime.log(
theme.muted("Note: --channel/--tag apply to npm installs only; git updates ignore them."), theme.muted("Note: --tag applies to npm installs only; git updates ignore it."),
); );
} }
@@ -351,6 +344,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
argv1: process.argv[1], argv1: process.argv[1],
timeoutMs, timeoutMs,
progress, progress,
channel,
tag, tag,
}); });
@@ -445,7 +439,7 @@ 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("--channel <stable|beta|dev>", "Persist update channel (git + npm)")
.option("--tag <dist-tag|version>", "Override npm dist-tag or version for this update") .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(
@@ -454,7 +448,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 --channel beta # Switch to beta channel (git + npm)
clawdbot update --channel dev # Switch to dev channel (git + npm)
clawdbot update --tag beta # One-off update to a dist-tag or version 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

View File

@@ -309,7 +309,7 @@ const FIELD_LABELS: Record<string, string> = {
const FIELD_HELP: Record<string, string> = { const FIELD_HELP: Record<string, string> = {
"meta.lastTouchedVersion": "Auto-set when Clawdbot writes the config.", "meta.lastTouchedVersion": "Auto-set when Clawdbot writes the config.",
"meta.lastTouchedAt": "ISO timestamp of the last config write (auto-set).", "meta.lastTouchedAt": "ISO timestamp of the last config write (auto-set).",
"update.channel": 'Update channel for npm installs ("stable" or "beta").', "update.channel": 'Update channel for git + npm installs ("stable", "beta", or "dev").',
"update.checkOnStart": "Check for npm updates when the gateway starts (default: true).", "update.checkOnStart": "Check for npm updates when the gateway starts (default: true).",
"gateway.remote.url": "Remote Gateway WebSocket URL (ws:// or wss://).", "gateway.remote.url": "Remote Gateway WebSocket URL (ws:// or wss://).",
"gateway.remote.tlsFingerprint": "gateway.remote.tlsFingerprint":

View File

@@ -55,8 +55,8 @@ export type ClawdbotConfig = {
}; };
logging?: LoggingConfig; logging?: LoggingConfig;
update?: { update?: {
/** Update channel for npm installs ("stable" or "beta"). */ /** Update channel for git + npm installs ("stable", "beta", or "dev"). */
channel?: "stable" | "beta"; channel?: "stable" | "beta" | "dev";
/** Check for updates on gateway start (npm installs only). */ /** Check for updates on gateway start (npm installs only). */
checkOnStart?: boolean; checkOnStart?: boolean;
}; };

View File

@@ -73,7 +73,7 @@ export const ClawdbotSchema = z
.optional(), .optional(),
update: z update: z
.object({ .object({
channel: z.union([z.literal("stable"), z.literal("beta")]).optional(), channel: z.union([z.literal("stable"), z.literal("beta"), z.literal("dev")]).optional(),
checkOnStart: z.boolean().optional(), checkOnStart: z.boolean().optional(),
}) })
.strict() .strict()

View File

@@ -0,0 +1,26 @@
export type UpdateChannel = "stable" | "beta" | "dev";
export const DEFAULT_PACKAGE_CHANNEL: UpdateChannel = "stable";
export const DEFAULT_GIT_CHANNEL: UpdateChannel = "dev";
export const DEV_BRANCH = "main";
export function normalizeUpdateChannel(value?: string | null): UpdateChannel | null {
if (!value) return null;
const normalized = value.trim().toLowerCase();
if (normalized === "stable" || normalized === "beta" || normalized === "dev") return normalized;
return null;
}
export function channelToNpmTag(channel: UpdateChannel): string {
if (channel === "beta") return "beta";
if (channel === "dev") return "dev";
return "latest";
}
export function isBetaTag(tag: string): boolean {
return tag.toLowerCase().includes("-beta");
}
export function isStableTag(tag: string): boolean {
return !isBetaTag(tag);
}

View File

@@ -43,6 +43,7 @@ describe("runGatewayUpdate", () => {
const { runner, calls } = createRunner({ const { runner, calls } = createRunner({
[`git -C ${tempDir} rev-parse --show-toplevel`]: { stdout: tempDir }, [`git -C ${tempDir} rev-parse --show-toplevel`]: { stdout: tempDir },
[`git -C ${tempDir} rev-parse HEAD`]: { stdout: "abc123" }, [`git -C ${tempDir} rev-parse HEAD`]: { stdout: "abc123" },
[`git -C ${tempDir} rev-parse --abbrev-ref HEAD`]: { stdout: "main" },
[`git -C ${tempDir} status --porcelain`]: { stdout: " M README.md" }, [`git -C ${tempDir} status --porcelain`]: { stdout: " M README.md" },
}); });
@@ -67,11 +68,12 @@ describe("runGatewayUpdate", () => {
const { runner, calls } = createRunner({ const { runner, calls } = createRunner({
[`git -C ${tempDir} rev-parse --show-toplevel`]: { stdout: tempDir }, [`git -C ${tempDir} rev-parse --show-toplevel`]: { stdout: tempDir },
[`git -C ${tempDir} rev-parse HEAD`]: { stdout: "abc123" }, [`git -C ${tempDir} rev-parse HEAD`]: { stdout: "abc123" },
[`git -C ${tempDir} rev-parse --abbrev-ref HEAD`]: { stdout: "main" },
[`git -C ${tempDir} status --porcelain`]: { stdout: "" }, [`git -C ${tempDir} status --porcelain`]: { stdout: "" },
[`git -C ${tempDir} rev-parse --abbrev-ref --symbolic-full-name @{upstream}`]: { [`git -C ${tempDir} rev-parse --abbrev-ref --symbolic-full-name @{upstream}`]: {
stdout: "origin/main", stdout: "origin/main",
}, },
[`git -C ${tempDir} fetch --all --prune`]: { stdout: "" }, [`git -C ${tempDir} fetch --all --prune --tags`]: { stdout: "" },
[`git -C ${tempDir} rebase @{upstream}`]: { code: 1, stderr: "conflict" }, [`git -C ${tempDir} rebase @{upstream}`]: { code: 1, stderr: "conflict" },
[`git -C ${tempDir} rebase --abort`]: { stdout: "" }, [`git -C ${tempDir} rebase --abort`]: { stdout: "" },
}); });

View File

@@ -3,6 +3,7 @@ import fs from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { type CommandOptions, runCommandWithTimeout } from "../process/exec.js"; import { type CommandOptions, runCommandWithTimeout } from "../process/exec.js";
import { DEV_BRANCH, isBetaTag, isStableTag, type UpdateChannel } from "./update-channels.js";
import { trimLogTail } from "./restart-sentinel.js"; import { trimLogTail } from "./restart-sentinel.js";
export type UpdateStepResult = { export type UpdateStepResult = {
@@ -53,6 +54,7 @@ type UpdateRunnerOptions = {
cwd?: string; cwd?: string;
argv1?: string; argv1?: string;
tag?: string; tag?: string;
channel?: UpdateChannel;
timeoutMs?: number; timeoutMs?: number;
runCommand?: CommandRunner; runCommand?: CommandRunner;
progress?: UpdateStepProgress; progress?: UpdateStepProgress;
@@ -60,7 +62,6 @@ type UpdateRunnerOptions = {
const DEFAULT_TIMEOUT_MS = 20 * 60_000; const DEFAULT_TIMEOUT_MS = 20 * 60_000;
const MAX_LOG_CHARS = 8000; const MAX_LOG_CHARS = 8000;
const START_DIRS = ["cwd", "argv1", "process"]; const START_DIRS = ["cwd", "argv1", "process"];
function normalizeDir(value?: string | null) { function normalizeDir(value?: string | null) {
@@ -106,6 +107,46 @@ async function readPackageVersion(root: string) {
} }
} }
async function readBranchName(
runCommand: CommandRunner,
root: string,
timeoutMs: number,
): Promise<string | null> {
const res = await runCommand(["git", "-C", root, "rev-parse", "--abbrev-ref", "HEAD"], {
timeoutMs,
}).catch(() => null);
if (!res || res.code !== 0) return null;
const branch = res.stdout.trim();
return branch || null;
}
async function listGitTags(
runCommand: CommandRunner,
root: string,
timeoutMs: number,
pattern = "v*",
): Promise<string[]> {
const res = await runCommand(["git", "-C", root, "tag", "--list", pattern, "--sort=-v:refname"], {
timeoutMs,
}).catch(() => null);
if (!res || res.code !== 0) return [];
return res.stdout
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
}
async function resolveChannelTag(
runCommand: CommandRunner,
root: string,
timeoutMs: number,
channel: Exclude<UpdateChannel, "dev">,
): Promise<string | null> {
const tags = await listGitTags(runCommand, root, timeoutMs);
const predicate = channel === "beta" ? isBetaTag : isStableTag;
return tags.find((tag) => predicate(tag)) ?? null;
}
async function resolveGitRoot( async function resolveGitRoot(
runCommand: CommandRunner, runCommand: CommandRunner,
candidates: string[], candidates: string[],
@@ -281,9 +322,6 @@ function globalUpdateArgs(manager: "pnpm" | "npm" | "bun", tag?: string) {
return ["npm", "i", "-g", spec]; return ["npm", "i", "-g", spec];
} }
// Total number of visible steps in a successful git update flow
const GIT_UPDATE_TOTAL_STEPS = 9;
export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise<UpdateRunResult> { export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise<UpdateRunResult> {
const startedAt = Date.now(); const startedAt = Date.now();
const runCommand = const runCommand =
@@ -298,6 +336,7 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise<
const candidates = buildStartDirs(opts); const candidates = buildStartDirs(opts);
let stepIndex = 0; let stepIndex = 0;
let gitTotalSteps = 0;
const step = ( const step = (
name: string, name: string,
@@ -316,7 +355,7 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise<
env, env,
progress, progress,
stepIndex: currentIndex, stepIndex: currentIndex,
totalSteps: GIT_UPDATE_TOTAL_STEPS, totalSteps: gitTotalSteps,
}; };
}; };
@@ -346,6 +385,10 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise<
}); });
const beforeSha = beforeShaResult.stdout.trim() || null; const beforeSha = beforeShaResult.stdout.trim() || null;
const beforeVersion = await readPackageVersion(gitRoot); const beforeVersion = await readPackageVersion(gitRoot);
const channel: UpdateChannel = opts.channel ?? "dev";
const branch = channel === "dev" ? await readBranchName(runCommand, gitRoot, timeoutMs) : null;
const needsCheckoutMain = channel === "dev" && branch !== DEV_BRANCH;
gitTotalSteps = channel === "dev" ? (needsCheckoutMain ? 10 : 9) : 8;
const statusCheck = await runStep( const statusCheck = await runStep(
step("clean check", ["git", "-C", gitRoot, "status", "--porcelain"], gitRoot), step("clean check", ["git", "-C", gitRoot, "status", "--porcelain"], gitRoot),
@@ -365,58 +408,135 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise<
}; };
} }
const upstreamStep = await runStep( if (channel === "dev") {
step( if (needsCheckoutMain) {
"upstream check", const checkoutStep = await runStep(
["git", "-C", gitRoot, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], step(
gitRoot, `git checkout ${DEV_BRANCH}`,
), ["git", "-C", gitRoot, "checkout", DEV_BRANCH],
); gitRoot,
steps.push(upstreamStep); ),
if (upstreamStep.exitCode !== 0) { );
return { steps.push(checkoutStep);
status: "skipped", if (checkoutStep.exitCode !== 0) {
mode: "git", return {
root: gitRoot, status: "error",
reason: "no-upstream", mode: "git",
before: { sha: beforeSha, version: beforeVersion }, root: gitRoot,
steps, reason: "checkout-failed",
durationMs: Date.now() - startedAt, before: { sha: beforeSha, version: beforeVersion },
}; steps,
} durationMs: Date.now() - startedAt,
};
}
}
const fetchStep = await runStep( const upstreamStep = await runStep(
step("git fetch", ["git", "-C", gitRoot, "fetch", "--all", "--prune"], gitRoot), step(
); "upstream check",
steps.push(fetchStep); [
"git",
"-C",
gitRoot,
"rev-parse",
"--abbrev-ref",
"--symbolic-full-name",
"@{upstream}",
],
gitRoot,
),
);
steps.push(upstreamStep);
if (upstreamStep.exitCode !== 0) {
return {
status: "skipped",
mode: "git",
root: gitRoot,
reason: "no-upstream",
before: { sha: beforeSha, version: beforeVersion },
steps,
durationMs: Date.now() - startedAt,
};
}
const rebaseStep = await runStep( const fetchStep = await runStep(
step("git rebase", ["git", "-C", gitRoot, "rebase", "@{upstream}"], gitRoot), step("git fetch", ["git", "-C", gitRoot, "fetch", "--all", "--prune", "--tags"], gitRoot),
); );
steps.push(rebaseStep); steps.push(fetchStep);
if (rebaseStep.exitCode !== 0) {
const abortResult = await runCommand(["git", "-C", gitRoot, "rebase", "--abort"], { const rebaseStep = await runStep(
cwd: gitRoot, step("git rebase", ["git", "-C", gitRoot, "rebase", "@{upstream}"], gitRoot),
timeoutMs, );
}); steps.push(rebaseStep);
steps.push({ if (rebaseStep.exitCode !== 0) {
name: "git rebase --abort", const abortResult = await runCommand(["git", "-C", gitRoot, "rebase", "--abort"], {
command: "git rebase --abort", cwd: gitRoot,
cwd: gitRoot, timeoutMs,
durationMs: 0, });
exitCode: abortResult.code, steps.push({
stdoutTail: trimLogTail(abortResult.stdout, MAX_LOG_CHARS), name: "git rebase --abort",
stderrTail: trimLogTail(abortResult.stderr, MAX_LOG_CHARS), command: "git rebase --abort",
}); cwd: gitRoot,
return { durationMs: 0,
status: "error", exitCode: abortResult.code,
mode: "git", stdoutTail: trimLogTail(abortResult.stdout, MAX_LOG_CHARS),
root: gitRoot, stderrTail: trimLogTail(abortResult.stderr, MAX_LOG_CHARS),
reason: "rebase-failed", });
before: { sha: beforeSha, version: beforeVersion }, return {
steps, status: "error",
durationMs: Date.now() - startedAt, mode: "git",
}; root: gitRoot,
reason: "rebase-failed",
before: { sha: beforeSha, version: beforeVersion },
steps,
durationMs: Date.now() - startedAt,
};
}
} else {
const fetchStep = await runStep(
step("git fetch", ["git", "-C", gitRoot, "fetch", "--all", "--prune", "--tags"], gitRoot),
);
steps.push(fetchStep);
if (fetchStep.exitCode !== 0) {
return {
status: "error",
mode: "git",
root: gitRoot,
reason: "fetch-failed",
before: { sha: beforeSha, version: beforeVersion },
steps,
durationMs: Date.now() - startedAt,
};
}
const tag = await resolveChannelTag(runCommand, gitRoot, timeoutMs, channel);
if (!tag) {
return {
status: "error",
mode: "git",
root: gitRoot,
reason: "no-release-tag",
before: { sha: beforeSha, version: beforeVersion },
steps,
durationMs: Date.now() - startedAt,
};
}
const checkoutStep = await runStep(
step(`git checkout ${tag}`, ["git", "-C", gitRoot, "checkout", "--detach", tag], gitRoot),
);
steps.push(checkoutStep);
if (checkoutStep.exitCode !== 0) {
return {
status: "error",
mode: "git",
root: gitRoot,
reason: "checkout-failed",
before: { sha: beforeSha, version: beforeVersion },
steps,
durationMs: Date.now() - startedAt,
};
}
} }
const manager = await detectPackageManager(gitRoot); const manager = await detectPackageManager(gitRoot);

View File

@@ -5,6 +5,11 @@ import type { loadConfig } from "../config/config.js";
import { resolveStateDir } from "../config/paths.js"; import { resolveStateDir } from "../config/paths.js";
import { resolveClawdbotPackageRoot } from "./clawdbot-root.js"; import { resolveClawdbotPackageRoot } from "./clawdbot-root.js";
import { compareSemverStrings, fetchNpmTagVersion, checkUpdateStatus } from "./update-check.js"; import { compareSemverStrings, fetchNpmTagVersion, checkUpdateStatus } from "./update-check.js";
import {
channelToNpmTag,
normalizeUpdateChannel,
DEFAULT_PACKAGE_CHANNEL,
} from "./update-channels.js";
import { VERSION } from "../version.js"; import { VERSION } from "../version.js";
import { formatCliCommand } from "../cli/command-format.js"; import { formatCliCommand } from "../cli/command-format.js";
@@ -17,17 +22,6 @@ type UpdateCheckState = {
const UPDATE_CHECK_FILENAME = "update-check.json"; const UPDATE_CHECK_FILENAME = "update-check.json";
const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
function normalizeChannel(value?: string | null): "stable" | "beta" | null {
if (!value) return null;
const normalized = value.trim().toLowerCase();
if (normalized === "stable" || normalized === "beta") return normalized;
return null;
}
function channelToTag(channel: "stable" | "beta"): string {
return channel === "beta" ? "beta" : "latest";
}
function shouldSkipCheck(allowInTests: boolean): boolean { function shouldSkipCheck(allowInTests: boolean): boolean {
if (allowInTests) return false; if (allowInTests) return false;
if (process.env.VITEST || process.env.NODE_ENV === "test") return true; if (process.env.VITEST || process.env.NODE_ENV === "test") return true;
@@ -89,8 +83,8 @@ export async function runGatewayUpdateCheck(params: {
return; return;
} }
const channel = normalizeChannel(params.cfg.update?.channel) ?? "stable"; const channel = normalizeUpdateChannel(params.cfg.update?.channel) ?? DEFAULT_PACKAGE_CHANNEL;
const tag = channelToTag(channel); const tag = channelToNpmTag(channel);
const tagStatus = await fetchNpmTagVersion({ tag, timeoutMs: 2500 }); const tagStatus = await fetchNpmTagVersion({ tag, timeoutMs: 2500 });
if (!tagStatus.version) { if (!tagStatus.version) {
await writeState(statePath, nextState); await writeState(statePath, nextState);