CLI: add managed web runtime with start/stop/update commands and major-version gating

Refactor bootstrap to use a managed web runtime lifecycle instead of
ad-hoc standalone server spawning. The managed runtime copies packaged
Next.js assets into ~/.openclaw-dench/web-runtime/, tracks deployment
state via manifest/process metadata, and cleanly separates Dench-owned
processes from foreign listeners on the target port.

- Fix false-negative web readiness when /api/profiles returns null
  activeProfile (first-run regression).
- Add `dench start` (start without updating assets), `dench stop`
  (terminate only Dench-managed web server), and `dench update`
  (refresh web runtime with major-version OpenClaw update gate).
- Major-version transitions (e.g. v2->v3) require mandatory OpenClaw
  update; non-interactive mode fails closed without --yes.
- All lifecycle commands show the ASCII banner/logo animation.
- Deploy smoke checks now verify update/stop/start --help paths.
This commit is contained in:
kumarabhirup 2026-03-04 16:32:58 -08:00
parent 4d6eec741d
commit af45d4d17b
No known key found for this signature in database
GPG Key ID: DB7CA2289CAB0167
15 changed files with 2414 additions and 441 deletions

View File

@ -1,6 +1,6 @@
{
"name": "denchclaw",
"version": "2026.2.22-1.1",
"version": "2.0.1",
"description": "AI-powered CRM platform with multi-channel agent gateway, DuckDB workspace, and knowledge management",
"keywords": [],
"homepage": "https://github.com/openclaw/openclaw#readme",
@ -14,21 +14,15 @@
"url": "git+https://github.com/openclaw/openclaw.git"
},
"bin": {
"dench": "openclaw.mjs",
"denchclaw": "openclaw.mjs"
},
"directories": {
"doc": "docs",
"test": "test"
"dench": "denchclaw.mjs",
"denchclaw": "denchclaw.mjs"
},
"files": [
"apps/web/.next/standalone/",
"apps/web/.next/static/",
"apps/web/public/",
"CHANGELOG.md",
"LICENSE",
"openclaw.mjs",
"README-header.png",
"denchclaw.mjs",
"README.md",
"assets/",
"dist/",
@ -38,202 +32,44 @@
"main": "dist/entry.js",
"exports": {
".": "./dist/entry.js",
"./cli-entry": "./openclaw.mjs"
"./cli-entry": "./denchclaw.mjs"
},
"scripts": {
"android:assemble": "cd apps/android && ./gradlew :app:assembleDebug",
"android:install": "cd apps/android && ./gradlew :app:installDebug",
"android:run": "cd apps/android && ./gradlew :app:installDebug && adb shell am start -n ai.openclaw.android/.MainActivity",
"android:test": "cd apps/android && ./gradlew :app:testDebugUnitTest",
"build": "tsdown && node --import tsx scripts/write-build-info.ts",
"canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh",
"check": "pnpm format:check && pnpm tsgo && pnpm lint",
"check:docs": "pnpm format:docs:check && pnpm lint:docs && pnpm docs:check-links",
"check:loc": "node --import tsx scripts/check-ts-max-loc.ts --max 500",
"deadcode:ci": "pnpm deadcode:report:ci:knip && pnpm deadcode:report:ci:ts-prune && pnpm deadcode:report:ci:ts-unused",
"deadcode:knip": "pnpm dlx knip --no-progress",
"deadcode:report": "pnpm deadcode:knip; pnpm deadcode:ts-prune; pnpm deadcode:ts-unused",
"deadcode:report:ci:knip": "mkdir -p .artifacts/deadcode && pnpm deadcode:knip > .artifacts/deadcode/knip.txt 2>&1 || true",
"deadcode:report:ci:ts-prune": "mkdir -p .artifacts/deadcode && pnpm deadcode:ts-prune > .artifacts/deadcode/ts-prune.txt 2>&1 || true",
"deadcode:report:ci:ts-unused": "mkdir -p .artifacts/deadcode && pnpm deadcode:ts-unused > .artifacts/deadcode/ts-unused-exports.txt 2>&1 || true",
"deadcode:ts-prune": "pnpm dlx ts-prune src extensions scripts",
"deadcode:ts-unused": "pnpm dlx ts-unused-exports tsconfig.json --ignoreTestFiles --exitWithCount",
"denchclaw": "node scripts/run-node.mjs",
"denchclaw:rpc": "node scripts/run-node.mjs agent --mode rpc --json",
"dev": "node scripts/run-node.mjs",
"docs:bin": "node scripts/build-docs-list.mjs",
"docs:check-links": "node scripts/docs-link-audit.mjs",
"docs:dev": "cd docs && mint dev",
"docs:list": "node scripts/docs-list.js",
"docs:spellcheck": "bash scripts/docs-spellcheck.sh",
"docs:spellcheck:fix": "bash scripts/docs-spellcheck.sh --write",
"build": "tsdown",
"check": "pnpm format:check && pnpm lint",
"denchclaw": "node denchclaw.mjs",
"dev": "node denchclaw.mjs",
"format": "oxfmt --write",
"format:all": "pnpm format && pnpm format:swift",
"format:check": "oxfmt --check",
"format:diff": "oxfmt --write && git --no-pager diff",
"format:docs": "git ls-files 'docs/**/*.md' 'docs/**/*.mdx' 'README.md' | xargs oxfmt --write",
"format:docs:check": "git ls-files 'docs/**/*.md' 'docs/**/*.mdx' 'README.md' | xargs oxfmt --check",
"format:fix": "oxfmt --write",
"format:swift": "swiftformat --lint --config .swiftformat apps/macos/Sources apps/ios/Sources apps/shared/OpenClawKit/Sources",
"gateway:dev": "OPENCLAW_SKIP_CHANNELS=1 CLAWDBOT_SKIP_CHANNELS=1 node scripts/run-node.mjs --dev gateway",
"gateway:dev:reset": "OPENCLAW_SKIP_CHANNELS=1 CLAWDBOT_SKIP_CHANNELS=1 node scripts/run-node.mjs --dev gateway --reset",
"gateway:watch": "node scripts/watch-node.mjs gateway --force",
"ios:build": "bash -lc './scripts/ios-configure-signing.sh && cd apps/ios && xcodegen generate && xcodebuild -project OpenClaw.xcodeproj -scheme OpenClaw -destination \"${IOS_DEST:-platform=iOS Simulator,name=iPhone 17}\" -configuration Debug build'",
"ios:gen": "bash -lc './scripts/ios-configure-signing.sh && cd apps/ios && xcodegen generate'",
"ios:open": "bash -lc './scripts/ios-configure-signing.sh && cd apps/ios && xcodegen generate && open OpenClaw.xcodeproj'",
"ios:run": "bash -lc './scripts/ios-configure-signing.sh && cd apps/ios && xcodegen generate && xcodebuild -project OpenClaw.xcodeproj -scheme OpenClaw -destination \"${IOS_DEST:-platform=iOS Simulator,name=iPhone 17}\" -configuration Debug build && xcrun simctl boot \"${IOS_SIM:-iPhone 17}\" || true && xcrun simctl launch booted ai.openclaw.ios'",
"lint": "oxlint --type-aware",
"lint:all": "pnpm lint && pnpm lint:swift",
"lint:docs": "pnpm dlx markdownlint-cli2",
"lint:docs:fix": "pnpm dlx markdownlint-cli2 --fix",
"lint:fix": "oxlint --type-aware --fix && pnpm format",
"lint:swift": "swiftlint lint --config .swiftlint.yml && (cd apps/ios && swiftlint lint --config .swiftlint.yml)",
"mac:open": "open dist/OpenClaw.app",
"mac:package": "bash scripts/package-mac-app.sh",
"mac:restart": "bash scripts/restart-mac.sh",
"moltbot:rpc": "node scripts/run-node.mjs agent --mode rpc --json",
"plugins:sync": "node --import tsx scripts/sync-plugin-versions.ts",
"prepack": "pnpm build && pnpm web:build && pnpm web:prepack",
"prepare": "command -v git >/dev/null 2>&1 && git rev-parse --is-inside-work-tree >/dev/null 2>&1 && git config core.hooksPath git-hooks || exit 0",
"protocol:check": "pnpm protocol:gen && pnpm protocol:gen:swift && git diff --exit-code -- dist/protocol.schema.json apps/macos/Sources/OpenClawProtocol/GatewayModels.swift",
"protocol:gen": "node --import tsx scripts/protocol-gen.ts",
"protocol:gen:swift": "node --import tsx scripts/protocol-gen-swift.ts",
"release:check": "node --import tsx scripts/release-check.ts",
"start": "node scripts/run-node.mjs",
"tail": "tail -f ~/.openclaw/agents/*/sessions/*.jsonl",
"test": "node scripts/test-parallel.mjs",
"test:all": "pnpm lint && pnpm build && pnpm test && pnpm test:e2e && pnpm test:live && pnpm test:docker:all",
"test:coverage": "vitest run --config vitest.unit.config.ts --coverage",
"test:docker:all": "pnpm test:docker:live-models && pnpm test:docker:live-gateway && pnpm test:docker:onboard && pnpm test:docker:gateway-network && pnpm test:docker:qr && pnpm test:docker:doctor-switch && pnpm test:docker:plugins && pnpm test:docker:cleanup",
"test:docker:cleanup": "bash scripts/test-cleanup-docker.sh",
"test:docker:doctor-switch": "bash scripts/e2e/doctor-install-switch-docker.sh",
"test:docker:gateway-network": "bash scripts/e2e/gateway-network-docker.sh",
"test:docker:live-gateway": "bash scripts/test-live-gateway-models-docker.sh",
"test:docker:live-models": "bash scripts/test-live-models-docker.sh",
"test:docker:onboard": "bash scripts/e2e/onboard-docker.sh",
"test:docker:plugins": "bash scripts/e2e/plugins-docker.sh",
"test:docker:qr": "bash scripts/e2e/qr-import-docker.sh",
"test:e2e": "vitest run --config vitest.e2e.config.ts",
"test:evals": "node --import tsx test/evals/run-evals.ts",
"test:evals:enforce": "EVALS_ENFORCE=1 node --import tsx test/evals/run-evals.ts",
"test:fast": "vitest run --config vitest.unit.config.ts",
"test:force": "node --import tsx scripts/test-force.ts",
"test:install:e2e": "bash scripts/test-install-sh-e2e-docker.sh",
"test:install:e2e:anthropic": "OPENCLAW_E2E_MODELS=anthropic CLAWDBOT_E2E_MODELS=anthropic bash scripts/test-install-sh-e2e-docker.sh",
"test:install:e2e:openai": "OPENCLAW_E2E_MODELS=openai CLAWDBOT_E2E_MODELS=openai bash scripts/test-install-sh-e2e-docker.sh",
"test:install:smoke": "bash scripts/test-install-sh-docker.sh",
"test:live": "OPENCLAW_LIVE_TEST=1 CLAWDBOT_LIVE_TEST=1 vitest run --config vitest.live.config.ts",
"test:macmini": "OPENCLAW_TEST_VM_FORKS=0 OPENCLAW_TEST_PROFILE=serial node scripts/test-parallel.mjs",
"test:ui": "pnpm --dir ui test",
"test:voicecall:closedloop": "vitest run extensions/voice-call/src/manager.test.ts extensions/voice-call/src/media-stream.test.ts src/plugins/voice-call.plugin.test.ts --maxWorkers=1",
"test:watch": "vitest",
"test:workspace": "(cd apps/web && pnpm vitest run -- workspace-profiles workspace-chat-isolation subagent-runs route.test)",
"test:workspace:live": "LIVE=1 vitest run --config vitest.live.config.ts -- workspace-context-awareness && LIVE=1 pnpm --dir apps/web vitest run -- subagent-streaming.live",
"tsgo:test": "tsgo -p tsconfig.test.json",
"tui": "node scripts/run-node.mjs tui",
"tui:dev": "OPENCLAW_PROFILE=dev CLAWDBOT_PROFILE=dev node scripts/run-node.mjs --dev tui",
"ui:build": "node scripts/ui.js build",
"ui:dev": "node scripts/ui.js dev",
"ui:install": "node scripts/ui.js install",
"start": "node denchclaw.mjs",
"test": "pnpm test:cli && pnpm --dir apps/web test",
"test:cli": "vitest run --config vitest.unit.config.ts src/cli/run-main.test.ts src/cli/bootstrap-external.test.ts src/cli/bootstrap-external.bootstrap-command.test.ts src/cli/workspace-seed.test.ts src/cli/web-runtime.test.ts src/cli/web-runtime-command.test.ts",
"test:web": "pnpm --dir apps/web test",
"web:build": "pnpm --dir apps/web build",
"web:dev": "pnpm --dir apps/web dev",
"web:install": "pnpm --dir apps/web install",
"web:prepack": "cp -r apps/web/public apps/web/.next/standalone/apps/web/public && cp -r apps/web/.next/static apps/web/.next/standalone/apps/web/.next/static && bash scripts/standalone-hoist-pnpm.sh"
"web:prepack": "cp -r apps/web/public apps/web/.next/standalone/apps/web/public && cp -r apps/web/.next/static apps/web/.next/standalone/apps/web/.next/static"
},
"dependencies": {
"@agentclientprotocol/sdk": "0.14.1",
"@ai-sdk/amazon-bedrock": "^3.0.0",
"@ai-sdk/anthropic": "^2.0.0",
"@ai-sdk/azure": "^2.0.0",
"@ai-sdk/gateway": "^2.0.0",
"@ai-sdk/google": "^2.0.0",
"@ai-sdk/groq": "^2.0.0",
"@ai-sdk/mistral": "^2.0.0",
"@ai-sdk/openai": "^2.0.0",
"@ai-sdk/openai-compatible": "^1.0.0",
"@ai-sdk/provider": "^2.0.0",
"@ai-sdk/provider-utils": "^3.0.0",
"@ai-sdk/xai": "^2.0.0",
"@aws-sdk/client-bedrock": "^3.990.0",
"@buape/carbon": "0.14.0",
"@clack/prompts": "^1.0.1",
"@discordjs/opus": "^0.10.0",
"@discordjs/voice": "^0.19.0",
"@grammyjs/runner": "^2.0.3",
"@grammyjs/transformer-throttler": "^1.2.1",
"@homebridge/ciao": "^1.3.5",
"@line/bot-sdk": "^10.6.0",
"@lydell/node-pty": "1.2.0-beta.3",
"@mariozechner/pi-agent-core": "0.54.0",
"@mariozechner/pi-ai": "0.54.0",
"@mariozechner/pi-coding-agent": "0.54.0",
"@mariozechner/pi-tui": "0.54.0",
"@mozilla/readability": "^0.6.0",
"@openrouter/ai-sdk-provider": "^2.1.1",
"@sinclair/typebox": "0.34.48",
"@slack/bolt": "^4.6.0",
"@slack/web-api": "^7.14.1",
"@whiskeysockets/baileys": "7.0.0-rc.9",
"ai": "^6.0.66",
"ajv": "^8.18.0",
"chalk": "^5.6.2",
"chokidar": "^5.0.0",
"cli-highlight": "^2.1.11",
"commander": "^14.0.3",
"croner": "^10.0.1",
"discord-api-types": "^0.38.40",
"dotenv": "^17.3.1",
"express": "^5.2.1",
"file-type": "^21.3.0",
"gradient-string": "^3.0.0",
"grammy": "^1.40.0",
"hono": "4.11.9",
"https-proxy-agent": "^7.0.6",
"jiti": "^2.6.1",
"json5": "^2.2.3",
"jszip": "^3.10.1",
"linkedom": "^0.18.12",
"long": "^5.3.2",
"markdown-it": "^14.1.1",
"node-edge-tts": "^1.2.10",
"opusscript": "^0.0.8",
"osc-progress": "^0.3.0",
"pdfjs-dist": "^5.4.624",
"playwright-core": "1.58.2",
"qrcode-terminal": "^0.12.0",
"sharp": "^0.34.5",
"sqlite-vec": "0.1.7-alpha.2",
"tar": "7.5.9",
"tslog": "^4.10.2",
"undici": "^7.22.0",
"ws": "^8.19.0",
"yaml": "^2.8.2",
"zod": "^4.3.6"
"tslog": "^4.10.2"
},
"devDependencies": {
"@grammyjs/types": "^3.24.0",
"@lit-labs/signals": "^0.2.0",
"@lit/context": "^1.1.6",
"@types/express": "^5.0.6",
"@types/markdown-it": "^14.1.2",
"@types/node": "^25.3.0",
"@types/qrcode-terminal": "^0.12.2",
"@types/ws": "^8.18.1",
"@typescript/native-preview": "7.0.0-dev.20260221.1",
"@vitest/coverage-v8": "^4.0.18",
"lit": "^3.3.2",
"oxfmt": "0.34.0",
"oxlint": "^1.49.0",
"oxlint-tsgolint": "^0.14.2",
"signal-utils": "0.21.1",
"tsdown": "^0.20.3",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"vitest": "^4.0.18"
},
"peerDependencies": {
"@napi-rs/canvas": "^0.1.89",
"node-llama-cpp": "3.15.1",
"openclaw": ">=2026.1.0"
},
"engines": {
@ -241,30 +77,6 @@
},
"packageManager": "pnpm@10.23.0",
"pnpm": {
"minimumReleaseAge": 2880,
"overrides": {
"hono": "4.11.10",
"fast-xml-parser": "5.3.6",
"request": "npm:@cypress/request@3.0.10",
"request-promise": "npm:@cypress/request-promise@5.0.0",
"form-data": "2.5.4",
"minimatch": "10.2.1",
"qs": "6.14.2",
"@sinclair/typebox": "0.34.48",
"tar": "7.5.9",
"tough-cookie": "4.1.3"
},
"onlyBuiltDependencies": [
"@lydell/node-pty",
"@matrix-org/matrix-sdk-crypto-nodejs",
"@napi-rs/canvas",
"@whiskeysockets/baileys",
"authenticate-pam",
"esbuild",
"koffi",
"node-llama-cpp",
"protobufjs",
"sharp"
]
"minimumReleaseAge": 2880
}
}

