diff --git a/docs/cli/status.md b/docs/cli/status.md index f69614b2d..a66dd958a 100644 --- a/docs/cli/status.md +++ b/docs/cli/status.md @@ -20,4 +20,5 @@ Notes: - `--deep` runs live probes (WhatsApp Web + Telegram + Discord + Slack + Signal). - Output includes per-agent session stores when multiple agents are configured. - Overview includes Gateway + Node service install/runtime status when available. +- Overview includes update channel + git SHA (for source checkouts). - Update info surfaces in the Overview; if an update is available, status prints a hint to run `clawdbot update` (see [Updating](/install/updating)). diff --git a/docs/cli/update.md b/docs/cli/update.md index ec4f8165d..11823977e 100644 --- a/docs/cli/update.md +++ b/docs/cli/update.md @@ -15,6 +15,7 @@ If you installed via **npm/pnpm** (global install, no git metadata), use the pac ```bash clawdbot update +clawdbot update status clawdbot update --channel beta clawdbot update --channel dev clawdbot update --tag beta @@ -33,6 +34,20 @@ clawdbot --update Note: downgrades require confirmation because older versions can break configuration. +## `update status` + +Show the active update channel + git tag/branch/SHA (for source checkouts), plus update availability. + +```bash +clawdbot update status +clawdbot update status --json +clawdbot update status --timeout 10 +``` + +Options: +- `--json`: print machine-readable status JSON. +- `--timeout `: timeout for checks (default is 3s). + ## What it does (git checkout) Channels: diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index cccf59a51..2cc17b6fb 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -25,6 +25,7 @@ vi.mock("../infra/update-check.js", async () => { ); return { ...actual, + checkUpdateStatus: vi.fn(), fetchNpmTagVersion: vi.fn(), }; }); @@ -72,13 +73,38 @@ describe("update-cli", () => { 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"); + const { checkUpdateStatus, 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", }); + vi.mocked(checkUpdateStatus).mockResolvedValue({ + root: "/test/path", + installKind: "git", + packageManager: "pnpm", + git: { + root: "/test/path", + sha: "abcdef1234567890", + tag: "v1.2.3", + branch: "main", + upstream: "origin/main", + dirty: false, + ahead: 0, + behind: 0, + fetchOk: true, + }, + deps: { + manager: "pnpm", + status: "ok", + lockfilePath: "/test/path/pnpm-lock.yaml", + markerPath: "/test/path/node_modules", + }, + registry: { + latestVersion: "1.2.3", + }, + }); setTty(false); setStdoutTty(false); }); @@ -120,6 +146,28 @@ describe("update-cli", () => { expect(defaultRuntime.log).toHaveBeenCalled(); }); + it("updateStatusCommand prints table output", async () => { + const { defaultRuntime } = await import("../runtime.js"); + const { updateStatusCommand } = await import("./update-cli.js"); + + await updateStatusCommand({ json: false }); + + const logs = vi.mocked(defaultRuntime.log).mock.calls.map((call) => call[0]); + expect(logs.join("\n")).toContain("Clawdbot update status"); + }); + + it("updateStatusCommand emits JSON", async () => { + const { defaultRuntime } = await import("../runtime.js"); + const { updateStatusCommand } = await import("./update-cli.js"); + + await updateStatusCommand({ json: true }); + + const last = vi.mocked(defaultRuntime.log).mock.calls.at(-1)?.[0]; + expect(typeof last).toBe("string"); + const parsed = JSON.parse(String(last)); + expect(parsed.channel.value).toBe("stable"); + }); + 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"); diff --git a/src/cli/update-cli.ts b/src/cli/update-cli.ts index af2a8dd41..f5f2e6a9a 100644 --- a/src/cli/update-cli.ts +++ b/src/cli/update-cli.ts @@ -5,7 +5,11 @@ 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 { + checkUpdateStatus, + compareSemverStrings, + fetchNpmTagVersion, +} from "../infra/update-check.js"; import { parseSemver } from "../infra/runtime-guard.js"; import { runGatewayUpdate, @@ -17,13 +21,22 @@ import { channelToNpmTag, DEFAULT_GIT_CHANNEL, DEFAULT_PACKAGE_CHANNEL, + formatUpdateChannelLabel, normalizeUpdateChannel, + resolveEffectiveUpdateChannel, + type UpdateChannel, } from "../infra/update-channels.js"; import { defaultRuntime } from "../runtime.js"; import { formatDocsLink } from "../terminal/links.js"; import { formatCliCommand } from "./command-format.js"; import { stylePromptMessage } from "../terminal/prompt-style.js"; import { theme } from "../terminal/theme.js"; +import { renderTable } from "../terminal/table.js"; +import { + formatUpdateAvailableHint, + formatUpdateOneLiner, + resolveUpdateAvailability, +} from "../commands/status.update.js"; export type UpdateCommandOptions = { json?: boolean; @@ -32,6 +45,10 @@ export type UpdateCommandOptions = { tag?: string; timeout?: string; }; +export type UpdateStatusOptions = { + json?: boolean; + timeout?: string; +}; const STEP_LABELS: Record = { "clean check": "Working directory is clean", @@ -113,6 +130,125 @@ async function isGitCheckout(root: string): Promise { } } +function formatGitStatusLine(params: { + branch: string | null; + tag: string | null; + sha: string | null; +}): string { + const shortSha = params.sha ? params.sha.slice(0, 8) : null; + const branch = params.branch && params.branch !== "HEAD" ? params.branch : null; + const tag = params.tag; + const parts = [ + branch ?? (tag ? "detached" : "git"), + tag ? `tag ${tag}` : null, + shortSha ? `@ ${shortSha}` : null, + ].filter(Boolean); + return parts.join(" · "); +} + +export async function updateStatusCommand(opts: UpdateStatusOptions): Promise { + const timeoutMs = opts.timeout ? Number.parseInt(opts.timeout, 10) * 1000 : undefined; + if (timeoutMs !== undefined && (Number.isNaN(timeoutMs) || timeoutMs <= 0)) { + defaultRuntime.error("--timeout must be a positive integer (seconds)"); + defaultRuntime.exit(1); + return; + } + + const root = + (await resolveClawdbotPackageRoot({ + moduleUrl: import.meta.url, + argv1: process.argv[1], + cwd: process.cwd(), + })) ?? process.cwd(); + const configSnapshot = await readConfigFileSnapshot(); + const configChannel = configSnapshot.valid + ? normalizeUpdateChannel(configSnapshot.config.update?.channel) + : null; + + const update = await checkUpdateStatus({ + root, + timeoutMs: timeoutMs ?? 3500, + fetchGit: true, + includeRegistry: true, + }); + const channelInfo = resolveEffectiveUpdateChannel({ + configChannel, + installKind: update.installKind, + git: update.git ? { tag: update.git.tag, branch: update.git.branch } : undefined, + }); + const channelLabel = formatUpdateChannelLabel({ + channel: channelInfo.channel, + source: channelInfo.source, + gitTag: update.git?.tag ?? null, + gitBranch: update.git?.branch ?? null, + }); + const gitLabel = + update.installKind === "git" + ? formatGitStatusLine({ + branch: update.git?.branch ?? null, + tag: update.git?.tag ?? null, + sha: update.git?.sha ?? null, + }) + : null; + const updateAvailability = resolveUpdateAvailability(update); + const updateLine = formatUpdateOneLiner(update).replace(/^Update:\s*/i, ""); + + if (opts.json) { + defaultRuntime.log( + JSON.stringify( + { + update, + channel: { + value: channelInfo.channel, + source: channelInfo.source, + label: channelLabel, + config: configChannel, + }, + availability: updateAvailability, + }, + null, + 2, + ), + ); + return; + } + + const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); + const installLabel = + update.installKind === "git" + ? `git (${update.root ?? "unknown"})` + : update.installKind === "package" + ? update.packageManager + : "unknown"; + const rows = [ + { Item: "Install", Value: installLabel }, + { Item: "Channel", Value: channelLabel }, + ...(gitLabel ? [{ Item: "Git", Value: gitLabel }] : []), + { + Item: "Update", + Value: updateAvailability.available ? theme.warn(`available · ${updateLine}`) : updateLine, + }, + ]; + + defaultRuntime.log(theme.heading("Clawdbot update status")); + defaultRuntime.log(""); + defaultRuntime.log( + renderTable({ + width: tableWidth, + columns: [ + { key: "Item", header: "Item", minWidth: 10 }, + { key: "Value", header: "Value", flex: true, minWidth: 24 }, + ], + rows, + }).trimEnd(), + ); + defaultRuntime.log(""); + const updateHint = formatUpdateAvailableHint(update); + if (updateHint) { + defaultRuntime.log(theme.warn(updateHint)); + } +} + function getStepLabel(step: UpdateStepInfo): string { return STEP_LABELS[step.name] ?? step.name; } @@ -433,7 +569,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { } export function registerUpdateCli(program: Command) { - program + const update = program .command("update") .description("Update Clawdbot to the latest version") .option("--json", "Output result as JSON", false) @@ -476,4 +612,36 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/update", "docs.clawd.bot/cli/upda defaultRuntime.exit(1); } }); + + update + .command("status") + .description("Show update channel and version status") + .option("--json", "Output result as JSON", false) + .option("--timeout ", "Timeout for update checks in seconds (default: 3)") + .addHelpText( + "after", + () => + ` +Examples: + clawdbot update status + clawdbot update status --json + clawdbot update status --timeout 10 + +Notes: + - Shows current update channel (stable/beta/dev) and source + - Includes git tag/branch/SHA for source checkouts + +${theme.muted("Docs:")} ${formatDocsLink("/cli/update", "docs.clawd.bot/cli/update")}`, + ) + .action(async (opts) => { + try { + await updateStatusCommand({ + json: Boolean(opts.json), + timeout: opts.timeout as string | undefined, + }); + } catch (err) { + defaultRuntime.error(String(err)); + defaultRuntime.exit(1); + } + }); } diff --git a/src/commands/status-all.ts b/src/commands/status-all.ts index d5f94b763..81845a309 100644 --- a/src/commands/status-all.ts +++ b/src/commands/status-all.ts @@ -16,6 +16,11 @@ import { inspectPortUsage } from "../infra/ports.js"; import { readRestartSentinel } from "../infra/restart-sentinel.js"; import { readTailscaleStatusJson } from "../infra/tailscale.js"; import { checkUpdateStatus, compareSemverStrings } from "../infra/update-check.js"; +import { + formatUpdateChannelLabel, + normalizeUpdateChannel, + resolveEffectiveUpdateChannel, +} from "../infra/update-channels.js"; import { getRemoteSkillEligibility } from "../infra/skills-remote.js"; import { runExec } from "../process/exec.js"; import type { RuntimeEnv } from "../runtime.js"; @@ -87,6 +92,33 @@ export async function statusAllCommand( fetchGit: true, includeRegistry: true, }); + const configChannel = normalizeUpdateChannel(cfg.update?.channel); + const channelInfo = resolveEffectiveUpdateChannel({ + configChannel, + installKind: update.installKind, + git: update.git ? { tag: update.git.tag, branch: update.git.branch } : undefined, + }); + const channelLabel = formatUpdateChannelLabel({ + channel: channelInfo.channel, + source: channelInfo.source, + gitTag: update.git?.tag ?? null, + gitBranch: update.git?.branch ?? null, + }); + const gitLabel = + update.installKind === "git" + ? (() => { + const shortSha = update.git?.sha ? update.git.sha.slice(0, 8) : null; + const branch = + update.git?.branch && update.git.branch !== "HEAD" ? update.git.branch : null; + const tag = update.git?.tag ?? null; + const parts = [ + branch ?? (tag ? "detached" : "git"), + tag ? `tag ${tag}` : null, + shortSha ? `@ ${shortSha}` : null, + ].filter(Boolean); + return parts.join(" · "); + })() + : null; progress.tick(); progress.setLabel("Probing gateway…"); @@ -333,6 +365,8 @@ export async function statusAllCommand( ? `${tailscaleMode} · ${tailscale.backendState ?? "unknown"} · ${tailscale.dnsName} · ${tailscaleHttpsUrl}` : `${tailscaleMode} · ${tailscale.backendState ?? "unknown"} · magicdns unknown`, }, + { Item: "Channel", Value: channelLabel }, + ...(gitLabel ? [{ Item: "Git", Value: gitLabel }] : []), { Item: "Update", Value: updateLine }, { Item: "Gateway", diff --git a/src/commands/status.command.ts b/src/commands/status.command.ts index f888bf9b8..a857c78bf 100644 --- a/src/commands/status.command.ts +++ b/src/commands/status.command.ts @@ -33,6 +33,11 @@ import { } from "./status.update.js"; import { formatGatewayAuthUsed } from "./status-all/format.js"; import { statusAllCommand } from "./status-all.js"; +import { + formatUpdateChannelLabel, + normalizeUpdateChannel, + resolveEffectiveUpdateChannel, +} from "../infra/update-channels.js"; export async function statusCommand( opts: { @@ -116,6 +121,13 @@ export async function statusCommand( ) : undefined; + const configChannel = normalizeUpdateChannel(cfg.update?.channel); + const channelInfo = resolveEffectiveUpdateChannel({ + configChannel, + installKind: update.installKind, + git: update.git ? { tag: update.git.tag, branch: update.git.branch } : undefined, + }); + if (opts.json) { const [daemon, nodeDaemon] = await Promise.all([ getDaemonStatusSummary(), @@ -127,6 +139,8 @@ export async function statusCommand( ...summary, os: osSummary, update, + updateChannel: channelInfo.channel, + updateChannelSource: channelInfo.source, memory, memoryPlugin, gateway: { @@ -295,6 +309,27 @@ export async function statusCommand( const updateAvailability = resolveUpdateAvailability(update); const updateLine = formatUpdateOneLiner(update).replace(/^Update:\s*/i, ""); + const channelLabel = formatUpdateChannelLabel({ + channel: channelInfo.channel, + source: channelInfo.source, + gitTag: update.git?.tag ?? null, + gitBranch: update.git?.branch ?? null, + }); + const gitLabel = + update.installKind === "git" + ? (() => { + const shortSha = update.git?.sha ? update.git.sha.slice(0, 8) : null; + const branch = + update.git?.branch && update.git.branch !== "HEAD" ? update.git.branch : null; + const tag = update.git?.tag ?? null; + const parts = [ + branch ?? (tag ? "detached" : "git"), + tag ? `tag ${tag}` : null, + shortSha ? `@ ${shortSha}` : null, + ].filter(Boolean); + return parts.join(" · "); + })() + : null; const overviewRows = [ { Item: "Dashboard", Value: dashboard }, @@ -308,6 +343,8 @@ export async function statusCommand( ? `${tailscaleMode} · ${tailscaleDns} · ${tailscaleHttpsUrl}` : warn(`${tailscaleMode} · magicdns unknown`), }, + { Item: "Channel", Value: channelLabel }, + ...(gitLabel ? [{ Item: "Git", Value: gitLabel }] : []), { Item: "Update", Value: updateAvailability.available ? warn(`available · ${updateLine}`) : updateLine, diff --git a/src/infra/update-channels.ts b/src/infra/update-channels.ts index 27548bfad..bb40295d5 100644 --- a/src/infra/update-channels.ts +++ b/src/infra/update-channels.ts @@ -1,4 +1,5 @@ export type UpdateChannel = "stable" | "beta" | "dev"; +export type UpdateChannelSource = "config" | "git-tag" | "git-branch" | "default"; export const DEFAULT_PACKAGE_CHANNEL: UpdateChannel = "stable"; export const DEFAULT_GIT_CHANNEL: UpdateChannel = "dev"; @@ -24,3 +25,49 @@ export function isBetaTag(tag: string): boolean { export function isStableTag(tag: string): boolean { return !isBetaTag(tag); } + +export function resolveEffectiveUpdateChannel(params: { + configChannel?: UpdateChannel | null; + installKind: "git" | "package" | "unknown"; + git?: { tag?: string | null; branch?: string | null }; +}): { channel: UpdateChannel; source: UpdateChannelSource } { + if (params.configChannel) { + return { channel: params.configChannel, source: "config" }; + } + + if (params.installKind === "git") { + const tag = params.git?.tag; + if (tag) { + return { channel: isBetaTag(tag) ? "beta" : "stable", source: "git-tag" }; + } + const branch = params.git?.branch; + if (branch && branch !== "HEAD") { + return { channel: "dev", source: "git-branch" }; + } + return { channel: DEFAULT_GIT_CHANNEL, source: "default" }; + } + + if (params.installKind === "package") { + return { channel: DEFAULT_PACKAGE_CHANNEL, source: "default" }; + } + + return { channel: DEFAULT_PACKAGE_CHANNEL, source: "default" }; +} + +export function formatUpdateChannelLabel(params: { + channel: UpdateChannel; + source: UpdateChannelSource; + gitTag?: string | null; + gitBranch?: string | null; +}): string { + if (params.source === "config") return `${params.channel} (config)`; + if (params.source === "git-tag") { + return params.gitTag ? `${params.channel} (${params.gitTag})` : `${params.channel} (tag)`; + } + if (params.source === "git-branch") { + return params.gitBranch + ? `${params.channel} (${params.gitBranch})` + : `${params.channel} (branch)`; + } + return `${params.channel} (default)`; +} diff --git a/src/infra/update-check.ts b/src/infra/update-check.ts index ba5519545..603230040 100644 --- a/src/infra/update-check.ts +++ b/src/infra/update-check.ts @@ -8,6 +8,8 @@ export type PackageManager = "pnpm" | "bun" | "npm" | "unknown"; export type GitUpdateStatus = { root: string; + sha: string | null; + tag: string | null; branch: string | null; upstream: string | null; dirty: boolean | null; @@ -90,6 +92,8 @@ export async function checkGitUpdateStatus(params: { const base: GitUpdateStatus = { root, + sha: null, + tag: null, branch: null, upstream: null, dirty: null, @@ -107,6 +111,17 @@ export async function checkGitUpdateStatus(params: { } const branch = branchRes.stdout.trim() || null; + const shaRes = await runCommandWithTimeout(["git", "-C", root, "rev-parse", "HEAD"], { + timeoutMs, + }).catch(() => null); + const sha = shaRes && shaRes.code === 0 ? shaRes.stdout.trim() : null; + + const tagRes = await runCommandWithTimeout( + ["git", "-C", root, "describe", "--tags", "--exact-match"], + { timeoutMs }, + ).catch(() => null); + const tag = tagRes && tagRes.code === 0 ? tagRes.stdout.trim() : null; + const upstreamRes = await runCommandWithTimeout( ["git", "-C", root, "rev-parse", "--abbrev-ref", "@{upstream}"], { timeoutMs }, @@ -144,6 +159,8 @@ export async function checkGitUpdateStatus(params: { return { root, + sha, + tag, branch, upstream, dirty,