2026-03-03 12:24:43 -08:00
import { spawn , type StdioOptions } from "node:child_process" ;
2026-03-03 15:37:39 -08:00
import { existsSync , mkdirSync , openSync , readFileSync , readdirSync } from "node:fs" ;
2026-03-01 16:11:40 -08:00
import os from "node:os" ;
import path from "node:path" ;
import process from "node:process" ;
import { fileURLToPath } from "node:url" ;
import { confirm , isCancel , spinner } from "@clack/prompts" ;
import { isTruthyEnvValue } from "../infra/env.js" ;
import { resolveRequiredHomeDir } from "../infra/home-dir.js" ;
import { defaultRuntime , type RuntimeEnv } from "../runtime.js" ;
import { stylePromptMessage } from "../terminal/prompt-style.js" ;
import { theme } from "../terminal/theme.js" ;
import { applyCliProfileEnv } from "./profile.js" ;
2026-03-02 18:31:15 -08:00
import { seedWorkspaceFromAssets , type WorkspaceSeedResult } from "./workspace-seed.js" ;
2026-03-01 16:11:40 -08:00
const DEFAULT_IRONCLAW_PROFILE = "ironclaw" ;
2026-03-03 12:24:43 -08:00
const IRONCLAW_STATE_DIRNAME = ".openclaw-ironclaw" ;
2026-03-01 16:11:40 -08:00
const DEFAULT_GATEWAY_PORT = 18789 ;
2026-03-03 12:24:43 -08:00
const IRONCLAW_GATEWAY_PORT_START = 19001 ;
const MAX_PORT_SCAN_ATTEMPTS = 100 ;
2026-03-01 16:11:40 -08:00
const DEFAULT_WEB_APP_PORT = 3100 ;
const WEB_APP_PROBE_ATTEMPTS = 20 ;
const WEB_APP_PROBE_DELAY_MS = 750 ;
const DEFAULT_BOOTSTRAP_ROLLOUT_STAGE = "default" ;
const DEFAULT_GATEWAY_LAUNCH_AGENT_LABEL = "ai.openclaw.gateway" ;
type BootstrapRolloutStage = "internal" | "beta" | "default" ;
type BootstrapCheckStatus = "pass" | "warn" | "fail" ;
export type BootstrapCheck = {
id :
| "openclaw-cli"
| "profile"
| "gateway"
2026-03-02 18:31:15 -08:00
| "agent-auth"
2026-03-01 16:11:40 -08:00
| "web-ui"
| "state-isolation"
| "daemon-label"
| "rollout-stage"
| "cutover-gates" ;
status : BootstrapCheckStatus ;
detail : string ;
remediation? : string ;
} ;
export type BootstrapDiagnostics = {
rolloutStage : BootstrapRolloutStage ;
legacyFallbackEnabled : boolean ;
checks : BootstrapCheck [ ] ;
hasFailures : boolean ;
} ;
export type BootstrapOptions = {
2026-03-02 22:10:46 -08:00
profile? : string ;
2026-03-01 16:11:40 -08:00
yes? : boolean ;
nonInteractive? : boolean ;
forceOnboard? : boolean ;
skipUpdate? : boolean ;
updateNow? : boolean ;
noOpen? : boolean ;
json? : boolean ;
gatewayPort? : string | number ;
webPort? : string | number ;
} ;
type BootstrapSummary = {
profile : string ;
onboarded : boolean ;
installedOpenClawCli : boolean ;
openClawCliAvailable : boolean ;
openClawVersion? : string ;
gatewayUrl : string ;
gatewayReachable : boolean ;
2026-03-02 18:31:15 -08:00
gatewayAutoFix ? : {
attempted : boolean ;
recovered : boolean ;
steps : GatewayAutoFixStep [ ] ;
failureSummary? : string ;
logExcerpts : GatewayLogExcerpt [ ] ;
} ;
workspaceSeed? : WorkspaceSeedResult ;
2026-03-01 16:11:40 -08:00
webUrl : string ;
webReachable : boolean ;
webOpened : boolean ;
diagnostics : BootstrapDiagnostics ;
} ;
type SpawnResult = {
stdout : string ;
stderr : string ;
code : number ;
} ;
2026-03-02 18:31:15 -08:00
type OpenClawCliAvailability = {
available : boolean ;
installed : boolean ;
version? : string ;
command : string ;
globalBinDir? : string ;
shellCommandPath? : string ;
} ;
type GatewayAutoFixStep = {
name : string ;
ok : boolean ;
detail? : string ;
} ;
type GatewayLogExcerpt = {
path : string ;
excerpt : string ;
} ;
type GatewayAutoFixResult = {
attempted : boolean ;
recovered : boolean ;
steps : GatewayAutoFixStep [ ] ;
finalProbe : { ok : boolean ; detail? : string } ;
failureSummary? : string ;
logExcerpts : GatewayLogExcerpt [ ] ;
} ;
2026-03-01 16:11:40 -08:00
function resolveCommandForPlatform ( command : string ) : string {
if ( process . platform !== "win32" ) {
return command ;
}
if ( path . extname ( command ) ) {
return command ;
}
const normalized = path . basename ( command ) . toLowerCase ( ) ;
if (
normalized === "npm" ||
normalized === "pnpm" ||
normalized === "npx" ||
normalized === "yarn"
) {
return ` ${ command } .cmd ` ;
}
return command ;
}
async function runCommandWithTimeout (
argv : string [ ] ,
2026-03-02 18:31:15 -08:00
options : {
timeoutMs : number ;
cwd? : string ;
env? : NodeJS.ProcessEnv ;
ioMode ? : "capture" | "inherit" ;
} ,
2026-03-01 16:11:40 -08:00
) : Promise < SpawnResult > {
const [ command , . . . args ] = argv ;
if ( ! command ) {
return { code : 1 , stdout : "" , stderr : "missing command" } ;
}
2026-03-03 12:24:43 -08:00
const stdio : StdioOptions = options . ioMode === "inherit" ? "inherit" : [ "ignore" , "pipe" , "pipe" ] ;
2026-03-01 16:11:40 -08:00
return await new Promise < SpawnResult > ( ( resolve , reject ) = > {
const child = spawn ( resolveCommandForPlatform ( command ) , args , {
cwd : options.cwd ,
env : options.env ? { . . . process . env , . . . options . env } : process . env ,
2026-03-02 18:31:15 -08:00
stdio ,
2026-03-01 16:11:40 -08:00
} ) ;
let stdout = "" ;
let stderr = "" ;
let settled = false ;
const timer = setTimeout ( ( ) = > {
if ( settled ) {
return ;
}
child . kill ( "SIGKILL" ) ;
} , options . timeoutMs ) ;
2026-03-03 12:24:43 -08:00
child . stdout ? . on ( "data" , ( chunk : Buffer | string ) = > {
2026-03-01 16:11:40 -08:00
stdout += String ( chunk ) ;
} ) ;
2026-03-03 12:24:43 -08:00
child . stderr ? . on ( "data" , ( chunk : Buffer | string ) = > {
2026-03-01 16:11:40 -08:00
stderr += String ( chunk ) ;
} ) ;
2026-03-03 12:24:43 -08:00
child . once ( "error" , ( error : Error ) = > {
2026-03-01 16:11:40 -08:00
if ( settled ) {
return ;
}
settled = true ;
clearTimeout ( timer ) ;
reject ( error ) ;
} ) ;
2026-03-03 12:24:43 -08:00
child . once ( "close" , ( code : number | null ) = > {
2026-03-01 16:11:40 -08:00
if ( settled ) {
return ;
}
settled = true ;
clearTimeout ( timer ) ;
resolve ( {
code : typeof code === "number" ? code : 1 ,
stdout ,
stderr ,
} ) ;
} ) ;
} ) ;
}
function parseOptionalPort ( value : string | number | undefined ) : number | undefined {
if ( value === undefined ) {
return undefined ;
}
const raw = typeof value === "number" ? value : Number.parseInt ( String ( value ) , 10 ) ;
if ( ! Number . isFinite ( raw ) || raw <= 0 ) {
return undefined ;
}
return raw ;
}
async function sleep ( ms : number ) {
await new Promise ( ( resolve ) = > setTimeout ( resolve , ms ) ) ;
}
2026-03-03 12:24:43 -08:00
import { createConnection } from "node:net" ;
function isPortAvailable ( port : number ) : Promise < boolean > {
return new Promise ( ( resolve ) = > {
const server = createConnection ( { port , host : "127.0.0.1" } , ( ) = > {
// Connection succeeded, port is in use
server . end ( ) ;
resolve ( false ) ;
} ) ;
server . on ( "error" , ( err : NodeJS.ErrnoException ) = > {
if ( err . code === "ECONNREFUSED" ) {
// Port is available (nothing listening)
resolve ( true ) ;
} else if ( err . code === "EADDRNOTAVAIL" ) {
// Address not available
resolve ( false ) ;
} else {
// Other errors, assume port is not available
resolve ( false ) ;
}
} ) ;
server . setTimeout ( 1000 , ( ) = > {
server . destroy ( ) ;
resolve ( false ) ;
} ) ;
} ) ;
}
async function findAvailablePort (
startPort : number ,
maxAttempts : number ,
) : Promise < number | undefined > {
for ( let i = 0 ; i < maxAttempts ; i ++ ) {
const port = startPort + i ;
if ( await isPortAvailable ( port ) ) {
return port ;
}
}
return undefined ;
}
2026-03-01 16:11:40 -08:00
function normalizeBootstrapRolloutStage ( raw : string | undefined ) : BootstrapRolloutStage {
const normalized = raw ? . trim ( ) . toLowerCase ( ) ;
if ( normalized === "internal" || normalized === "beta" || normalized === "default" ) {
return normalized ;
}
return DEFAULT_BOOTSTRAP_ROLLOUT_STAGE ;
}
export function resolveBootstrapRolloutStage (
env : NodeJS.ProcessEnv = process . env ,
) : BootstrapRolloutStage {
return normalizeBootstrapRolloutStage (
env . IRONCLAW_BOOTSTRAP_ROLLOUT ? ? env . OPENCLAW_BOOTSTRAP_ROLLOUT ,
) ;
}
export function isLegacyFallbackEnabled ( env : NodeJS.ProcessEnv = process . env ) : boolean {
return (
isTruthyEnvValue ( env . IRONCLAW_BOOTSTRAP_LEGACY_FALLBACK ) ||
isTruthyEnvValue ( env . OPENCLAW_BOOTSTRAP_LEGACY_FALLBACK )
) ;
}
function normalizeVersionOutput ( raw : string | undefined ) : string | undefined {
const first = raw
? . split ( /\r?\n/ )
. map ( ( line ) = > line . trim ( ) )
. find ( Boolean ) ;
return first && first . length > 0 ? first : undefined ;
}
function firstNonEmptyLine ( . . . values : Array < string | undefined > ) : string | undefined {
for ( const value of values ) {
const first = value
? . split ( /\r?\n/ )
. map ( ( line ) = > line . trim ( ) )
. find ( Boolean ) ;
if ( first ) {
return first ;
}
}
return undefined ;
}
function resolveProfileStateDir ( profile : string , env : NodeJS.ProcessEnv = process . env ) : string {
2026-03-03 12:24:43 -08:00
void profile ;
2026-03-01 16:11:40 -08:00
const home = resolveRequiredHomeDir ( env , os . homedir ) ;
2026-03-03 12:24:43 -08:00
return path . join ( home , IRONCLAW_STATE_DIRNAME ) ;
2026-03-02 22:10:46 -08:00
}
2026-03-01 16:11:40 -08:00
function resolveGatewayLaunchAgentLabel ( profile : string ) : string {
const normalized = profile . trim ( ) . toLowerCase ( ) ;
if ( ! normalized || normalized === "default" ) {
return DEFAULT_GATEWAY_LAUNCH_AGENT_LABEL ;
}
return ` ai.openclaw. ${ normalized } ` ;
}
2026-03-02 18:31:15 -08:00
async function ensureGatewayModeLocal ( openclawCommand : string , profile : string ) : Promise < void > {
2026-03-01 16:11:40 -08:00
const result = await runOpenClaw (
2026-03-02 18:31:15 -08:00
openclawCommand ,
[ "--profile" , profile , "config" , "get" , "gateway.mode" ] ,
2026-03-01 16:11:40 -08:00
10 _000 ,
) ;
const currentMode = result . stdout . trim ( ) ;
if ( currentMode === "local" ) {
return ;
}
await runOpenClawOrThrow ( {
2026-03-02 18:31:15 -08:00
openclawCommand ,
args : [ "--profile" , profile , "config" , "set" , "gateway.mode" , "local" ] ,
2026-03-01 16:11:40 -08:00
timeoutMs : 10_000 ,
errorMessage : "Failed to set gateway.mode=local." ,
} ) ;
}
2026-03-03 12:24:43 -08:00
async function ensureGatewayPort (
openclawCommand : string ,
profile : string ,
gatewayPort : number ,
) : Promise < void > {
await runOpenClawOrThrow ( {
openclawCommand ,
args : [ "--profile" , profile , "config" , "set" , "gateway.port" , String ( gatewayPort ) ] ,
timeoutMs : 10_000 ,
errorMessage : ` Failed to set gateway.port= ${ gatewayPort } . ` ,
} ) ;
}
2026-03-03 13:47:23 -08:00
async function ensureDefaultWorkspacePath (
openclawCommand : string ,
profile : string ,
workspaceDir : string ,
) : Promise < void > {
await runOpenClawOrThrow ( {
openclawCommand ,
args : [ "--profile" , profile , "config" , "set" , "agents.defaults.workspace" , workspaceDir ] ,
timeoutMs : 10_000 ,
errorMessage : ` Failed to set agents.defaults.workspace= ${ workspaceDir } . ` ,
} ) ;
}
2026-03-03 16:34:03 -08:00
async function ensureSubagentDefaults ( openclawCommand : string , profile : string ) : Promise < void > {
const settings : Array < [ string , string ] > = [
[ "agents.defaults.subagents.maxConcurrent" , "8" ] ,
[ "agents.defaults.subagents.maxSpawnDepth" , "2" ] ,
[ "agents.defaults.subagents.maxChildrenPerAgent" , "10" ] ,
[ "agents.defaults.subagents.archiveAfterMinutes" , "180" ] ,
[ "agents.defaults.subagents.runTimeoutSeconds" , "0" ] ,
[ "tools.subagents.tools.deny" , "[]" ] ,
] ;
for ( const [ key , value ] of settings ) {
await runOpenClawOrThrow ( {
openclawCommand ,
args : [ "--profile" , profile , "config" , "set" , key , value ] ,
timeoutMs : 10_000 ,
errorMessage : ` Failed to set ${ key } = ${ value } . ` ,
} ) ;
}
}
2026-03-01 16:11:40 -08:00
async function probeForWebApp ( port : number ) : Promise < boolean > {
const controller = new AbortController ( ) ;
const timer = setTimeout ( ( ) = > controller . abort ( ) , 1 _500 ) ;
try {
2026-03-02 22:10:46 -08:00
const response = await fetch ( ` http://127.0.0.1: ${ port } /api/profiles ` , {
2026-03-01 16:11:40 -08:00
method : "GET" ,
signal : controller.signal ,
redirect : "manual" ,
} ) ;
2026-03-02 22:10:46 -08:00
if ( response . status < 200 || response . status >= 400 ) {
return false ;
}
const payload = ( await response . json ( ) . catch ( ( ) = > null ) ) as {
profiles? : unknown ;
activeProfile? : unknown ;
} | null ;
return Boolean (
payload &&
typeof payload === "object" &&
Array . isArray ( payload . profiles ) &&
typeof payload . activeProfile === "string" ,
) ;
2026-03-01 16:11:40 -08:00
} catch {
return false ;
} finally {
clearTimeout ( timer ) ;
}
}
2026-03-02 22:10:46 -08:00
async function waitForWebApp ( preferredPort : number ) : Promise < boolean > {
2026-03-01 16:11:40 -08:00
for ( let attempt = 0 ; attempt < WEB_APP_PROBE_ATTEMPTS ; attempt += 1 ) {
2026-03-02 22:10:46 -08:00
if ( await probeForWebApp ( preferredPort ) ) {
return true ;
2026-03-01 16:11:40 -08:00
}
await sleep ( WEB_APP_PROBE_DELAY_MS ) ;
}
2026-03-02 22:10:46 -08:00
return false ;
2026-03-01 16:11:40 -08:00
}
function resolveCliPackageRoot ( ) : string {
let dir = path . dirname ( fileURLToPath ( import . meta . url ) ) ;
for ( let i = 0 ; i < 5 ; i ++ ) {
if ( existsSync ( path . join ( dir , "package.json" ) ) ) {
return dir ;
}
dir = path . dirname ( dir ) ;
}
return process . cwd ( ) ;
}
/ * *
* Spawn the pre - built standalone Next . js server as a detached background
* process if it isn ' t already running on the target port .
* /
2026-03-03 12:24:43 -08:00
function startWebAppIfNeeded ( port : number , stateDir : string , gatewayPort : number ) : void {
2026-03-01 16:11:40 -08:00
const pkgRoot = resolveCliPackageRoot ( ) ;
const standaloneServer = path . join ( pkgRoot , "apps/web/.next/standalone/apps/web/server.js" ) ;
if ( ! existsSync ( standaloneServer ) ) {
return ;
}
const logDir = path . join ( stateDir , "logs" ) ;
mkdirSync ( logDir , { recursive : true } ) ;
const outFd = openSync ( path . join ( logDir , "web-app.log" ) , "a" ) ;
const errFd = openSync ( path . join ( logDir , "web-app.err.log" ) , "a" ) ;
const child = spawn ( process . execPath , [ standaloneServer ] , {
cwd : path.dirname ( standaloneServer ) ,
detached : true ,
stdio : [ "ignore" , outFd , errFd ] ,
2026-03-03 12:24:43 -08:00
env : {
. . . process . env ,
PORT : String ( port ) ,
HOSTNAME : "127.0.0.1" ,
OPENCLAW_GATEWAY_PORT : String ( gatewayPort ) ,
} ,
2026-03-01 16:11:40 -08:00
} ) ;
child . unref ( ) ;
}
2026-03-02 18:31:15 -08:00
async function runOpenClaw (
openclawCommand : string ,
args : string [ ] ,
timeoutMs : number ,
ioMode : "capture" | "inherit" = "capture" ,
2026-03-03 12:24:43 -08:00
env? : NodeJS.ProcessEnv ,
2026-03-02 18:31:15 -08:00
) : Promise < SpawnResult > {
2026-03-03 12:24:43 -08:00
return await runCommandWithTimeout ( [ openclawCommand , . . . args ] , { timeoutMs , ioMode , env } ) ;
2026-03-01 16:11:40 -08:00
}
async function runOpenClawOrThrow ( params : {
2026-03-02 18:31:15 -08:00
openclawCommand : string ;
args : string [ ] ;
2026-03-01 16:11:40 -08:00
timeoutMs : number ;
errorMessage : string ;
} ) : Promise < SpawnResult > {
2026-03-02 18:31:15 -08:00
const result = await runOpenClaw ( params . openclawCommand , params . args , params . timeoutMs ) ;
if ( result . code === 0 ) {
return result ;
}
const detail = firstNonEmptyLine ( result . stderr , result . stdout ) ;
throw new Error ( detail ? ` ${ params . errorMessage } \ n ${ detail } ` : params . errorMessage ) ;
}
/ * *
* Runs an OpenClaw command attached to the current terminal .
* Use this for interactive flows like ` openclaw onboard ` .
* /
async function runOpenClawInteractiveOrThrow ( params : {
openclawCommand : string ;
args : string [ ] ;
timeoutMs : number ;
errorMessage : string ;
} ) : Promise < SpawnResult > {
const result = await runOpenClaw (
params . openclawCommand ,
params . args ,
params . timeoutMs ,
"inherit" ,
) ;
2026-03-01 16:11:40 -08:00
if ( result . code === 0 ) {
return result ;
}
const detail = firstNonEmptyLine ( result . stderr , result . stdout ) ;
throw new Error ( detail ? ` ${ params . errorMessage } \ n ${ detail } ` : params . errorMessage ) ;
}
/ * *
* Runs an openclaw sub - command with a visible spinner that streams progress
* from the subprocess stdout / stderr into the spinner message .
* /
async function runOpenClawWithProgress ( params : {
2026-03-02 18:31:15 -08:00
openclawCommand : string ;
args : string [ ] ;
2026-03-01 16:11:40 -08:00
timeoutMs : number ;
startMessage : string ;
successMessage : string ;
errorMessage : string ;
} ) : Promise < SpawnResult > {
const s = spinner ( ) ;
s . start ( params . startMessage ) ;
const result = await new Promise < SpawnResult > ( ( resolve , reject ) = > {
2026-03-02 18:31:15 -08:00
const child = spawn ( resolveCommandForPlatform ( params . openclawCommand ) , params . args , {
2026-03-01 16:11:40 -08:00
stdio : [ "ignore" , "pipe" , "pipe" ] ,
} ) ;
let stdout = "" ;
let stderr = "" ;
let settled = false ;
const timer = setTimeout ( ( ) = > {
if ( ! settled ) {
child . kill ( "SIGKILL" ) ;
}
} , params . timeoutMs ) ;
const updateSpinner = ( chunk : string ) = > {
const line = chunk
. split ( /\r?\n/ )
. map ( ( l ) = > l . trim ( ) )
. filter ( Boolean )
. pop ( ) ;
if ( line ) {
s . message ( line . length > 72 ? ` ${ line . slice ( 0 , 69 ) } ... ` : line ) ;
}
} ;
child . stdout ? . on ( "data" , ( chunk ) = > {
const text = String ( chunk ) ;
stdout += text ;
updateSpinner ( text ) ;
} ) ;
child . stderr ? . on ( "data" , ( chunk ) = > {
const text = String ( chunk ) ;
stderr += text ;
updateSpinner ( text ) ;
} ) ;
child . once ( "error" , ( error ) = > {
if ( settled ) {
return ;
}
settled = true ;
clearTimeout ( timer ) ;
reject ( error ) ;
} ) ;
child . once ( "close" , ( code ) = > {
if ( settled ) {
return ;
}
settled = true ;
clearTimeout ( timer ) ;
resolve ( { code : typeof code === "number" ? code : 1 , stdout , stderr } ) ;
} ) ;
} ) ;
if ( result . code === 0 ) {
s . stop ( params . successMessage ) ;
return result ;
}
const detail = firstNonEmptyLine ( result . stderr , result . stdout ) ;
2026-03-03 12:24:43 -08:00
const stopMessage = detail ? ` ${ params . errorMessage } : ${ detail } ` : params . errorMessage ;
s . stop ( stopMessage ) ;
2026-03-01 16:11:40 -08:00
throw new Error ( detail ? ` ${ params . errorMessage } \ n ${ detail } ` : params . errorMessage ) ;
}
2026-03-02 18:31:15 -08:00
function parseJsonPayload ( raw : string | undefined ) : Record < string , unknown > | undefined {
if ( ! raw ) {
return undefined ;
}
const trimmed = raw . trim ( ) ;
if ( ! trimmed ) {
return undefined ;
}
try {
const parsed = JSON . parse ( trimmed ) ;
return parsed && typeof parsed === "object" ? ( parsed as Record < string , unknown > ) : undefined ;
} catch {
const start = trimmed . indexOf ( "{" ) ;
const end = trimmed . lastIndexOf ( "}" ) ;
if ( start === - 1 || end <= start ) {
return undefined ;
}
try {
const parsed = JSON . parse ( trimmed . slice ( start , end + 1 ) ) ;
return parsed && typeof parsed === "object" ? ( parsed as Record < string , unknown > ) : undefined ;
} catch {
return undefined ;
}
}
}
async function detectGlobalOpenClawInstall ( ) : Promise < { installed : boolean ; version? : string } > {
const result = await runCommandWithTimeout (
[ "npm" , "ls" , "-g" , "openclaw" , "--depth=0" , "--json" , "--silent" ] ,
{
timeoutMs : 15_000 ,
} ,
) . catch ( ( ) = > null ) ;
const parsed = parseJsonPayload ( result ? . stdout ? ? result ? . stderr ) ;
const dependencies = parsed ? . dependencies as
| Record < string , { version ? : string } | undefined >
| undefined ;
const installedVersion = dependencies ? . openclaw ? . version ;
if ( typeof installedVersion === "string" && installedVersion . length > 0 ) {
return { installed : true , version : installedVersion } ;
}
return { installed : false } ;
}
async function resolveNpmGlobalBinDir ( ) : Promise < string | undefined > {
const result = await runCommandWithTimeout ( [ "npm" , "prefix" , "-g" ] , {
timeoutMs : 8_000 ,
} ) . catch ( ( ) = > null ) ;
if ( ! result || result . code !== 0 ) {
return undefined ;
}
const prefix = firstNonEmptyLine ( result . stdout ) ;
if ( ! prefix ) {
return undefined ;
2026-03-01 16:11:40 -08:00
}
2026-03-02 18:31:15 -08:00
return process . platform === "win32" ? prefix : path.join ( prefix , "bin" ) ;
}
2026-03-01 16:11:40 -08:00
2026-03-02 18:31:15 -08:00
function resolveGlobalOpenClawCommand ( globalBinDir : string | undefined ) : string | undefined {
if ( ! globalBinDir ) {
return undefined ;
}
const candidates =
process . platform === "win32"
? [ path . join ( globalBinDir , "openclaw.cmd" ) , path . join ( globalBinDir , "openclaw.exe" ) ]
: [ path . join ( globalBinDir , "openclaw" ) ] ;
return candidates . find ( ( candidate ) = > existsSync ( candidate ) ) ;
}
async function resolveShellOpenClawPath ( ) : Promise < string | undefined > {
const locator = process . platform === "win32" ? "where" : "which" ;
const result = await runCommandWithTimeout ( [ locator , "openclaw" ] , {
timeoutMs : 4_000 ,
2026-03-01 16:11:40 -08:00
} ) . catch ( ( ) = > null ) ;
2026-03-02 18:31:15 -08:00
if ( ! result || result . code !== 0 ) {
return undefined ;
2026-03-01 16:11:40 -08:00
}
2026-03-02 18:31:15 -08:00
return firstNonEmptyLine ( result . stdout ) ;
}
2026-03-01 16:11:40 -08:00
2026-03-02 18:31:15 -08:00
function isProjectLocalOpenClawPath ( commandPath : string | undefined ) : boolean {
if ( ! commandPath ) {
return false ;
}
const normalized = commandPath . replaceAll ( "\\" , "/" ) ;
return normalized . includes ( "/node_modules/.bin/openclaw" ) ;
}
async function ensureOpenClawCliAvailable ( ) : Promise < OpenClawCliAvailability > {
const globalBefore = await detectGlobalOpenClawInstall ( ) ;
let installed = false ;
if ( ! globalBefore . installed ) {
const install = await runCommandWithTimeout ( [ "npm" , "install" , "-g" , "openclaw@latest" ] , {
timeoutMs : 10 * 60 _000 ,
} ) . catch ( ( ) = > null ) ;
if ( ! install || install . code !== 0 ) {
return {
available : false ,
installed : false ,
version : undefined ,
command : "openclaw" ,
} ;
}
installed = true ;
}
const globalAfter = installed ? await detectGlobalOpenClawInstall ( ) : globalBefore ;
const globalBinDir = await resolveNpmGlobalBinDir ( ) ;
const globalCommand = resolveGlobalOpenClawCommand ( globalBinDir ) ;
const command = globalCommand ? ? "openclaw" ;
const check = await runOpenClaw ( command , [ "--version" ] , 4 _000 ) . catch ( ( ) = > null ) ;
const shellCommandPath = await resolveShellOpenClawPath ( ) ;
const version = normalizeVersionOutput ( check ? . stdout || check ? . stderr || globalAfter . version ) ;
const available = Boolean ( globalAfter . installed && check && check . code === 0 ) ;
2026-03-01 16:11:40 -08:00
return {
2026-03-02 18:31:15 -08:00
available ,
installed ,
version ,
command ,
globalBinDir ,
shellCommandPath ,
2026-03-01 16:11:40 -08:00
} ;
}
2026-03-02 18:31:15 -08:00
async function probeGateway (
openclawCommand : string ,
profile : string ,
2026-03-03 12:24:43 -08:00
gatewayPort? : number ,
2026-03-02 18:31:15 -08:00
) : Promise < { ok : boolean ; detail? : string } > {
2026-03-03 12:24:43 -08:00
const env = gatewayPort ? { OPENCLAW_GATEWAY_PORT : String ( gatewayPort ) } : undefined ;
2026-03-01 16:11:40 -08:00
const result = await runOpenClaw (
2026-03-02 18:31:15 -08:00
openclawCommand ,
[ "--profile" , profile , "health" , "--json" ] ,
2026-03-01 16:11:40 -08:00
12 _000 ,
2026-03-03 12:24:43 -08:00
"capture" ,
env ,
2026-03-01 16:11:40 -08:00
) . catch ( ( error ) = > {
const message = error instanceof Error ? error.message : String ( error ) ;
return {
code : 1 ,
stdout : "" ,
stderr : message ,
} as SpawnResult ;
} ) ;
if ( result . code === 0 ) {
return { ok : true } ;
}
return {
ok : false ,
detail : firstNonEmptyLine ( result . stderr , result . stdout ) ,
} ;
}
2026-03-02 18:31:15 -08:00
function readLogTail ( logPath : string , maxLines = 16 ) : string | undefined {
if ( ! existsSync ( logPath ) ) {
return undefined ;
}
try {
const lines = readFileSync ( logPath , "utf-8" )
. split ( /\r?\n/ )
. map ( ( line ) = > line . trimEnd ( ) )
. filter ( ( line ) = > line . length > 0 ) ;
if ( lines . length === 0 ) {
return undefined ;
}
return lines . slice ( - maxLines ) . join ( "\n" ) ;
} catch {
return undefined ;
}
}
function resolveLatestRuntimeLogPath ( ) : string | undefined {
const runtimeLogDir = "/tmp/openclaw" ;
if ( ! existsSync ( runtimeLogDir ) ) {
return undefined ;
}
try {
const files = readdirSync ( runtimeLogDir )
. filter ( ( name ) = > / ^ o p e n c l a w - . * \ . l o g $ / u . t e s t ( n a m e ) )
. toSorted ( ( a , b ) = > b . localeCompare ( a ) ) ;
if ( files . length === 0 ) {
return undefined ;
}
return path . join ( runtimeLogDir , files [ 0 ] ) ;
} catch {
return undefined ;
}
}
function collectGatewayLogExcerpts ( stateDir : string ) : GatewayLogExcerpt [ ] {
const candidates = [
path . join ( stateDir , "logs" , "gateway.err.log" ) ,
path . join ( stateDir , "logs" , "gateway.log" ) ,
resolveLatestRuntimeLogPath ( ) ,
] . filter ( ( candidate ) : candidate is string = > Boolean ( candidate ) ) ;
const excerpts : GatewayLogExcerpt [ ] = [ ] ;
for ( const candidate of candidates ) {
const excerpt = readLogTail ( candidate ) ;
if ( ! excerpt ) {
continue ;
}
excerpts . push ( { path : candidate , excerpt } ) ;
}
return excerpts ;
}
function deriveGatewayFailureSummary (
probeDetail : string | undefined ,
excerpts : GatewayLogExcerpt [ ] ,
) : string | undefined {
const combinedLines = excerpts . flatMap ( ( entry ) = > entry . excerpt . split ( /\r?\n/ ) ) ;
const signalRegex =
2026-03-02 22:10:46 -08:00
/ ( c a n n o t f i n d m o d u l e | p l u g i n n o t f o u n d | i n v a l i d c o n f i g | u n a u t h o r i z e d | t o k e n m i s m a t c h | d e v i c e t o k e n m i s m a t c h | d e v i c e s i g n a t u r e i n v a l i d | d e v i c e s i g n a t u r e e x p i r e d | d e v i c e - s i g n a t u r e | e a d d r i n u s e | a d d r e s s a l r e a d y i n u s e | e r r o r : | f a i l e d t o | f a i l o v e r e r r o r ) / i u ;
2026-03-02 18:31:15 -08:00
const likely = [ . . . combinedLines ] . toReversed ( ) . find ( ( line ) = > signalRegex . test ( line ) ) ;
if ( likely ) {
return likely . length > 220 ? ` ${ likely . slice ( 0 , 217 ) } ... ` : likely ;
}
return probeDetail ;
}
async function attemptGatewayAutoFix ( params : {
openclawCommand : string ;
profile : string ;
stateDir : string ;
2026-03-03 12:24:43 -08:00
gatewayPort : number ;
2026-03-02 18:31:15 -08:00
} ) : Promise < GatewayAutoFixResult > {
const steps : GatewayAutoFixStep [ ] = [ ] ;
const commands : Array < {
name : string ;
args : string [ ] ;
timeoutMs : number ;
} > = [
2026-03-02 22:10:46 -08:00
{
name : "openclaw gateway stop" ,
args : [ "--profile" , params . profile , "gateway" , "stop" ] ,
timeoutMs : 90_000 ,
} ,
2026-03-02 18:31:15 -08:00
{
name : "openclaw doctor --fix" ,
args : [ "--profile" , params . profile , "doctor" , "--fix" ] ,
timeoutMs : 2 * 60 _000 ,
} ,
{
2026-03-02 22:10:46 -08:00
name : "openclaw gateway install --force" ,
2026-03-03 12:24:43 -08:00
args : [
"--profile" ,
params . profile ,
"gateway" ,
"install" ,
"--force" ,
"--port" ,
String ( params . gatewayPort ) ,
] ,
2026-03-02 18:31:15 -08:00
timeoutMs : 2 * 60 _000 ,
} ,
{
name : "openclaw gateway start" ,
2026-03-03 12:24:43 -08:00
args : [ "--profile" , params . profile , "gateway" , "start" , "--port" , String ( params . gatewayPort ) ] ,
2026-03-02 18:31:15 -08:00
timeoutMs : 2 * 60 _000 ,
} ,
] ;
for ( const command of commands ) {
const result = await runOpenClaw ( params . openclawCommand , command . args , command . timeoutMs ) . catch (
( error ) = > {
const message = error instanceof Error ? error.message : String ( error ) ;
return {
code : 1 ,
stdout : "" ,
stderr : message ,
} as SpawnResult ;
} ,
) ;
steps . push ( {
name : command.name ,
ok : result.code === 0 ,
detail : result.code === 0 ? undefined : firstNonEmptyLine ( result . stderr , result . stdout ) ,
} ) ;
}
2026-03-03 12:24:43 -08:00
let finalProbe = await probeGateway ( params . openclawCommand , params . profile , params . gatewayPort ) ;
2026-03-02 18:31:15 -08:00
for ( let attempt = 0 ; attempt < 2 && ! finalProbe . ok ; attempt += 1 ) {
await sleep ( 1 _200 ) ;
2026-03-03 12:24:43 -08:00
finalProbe = await probeGateway ( params . openclawCommand , params . profile , params . gatewayPort ) ;
2026-03-02 18:31:15 -08:00
}
const logExcerpts = finalProbe . ok ? [ ] : collectGatewayLogExcerpts ( params . stateDir ) ;
const failureSummary = finalProbe . ok
? undefined
: deriveGatewayFailureSummary ( finalProbe . detail , logExcerpts ) ;
return {
attempted : true ,
recovered : finalProbe.ok ,
steps ,
finalProbe ,
failureSummary ,
logExcerpts ,
} ;
}
2026-03-01 16:11:40 -08:00
async function openUrl ( url : string ) : Promise < boolean > {
const argv =
process . platform === "darwin"
? [ "open" , url ]
: process . platform === "win32"
? [ "cmd" , "/c" , "start" , "" , url ]
: [ "xdg-open" , url ] ;
const result = await runCommandWithTimeout ( argv , { timeoutMs : 5_000 } ) . catch ( ( ) = > null ) ;
return Boolean ( result && result . code === 0 ) ;
}
2026-03-02 22:10:46 -08:00
function remediationForGatewayFailure (
detail : string | undefined ,
port : number ,
profile : string ,
) : string {
2026-03-01 16:11:40 -08:00
const normalized = detail ? . toLowerCase ( ) ? ? "" ;
2026-03-02 22:10:46 -08:00
const isDeviceAuthMismatch =
normalized . includes ( "device token mismatch" ) ||
normalized . includes ( "device signature invalid" ) ||
normalized . includes ( "device signature expired" ) ||
normalized . includes ( "device-signature" ) ;
if ( isDeviceAuthMismatch ) {
return [
` Gateway device-auth mismatch detected. Re-run \` openclaw --profile ${ profile } onboard --install-daemon --reset \` . ` ,
` Last resort (security downgrade): \` openclaw --profile ${ profile } config set gateway.controlUi.dangerouslyDisableDeviceAuth true \` . Revert after recovery: \` openclaw --profile ${ profile } config set gateway.controlUi.dangerouslyDisableDeviceAuth false \` . ` ,
] . join ( " " ) ;
2026-03-01 16:11:40 -08:00
}
if (
normalized . includes ( "unauthorized" ) ||
normalized . includes ( "token" ) ||
normalized . includes ( "password" )
) {
2026-03-02 22:10:46 -08:00
return ` Gateway auth mismatch detected. Re-run \` openclaw --profile ${ profile } onboard --install-daemon --reset \` . ` ;
2026-03-01 16:11:40 -08:00
}
if ( normalized . includes ( "address already in use" ) || normalized . includes ( "eaddrinuse" ) ) {
2026-03-03 12:24:43 -08:00
return ` Port ${ port } is busy. The bootstrap will auto-assign an available port, or you can explicitly specify one with \` --gateway-port <port> \` . ` ;
2026-03-01 16:11:40 -08:00
}
2026-03-02 22:10:46 -08:00
return ` Run \` openclaw --profile ${ profile } doctor --fix \` and retry \` ironclaw bootstrap --profile ${ profile } --force-onboard \` . ` ;
2026-03-01 16:11:40 -08:00
}
function remediationForWebUiFailure ( port : number ) : string {
return ` Web UI did not respond on ${ port } . Ensure the apps/web directory exists and rerun with \` ironclaw bootstrap --web-port <port> \` if needed. ` ;
}
2026-03-02 18:31:15 -08:00
function describeWorkspaceSeedResult ( result : WorkspaceSeedResult ) : string {
if ( result . seeded ) {
return ` seeded ${ result . dbPath } ` ;
}
if ( result . reason === "already-exists" ) {
return ` skipped; existing database found at ${ result . dbPath } ` ;
}
if ( result . reason === "seed-asset-missing" ) {
return ` skipped; seed asset missing at ${ result . seedDbPath } ` ;
}
if ( result . reason === "copy-failed" ) {
return ` failed to copy seed database: ${ result . error ? ? "unknown error" } ` ;
}
return ` skipped; reason= ${ result . reason } ` ;
}
2026-03-01 16:11:40 -08:00
function createCheck (
id : BootstrapCheck [ "id" ] ,
status : BootstrapCheckStatus ,
detail : string ,
remediation? : string ,
) : BootstrapCheck {
return { id , status , detail , remediation } ;
}
2026-03-02 18:31:15 -08:00
/ * *
* Load OpenClaw profile config from state dir .
* Supports both openclaw . json ( current ) and config . json ( legacy ) .
* /
function readBootstrapConfig ( stateDir : string ) : Record < string , unknown > | undefined {
for ( const name of [ "openclaw.json" , "config.json" ] ) {
const configPath = path . join ( stateDir , name ) ;
if ( ! existsSync ( configPath ) ) {
continue ;
}
try {
const raw = JSON . parse ( readFileSync ( configPath , "utf-8" ) ) ;
if ( raw && typeof raw === "object" ) {
return raw as Record < string , unknown > ;
}
} catch {
// Config unreadable; skip.
}
}
return undefined ;
}
2026-03-03 12:24:43 -08:00
function resolveBootstrapWorkspaceDir ( stateDir : string ) : string {
2026-03-03 13:47:23 -08:00
return path . join ( stateDir , "workspace" ) ;
2026-03-02 18:31:15 -08:00
}
/ * *
* Resolve the model provider prefix from the config ' s primary model string .
* e . g . "vercel-ai-gateway/anthropic/claude-opus-4.6" → "vercel-ai-gateway"
* /
function resolveModelProvider ( stateDir : string ) : string | undefined {
const raw = readBootstrapConfig ( stateDir ) ;
const model = ( raw as { agents ? : { defaults ? : { model ? : { primary? : string } | string } } } )
? . agents ? . defaults ? . model ;
const modelName = typeof model === "string" ? model : model?.primary ;
if ( typeof modelName === "string" && modelName . includes ( "/" ) ) {
return modelName . split ( "/" ) [ 0 ] ;
}
return undefined ;
}
/ * *
* Check if the agent auth store has at least one key for the given provider .
* /
export function checkAgentAuth (
stateDir : string ,
provider : string | undefined ,
) : { ok : boolean ; provider? : string ; detail : string } {
if ( ! provider ) {
return { ok : false , detail : "No model provider configured." } ;
}
const authPath = path . join ( stateDir , "agents" , "main" , "agent" , "auth-profiles.json" ) ;
if ( ! existsSync ( authPath ) ) {
return {
ok : false ,
provider ,
detail : ` No auth-profiles.json found for agent (expected at ${ authPath } ). ` ,
} ;
}
try {
const raw = JSON . parse ( readFileSync ( authPath , "utf-8" ) ) ;
const profiles = raw ? . profiles ;
if ( ! profiles || typeof profiles !== "object" ) {
return { ok : false , provider , detail : ` auth-profiles.json has no profiles configured. ` } ;
}
const hasKey = Object . values ( profiles ) . some (
( p : unknown ) = >
p &&
typeof p === "object" &&
( p as Record < string , unknown > ) . provider === provider &&
typeof ( p as Record < string , unknown > ) . key === "string" &&
( ( p as Record < string , unknown > ) . key as string ) . length > 0 ,
) ;
if ( ! hasKey ) {
return {
ok : false ,
provider ,
detail : ` No API key for provider " ${ provider } " in agent auth store. ` ,
} ;
}
return { ok : true , provider , detail : ` API key configured for ${ provider } . ` } ;
} catch {
return { ok : false , provider , detail : ` Failed to read auth-profiles.json. ` } ;
}
}
2026-03-01 16:11:40 -08:00
export function buildBootstrapDiagnostics ( params : {
profile : string ;
openClawCliAvailable : boolean ;
openClawVersion? : string ;
gatewayPort : number ;
gatewayUrl : string ;
gatewayProbe : { ok : boolean ; detail? : string } ;
webPort : number ;
webReachable : boolean ;
rolloutStage : BootstrapRolloutStage ;
legacyFallbackEnabled : boolean ;
2026-03-02 18:31:15 -08:00
stateDir? : string ;
2026-03-01 16:11:40 -08:00
env? : NodeJS.ProcessEnv ;
} ) : BootstrapDiagnostics {
const env = params . env ? ? process . env ;
const checks : BootstrapCheck [ ] = [ ] ;
if ( params . openClawCliAvailable ) {
checks . push (
createCheck (
"openclaw-cli" ,
"pass" ,
` OpenClaw CLI detected ${ params . openClawVersion ? ` ( ${ params . openClawVersion } ) ` : "" } . ` ,
) ,
) ;
} else {
checks . push (
createCheck (
"openclaw-cli" ,
"fail" ,
"OpenClaw CLI is missing." ,
"Install OpenClaw globally once: `npm install -g openclaw`." ,
) ,
) ;
}
if ( params . profile === DEFAULT_IRONCLAW_PROFILE ) {
2026-03-03 12:24:43 -08:00
checks . push ( createCheck ( "profile" , "pass" , ` Profile pinned: ${ params . profile } . ` ) ) ;
2026-03-01 16:11:40 -08:00
} else {
checks . push (
createCheck (
"profile" ,
2026-03-03 12:24:43 -08:00
"fail" ,
` Ironclaw profile drift detected ( ${ params . profile } ). ` ,
` Ironclaw requires \` --profile ${ DEFAULT_IRONCLAW_PROFILE } \` . Re-run bootstrap to repair environment defaults. ` ,
2026-03-01 16:11:40 -08:00
) ,
) ;
}
if ( params . gatewayProbe . ok ) {
checks . push ( createCheck ( "gateway" , "pass" , ` Gateway reachable at ${ params . gatewayUrl } . ` ) ) ;
} else {
checks . push (
createCheck (
"gateway" ,
"fail" ,
` Gateway probe failed at ${ params . gatewayUrl } ${ params . gatewayProbe . detail ? ` ( ${ params . gatewayProbe . detail } ) ` : "" } . ` ,
2026-03-02 22:10:46 -08:00
remediationForGatewayFailure (
params . gatewayProbe . detail ,
params . gatewayPort ,
params . profile ,
) ,
2026-03-01 16:11:40 -08:00
) ,
) ;
}
2026-03-02 18:31:15 -08:00
const stateDir = params . stateDir ? ? resolveProfileStateDir ( params . profile , env ) ;
const modelProvider = resolveModelProvider ( stateDir ) ;
const authCheck = checkAgentAuth ( stateDir , modelProvider ) ;
if ( authCheck . ok ) {
checks . push ( createCheck ( "agent-auth" , "pass" , authCheck . detail ) ) ;
} else {
checks . push (
createCheck (
"agent-auth" ,
"fail" ,
authCheck . detail ,
2026-03-03 12:24:43 -08:00
` Run \` openclaw --profile ${ DEFAULT_IRONCLAW_PROFILE } onboard --install-daemon \` to configure API keys. ` ,
2026-03-02 18:31:15 -08:00
) ,
) ;
}
2026-03-01 16:11:40 -08:00
if ( params . webReachable ) {
checks . push ( createCheck ( "web-ui" , "pass" , ` Web UI reachable on port ${ params . webPort } . ` ) ) ;
} else {
checks . push (
createCheck (
"web-ui" ,
"fail" ,
` Web UI is not reachable on port ${ params . webPort } . ` ,
remediationForWebUiFailure ( params . webPort ) ,
) ,
) ;
}
2026-03-03 12:24:43 -08:00
const expectedStateDir = resolveProfileStateDir ( DEFAULT_IRONCLAW_PROFILE , env ) ;
const usesPinnedStateDir = path . resolve ( stateDir ) === path . resolve ( expectedStateDir ) ;
if ( usesPinnedStateDir ) {
checks . push ( createCheck ( "state-isolation" , "pass" , ` State dir pinned: ${ stateDir } . ` ) ) ;
2026-03-01 16:11:40 -08:00
} else {
checks . push (
createCheck (
"state-isolation" ,
"fail" ,
2026-03-03 12:24:43 -08:00
` Unexpected state dir: ${ stateDir } . ` ,
` Ironclaw requires \` ${ expectedStateDir } \` . Re-run bootstrap to restore pinned defaults. ` ,
2026-03-01 16:11:40 -08:00
) ,
) ;
}
const launchAgentLabel = resolveGatewayLaunchAgentLabel ( params . profile ) ;
2026-03-03 12:24:43 -08:00
const expectedLaunchAgentLabel = resolveGatewayLaunchAgentLabel ( DEFAULT_IRONCLAW_PROFILE ) ;
if ( launchAgentLabel === expectedLaunchAgentLabel ) {
2026-03-01 16:11:40 -08:00
checks . push ( createCheck ( "daemon-label" , "pass" , ` Gateway service label: ${ launchAgentLabel } . ` ) ) ;
} else {
checks . push (
createCheck (
"daemon-label" ,
"fail" ,
2026-03-03 12:24:43 -08:00
` Gateway service label mismatch ( ${ launchAgentLabel } ). ` ,
` Ironclaw requires launch agent label ${ expectedLaunchAgentLabel } . ` ,
2026-03-01 16:11:40 -08:00
) ,
) ;
}
checks . push (
createCheck (
"rollout-stage" ,
params . rolloutStage === "default" ? "pass" : "warn" ,
` Bootstrap rollout stage: ${ params . rolloutStage } ${ params . legacyFallbackEnabled ? " (legacy fallback enabled)" : "" } . ` ,
params . rolloutStage === "beta"
? "Enable beta cutover by setting IRONCLAW_BOOTSTRAP_BETA_OPT_IN=1."
: undefined ,
) ,
) ;
const migrationSuiteOk = isTruthyEnvValue ( env . IRONCLAW_BOOTSTRAP_MIGRATION_SUITE_OK ) ;
const onboardingE2EOk = isTruthyEnvValue ( env . IRONCLAW_BOOTSTRAP_ONBOARDING_E2E_OK ) ;
const enforceCutoverGates = isTruthyEnvValue ( env . IRONCLAW_BOOTSTRAP_ENFORCE_SAFETY_GATES ) ;
const cutoverGatePassed = migrationSuiteOk && onboardingE2EOk ;
checks . push (
createCheck (
"cutover-gates" ,
cutoverGatePassed ? "pass" : enforceCutoverGates ? "fail" : "warn" ,
` Cutover gate: migrationSuite= ${ migrationSuiteOk ? "pass" : "missing" } , onboardingE2E= ${ onboardingE2EOk ? "pass" : "missing" } . ` ,
cutoverGatePassed
? undefined
: "Run migration contracts + onboarding E2E and set IRONCLAW_BOOTSTRAP_MIGRATION_SUITE_OK=1 and IRONCLAW_BOOTSTRAP_ONBOARDING_E2E_OK=1 before full cutover." ,
) ,
) ;
return {
rolloutStage : params.rolloutStage ,
legacyFallbackEnabled : params.legacyFallbackEnabled ,
checks ,
hasFailures : checks.some ( ( check ) = > check . status === "fail" ) ,
} ;
}
function formatCheckStatus ( status : BootstrapCheckStatus ) : string {
if ( status === "pass" ) {
return theme . success ( "[ok]" ) ;
}
if ( status === "warn" ) {
return theme . warn ( "[warn]" ) ;
}
return theme . error ( "[fail]" ) ;
}
function logBootstrapChecklist ( diagnostics : BootstrapDiagnostics , runtime : RuntimeEnv ) {
runtime . log ( "" ) ;
runtime . log ( theme . heading ( "Bootstrap checklist" ) ) ;
for ( const check of diagnostics . checks ) {
runtime . log ( ` ${ formatCheckStatus ( check . status ) } ${ check . detail } ` ) ;
if ( check . status !== "pass" && check . remediation ) {
runtime . log ( theme . muted ( ` remediation: ${ check . remediation } ` ) ) ;
}
}
}
async function shouldRunUpdate ( params : {
opts : BootstrapOptions ;
runtime : RuntimeEnv ;
} ) : Promise < boolean > {
if ( params . opts . updateNow ) {
return true ;
}
if (
params . opts . skipUpdate ||
params . opts . nonInteractive ||
params . opts . json ||
! process . stdin . isTTY
) {
return false ;
}
const decision = await confirm ( {
message : stylePromptMessage ( "Check and install OpenClaw updates now?" ) ,
initialValue : false ,
} ) ;
if ( isCancel ( decision ) ) {
params . runtime . log ( theme . muted ( "Update check skipped." ) ) ;
return false ;
}
return Boolean ( decision ) ;
}
export async function bootstrapCommand (
opts : BootstrapOptions ,
runtime : RuntimeEnv = defaultRuntime ,
) : Promise < BootstrapSummary > {
const nonInteractive = Boolean ( opts . nonInteractive || opts . json ) ;
const rolloutStage = resolveBootstrapRolloutStage ( ) ;
const legacyFallbackEnabled = isLegacyFallbackEnabled ( ) ;
2026-03-03 12:24:43 -08:00
const appliedProfile = applyCliProfileEnv ( { profile : opts.profile } ) ;
const profile = appliedProfile . effectiveProfile ;
if ( appliedProfile . warning && ! opts . json ) {
runtime . log ( theme . warn ( appliedProfile . warning ) ) ;
}
2026-03-01 16:11:40 -08:00
const installResult = await ensureOpenClawCliAvailable ( ) ;
if ( ! installResult . available ) {
throw new Error (
[
"OpenClaw CLI is required but unavailable." ,
"Install it with: npm install -g openclaw" ,
2026-03-02 18:31:15 -08:00
installResult . globalBinDir
? ` Expected global binary directory: ${ installResult . globalBinDir } `
: "" ,
]
. filter ( ( line ) = > line . length > 0 )
. join ( "\n" ) ,
2026-03-01 16:11:40 -08:00
) ;
}
2026-03-02 18:31:15 -08:00
const openclawCommand = installResult . command ;
2026-03-01 16:11:40 -08:00
2026-03-02 22:10:46 -08:00
if ( await shouldRunUpdate ( { opts , runtime } ) ) {
await runOpenClawWithProgress ( {
openclawCommand ,
args : [ "update" , "--yes" ] ,
timeoutMs : 8 * 60 _000 ,
startMessage : "Checking for OpenClaw updates..." ,
successMessage : "OpenClaw is up to date." ,
errorMessage : "OpenClaw update failed" ,
} ) ;
}
2026-03-03 12:24:43 -08:00
// Determine gateway port: use explicit override, or find available port
const explicitPort = parseOptionalPort ( opts . gatewayPort ) ;
let gatewayPort : number ;
let portAutoAssigned = false ;
if ( explicitPort ) {
gatewayPort = explicitPort ;
} else if ( await isPortAvailable ( DEFAULT_GATEWAY_PORT ) ) {
gatewayPort = DEFAULT_GATEWAY_PORT ;
} else {
// Default port is taken, find an available one starting from Ironclaw range
const availablePort = await findAvailablePort (
IRONCLAW_GATEWAY_PORT_START ,
MAX_PORT_SCAN_ATTEMPTS ,
) ;
if ( ! availablePort ) {
throw new Error (
` Could not find an available gateway port between ${ IRONCLAW_GATEWAY_PORT_START } and ${ IRONCLAW_GATEWAY_PORT_START + MAX_PORT_SCAN_ATTEMPTS } . ` +
` Please specify a port explicitly with --gateway-port. ` ,
) ;
}
gatewayPort = availablePort ;
portAutoAssigned = true ;
}
2026-03-01 16:11:40 -08:00
const stateDir = resolveProfileStateDir ( profile ) ;
2026-03-03 13:47:23 -08:00
const workspaceDir = resolveBootstrapWorkspaceDir ( stateDir ) ;
2026-03-03 12:24:43 -08:00
if ( portAutoAssigned && ! opts . json ) {
runtime . log (
theme . muted (
` Default gateway port ${ DEFAULT_GATEWAY_PORT } is in use. Using auto-assigned port ${ gatewayPort } . ` ,
) ,
) ;
}
2026-03-03 13:47:23 -08:00
// Pin OpenClaw to the managed default workspace before onboarding so bootstrap
// never drifts into creating/using legacy workspace-* paths.
await ensureDefaultWorkspacePath ( openclawCommand , profile , workspaceDir ) ;
2026-03-02 18:31:15 -08:00
const onboardArgv = [
"--profile" ,
profile ,
"onboard" ,
"--install-daemon" ,
"--gateway-bind" ,
"loopback" ,
"--gateway-port" ,
2026-03-03 12:24:43 -08:00
String ( gatewayPort ) ,
2026-03-02 18:31:15 -08:00
] ;
2026-03-02 22:10:46 -08:00
if ( opts . forceOnboard ) {
onboardArgv . push ( "--reset" ) ;
}
2026-03-02 18:31:15 -08:00
if ( nonInteractive ) {
onboardArgv . push ( "--non-interactive" , "--accept-risk" ) ;
}
if ( opts . noOpen ) {
onboardArgv . push ( "--skip-ui" ) ;
}
if ( nonInteractive ) {
2026-03-01 16:11:40 -08:00
await runOpenClawOrThrow ( {
2026-03-02 18:31:15 -08:00
openclawCommand ,
args : onboardArgv ,
timeoutMs : 12 * 60 _000 ,
errorMessage : "OpenClaw onboarding failed." ,
} ) ;
} else {
await runOpenClawInteractiveOrThrow ( {
openclawCommand ,
args : onboardArgv ,
2026-03-01 16:11:40 -08:00
timeoutMs : 12 * 60 _000 ,
errorMessage : "OpenClaw onboarding failed." ,
} ) ;
}
2026-03-02 18:31:15 -08:00
const workspaceSeed = seedWorkspaceFromAssets ( {
2026-03-03 12:24:43 -08:00
workspaceDir ,
2026-03-02 18:31:15 -08:00
packageRoot : resolveCliPackageRoot ( ) ,
} ) ;
2026-03-01 16:11:40 -08:00
2026-03-02 18:31:15 -08:00
// Ensure gateway.mode=local so the gateway never drifts to remote mode.
// Keep this post-onboard so we normalize any wizard defaults.
await ensureGatewayModeLocal ( openclawCommand , profile ) ;
2026-03-03 12:24:43 -08:00
// Persist the assigned port so all runtime clients (including web) resolve
// the same gateway target on subsequent requests.
await ensureGatewayPort ( openclawCommand , profile , gatewayPort ) ;
2026-03-01 16:11:40 -08:00
2026-03-03 16:34:03 -08:00
await ensureSubagentDefaults ( openclawCommand , profile ) ;
2026-03-03 12:24:43 -08:00
let gatewayProbe = await probeGateway ( openclawCommand , profile , gatewayPort ) ;
2026-03-02 18:31:15 -08:00
let gatewayAutoFix : GatewayAutoFixResult | undefined ;
if ( ! gatewayProbe . ok ) {
gatewayAutoFix = await attemptGatewayAutoFix ( {
openclawCommand ,
profile ,
stateDir ,
2026-03-03 12:24:43 -08:00
gatewayPort ,
2026-03-02 18:31:15 -08:00
} ) ;
gatewayProbe = gatewayAutoFix . finalProbe ;
if ( ! gatewayProbe . ok && gatewayAutoFix . failureSummary ) {
gatewayProbe = {
. . . gatewayProbe ,
detail : [ gatewayProbe . detail , gatewayAutoFix . failureSummary ]
. filter ( ( value , index , self ) = > value && self . indexOf ( value ) === index )
. join ( " | " ) ,
} ;
}
}
2026-03-03 12:24:43 -08:00
const gatewayUrl = ` ws://127.0.0.1: ${ gatewayPort } ` ;
2026-03-01 16:11:40 -08:00
const preferredWebPort = parseOptionalPort ( opts . webPort ) ? ? DEFAULT_WEB_APP_PORT ;
if ( ! ( await probeForWebApp ( preferredWebPort ) ) ) {
2026-03-03 12:24:43 -08:00
startWebAppIfNeeded ( preferredWebPort , stateDir , gatewayPort ) ;
2026-03-01 16:11:40 -08:00
}
2026-03-02 22:10:46 -08:00
const webReachable = await waitForWebApp ( preferredWebPort ) ;
const webUrl = ` http://localhost: ${ preferredWebPort } ` ;
2026-03-01 16:11:40 -08:00
const diagnostics = buildBootstrapDiagnostics ( {
profile ,
openClawCliAvailable : installResult.available ,
openClawVersion : installResult.version ,
2026-03-03 12:24:43 -08:00
gatewayPort ,
2026-03-01 16:11:40 -08:00
gatewayUrl ,
gatewayProbe ,
2026-03-02 22:10:46 -08:00
webPort : preferredWebPort ,
2026-03-01 16:11:40 -08:00
webReachable ,
rolloutStage ,
legacyFallbackEnabled ,
2026-03-02 18:31:15 -08:00
stateDir ,
2026-03-01 16:11:40 -08:00
} ) ;
const shouldOpen = ! opts . noOpen && ! opts . json ;
const opened = shouldOpen ? await openUrl ( webUrl ) : false ;
if ( ! opts . json ) {
2026-03-02 18:31:15 -08:00
if ( installResult . installed ) {
runtime . log ( theme . muted ( "Installed global OpenClaw CLI via npm." ) ) ;
}
if ( isProjectLocalOpenClawPath ( installResult . shellCommandPath ) ) {
runtime . log (
theme . warn (
` \` openclaw \` currently resolves to a project-local binary ( ${ installResult . shellCommandPath } ). ` ,
) ,
) ;
runtime . log (
theme . muted (
` Bootstrap now uses the global binary ( ${ openclawCommand } ) to avoid repo-local drift. ` ,
) ,
) ;
} else if ( ! installResult . shellCommandPath && installResult . globalBinDir ) {
runtime . log (
theme . warn ( "Global OpenClaw was installed, but `openclaw` is not on shell PATH." ) ,
) ;
runtime . log (
theme . muted (
` Add this to your shell profile, then open a new terminal: export PATH=" ${ installResult . globalBinDir } : $ PATH" ` ,
) ,
) ;
}
runtime . log ( theme . muted ( ` Workspace seed: ${ describeWorkspaceSeedResult ( workspaceSeed ) } ` ) ) ;
if ( gatewayAutoFix ? . attempted ) {
runtime . log (
theme . muted (
` Gateway auto-fix ${ gatewayAutoFix . recovered ? "recovered connectivity" : "ran but gateway is still unhealthy" } . ` ,
) ,
) ;
for ( const step of gatewayAutoFix . steps ) {
runtime . log (
theme . muted (
` ${ step . ok ? "[ok]" : "[fail]" } ${ step . name } ${ step . detail ? ` ( ${ step . detail } ) ` : "" } ` ,
) ,
) ;
}
if ( ! gatewayAutoFix . recovered && gatewayAutoFix . failureSummary ) {
runtime . log ( theme . error ( ` Likely gateway cause: ${ gatewayAutoFix . failureSummary } ` ) ) ;
}
if ( ! gatewayAutoFix . recovered && gatewayAutoFix . logExcerpts . length > 0 ) {
runtime . log ( theme . muted ( "Recent gateway logs:" ) ) ;
for ( const excerpt of gatewayAutoFix . logExcerpts ) {
runtime . log ( theme . muted ( ` ${ excerpt . path } ` ) ) ;
for ( const line of excerpt . excerpt . split ( /\r?\n/ ) ) {
runtime . log ( theme . muted ( ` ${ line } ` ) ) ;
}
}
}
}
2026-03-01 16:11:40 -08:00
logBootstrapChecklist ( diagnostics , runtime ) ;
runtime . log ( "" ) ;
runtime . log ( theme . heading ( "IronClaw ready" ) ) ;
runtime . log ( ` Profile: ${ profile } ` ) ;
runtime . log ( ` OpenClaw CLI: ${ installResult . version ? ? "detected" } ` ) ;
runtime . log ( ` Gateway: ${ gatewayProbe . ok ? "reachable" : "check failed" } ` ) ;
runtime . log ( ` Web UI: ${ webUrl } ` ) ;
runtime . log (
` Rollout stage: ${ rolloutStage } ${ legacyFallbackEnabled ? " (legacy fallback enabled)" : "" } ` ,
) ;
if ( ! opened && shouldOpen ) {
runtime . log ( theme . muted ( "Browser open failed; copy/paste the URL above." ) ) ;
}
if ( diagnostics . hasFailures ) {
runtime . log (
theme . warn (
"Bootstrap completed with failing checks. Address remediation items above before full cutover." ,
) ,
) ;
}
}
const summary : BootstrapSummary = {
profile ,
2026-03-02 18:31:15 -08:00
onboarded : true ,
2026-03-01 16:11:40 -08:00
installedOpenClawCli : installResult.installed ,
openClawCliAvailable : installResult.available ,
openClawVersion : installResult.version ,
gatewayUrl ,
gatewayReachable : gatewayProbe.ok ,
2026-03-02 18:31:15 -08:00
gatewayAutoFix : gatewayAutoFix
? {
attempted : gatewayAutoFix.attempted ,
recovered : gatewayAutoFix.recovered ,
steps : gatewayAutoFix.steps ,
failureSummary : gatewayAutoFix.failureSummary ,
logExcerpts : gatewayAutoFix.logExcerpts ,
}
: undefined ,
workspaceSeed ,
2026-03-01 16:11:40 -08:00
webUrl ,
webReachable ,
webOpened : opened ,
diagnostics ,
} ;
if ( opts . json ) {
runtime . log ( JSON . stringify ( summary , null , 2 ) ) ;
}
return summary ;
}