View File

@ -1,18 +1,16 @@
#!/usr/bin/env bash
# deploy.sh — build and publish denchclaw to npm
#
# Versioning convention (mirrors upstream openclaw tags):
# --upstream <ver> Sync to an upstream release version.
# If that version is already published, appends .1, .2, …
# (or -1, -2, … when the base has no prerelease).
# --bump Increment the local fork suffix on the current version.
# 2026.2.6-3 → 2026.2.6-3.1
# 2026.2.6-3.1 → 2026.2.6-3.2
# 2026.2.7 → 2026.2.7-1
# Versioning convention (standard semver):
# --bump <kind> Increment current package version.
# kind: major | minor | patch
# 2.0.0 --bump patch => 2.0.1
# --version <ver> Publish an explicit semver version (x.y.z).
# (no flag) Publish whatever version is already in package.json.
#
# Flags:
# --skip-tests Skip running tests before build/publish.
# --skip-npx-smoke Skip post-publish npx binary verification.
#
# Environment:
# NPM_TOKEN Required. npm auth token for publishing.
@ -39,78 +37,117 @@ npm_version_exists() {
npm view "${PACKAGE_NAME}@${v}" version 2>/dev/null | grep -q "${v}" 2>/dev/null
}
# Given a base version, return it if available on npm, otherwise find the
# next free slot by appending a dot-suffix (.1, .2, …) for versions that
# already contain a prerelease, or a hyphen-suffix (-1, -2, …) otherwise.
find_available_version() {
local base="$1"
if ! npm_version_exists "$base"; then
echo "$base"
return
fi
local n=1
if [[ "$base" == *-* ]]; then
# Has prerelease already → append .N
while npm_version_exists "${base}.${n}"; do
n=$((n + 1))
done
echo "${base}.${n}"
else
# No prerelease → append -N
while npm_version_exists "${base}-${n}"; do
n=$((n + 1))
done
echo "${base}-${n}"
fi
is_plain_semver() {
local v="$1"
[[ "$v" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]
}
# Increment the local fork suffix on a version string.
# 2026.2.6-3 → 2026.2.6-3.1 (upstream prerelease, add .1)
# 2026.2.6-3.1 → 2026.2.6-3.2 (increment last dot segment)
# 2026.2.7 → 2026.2.7-1 (no prerelease, add -1)
# 2026.2.7-1 → 2026.2.7-1.1 (treat -1 as upstream-like, add .1)
bump_version() {
bump_semver() {
local current="$1"
local kind="$2"
# If the prerelease already has a dot (e.g. 3.1 in 2026.2.6-3.1),
# increment the last numeric segment after the final dot.
local prerelease="${current#*-}"
if [[ "$current" == *-* ]] && [[ "$prerelease" == *.* ]]; then
if [[ "$current" =~ ^(.*\.)([0-9]+)$ ]]; then
echo "${BASH_REMATCH[1]}$((BASH_REMATCH[2] + 1))"
return
if ! is_plain_semver "$current"; then
die "current version must be plain semver (x.y.z) for --bump, got: $current"
fi
local major minor patch
IFS='.' read -r major minor patch <<<"$current"
case "$kind" in
major)
echo "$((major + 1)).0.0"
;;
minor)
echo "${major}.$((minor + 1)).0"
;;
patch)
echo "${major}.${minor}.$((patch + 1))"
;;
*)
die "--bump requires one of: major, minor, patch"
;;
esac
}
verify_npx_command() {
local version="$1"
local label="$2"
shift 2
local attempts=15
local delay_seconds=2
local output=""
local temp_dir
temp_dir="$(mktemp -d)"
for ((i = 1; i <= attempts; i++)); do
if output="$(cd "$temp_dir" && "$@" 2>/dev/null)"; then
if [[ "$output" == *"$version"* ]]; then
echo "verified ${label}: ${output}"
rm -rf "$temp_dir"
return 0
fi
fi
fi
sleep "$delay_seconds"
done
# Has a prerelease but no dot-suffix yet → append .1
if [[ "$current" == *-* ]]; then
echo "${current}.1"
return
fi
rm -rf "$temp_dir"
echo "error: failed to verify ${label} for ${PACKAGE_NAME}@${version}" >&2
return 1
}
# Plain semver with no prerelease → append -1
echo "${current}-1"
verify_npx_invocation() {
local label="$1"
shift
local attempts=15
local delay_seconds=2
local temp_dir
temp_dir="$(mktemp -d)"
for ((i = 1; i <= attempts; i++)); do
if (cd "$temp_dir" && "$@" >/dev/null 2>&1); then
echo "verified ${label}"
rm -rf "$temp_dir"
return 0
fi
sleep "$delay_seconds"
done
rm -rf "$temp_dir"
echo "error: failed to verify ${label}" >&2
return 1
}
# ── parse args ───────────────────────────────────────────────────────────────
MODE=""
UPSTREAM_VERSION=""
BUMP_KIND=""
EXPLICIT_VERSION=""
DRY_RUN=false
SKIP_BUILD=false
SKIP_TESTS=false
SKIP_NPX_SMOKE=false
set_mode() {
local next="$1"
if [[ -n "$MODE" && "$MODE" != "$next" ]]; then
die "choose only one version mode: --version <x.y.z> or --bump <major|minor|patch>"
fi
MODE="$next"
}
while [[ $# -gt 0 ]]; do
case $1 in
--upstream)
MODE="upstream"
UPSTREAM_VERSION="${2:?--upstream requires a version argument}"
--version)
set_mode "version"
EXPLICIT_VERSION="${2:?--version requires a semver argument (x.y.z)}"
shift 2
;;
--bump)
MODE="bump"
shift
set_mode "bump"
BUMP_KIND="${2:?--bump requires one of: major, minor, patch}"
shift 2
;;
--upstream)
die "--upstream has been removed. Use --version <x.y.z> or --bump <major|minor|patch>."
;;
--dry-run)
DRY_RUN=true
@ -124,6 +161,10 @@ while [[ $# -gt 0 ]]; do
SKIP_TESTS=true
shift
;;
--skip-npx-smoke)
SKIP_NPX_SMOKE=true
shift
;;
--help|-h)
sed -n '2,/^[^#]/{ /^#/s/^# \{0,1\}//p; }' "$0"
exit 0
@ -152,24 +193,27 @@ NPM_FLAGS=(--userconfig "$NPMRC_TEMP")
CURRENT="$(current_version)"
case "$MODE" in
upstream)
VERSION="$(find_available_version "$UPSTREAM_VERSION")"
echo "upstream sync: $UPSTREAM_VERSION → publishing as $VERSION"
version)
if ! is_plain_semver "$EXPLICIT_VERSION"; then
die "--version must be plain semver (x.y.z), got: $EXPLICIT_VERSION"
fi
VERSION="$EXPLICIT_VERSION"
echo "explicit version: $CURRENT$VERSION"
;;
bump)
NEXT="$(bump_version "$CURRENT")"
VERSION="$(find_available_version "$NEXT")"
echo "local bump: $CURRENT$VERSION"
VERSION="$(bump_semver "$CURRENT" "$BUMP_KIND")"
echo "semver bump (${BUMP_KIND}): $CURRENT$VERSION"
;;
*)
if npm_version_exists "$CURRENT"; then
die "version $CURRENT already exists on npm. Use --bump or --upstream <ver>."
fi
VERSION="$CURRENT"
echo "publishing current version: $VERSION"
;;
esac
if npm_version_exists "$VERSION"; then
die "version $VERSION already exists on npm. Use --bump <major|minor|patch> or --version <x.y.z>."
fi
if [[ "$DRY_RUN" == true ]]; then
echo "[dry-run] would publish ${PACKAGE_NAME}@${VERSION}"
exit 0
@ -208,6 +252,21 @@ fi
echo "publishing ${PACKAGE_NAME}@${VERSION}..."
npm publish --access public --tag latest "${NPM_FLAGS[@]}"
# Verify published npx flows for both CLI aliases.
if [[ "$SKIP_NPX_SMOKE" != true ]]; then
echo "verifying npx binaries..."
verify_npx_command "$VERSION" "npx denchclaw" \
npx --yes "${PACKAGE_NAME}@${VERSION}" --version
verify_npx_command "$VERSION" "npx dench" \
npx --yes --package "${PACKAGE_NAME}@${VERSION}" dench --version
verify_npx_invocation "npx dench update --help" \
npx --yes --package "${PACKAGE_NAME}@${VERSION}" dench update --help
verify_npx_invocation "npx dench start --help" \
npx --yes --package "${PACKAGE_NAME}@${VERSION}" dench start --help
verify_npx_invocation "npx dench stop --help" \
npx --yes --package "${PACKAGE_NAME}@${VERSION}" dench stop --help
fi
# Verify the standalone web app was included in the published package.
# `prepack` should have built it; if this file is missing, the web UI
# won't work for users who install globally.
@ -217,17 +276,6 @@ if [[ ! -f "$STANDALONE_SERVER" ]]; then
echo " users may not get a working Web UI — check the prepack step"
fi
# ── post-publish: commit + push version bump ─────────────────────────────────
if git diff --quiet package.json 2>/dev/null; then
echo "package.json unchanged — skipping git commit"
else
echo "committing version bump..."
git add package.json
git commit -m "release: v${VERSION}"
git push
fi
echo ""
echo "published ${PACKAGE_NAME}@${VERSION}"
echo "install: npm i -g ${PACKAGE_NAME}"

View File

