diff --git a/README.md b/README.md index 925246417..bc2dc9b01 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,15 @@ clawdbot agent --message "Ship checklist" --thinking high 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-`), 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) Prefer `pnpm` for builds from source. Bun is optional for running TypeScript directly. diff --git a/docs/cli/update.md b/docs/cli/update.md index 839ff5690..ec4f8165d 100644 --- a/docs/cli/update.md +++ b/docs/cli/update.md @@ -16,6 +16,7 @@ If you installed via **npm/pnpm** (global install, no git metadata), use the pac ```bash clawdbot update clawdbot update --channel beta +clawdbot update --channel dev clawdbot update --tag beta clawdbot update --restart clawdbot update --json @@ -25,7 +26,7 @@ clawdbot --update ## Options - `--restart`: restart the Gateway daemon after a successful update. -- `--channel `: set the update channel for npm installs (persisted in config). +- `--channel `: set the update channel (git + npm; persisted in config). - `--tag `: override the npm dist-tag or version for this update only. - `--json`: print machine-readable `UpdateRunResult` JSON. - `--timeout `: 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) +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: 1. Requires a clean worktree (no uncommitted changes). -2. Fetches and rebases against `@{upstream}`. -3. Installs deps (pnpm preferred; npm fallback). -4. Builds + builds the Control UI. -5. Runs `clawdbot doctor` as the final “safe update” check. +2. Switches to the selected channel (tag or branch). +3. Fetches and rebases against `@{upstream}` (dev only). +4. Installs deps (pnpm preferred; npm fallback). +5. Builds + builds the Control UI. +6. Runs `clawdbot doctor` as the final “safe update” check. ## `--update` shorthand @@ -49,5 +57,6 @@ High-level: ## See also - `clawdbot doctor` (offers to run update first on git checkouts) +- [Development channels](/install/development-channels) - [Updating](/install/updating) - [CLI reference](/cli) diff --git a/docs/docs.json b/docs/docs.json index c2ee76b79..8b81228ab 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -793,6 +793,7 @@ "install/index", "install/installer", "install/updating", + "install/development-channels", "install/uninstall", "install/ansible", "install/nix", diff --git a/docs/install/development-channels.md b/docs/install/development-channels.md new file mode 100644 index 000000000..f0bdd688f --- /dev/null +++ b/docs/install/development-channels.md @@ -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-`). 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-`). +- 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. That’s 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. diff --git a/docs/install/updating.md b/docs/install/updating.md index 7bb6fc16a..327975f50 100644 --- a/docs/install/updating.md +++ b/docs/install/updating.md @@ -50,20 +50,18 @@ 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: +To switch update channels (git + npm installs): ```bash clawdbot update --channel beta -``` - -Switch back to stable later: - -```bash +clawdbot update --channel dev clawdbot update --channel stable ``` Use `--tag ` 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`. Then: @@ -88,7 +86,8 @@ clawdbot update --restart It runs a safe-ish update flow: - 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`. 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. diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index dfc233a98..cccf59a51 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -120,7 +120,7 @@ describe("update-cli", () => { 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 { updateCommand } = await import("./update-cli.js"); @@ -134,7 +134,38 @@ describe("update-cli", () => { await updateCommand({}); 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 () => { @@ -156,24 +187,37 @@ describe("update-cli", () => { await updateCommand({}); const call = vi.mocked(runGatewayUpdate).mock.calls[0]?.[0]; - expect(call?.tag).toBe("beta"); + expect(call?.channel).toBe("beta"); }); it("honors --tag override", async () => { - const { runGatewayUpdate } = await import("../infra/update-runner.js"); - const { updateCommand } = await import("./update-cli.js"); + 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", + ); - vi.mocked(runGatewayUpdate).mockResolvedValue({ - status: "ok", - mode: "git", - steps: [], - durationMs: 100, - }); + const { resolveClawdbotPackageRoot } = await import("../infra/clawdbot-root.js"); + const { runGatewayUpdate } = await import("../infra/update-runner.js"); + const { updateCommand } = await import("./update-cli.js"); - 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]; - expect(call?.tag).toBe("next"); + await updateCommand({ tag: "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 () => { diff --git a/src/cli/update-cli.ts b/src/cli/update-cli.ts index 1d8bad644..e4cf84298 100644 --- a/src/cli/update-cli.ts +++ b/src/cli/update-cli.ts @@ -13,6 +13,13 @@ import { type UpdateStepInfo, type UpdateStepProgress, } 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 { formatDocsLink } from "../terminal/links.js"; import { formatCliCommand } from "./command-format.js"; @@ -40,9 +47,6 @@ const STEP_LABELS: Record = { "global update": "Updating via package manager", }; -type UpdateChannel = "stable" | "beta"; - -const DEFAULT_UPDATE_CHANNEL: UpdateChannel = "stable"; const UPDATE_QUIPS = [ "Leveled up! New skills unlocked. You're welcome.", "Fresh code, same lobster. Miss me?", @@ -66,13 +70,6 @@ const UPDATE_QUIPS = [ "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 { if (!value) return null; const trimmed = value.trim(); @@ -80,10 +77,6 @@ function normalizeTag(value?: string | null): string | null { return trimmed.startsWith("clawdbot@") ? trimmed.slice("clawdbot@".length) : trimmed; } -function channelToTag(channel: UpdateChannel): string { - return channel === "beta" ? "beta" : "latest"; -} - function pickUpdateQuip(): string { return UPDATE_QUIPS[Math.floor(Math.random() * UPDATE_QUIPS.length)] ?? "Update complete."; } @@ -263,12 +256,12 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { const configSnapshot = await readConfigFileSnapshot(); const storedChannel = configSnapshot.valid - ? normalizeChannel(configSnapshot.config.update?.channel) + ? normalizeUpdateChannel(configSnapshot.config.update?.channel) : null; - const requestedChannel = normalizeChannel(opts.channel); + const requestedChannel = normalizeUpdateChannel(opts.channel); 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); return; } @@ -279,10 +272,10 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { return; } - const channel = requestedChannel ?? storedChannel ?? DEFAULT_UPDATE_CHANNEL; - const tag = normalizeTag(opts.tag) ?? channelToTag(channel); - 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) { const currentVersion = await readPackageVersion(root); const targetVersion = await resolveTargetVersion(tag, timeoutMs); @@ -317,9 +310,9 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { return; } } - } else if ((opts.channel || opts.tag) && !opts.json) { + } else if (opts.tag && !opts.json) { 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 { argv1: process.argv[1], timeoutMs, progress, + channel, tag, }); @@ -445,7 +439,7 @@ 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 ", "Persist update channel (npm installs only)") + .option("--channel ", "Persist update channel (git + npm)") .option("--tag ", "Override npm dist-tag or version for this update") .option("--timeout ", "Timeout for each update step in seconds (default: 1200)") .addHelpText( @@ -454,7 +448,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 --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 --restart # Update and restart the daemon clawdbot update --json # Output result as JSON diff --git a/src/config/schema.ts b/src/config/schema.ts index 9a3eff29e..cafe28309 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -309,7 +309,7 @@ const FIELD_LABELS: Record = { const FIELD_HELP: Record = { "meta.lastTouchedVersion": "Auto-set when Clawdbot writes the config.", "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).", "gateway.remote.url": "Remote Gateway WebSocket URL (ws:// or wss://).", "gateway.remote.tlsFingerprint": diff --git a/src/config/types.clawdbot.ts b/src/config/types.clawdbot.ts index c77dc86c5..a3f6d816f 100644 --- a/src/config/types.clawdbot.ts +++ b/src/config/types.clawdbot.ts @@ -55,8 +55,8 @@ export type ClawdbotConfig = { }; logging?: LoggingConfig; update?: { - /** Update channel for npm installs ("stable" or "beta"). */ - channel?: "stable" | "beta"; + /** Update channel for git + npm installs ("stable", "beta", or "dev"). */ + channel?: "stable" | "beta" | "dev"; /** Check for updates on gateway start (npm installs only). */ checkOnStart?: boolean; }; diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index fd000a27c..285734314 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -73,7 +73,7 @@ export const ClawdbotSchema = z .optional(), update: z .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(), }) .strict() diff --git a/src/infra/update-channels.ts b/src/infra/update-channels.ts new file mode 100644 index 000000000..27548bfad --- /dev/null +++ b/src/infra/update-channels.ts @@ -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); +} diff --git a/src/infra/update-runner.test.ts b/src/infra/update-runner.test.ts index 4df7adc0c..de2144f37 100644 --- a/src/infra/update-runner.test.ts +++ b/src/infra/update-runner.test.ts @@ -43,6 +43,7 @@ describe("runGatewayUpdate", () => { const { runner, calls } = createRunner({ [`git -C ${tempDir} rev-parse --show-toplevel`]: { stdout: tempDir }, [`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" }, }); @@ -67,11 +68,12 @@ describe("runGatewayUpdate", () => { const { runner, calls } = createRunner({ [`git -C ${tempDir} rev-parse --show-toplevel`]: { stdout: tempDir }, [`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} rev-parse --abbrev-ref --symbolic-full-name @{upstream}`]: { 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 --abort`]: { stdout: "" }, }); diff --git a/src/infra/update-runner.ts b/src/infra/update-runner.ts index 6ede48bc4..1ca9adf1c 100644 --- a/src/infra/update-runner.ts +++ b/src/infra/update-runner.ts @@ -3,6 +3,7 @@ import fs from "node:fs/promises"; import path from "node:path"; 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"; export type UpdateStepResult = { @@ -53,6 +54,7 @@ type UpdateRunnerOptions = { cwd?: string; argv1?: string; tag?: string; + channel?: UpdateChannel; timeoutMs?: number; runCommand?: CommandRunner; progress?: UpdateStepProgress; @@ -60,7 +62,6 @@ type UpdateRunnerOptions = { const DEFAULT_TIMEOUT_MS = 20 * 60_000; const MAX_LOG_CHARS = 8000; - const START_DIRS = ["cwd", "argv1", "process"]; function normalizeDir(value?: string | null) { @@ -106,6 +107,46 @@ async function readPackageVersion(root: string) { } } +async function readBranchName( + runCommand: CommandRunner, + root: string, + timeoutMs: number, +): Promise { + 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 { + 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, +): Promise { + const tags = await listGitTags(runCommand, root, timeoutMs); + const predicate = channel === "beta" ? isBetaTag : isStableTag; + return tags.find((tag) => predicate(tag)) ?? null; +} + async function resolveGitRoot( runCommand: CommandRunner, candidates: string[], @@ -281,9 +322,6 @@ function globalUpdateArgs(manager: "pnpm" | "npm" | "bun", tag?: string) { 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 { const startedAt = Date.now(); const runCommand = @@ -298,6 +336,7 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise< const candidates = buildStartDirs(opts); let stepIndex = 0; + let gitTotalSteps = 0; const step = ( name: string, @@ -316,7 +355,7 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise< env, progress, 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 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( step("clean check", ["git", "-C", gitRoot, "status", "--porcelain"], gitRoot), @@ -365,58 +408,135 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise< }; } - const upstreamStep = await runStep( - step( - "upstream check", - ["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, - }; - } + if (channel === "dev") { + if (needsCheckoutMain) { + const checkoutStep = await runStep( + step( + `git checkout ${DEV_BRANCH}`, + ["git", "-C", gitRoot, "checkout", DEV_BRANCH], + 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 fetchStep = await runStep( - step("git fetch", ["git", "-C", gitRoot, "fetch", "--all", "--prune"], gitRoot), - ); - steps.push(fetchStep); + const upstreamStep = await runStep( + step( + "upstream check", + [ + "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( - step("git rebase", ["git", "-C", gitRoot, "rebase", "@{upstream}"], gitRoot), - ); - steps.push(rebaseStep); - if (rebaseStep.exitCode !== 0) { - const abortResult = await runCommand(["git", "-C", gitRoot, "rebase", "--abort"], { - cwd: gitRoot, - timeoutMs, - }); - steps.push({ - name: "git rebase --abort", - command: "git rebase --abort", - cwd: gitRoot, - durationMs: 0, - exitCode: abortResult.code, - stdoutTail: trimLogTail(abortResult.stdout, MAX_LOG_CHARS), - stderrTail: trimLogTail(abortResult.stderr, MAX_LOG_CHARS), - }); - return { - status: "error", - mode: "git", - root: gitRoot, - reason: "rebase-failed", - before: { sha: beforeSha, version: beforeVersion }, - steps, - durationMs: Date.now() - startedAt, - }; + const fetchStep = await runStep( + step("git fetch", ["git", "-C", gitRoot, "fetch", "--all", "--prune", "--tags"], gitRoot), + ); + steps.push(fetchStep); + + const rebaseStep = await runStep( + step("git rebase", ["git", "-C", gitRoot, "rebase", "@{upstream}"], gitRoot), + ); + steps.push(rebaseStep); + if (rebaseStep.exitCode !== 0) { + const abortResult = await runCommand(["git", "-C", gitRoot, "rebase", "--abort"], { + cwd: gitRoot, + timeoutMs, + }); + steps.push({ + name: "git rebase --abort", + command: "git rebase --abort", + cwd: gitRoot, + durationMs: 0, + exitCode: abortResult.code, + stdoutTail: trimLogTail(abortResult.stdout, MAX_LOG_CHARS), + stderrTail: trimLogTail(abortResult.stderr, MAX_LOG_CHARS), + }); + return { + status: "error", + 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); diff --git a/src/infra/update-startup.ts b/src/infra/update-startup.ts index 003a090c4..48facd2c5 100644 --- a/src/infra/update-startup.ts +++ b/src/infra/update-startup.ts @@ -5,6 +5,11 @@ import type { loadConfig } from "../config/config.js"; import { resolveStateDir } from "../config/paths.js"; import { resolveClawdbotPackageRoot } from "./clawdbot-root.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 { formatCliCommand } from "../cli/command-format.js"; @@ -17,17 +22,6 @@ type UpdateCheckState = { const UPDATE_CHECK_FILENAME = "update-check.json"; 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 { if (allowInTests) return false; if (process.env.VITEST || process.env.NODE_ENV === "test") return true; @@ -89,8 +83,8 @@ export async function runGatewayUpdateCheck(params: { return; } - const channel = normalizeChannel(params.cfg.update?.channel) ?? "stable"; - const tag = channelToTag(channel); + const channel = normalizeUpdateChannel(params.cfg.update?.channel) ?? DEFAULT_PACKAGE_CHANNEL; + const tag = channelToNpmTag(channel); const tagStatus = await fetchNpmTagVersion({ tag, timeoutMs: 2500 }); if (!tagStatus.version) { await writeState(statePath, nextState);