diff --git a/package.json b/package.json index d8241fe9811..4f916e6cfad 100644 --- a/package.json +++ b/package.json @@ -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 } } diff --git a/scripts/deploy.sh b/scripts/deploy.sh index bf807b87ce1..7162ae9a4d2 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -1,18 +1,16 @@ #!/usr/bin/env bash # deploy.sh — build and publish denchclaw to npm # -# Versioning convention (mirrors upstream openclaw tags): -# --upstream 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 Increment current package version. +# kind: major | minor | patch +# 2.0.0 --bump patch => 2.0.1 +# --version 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 or --bump " + 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 or --bump ." ;; --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 ." - fi VERSION="$CURRENT" echo "publishing current version: $VERSION" ;; esac +if npm_version_exists "$VERSION"; then + die "version $VERSION already exists on npm. Use --bump or --version ." +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}" diff --git a/src/cli/bootstrap-external.bootstrap-command.test.ts b/src/cli/bootstrap-external.bootstrap-command.test.ts index 371c43074b1..2188bb53ab3 100644 --- a/src/cli/bootstrap-external.bootstrap-command.test.ts +++ b/src/cli/bootstrap-external.bootstrap-command.test.ts @@ -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; + unref: ReturnType; } { const child = new EventEmitter() as EventEmitter & { stdout: EventEmitter; stderr: EventEmitter; kill: ReturnType; + unref: ReturnType; }; 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 }); diff --git a/src/cli/bootstrap-external.ts b/src/cli/bootstrap-external.ts index 2266b9a956b..edb3082f3ec 100644 --- a/src/cli/bootstrap-external.ts +++ b/src/cli/bootstrap-external.ts @@ -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 { 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 | 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 { - 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 { - 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 { - 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 | 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; + 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, +): 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 { +async function resolveNpmGlobalBinDir( + onOutputLine?: OutputLineHandler, +): Promise { 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 { +async function resolveShellOpenClawPath( + onOutputLine?: OutputLineHandler, +): Promise { 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 { - const globalBefore = await detectGlobalOpenClawInstall(); +async function ensureOpenClawCliAvailable(params: { + stateDir: string; + showProgress: boolean; +}): Promise { + 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 { }; } 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 \` 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 { 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.")); } diff --git a/src/cli/program/command-registry.ts b/src/cli/program/command-registry.ts index 283eb5fc8d0..8c8cb6c999f 100644 --- a/src/cli/program/command-registry.ts +++ b/src/cli/program/command-registry.ts @@ -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; }; -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 { - 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( diff --git a/src/cli/program/help.ts b/src/cli/program/help.ts index d6c02b50666..da3f36dd015 100644 --- a/src/cli/program/help.ts +++ b/src/cli/program/help.ts @@ -15,6 +15,9 @@ const ROOT_COMMANDS_HINT = "Hint: commands suffixed with * have subcommands. Run --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", diff --git a/src/cli/program/register.start.ts b/src/cli/program/register.start.ts new file mode 100644 index 00000000000..04eda99b8cc --- /dev/null +++ b/src/cli/program/register.start.ts @@ -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 ", "Compatibility flag; non-dench values are ignored with a warning") + .option("--web-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), + }); + }); + }); +} diff --git a/src/cli/program/register.stop.ts b/src/cli/program/register.stop.ts new file mode 100644 index 00000000000..c2474879558 --- /dev/null +++ b/src/cli/program/register.stop.ts @@ -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 ", "Compatibility flag; non-dench values are ignored with a warning") + .option("--web-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), + }); + }); + }); +} diff --git a/src/cli/program/register.update.ts b/src/cli/program/register.update.ts new file mode 100644 index 00000000000..1391e5ed7d6 --- /dev/null +++ b/src/cli/program/register.update.ts @@ -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 ", "Compatibility flag; non-dench values are ignored with a warning") + .option("--web-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), + }); + }); + }); +} diff --git a/src/cli/run-main.test.ts b/src/cli/run-main.test.ts index c8924e10360..9e965b19cf8 100644 --- a/src/cli/run-main.test.ts +++ b/src/cli/run-main.test.ts @@ -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); + }); +}); diff --git a/src/cli/run-main.ts b/src/cli/run-main.ts index 3943ed25dba..d9f9c7f6e59 100644 --- a/src/cli/run-main.ts +++ b/src/cli/run-main.ts @@ -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 { @@ -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 }); } diff --git a/src/cli/web-runtime-command.test.ts b/src/cli/web-runtime-command.test.ts new file mode 100644 index 00000000000..d0a58f1506d --- /dev/null +++ b/src/cli/web-runtime-command.test.ts @@ -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; +} { + const child = new EventEmitter() as EventEmitter & { + stdout: EventEmitter; + stderr: EventEmitter; + kill: ReturnType; + }; + 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); + }); +}); diff --git a/src/cli/web-runtime-command.ts b/src/cli/web-runtime-command.ts new file mode 100644 index 00000000000..1844f737626 --- /dev/null +++ b/src/cli/web-runtime-command.ts @@ -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 { + 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 { + const s = spinner(); + s.start("Updating OpenClaw (required for this major Dench upgrade)..."); + const result = await new Promise((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 { + 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 { + 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 { + 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 { + 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 { + 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.", + ); +} diff --git a/src/cli/web-runtime.test.ts b/src/cli/web-runtime.test.ts new file mode 100644 index 00000000000..a1e8b8131ce --- /dev/null +++ b/src/cli/web-runtime.test.ts @@ -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); + }); +}); diff --git a/src/cli/web-runtime.ts b/src/cli/web-runtime.ts new file mode 100644 index 00000000000..798ee2177fe --- /dev/null +++ b/src/cli/web-runtime.ts @@ -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(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 { + 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(resolveManagedWebRuntimeManifestPath(stateDir)); +} + +export function readManagedWebRuntimeProcess(stateDir: string): ManagedWebRuntimeProcess | null { + return readJsonFile(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; + 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 { + 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 { + 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 { + 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, + }); + }); + }); +}