2026-01-17 11:40:02 +00:00
import { confirm , isCancel , spinner } from "@clack/prompts" ;
import fs from "node:fs/promises" ;
import path from "node:path" ;
2026-01-10 18:18:10 +00:00
import type { Command } from "commander" ;
2026-01-17 11:40:02 +00:00
import { readConfigFileSnapshot , writeConfigFile } from "../config/config.js" ;
2026-01-10 20:32:15 +01:00
import { resolveClawdbotPackageRoot } from "../infra/clawdbot-root.js" ;
2026-01-17 11:40:02 +00:00
import { compareSemverStrings , fetchNpmTagVersion } from "../infra/update-check.js" ;
import { parseSemver } from "../infra/runtime-guard.js" ;
2026-01-10 18:18:10 +00:00
import {
runGatewayUpdate ,
type UpdateRunResult ,
2026-01-11 00:48:39 +01:00
type UpdateStepInfo ,
type UpdateStepProgress ,
2026-01-10 18:18:10 +00:00
} from "../infra/update-runner.js" ;
import { defaultRuntime } from "../runtime.js" ;
2026-01-10 20:50:17 +01:00
import { formatDocsLink } from "../terminal/links.js" ;
2026-01-17 11:40:02 +00:00
import { stylePromptMessage } from "../terminal/prompt-style.js" ;
2026-01-10 18:18:10 +00:00
import { theme } from "../terminal/theme.js" ;
export type UpdateCommandOptions = {
json? : boolean ;
restart? : boolean ;
2026-01-17 11:40:02 +00:00
channel? : string ;
tag? : string ;
2026-01-10 18:18:10 +00:00
timeout? : string ;
} ;
2026-01-11 00:48:39 +01:00
const STEP_LABELS : Record < string , string > = {
2026-01-11 01:14:59 +01:00
"clean check" : "Working directory is clean" ,
"upstream check" : "Upstream branch exists" ,
2026-01-11 00:48:39 +01:00
"git fetch" : "Fetching latest changes" ,
"git rebase" : "Rebasing onto upstream" ,
"deps install" : "Installing dependencies" ,
build : "Building" ,
"ui:build" : "Building UI" ,
"clawdbot doctor" : "Running doctor checks" ,
"git rev-parse HEAD (after)" : "Verifying update" ,
2026-01-16 11:45:37 +00:00
"global update" : "Updating via package manager" ,
2026-01-11 00:48:39 +01:00
} ;
2026-01-17 11:40:02 +00:00
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 ;
}
}
2026-01-11 00:48:39 +01:00
function getStepLabel ( step : UpdateStepInfo ) : string {
2026-01-11 01:21:39 +01:00
return STEP_LABELS [ step . name ] ? ? step . name ;
2026-01-11 00:48:39 +01:00
}
2026-01-11 00:55:04 +01:00
type ProgressController = {
2026-01-11 00:48:39 +01:00
progress : UpdateStepProgress ;
2026-01-11 00:55:04 +01:00
stop : ( ) = > void ;
} ;
function createUpdateProgress ( enabled : boolean ) : ProgressController {
if ( ! enabled ) {
return {
progress : { } ,
stop : ( ) = > { } ,
} ;
}
let currentSpinner : ReturnType < typeof spinner > | null = null ;
2026-01-11 00:48:39 +01:00
const progress : UpdateStepProgress = {
onStepStart : ( step ) = > {
2026-01-11 00:55:04 +01:00
currentSpinner = spinner ( ) ;
currentSpinner . start ( theme . accent ( getStepLabel ( step ) ) ) ;
} ,
onStepComplete : ( step ) = > {
if ( ! currentSpinner ) return ;
const label = getStepLabel ( step ) ;
const duration = theme . muted ( ` ( ${ formatDuration ( step . durationMs ) } ) ` ) ;
2026-01-14 14:31:43 +00:00
const icon = step . exitCode === 0 ? theme . success ( "\u2713" ) : theme . error ( "\u2717" ) ;
2026-01-11 01:06:34 +01:00
currentSpinner . stop ( ` ${ icon } ${ label } ${ duration } ` ) ;
2026-01-11 00:55:04 +01:00
currentSpinner = null ;
2026-01-11 01:26:14 +01:00
if ( step . exitCode !== 0 && step . stderrTail ) {
const lines = step . stderrTail . split ( "\n" ) . slice ( - 10 ) ;
for ( const line of lines ) {
if ( line . trim ( ) ) {
defaultRuntime . log ( ` ${ theme . error ( line ) } ` ) ;
}
}
}
2026-01-11 00:48:39 +01:00
} ,
} ;
2026-01-11 00:55:04 +01:00
return {
progress ,
stop : ( ) = > {
if ( currentSpinner ) {
currentSpinner . stop ( ) ;
currentSpinner = null ;
}
} ,
} ;
2026-01-11 00:48:39 +01:00
}
2026-01-10 18:18:10 +00:00
function formatDuration ( ms : number ) : string {
if ( ms < 1000 ) return ` ${ ms } ms ` ;
const seconds = ( ms / 1000 ) . toFixed ( 1 ) ;
return ` ${ seconds } s ` ;
}
function formatStepStatus ( exitCode : number | null ) : string {
if ( exitCode === 0 ) return theme . success ( "\u2713" ) ;
if ( exitCode === null ) return theme . warn ( "?" ) ;
return theme . error ( "\u2717" ) ;
}
2026-01-11 00:58:01 +01:00
type PrintResultOptions = UpdateCommandOptions & {
hideSteps? : boolean ;
} ;
function printResult ( result : UpdateRunResult , opts : PrintResultOptions ) {
2026-01-10 18:18:10 +00:00
if ( opts . json ) {
defaultRuntime . log ( JSON . stringify ( result , null , 2 ) ) ;
return ;
}
const statusColor =
2026-01-14 14:31:43 +00:00
result . status === "ok" ? theme.success : result.status === "skipped" ? theme.warn : theme.error ;
2026-01-10 18:18:10 +00:00
defaultRuntime . log ( "" ) ;
defaultRuntime . log (
` ${ theme . heading ( "Update Result:" ) } ${ statusColor ( result . status . toUpperCase ( ) ) } ` ,
) ;
if ( result . root ) {
defaultRuntime . log ( ` Root: ${ theme . muted ( result . root ) } ` ) ;
}
2026-01-11 00:59:17 +01:00
if ( result . reason ) {
defaultRuntime . log ( ` Reason: ${ theme . muted ( result . reason ) } ` ) ;
}
2026-01-10 18:18:10 +00:00
if ( result . before ? . version || result . before ? . sha ) {
2026-01-14 14:31:43 +00:00
const before = result . before . version ? ? result . before . sha ? . slice ( 0 , 8 ) ? ? "" ;
2026-01-10 18:18:10 +00:00
defaultRuntime . log ( ` Before: ${ theme . muted ( before ) } ` ) ;
}
if ( result . after ? . version || result . after ? . sha ) {
const after = result . after . version ? ? result . after . sha ? . slice ( 0 , 8 ) ? ? "" ;
defaultRuntime . log ( ` After: ${ theme . muted ( after ) } ` ) ;
}
2026-01-11 00:58:01 +01:00
if ( ! opts . hideSteps && result . steps . length > 0 ) {
2026-01-10 18:18:10 +00:00
defaultRuntime . log ( "" ) ;
defaultRuntime . log ( theme . heading ( "Steps:" ) ) ;
for ( const step of result . steps ) {
const status = formatStepStatus ( step . exitCode ) ;
const duration = theme . muted ( ` ( ${ formatDuration ( step . durationMs ) } ) ` ) ;
defaultRuntime . log ( ` ${ status } ${ step . name } ${ duration } ` ) ;
if ( step . exitCode !== 0 && step . stderrTail ) {
const lines = step . stderrTail . split ( "\n" ) . slice ( 0 , 5 ) ;
for ( const line of lines ) {
if ( line . trim ( ) ) {
defaultRuntime . log ( ` ${ theme . error ( line ) } ` ) ;
}
}
}
}
}
defaultRuntime . log ( "" ) ;
2026-01-14 14:31:43 +00:00
defaultRuntime . log ( ` Total time: ${ theme . muted ( formatDuration ( result . durationMs ) ) } ` ) ;
2026-01-10 18:18:10 +00:00
}
export async function updateCommand ( opts : UpdateCommandOptions ) : Promise < void > {
2026-01-14 14:31:43 +00:00
const timeoutMs = opts . timeout ? Number . parseInt ( opts . timeout , 10 ) * 1000 : undefined ;
2026-01-10 18:18:10 +00:00
if ( timeoutMs !== undefined && ( Number . isNaN ( timeoutMs ) || timeoutMs <= 0 ) ) {
defaultRuntime . error ( "--timeout must be a positive integer (seconds)" ) ;
defaultRuntime . exit ( 1 ) ;
return ;
}
2026-01-10 20:32:15 +01:00
const root =
( await resolveClawdbotPackageRoot ( {
moduleUrl : import.meta.url ,
argv1 : process.argv [ 1 ] ,
cwd : process.cwd ( ) ,
} ) ) ? ? process . cwd ( ) ;
2026-01-17 11:40:02 +00:00
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 ( "" ) ;
}
2026-01-11 00:55:04 +01:00
const { progress , stop } = createUpdateProgress ( showProgress ) ;
2026-01-11 00:48:39 +01:00
2026-01-10 18:18:10 +00:00
const result = await runGatewayUpdate ( {
2026-01-10 20:32:15 +01:00
cwd : root ,
2026-01-10 18:18:10 +00:00
argv1 : process.argv [ 1 ] ,
timeoutMs ,
2026-01-11 00:48:39 +01:00
progress ,
2026-01-17 11:40:02 +00:00
tag ,
2026-01-10 18:18:10 +00:00
} ) ;
2026-01-11 00:55:04 +01:00
stop ( ) ;
2026-01-11 00:48:39 +01:00
2026-01-11 00:58:01 +01:00
printResult ( result , { . . . opts , hideSteps : showProgress } ) ;
2026-01-10 18:18:10 +00:00
if ( result . status === "error" ) {
defaultRuntime . exit ( 1 ) ;
return ;
}
if ( result . status === "skipped" ) {
if ( result . reason === "dirty" ) {
defaultRuntime . log (
theme . warn (
"Skipped: working directory has uncommitted changes. Commit or stash them first." ,
) ,
) ;
}
2026-01-10 20:32:15 +01:00
if ( result . reason === "not-git-install" ) {
defaultRuntime . log (
theme . warn (
2026-01-16 11:45:37 +00:00
"Skipped: this Clawdbot install isn't a git checkout, and the package manager couldn't be detected. Update via your package manager, then run `clawdbot doctor` and `clawdbot daemon restart`." ,
2026-01-10 20:32:15 +01:00
) ,
) ;
defaultRuntime . log (
2026-01-16 16:01:59 +00:00
theme . muted ( "Examples: `npm i -g clawdbot@latest` or `pnpm add -g clawdbot@latest`" ) ,
2026-01-10 20:32:15 +01:00
) ;
}
2026-01-10 18:18:10 +00:00
defaultRuntime . exit ( 0 ) ;
return ;
}
// Restart daemon if requested
if ( opts . restart ) {
if ( ! opts . json ) {
defaultRuntime . log ( "" ) ;
defaultRuntime . log ( theme . heading ( "Restarting daemon..." ) ) ;
}
try {
2026-01-10 23:39:30 +01:00
const { runDaemonRestart } = await import ( "./daemon-cli.js" ) ;
2026-01-10 22:38:01 +01:00
const restarted = await runDaemonRestart ( ) ;
if ( ! opts . json && restarted ) {
2026-01-10 18:18:10 +00:00
defaultRuntime . log ( theme . success ( "Daemon restarted successfully." ) ) ;
2026-01-10 23:14:55 +01:00
defaultRuntime . log ( "" ) ;
process . env . CLAWDBOT_UPDATE_IN_PROGRESS = "1" ;
try {
2026-01-10 23:39:30 +01:00
const { doctorCommand } = await import ( "../commands/doctor.js" ) ;
2026-01-10 23:14:55 +01:00
await doctorCommand ( defaultRuntime , { nonInteractive : true } ) ;
} catch ( err ) {
2026-01-10 23:23:23 +01:00
defaultRuntime . log ( theme . warn ( ` Doctor failed: ${ String ( err ) } ` ) ) ;
2026-01-10 23:14:55 +01:00
} finally {
delete process . env . CLAWDBOT_UPDATE_IN_PROGRESS ;
}
2026-01-10 18:18:10 +00:00
}
} catch ( err ) {
if ( ! opts . json ) {
2026-01-10 20:32:15 +01:00
defaultRuntime . log ( theme . warn ( ` Daemon restart failed: ${ String ( err ) } ` ) ) ;
2026-01-10 18:18:10 +00:00
defaultRuntime . log (
2026-01-14 14:31:43 +00:00
theme . muted ( "You may need to restart the daemon manually: clawdbot daemon restart" ) ,
2026-01-10 18:18:10 +00:00
) ;
}
}
} else if ( ! opts . json ) {
defaultRuntime . log ( "" ) ;
2026-01-16 11:45:37 +00:00
if ( result . mode === "npm" || result . mode === "pnpm" ) {
defaultRuntime . log (
theme . muted (
"Tip: Run `clawdbot doctor`, then `clawdbot daemon restart` to apply updates to a running gateway." ,
) ,
) ;
} else {
defaultRuntime . log (
theme . muted ( "Tip: Run `clawdbot daemon restart` to apply updates to a running gateway." ) ,
) ;
}
2026-01-10 18:18:10 +00:00
}
}
export function registerUpdateCli ( program : Command ) {
program
. command ( "update" )
. description ( "Update Clawdbot to the latest version" )
. option ( "--json" , "Output result as JSON" , false )
2026-01-14 14:31:43 +00:00
. option ( "--restart" , "Restart the gateway daemon after a successful update" , false )
2026-01-17 11:40:02 +00:00
. option ( "--channel <stable|beta>" , "Persist update channel (npm installs only)" )
. option ( "--tag <dist-tag|version>" , "Override npm dist-tag or version for this update" )
2026-01-14 14:31:43 +00:00
. option ( "--timeout <seconds>" , "Timeout for each update step in seconds (default: 1200)" )
2026-01-10 18:18:10 +00:00
. addHelpText (
"after" ,
2026-01-10 20:50:17 +01:00
( ) = >
`
2026-01-10 18:18:10 +00:00
Examples :
2026-01-10 20:32:15 +01:00
clawdbot update # Update a source checkout ( git )
2026-01-17 11:40:02 +00:00
clawdbot update -- channel beta # Switch to the beta channel ( npm installs )
clawdbot update -- tag beta # One - off update to a dist - tag or version
2026-01-10 18:18:10 +00:00
clawdbot update -- restart # Update and restart the daemon
clawdbot update -- json # Output result as JSON
clawdbot -- update # Shorthand for clawdbot update
Notes :
- For git installs : fetches , rebases , installs deps , builds , and runs doctor
2026-01-16 11:45:37 +00:00
- For global installs : auto - updates via detected package manager when possible ( see docs / install / updating . md )
2026-01-17 11:40:02 +00:00
- Downgrades require confirmation ( can break configuration )
2026-01-10 18:18:10 +00:00
- Skips update if the working directory has uncommitted changes
2026-01-10 20:50:17 +01:00
2026-01-15 06:12:54 +00:00
$ { theme . muted ( "Docs:" ) } $ { formatDocsLink ( "/cli/update" , "docs.clawd.bot/cli/update" ) } ` ,
2026-01-10 18:18:10 +00:00
)
. action ( async ( opts ) = > {
try {
await updateCommand ( {
json : Boolean ( opts . json ) ,
restart : Boolean ( opts . restart ) ,
2026-01-17 11:40:02 +00:00
channel : opts.channel as string | undefined ,
tag : opts.tag as string | undefined ,
2026-01-10 18:18:10 +00:00
timeout : opts.timeout as string | undefined ,
} ) ;
} catch ( err ) {
defaultRuntime . error ( String ( err ) ) ;
defaultRuntime . exit ( 1 ) ;
}
} ) ;
}