@ -44,7 +44,7 @@ type SpawnCall = {
function createWebProfilesResponse(params?: {
status?: number;
payload?: { profiles?: unknown[]; activeProfile?: string };
payload?: { profiles?: unknown[]; activeProfile?: string | null };
}): Response {
const status = params?.status ?? 200;
const payload = params?.payload ?? { profiles: [], activeProfile: "dench" };
@ -98,15 +98,18 @@ function createMockChild(params: {
stdout: EventEmitter;
stderr: EventEmitter;
kill: ReturnType<typeof vi.fn>;
unref: ReturnType<typeof vi.fn>;
} {
const child = new EventEmitter() as EventEmitter & {
stdout: EventEmitter;
stderr: EventEmitter;
kill: ReturnType<typeof vi.fn>;
unref: ReturnType<typeof vi.fn>;
};
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
child.kill = vi.fn();
child.unref = vi.fn();
queueMicrotask(() => {
if (params.stdout) {
@ -411,6 +414,41 @@ describe("bootstrapCommand always-onboard behavior", () => {
expect(updateIndex).toBeLessThan(onboardIndex);
});
it("skips update prompt right after installing openclaw@latest (avoids redundant update checks)", async () => {
forceGlobalMissing = true;
promptMocks.confirmDecision = true;
const runtime: RuntimeEnv = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
await withForcedStdinTty(true, async () => {
await bootstrapCommand(
{
noOpen: true,
},
runtime,
);
});
const installedGlobalOpenClaw = spawnCalls.some(
(call) =>
call.command === "npm" &&
call.args.includes("install") &&
call.args.includes("-g") &&
call.args.includes("openclaw@latest"),
);
const updateCalled = spawnCalls.some(
(call) =>
call.command === "openclaw" && call.args.includes("update") && call.args.includes("--yes"),
);
expect(installedGlobalOpenClaw).toBe(true);
expect(promptMocks.confirm).toHaveBeenCalledTimes(0);
expect(updateCalled).toBe(false);
});
it("skips update when interactive prompt is declined", async () => {
promptMocks.confirmDecision = false;
const runtime: RuntimeEnv = {
@ -441,6 +479,87 @@ describe("bootstrapCommand always-onboard behavior", () => {
expect(onboardCalls).toHaveLength(1);
});
it("reuses recent OpenClaw CLI availability checks to avoid repeated npm/which probes", async () => {
const runtime: RuntimeEnv = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
await bootstrapCommand(
{
nonInteractive: true,
noOpen: true,
skipUpdate: true,
},
runtime,
);
const firstProbeCounts = {
npmLs: spawnCalls.filter(
(call) =>
call.command === "npm" &&
call.args.includes("ls") &&
call.args.includes("-g") &&
call.args.includes("openclaw"),
).length,
npmPrefix: spawnCalls.filter(
(call) =>
call.command === "npm" && call.args.includes("prefix") && call.args.includes("-g"),
).length,
shellWhich: spawnCalls.filter(
(call) =>
(call.command === "which" || call.command === "where") && call.args[0] === "openclaw",
).length,
versionCheck: spawnCalls.filter(
(call) => call.command === "openclaw" && call.args[0] === "--version",
).length,
};
spawnCalls = [];
await bootstrapCommand(
{
nonInteractive: true,
noOpen: true,
skipUpdate: true,
},
runtime,
);
const secondProbeCounts = {
npmLs: spawnCalls.filter(
(call) =>
call.command === "npm" &&
call.args.includes("ls") &&
call.args.includes("-g") &&
call.args.includes("openclaw"),
).length,
npmPrefix: spawnCalls.filter(
(call) =>
call.command === "npm" && call.args.includes("prefix") && call.args.includes("-g"),
).length,
shellWhich: spawnCalls.filter(
(call) =>
(call.command === "which" || call.command === "where") && call.args[0] === "openclaw",
).length,
versionCheck: spawnCalls.filter(
(call) => call.command === "openclaw" && call.args[0] === "--version",
).length,
};
expect(firstProbeCounts.npmLs).toBeGreaterThan(0);
expect(firstProbeCounts.npmPrefix).toBeGreaterThan(0);
expect(firstProbeCounts.shellWhich).toBeGreaterThan(0);
expect(firstProbeCounts.versionCheck).toBeGreaterThan(0);
expect(secondProbeCounts).toEqual({
npmLs: 0,
npmPrefix: 0,
shellWhich: 0,
versionCheck: 0,
});
});
it("seeds workspace.duckdb on bootstrap when missing", async () => {
const runtime: RuntimeEnv = {
log: vi.fn(),
@ -911,6 +1030,35 @@ describe("bootstrapCommand always-onboard behavior", () => {
);
});
it("accepts nullable activeProfile in /api/profiles payload (prevents first-run false-negative readiness)", async () => {
fetchBehavior = async (url: string) => {
if (url.includes("127.0.0.1:3100/api/profiles")) {
return createWebProfilesResponse({
status: 200,
payload: { profiles: [], activeProfile: null },
});
}
return createWebProfilesResponse({ status: 404, payload: {} });
};
const runtime: RuntimeEnv = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
const summary = await bootstrapCommand(
{
nonInteractive: true,
noOpen: true,
skipUpdate: true,
},
runtime,
);
expect(summary.webReachable).toBe(true);
expect(summary.diagnostics.checks.find((check) => check.id === "web-ui")?.status).toBe("pass");
});
it("prints likely gateway cause with log excerpt when autofix cannot recover", async () => {
alwaysHealthFail = true;
mkdirSync(path.join(stateDir, "logs"), { recursive: true });

View File

@ -1,29 +1,33 @@
import { spawn, type StdioOptions } from "node:child_process";
import { existsSync, mkdirSync, openSync, readFileSync, readdirSync } from "node:fs";
import os from "node:os";
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
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 { VERSION } from "../version.js";
import { applyCliProfileEnv } from "./profile.js";
import {
DEFAULT_WEB_APP_PORT,
ensureManagedWebRuntime,
resolveCliPackageRoot,
resolveProfileStateDir,
} from "./web-runtime.js";
import { seedWorkspaceFromAssets, type WorkspaceSeedResult } from "./workspace-seed.js";
const DEFAULT_DENCHCLAW_PROFILE = "dench";
const DENCHCLAW_STATE_DIRNAME = ".openclaw-dench";
const DEFAULT_GATEWAY_PORT = 18789;
const DENCHCLAW_GATEWAY_PORT_START = 19001;
const MAX_PORT_SCAN_ATTEMPTS = 100;
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";
const REQUIRED_TOOLS_PROFILE = "full";
const OPENCLAW_CLI_CHECK_CACHE_TTL_MS = 5 * 60_000;
const OPENCLAW_UPDATE_PROMPT_SUPPRESS_AFTER_INSTALL_MS = 5 * 60_000;
const OPENCLAW_CLI_CHECK_CACHE_FILE = "openclaw-cli-check.json";
const OPENCLAW_SETUP_PROGRESS_BAR_WIDTH = 16;
type BootstrapRolloutStage = "internal" | "beta" | "default";
type BootstrapCheckStatus = "pass" | "warn" | "fail";
@ -95,12 +99,34 @@ type SpawnResult = {
type OpenClawCliAvailability = {
available: boolean;
installed: boolean;
installedAt?: number;
version?: string;
command: string;
globalBinDir?: string;
shellCommandPath?: string;
};
type OutputLineHandler = (line: string, stream: "stdout" | "stderr") => void;
type OpenClawCliCheckCache = {
checkedAt: number;
pathEnv: string;
available: boolean;
command: string;
version?: string;
globalBinDir?: string;
shellCommandPath?: string;
installedAt?: number;
};
type OpenClawSetupProgress = {
startStage: (label: string) => void;
output: (line: string) => void;
completeStage: (suffix?: string) => void;
finish: (message: string) => void;
fail: (message: string) => void;
};
type GatewayAutoFixStep = {
name: string;
ok: boolean;
@ -147,6 +173,7 @@ async function runCommandWithTimeout(
cwd?: string;
env?: NodeJS.ProcessEnv;
ioMode?: "capture" | "inherit";
onOutputLine?: OutputLineHandler;
},
): Promise<SpawnResult> {
const [command, ...args] = argv;
@ -172,10 +199,28 @@ async function runCommandWithTimeout(
}, options.timeoutMs);
child.stdout?.on("data", (chunk: Buffer | string) => {
stdout += String(chunk);
const text = String(chunk);
stdout += text;
if (options.onOutputLine) {
for (const segment of text.split(/\r?\n/)) {
const line = segment.trim();
if (line.length > 0) {
options.onOutputLine(line, "stdout");
}
}
}
});
child.stderr?.on("data", (chunk: Buffer | string) => {
stderr += String(chunk);
const text = String(chunk);
stderr += text;
if (options.onOutputLine) {
for (const segment of text.split(/\r?\n/)) {
const line = segment.trim();
if (line.length > 0) {
options.onOutputLine(line, "stderr");
}
}
}
});
child.once("error", (error: Error) => {
if (settled) {
@ -300,12 +345,6 @@ function firstNonEmptyLine(...values: Array<string | undefined>): string | undef
return undefined;
}
function resolveProfileStateDir(profile: string, env: NodeJS.ProcessEnv = process.env): string {
void profile;
const home = resolveRequiredHomeDir(env, os.homedir);
return path.join(home, DENCHCLAW_STATE_DIRNAME);
}
function resolveGatewayLaunchAgentLabel(profile: string): string {
const normalized = profile.trim().toLowerCase();
if (!normalized || normalized === "default") {
@ -386,94 +425,20 @@ async function ensureToolsProfile(openclawCommand: string, profile: string): Pro
});
}
async function probeForWebApp(port: number): Promise<boolean> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 1_500);
try {
const response = await fetch(`http://127.0.0.1:${port}/api/profiles`, {
method: "GET",
signal: controller.signal,
redirect: "manual",
});
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",
);
} catch {
return false;
} finally {
clearTimeout(timer);
}
}
async function waitForWebApp(preferredPort: number): Promise<boolean> {
for (let attempt = 0; attempt < WEB_APP_PROBE_ATTEMPTS; attempt += 1) {
if (await probeForWebApp(preferredPort)) {
return true;
}
await sleep(WEB_APP_PROBE_DELAY_MS);
}
return false;
}
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.
*/
function startWebAppIfNeeded(port: number, stateDir: string, gatewayPort: number): void {
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],
env: {
...process.env,
PORT: String(port),
HOSTNAME: "127.0.0.1",
OPENCLAW_GATEWAY_PORT: String(gatewayPort),
},
});
child.unref();
}
async function runOpenClaw(
openclawCommand: string,
args: string[],
timeoutMs: number,
ioMode: "capture" | "inherit" = "capture",
env?: NodeJS.ProcessEnv,
onOutputLine?: OutputLineHandler,
): Promise<SpawnResult> {
return await runCommandWithTimeout([openclawCommand, ...args], { timeoutMs, ioMode, env });
return await runCommandWithTimeout([openclawCommand, ...args], {
timeoutMs,
ioMode,
env,
onOutputLine,
});
}
async function runOpenClawOrThrow(params: {
@ -618,11 +583,150 @@ function parseJsonPayload(raw: string | undefined): Record<string, unknown> | un
}
}
async function detectGlobalOpenClawInstall(): Promise<{ installed: boolean; version?: string }> {
function resolveOpenClawCliCheckCachePath(stateDir: string): string {
return path.join(stateDir, "cache", OPENCLAW_CLI_CHECK_CACHE_FILE);
}
function readOpenClawCliCheckCache(stateDir: string): OpenClawCliCheckCache | undefined {
const cachePath = resolveOpenClawCliCheckCachePath(stateDir);
if (!existsSync(cachePath)) {
return undefined;
}
try {
const parsed = JSON.parse(readFileSync(cachePath, "utf-8")) as Partial<OpenClawCliCheckCache>;
if (
typeof parsed.checkedAt !== "number" ||
!Number.isFinite(parsed.checkedAt) ||
typeof parsed.pathEnv !== "string" ||
parsed.pathEnv !== (process.env.PATH ?? "") ||
typeof parsed.available !== "boolean" ||
!parsed.available ||
typeof parsed.command !== "string" ||
parsed.command.length === 0
) {
return undefined;
}
const ageMs = Date.now() - parsed.checkedAt;
if (ageMs < 0 || ageMs > OPENCLAW_CLI_CHECK_CACHE_TTL_MS) {
return undefined;
}
const looksLikePath =
parsed.command.includes(path.sep) ||
parsed.command.includes("/") ||
parsed.command.includes("\\");
if (looksLikePath && !existsSync(parsed.command)) {
return undefined;
}
return {
checkedAt: parsed.checkedAt,
pathEnv: parsed.pathEnv,
available: parsed.available,
command: parsed.command,
version: typeof parsed.version === "string" ? parsed.version : undefined,
globalBinDir: typeof parsed.globalBinDir === "string" ? parsed.globalBinDir : undefined,
shellCommandPath:
typeof parsed.shellCommandPath === "string" ? parsed.shellCommandPath : undefined,
installedAt: typeof parsed.installedAt === "number" ? parsed.installedAt : undefined,
};
} catch {
return undefined;
}
}
function writeOpenClawCliCheckCache(
stateDir: string,
cache: Omit<OpenClawCliCheckCache, "checkedAt" | "pathEnv">,
): void {
try {
const cachePath = resolveOpenClawCliCheckCachePath(stateDir);
mkdirSync(path.dirname(cachePath), { recursive: true });
const payload: OpenClawCliCheckCache = {
...cache,
checkedAt: Date.now(),
pathEnv: process.env.PATH ?? "",
};
writeFileSync(cachePath, JSON.stringify(payload, null, 2), "utf-8");
} catch {
// Cache write failures should never block bootstrap.
}
}
function createOpenClawSetupProgress(params: {
enabled: boolean;
totalStages: number;
}): OpenClawSetupProgress {
if (!params.enabled || params.totalStages <= 0 || !process.stdout.isTTY) {
const noop = () => undefined;
return {
startStage: noop,
output: noop,
completeStage: noop,
finish: noop,
fail: noop,
};
}
const s = spinner();
let completedStages = 0;
let activeLabel = "";
const renderBar = () => {
const ratio = completedStages / params.totalStages;
const filled = Math.max(
0,
Math.min(
OPENCLAW_SETUP_PROGRESS_BAR_WIDTH,
Math.round(ratio * OPENCLAW_SETUP_PROGRESS_BAR_WIDTH),
),
);
const bar = `${"#".repeat(filled)}${"-".repeat(OPENCLAW_SETUP_PROGRESS_BAR_WIDTH - filled)}`;
return `[${bar}] ${completedStages}/${params.totalStages}`;
};
const truncate = (value: string, max = 84) =>
value.length > max ? `${value.slice(0, max - 3)}...` : value;
const renderStageLine = (detail?: string) => {
const base = `${renderBar()} ${activeLabel}`.trim();
if (!detail) {
return base;
}
return truncate(`${base} -> ${detail}`);
};
return {
startStage: (label: string) => {
activeLabel = label;
s.start(renderStageLine());
},
output: (line: string) => {
if (!line) {
return;
}
s.message(renderStageLine(line));
},
completeStage: (suffix?: string) => {
completedStages = Math.min(params.totalStages, completedStages + 1);
s.stop(renderStageLine(suffix ?? "done"));
},
finish: (message: string) => {
completedStages = params.totalStages;
s.stop(`${renderBar()} ${truncate(message)}`.trim());
},
fail: (message: string) => {
s.stop(`${renderBar()} ${truncate(message)}`.trim());
},
};
}
async function detectGlobalOpenClawInstall(
onOutputLine?: OutputLineHandler,
): Promise<{ installed: boolean; version?: string }> {
const result = await runCommandWithTimeout(
["npm", "ls", "-g", "openclaw", "--depth=0", "--json", "--silent"],
{
timeoutMs: 15_000,
onOutputLine,
},
).catch(() => null);
@ -637,9 +741,12 @@ async function detectGlobalOpenClawInstall(): Promise<{ installed: boolean; vers
return { installed: false };
}
async function resolveNpmGlobalBinDir(): Promise<string | undefined> {
async function resolveNpmGlobalBinDir(
onOutputLine?: OutputLineHandler,
): Promise<string | undefined> {
const result = await runCommandWithTimeout(["npm", "prefix", "-g"], {
timeoutMs: 8_000,
onOutputLine,
}).catch(() => null);
if (!result || result.code !== 0) {
return undefined;
@ -662,10 +769,13 @@ function resolveGlobalOpenClawCommand(globalBinDir: string | undefined): string
return candidates.find((candidate) => existsSync(candidate));
}
async function resolveShellOpenClawPath(): Promise<string | undefined> {
async function resolveShellOpenClawPath(
onOutputLine?: OutputLineHandler,
): Promise<string | undefined> {
const locator = process.platform === "win32" ? "where" : "which";
const result = await runCommandWithTimeout([locator, "openclaw"], {
timeoutMs: 4_000,
onOutputLine,
}).catch(() => null);
if (!result || result.code !== 0) {
return undefined;
@ -681,14 +791,55 @@ function isProjectLocalOpenClawPath(commandPath: string | undefined): boolean {
return normalized.includes("/node_modules/.bin/openclaw");
}
async function ensureOpenClawCliAvailable(): Promise<OpenClawCliAvailability> {
const globalBefore = await detectGlobalOpenClawInstall();
async function ensureOpenClawCliAvailable(params: {
stateDir: string;
showProgress: boolean;
}): Promise<OpenClawCliAvailability> {
const cached = readOpenClawCliCheckCache(params.stateDir);
if (cached) {
const ageSeconds = Math.max(0, Math.floor((Date.now() - cached.checkedAt) / 1000));
const progress = createOpenClawSetupProgress({
enabled: params.showProgress,
totalStages: 1,
});
progress.startStage("Reusing cached OpenClaw install check");
progress.completeStage(`cache hit (${ageSeconds}s old)`);
return {
available: true,
installed: false,
installedAt: cached.installedAt,
version: cached.version,
command: cached.command,
globalBinDir: cached.globalBinDir,
shellCommandPath: cached.shellCommandPath,
};
}
const progress = createOpenClawSetupProgress({
enabled: params.showProgress,
totalStages: 5,
});
progress.startStage("Checking global OpenClaw install");
const globalBefore = await detectGlobalOpenClawInstall((line) => {
progress.output(`npm ls: ${line}`);
});
progress.completeStage(
globalBefore.installed ? `found ${globalBefore.version ?? "installed"}` : "missing",
);
let installed = false;
let installedAt: number | undefined;
progress.startStage("Ensuring openclaw@latest is installed globally");
if (!globalBefore.installed) {
const install = await runCommandWithTimeout(["npm", "install", "-g", "openclaw@latest"], {
timeoutMs: 10 * 60_000,
onOutputLine: (line) => {
progress.output(`npm install: ${line}`);
},
}).catch(() => null);
if (!install || install.code !== 0) {
progress.fail("OpenClaw global install failed.");
return {
available: false,
installed: false,
@ -697,19 +848,55 @@ async function ensureOpenClawCliAvailable(): Promise<OpenClawCliAvailability> {
};
}
installed = true;
installedAt = Date.now();
progress.completeStage("installed openclaw@latest");
} else {
progress.completeStage("already installed; skipping install");
}
const globalAfter = installed ? await detectGlobalOpenClawInstall() : globalBefore;
const globalBinDir = await resolveNpmGlobalBinDir();
progress.startStage("Resolving global and shell OpenClaw paths");
const [globalBinDir, shellCommandPath] = await Promise.all([
resolveNpmGlobalBinDir((line) => {
progress.output(`npm prefix: ${line}`);
}),
resolveShellOpenClawPath((line) => {
progress.output(`${process.platform === "win32" ? "where" : "which"}: ${line}`);
}),
]);
progress.completeStage("path discovery complete");
const globalAfter = installed ? { installed: true, version: globalBefore.version } : globalBefore;
const globalCommand = resolveGlobalOpenClawCommand(globalBinDir);
const command = globalCommand ?? "openclaw";
const check = await runOpenClaw(command, ["--version"], 4_000).catch(() => null);
const shellCommandPath = await resolveShellOpenClawPath();
progress.startStage("Verifying OpenClaw CLI responsiveness");
const check = await runOpenClaw(command, ["--version"], 4_000, "capture", undefined, (line) => {
progress.output(`openclaw --version: ${line}`);
}).catch(() => null);
progress.completeStage(
check?.code === 0 ? "OpenClaw responded" : "OpenClaw version probe failed",
);
const version = normalizeVersionOutput(check?.stdout || check?.stderr || globalAfter.version);
const available = Boolean(globalAfter.installed && check && check.code === 0);
progress.startStage("Caching OpenClaw check result");
if (available) {
writeOpenClawCliCheckCache(params.stateDir, {
available,
command,
version,
globalBinDir,
shellCommandPath,
installedAt,
});
progress.completeStage(`saved (${Math.floor(OPENCLAW_CLI_CHECK_CACHE_TTL_MS / 60_000)}m TTL)`);
} else {
progress.fail("OpenClaw CLI check failed (cache not written).");
}
return {
available,
installed,
installedAt,
version,
command,
globalBinDir,
@ -937,7 +1124,11 @@ function remediationForGatewayFailure(
}
function remediationForWebUiFailure(port: number): string {
return `Web UI did not respond on ${port}. Ensure the apps/web directory exists and rerun with \`denchclaw bootstrap --web-port <port>\` if needed.`;
return [
`Web UI did not respond on ${port}.`,
`Run \`dench update --web-port ${port}\` to refresh the managed web runtime.`,
`If the port is stuck, run \`dench stop --web-port ${port}\` first.`,
].join(" ");
}
function describeWorkspaceSeedResult(result: WorkspaceSeedResult): string {
@ -1234,6 +1425,7 @@ function logBootstrapChecklist(diagnostics: BootstrapDiagnostics, runtime: Runti
async function shouldRunUpdate(params: {
opts: BootstrapOptions;
runtime: RuntimeEnv;
installResult: OpenClawCliAvailability;
}): Promise<boolean> {
if (params.opts.updateNow) {
return true;
@ -1246,6 +1438,17 @@ async function shouldRunUpdate(params: {
) {
return false;
}
const installedRecently =
params.installResult.installed ||
(typeof params.installResult.installedAt === "number" &&
Date.now() - params.installResult.installedAt <=
OPENCLAW_UPDATE_PROMPT_SUPPRESS_AFTER_INSTALL_MS);
if (installedRecently) {
params.runtime.log(
theme.muted("Skipping update prompt because OpenClaw was installed moments ago."),
);
return false;
}
const decision = await confirm({
message: stylePromptMessage("Check and install OpenClaw updates now?"),
initialValue: false,
@ -1266,11 +1469,16 @@ export async function bootstrapCommand(
const legacyFallbackEnabled = isLegacyFallbackEnabled();
const appliedProfile = applyCliProfileEnv({ profile: opts.profile });
const profile = appliedProfile.effectiveProfile;
const stateDir = resolveProfileStateDir(profile);
const workspaceDir = resolveBootstrapWorkspaceDir(stateDir);
if (appliedProfile.warning && !opts.json) {
runtime.log(theme.warn(appliedProfile.warning));
}
const installResult = await ensureOpenClawCliAvailable();
const installResult = await ensureOpenClawCliAvailable({
stateDir,
showProgress: !opts.json,
});
if (!installResult.available) {
throw new Error(
[
@ -1286,7 +1494,7 @@ export async function bootstrapCommand(
}
const openclawCommand = installResult.command;
if (await shouldRunUpdate({ opts, runtime })) {
if (await shouldRunUpdate({ opts, runtime, installResult })) {
await runOpenClawWithProgress({
openclawCommand,
args: ["update", "--yes"],
@ -1322,9 +1530,6 @@ export async function bootstrapCommand(
portAutoAssigned = true;
}
const stateDir = resolveProfileStateDir(profile);
const workspaceDir = resolveBootstrapWorkspaceDir(stateDir);
if (portAutoAssigned && !opts.json) {
runtime.log(
theme.muted(
@ -1372,9 +1577,10 @@ export async function bootstrapCommand(
});
}
const packageRoot = resolveCliPackageRoot();
const workspaceSeed = seedWorkspaceFromAssets({
workspaceDir,
packageRoot: resolveCliPackageRoot(),
packageRoot,
});
// Ensure gateway.mode=local so the gateway never drifts to remote mode.
@ -1410,12 +1616,14 @@ export async function bootstrapCommand(
}
const gatewayUrl = `ws://127.0.0.1:${gatewayPort}`;
const preferredWebPort = parseOptionalPort(opts.webPort) ?? DEFAULT_WEB_APP_PORT;
if (!(await probeForWebApp(preferredWebPort))) {
startWebAppIfNeeded(preferredWebPort, stateDir, gatewayPort);
}
const webReachable = await waitForWebApp(preferredWebPort);
const webRuntimeStatus = await ensureManagedWebRuntime({
stateDir,
packageRoot,
denchVersion: VERSION,
port: preferredWebPort,
gatewayPort,
});
const webReachable = webRuntimeStatus.ready;
const webUrl = `http://localhost:${preferredWebPort}`;
const diagnostics = buildBootstrapDiagnostics({
profile,
@ -1435,6 +1643,9 @@ export async function bootstrapCommand(
const opened = shouldOpen ? await openUrl(webUrl) : false;
if (!opts.json) {
if (!webRuntimeStatus.ready) {
runtime.log(theme.warn(`Managed web runtime check failed: ${webRuntimeStatus.reason}`));
}
if (installResult.installed) {
runtime.log(theme.muted("Installed global OpenClaw CLI via npm."));
}

View File

@ -1,8 +1,10 @@
import type { Command } from "commander";
import { getPrimaryCommand } from "../argv.js";
import { reparseProgramFromActionArgs } from "./action-reparse.js";
import { removeCommandByName } from "./command-tree.js";
import type { ProgramContext } from "./context.js";
import { registerBootstrapCommand } from "./register.bootstrap.js";
import { registerStartCommand } from "./register.start.js";
import { registerStopCommand } from "./register.stop.js";
import { registerUpdateCommand } from "./register.update.js";
type CommandRegisterParams = {
program: Command;
@ -16,30 +18,40 @@ type CoreCliEntry = {
register: (params: CommandRegisterParams) => Promise<void> | void;
};
const BOOTSTRAP_ENTRY: CoreCliEntry = {
name: "bootstrap",
description: "Bootstrap DenchClaw + OpenClaw and launch the web UI",
register: async ({ program }) => {
const mod = await import("./register.bootstrap.js");
mod.registerBootstrapCommand(program);
const CORE_CLI_ENTRIES: CoreCliEntry[] = [
{
name: "bootstrap",
description: "Bootstrap DenchClaw + OpenClaw and launch the web UI",
register: ({ program }) => {
registerBootstrapCommand(program);
},
},
};
function registerLazyBootstrap(program: Command, ctx: ProgramContext) {
const placeholder = program
.command(BOOTSTRAP_ENTRY.name)
.description(BOOTSTRAP_ENTRY.description);
placeholder.allowUnknownOption(true);
placeholder.allowExcessArguments(true);
placeholder.action(async (...actionArgs) => {
removeCommandByName(program, BOOTSTRAP_ENTRY.name);
await BOOTSTRAP_ENTRY.register({ program, ctx, argv: process.argv });
await reparseProgramFromActionArgs(program, actionArgs);
});
}
{
name: "update",
description: "Update Dench web runtime without onboarding",
register: ({ program }) => {
registerUpdateCommand(program);
},
},
{
name: "stop",
description: "Stop Dench managed web runtime",
register: ({ program }) => {
registerStopCommand(program);
},
},
{
name: "start",
description: "Start Dench managed web runtime",
register: ({ program }) => {
registerStartCommand(program);
},
},
];
const CORE_CLI_ENTRY_BY_NAME = new Map(CORE_CLI_ENTRIES.map((entry) => [entry.name, entry]));
export function getCoreCliCommandNames(): string[] {
return [BOOTSTRAP_ENTRY.name];
return CORE_CLI_ENTRIES.map((entry) => entry.name);
}
export function getCoreCliCommandsWithSubcommands(): string[] {
@ -52,21 +64,27 @@ export async function registerCoreCliByName(
name: string,
argv: string[] = process.argv,
): Promise<boolean> {
void argv;
if (name !== BOOTSTRAP_ENTRY.name) {
const entry = CORE_CLI_ENTRY_BY_NAME.get(name);
if (!entry) {
return false;
}
removeCommandByName(program, BOOTSTRAP_ENTRY.name);
await BOOTSTRAP_ENTRY.register({ program, ctx, argv });
await entry.register({ program, ctx, argv });
return true;
}
export function registerCoreCliCommands(program: Command, ctx: ProgramContext, argv: string[]) {
const primary = getPrimaryCommand(argv);
if (primary && primary !== BOOTSTRAP_ENTRY.name) {
if (primary) {
const entry = CORE_CLI_ENTRY_BY_NAME.get(primary);
if (!entry) {
return;
}
void entry.register({ program, ctx, argv });
return;
}
registerLazyBootstrap(program, ctx);
for (const entry of CORE_CLI_ENTRIES) {
void entry.register({ program, ctx, argv });
}
}
export function registerProgramCommands(

View File

@ -15,6 +15,9 @@ const ROOT_COMMANDS_HINT =
"Hint: commands suffixed with * have subcommands. Run <command> --help for details.";
const EXAMPLES = [
["openclaw start --web-port 3100", "Start the managed web runtime without replacing assets."],
["openclaw update", "Refresh the managed web runtime and enforce major upgrade gates."],
["openclaw stop --web-port 3100", "Stop only the managed web runtime on a specific port."],
["openclaw models --help", "Show detailed help for the models command."],
[
"openclaw channels login --verbose",

View File

@ -0,0 +1,22 @@
import type { Command } from "commander";
import { defaultRuntime } from "../../runtime.js";
import { runCommandWithRuntime } from "../cli-utils.js";
import { startWebRuntimeCommand } from "../web-runtime-command.js";
export function registerStartCommand(program: Command) {
program
.command("start")
.description("Start Dench managed web runtime without updating assets")
.option("--profile <name>", "Compatibility flag; non-dench values are ignored with a warning")
.option("--web-port <port>", "Web runtime port override")
.option("--json", "Output summary as JSON", false)
.action(async (opts) => {
await runCommandWithRuntime(defaultRuntime, async () => {
await startWebRuntimeCommand({
profile: opts.profile as string | undefined,
webPort: opts.webPort as string | undefined,
json: Boolean(opts.json),
});
});
});
}

View File

@ -0,0 +1,22 @@
import type { Command } from "commander";
import { defaultRuntime } from "../../runtime.js";
import { runCommandWithRuntime } from "../cli-utils.js";
import { stopWebRuntimeCommand } from "../web-runtime-command.js";
export function registerStopCommand(program: Command) {
program
.command("stop")
.description("Stop Dench managed web runtime on the configured port")
.option("--profile <name>", "Compatibility flag; non-dench values are ignored with a warning")
.option("--web-port <port>", "Web runtime port override")
.option("--json", "Output summary as JSON", false)
.action(async (opts) => {
await runCommandWithRuntime(defaultRuntime, async () => {
await stopWebRuntimeCommand({
profile: opts.profile as string | undefined,
webPort: opts.webPort as string | undefined,
json: Boolean(opts.json),
});
});
});
}

View File

@ -0,0 +1,26 @@
import type { Command } from "commander";
import { defaultRuntime } from "../../runtime.js";
import { runCommandWithRuntime } from "../cli-utils.js";
import { updateWebRuntimeCommand } from "../web-runtime-command.js";
export function registerUpdateCommand(program: Command) {
program
.command("update")
.description("Update Dench managed web runtime without onboarding")
.option("--profile <name>", "Compatibility flag; non-dench values are ignored with a warning")
.option("--web-port <port>", "Web runtime port override")
.option("--non-interactive", "Fail instead of prompting for major-gate approval", false)
.option("--yes", "Approve mandatory major-gate OpenClaw update", false)
.option("--json", "Output summary as JSON", false)
.action(async (opts) => {
await runCommandWithRuntime(defaultRuntime, async () => {
await updateWebRuntimeCommand({
profile: opts.profile as string | undefined,
webPort: opts.webPort as string | undefined,
nonInteractive: Boolean(opts.nonInteractive),
yes: Boolean(opts.yes),
json: Boolean(opts.json),
});
});
});
}

View File

@ -1,6 +1,7 @@
import { describe, it, expect } from "vitest";
import {
rewriteBareArgvToBootstrap,
shouldHideCliBanner,
shouldEnableBootstrapCutover,
shouldEnsureCliPath,
shouldDelegateToGlobalOpenClaw,
@ -60,6 +61,9 @@ describe("run-main delegation and path guards", () => {
it("delegates non-bootstrap commands by default and never delegates bootstrap", () => {
expect(shouldDelegateToGlobalOpenClaw(["node", "denchclaw", "chat"])).toBe(true);
expect(shouldDelegateToGlobalOpenClaw(["node", "denchclaw", "bootstrap"])).toBe(false);
expect(shouldDelegateToGlobalOpenClaw(["node", "denchclaw", "update"])).toBe(false);
expect(shouldDelegateToGlobalOpenClaw(["node", "denchclaw", "stop"])).toBe(false);
expect(shouldDelegateToGlobalOpenClaw(["node", "denchclaw", "start"])).toBe(false);
expect(shouldDelegateToGlobalOpenClaw(["node", "denchclaw"])).toBe(false);
});
@ -76,3 +80,17 @@ describe("run-main delegation and path guards", () => {
).toBe(false);
});
});
describe("run-main banner visibility", () => {
it("keeps banner visible for update/start/stop lifecycle commands", () => {
expect(shouldHideCliBanner(["node", "denchclaw", "update"])).toBe(false);
expect(shouldHideCliBanner(["node", "denchclaw", "start"])).toBe(false);
expect(shouldHideCliBanner(["node", "denchclaw", "stop"])).toBe(false);
});
it("hides banner only for completion and plugin-update helper commands", () => {
expect(shouldHideCliBanner(["node", "denchclaw", "completion"])).toBe(true);
expect(shouldHideCliBanner(["node", "denchclaw", "plugins", "update"])).toBe(true);
expect(shouldHideCliBanner(["node", "denchclaw", "chat"])).toBe(false);
});
});

View File

@ -127,7 +127,19 @@ export function shouldDelegateToGlobalOpenClaw(
if (!primary) {
return false;
}
return primary !== "bootstrap";
return (
primary !== "bootstrap" && primary !== "update" && primary !== "stop" && primary !== "start"
);
}
export function shouldHideCliBanner(argv: string[], env: NodeJS.ProcessEnv = process.env): boolean {
const commandPath = getCommandPath(argv, 2);
return (
isTruthyEnvValue(env.DENCHCLAW_HIDE_BANNER) ||
isTruthyEnvValue(env.OPENCLAW_HIDE_BANNER) ||
commandPath[0] === "completion" ||
(commandPath[0] === "plugins" && commandPath[1] === "update")
);
}
async function delegateToGlobalOpenClaw(argv: string[]): Promise<number> {
@ -189,14 +201,7 @@ export async function runCli(argv: string[] = process.argv) {
// Show the animated DenchClaw banner early so it appears for ALL invocations
// (bare `denchclaw`, subcommands, help, etc.). The bannerEmitted flag inside
// emitCliBanner prevents double-emission from the route / preAction hooks.
const commandPath = getCommandPath(normalizedArgv, 2);
const hideBanner =
isTruthyEnvValue(process.env.DENCHCLAW_HIDE_BANNER) ||
isTruthyEnvValue(process.env.OPENCLAW_HIDE_BANNER) ||
commandPath[0] === "update" ||
commandPath[0] === "completion" ||
(commandPath[0] === "plugins" && commandPath[1] === "update");
if (!hideBanner) {
if (!shouldHideCliBanner(normalizedArgv, process.env)) {
await emitCliBanner(VERSION, { argv: normalizedArgv });
}

View File

@ -0,0 +1,333 @@
import { EventEmitter } from "node:events";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { RuntimeEnv } from "../runtime.js";
const promptMocks = vi.hoisted(() => ({
confirm: vi.fn(async () => true),
isCancel: vi.fn(() => false),
spinner: vi.fn(() => ({
start: vi.fn(),
stop: vi.fn(),
message: vi.fn(),
})),
}));
const spawnMock = vi.hoisted(() => vi.fn());
const webRuntimeMocks = vi.hoisted(() => ({
DEFAULT_WEB_APP_PORT: 3100,
ensureManagedWebRuntime: vi.fn(async () => ({ ready: true, reason: "ready" })),
evaluateMajorVersionTransition: vi.fn(() => ({
previousMajor: 2,
currentMajor: 2,
isMajorTransition: false,
})),
readLastKnownWebPort: vi.fn(() => 3100),
readManagedWebRuntimeManifest: vi.fn(() => ({
schemaVersion: 1,
deployedDenchVersion: "2.1.0",
deployedAt: "2026-01-01T00:00:00.000Z",
sourceStandaloneServer: "/tmp/server.js",
lastPort: 3100,
lastGatewayPort: 18789,
})),
resolveCliPackageRoot: vi.fn(() => "/tmp/pkg"),
resolveManagedWebRuntimeServerPath: vi.fn(() => "/tmp/.openclaw-dench/web-runtime/app/server.js"),
resolveOpenClawCommandOrThrow: vi.fn(() => "/usr/local/bin/openclaw"),
resolveProfileStateDir: vi.fn(() => "/tmp/.openclaw-dench"),
runOpenClawCommand: vi.fn(async () => ({ code: 0, stdout: '{"ok":true}', stderr: "" })),
startManagedWebRuntime: vi.fn(() => ({
started: true,
pid: 7788,
runtimeServerPath: "/tmp/.openclaw-dench/web-runtime/app/server.js",
})),
stopManagedWebRuntime: vi.fn(async () => ({
port: 3100,
stoppedPids: [1234],
skippedForeignPids: [],
})),
waitForWebRuntime: vi.fn(async () => ({ ok: true, reason: "profiles payload shape is valid" })),
}));
vi.mock("@clack/prompts", () => ({
confirm: promptMocks.confirm,
isCancel: promptMocks.isCancel,
spinner: promptMocks.spinner,
}));
vi.mock("node:child_process", () => ({
spawn: spawnMock,
}));
vi.mock("./web-runtime.js", () => ({
DEFAULT_WEB_APP_PORT: webRuntimeMocks.DEFAULT_WEB_APP_PORT,
ensureManagedWebRuntime: webRuntimeMocks.ensureManagedWebRuntime,
evaluateMajorVersionTransition: webRuntimeMocks.evaluateMajorVersionTransition,
readLastKnownWebPort: webRuntimeMocks.readLastKnownWebPort,
readManagedWebRuntimeManifest: webRuntimeMocks.readManagedWebRuntimeManifest,
resolveCliPackageRoot: webRuntimeMocks.resolveCliPackageRoot,
resolveManagedWebRuntimeServerPath: webRuntimeMocks.resolveManagedWebRuntimeServerPath,
resolveOpenClawCommandOrThrow: webRuntimeMocks.resolveOpenClawCommandOrThrow,
resolveProfileStateDir: webRuntimeMocks.resolveProfileStateDir,
runOpenClawCommand: webRuntimeMocks.runOpenClawCommand,
startManagedWebRuntime: webRuntimeMocks.startManagedWebRuntime,
stopManagedWebRuntime: webRuntimeMocks.stopManagedWebRuntime,
waitForWebRuntime: webRuntimeMocks.waitForWebRuntime,
}));
import {
startWebRuntimeCommand,
stopWebRuntimeCommand,
updateWebRuntimeCommand,
} from "./web-runtime-command.js";
function createMockChild(code = 0): EventEmitter & {
stdout: EventEmitter;
stderr: EventEmitter;
kill: ReturnType<typeof vi.fn>;
} {
const child = new EventEmitter() as EventEmitter & {
stdout: EventEmitter;
stderr: EventEmitter;
kill: ReturnType<typeof vi.fn>;
};
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
child.kill = vi.fn();
queueMicrotask(() => {
child.emit("close", code);
});
return child;
}
function runtimeStub(): RuntimeEnv {
return {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
}
describe("updateWebRuntimeCommand", () => {
beforeEach(() => {
spawnMock.mockReset();
spawnMock.mockImplementation(() => createMockChild(0));
promptMocks.confirm.mockReset();
promptMocks.confirm.mockImplementation(async () => true);
promptMocks.isCancel.mockReset();
promptMocks.isCancel.mockImplementation(() => false);
webRuntimeMocks.ensureManagedWebRuntime.mockReset();
webRuntimeMocks.ensureManagedWebRuntime.mockImplementation(
async () => ({ ready: true, reason: "ready" }) as { ready: boolean; reason: string },
);
webRuntimeMocks.stopManagedWebRuntime.mockReset();
webRuntimeMocks.stopManagedWebRuntime.mockImplementation(
async () =>
({
port: 3100,
stoppedPids: [1234],
skippedForeignPids: [],
}) as { port: number; stoppedPids: number[]; skippedForeignPids: number[] },
);
webRuntimeMocks.evaluateMajorVersionTransition.mockReset();
webRuntimeMocks.evaluateMajorVersionTransition.mockImplementation(() => ({
previousMajor: 2,
currentMajor: 2,
isMajorTransition: false,
}));
webRuntimeMocks.readManagedWebRuntimeManifest.mockReset();
webRuntimeMocks.readManagedWebRuntimeManifest.mockImplementation(() => ({
schemaVersion: 1,
deployedDenchVersion: "2.1.0",
deployedAt: "2026-01-01T00:00:00.000Z",
sourceStandaloneServer: "/tmp/server.js",
lastPort: 3100,
lastGatewayPort: 18789,
}));
webRuntimeMocks.startManagedWebRuntime.mockReset();
webRuntimeMocks.startManagedWebRuntime.mockImplementation(() => ({
started: true,
pid: 7788,
runtimeServerPath: "/tmp/.openclaw-dench/web-runtime/app/server.js",
}));
webRuntimeMocks.waitForWebRuntime.mockReset();
webRuntimeMocks.waitForWebRuntime.mockImplementation(
async () =>
({ ok: true, reason: "profiles payload shape is valid" }) as {
ok: boolean;
reason: string;
},
);
});
it("fails closed in non-interactive major upgrades without explicit approval (enforces mandatory operator consent)", async () => {
webRuntimeMocks.evaluateMajorVersionTransition.mockReturnValue({
previousMajor: 2,
currentMajor: 3,
isMajorTransition: true,
});
const runtime = runtimeStub();
await expect(
updateWebRuntimeCommand(
{
nonInteractive: true,
yes: false,
},
runtime,
),
).rejects.toThrow("Major Dench upgrade detected");
expect(spawnMock).not.toHaveBeenCalled();
expect(webRuntimeMocks.ensureManagedWebRuntime).not.toHaveBeenCalled();
});
it("runs OpenClaw update before refreshing web runtime on major transitions (protects upgrade compatibility)", async () => {
webRuntimeMocks.evaluateMajorVersionTransition.mockReturnValue({
previousMajor: 2,
currentMajor: 3,
isMajorTransition: true,
});
const runtime = runtimeStub();
const summary = await updateWebRuntimeCommand(
{
nonInteractive: true,
yes: true,
},
runtime,
);
expect(spawnMock).toHaveBeenCalledWith(
"/usr/local/bin/openclaw",
["update", "--yes"],
expect.objectContaining({ stdio: ["ignore", "pipe", "pipe"] }),
);
expect(webRuntimeMocks.ensureManagedWebRuntime).toHaveBeenCalled();
expect(summary.majorGate.required).toBe(true);
});
it("skips OpenClaw update on minor upgrades while still refreshing runtime (avoids unnecessary blocking)", async () => {
webRuntimeMocks.evaluateMajorVersionTransition.mockReturnValue({
previousMajor: 2,
currentMajor: 2,
isMajorTransition: false,
});
const runtime = runtimeStub();
const summary = await updateWebRuntimeCommand(
{
nonInteractive: true,
},
runtime,
);
expect(spawnMock).not.toHaveBeenCalled();
expect(webRuntimeMocks.stopManagedWebRuntime).toHaveBeenCalledWith({
stateDir: "/tmp/.openclaw-dench",
port: 3100,
includeLegacyStandalone: true,
});
expect(webRuntimeMocks.ensureManagedWebRuntime).toHaveBeenCalled();
expect(summary.ready).toBe(true);
});
});
describe("stopWebRuntimeCommand", () => {
it("reports foreign listeners without terminating them (preserves process boundaries)", async () => {
webRuntimeMocks.stopManagedWebRuntime.mockResolvedValue({
port: 3100,
stoppedPids: [],
skippedForeignPids: [91, 92],
});
const runtime = runtimeStub();
const summary = await stopWebRuntimeCommand(
{
webPort: "3100",
},
runtime,
);
expect(summary.stoppedPids).toEqual([]);
expect(summary.skippedForeignPids).toEqual([91, 92]);
});
});
describe("startWebRuntimeCommand", () => {
beforeEach(() => {
webRuntimeMocks.ensureManagedWebRuntime.mockClear();
webRuntimeMocks.stopManagedWebRuntime.mockReset();
webRuntimeMocks.stopManagedWebRuntime.mockImplementation(
async () =>
({
port: 3100,
stoppedPids: [1234],
skippedForeignPids: [],
}) as { port: number; stoppedPids: number[]; skippedForeignPids: number[] },
);
webRuntimeMocks.startManagedWebRuntime.mockReset();
webRuntimeMocks.startManagedWebRuntime.mockImplementation(() => ({
started: true,
pid: 7788,
runtimeServerPath: "/tmp/.openclaw-dench/web-runtime/app/server.js",
}));
webRuntimeMocks.waitForWebRuntime.mockReset();
webRuntimeMocks.waitForWebRuntime.mockImplementation(
async () =>
({ ok: true, reason: "profiles payload shape is valid" }) as {
ok: boolean;
reason: string;
},
);
});
it("fails closed when non-dench listeners still own the port (prevents cross-process takeover)", async () => {
webRuntimeMocks.stopManagedWebRuntime.mockResolvedValue({
port: 3100,
stoppedPids: [],
skippedForeignPids: [9912],
});
const runtime = runtimeStub();
await expect(startWebRuntimeCommand({}, runtime)).rejects.toThrow("non-Dench listener");
expect(webRuntimeMocks.startManagedWebRuntime).not.toHaveBeenCalled();
expect(spawnMock).not.toHaveBeenCalled();
});
it("fails with actionable remediation when managed runtime is missing (requires explicit update/bootstrap)", async () => {
webRuntimeMocks.startManagedWebRuntime.mockReturnValue({
started: false,
runtimeServerPath: "/tmp/.openclaw-dench/web-runtime/app/server.js",
reason: "runtime-missing",
});
const runtime = runtimeStub();
await expect(startWebRuntimeCommand({}, runtime)).rejects.toThrow("dench update");
expect(spawnMock).not.toHaveBeenCalled();
});
it("starts managed runtime without triggering update/install workflow (start-only behavior)", async () => {
const runtime = runtimeStub();
const summary = await startWebRuntimeCommand(
{
webPort: "3100",
},
runtime,
);
expect(webRuntimeMocks.stopManagedWebRuntime).toHaveBeenCalledWith({
stateDir: "/tmp/.openclaw-dench",
port: 3100,
includeLegacyStandalone: true,
});
expect(webRuntimeMocks.startManagedWebRuntime).toHaveBeenCalledWith({
stateDir: "/tmp/.openclaw-dench",
port: 3100,
gatewayPort: 18789,
});
expect(webRuntimeMocks.ensureManagedWebRuntime).not.toHaveBeenCalled();
expect(spawnMock).not.toHaveBeenCalled();
expect(summary.started).toBe(true);
});
});

View File

@ -0,0 +1,488 @@
import { spawn } from "node:child_process";
import { readFileSync } from "node:fs";
import path from "node:path";
import process from "node:process";
import { confirm, isCancel, spinner } from "@clack/prompts";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { stylePromptMessage } from "../terminal/prompt-style.js";
import { theme } from "../terminal/theme.js";
import { VERSION } from "../version.js";
import { applyCliProfileEnv } from "./profile.js";
import {
DEFAULT_WEB_APP_PORT,
ensureManagedWebRuntime,
evaluateMajorVersionTransition,
readLastKnownWebPort,
readManagedWebRuntimeManifest,
resolveCliPackageRoot,
resolveManagedWebRuntimeServerPath,
resolveOpenClawCommandOrThrow,
resolveProfileStateDir,
runOpenClawCommand,
startManagedWebRuntime,
stopManagedWebRuntime,
waitForWebRuntime,
} from "./web-runtime.js";
type SpawnResult = {
code: number;
stdout: string;
stderr: string;
};
export type UpdateWebRuntimeOptions = {
profile?: string;
webPort?: string | number;
nonInteractive?: boolean;
yes?: boolean;
json?: boolean;
};
export type StopWebRuntimeOptions = {
profile?: string;
webPort?: string | number;
json?: boolean;
};
export type StartWebRuntimeOptions = {
profile?: string;
webPort?: string | number;
json?: boolean;
};
export type UpdateWebRuntimeSummary = {
profile: string;
webPort: number;
version: string;
majorGate: {
required: boolean;
previousVersion?: string;
currentVersion: string;
};
stoppedPids: number[];
skippedForeignPids: number[];
ready: boolean;
reason: string;
};
export type StopWebRuntimeSummary = {
profile: string;
webPort: number;
stoppedPids: number[];
skippedForeignPids: number[];
};
export type StartWebRuntimeSummary = {
profile: string;
webPort: number;
stoppedPids: number[];
skippedForeignPids: number[];
started: boolean;
reason: string;
};
function parseOptionalPort(value: string | number | undefined): number | undefined {
if (value === undefined) {
return undefined;
}
const parsed = typeof value === "number" ? value : Number.parseInt(String(value), 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
return undefined;
}
return parsed;
}
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;
}
async function runOpenClawUpdateWithProgress(openclawCommand: string): Promise<void> {
const s = spinner();
s.start("Updating OpenClaw (required for this major Dench upgrade)...");
const result = await new Promise<SpawnResult>((resolve, reject) => {
const child = spawn(openclawCommand, ["update", "--yes"], {
stdio: ["ignore", "pipe", "pipe"],
env: process.env,
});
let stdout = "";
let stderr = "";
let settled = false;
const timer = setTimeout(() => {
if (!settled) {
child.kill("SIGKILL");
}
}, 8 * 60_000);
const updateSpinner = (chunk: string) => {
const line = chunk
.split(/\r?\n/)
.map((item) => item.trim())
.filter(Boolean)
.at(-1);
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("OpenClaw update complete.");
return;
}
const detail = firstNonEmptyLine(result.stderr, result.stdout);
s.stop(detail ? `OpenClaw update failed: ${detail}` : "OpenClaw update failed.");
throw new Error(
detail
? `OpenClaw update failed.\n${detail}`
: "OpenClaw update failed. Fix this before running `dench update` again.",
);
}
async function ensureMajorUpgradeAcknowledged(params: {
required: boolean;
previousVersion: string | undefined;
currentVersion: string;
nonInteractive: boolean;
yes: boolean;
runtime: RuntimeEnv;
}): Promise<void> {
if (!params.required) {
return;
}
if (params.nonInteractive || !process.stdin.isTTY) {
if (!params.yes) {
throw new Error(
`Major Dench upgrade detected (${params.previousVersion ?? "unknown"} -> ${params.currentVersion}). Re-run with --yes to approve the required OpenClaw update.`,
);
}
return;
}
if (params.yes) {
return;
}
const decision = await confirm({
message: stylePromptMessage(
`Major Dench upgrade detected (${params.previousVersion ?? "unknown"} -> ${params.currentVersion}). Continue with mandatory OpenClaw update now?`,
),
initialValue: true,
});
if (isCancel(decision) || !decision) {
params.runtime.log(
theme.warn("Update cancelled. OpenClaw update is required for major upgrades."),
);
throw new Error("Major upgrade requires OpenClaw update approval.");
}
}
function resolveGatewayPort(stateDir: string): number {
const manifest = readManagedWebRuntimeManifest(stateDir);
if (
typeof manifest?.lastGatewayPort === "number" &&
Number.isFinite(manifest.lastGatewayPort) &&
manifest.lastGatewayPort > 0
) {
return manifest.lastGatewayPort;
}
for (const name of ["openclaw.json", "config.json"]) {
const port = readConfigGatewayPort(path.join(stateDir, name));
if (typeof port === "number") {
return port;
}
}
return 18789;
}
function readConfigGatewayPort(configPath: string): number | undefined {
try {
const raw = JSON.parse(readFileSync(configPath, "utf-8")) as {
gateway?: { port?: unknown };
};
const parsedPort =
typeof raw.gateway?.port === "number"
? raw.gateway.port
: typeof raw.gateway?.port === "string"
? Number.parseInt(raw.gateway.port, 10)
: undefined;
if (typeof parsedPort === "number" && Number.isFinite(parsedPort) && parsedPort > 0) {
return parsedPort;
}
return undefined;
} catch {
return undefined;
}
}
export async function updateWebRuntimeCommand(
opts: UpdateWebRuntimeOptions,
runtime: RuntimeEnv = defaultRuntime,
): Promise<UpdateWebRuntimeSummary> {
const appliedProfile = applyCliProfileEnv({ profile: opts.profile });
const profile = appliedProfile.effectiveProfile;
if (appliedProfile.warning && !opts.json) {
runtime.log(theme.warn(appliedProfile.warning));
}
const stateDir = resolveProfileStateDir(profile);
const packageRoot = resolveCliPackageRoot();
const previousManifest = readManagedWebRuntimeManifest(stateDir);
const transition = evaluateMajorVersionTransition({
previousVersion: previousManifest?.deployedDenchVersion,
currentVersion: VERSION,
});
const nonInteractive = Boolean(opts.nonInteractive || opts.json);
await ensureMajorUpgradeAcknowledged({
required: transition.isMajorTransition,
previousVersion: previousManifest?.deployedDenchVersion,
currentVersion: VERSION,
nonInteractive,
yes: Boolean(opts.yes),
runtime,
});
if (transition.isMajorTransition) {
const openclawCommand = resolveOpenClawCommandOrThrow();
await runOpenClawUpdateWithProgress(openclawCommand);
}
const selectedPort =
parseOptionalPort(opts.webPort) ??
parseOptionalPort(previousManifest?.lastPort) ??
readLastKnownWebPort(stateDir) ??
DEFAULT_WEB_APP_PORT;
const gatewayPort = resolveGatewayPort(stateDir);
const stopResult = await stopManagedWebRuntime({
stateDir,
port: selectedPort,
includeLegacyStandalone: true,
});
const ensureResult = await ensureManagedWebRuntime({
stateDir,
packageRoot,
denchVersion: VERSION,
port: selectedPort,
gatewayPort,
});
const summary: UpdateWebRuntimeSummary = {
profile,
webPort: selectedPort,
version: VERSION,
majorGate: {
required: transition.isMajorTransition,
previousVersion: previousManifest?.deployedDenchVersion,
currentVersion: VERSION,
},
stoppedPids: stopResult.stoppedPids,
skippedForeignPids: stopResult.skippedForeignPids,
ready: ensureResult.ready,
reason: ensureResult.reason,
};
if (!opts.json) {
runtime.log("");
runtime.log(theme.heading("Dench web update"));
runtime.log(`Profile: ${profile}`);
runtime.log(`Version: ${VERSION}`);
runtime.log(`Web port: ${selectedPort}`);
runtime.log(`Stopped web processes: ${summary.stoppedPids.length}`);
if (summary.skippedForeignPids.length > 0) {
runtime.log(
theme.warn(
`Skipped non-Dench listeners on ${selectedPort}: ${summary.skippedForeignPids.join(", ")}`,
),
);
}
runtime.log(`Web runtime: ${summary.ready ? "ready" : "not ready"}`);
if (!summary.ready) {
runtime.log(theme.warn(summary.reason));
}
}
if (!summary.ready) {
throw new Error(`Web runtime update failed: ${summary.reason}`);
}
if (opts.json) {
runtime.log(JSON.stringify(summary, null, 2));
}
return summary;
}
export async function stopWebRuntimeCommand(
opts: StopWebRuntimeOptions,
runtime: RuntimeEnv = defaultRuntime,
): Promise<StopWebRuntimeSummary> {
const appliedProfile = applyCliProfileEnv({ profile: opts.profile });
const profile = appliedProfile.effectiveProfile;
if (appliedProfile.warning && !opts.json) {
runtime.log(theme.warn(appliedProfile.warning));
}
const stateDir = resolveProfileStateDir(profile);
const selectedPort = parseOptionalPort(opts.webPort) ?? readLastKnownWebPort(stateDir);
const stopResult = await stopManagedWebRuntime({
stateDir,
port: selectedPort,
includeLegacyStandalone: true,
});
const summary: StopWebRuntimeSummary = {
profile,
webPort: selectedPort,
stoppedPids: stopResult.stoppedPids,
skippedForeignPids: stopResult.skippedForeignPids,
};
if (opts.json) {
runtime.log(JSON.stringify(summary, null, 2));
return summary;
}
runtime.log("");
runtime.log(theme.heading("Dench web stop"));
runtime.log(`Profile: ${profile}`);
runtime.log(`Web port: ${selectedPort}`);
runtime.log(
summary.stoppedPids.length > 0
? `Stopped web processes: ${summary.stoppedPids.join(", ")}`
: "Stopped web processes: none",
);
if (summary.skippedForeignPids.length > 0) {
runtime.log(
theme.warn(
`Left non-Dench listener(s) running on ${selectedPort}: ${summary.skippedForeignPids.join(", ")}`,
),
);
}
return summary;
}
export async function startWebRuntimeCommand(
opts: StartWebRuntimeOptions,
runtime: RuntimeEnv = defaultRuntime,
): Promise<StartWebRuntimeSummary> {
const appliedProfile = applyCliProfileEnv({ profile: opts.profile });
const profile = appliedProfile.effectiveProfile;
if (appliedProfile.warning && !opts.json) {
runtime.log(theme.warn(appliedProfile.warning));
}
const stateDir = resolveProfileStateDir(profile);
const selectedPort = parseOptionalPort(opts.webPort) ?? readLastKnownWebPort(stateDir);
const gatewayPort = resolveGatewayPort(stateDir);
const stopResult = await stopManagedWebRuntime({
stateDir,
port: selectedPort,
includeLegacyStandalone: true,
});
if (stopResult.skippedForeignPids.length > 0) {
throw new Error(
`Cannot start on ${selectedPort}; non-Dench listener(s) still own the port: ${stopResult.skippedForeignPids.join(", ")}`,
);
}
const startResult = startManagedWebRuntime({
stateDir,
port: selectedPort,
gatewayPort,
});
if (!startResult.started) {
const runtimeServerPath = resolveManagedWebRuntimeServerPath(stateDir);
throw new Error(
[
`Managed web runtime is missing at ${runtimeServerPath}.`,
"Run `dench update` (or `dench bootstrap`) to install/update the web runtime first.",
].join(" "),
);
}
const probe = await waitForWebRuntime(selectedPort);
const summary: StartWebRuntimeSummary = {
profile,
webPort: selectedPort,
stoppedPids: stopResult.stoppedPids,
skippedForeignPids: stopResult.skippedForeignPids,
started: probe.ok,
reason: probe.reason,
};
if (opts.json) {
runtime.log(JSON.stringify(summary, null, 2));
return summary;
}
runtime.log("");
runtime.log(theme.heading("Dench web start"));
runtime.log(`Profile: ${profile}`);
runtime.log(`Web port: ${selectedPort}`);
runtime.log(`Restarted managed web runtime: ${summary.started ? "yes" : "no"}`);
if (!summary.started) {
runtime.log(theme.warn(summary.reason));
}
if (!summary.started) {
throw new Error(`Web runtime failed readiness probe: ${summary.reason}`);
}
return summary;
}
export async function verifyOpenClawHealthForUpdate(profile: string): Promise<void> {
const openclawCommand = resolveOpenClawCommandOrThrow();
const result = await runOpenClawCommand({
openclawCommand,
args: ["--profile", profile, "health", "--json"],
timeoutMs: 12_000,
});
if (result.code === 0) {
return;
}
const detail = firstNonEmptyLine(result.stderr, result.stdout);
throw new Error(
detail ? `Gateway health check failed.\n${detail}` : "Gateway health check failed.",
);
}

View File

@ -0,0 +1,81 @@
import { describe, expect, it } from "vitest";
import {
classifyWebPortListener,
evaluateMajorVersionTransition,
evaluateWebProfilesPayload,
} from "./web-runtime.js";
describe("evaluateWebProfilesPayload", () => {
it("accepts nullable active profile when profiles payload shape is valid (prevents first-run false negatives)", () => {
const result = evaluateWebProfilesPayload({
profiles: [],
activeProfile: null,
});
expect(result.ok).toBe(true);
});
it("accepts workspace compatibility fields when profile aliases are missing (preserves API compatibility)", () => {
const result = evaluateWebProfilesPayload({
workspaces: [{ name: "default" }],
activeWorkspace: "default",
});
expect(result.ok).toBe(true);
});
it("rejects payloads that omit active profile/workspace state (guards readiness contract)", () => {
const result = evaluateWebProfilesPayload({
profiles: [],
});
expect(result.ok).toBe(false);
expect(result.reason).toContain("active profile/workspace");
});
});
describe("classifyWebPortListener", () => {
it("classifies listeners under managed runtime dir as managed ownership (prevents cross-process kills)", () => {
const managedRuntimeAppDir = "/Users/test/.openclaw-dench/web-runtime/app";
const ownership = classifyWebPortListener({
cwd: "/Users/test/.openclaw-dench/web-runtime/app",
managedRuntimeAppDir,
});
expect(ownership).toBe("managed");
});
it("classifies legacy standalone cwd as dench-owned legacy runtime (supports old bootstrap cleanup)", () => {
const ownership = classifyWebPortListener({
cwd: "/Users/test/projects/ironclaw/apps/web/.next/standalone/apps/web",
managedRuntimeAppDir: "/Users/test/.openclaw-dench/web-runtime/app",
});
expect(ownership).toBe("legacy-standalone");
});
it("classifies unknown cwd as foreign ownership (enforces process boundary safety)", () => {
const ownership = classifyWebPortListener({
cwd: "/Applications/OtherApp/runtime",
managedRuntimeAppDir: "/Users/test/.openclaw-dench/web-runtime/app",
});
expect(ownership).toBe("foreign");
});
});
describe("evaluateMajorVersionTransition", () => {
it("detects major changes across semver values (enforces mandatory upgrade gate)", () => {
const result = evaluateMajorVersionTransition({
previousVersion: "2.9.0",
currentVersion: "3.0.1",
});
expect(result.isMajorTransition).toBe(true);
expect(result.previousMajor).toBe(2);
expect(result.currentMajor).toBe(3);
});
it("treats prerelease-to-minor within same major as non-major transition (avoids unnecessary blocking)", () => {
const result = evaluateMajorVersionTransition({
previousVersion: "2.0.0-1",
currentVersion: "2.1.0",
});
expect(result.isMajorTransition).toBe(false);
expect(result.previousMajor).toBe(2);
expect(result.currentMajor).toBe(2);
});
});

738
src/cli/web-runtime.ts Normal file
View File

@ -0,0 +1,738 @@
import { spawn, execFileSync } from "node:child_process";
import {
cpSync,
existsSync,
mkdirSync,
openSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import os from "node:os";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
import { resolveRequiredHomeDir } from "../infra/home-dir.js";
import { resolveLsofCommandSync } from "../infra/ports-lsof.js";
import { sleep } from "../utils.js";
import { listPortListeners, type PortProcess } from "./ports.js";
export const DEFAULT_WEB_APP_PORT = 3100;
const WEB_RUNTIME_DIRNAME = "web-runtime";
const WEB_RUNTIME_APP_DIRNAME = "app";
const WEB_RUNTIME_MANIFEST_FILENAME = "manifest.json";
const WEB_RUNTIME_PROCESS_FILENAME = "process.json";
const WEB_APP_PROBE_ATTEMPTS = 20;
const WEB_APP_PROBE_DELAY_MS = 750;
const WEB_APP_PROBE_TIMEOUT_MS = 1_500;
const LEGACY_STANDALONE_SEGMENT = "/apps/web/.next/standalone/apps/web";
export type WebProbeResult = {
ok: boolean;
status?: number;
reason: string;
};
export type WebProfilesPayloadEvaluation = {
ok: boolean;
reason: string;
};
export type ManagedWebRuntimeManifest = {
schemaVersion: 1;
deployedDenchVersion: string;
deployedAt: string;
sourceStandaloneServer: string;
lastPort?: number;
lastGatewayPort?: number;
};
export type ManagedWebRuntimeProcess = {
pid: number;
port: number;
gatewayPort: number;
startedAt: string;
runtimeAppDir: string;
};
export type InstallManagedWebRuntimeResult =
| {
installed: true;
runtimeDir: string;
runtimeAppDir: string;
runtimeServerPath: string;
manifest: ManagedWebRuntimeManifest;
}
| {
installed: false;
runtimeDir: string;
runtimeAppDir: string;
runtimeServerPath: string;
reason: "standalone-missing";
};
export type StartManagedWebRuntimeResult =
| {
started: true;
pid: number;
runtimeServerPath: string;
}
| {
started: false;
runtimeServerPath: string;
reason: "runtime-missing";
};
export type WebPortListenerOwnership = "managed" | "legacy-standalone" | "foreign";
export type WebPortListener = PortProcess & {
cwd?: string;
ownership: WebPortListenerOwnership;
};
export type StopManagedWebRuntimeResult = {
port: number;
stoppedPids: number[];
skippedForeignPids: number[];
};
export type MajorVersionTransition = {
previousMajor: number | null;
currentMajor: number | null;
isMajorTransition: boolean;
};
function normalizePathForMatch(value: string): string {
return value.replaceAll("\\", "/").toLowerCase();
}
function isPathWithin(parentDir: string, candidatePath: string): boolean {
const relative = path.relative(path.resolve(parentDir), path.resolve(candidatePath));
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
}
function parseOptionalPositiveInt(value: string | number | undefined): number | undefined {
if (value === undefined) {
return undefined;
}
const parsed = typeof value === "number" ? value : Number.parseInt(String(value), 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
return undefined;
}
return parsed;
}
function ensureParentDir(filePath: string): void {
mkdirSync(path.dirname(filePath), { recursive: true });
}
function readJsonFile<T>(filePath: string): T | null {
if (!existsSync(filePath)) {
return null;
}
try {
return JSON.parse(readFileSync(filePath, "utf-8")) as T;
} catch {
return null;
}
}
function writeJsonFile(filePath: string, value: unknown): void {
ensureParentDir(filePath);
writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf-8");
}
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;
}
function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (error) {
const err = error as NodeJS.ErrnoException;
return err.code !== "ESRCH";
}
}
async function terminatePidWithEscalation(pid: number): Promise<void> {
try {
process.kill(pid, "SIGTERM");
} catch (error) {
const err = error as NodeJS.ErrnoException;
if (err.code === "ESRCH") {
return;
}
throw error;
}
for (let i = 0; i < 8; i += 1) {
if (!isProcessAlive(pid)) {
return;
}
await sleep(100);
}
if (!isProcessAlive(pid)) {
return;
}
try {
process.kill(pid, "SIGKILL");
} catch (error) {
const err = error as NodeJS.ErrnoException;
if (err.code === "ESRCH") {
return;
}
throw error;
}
for (let i = 0; i < 8; i += 1) {
if (!isProcessAlive(pid)) {
return;
}
await sleep(100);
}
}
export function resolveCliPackageRoot(): string {
let dir = path.dirname(fileURLToPath(import.meta.url));
for (let i = 0; i < 5; i += 1) {
if (existsSync(path.join(dir, "package.json"))) {
return dir;
}
dir = path.dirname(dir);
}
return process.cwd();
}
export function resolveProfileStateDir(
profile: string,
env: NodeJS.ProcessEnv = process.env,
): string {
void profile;
const home = resolveRequiredHomeDir(env, os.homedir);
return path.join(home, ".openclaw-dench");
}
export function resolveManagedWebRuntimeDir(stateDir: string): string {
return path.join(stateDir, WEB_RUNTIME_DIRNAME);
}
export function resolveManagedWebRuntimeAppDir(stateDir: string): string {
return path.join(resolveManagedWebRuntimeDir(stateDir), WEB_RUNTIME_APP_DIRNAME);
}
export function resolveManagedWebRuntimeServerPath(stateDir: string): string {
return path.join(resolveManagedWebRuntimeAppDir(stateDir), "server.js");
}
export function resolveManagedWebRuntimeManifestPath(stateDir: string): string {
return path.join(resolveManagedWebRuntimeDir(stateDir), WEB_RUNTIME_MANIFEST_FILENAME);
}
export function resolveManagedWebRuntimeProcessPath(stateDir: string): string {
return path.join(resolveManagedWebRuntimeDir(stateDir), WEB_RUNTIME_PROCESS_FILENAME);
}
export function resolvePackagedStandaloneServerPath(packageRoot: string): string {
return path.join(packageRoot, "apps/web/.next/standalone/apps/web/server.js");
}
function resolvePackagedStandaloneAppDir(packageRoot: string): string {
return path.dirname(resolvePackagedStandaloneServerPath(packageRoot));
}
export function readManagedWebRuntimeManifest(stateDir: string): ManagedWebRuntimeManifest | null {
return readJsonFile<ManagedWebRuntimeManifest>(resolveManagedWebRuntimeManifestPath(stateDir));
}
export function readManagedWebRuntimeProcess(stateDir: string): ManagedWebRuntimeProcess | null {
return readJsonFile<ManagedWebRuntimeProcess>(resolveManagedWebRuntimeProcessPath(stateDir));
}
function writeManagedWebRuntimeManifest(
stateDir: string,
manifest: ManagedWebRuntimeManifest,
): ManagedWebRuntimeManifest {
writeJsonFile(resolveManagedWebRuntimeManifestPath(stateDir), manifest);
return manifest;
}
function writeManagedWebRuntimeProcess(
stateDir: string,
processMeta: ManagedWebRuntimeProcess,
): void {
writeJsonFile(resolveManagedWebRuntimeProcessPath(stateDir), processMeta);
}
function clearManagedWebRuntimeProcess(stateDir: string): void {
rmSync(resolveManagedWebRuntimeProcessPath(stateDir), { force: true });
}
function updateManifestLastPort(
stateDir: string,
webPort: number,
gatewayPort: number,
): ManagedWebRuntimeManifest | null {
const manifest = readManagedWebRuntimeManifest(stateDir);
if (!manifest) {
return null;
}
const nextManifest: ManagedWebRuntimeManifest = {
...manifest,
lastPort: webPort,
lastGatewayPort: gatewayPort,
};
return writeManagedWebRuntimeManifest(stateDir, nextManifest);
}
export function evaluateWebProfilesPayload(payload: unknown): WebProfilesPayloadEvaluation {
if (!payload || typeof payload !== "object") {
return { ok: false, reason: "response payload is not an object" };
}
const data = payload as Record<string, unknown>;
const profiles = Array.isArray(data.profiles)
? data.profiles
: Array.isArray(data.workspaces)
? data.workspaces
: undefined;
if (!profiles) {
return { ok: false, reason: "response payload missing profiles/workspaces array" };
}
const active =
data.activeProfile === null || typeof data.activeProfile === "string"
? data.activeProfile
: data.activeWorkspace === null || typeof data.activeWorkspace === "string"
? data.activeWorkspace
: undefined;
if (active === undefined) {
return { ok: false, reason: "response payload missing active profile/workspace field" };
}
return { ok: true, reason: "profiles payload shape is valid" };
}
export async function probeWebRuntime(port: number): Promise<WebProbeResult> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), WEB_APP_PROBE_TIMEOUT_MS);
try {
const response = await fetch(`http://127.0.0.1:${port}/api/profiles`, {
method: "GET",
signal: controller.signal,
redirect: "manual",
});
if (response.status < 200 || response.status >= 400) {
return {
ok: false,
status: response.status,
reason: `/api/profiles returned status ${response.status}`,
};
}
const payload = await response.json().catch(() => null);
const evaluation = evaluateWebProfilesPayload(payload);
return {
ok: evaluation.ok,
status: response.status,
reason: evaluation.reason,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
ok: false,
reason: message || "probe failed",
};
} finally {
clearTimeout(timer);
}
}
export async function waitForWebRuntime(port: number): Promise<WebProbeResult> {
let lastResult: WebProbeResult = { ok: false, reason: "web runtime did not respond" };
for (let attempt = 0; attempt < WEB_APP_PROBE_ATTEMPTS; attempt += 1) {
const result = await probeWebRuntime(port);
if (result.ok) {
return result;
}
lastResult = result;
await sleep(WEB_APP_PROBE_DELAY_MS);
}
return lastResult;
}
export function classifyWebPortListener(params: {
cwd: string | undefined;
managedRuntimeAppDir: string;
}): WebPortListenerOwnership {
if (!params.cwd) {
return "foreign";
}
if (isPathWithin(params.managedRuntimeAppDir, params.cwd)) {
return "managed";
}
const cwdNormalized = normalizePathForMatch(params.cwd);
if (cwdNormalized.includes(LEGACY_STANDALONE_SEGMENT)) {
return "legacy-standalone";
}
return "foreign";
}
export function parseSemverMajor(version: string | undefined): number | null {
if (!version) {
return null;
}
const match = version.trim().match(/^v?(\d+)(?:\.\d+)?(?:\.\d+)?(?:[-+].*)?$/u);
if (!match) {
return null;
}
const major = Number.parseInt(match[1], 10);
if (!Number.isFinite(major)) {
return null;
}
return major;
}
export function evaluateMajorVersionTransition(params: {
previousVersion: string | undefined;
currentVersion: string | undefined;
}): MajorVersionTransition {
const previousMajor = parseSemverMajor(params.previousVersion);
const currentMajor = parseSemverMajor(params.currentVersion);
return {
previousMajor,
currentMajor,
isMajorTransition:
previousMajor !== null && currentMajor !== null && previousMajor !== currentMajor,
};
}
function resolveProcessCwd(pid: number): string | undefined {
try {
const lsof = resolveLsofCommandSync();
const output = execFileSync(lsof, ["-nP", "-a", "-p", String(pid), "-d", "cwd", "-Fn"], {
encoding: "utf-8",
});
for (const line of output.split(/\r?\n/)) {
if (line.startsWith("n")) {
const cwd = line.slice(1).trim();
if (cwd.length > 0) {
return cwd;
}
}
}
return undefined;
} catch {
return undefined;
}
}
export function inspectWebPortListeners(port: number, stateDir: string): WebPortListener[] {
if (process.env.VITEST === "true" && process.env.OPENCLAW_TEST_REAL_PORTS !== "1") {
return [];
}
const listeners = listPortListeners(port);
const managedRuntimeAppDir = resolveManagedWebRuntimeAppDir(stateDir);
return listeners.map((listener) => {
const cwd = resolveProcessCwd(listener.pid);
const ownership = classifyWebPortListener({
cwd,
managedRuntimeAppDir,
});
return {
...listener,
cwd,
ownership,
};
});
}
export function readLastKnownWebPort(stateDir: string): number {
const processMeta = readManagedWebRuntimeProcess(stateDir);
const processPort = parseOptionalPositiveInt(processMeta?.port);
if (processPort) {
return processPort;
}
const manifest = readManagedWebRuntimeManifest(stateDir);
const manifestPort = parseOptionalPositiveInt(manifest?.lastPort);
if (manifestPort) {
return manifestPort;
}
return DEFAULT_WEB_APP_PORT;
}
export function installManagedWebRuntime(params: {
stateDir: string;
packageRoot: string;
denchVersion: string;
webPort?: number;
gatewayPort?: number;
}): InstallManagedWebRuntimeResult {
const runtimeDir = resolveManagedWebRuntimeDir(params.stateDir);
const runtimeAppDir = resolveManagedWebRuntimeAppDir(params.stateDir);
const runtimeServerPath = resolveManagedWebRuntimeServerPath(params.stateDir);
const sourceStandaloneServer = resolvePackagedStandaloneServerPath(params.packageRoot);
const sourceAppDir = resolvePackagedStandaloneAppDir(params.packageRoot);
if (!existsSync(sourceStandaloneServer)) {
return {
installed: false,
runtimeDir,
runtimeAppDir,
runtimeServerPath,
reason: "standalone-missing",
};
}
mkdirSync(runtimeDir, { recursive: true });
rmSync(runtimeAppDir, { recursive: true, force: true });
cpSync(sourceAppDir, runtimeAppDir, { recursive: true, force: true });
const manifest: ManagedWebRuntimeManifest = {
schemaVersion: 1,
deployedDenchVersion: params.denchVersion,
deployedAt: new Date().toISOString(),
sourceStandaloneServer,
...(typeof params.webPort === "number" ? { lastPort: params.webPort } : {}),
...(typeof params.gatewayPort === "number" ? { lastGatewayPort: params.gatewayPort } : {}),
};
writeManagedWebRuntimeManifest(params.stateDir, manifest);
return {
installed: true,
runtimeDir,
runtimeAppDir,
runtimeServerPath,
manifest,
};
}
export async function stopManagedWebRuntime(params: {
stateDir: string;
port: number;
includeLegacyStandalone?: boolean;
}): Promise<StopManagedWebRuntimeResult> {
const listeners = inspectWebPortListeners(params.port, params.stateDir);
const includeLegacyStandalone = params.includeLegacyStandalone ?? true;
const stoppable = listeners.filter(
(listener) =>
listener.ownership === "managed" ||
(includeLegacyStandalone && listener.ownership === "legacy-standalone"),
);
const skippedForeign = listeners.filter((listener) => listener.ownership === "foreign");
const uniquePids = [...new Set(stoppable.map((listener) => listener.pid))];
const stoppedPids: number[] = [];
for (const pid of uniquePids) {
await terminatePidWithEscalation(pid);
if (!isProcessAlive(pid)) {
stoppedPids.push(pid);
}
}
const processMeta = readManagedWebRuntimeProcess(params.stateDir);
if (processMeta?.port === params.port && stoppedPids.includes(processMeta.pid)) {
clearManagedWebRuntimeProcess(params.stateDir);
}
const remainingManaged = inspectWebPortListeners(params.port, params.stateDir).filter(
(listener) => listener.ownership === "managed",
);
if (remainingManaged.length === 0) {
clearManagedWebRuntimeProcess(params.stateDir);
}
return {
port: params.port,
stoppedPids,
skippedForeignPids: [...new Set(skippedForeign.map((listener) => listener.pid))],
};
}
export function startManagedWebRuntime(params: {
stateDir: string;
port: number;
gatewayPort: number;
env?: NodeJS.ProcessEnv;
}): StartManagedWebRuntimeResult {
const runtimeServerPath = resolveManagedWebRuntimeServerPath(params.stateDir);
if (!existsSync(runtimeServerPath)) {
return { started: false, runtimeServerPath, reason: "runtime-missing" };
}
const logsDir = path.join(params.stateDir, "logs");
mkdirSync(logsDir, { recursive: true });
const outFd = openSync(path.join(logsDir, "web-app.log"), "a");
const errFd = openSync(path.join(logsDir, "web-app.err.log"), "a");
const child = spawn(process.execPath, [runtimeServerPath], {
cwd: path.dirname(runtimeServerPath),
detached: true,
stdio: ["ignore", outFd, errFd],
env: {
...process.env,
...params.env,
PORT: String(params.port),
HOSTNAME: "127.0.0.1",
OPENCLAW_GATEWAY_PORT: String(params.gatewayPort),
},
});
child.unref();
writeManagedWebRuntimeProcess(params.stateDir, {
pid: child.pid ?? -1,
port: params.port,
gatewayPort: params.gatewayPort,
startedAt: new Date().toISOString(),
runtimeAppDir: path.dirname(runtimeServerPath),
});
updateManifestLastPort(params.stateDir, params.port, params.gatewayPort);
return {
started: true,
pid: child.pid ?? -1,
runtimeServerPath,
};
}
export async function ensureManagedWebRuntime(params: {
stateDir: string;
packageRoot: string;
denchVersion: string;
port: number;
gatewayPort: number;
}): Promise<{ ready: boolean; reason: string }> {
const install = installManagedWebRuntime({
stateDir: params.stateDir,
packageRoot: params.packageRoot,
denchVersion: params.denchVersion,
webPort: params.port,
gatewayPort: params.gatewayPort,
});
if (!install.installed) {
return { ready: false, reason: "standalone web build is missing from package" };
}
await stopManagedWebRuntime({
stateDir: params.stateDir,
port: params.port,
includeLegacyStandalone: true,
});
const listenersAfterStop = inspectWebPortListeners(params.port, params.stateDir);
const foreign = listenersAfterStop.filter((listener) => listener.ownership === "foreign");
if (foreign.length > 0) {
const detail = foreign
.map((listener) => `${listener.pid}${listener.command ? `:${listener.command}` : ""}`)
.join(", ");
return {
ready: false,
reason: `port ${params.port} is owned by non-Dench process(es): ${detail}`,
};
}
const start = startManagedWebRuntime({
stateDir: params.stateDir,
port: params.port,
gatewayPort: params.gatewayPort,
});
if (!start.started) {
return {
ready: false,
reason: "managed web runtime is missing after install",
};
}
const probe = await waitForWebRuntime(params.port);
return {
ready: probe.ok,
reason: probe.reason,
};
}
export function resolveOpenClawCommandOrThrow(): string {
try {
const locator = process.platform === "win32" ? "where" : "which";
const output = execFileSync(locator, ["openclaw"], { encoding: "utf-8" }).trim();
const first = output
.split(/\r?\n/)
.map((line) => line.trim())
.find(Boolean);
if (first) {
return first;
}
throw new Error("openclaw command not found");
} catch {
throw new Error(
"Global `openclaw` CLI was not found on PATH. Install it with: npm install -g openclaw",
);
}
}
export async function runOpenClawCommand(params: {
openclawCommand: string;
args: string[];
timeoutMs: number;
env?: NodeJS.ProcessEnv;
}): Promise<{ code: number; stdout: string; stderr: string }> {
return await new Promise((resolve, reject) => {
const child = spawn(resolveCommandForPlatform(params.openclawCommand), params.args, {
stdio: ["ignore", "pipe", "pipe"],
env: params.env ? { ...process.env, ...params.env } : process.env,
});
let stdout = "";
let stderr = "";
let settled = false;
const timer = setTimeout(() => {
if (settled) {
return;
}
child.kill("SIGKILL");
}, params.timeoutMs);
child.stdout?.on("data", (chunk: Buffer | string) => {
stdout += String(chunk);
});
child.stderr?.on("data", (chunk: Buffer | string) => {
stderr += String(chunk);
});
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,
});
});
});
}