CLI: add managed web runtime with start/stop/update commands and major-version gating
Refactor bootstrap to use a managed web runtime lifecycle instead of ad-hoc standalone server spawning. The managed runtime copies packaged Next.js assets into ~/.openclaw-dench/web-runtime/, tracks deployment state via manifest/process metadata, and cleanly separates Dench-owned processes from foreign listeners on the target port. - Fix false-negative web readiness when /api/profiles returns null activeProfile (first-run regression). - Add `dench start` (start without updating assets), `dench stop` (terminate only Dench-managed web server), and `dench update` (refresh web runtime with major-version OpenClaw update gate). - Major-version transitions (e.g. v2->v3) require mandatory OpenClaw update; non-interactive mode fails closed without --yes. - All lifecycle commands show the ASCII banner/logo animation. - Deploy smoke checks now verify update/stop/start --help paths.
This commit is contained in:
parent
4d6eec741d
commit
af45d4d17b
220
package.json
220
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
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,18 +1,16 @@
|
||||
#!/usr/bin/env bash
|
||||
# deploy.sh — build and publish denchclaw to npm
|
||||
#
|
||||
# Versioning convention (mirrors upstream openclaw tags):
|
||||
# --upstream <ver> Sync to an upstream release version.
|
||||
# If that version is already published, appends .1, .2, …
|
||||
# (or -1, -2, … when the base has no prerelease).
|
||||
# --bump Increment the local fork suffix on the current version.
|
||||
# 2026.2.6-3 → 2026.2.6-3.1
|
||||
# 2026.2.6-3.1 → 2026.2.6-3.2
|
||||
# 2026.2.7 → 2026.2.7-1
|
||||
# Versioning convention (standard semver):
|
||||
# --bump <kind> Increment current package version.
|
||||
# kind: major | minor | patch
|
||||
# 2.0.0 --bump patch => 2.0.1
|
||||
# --version <ver> Publish an explicit semver version (x.y.z).
|
||||
# (no flag) Publish whatever version is already in package.json.
|
||||
#
|
||||
# Flags:
|
||||
# --skip-tests Skip running tests before build/publish.
|
||||
# --skip-npx-smoke Skip post-publish npx binary verification.
|
||||
#
|
||||
# Environment:
|
||||
# NPM_TOKEN Required. npm auth token for publishing.
|
||||
@ -39,78 +37,117 @@ npm_version_exists() {
|
||||
npm view "${PACKAGE_NAME}@${v}" version 2>/dev/null | grep -q "${v}" 2>/dev/null
|
||||
}
|
||||
|
||||
# Given a base version, return it if available on npm, otherwise find the
|
||||
# next free slot by appending a dot-suffix (.1, .2, …) for versions that
|
||||
# already contain a prerelease, or a hyphen-suffix (-1, -2, …) otherwise.
|
||||
find_available_version() {
|
||||
local base="$1"
|
||||
if ! npm_version_exists "$base"; then
|
||||
echo "$base"
|
||||
return
|
||||
fi
|
||||
|
||||
local n=1
|
||||
if [[ "$base" == *-* ]]; then
|
||||
# Has prerelease already → append .N
|
||||
while npm_version_exists "${base}.${n}"; do
|
||||
n=$((n + 1))
|
||||
done
|
||||
echo "${base}.${n}"
|
||||
else
|
||||
# No prerelease → append -N
|
||||
while npm_version_exists "${base}-${n}"; do
|
||||
n=$((n + 1))
|
||||
done
|
||||
echo "${base}-${n}"
|
||||
fi
|
||||
is_plain_semver() {
|
||||
local v="$1"
|
||||
[[ "$v" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]
|
||||
}
|
||||
|
||||
# Increment the local fork suffix on a version string.
|
||||
# 2026.2.6-3 → 2026.2.6-3.1 (upstream prerelease, add .1)
|
||||
# 2026.2.6-3.1 → 2026.2.6-3.2 (increment last dot segment)
|
||||
# 2026.2.7 → 2026.2.7-1 (no prerelease, add -1)
|
||||
# 2026.2.7-1 → 2026.2.7-1.1 (treat -1 as upstream-like, add .1)
|
||||
bump_version() {
|
||||
bump_semver() {
|
||||
local current="$1"
|
||||
local kind="$2"
|
||||
|
||||
# If the prerelease already has a dot (e.g. 3.1 in 2026.2.6-3.1),
|
||||
# increment the last numeric segment after the final dot.
|
||||
local prerelease="${current#*-}"
|
||||
if [[ "$current" == *-* ]] && [[ "$prerelease" == *.* ]]; then
|
||||
if [[ "$current" =~ ^(.*\.)([0-9]+)$ ]]; then
|
||||
echo "${BASH_REMATCH[1]}$((BASH_REMATCH[2] + 1))"
|
||||
return
|
||||
if ! is_plain_semver "$current"; then
|
||||
die "current version must be plain semver (x.y.z) for --bump, got: $current"
|
||||
fi
|
||||
|
||||
local major minor patch
|
||||
IFS='.' read -r major minor patch <<<"$current"
|
||||
case "$kind" in
|
||||
major)
|
||||
echo "$((major + 1)).0.0"
|
||||
;;
|
||||
minor)
|
||||
echo "${major}.$((minor + 1)).0"
|
||||
;;
|
||||
patch)
|
||||
echo "${major}.${minor}.$((patch + 1))"
|
||||
;;
|
||||
*)
|
||||
die "--bump requires one of: major, minor, patch"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
verify_npx_command() {
|
||||
local version="$1"
|
||||
local label="$2"
|
||||
shift 2
|
||||
local attempts=15
|
||||
local delay_seconds=2
|
||||
local output=""
|
||||
local temp_dir
|
||||
temp_dir="$(mktemp -d)"
|
||||
|
||||
for ((i = 1; i <= attempts; i++)); do
|
||||
if output="$(cd "$temp_dir" && "$@" 2>/dev/null)"; then
|
||||
if [[ "$output" == *"$version"* ]]; then
|
||||
echo "verified ${label}: ${output}"
|
||||
rm -rf "$temp_dir"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
sleep "$delay_seconds"
|
||||
done
|
||||
|
||||
# Has a prerelease but no dot-suffix yet → append .1
|
||||
if [[ "$current" == *-* ]]; then
|
||||
echo "${current}.1"
|
||||
return
|
||||
fi
|
||||
rm -rf "$temp_dir"
|
||||
echo "error: failed to verify ${label} for ${PACKAGE_NAME}@${version}" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
# Plain semver with no prerelease → append -1
|
||||
echo "${current}-1"
|
||||
verify_npx_invocation() {
|
||||
local label="$1"
|
||||
shift
|
||||
local attempts=15
|
||||
local delay_seconds=2
|
||||
local temp_dir
|
||||
temp_dir="$(mktemp -d)"
|
||||
|
||||
for ((i = 1; i <= attempts; i++)); do
|
||||
if (cd "$temp_dir" && "$@" >/dev/null 2>&1); then
|
||||
echo "verified ${label}"
|
||||
rm -rf "$temp_dir"
|
||||
return 0
|
||||
fi
|
||||
sleep "$delay_seconds"
|
||||
done
|
||||
|
||||
rm -rf "$temp_dir"
|
||||
echo "error: failed to verify ${label}" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
# ── parse args ───────────────────────────────────────────────────────────────
|
||||
|
||||
MODE=""
|
||||
UPSTREAM_VERSION=""
|
||||
BUMP_KIND=""
|
||||
EXPLICIT_VERSION=""
|
||||
DRY_RUN=false
|
||||
SKIP_BUILD=false
|
||||
SKIP_TESTS=false
|
||||
SKIP_NPX_SMOKE=false
|
||||
|
||||
set_mode() {
|
||||
local next="$1"
|
||||
if [[ -n "$MODE" && "$MODE" != "$next" ]]; then
|
||||
die "choose only one version mode: --version <x.y.z> or --bump <major|minor|patch>"
|
||||
fi
|
||||
MODE="$next"
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--upstream)
|
||||
MODE="upstream"
|
||||
UPSTREAM_VERSION="${2:?--upstream requires a version argument}"
|
||||
--version)
|
||||
set_mode "version"
|
||||
EXPLICIT_VERSION="${2:?--version requires a semver argument (x.y.z)}"
|
||||
shift 2
|
||||
;;
|
||||
--bump)
|
||||
MODE="bump"
|
||||
shift
|
||||
set_mode "bump"
|
||||
BUMP_KIND="${2:?--bump requires one of: major, minor, patch}"
|
||||
shift 2
|
||||
;;
|
||||
--upstream)
|
||||
die "--upstream has been removed. Use --version <x.y.z> or --bump <major|minor|patch>."
|
||||
;;
|
||||
--dry-run)
|
||||
DRY_RUN=true
|
||||
@ -124,6 +161,10 @@ while [[ $# -gt 0 ]]; do
|
||||
SKIP_TESTS=true
|
||||
shift
|
||||
;;
|
||||
--skip-npx-smoke)
|
||||
SKIP_NPX_SMOKE=true
|
||||
shift
|
||||
;;
|
||||
--help|-h)
|
||||
sed -n '2,/^[^#]/{ /^#/s/^# \{0,1\}//p; }' "$0"
|
||||
exit 0
|
||||
@ -152,24 +193,27 @@ NPM_FLAGS=(--userconfig "$NPMRC_TEMP")
|
||||
CURRENT="$(current_version)"
|
||||
|
||||
case "$MODE" in
|
||||
upstream)
|
||||
VERSION="$(find_available_version "$UPSTREAM_VERSION")"
|
||||
echo "upstream sync: $UPSTREAM_VERSION → publishing as $VERSION"
|
||||
version)
|
||||
if ! is_plain_semver "$EXPLICIT_VERSION"; then
|
||||
die "--version must be plain semver (x.y.z), got: $EXPLICIT_VERSION"
|
||||
fi
|
||||
VERSION="$EXPLICIT_VERSION"
|
||||
echo "explicit version: $CURRENT → $VERSION"
|
||||
;;
|
||||
bump)
|
||||
NEXT="$(bump_version "$CURRENT")"
|
||||
VERSION="$(find_available_version "$NEXT")"
|
||||
echo "local bump: $CURRENT → $VERSION"
|
||||
VERSION="$(bump_semver "$CURRENT" "$BUMP_KIND")"
|
||||
echo "semver bump (${BUMP_KIND}): $CURRENT → $VERSION"
|
||||
;;
|
||||
*)
|
||||
if npm_version_exists "$CURRENT"; then
|
||||
die "version $CURRENT already exists on npm. Use --bump or --upstream <ver>."
|
||||
fi
|
||||
VERSION="$CURRENT"
|
||||
echo "publishing current version: $VERSION"
|
||||
;;
|
||||
esac
|
||||
|
||||
if npm_version_exists "$VERSION"; then
|
||||
die "version $VERSION already exists on npm. Use --bump <major|minor|patch> or --version <x.y.z>."
|
||||
fi
|
||||
|
||||
if [[ "$DRY_RUN" == true ]]; then
|
||||
echo "[dry-run] would publish ${PACKAGE_NAME}@${VERSION}"
|
||||
exit 0
|
||||
@ -208,6 +252,21 @@ fi
|
||||
echo "publishing ${PACKAGE_NAME}@${VERSION}..."
|
||||
npm publish --access public --tag latest "${NPM_FLAGS[@]}"
|
||||
|
||||
# Verify published npx flows for both CLI aliases.
|
||||
if [[ "$SKIP_NPX_SMOKE" != true ]]; then
|
||||
echo "verifying npx binaries..."
|
||||
verify_npx_command "$VERSION" "npx denchclaw" \
|
||||
npx --yes "${PACKAGE_NAME}@${VERSION}" --version
|
||||
verify_npx_command "$VERSION" "npx dench" \
|
||||
npx --yes --package "${PACKAGE_NAME}@${VERSION}" dench --version
|
||||
verify_npx_invocation "npx dench update --help" \
|
||||
npx --yes --package "${PACKAGE_NAME}@${VERSION}" dench update --help
|
||||
verify_npx_invocation "npx dench start --help" \
|
||||
npx --yes --package "${PACKAGE_NAME}@${VERSION}" dench start --help
|
||||
verify_npx_invocation "npx dench stop --help" \
|
||||
npx --yes --package "${PACKAGE_NAME}@${VERSION}" dench stop --help
|
||||
fi
|
||||
|
||||
# Verify the standalone web app was included in the published package.
|
||||
# `prepack` should have built it; if this file is missing, the web UI
|
||||
# won't work for users who install globally.
|
||||
@ -217,17 +276,6 @@ if [[ ! -f "$STANDALONE_SERVER" ]]; then
|
||||
echo " users may not get a working Web UI — check the prepack step"
|
||||
fi
|
||||
|
||||
# ── post-publish: commit + push version bump ─────────────────────────────────
|
||||
|
||||
if git diff --quiet package.json 2>/dev/null; then
|
||||
echo "package.json unchanged — skipping git commit"
|
||||
else
|
||||
echo "committing version bump..."
|
||||
git add package.json
|
||||
git commit -m "release: v${VERSION}"
|
||||
git push
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "published ${PACKAGE_NAME}@${VERSION}"
|
||||
echo "install: npm i -g ${PACKAGE_NAME}"
|
||||
|
||||
@ -44,7 +44,7 @@ type SpawnCall = {
|
||||
|
||||
function createWebProfilesResponse(params?: {
|
||||
status?: number;
|
||||
payload?: { profiles?: unknown[]; activeProfile?: string };
|
||||
payload?: { profiles?: unknown[]; activeProfile?: string | null };
|
||||
}): Response {
|
||||
const status = params?.status ?? 200;
|
||||
const payload = params?.payload ?? { profiles: [], activeProfile: "dench" };
|
||||
@ -98,15 +98,18 @@ function createMockChild(params: {
|
||||
stdout: EventEmitter;
|
||||
stderr: EventEmitter;
|
||||
kill: ReturnType<typeof vi.fn>;
|
||||
unref: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
const child = new EventEmitter() as EventEmitter & {
|
||||
stdout: EventEmitter;
|
||||
stderr: EventEmitter;
|
||||
kill: ReturnType<typeof vi.fn>;
|
||||
unref: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
child.stdout = new EventEmitter();
|
||||
child.stderr = new EventEmitter();
|
||||
child.kill = vi.fn();
|
||||
child.unref = vi.fn();
|
||||
|
||||
queueMicrotask(() => {
|
||||
if (params.stdout) {
|
||||
@ -411,6 +414,41 @@ describe("bootstrapCommand always-onboard behavior", () => {
|
||||
expect(updateIndex).toBeLessThan(onboardIndex);
|
||||
});
|
||||
|
||||
it("skips update prompt right after installing openclaw@latest (avoids redundant update checks)", async () => {
|
||||
forceGlobalMissing = true;
|
||||
promptMocks.confirmDecision = true;
|
||||
const runtime: RuntimeEnv = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
};
|
||||
|
||||
await withForcedStdinTty(true, async () => {
|
||||
await bootstrapCommand(
|
||||
{
|
||||
noOpen: true,
|
||||
},
|
||||
runtime,
|
||||
);
|
||||
});
|
||||
|
||||
const installedGlobalOpenClaw = spawnCalls.some(
|
||||
(call) =>
|
||||
call.command === "npm" &&
|
||||
call.args.includes("install") &&
|
||||
call.args.includes("-g") &&
|
||||
call.args.includes("openclaw@latest"),
|
||||
);
|
||||
const updateCalled = spawnCalls.some(
|
||||
(call) =>
|
||||
call.command === "openclaw" && call.args.includes("update") && call.args.includes("--yes"),
|
||||
);
|
||||
|
||||
expect(installedGlobalOpenClaw).toBe(true);
|
||||
expect(promptMocks.confirm).toHaveBeenCalledTimes(0);
|
||||
expect(updateCalled).toBe(false);
|
||||
});
|
||||
|
||||
it("skips update when interactive prompt is declined", async () => {
|
||||
promptMocks.confirmDecision = false;
|
||||
const runtime: RuntimeEnv = {
|
||||
@ -441,6 +479,87 @@ describe("bootstrapCommand always-onboard behavior", () => {
|
||||
expect(onboardCalls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("reuses recent OpenClaw CLI availability checks to avoid repeated npm/which probes", async () => {
|
||||
const runtime: RuntimeEnv = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
};
|
||||
|
||||
await bootstrapCommand(
|
||||
{
|
||||
nonInteractive: true,
|
||||
noOpen: true,
|
||||
skipUpdate: true,
|
||||
},
|
||||
runtime,
|
||||
);
|
||||
|
||||
const firstProbeCounts = {
|
||||
npmLs: spawnCalls.filter(
|
||||
(call) =>
|
||||
call.command === "npm" &&
|
||||
call.args.includes("ls") &&
|
||||
call.args.includes("-g") &&
|
||||
call.args.includes("openclaw"),
|
||||
).length,
|
||||
npmPrefix: spawnCalls.filter(
|
||||
(call) =>
|
||||
call.command === "npm" && call.args.includes("prefix") && call.args.includes("-g"),
|
||||
).length,
|
||||
shellWhich: spawnCalls.filter(
|
||||
(call) =>
|
||||
(call.command === "which" || call.command === "where") && call.args[0] === "openclaw",
|
||||
).length,
|
||||
versionCheck: spawnCalls.filter(
|
||||
(call) => call.command === "openclaw" && call.args[0] === "--version",
|
||||
).length,
|
||||
};
|
||||
|
||||
spawnCalls = [];
|
||||
|
||||
await bootstrapCommand(
|
||||
{
|
||||
nonInteractive: true,
|
||||
noOpen: true,
|
||||
skipUpdate: true,
|
||||
},
|
||||
runtime,
|
||||
);
|
||||
|
||||
const secondProbeCounts = {
|
||||
npmLs: spawnCalls.filter(
|
||||
(call) =>
|
||||
call.command === "npm" &&
|
||||
call.args.includes("ls") &&
|
||||
call.args.includes("-g") &&
|
||||
call.args.includes("openclaw"),
|
||||
).length,
|
||||
npmPrefix: spawnCalls.filter(
|
||||
(call) =>
|
||||
call.command === "npm" && call.args.includes("prefix") && call.args.includes("-g"),
|
||||
).length,
|
||||
shellWhich: spawnCalls.filter(
|
||||
(call) =>
|
||||
(call.command === "which" || call.command === "where") && call.args[0] === "openclaw",
|
||||
).length,
|
||||
versionCheck: spawnCalls.filter(
|
||||
(call) => call.command === "openclaw" && call.args[0] === "--version",
|
||||
).length,
|
||||
};
|
||||
|
||||
expect(firstProbeCounts.npmLs).toBeGreaterThan(0);
|
||||
expect(firstProbeCounts.npmPrefix).toBeGreaterThan(0);
|
||||
expect(firstProbeCounts.shellWhich).toBeGreaterThan(0);
|
||||
expect(firstProbeCounts.versionCheck).toBeGreaterThan(0);
|
||||
expect(secondProbeCounts).toEqual({
|
||||
npmLs: 0,
|
||||
npmPrefix: 0,
|
||||
shellWhich: 0,
|
||||
versionCheck: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("seeds workspace.duckdb on bootstrap when missing", async () => {
|
||||
const runtime: RuntimeEnv = {
|
||||
log: vi.fn(),
|
||||
@ -911,6 +1030,35 @@ describe("bootstrapCommand always-onboard behavior", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts nullable activeProfile in /api/profiles payload (prevents first-run false-negative readiness)", async () => {
|
||||
fetchBehavior = async (url: string) => {
|
||||
if (url.includes("127.0.0.1:3100/api/profiles")) {
|
||||
return createWebProfilesResponse({
|
||||
status: 200,
|
||||
payload: { profiles: [], activeProfile: null },
|
||||
});
|
||||
}
|
||||
return createWebProfilesResponse({ status: 404, payload: {} });
|
||||
};
|
||||
const runtime: RuntimeEnv = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
};
|
||||
|
||||
const summary = await bootstrapCommand(
|
||||
{
|
||||
nonInteractive: true,
|
||||
noOpen: true,
|
||||
skipUpdate: true,
|
||||
},
|
||||
runtime,
|
||||
);
|
||||
|
||||
expect(summary.webReachable).toBe(true);
|
||||
expect(summary.diagnostics.checks.find((check) => check.id === "web-ui")?.status).toBe("pass");
|
||||
});
|
||||
|
||||
it("prints likely gateway cause with log excerpt when autofix cannot recover", async () => {
|
||||
alwaysHealthFail = true;
|
||||
mkdirSync(path.join(stateDir, "logs"), { recursive: true });
|
||||
|
||||
@ -1,29 +1,33 @@
|
||||
import { spawn, type StdioOptions } from "node:child_process";
|
||||
import { existsSync, mkdirSync, openSync, readFileSync, readdirSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { confirm, isCancel, spinner } from "@clack/prompts";
|
||||
import { isTruthyEnvValue } from "../infra/env.js";
|
||||
import { resolveRequiredHomeDir } from "../infra/home-dir.js";
|
||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||
import { stylePromptMessage } from "../terminal/prompt-style.js";
|
||||
import { theme } from "../terminal/theme.js";
|
||||
import { VERSION } from "../version.js";
|
||||
import { applyCliProfileEnv } from "./profile.js";
|
||||
import {
|
||||
DEFAULT_WEB_APP_PORT,
|
||||
ensureManagedWebRuntime,
|
||||
resolveCliPackageRoot,
|
||||
resolveProfileStateDir,
|
||||
} from "./web-runtime.js";
|
||||
import { seedWorkspaceFromAssets, type WorkspaceSeedResult } from "./workspace-seed.js";
|
||||
|
||||
const DEFAULT_DENCHCLAW_PROFILE = "dench";
|
||||
const DENCHCLAW_STATE_DIRNAME = ".openclaw-dench";
|
||||
const DEFAULT_GATEWAY_PORT = 18789;
|
||||
const DENCHCLAW_GATEWAY_PORT_START = 19001;
|
||||
const MAX_PORT_SCAN_ATTEMPTS = 100;
|
||||
const DEFAULT_WEB_APP_PORT = 3100;
|
||||
const WEB_APP_PROBE_ATTEMPTS = 20;
|
||||
const WEB_APP_PROBE_DELAY_MS = 750;
|
||||
const DEFAULT_BOOTSTRAP_ROLLOUT_STAGE = "default";
|
||||
const DEFAULT_GATEWAY_LAUNCH_AGENT_LABEL = "ai.openclaw.gateway";
|
||||
const REQUIRED_TOOLS_PROFILE = "full";
|
||||
const OPENCLAW_CLI_CHECK_CACHE_TTL_MS = 5 * 60_000;
|
||||
const OPENCLAW_UPDATE_PROMPT_SUPPRESS_AFTER_INSTALL_MS = 5 * 60_000;
|
||||
const OPENCLAW_CLI_CHECK_CACHE_FILE = "openclaw-cli-check.json";
|
||||
const OPENCLAW_SETUP_PROGRESS_BAR_WIDTH = 16;
|
||||
|
||||
type BootstrapRolloutStage = "internal" | "beta" | "default";
|
||||
type BootstrapCheckStatus = "pass" | "warn" | "fail";
|
||||
@ -95,12 +99,34 @@ type SpawnResult = {
|
||||
type OpenClawCliAvailability = {
|
||||
available: boolean;
|
||||
installed: boolean;
|
||||
installedAt?: number;
|
||||
version?: string;
|
||||
command: string;
|
||||
globalBinDir?: string;
|
||||
shellCommandPath?: string;
|
||||
};
|
||||
|
||||
type OutputLineHandler = (line: string, stream: "stdout" | "stderr") => void;
|
||||
|
||||
type OpenClawCliCheckCache = {
|
||||
checkedAt: number;
|
||||
pathEnv: string;
|
||||
available: boolean;
|
||||
command: string;
|
||||
version?: string;
|
||||
globalBinDir?: string;
|
||||
shellCommandPath?: string;
|
||||
installedAt?: number;
|
||||
};
|
||||
|
||||
type OpenClawSetupProgress = {
|
||||
startStage: (label: string) => void;
|
||||
output: (line: string) => void;
|
||||
completeStage: (suffix?: string) => void;
|
||||
finish: (message: string) => void;
|
||||
fail: (message: string) => void;
|
||||
};
|
||||
|
||||
type GatewayAutoFixStep = {
|
||||
name: string;
|
||||
ok: boolean;
|
||||
@ -147,6 +173,7 @@ async function runCommandWithTimeout(
|
||||
cwd?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
ioMode?: "capture" | "inherit";
|
||||
onOutputLine?: OutputLineHandler;
|
||||
},
|
||||
): Promise<SpawnResult> {
|
||||
const [command, ...args] = argv;
|
||||
@ -172,10 +199,28 @@ async function runCommandWithTimeout(
|
||||
}, options.timeoutMs);
|
||||
|
||||
child.stdout?.on("data", (chunk: Buffer | string) => {
|
||||
stdout += String(chunk);
|
||||
const text = String(chunk);
|
||||
stdout += text;
|
||||
if (options.onOutputLine) {
|
||||
for (const segment of text.split(/\r?\n/)) {
|
||||
const line = segment.trim();
|
||||
if (line.length > 0) {
|
||||
options.onOutputLine(line, "stdout");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
child.stderr?.on("data", (chunk: Buffer | string) => {
|
||||
stderr += String(chunk);
|
||||
const text = String(chunk);
|
||||
stderr += text;
|
||||
if (options.onOutputLine) {
|
||||
for (const segment of text.split(/\r?\n/)) {
|
||||
const line = segment.trim();
|
||||
if (line.length > 0) {
|
||||
options.onOutputLine(line, "stderr");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
child.once("error", (error: Error) => {
|
||||
if (settled) {
|
||||
@ -300,12 +345,6 @@ function firstNonEmptyLine(...values: Array<string | undefined>): string | undef
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveProfileStateDir(profile: string, env: NodeJS.ProcessEnv = process.env): string {
|
||||
void profile;
|
||||
const home = resolveRequiredHomeDir(env, os.homedir);
|
||||
return path.join(home, DENCHCLAW_STATE_DIRNAME);
|
||||
}
|
||||
|
||||
function resolveGatewayLaunchAgentLabel(profile: string): string {
|
||||
const normalized = profile.trim().toLowerCase();
|
||||
if (!normalized || normalized === "default") {
|
||||
@ -386,94 +425,20 @@ async function ensureToolsProfile(openclawCommand: string, profile: string): Pro
|
||||
});
|
||||
}
|
||||
|
||||
async function probeForWebApp(port: number): Promise<boolean> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 1_500);
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/api/profiles`, {
|
||||
method: "GET",
|
||||
signal: controller.signal,
|
||||
redirect: "manual",
|
||||
});
|
||||
if (response.status < 200 || response.status >= 400) {
|
||||
return false;
|
||||
}
|
||||
const payload = (await response.json().catch(() => null)) as {
|
||||
profiles?: unknown;
|
||||
activeProfile?: unknown;
|
||||
} | null;
|
||||
return Boolean(
|
||||
payload &&
|
||||
typeof payload === "object" &&
|
||||
Array.isArray(payload.profiles) &&
|
||||
typeof payload.activeProfile === "string",
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForWebApp(preferredPort: number): Promise<boolean> {
|
||||
for (let attempt = 0; attempt < WEB_APP_PROBE_ATTEMPTS; attempt += 1) {
|
||||
if (await probeForWebApp(preferredPort)) {
|
||||
return true;
|
||||
}
|
||||
await sleep(WEB_APP_PROBE_DELAY_MS);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function resolveCliPackageRoot(): string {
|
||||
let dir = path.dirname(fileURLToPath(import.meta.url));
|
||||
for (let i = 0; i < 5; i++) {
|
||||
if (existsSync(path.join(dir, "package.json"))) {
|
||||
return dir;
|
||||
}
|
||||
dir = path.dirname(dir);
|
||||
}
|
||||
return process.cwd();
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn the pre-built standalone Next.js server as a detached background
|
||||
* process if it isn't already running on the target port.
|
||||
*/
|
||||
function startWebAppIfNeeded(port: number, stateDir: string, gatewayPort: number): void {
|
||||
const pkgRoot = resolveCliPackageRoot();
|
||||
const standaloneServer = path.join(pkgRoot, "apps/web/.next/standalone/apps/web/server.js");
|
||||
if (!existsSync(standaloneServer)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const logDir = path.join(stateDir, "logs");
|
||||
mkdirSync(logDir, { recursive: true });
|
||||
const outFd = openSync(path.join(logDir, "web-app.log"), "a");
|
||||
const errFd = openSync(path.join(logDir, "web-app.err.log"), "a");
|
||||
|
||||
const child = spawn(process.execPath, [standaloneServer], {
|
||||
cwd: path.dirname(standaloneServer),
|
||||
detached: true,
|
||||
stdio: ["ignore", outFd, errFd],
|
||||
env: {
|
||||
...process.env,
|
||||
PORT: String(port),
|
||||
HOSTNAME: "127.0.0.1",
|
||||
OPENCLAW_GATEWAY_PORT: String(gatewayPort),
|
||||
},
|
||||
});
|
||||
child.unref();
|
||||
}
|
||||
|
||||
async function runOpenClaw(
|
||||
openclawCommand: string,
|
||||
args: string[],
|
||||
timeoutMs: number,
|
||||
ioMode: "capture" | "inherit" = "capture",
|
||||
env?: NodeJS.ProcessEnv,
|
||||
onOutputLine?: OutputLineHandler,
|
||||
): Promise<SpawnResult> {
|
||||
return await runCommandWithTimeout([openclawCommand, ...args], { timeoutMs, ioMode, env });
|
||||
return await runCommandWithTimeout([openclawCommand, ...args], {
|
||||
timeoutMs,
|
||||
ioMode,
|
||||
env,
|
||||
onOutputLine,
|
||||
});
|
||||
}
|
||||
|
||||
async function runOpenClawOrThrow(params: {
|
||||
@ -618,11 +583,150 @@ function parseJsonPayload(raw: string | undefined): Record<string, unknown> | un
|
||||
}
|
||||
}
|
||||
|
||||
async function detectGlobalOpenClawInstall(): Promise<{ installed: boolean; version?: string }> {
|
||||
function resolveOpenClawCliCheckCachePath(stateDir: string): string {
|
||||
return path.join(stateDir, "cache", OPENCLAW_CLI_CHECK_CACHE_FILE);
|
||||
}
|
||||
|
||||
function readOpenClawCliCheckCache(stateDir: string): OpenClawCliCheckCache | undefined {
|
||||
const cachePath = resolveOpenClawCliCheckCachePath(stateDir);
|
||||
if (!existsSync(cachePath)) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(cachePath, "utf-8")) as Partial<OpenClawCliCheckCache>;
|
||||
if (
|
||||
typeof parsed.checkedAt !== "number" ||
|
||||
!Number.isFinite(parsed.checkedAt) ||
|
||||
typeof parsed.pathEnv !== "string" ||
|
||||
parsed.pathEnv !== (process.env.PATH ?? "") ||
|
||||
typeof parsed.available !== "boolean" ||
|
||||
!parsed.available ||
|
||||
typeof parsed.command !== "string" ||
|
||||
parsed.command.length === 0
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const ageMs = Date.now() - parsed.checkedAt;
|
||||
if (ageMs < 0 || ageMs > OPENCLAW_CLI_CHECK_CACHE_TTL_MS) {
|
||||
return undefined;
|
||||
}
|
||||
const looksLikePath =
|
||||
parsed.command.includes(path.sep) ||
|
||||
parsed.command.includes("/") ||
|
||||
parsed.command.includes("\\");
|
||||
if (looksLikePath && !existsSync(parsed.command)) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
checkedAt: parsed.checkedAt,
|
||||
pathEnv: parsed.pathEnv,
|
||||
available: parsed.available,
|
||||
command: parsed.command,
|
||||
version: typeof parsed.version === "string" ? parsed.version : undefined,
|
||||
globalBinDir: typeof parsed.globalBinDir === "string" ? parsed.globalBinDir : undefined,
|
||||
shellCommandPath:
|
||||
typeof parsed.shellCommandPath === "string" ? parsed.shellCommandPath : undefined,
|
||||
installedAt: typeof parsed.installedAt === "number" ? parsed.installedAt : undefined,
|
||||
};
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function writeOpenClawCliCheckCache(
|
||||
stateDir: string,
|
||||
cache: Omit<OpenClawCliCheckCache, "checkedAt" | "pathEnv">,
|
||||
): void {
|
||||
try {
|
||||
const cachePath = resolveOpenClawCliCheckCachePath(stateDir);
|
||||
mkdirSync(path.dirname(cachePath), { recursive: true });
|
||||
const payload: OpenClawCliCheckCache = {
|
||||
...cache,
|
||||
checkedAt: Date.now(),
|
||||
pathEnv: process.env.PATH ?? "",
|
||||
};
|
||||
writeFileSync(cachePath, JSON.stringify(payload, null, 2), "utf-8");
|
||||
} catch {
|
||||
// Cache write failures should never block bootstrap.
|
||||
}
|
||||
}
|
||||
|
||||
function createOpenClawSetupProgress(params: {
|
||||
enabled: boolean;
|
||||
totalStages: number;
|
||||
}): OpenClawSetupProgress {
|
||||
if (!params.enabled || params.totalStages <= 0 || !process.stdout.isTTY) {
|
||||
const noop = () => undefined;
|
||||
return {
|
||||
startStage: noop,
|
||||
output: noop,
|
||||
completeStage: noop,
|
||||
finish: noop,
|
||||
fail: noop,
|
||||
};
|
||||
}
|
||||
|
||||
const s = spinner();
|
||||
let completedStages = 0;
|
||||
let activeLabel = "";
|
||||
|
||||
const renderBar = () => {
|
||||
const ratio = completedStages / params.totalStages;
|
||||
const filled = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
OPENCLAW_SETUP_PROGRESS_BAR_WIDTH,
|
||||
Math.round(ratio * OPENCLAW_SETUP_PROGRESS_BAR_WIDTH),
|
||||
),
|
||||
);
|
||||
const bar = `${"#".repeat(filled)}${"-".repeat(OPENCLAW_SETUP_PROGRESS_BAR_WIDTH - filled)}`;
|
||||
return `[${bar}] ${completedStages}/${params.totalStages}`;
|
||||
};
|
||||
|
||||
const truncate = (value: string, max = 84) =>
|
||||
value.length > max ? `${value.slice(0, max - 3)}...` : value;
|
||||
|
||||
const renderStageLine = (detail?: string) => {
|
||||
const base = `${renderBar()} ${activeLabel}`.trim();
|
||||
if (!detail) {
|
||||
return base;
|
||||
}
|
||||
return truncate(`${base} -> ${detail}`);
|
||||
};
|
||||
|
||||
return {
|
||||
startStage: (label: string) => {
|
||||
activeLabel = label;
|
||||
s.start(renderStageLine());
|
||||
},
|
||||
output: (line: string) => {
|
||||
if (!line) {
|
||||
return;
|
||||
}
|
||||
s.message(renderStageLine(line));
|
||||
},
|
||||
completeStage: (suffix?: string) => {
|
||||
completedStages = Math.min(params.totalStages, completedStages + 1);
|
||||
s.stop(renderStageLine(suffix ?? "done"));
|
||||
},
|
||||
finish: (message: string) => {
|
||||
completedStages = params.totalStages;
|
||||
s.stop(`${renderBar()} ${truncate(message)}`.trim());
|
||||
},
|
||||
fail: (message: string) => {
|
||||
s.stop(`${renderBar()} ${truncate(message)}`.trim());
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function detectGlobalOpenClawInstall(
|
||||
onOutputLine?: OutputLineHandler,
|
||||
): Promise<{ installed: boolean; version?: string }> {
|
||||
const result = await runCommandWithTimeout(
|
||||
["npm", "ls", "-g", "openclaw", "--depth=0", "--json", "--silent"],
|
||||
{
|
||||
timeoutMs: 15_000,
|
||||
onOutputLine,
|
||||
},
|
||||
).catch(() => null);
|
||||
|
||||
@ -637,9 +741,12 @@ async function detectGlobalOpenClawInstall(): Promise<{ installed: boolean; vers
|
||||
return { installed: false };
|
||||
}
|
||||
|
||||
async function resolveNpmGlobalBinDir(): Promise<string | undefined> {
|
||||
async function resolveNpmGlobalBinDir(
|
||||
onOutputLine?: OutputLineHandler,
|
||||
): Promise<string | undefined> {
|
||||
const result = await runCommandWithTimeout(["npm", "prefix", "-g"], {
|
||||
timeoutMs: 8_000,
|
||||
onOutputLine,
|
||||
}).catch(() => null);
|
||||
if (!result || result.code !== 0) {
|
||||
return undefined;
|
||||
@ -662,10 +769,13 @@ function resolveGlobalOpenClawCommand(globalBinDir: string | undefined): string
|
||||
return candidates.find((candidate) => existsSync(candidate));
|
||||
}
|
||||
|
||||
async function resolveShellOpenClawPath(): Promise<string | undefined> {
|
||||
async function resolveShellOpenClawPath(
|
||||
onOutputLine?: OutputLineHandler,
|
||||
): Promise<string | undefined> {
|
||||
const locator = process.platform === "win32" ? "where" : "which";
|
||||
const result = await runCommandWithTimeout([locator, "openclaw"], {
|
||||
timeoutMs: 4_000,
|
||||
onOutputLine,
|
||||
}).catch(() => null);
|
||||
if (!result || result.code !== 0) {
|
||||
return undefined;
|
||||
@ -681,14 +791,55 @@ function isProjectLocalOpenClawPath(commandPath: string | undefined): boolean {
|
||||
return normalized.includes("/node_modules/.bin/openclaw");
|
||||
}
|
||||
|
||||
async function ensureOpenClawCliAvailable(): Promise<OpenClawCliAvailability> {
|
||||
const globalBefore = await detectGlobalOpenClawInstall();
|
||||
async function ensureOpenClawCliAvailable(params: {
|
||||
stateDir: string;
|
||||
showProgress: boolean;
|
||||
}): Promise<OpenClawCliAvailability> {
|
||||
const cached = readOpenClawCliCheckCache(params.stateDir);
|
||||
if (cached) {
|
||||
const ageSeconds = Math.max(0, Math.floor((Date.now() - cached.checkedAt) / 1000));
|
||||
const progress = createOpenClawSetupProgress({
|
||||
enabled: params.showProgress,
|
||||
totalStages: 1,
|
||||
});
|
||||
progress.startStage("Reusing cached OpenClaw install check");
|
||||
progress.completeStage(`cache hit (${ageSeconds}s old)`);
|
||||
return {
|
||||
available: true,
|
||||
installed: false,
|
||||
installedAt: cached.installedAt,
|
||||
version: cached.version,
|
||||
command: cached.command,
|
||||
globalBinDir: cached.globalBinDir,
|
||||
shellCommandPath: cached.shellCommandPath,
|
||||
};
|
||||
}
|
||||
|
||||
const progress = createOpenClawSetupProgress({
|
||||
enabled: params.showProgress,
|
||||
totalStages: 5,
|
||||
});
|
||||
progress.startStage("Checking global OpenClaw install");
|
||||
|
||||
const globalBefore = await detectGlobalOpenClawInstall((line) => {
|
||||
progress.output(`npm ls: ${line}`);
|
||||
});
|
||||
progress.completeStage(
|
||||
globalBefore.installed ? `found ${globalBefore.version ?? "installed"}` : "missing",
|
||||
);
|
||||
|
||||
let installed = false;
|
||||
let installedAt: number | undefined;
|
||||
progress.startStage("Ensuring openclaw@latest is installed globally");
|
||||
if (!globalBefore.installed) {
|
||||
const install = await runCommandWithTimeout(["npm", "install", "-g", "openclaw@latest"], {
|
||||
timeoutMs: 10 * 60_000,
|
||||
onOutputLine: (line) => {
|
||||
progress.output(`npm install: ${line}`);
|
||||
},
|
||||
}).catch(() => null);
|
||||
if (!install || install.code !== 0) {
|
||||
progress.fail("OpenClaw global install failed.");
|
||||
return {
|
||||
available: false,
|
||||
installed: false,
|
||||
@ -697,19 +848,55 @@ async function ensureOpenClawCliAvailable(): Promise<OpenClawCliAvailability> {
|
||||
};
|
||||
}
|
||||
installed = true;
|
||||
installedAt = Date.now();
|
||||
progress.completeStage("installed openclaw@latest");
|
||||
} else {
|
||||
progress.completeStage("already installed; skipping install");
|
||||
}
|
||||
|
||||
const globalAfter = installed ? await detectGlobalOpenClawInstall() : globalBefore;
|
||||
const globalBinDir = await resolveNpmGlobalBinDir();
|
||||
progress.startStage("Resolving global and shell OpenClaw paths");
|
||||
const [globalBinDir, shellCommandPath] = await Promise.all([
|
||||
resolveNpmGlobalBinDir((line) => {
|
||||
progress.output(`npm prefix: ${line}`);
|
||||
}),
|
||||
resolveShellOpenClawPath((line) => {
|
||||
progress.output(`${process.platform === "win32" ? "where" : "which"}: ${line}`);
|
||||
}),
|
||||
]);
|
||||
progress.completeStage("path discovery complete");
|
||||
|
||||
const globalAfter = installed ? { installed: true, version: globalBefore.version } : globalBefore;
|
||||
const globalCommand = resolveGlobalOpenClawCommand(globalBinDir);
|
||||
const command = globalCommand ?? "openclaw";
|
||||
const check = await runOpenClaw(command, ["--version"], 4_000).catch(() => null);
|
||||
const shellCommandPath = await resolveShellOpenClawPath();
|
||||
progress.startStage("Verifying OpenClaw CLI responsiveness");
|
||||
const check = await runOpenClaw(command, ["--version"], 4_000, "capture", undefined, (line) => {
|
||||
progress.output(`openclaw --version: ${line}`);
|
||||
}).catch(() => null);
|
||||
progress.completeStage(
|
||||
check?.code === 0 ? "OpenClaw responded" : "OpenClaw version probe failed",
|
||||
);
|
||||
|
||||
const version = normalizeVersionOutput(check?.stdout || check?.stderr || globalAfter.version);
|
||||
const available = Boolean(globalAfter.installed && check && check.code === 0);
|
||||
progress.startStage("Caching OpenClaw check result");
|
||||
if (available) {
|
||||
writeOpenClawCliCheckCache(params.stateDir, {
|
||||
available,
|
||||
command,
|
||||
version,
|
||||
globalBinDir,
|
||||
shellCommandPath,
|
||||
installedAt,
|
||||
});
|
||||
progress.completeStage(`saved (${Math.floor(OPENCLAW_CLI_CHECK_CACHE_TTL_MS / 60_000)}m TTL)`);
|
||||
} else {
|
||||
progress.fail("OpenClaw CLI check failed (cache not written).");
|
||||
}
|
||||
|
||||
return {
|
||||
available,
|
||||
installed,
|
||||
installedAt,
|
||||
version,
|
||||
command,
|
||||
globalBinDir,
|
||||
@ -937,7 +1124,11 @@ function remediationForGatewayFailure(
|
||||
}
|
||||
|
||||
function remediationForWebUiFailure(port: number): string {
|
||||
return `Web UI did not respond on ${port}. Ensure the apps/web directory exists and rerun with \`denchclaw bootstrap --web-port <port>\` if needed.`;
|
||||
return [
|
||||
`Web UI did not respond on ${port}.`,
|
||||
`Run \`dench update --web-port ${port}\` to refresh the managed web runtime.`,
|
||||
`If the port is stuck, run \`dench stop --web-port ${port}\` first.`,
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
function describeWorkspaceSeedResult(result: WorkspaceSeedResult): string {
|
||||
@ -1234,6 +1425,7 @@ function logBootstrapChecklist(diagnostics: BootstrapDiagnostics, runtime: Runti
|
||||
async function shouldRunUpdate(params: {
|
||||
opts: BootstrapOptions;
|
||||
runtime: RuntimeEnv;
|
||||
installResult: OpenClawCliAvailability;
|
||||
}): Promise<boolean> {
|
||||
if (params.opts.updateNow) {
|
||||
return true;
|
||||
@ -1246,6 +1438,17 @@ async function shouldRunUpdate(params: {
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const installedRecently =
|
||||
params.installResult.installed ||
|
||||
(typeof params.installResult.installedAt === "number" &&
|
||||
Date.now() - params.installResult.installedAt <=
|
||||
OPENCLAW_UPDATE_PROMPT_SUPPRESS_AFTER_INSTALL_MS);
|
||||
if (installedRecently) {
|
||||
params.runtime.log(
|
||||
theme.muted("Skipping update prompt because OpenClaw was installed moments ago."),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
const decision = await confirm({
|
||||
message: stylePromptMessage("Check and install OpenClaw updates now?"),
|
||||
initialValue: false,
|
||||
@ -1266,11 +1469,16 @@ export async function bootstrapCommand(
|
||||
const legacyFallbackEnabled = isLegacyFallbackEnabled();
|
||||
const appliedProfile = applyCliProfileEnv({ profile: opts.profile });
|
||||
const profile = appliedProfile.effectiveProfile;
|
||||
const stateDir = resolveProfileStateDir(profile);
|
||||
const workspaceDir = resolveBootstrapWorkspaceDir(stateDir);
|
||||
if (appliedProfile.warning && !opts.json) {
|
||||
runtime.log(theme.warn(appliedProfile.warning));
|
||||
}
|
||||
|
||||
const installResult = await ensureOpenClawCliAvailable();
|
||||
const installResult = await ensureOpenClawCliAvailable({
|
||||
stateDir,
|
||||
showProgress: !opts.json,
|
||||
});
|
||||
if (!installResult.available) {
|
||||
throw new Error(
|
||||
[
|
||||
@ -1286,7 +1494,7 @@ export async function bootstrapCommand(
|
||||
}
|
||||
const openclawCommand = installResult.command;
|
||||
|
||||
if (await shouldRunUpdate({ opts, runtime })) {
|
||||
if (await shouldRunUpdate({ opts, runtime, installResult })) {
|
||||
await runOpenClawWithProgress({
|
||||
openclawCommand,
|
||||
args: ["update", "--yes"],
|
||||
@ -1322,9 +1530,6 @@ export async function bootstrapCommand(
|
||||
portAutoAssigned = true;
|
||||
}
|
||||
|
||||
const stateDir = resolveProfileStateDir(profile);
|
||||
const workspaceDir = resolveBootstrapWorkspaceDir(stateDir);
|
||||
|
||||
if (portAutoAssigned && !opts.json) {
|
||||
runtime.log(
|
||||
theme.muted(
|
||||
@ -1372,9 +1577,10 @@ export async function bootstrapCommand(
|
||||
});
|
||||
}
|
||||
|
||||
const packageRoot = resolveCliPackageRoot();
|
||||
const workspaceSeed = seedWorkspaceFromAssets({
|
||||
workspaceDir,
|
||||
packageRoot: resolveCliPackageRoot(),
|
||||
packageRoot,
|
||||
});
|
||||
|
||||
// Ensure gateway.mode=local so the gateway never drifts to remote mode.
|
||||
@ -1410,12 +1616,14 @@ export async function bootstrapCommand(
|
||||
}
|
||||
const gatewayUrl = `ws://127.0.0.1:${gatewayPort}`;
|
||||
const preferredWebPort = parseOptionalPort(opts.webPort) ?? DEFAULT_WEB_APP_PORT;
|
||||
|
||||
if (!(await probeForWebApp(preferredWebPort))) {
|
||||
startWebAppIfNeeded(preferredWebPort, stateDir, gatewayPort);
|
||||
}
|
||||
|
||||
const webReachable = await waitForWebApp(preferredWebPort);
|
||||
const webRuntimeStatus = await ensureManagedWebRuntime({
|
||||
stateDir,
|
||||
packageRoot,
|
||||
denchVersion: VERSION,
|
||||
port: preferredWebPort,
|
||||
gatewayPort,
|
||||
});
|
||||
const webReachable = webRuntimeStatus.ready;
|
||||
const webUrl = `http://localhost:${preferredWebPort}`;
|
||||
const diagnostics = buildBootstrapDiagnostics({
|
||||
profile,
|
||||
@ -1435,6 +1643,9 @@ export async function bootstrapCommand(
|
||||
const opened = shouldOpen ? await openUrl(webUrl) : false;
|
||||
|
||||
if (!opts.json) {
|
||||
if (!webRuntimeStatus.ready) {
|
||||
runtime.log(theme.warn(`Managed web runtime check failed: ${webRuntimeStatus.reason}`));
|
||||
}
|
||||
if (installResult.installed) {
|
||||
runtime.log(theme.muted("Installed global OpenClaw CLI via npm."));
|
||||
}
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
import type { Command } from "commander";
|
||||
import { getPrimaryCommand } from "../argv.js";
|
||||
import { reparseProgramFromActionArgs } from "./action-reparse.js";
|
||||
import { removeCommandByName } from "./command-tree.js";
|
||||
import type { ProgramContext } from "./context.js";
|
||||
import { registerBootstrapCommand } from "./register.bootstrap.js";
|
||||
import { registerStartCommand } from "./register.start.js";
|
||||
import { registerStopCommand } from "./register.stop.js";
|
||||
import { registerUpdateCommand } from "./register.update.js";
|
||||
|
||||
type CommandRegisterParams = {
|
||||
program: Command;
|
||||
@ -16,30 +18,40 @@ type CoreCliEntry = {
|
||||
register: (params: CommandRegisterParams) => Promise<void> | void;
|
||||
};
|
||||
|
||||
const BOOTSTRAP_ENTRY: CoreCliEntry = {
|
||||
name: "bootstrap",
|
||||
description: "Bootstrap DenchClaw + OpenClaw and launch the web UI",
|
||||
register: async ({ program }) => {
|
||||
const mod = await import("./register.bootstrap.js");
|
||||
mod.registerBootstrapCommand(program);
|
||||
const CORE_CLI_ENTRIES: CoreCliEntry[] = [
|
||||
{
|
||||
name: "bootstrap",
|
||||
description: "Bootstrap DenchClaw + OpenClaw and launch the web UI",
|
||||
register: ({ program }) => {
|
||||
registerBootstrapCommand(program);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function registerLazyBootstrap(program: Command, ctx: ProgramContext) {
|
||||
const placeholder = program
|
||||
.command(BOOTSTRAP_ENTRY.name)
|
||||
.description(BOOTSTRAP_ENTRY.description);
|
||||
placeholder.allowUnknownOption(true);
|
||||
placeholder.allowExcessArguments(true);
|
||||
placeholder.action(async (...actionArgs) => {
|
||||
removeCommandByName(program, BOOTSTRAP_ENTRY.name);
|
||||
await BOOTSTRAP_ENTRY.register({ program, ctx, argv: process.argv });
|
||||
await reparseProgramFromActionArgs(program, actionArgs);
|
||||
});
|
||||
}
|
||||
{
|
||||
name: "update",
|
||||
description: "Update Dench web runtime without onboarding",
|
||||
register: ({ program }) => {
|
||||
registerUpdateCommand(program);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "stop",
|
||||
description: "Stop Dench managed web runtime",
|
||||
register: ({ program }) => {
|
||||
registerStopCommand(program);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "start",
|
||||
description: "Start Dench managed web runtime",
|
||||
register: ({ program }) => {
|
||||
registerStartCommand(program);
|
||||
},
|
||||
},
|
||||
];
|
||||
const CORE_CLI_ENTRY_BY_NAME = new Map(CORE_CLI_ENTRIES.map((entry) => [entry.name, entry]));
|
||||
|
||||
export function getCoreCliCommandNames(): string[] {
|
||||
return [BOOTSTRAP_ENTRY.name];
|
||||
return CORE_CLI_ENTRIES.map((entry) => entry.name);
|
||||
}
|
||||
|
||||
export function getCoreCliCommandsWithSubcommands(): string[] {
|
||||
@ -52,21 +64,27 @@ export async function registerCoreCliByName(
|
||||
name: string,
|
||||
argv: string[] = process.argv,
|
||||
): Promise<boolean> {
|
||||
void argv;
|
||||
if (name !== BOOTSTRAP_ENTRY.name) {
|
||||
const entry = CORE_CLI_ENTRY_BY_NAME.get(name);
|
||||
if (!entry) {
|
||||
return false;
|
||||
}
|
||||
removeCommandByName(program, BOOTSTRAP_ENTRY.name);
|
||||
await BOOTSTRAP_ENTRY.register({ program, ctx, argv });
|
||||
await entry.register({ program, ctx, argv });
|
||||
return true;
|
||||
}
|
||||
|
||||
export function registerCoreCliCommands(program: Command, ctx: ProgramContext, argv: string[]) {
|
||||
const primary = getPrimaryCommand(argv);
|
||||
if (primary && primary !== BOOTSTRAP_ENTRY.name) {
|
||||
if (primary) {
|
||||
const entry = CORE_CLI_ENTRY_BY_NAME.get(primary);
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
void entry.register({ program, ctx, argv });
|
||||
return;
|
||||
}
|
||||
registerLazyBootstrap(program, ctx);
|
||||
for (const entry of CORE_CLI_ENTRIES) {
|
||||
void entry.register({ program, ctx, argv });
|
||||
}
|
||||
}
|
||||
|
||||
export function registerProgramCommands(
|
||||
|
||||
@ -15,6 +15,9 @@ const ROOT_COMMANDS_HINT =
|
||||
"Hint: commands suffixed with * have subcommands. Run <command> --help for details.";
|
||||
|
||||
const EXAMPLES = [
|
||||
["openclaw start --web-port 3100", "Start the managed web runtime without replacing assets."],
|
||||
["openclaw update", "Refresh the managed web runtime and enforce major upgrade gates."],
|
||||
["openclaw stop --web-port 3100", "Stop only the managed web runtime on a specific port."],
|
||||
["openclaw models --help", "Show detailed help for the models command."],
|
||||
[
|
||||
"openclaw channels login --verbose",
|
||||
|
||||
22
src/cli/program/register.start.ts
Normal file
22
src/cli/program/register.start.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import type { Command } from "commander";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { runCommandWithRuntime } from "../cli-utils.js";
|
||||
import { startWebRuntimeCommand } from "../web-runtime-command.js";
|
||||
|
||||
export function registerStartCommand(program: Command) {
|
||||
program
|
||||
.command("start")
|
||||
.description("Start Dench managed web runtime without updating assets")
|
||||
.option("--profile <name>", "Compatibility flag; non-dench values are ignored with a warning")
|
||||
.option("--web-port <port>", "Web runtime port override")
|
||||
.option("--json", "Output summary as JSON", false)
|
||||
.action(async (opts) => {
|
||||
await runCommandWithRuntime(defaultRuntime, async () => {
|
||||
await startWebRuntimeCommand({
|
||||
profile: opts.profile as string | undefined,
|
||||
webPort: opts.webPort as string | undefined,
|
||||
json: Boolean(opts.json),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
22
src/cli/program/register.stop.ts
Normal file
22
src/cli/program/register.stop.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import type { Command } from "commander";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { runCommandWithRuntime } from "../cli-utils.js";
|
||||
import { stopWebRuntimeCommand } from "../web-runtime-command.js";
|
||||
|
||||
export function registerStopCommand(program: Command) {
|
||||
program
|
||||
.command("stop")
|
||||
.description("Stop Dench managed web runtime on the configured port")
|
||||
.option("--profile <name>", "Compatibility flag; non-dench values are ignored with a warning")
|
||||
.option("--web-port <port>", "Web runtime port override")
|
||||
.option("--json", "Output summary as JSON", false)
|
||||
.action(async (opts) => {
|
||||
await runCommandWithRuntime(defaultRuntime, async () => {
|
||||
await stopWebRuntimeCommand({
|
||||
profile: opts.profile as string | undefined,
|
||||
webPort: opts.webPort as string | undefined,
|
||||
json: Boolean(opts.json),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
26
src/cli/program/register.update.ts
Normal file
26
src/cli/program/register.update.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import type { Command } from "commander";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { runCommandWithRuntime } from "../cli-utils.js";
|
||||
import { updateWebRuntimeCommand } from "../web-runtime-command.js";
|
||||
|
||||
export function registerUpdateCommand(program: Command) {
|
||||
program
|
||||
.command("update")
|
||||
.description("Update Dench managed web runtime without onboarding")
|
||||
.option("--profile <name>", "Compatibility flag; non-dench values are ignored with a warning")
|
||||
.option("--web-port <port>", "Web runtime port override")
|
||||
.option("--non-interactive", "Fail instead of prompting for major-gate approval", false)
|
||||
.option("--yes", "Approve mandatory major-gate OpenClaw update", false)
|
||||
.option("--json", "Output summary as JSON", false)
|
||||
.action(async (opts) => {
|
||||
await runCommandWithRuntime(defaultRuntime, async () => {
|
||||
await updateWebRuntimeCommand({
|
||||
profile: opts.profile as string | undefined,
|
||||
webPort: opts.webPort as string | undefined,
|
||||
nonInteractive: Boolean(opts.nonInteractive),
|
||||
yes: Boolean(opts.yes),
|
||||
json: Boolean(opts.json),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -127,7 +127,19 @@ export function shouldDelegateToGlobalOpenClaw(
|
||||
if (!primary) {
|
||||
return false;
|
||||
}
|
||||
return primary !== "bootstrap";
|
||||
return (
|
||||
primary !== "bootstrap" && primary !== "update" && primary !== "stop" && primary !== "start"
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldHideCliBanner(argv: string[], env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
const commandPath = getCommandPath(argv, 2);
|
||||
return (
|
||||
isTruthyEnvValue(env.DENCHCLAW_HIDE_BANNER) ||
|
||||
isTruthyEnvValue(env.OPENCLAW_HIDE_BANNER) ||
|
||||
commandPath[0] === "completion" ||
|
||||
(commandPath[0] === "plugins" && commandPath[1] === "update")
|
||||
);
|
||||
}
|
||||
|
||||
async function delegateToGlobalOpenClaw(argv: string[]): Promise<number> {
|
||||
@ -189,14 +201,7 @@ export async function runCli(argv: string[] = process.argv) {
|
||||
// Show the animated DenchClaw banner early so it appears for ALL invocations
|
||||
// (bare `denchclaw`, subcommands, help, etc.). The bannerEmitted flag inside
|
||||
// emitCliBanner prevents double-emission from the route / preAction hooks.
|
||||
const commandPath = getCommandPath(normalizedArgv, 2);
|
||||
const hideBanner =
|
||||
isTruthyEnvValue(process.env.DENCHCLAW_HIDE_BANNER) ||
|
||||
isTruthyEnvValue(process.env.OPENCLAW_HIDE_BANNER) ||
|
||||
commandPath[0] === "update" ||
|
||||
commandPath[0] === "completion" ||
|
||||
(commandPath[0] === "plugins" && commandPath[1] === "update");
|
||||
if (!hideBanner) {
|
||||
if (!shouldHideCliBanner(normalizedArgv, process.env)) {
|
||||
await emitCliBanner(VERSION, { argv: normalizedArgv });
|
||||
}
|
||||
|
||||
|
||||
333
src/cli/web-runtime-command.test.ts
Normal file
333
src/cli/web-runtime-command.test.ts
Normal file
@ -0,0 +1,333 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
|
||||
const promptMocks = vi.hoisted(() => ({
|
||||
confirm: vi.fn(async () => true),
|
||||
isCancel: vi.fn(() => false),
|
||||
spinner: vi.fn(() => ({
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
message: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
const spawnMock = vi.hoisted(() => vi.fn());
|
||||
const webRuntimeMocks = vi.hoisted(() => ({
|
||||
DEFAULT_WEB_APP_PORT: 3100,
|
||||
ensureManagedWebRuntime: vi.fn(async () => ({ ready: true, reason: "ready" })),
|
||||
evaluateMajorVersionTransition: vi.fn(() => ({
|
||||
previousMajor: 2,
|
||||
currentMajor: 2,
|
||||
isMajorTransition: false,
|
||||
})),
|
||||
readLastKnownWebPort: vi.fn(() => 3100),
|
||||
readManagedWebRuntimeManifest: vi.fn(() => ({
|
||||
schemaVersion: 1,
|
||||
deployedDenchVersion: "2.1.0",
|
||||
deployedAt: "2026-01-01T00:00:00.000Z",
|
||||
sourceStandaloneServer: "/tmp/server.js",
|
||||
lastPort: 3100,
|
||||
lastGatewayPort: 18789,
|
||||
})),
|
||||
resolveCliPackageRoot: vi.fn(() => "/tmp/pkg"),
|
||||
resolveManagedWebRuntimeServerPath: vi.fn(() => "/tmp/.openclaw-dench/web-runtime/app/server.js"),
|
||||
resolveOpenClawCommandOrThrow: vi.fn(() => "/usr/local/bin/openclaw"),
|
||||
resolveProfileStateDir: vi.fn(() => "/tmp/.openclaw-dench"),
|
||||
runOpenClawCommand: vi.fn(async () => ({ code: 0, stdout: '{"ok":true}', stderr: "" })),
|
||||
startManagedWebRuntime: vi.fn(() => ({
|
||||
started: true,
|
||||
pid: 7788,
|
||||
runtimeServerPath: "/tmp/.openclaw-dench/web-runtime/app/server.js",
|
||||
})),
|
||||
stopManagedWebRuntime: vi.fn(async () => ({
|
||||
port: 3100,
|
||||
stoppedPids: [1234],
|
||||
skippedForeignPids: [],
|
||||
})),
|
||||
waitForWebRuntime: vi.fn(async () => ({ ok: true, reason: "profiles payload shape is valid" })),
|
||||
}));
|
||||
|
||||
vi.mock("@clack/prompts", () => ({
|
||||
confirm: promptMocks.confirm,
|
||||
isCancel: promptMocks.isCancel,
|
||||
spinner: promptMocks.spinner,
|
||||
}));
|
||||
|
||||
vi.mock("node:child_process", () => ({
|
||||
spawn: spawnMock,
|
||||
}));
|
||||
|
||||
vi.mock("./web-runtime.js", () => ({
|
||||
DEFAULT_WEB_APP_PORT: webRuntimeMocks.DEFAULT_WEB_APP_PORT,
|
||||
ensureManagedWebRuntime: webRuntimeMocks.ensureManagedWebRuntime,
|
||||
evaluateMajorVersionTransition: webRuntimeMocks.evaluateMajorVersionTransition,
|
||||
readLastKnownWebPort: webRuntimeMocks.readLastKnownWebPort,
|
||||
readManagedWebRuntimeManifest: webRuntimeMocks.readManagedWebRuntimeManifest,
|
||||
resolveCliPackageRoot: webRuntimeMocks.resolveCliPackageRoot,
|
||||
resolveManagedWebRuntimeServerPath: webRuntimeMocks.resolveManagedWebRuntimeServerPath,
|
||||
resolveOpenClawCommandOrThrow: webRuntimeMocks.resolveOpenClawCommandOrThrow,
|
||||
resolveProfileStateDir: webRuntimeMocks.resolveProfileStateDir,
|
||||
runOpenClawCommand: webRuntimeMocks.runOpenClawCommand,
|
||||
startManagedWebRuntime: webRuntimeMocks.startManagedWebRuntime,
|
||||
stopManagedWebRuntime: webRuntimeMocks.stopManagedWebRuntime,
|
||||
waitForWebRuntime: webRuntimeMocks.waitForWebRuntime,
|
||||
}));
|
||||
|
||||
import {
|
||||
startWebRuntimeCommand,
|
||||
stopWebRuntimeCommand,
|
||||
updateWebRuntimeCommand,
|
||||
} from "./web-runtime-command.js";
|
||||
|
||||
function createMockChild(code = 0): EventEmitter & {
|
||||
stdout: EventEmitter;
|
||||
stderr: EventEmitter;
|
||||
kill: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
const child = new EventEmitter() as EventEmitter & {
|
||||
stdout: EventEmitter;
|
||||
stderr: EventEmitter;
|
||||
kill: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
child.stdout = new EventEmitter();
|
||||
child.stderr = new EventEmitter();
|
||||
child.kill = vi.fn();
|
||||
queueMicrotask(() => {
|
||||
child.emit("close", code);
|
||||
});
|
||||
return child;
|
||||
}
|
||||
|
||||
function runtimeStub(): RuntimeEnv {
|
||||
return {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("updateWebRuntimeCommand", () => {
|
||||
beforeEach(() => {
|
||||
spawnMock.mockReset();
|
||||
spawnMock.mockImplementation(() => createMockChild(0));
|
||||
promptMocks.confirm.mockReset();
|
||||
promptMocks.confirm.mockImplementation(async () => true);
|
||||
promptMocks.isCancel.mockReset();
|
||||
promptMocks.isCancel.mockImplementation(() => false);
|
||||
|
||||
webRuntimeMocks.ensureManagedWebRuntime.mockReset();
|
||||
webRuntimeMocks.ensureManagedWebRuntime.mockImplementation(
|
||||
async () => ({ ready: true, reason: "ready" }) as { ready: boolean; reason: string },
|
||||
);
|
||||
webRuntimeMocks.stopManagedWebRuntime.mockReset();
|
||||
webRuntimeMocks.stopManagedWebRuntime.mockImplementation(
|
||||
async () =>
|
||||
({
|
||||
port: 3100,
|
||||
stoppedPids: [1234],
|
||||
skippedForeignPids: [],
|
||||
}) as { port: number; stoppedPids: number[]; skippedForeignPids: number[] },
|
||||
);
|
||||
webRuntimeMocks.evaluateMajorVersionTransition.mockReset();
|
||||
webRuntimeMocks.evaluateMajorVersionTransition.mockImplementation(() => ({
|
||||
previousMajor: 2,
|
||||
currentMajor: 2,
|
||||
isMajorTransition: false,
|
||||
}));
|
||||
webRuntimeMocks.readManagedWebRuntimeManifest.mockReset();
|
||||
webRuntimeMocks.readManagedWebRuntimeManifest.mockImplementation(() => ({
|
||||
schemaVersion: 1,
|
||||
deployedDenchVersion: "2.1.0",
|
||||
deployedAt: "2026-01-01T00:00:00.000Z",
|
||||
sourceStandaloneServer: "/tmp/server.js",
|
||||
lastPort: 3100,
|
||||
lastGatewayPort: 18789,
|
||||
}));
|
||||
webRuntimeMocks.startManagedWebRuntime.mockReset();
|
||||
webRuntimeMocks.startManagedWebRuntime.mockImplementation(() => ({
|
||||
started: true,
|
||||
pid: 7788,
|
||||
runtimeServerPath: "/tmp/.openclaw-dench/web-runtime/app/server.js",
|
||||
}));
|
||||
webRuntimeMocks.waitForWebRuntime.mockReset();
|
||||
webRuntimeMocks.waitForWebRuntime.mockImplementation(
|
||||
async () =>
|
||||
({ ok: true, reason: "profiles payload shape is valid" }) as {
|
||||
ok: boolean;
|
||||
reason: string;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("fails closed in non-interactive major upgrades without explicit approval (enforces mandatory operator consent)", async () => {
|
||||
webRuntimeMocks.evaluateMajorVersionTransition.mockReturnValue({
|
||||
previousMajor: 2,
|
||||
currentMajor: 3,
|
||||
isMajorTransition: true,
|
||||
});
|
||||
const runtime = runtimeStub();
|
||||
|
||||
await expect(
|
||||
updateWebRuntimeCommand(
|
||||
{
|
||||
nonInteractive: true,
|
||||
yes: false,
|
||||
},
|
||||
runtime,
|
||||
),
|
||||
).rejects.toThrow("Major Dench upgrade detected");
|
||||
|
||||
expect(spawnMock).not.toHaveBeenCalled();
|
||||
expect(webRuntimeMocks.ensureManagedWebRuntime).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs OpenClaw update before refreshing web runtime on major transitions (protects upgrade compatibility)", async () => {
|
||||
webRuntimeMocks.evaluateMajorVersionTransition.mockReturnValue({
|
||||
previousMajor: 2,
|
||||
currentMajor: 3,
|
||||
isMajorTransition: true,
|
||||
});
|
||||
const runtime = runtimeStub();
|
||||
|
||||
const summary = await updateWebRuntimeCommand(
|
||||
{
|
||||
nonInteractive: true,
|
||||
yes: true,
|
||||
},
|
||||
runtime,
|
||||
);
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
"/usr/local/bin/openclaw",
|
||||
["update", "--yes"],
|
||||
expect.objectContaining({ stdio: ["ignore", "pipe", "pipe"] }),
|
||||
);
|
||||
expect(webRuntimeMocks.ensureManagedWebRuntime).toHaveBeenCalled();
|
||||
expect(summary.majorGate.required).toBe(true);
|
||||
});
|
||||
|
||||
it("skips OpenClaw update on minor upgrades while still refreshing runtime (avoids unnecessary blocking)", async () => {
|
||||
webRuntimeMocks.evaluateMajorVersionTransition.mockReturnValue({
|
||||
previousMajor: 2,
|
||||
currentMajor: 2,
|
||||
isMajorTransition: false,
|
||||
});
|
||||
const runtime = runtimeStub();
|
||||
|
||||
const summary = await updateWebRuntimeCommand(
|
||||
{
|
||||
nonInteractive: true,
|
||||
},
|
||||
runtime,
|
||||
);
|
||||
|
||||
expect(spawnMock).not.toHaveBeenCalled();
|
||||
expect(webRuntimeMocks.stopManagedWebRuntime).toHaveBeenCalledWith({
|
||||
stateDir: "/tmp/.openclaw-dench",
|
||||
port: 3100,
|
||||
includeLegacyStandalone: true,
|
||||
});
|
||||
expect(webRuntimeMocks.ensureManagedWebRuntime).toHaveBeenCalled();
|
||||
expect(summary.ready).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stopWebRuntimeCommand", () => {
|
||||
it("reports foreign listeners without terminating them (preserves process boundaries)", async () => {
|
||||
webRuntimeMocks.stopManagedWebRuntime.mockResolvedValue({
|
||||
port: 3100,
|
||||
stoppedPids: [],
|
||||
skippedForeignPids: [91, 92],
|
||||
});
|
||||
const runtime = runtimeStub();
|
||||
|
||||
const summary = await stopWebRuntimeCommand(
|
||||
{
|
||||
webPort: "3100",
|
||||
},
|
||||
runtime,
|
||||
);
|
||||
|
||||
expect(summary.stoppedPids).toEqual([]);
|
||||
expect(summary.skippedForeignPids).toEqual([91, 92]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("startWebRuntimeCommand", () => {
|
||||
beforeEach(() => {
|
||||
webRuntimeMocks.ensureManagedWebRuntime.mockClear();
|
||||
webRuntimeMocks.stopManagedWebRuntime.mockReset();
|
||||
webRuntimeMocks.stopManagedWebRuntime.mockImplementation(
|
||||
async () =>
|
||||
({
|
||||
port: 3100,
|
||||
stoppedPids: [1234],
|
||||
skippedForeignPids: [],
|
||||
}) as { port: number; stoppedPids: number[]; skippedForeignPids: number[] },
|
||||
);
|
||||
webRuntimeMocks.startManagedWebRuntime.mockReset();
|
||||
webRuntimeMocks.startManagedWebRuntime.mockImplementation(() => ({
|
||||
started: true,
|
||||
pid: 7788,
|
||||
runtimeServerPath: "/tmp/.openclaw-dench/web-runtime/app/server.js",
|
||||
}));
|
||||
webRuntimeMocks.waitForWebRuntime.mockReset();
|
||||
webRuntimeMocks.waitForWebRuntime.mockImplementation(
|
||||
async () =>
|
||||
({ ok: true, reason: "profiles payload shape is valid" }) as {
|
||||
ok: boolean;
|
||||
reason: string;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("fails closed when non-dench listeners still own the port (prevents cross-process takeover)", async () => {
|
||||
webRuntimeMocks.stopManagedWebRuntime.mockResolvedValue({
|
||||
port: 3100,
|
||||
stoppedPids: [],
|
||||
skippedForeignPids: [9912],
|
||||
});
|
||||
const runtime = runtimeStub();
|
||||
|
||||
await expect(startWebRuntimeCommand({}, runtime)).rejects.toThrow("non-Dench listener");
|
||||
expect(webRuntimeMocks.startManagedWebRuntime).not.toHaveBeenCalled();
|
||||
expect(spawnMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails with actionable remediation when managed runtime is missing (requires explicit update/bootstrap)", async () => {
|
||||
webRuntimeMocks.startManagedWebRuntime.mockReturnValue({
|
||||
started: false,
|
||||
runtimeServerPath: "/tmp/.openclaw-dench/web-runtime/app/server.js",
|
||||
reason: "runtime-missing",
|
||||
});
|
||||
const runtime = runtimeStub();
|
||||
|
||||
await expect(startWebRuntimeCommand({}, runtime)).rejects.toThrow("dench update");
|
||||
expect(spawnMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("starts managed runtime without triggering update/install workflow (start-only behavior)", async () => {
|
||||
const runtime = runtimeStub();
|
||||
const summary = await startWebRuntimeCommand(
|
||||
{
|
||||
webPort: "3100",
|
||||
},
|
||||
runtime,
|
||||
);
|
||||
|
||||
expect(webRuntimeMocks.stopManagedWebRuntime).toHaveBeenCalledWith({
|
||||
stateDir: "/tmp/.openclaw-dench",
|
||||
port: 3100,
|
||||
includeLegacyStandalone: true,
|
||||
});
|
||||
expect(webRuntimeMocks.startManagedWebRuntime).toHaveBeenCalledWith({
|
||||
stateDir: "/tmp/.openclaw-dench",
|
||||
port: 3100,
|
||||
gatewayPort: 18789,
|
||||
});
|
||||
expect(webRuntimeMocks.ensureManagedWebRuntime).not.toHaveBeenCalled();
|
||||
expect(spawnMock).not.toHaveBeenCalled();
|
||||
expect(summary.started).toBe(true);
|
||||
});
|
||||
});
|
||||
488
src/cli/web-runtime-command.ts
Normal file
488
src/cli/web-runtime-command.ts
Normal file
@ -0,0 +1,488 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { confirm, isCancel, spinner } from "@clack/prompts";
|
||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||
import { stylePromptMessage } from "../terminal/prompt-style.js";
|
||||
import { theme } from "../terminal/theme.js";
|
||||
import { VERSION } from "../version.js";
|
||||
import { applyCliProfileEnv } from "./profile.js";
|
||||
import {
|
||||
DEFAULT_WEB_APP_PORT,
|
||||
ensureManagedWebRuntime,
|
||||
evaluateMajorVersionTransition,
|
||||
readLastKnownWebPort,
|
||||
readManagedWebRuntimeManifest,
|
||||
resolveCliPackageRoot,
|
||||
resolveManagedWebRuntimeServerPath,
|
||||
resolveOpenClawCommandOrThrow,
|
||||
resolveProfileStateDir,
|
||||
runOpenClawCommand,
|
||||
startManagedWebRuntime,
|
||||
stopManagedWebRuntime,
|
||||
waitForWebRuntime,
|
||||
} from "./web-runtime.js";
|
||||
|
||||
type SpawnResult = {
|
||||
code: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
};
|
||||
|
||||
export type UpdateWebRuntimeOptions = {
|
||||
profile?: string;
|
||||
webPort?: string | number;
|
||||
nonInteractive?: boolean;
|
||||
yes?: boolean;
|
||||
json?: boolean;
|
||||
};
|
||||
|
||||
export type StopWebRuntimeOptions = {
|
||||
profile?: string;
|
||||
webPort?: string | number;
|
||||
json?: boolean;
|
||||
};
|
||||
|
||||
export type StartWebRuntimeOptions = {
|
||||
profile?: string;
|
||||
webPort?: string | number;
|
||||
json?: boolean;
|
||||
};
|
||||
|
||||
export type UpdateWebRuntimeSummary = {
|
||||
profile: string;
|
||||
webPort: number;
|
||||
version: string;
|
||||
majorGate: {
|
||||
required: boolean;
|
||||
previousVersion?: string;
|
||||
currentVersion: string;
|
||||
};
|
||||
stoppedPids: number[];
|
||||
skippedForeignPids: number[];
|
||||
ready: boolean;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export type StopWebRuntimeSummary = {
|
||||
profile: string;
|
||||
webPort: number;
|
||||
stoppedPids: number[];
|
||||
skippedForeignPids: number[];
|
||||
};
|
||||
|
||||
export type StartWebRuntimeSummary = {
|
||||
profile: string;
|
||||
webPort: number;
|
||||
stoppedPids: number[];
|
||||
skippedForeignPids: number[];
|
||||
started: boolean;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
function parseOptionalPort(value: string | number | undefined): number | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = typeof value === "number" ? value : Number.parseInt(String(value), 10);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function firstNonEmptyLine(...values: Array<string | undefined>): string | undefined {
|
||||
for (const value of values) {
|
||||
const first = value
|
||||
?.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find(Boolean);
|
||||
if (first) {
|
||||
return first;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function runOpenClawUpdateWithProgress(openclawCommand: string): Promise<void> {
|
||||
const s = spinner();
|
||||
s.start("Updating OpenClaw (required for this major Dench upgrade)...");
|
||||
const result = await new Promise<SpawnResult>((resolve, reject) => {
|
||||
const child = spawn(openclawCommand, ["update", "--yes"], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env: process.env,
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let settled = false;
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
if (!settled) {
|
||||
child.kill("SIGKILL");
|
||||
}
|
||||
}, 8 * 60_000);
|
||||
|
||||
const updateSpinner = (chunk: string) => {
|
||||
const line = chunk
|
||||
.split(/\r?\n/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
.at(-1);
|
||||
if (line) {
|
||||
s.message(line.length > 72 ? `${line.slice(0, 69)}...` : line);
|
||||
}
|
||||
};
|
||||
|
||||
child.stdout?.on("data", (chunk) => {
|
||||
const text = String(chunk);
|
||||
stdout += text;
|
||||
updateSpinner(text);
|
||||
});
|
||||
child.stderr?.on("data", (chunk) => {
|
||||
const text = String(chunk);
|
||||
stderr += text;
|
||||
updateSpinner(text);
|
||||
});
|
||||
child.once("error", (error) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
});
|
||||
child.once("close", (code) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve({ code: typeof code === "number" ? code : 1, stdout, stderr });
|
||||
});
|
||||
});
|
||||
|
||||
if (result.code === 0) {
|
||||
s.stop("OpenClaw update complete.");
|
||||
return;
|
||||
}
|
||||
const detail = firstNonEmptyLine(result.stderr, result.stdout);
|
||||
s.stop(detail ? `OpenClaw update failed: ${detail}` : "OpenClaw update failed.");
|
||||
throw new Error(
|
||||
detail
|
||||
? `OpenClaw update failed.\n${detail}`
|
||||
: "OpenClaw update failed. Fix this before running `dench update` again.",
|
||||
);
|
||||
}
|
||||
|
||||
async function ensureMajorUpgradeAcknowledged(params: {
|
||||
required: boolean;
|
||||
previousVersion: string | undefined;
|
||||
currentVersion: string;
|
||||
nonInteractive: boolean;
|
||||
yes: boolean;
|
||||
runtime: RuntimeEnv;
|
||||
}): Promise<void> {
|
||||
if (!params.required) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (params.nonInteractive || !process.stdin.isTTY) {
|
||||
if (!params.yes) {
|
||||
throw new Error(
|
||||
`Major Dench upgrade detected (${params.previousVersion ?? "unknown"} -> ${params.currentVersion}). Re-run with --yes to approve the required OpenClaw update.`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (params.yes) {
|
||||
return;
|
||||
}
|
||||
|
||||
const decision = await confirm({
|
||||
message: stylePromptMessage(
|
||||
`Major Dench upgrade detected (${params.previousVersion ?? "unknown"} -> ${params.currentVersion}). Continue with mandatory OpenClaw update now?`,
|
||||
),
|
||||
initialValue: true,
|
||||
});
|
||||
if (isCancel(decision) || !decision) {
|
||||
params.runtime.log(
|
||||
theme.warn("Update cancelled. OpenClaw update is required for major upgrades."),
|
||||
);
|
||||
throw new Error("Major upgrade requires OpenClaw update approval.");
|
||||
}
|
||||
}
|
||||
|
||||
function resolveGatewayPort(stateDir: string): number {
|
||||
const manifest = readManagedWebRuntimeManifest(stateDir);
|
||||
if (
|
||||
typeof manifest?.lastGatewayPort === "number" &&
|
||||
Number.isFinite(manifest.lastGatewayPort) &&
|
||||
manifest.lastGatewayPort > 0
|
||||
) {
|
||||
return manifest.lastGatewayPort;
|
||||
}
|
||||
|
||||
for (const name of ["openclaw.json", "config.json"]) {
|
||||
const port = readConfigGatewayPort(path.join(stateDir, name));
|
||||
if (typeof port === "number") {
|
||||
return port;
|
||||
}
|
||||
}
|
||||
return 18789;
|
||||
}
|
||||
|
||||
function readConfigGatewayPort(configPath: string): number | undefined {
|
||||
try {
|
||||
const raw = JSON.parse(readFileSync(configPath, "utf-8")) as {
|
||||
gateway?: { port?: unknown };
|
||||
};
|
||||
const parsedPort =
|
||||
typeof raw.gateway?.port === "number"
|
||||
? raw.gateway.port
|
||||
: typeof raw.gateway?.port === "string"
|
||||
? Number.parseInt(raw.gateway.port, 10)
|
||||
: undefined;
|
||||
if (typeof parsedPort === "number" && Number.isFinite(parsedPort) && parsedPort > 0) {
|
||||
return parsedPort;
|
||||
}
|
||||
return undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateWebRuntimeCommand(
|
||||
opts: UpdateWebRuntimeOptions,
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
): Promise<UpdateWebRuntimeSummary> {
|
||||
const appliedProfile = applyCliProfileEnv({ profile: opts.profile });
|
||||
const profile = appliedProfile.effectiveProfile;
|
||||
if (appliedProfile.warning && !opts.json) {
|
||||
runtime.log(theme.warn(appliedProfile.warning));
|
||||
}
|
||||
|
||||
const stateDir = resolveProfileStateDir(profile);
|
||||
const packageRoot = resolveCliPackageRoot();
|
||||
const previousManifest = readManagedWebRuntimeManifest(stateDir);
|
||||
const transition = evaluateMajorVersionTransition({
|
||||
previousVersion: previousManifest?.deployedDenchVersion,
|
||||
currentVersion: VERSION,
|
||||
});
|
||||
|
||||
const nonInteractive = Boolean(opts.nonInteractive || opts.json);
|
||||
await ensureMajorUpgradeAcknowledged({
|
||||
required: transition.isMajorTransition,
|
||||
previousVersion: previousManifest?.deployedDenchVersion,
|
||||
currentVersion: VERSION,
|
||||
nonInteractive,
|
||||
yes: Boolean(opts.yes),
|
||||
runtime,
|
||||
});
|
||||
|
||||
if (transition.isMajorTransition) {
|
||||
const openclawCommand = resolveOpenClawCommandOrThrow();
|
||||
await runOpenClawUpdateWithProgress(openclawCommand);
|
||||
}
|
||||
|
||||
const selectedPort =
|
||||
parseOptionalPort(opts.webPort) ??
|
||||
parseOptionalPort(previousManifest?.lastPort) ??
|
||||
readLastKnownWebPort(stateDir) ??
|
||||
DEFAULT_WEB_APP_PORT;
|
||||
const gatewayPort = resolveGatewayPort(stateDir);
|
||||
const stopResult = await stopManagedWebRuntime({
|
||||
stateDir,
|
||||
port: selectedPort,
|
||||
includeLegacyStandalone: true,
|
||||
});
|
||||
const ensureResult = await ensureManagedWebRuntime({
|
||||
stateDir,
|
||||
packageRoot,
|
||||
denchVersion: VERSION,
|
||||
port: selectedPort,
|
||||
gatewayPort,
|
||||
});
|
||||
|
||||
const summary: UpdateWebRuntimeSummary = {
|
||||
profile,
|
||||
webPort: selectedPort,
|
||||
version: VERSION,
|
||||
majorGate: {
|
||||
required: transition.isMajorTransition,
|
||||
previousVersion: previousManifest?.deployedDenchVersion,
|
||||
currentVersion: VERSION,
|
||||
},
|
||||
stoppedPids: stopResult.stoppedPids,
|
||||
skippedForeignPids: stopResult.skippedForeignPids,
|
||||
ready: ensureResult.ready,
|
||||
reason: ensureResult.reason,
|
||||
};
|
||||
|
||||
if (!opts.json) {
|
||||
runtime.log("");
|
||||
runtime.log(theme.heading("Dench web update"));
|
||||
runtime.log(`Profile: ${profile}`);
|
||||
runtime.log(`Version: ${VERSION}`);
|
||||
runtime.log(`Web port: ${selectedPort}`);
|
||||
runtime.log(`Stopped web processes: ${summary.stoppedPids.length}`);
|
||||
if (summary.skippedForeignPids.length > 0) {
|
||||
runtime.log(
|
||||
theme.warn(
|
||||
`Skipped non-Dench listeners on ${selectedPort}: ${summary.skippedForeignPids.join(", ")}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
runtime.log(`Web runtime: ${summary.ready ? "ready" : "not ready"}`);
|
||||
if (!summary.ready) {
|
||||
runtime.log(theme.warn(summary.reason));
|
||||
}
|
||||
}
|
||||
|
||||
if (!summary.ready) {
|
||||
throw new Error(`Web runtime update failed: ${summary.reason}`);
|
||||
}
|
||||
|
||||
if (opts.json) {
|
||||
runtime.log(JSON.stringify(summary, null, 2));
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
export async function stopWebRuntimeCommand(
|
||||
opts: StopWebRuntimeOptions,
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
): Promise<StopWebRuntimeSummary> {
|
||||
const appliedProfile = applyCliProfileEnv({ profile: opts.profile });
|
||||
const profile = appliedProfile.effectiveProfile;
|
||||
if (appliedProfile.warning && !opts.json) {
|
||||
runtime.log(theme.warn(appliedProfile.warning));
|
||||
}
|
||||
|
||||
const stateDir = resolveProfileStateDir(profile);
|
||||
const selectedPort = parseOptionalPort(opts.webPort) ?? readLastKnownWebPort(stateDir);
|
||||
const stopResult = await stopManagedWebRuntime({
|
||||
stateDir,
|
||||
port: selectedPort,
|
||||
includeLegacyStandalone: true,
|
||||
});
|
||||
|
||||
const summary: StopWebRuntimeSummary = {
|
||||
profile,
|
||||
webPort: selectedPort,
|
||||
stoppedPids: stopResult.stoppedPids,
|
||||
skippedForeignPids: stopResult.skippedForeignPids,
|
||||
};
|
||||
|
||||
if (opts.json) {
|
||||
runtime.log(JSON.stringify(summary, null, 2));
|
||||
return summary;
|
||||
}
|
||||
|
||||
runtime.log("");
|
||||
runtime.log(theme.heading("Dench web stop"));
|
||||
runtime.log(`Profile: ${profile}`);
|
||||
runtime.log(`Web port: ${selectedPort}`);
|
||||
runtime.log(
|
||||
summary.stoppedPids.length > 0
|
||||
? `Stopped web processes: ${summary.stoppedPids.join(", ")}`
|
||||
: "Stopped web processes: none",
|
||||
);
|
||||
if (summary.skippedForeignPids.length > 0) {
|
||||
runtime.log(
|
||||
theme.warn(
|
||||
`Left non-Dench listener(s) running on ${selectedPort}: ${summary.skippedForeignPids.join(", ")}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
export async function startWebRuntimeCommand(
|
||||
opts: StartWebRuntimeOptions,
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
): Promise<StartWebRuntimeSummary> {
|
||||
const appliedProfile = applyCliProfileEnv({ profile: opts.profile });
|
||||
const profile = appliedProfile.effectiveProfile;
|
||||
if (appliedProfile.warning && !opts.json) {
|
||||
runtime.log(theme.warn(appliedProfile.warning));
|
||||
}
|
||||
|
||||
const stateDir = resolveProfileStateDir(profile);
|
||||
const selectedPort = parseOptionalPort(opts.webPort) ?? readLastKnownWebPort(stateDir);
|
||||
const gatewayPort = resolveGatewayPort(stateDir);
|
||||
|
||||
const stopResult = await stopManagedWebRuntime({
|
||||
stateDir,
|
||||
port: selectedPort,
|
||||
includeLegacyStandalone: true,
|
||||
});
|
||||
|
||||
if (stopResult.skippedForeignPids.length > 0) {
|
||||
throw new Error(
|
||||
`Cannot start on ${selectedPort}; non-Dench listener(s) still own the port: ${stopResult.skippedForeignPids.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
const startResult = startManagedWebRuntime({
|
||||
stateDir,
|
||||
port: selectedPort,
|
||||
gatewayPort,
|
||||
});
|
||||
|
||||
if (!startResult.started) {
|
||||
const runtimeServerPath = resolveManagedWebRuntimeServerPath(stateDir);
|
||||
throw new Error(
|
||||
[
|
||||
`Managed web runtime is missing at ${runtimeServerPath}.`,
|
||||
"Run `dench update` (or `dench bootstrap`) to install/update the web runtime first.",
|
||||
].join(" "),
|
||||
);
|
||||
}
|
||||
|
||||
const probe = await waitForWebRuntime(selectedPort);
|
||||
const summary: StartWebRuntimeSummary = {
|
||||
profile,
|
||||
webPort: selectedPort,
|
||||
stoppedPids: stopResult.stoppedPids,
|
||||
skippedForeignPids: stopResult.skippedForeignPids,
|
||||
started: probe.ok,
|
||||
reason: probe.reason,
|
||||
};
|
||||
|
||||
if (opts.json) {
|
||||
runtime.log(JSON.stringify(summary, null, 2));
|
||||
return summary;
|
||||
}
|
||||
|
||||
runtime.log("");
|
||||
runtime.log(theme.heading("Dench web start"));
|
||||
runtime.log(`Profile: ${profile}`);
|
||||
runtime.log(`Web port: ${selectedPort}`);
|
||||
runtime.log(`Restarted managed web runtime: ${summary.started ? "yes" : "no"}`);
|
||||
if (!summary.started) {
|
||||
runtime.log(theme.warn(summary.reason));
|
||||
}
|
||||
|
||||
if (!summary.started) {
|
||||
throw new Error(`Web runtime failed readiness probe: ${summary.reason}`);
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
export async function verifyOpenClawHealthForUpdate(profile: string): Promise<void> {
|
||||
const openclawCommand = resolveOpenClawCommandOrThrow();
|
||||
const result = await runOpenClawCommand({
|
||||
openclawCommand,
|
||||
args: ["--profile", profile, "health", "--json"],
|
||||
timeoutMs: 12_000,
|
||||
});
|
||||
if (result.code === 0) {
|
||||
return;
|
||||
}
|
||||
const detail = firstNonEmptyLine(result.stderr, result.stdout);
|
||||
throw new Error(
|
||||
detail ? `Gateway health check failed.\n${detail}` : "Gateway health check failed.",
|
||||
);
|
||||
}
|
||||
81
src/cli/web-runtime.test.ts
Normal file
81
src/cli/web-runtime.test.ts
Normal file
@ -0,0 +1,81 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
classifyWebPortListener,
|
||||
evaluateMajorVersionTransition,
|
||||
evaluateWebProfilesPayload,
|
||||
} from "./web-runtime.js";
|
||||
|
||||
describe("evaluateWebProfilesPayload", () => {
|
||||
it("accepts nullable active profile when profiles payload shape is valid (prevents first-run false negatives)", () => {
|
||||
const result = evaluateWebProfilesPayload({
|
||||
profiles: [],
|
||||
activeProfile: null,
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts workspace compatibility fields when profile aliases are missing (preserves API compatibility)", () => {
|
||||
const result = evaluateWebProfilesPayload({
|
||||
workspaces: [{ name: "default" }],
|
||||
activeWorkspace: "default",
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects payloads that omit active profile/workspace state (guards readiness contract)", () => {
|
||||
const result = evaluateWebProfilesPayload({
|
||||
profiles: [],
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.reason).toContain("active profile/workspace");
|
||||
});
|
||||
});
|
||||
|
||||
describe("classifyWebPortListener", () => {
|
||||
it("classifies listeners under managed runtime dir as managed ownership (prevents cross-process kills)", () => {
|
||||
const managedRuntimeAppDir = "/Users/test/.openclaw-dench/web-runtime/app";
|
||||
const ownership = classifyWebPortListener({
|
||||
cwd: "/Users/test/.openclaw-dench/web-runtime/app",
|
||||
managedRuntimeAppDir,
|
||||
});
|
||||
expect(ownership).toBe("managed");
|
||||
});
|
||||
|
||||
it("classifies legacy standalone cwd as dench-owned legacy runtime (supports old bootstrap cleanup)", () => {
|
||||
const ownership = classifyWebPortListener({
|
||||
cwd: "/Users/test/projects/ironclaw/apps/web/.next/standalone/apps/web",
|
||||
managedRuntimeAppDir: "/Users/test/.openclaw-dench/web-runtime/app",
|
||||
});
|
||||
expect(ownership).toBe("legacy-standalone");
|
||||
});
|
||||
|
||||
it("classifies unknown cwd as foreign ownership (enforces process boundary safety)", () => {
|
||||
const ownership = classifyWebPortListener({
|
||||
cwd: "/Applications/OtherApp/runtime",
|
||||
managedRuntimeAppDir: "/Users/test/.openclaw-dench/web-runtime/app",
|
||||
});
|
||||
expect(ownership).toBe("foreign");
|
||||
});
|
||||
});
|
||||
|
||||
describe("evaluateMajorVersionTransition", () => {
|
||||
it("detects major changes across semver values (enforces mandatory upgrade gate)", () => {
|
||||
const result = evaluateMajorVersionTransition({
|
||||
previousVersion: "2.9.0",
|
||||
currentVersion: "3.0.1",
|
||||
});
|
||||
expect(result.isMajorTransition).toBe(true);
|
||||
expect(result.previousMajor).toBe(2);
|
||||
expect(result.currentMajor).toBe(3);
|
||||
});
|
||||
|
||||
it("treats prerelease-to-minor within same major as non-major transition (avoids unnecessary blocking)", () => {
|
||||
const result = evaluateMajorVersionTransition({
|
||||
previousVersion: "2.0.0-1",
|
||||
currentVersion: "2.1.0",
|
||||
});
|
||||
expect(result.isMajorTransition).toBe(false);
|
||||
expect(result.previousMajor).toBe(2);
|
||||
expect(result.currentMajor).toBe(2);
|
||||
});
|
||||
});
|
||||
738
src/cli/web-runtime.ts
Normal file
738
src/cli/web-runtime.ts
Normal file
@ -0,0 +1,738 @@
|
||||
import { spawn, execFileSync } from "node:child_process";
|
||||
import {
|
||||
cpSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
openSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { resolveRequiredHomeDir } from "../infra/home-dir.js";
|
||||
import { resolveLsofCommandSync } from "../infra/ports-lsof.js";
|
||||
import { sleep } from "../utils.js";
|
||||
import { listPortListeners, type PortProcess } from "./ports.js";
|
||||
|
||||
export const DEFAULT_WEB_APP_PORT = 3100;
|
||||
const WEB_RUNTIME_DIRNAME = "web-runtime";
|
||||
const WEB_RUNTIME_APP_DIRNAME = "app";
|
||||
const WEB_RUNTIME_MANIFEST_FILENAME = "manifest.json";
|
||||
const WEB_RUNTIME_PROCESS_FILENAME = "process.json";
|
||||
const WEB_APP_PROBE_ATTEMPTS = 20;
|
||||
const WEB_APP_PROBE_DELAY_MS = 750;
|
||||
const WEB_APP_PROBE_TIMEOUT_MS = 1_500;
|
||||
const LEGACY_STANDALONE_SEGMENT = "/apps/web/.next/standalone/apps/web";
|
||||
|
||||
export type WebProbeResult = {
|
||||
ok: boolean;
|
||||
status?: number;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export type WebProfilesPayloadEvaluation = {
|
||||
ok: boolean;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export type ManagedWebRuntimeManifest = {
|
||||
schemaVersion: 1;
|
||||
deployedDenchVersion: string;
|
||||
deployedAt: string;
|
||||
sourceStandaloneServer: string;
|
||||
lastPort?: number;
|
||||
lastGatewayPort?: number;
|
||||
};
|
||||
|
||||
export type ManagedWebRuntimeProcess = {
|
||||
pid: number;
|
||||
port: number;
|
||||
gatewayPort: number;
|
||||
startedAt: string;
|
||||
runtimeAppDir: string;
|
||||
};
|
||||
|
||||
export type InstallManagedWebRuntimeResult =
|
||||
| {
|
||||
installed: true;
|
||||
runtimeDir: string;
|
||||
runtimeAppDir: string;
|
||||
runtimeServerPath: string;
|
||||
manifest: ManagedWebRuntimeManifest;
|
||||
}
|
||||
| {
|
||||
installed: false;
|
||||
runtimeDir: string;
|
||||
runtimeAppDir: string;
|
||||
runtimeServerPath: string;
|
||||
reason: "standalone-missing";
|
||||
};
|
||||
|
||||
export type StartManagedWebRuntimeResult =
|
||||
| {
|
||||
started: true;
|
||||
pid: number;
|
||||
runtimeServerPath: string;
|
||||
}
|
||||
| {
|
||||
started: false;
|
||||
runtimeServerPath: string;
|
||||
reason: "runtime-missing";
|
||||
};
|
||||
|
||||
export type WebPortListenerOwnership = "managed" | "legacy-standalone" | "foreign";
|
||||
|
||||
export type WebPortListener = PortProcess & {
|
||||
cwd?: string;
|
||||
ownership: WebPortListenerOwnership;
|
||||
};
|
||||
|
||||
export type StopManagedWebRuntimeResult = {
|
||||
port: number;
|
||||
stoppedPids: number[];
|
||||
skippedForeignPids: number[];
|
||||
};
|
||||
|
||||
export type MajorVersionTransition = {
|
||||
previousMajor: number | null;
|
||||
currentMajor: number | null;
|
||||
isMajorTransition: boolean;
|
||||
};
|
||||
|
||||
function normalizePathForMatch(value: string): string {
|
||||
return value.replaceAll("\\", "/").toLowerCase();
|
||||
}
|
||||
|
||||
function isPathWithin(parentDir: string, candidatePath: string): boolean {
|
||||
const relative = path.relative(path.resolve(parentDir), path.resolve(candidatePath));
|
||||
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
function parseOptionalPositiveInt(value: string | number | undefined): number | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = typeof value === "number" ? value : Number.parseInt(String(value), 10);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function ensureParentDir(filePath: string): void {
|
||||
mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
}
|
||||
|
||||
function readJsonFile<T>(filePath: string): T | null {
|
||||
if (!existsSync(filePath)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(readFileSync(filePath, "utf-8")) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeJsonFile(filePath: string, value: unknown): void {
|
||||
ensureParentDir(filePath);
|
||||
writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf-8");
|
||||
}
|
||||
|
||||
function resolveCommandForPlatform(command: string): string {
|
||||
if (process.platform !== "win32") {
|
||||
return command;
|
||||
}
|
||||
if (path.extname(command)) {
|
||||
return command;
|
||||
}
|
||||
const normalized = path.basename(command).toLowerCase();
|
||||
if (
|
||||
normalized === "npm" ||
|
||||
normalized === "pnpm" ||
|
||||
normalized === "npx" ||
|
||||
normalized === "yarn"
|
||||
) {
|
||||
return `${command}.cmd`;
|
||||
}
|
||||
return command;
|
||||
}
|
||||
|
||||
function isProcessAlive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
const err = error as NodeJS.ErrnoException;
|
||||
return err.code !== "ESRCH";
|
||||
}
|
||||
}
|
||||
|
||||
async function terminatePidWithEscalation(pid: number): Promise<void> {
|
||||
try {
|
||||
process.kill(pid, "SIGTERM");
|
||||
} catch (error) {
|
||||
const err = error as NodeJS.ErrnoException;
|
||||
if (err.code === "ESRCH") {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
for (let i = 0; i < 8; i += 1) {
|
||||
if (!isProcessAlive(pid)) {
|
||||
return;
|
||||
}
|
||||
await sleep(100);
|
||||
}
|
||||
|
||||
if (!isProcessAlive(pid)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
process.kill(pid, "SIGKILL");
|
||||
} catch (error) {
|
||||
const err = error as NodeJS.ErrnoException;
|
||||
if (err.code === "ESRCH") {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
for (let i = 0; i < 8; i += 1) {
|
||||
if (!isProcessAlive(pid)) {
|
||||
return;
|
||||
}
|
||||
await sleep(100);
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveCliPackageRoot(): string {
|
||||
let dir = path.dirname(fileURLToPath(import.meta.url));
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
if (existsSync(path.join(dir, "package.json"))) {
|
||||
return dir;
|
||||
}
|
||||
dir = path.dirname(dir);
|
||||
}
|
||||
return process.cwd();
|
||||
}
|
||||
|
||||
export function resolveProfileStateDir(
|
||||
profile: string,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): string {
|
||||
void profile;
|
||||
const home = resolveRequiredHomeDir(env, os.homedir);
|
||||
return path.join(home, ".openclaw-dench");
|
||||
}
|
||||
|
||||
export function resolveManagedWebRuntimeDir(stateDir: string): string {
|
||||
return path.join(stateDir, WEB_RUNTIME_DIRNAME);
|
||||
}
|
||||
|
||||
export function resolveManagedWebRuntimeAppDir(stateDir: string): string {
|
||||
return path.join(resolveManagedWebRuntimeDir(stateDir), WEB_RUNTIME_APP_DIRNAME);
|
||||
}
|
||||
|
||||
export function resolveManagedWebRuntimeServerPath(stateDir: string): string {
|
||||
return path.join(resolveManagedWebRuntimeAppDir(stateDir), "server.js");
|
||||
}
|
||||
|
||||
export function resolveManagedWebRuntimeManifestPath(stateDir: string): string {
|
||||
return path.join(resolveManagedWebRuntimeDir(stateDir), WEB_RUNTIME_MANIFEST_FILENAME);
|
||||
}
|
||||
|
||||
export function resolveManagedWebRuntimeProcessPath(stateDir: string): string {
|
||||
return path.join(resolveManagedWebRuntimeDir(stateDir), WEB_RUNTIME_PROCESS_FILENAME);
|
||||
}
|
||||
|
||||
export function resolvePackagedStandaloneServerPath(packageRoot: string): string {
|
||||
return path.join(packageRoot, "apps/web/.next/standalone/apps/web/server.js");
|
||||
}
|
||||
|
||||
function resolvePackagedStandaloneAppDir(packageRoot: string): string {
|
||||
return path.dirname(resolvePackagedStandaloneServerPath(packageRoot));
|
||||
}
|
||||
|
||||
export function readManagedWebRuntimeManifest(stateDir: string): ManagedWebRuntimeManifest | null {
|
||||
return readJsonFile<ManagedWebRuntimeManifest>(resolveManagedWebRuntimeManifestPath(stateDir));
|
||||
}
|
||||
|
||||
export function readManagedWebRuntimeProcess(stateDir: string): ManagedWebRuntimeProcess | null {
|
||||
return readJsonFile<ManagedWebRuntimeProcess>(resolveManagedWebRuntimeProcessPath(stateDir));
|
||||
}
|
||||
|
||||
function writeManagedWebRuntimeManifest(
|
||||
stateDir: string,
|
||||
manifest: ManagedWebRuntimeManifest,
|
||||
): ManagedWebRuntimeManifest {
|
||||
writeJsonFile(resolveManagedWebRuntimeManifestPath(stateDir), manifest);
|
||||
return manifest;
|
||||
}
|
||||
|
||||
function writeManagedWebRuntimeProcess(
|
||||
stateDir: string,
|
||||
processMeta: ManagedWebRuntimeProcess,
|
||||
): void {
|
||||
writeJsonFile(resolveManagedWebRuntimeProcessPath(stateDir), processMeta);
|
||||
}
|
||||
|
||||
function clearManagedWebRuntimeProcess(stateDir: string): void {
|
||||
rmSync(resolveManagedWebRuntimeProcessPath(stateDir), { force: true });
|
||||
}
|
||||
|
||||
function updateManifestLastPort(
|
||||
stateDir: string,
|
||||
webPort: number,
|
||||
gatewayPort: number,
|
||||
): ManagedWebRuntimeManifest | null {
|
||||
const manifest = readManagedWebRuntimeManifest(stateDir);
|
||||
if (!manifest) {
|
||||
return null;
|
||||
}
|
||||
const nextManifest: ManagedWebRuntimeManifest = {
|
||||
...manifest,
|
||||
lastPort: webPort,
|
||||
lastGatewayPort: gatewayPort,
|
||||
};
|
||||
return writeManagedWebRuntimeManifest(stateDir, nextManifest);
|
||||
}
|
||||
|
||||
export function evaluateWebProfilesPayload(payload: unknown): WebProfilesPayloadEvaluation {
|
||||
if (!payload || typeof payload !== "object") {
|
||||
return { ok: false, reason: "response payload is not an object" };
|
||||
}
|
||||
|
||||
const data = payload as Record<string, unknown>;
|
||||
const profiles = Array.isArray(data.profiles)
|
||||
? data.profiles
|
||||
: Array.isArray(data.workspaces)
|
||||
? data.workspaces
|
||||
: undefined;
|
||||
if (!profiles) {
|
||||
return { ok: false, reason: "response payload missing profiles/workspaces array" };
|
||||
}
|
||||
|
||||
const active =
|
||||
data.activeProfile === null || typeof data.activeProfile === "string"
|
||||
? data.activeProfile
|
||||
: data.activeWorkspace === null || typeof data.activeWorkspace === "string"
|
||||
? data.activeWorkspace
|
||||
: undefined;
|
||||
|
||||
if (active === undefined) {
|
||||
return { ok: false, reason: "response payload missing active profile/workspace field" };
|
||||
}
|
||||
|
||||
return { ok: true, reason: "profiles payload shape is valid" };
|
||||
}
|
||||
|
||||
export async function probeWebRuntime(port: number): Promise<WebProbeResult> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), WEB_APP_PROBE_TIMEOUT_MS);
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/api/profiles`, {
|
||||
method: "GET",
|
||||
signal: controller.signal,
|
||||
redirect: "manual",
|
||||
});
|
||||
if (response.status < 200 || response.status >= 400) {
|
||||
return {
|
||||
ok: false,
|
||||
status: response.status,
|
||||
reason: `/api/profiles returned status ${response.status}`,
|
||||
};
|
||||
}
|
||||
const payload = await response.json().catch(() => null);
|
||||
const evaluation = evaluateWebProfilesPayload(payload);
|
||||
return {
|
||||
ok: evaluation.ok,
|
||||
status: response.status,
|
||||
reason: evaluation.reason,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
ok: false,
|
||||
reason: message || "probe failed",
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitForWebRuntime(port: number): Promise<WebProbeResult> {
|
||||
let lastResult: WebProbeResult = { ok: false, reason: "web runtime did not respond" };
|
||||
for (let attempt = 0; attempt < WEB_APP_PROBE_ATTEMPTS; attempt += 1) {
|
||||
const result = await probeWebRuntime(port);
|
||||
if (result.ok) {
|
||||
return result;
|
||||
}
|
||||
lastResult = result;
|
||||
await sleep(WEB_APP_PROBE_DELAY_MS);
|
||||
}
|
||||
return lastResult;
|
||||
}
|
||||
|
||||
export function classifyWebPortListener(params: {
|
||||
cwd: string | undefined;
|
||||
managedRuntimeAppDir: string;
|
||||
}): WebPortListenerOwnership {
|
||||
if (!params.cwd) {
|
||||
return "foreign";
|
||||
}
|
||||
if (isPathWithin(params.managedRuntimeAppDir, params.cwd)) {
|
||||
return "managed";
|
||||
}
|
||||
const cwdNormalized = normalizePathForMatch(params.cwd);
|
||||
if (cwdNormalized.includes(LEGACY_STANDALONE_SEGMENT)) {
|
||||
return "legacy-standalone";
|
||||
}
|
||||
return "foreign";
|
||||
}
|
||||
|
||||
export function parseSemverMajor(version: string | undefined): number | null {
|
||||
if (!version) {
|
||||
return null;
|
||||
}
|
||||
const match = version.trim().match(/^v?(\d+)(?:\.\d+)?(?:\.\d+)?(?:[-+].*)?$/u);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const major = Number.parseInt(match[1], 10);
|
||||
if (!Number.isFinite(major)) {
|
||||
return null;
|
||||
}
|
||||
return major;
|
||||
}
|
||||
|
||||
export function evaluateMajorVersionTransition(params: {
|
||||
previousVersion: string | undefined;
|
||||
currentVersion: string | undefined;
|
||||
}): MajorVersionTransition {
|
||||
const previousMajor = parseSemverMajor(params.previousVersion);
|
||||
const currentMajor = parseSemverMajor(params.currentVersion);
|
||||
return {
|
||||
previousMajor,
|
||||
currentMajor,
|
||||
isMajorTransition:
|
||||
previousMajor !== null && currentMajor !== null && previousMajor !== currentMajor,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveProcessCwd(pid: number): string | undefined {
|
||||
try {
|
||||
const lsof = resolveLsofCommandSync();
|
||||
const output = execFileSync(lsof, ["-nP", "-a", "-p", String(pid), "-d", "cwd", "-Fn"], {
|
||||
encoding: "utf-8",
|
||||
});
|
||||
for (const line of output.split(/\r?\n/)) {
|
||||
if (line.startsWith("n")) {
|
||||
const cwd = line.slice(1).trim();
|
||||
if (cwd.length > 0) {
|
||||
return cwd;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function inspectWebPortListeners(port: number, stateDir: string): WebPortListener[] {
|
||||
if (process.env.VITEST === "true" && process.env.OPENCLAW_TEST_REAL_PORTS !== "1") {
|
||||
return [];
|
||||
}
|
||||
const listeners = listPortListeners(port);
|
||||
const managedRuntimeAppDir = resolveManagedWebRuntimeAppDir(stateDir);
|
||||
return listeners.map((listener) => {
|
||||
const cwd = resolveProcessCwd(listener.pid);
|
||||
const ownership = classifyWebPortListener({
|
||||
cwd,
|
||||
managedRuntimeAppDir,
|
||||
});
|
||||
return {
|
||||
...listener,
|
||||
cwd,
|
||||
ownership,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function readLastKnownWebPort(stateDir: string): number {
|
||||
const processMeta = readManagedWebRuntimeProcess(stateDir);
|
||||
const processPort = parseOptionalPositiveInt(processMeta?.port);
|
||||
if (processPort) {
|
||||
return processPort;
|
||||
}
|
||||
const manifest = readManagedWebRuntimeManifest(stateDir);
|
||||
const manifestPort = parseOptionalPositiveInt(manifest?.lastPort);
|
||||
if (manifestPort) {
|
||||
return manifestPort;
|
||||
}
|
||||
return DEFAULT_WEB_APP_PORT;
|
||||
}
|
||||
|
||||
export function installManagedWebRuntime(params: {
|
||||
stateDir: string;
|
||||
packageRoot: string;
|
||||
denchVersion: string;
|
||||
webPort?: number;
|
||||
gatewayPort?: number;
|
||||
}): InstallManagedWebRuntimeResult {
|
||||
const runtimeDir = resolveManagedWebRuntimeDir(params.stateDir);
|
||||
const runtimeAppDir = resolveManagedWebRuntimeAppDir(params.stateDir);
|
||||
const runtimeServerPath = resolveManagedWebRuntimeServerPath(params.stateDir);
|
||||
const sourceStandaloneServer = resolvePackagedStandaloneServerPath(params.packageRoot);
|
||||
const sourceAppDir = resolvePackagedStandaloneAppDir(params.packageRoot);
|
||||
if (!existsSync(sourceStandaloneServer)) {
|
||||
return {
|
||||
installed: false,
|
||||
runtimeDir,
|
||||
runtimeAppDir,
|
||||
runtimeServerPath,
|
||||
reason: "standalone-missing",
|
||||
};
|
||||
}
|
||||
|
||||
mkdirSync(runtimeDir, { recursive: true });
|
||||
rmSync(runtimeAppDir, { recursive: true, force: true });
|
||||
cpSync(sourceAppDir, runtimeAppDir, { recursive: true, force: true });
|
||||
|
||||
const manifest: ManagedWebRuntimeManifest = {
|
||||
schemaVersion: 1,
|
||||
deployedDenchVersion: params.denchVersion,
|
||||
deployedAt: new Date().toISOString(),
|
||||
sourceStandaloneServer,
|
||||
...(typeof params.webPort === "number" ? { lastPort: params.webPort } : {}),
|
||||
...(typeof params.gatewayPort === "number" ? { lastGatewayPort: params.gatewayPort } : {}),
|
||||
};
|
||||
writeManagedWebRuntimeManifest(params.stateDir, manifest);
|
||||
|
||||
return {
|
||||
installed: true,
|
||||
runtimeDir,
|
||||
runtimeAppDir,
|
||||
runtimeServerPath,
|
||||
manifest,
|
||||
};
|
||||
}
|
||||
|
||||
export async function stopManagedWebRuntime(params: {
|
||||
stateDir: string;
|
||||
port: number;
|
||||
includeLegacyStandalone?: boolean;
|
||||
}): Promise<StopManagedWebRuntimeResult> {
|
||||
const listeners = inspectWebPortListeners(params.port, params.stateDir);
|
||||
const includeLegacyStandalone = params.includeLegacyStandalone ?? true;
|
||||
const stoppable = listeners.filter(
|
||||
(listener) =>
|
||||
listener.ownership === "managed" ||
|
||||
(includeLegacyStandalone && listener.ownership === "legacy-standalone"),
|
||||
);
|
||||
const skippedForeign = listeners.filter((listener) => listener.ownership === "foreign");
|
||||
|
||||
const uniquePids = [...new Set(stoppable.map((listener) => listener.pid))];
|
||||
const stoppedPids: number[] = [];
|
||||
for (const pid of uniquePids) {
|
||||
await terminatePidWithEscalation(pid);
|
||||
if (!isProcessAlive(pid)) {
|
||||
stoppedPids.push(pid);
|
||||
}
|
||||
}
|
||||
|
||||
const processMeta = readManagedWebRuntimeProcess(params.stateDir);
|
||||
if (processMeta?.port === params.port && stoppedPids.includes(processMeta.pid)) {
|
||||
clearManagedWebRuntimeProcess(params.stateDir);
|
||||
}
|
||||
|
||||
const remainingManaged = inspectWebPortListeners(params.port, params.stateDir).filter(
|
||||
(listener) => listener.ownership === "managed",
|
||||
);
|
||||
if (remainingManaged.length === 0) {
|
||||
clearManagedWebRuntimeProcess(params.stateDir);
|
||||
}
|
||||
|
||||
return {
|
||||
port: params.port,
|
||||
stoppedPids,
|
||||
skippedForeignPids: [...new Set(skippedForeign.map((listener) => listener.pid))],
|
||||
};
|
||||
}
|
||||
|
||||
export function startManagedWebRuntime(params: {
|
||||
stateDir: string;
|
||||
port: number;
|
||||
gatewayPort: number;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): StartManagedWebRuntimeResult {
|
||||
const runtimeServerPath = resolveManagedWebRuntimeServerPath(params.stateDir);
|
||||
if (!existsSync(runtimeServerPath)) {
|
||||
return { started: false, runtimeServerPath, reason: "runtime-missing" };
|
||||
}
|
||||
|
||||
const logsDir = path.join(params.stateDir, "logs");
|
||||
mkdirSync(logsDir, { recursive: true });
|
||||
const outFd = openSync(path.join(logsDir, "web-app.log"), "a");
|
||||
const errFd = openSync(path.join(logsDir, "web-app.err.log"), "a");
|
||||
|
||||
const child = spawn(process.execPath, [runtimeServerPath], {
|
||||
cwd: path.dirname(runtimeServerPath),
|
||||
detached: true,
|
||||
stdio: ["ignore", outFd, errFd],
|
||||
env: {
|
||||
...process.env,
|
||||
...params.env,
|
||||
PORT: String(params.port),
|
||||
HOSTNAME: "127.0.0.1",
|
||||
OPENCLAW_GATEWAY_PORT: String(params.gatewayPort),
|
||||
},
|
||||
});
|
||||
child.unref();
|
||||
|
||||
writeManagedWebRuntimeProcess(params.stateDir, {
|
||||
pid: child.pid ?? -1,
|
||||
port: params.port,
|
||||
gatewayPort: params.gatewayPort,
|
||||
startedAt: new Date().toISOString(),
|
||||
runtimeAppDir: path.dirname(runtimeServerPath),
|
||||
});
|
||||
updateManifestLastPort(params.stateDir, params.port, params.gatewayPort);
|
||||
|
||||
return {
|
||||
started: true,
|
||||
pid: child.pid ?? -1,
|
||||
runtimeServerPath,
|
||||
};
|
||||
}
|
||||
|
||||
export async function ensureManagedWebRuntime(params: {
|
||||
stateDir: string;
|
||||
packageRoot: string;
|
||||
denchVersion: string;
|
||||
port: number;
|
||||
gatewayPort: number;
|
||||
}): Promise<{ ready: boolean; reason: string }> {
|
||||
const install = installManagedWebRuntime({
|
||||
stateDir: params.stateDir,
|
||||
packageRoot: params.packageRoot,
|
||||
denchVersion: params.denchVersion,
|
||||
webPort: params.port,
|
||||
gatewayPort: params.gatewayPort,
|
||||
});
|
||||
if (!install.installed) {
|
||||
return { ready: false, reason: "standalone web build is missing from package" };
|
||||
}
|
||||
|
||||
await stopManagedWebRuntime({
|
||||
stateDir: params.stateDir,
|
||||
port: params.port,
|
||||
includeLegacyStandalone: true,
|
||||
});
|
||||
|
||||
const listenersAfterStop = inspectWebPortListeners(params.port, params.stateDir);
|
||||
const foreign = listenersAfterStop.filter((listener) => listener.ownership === "foreign");
|
||||
if (foreign.length > 0) {
|
||||
const detail = foreign
|
||||
.map((listener) => `${listener.pid}${listener.command ? `:${listener.command}` : ""}`)
|
||||
.join(", ");
|
||||
return {
|
||||
ready: false,
|
||||
reason: `port ${params.port} is owned by non-Dench process(es): ${detail}`,
|
||||
};
|
||||
}
|
||||
|
||||
const start = startManagedWebRuntime({
|
||||
stateDir: params.stateDir,
|
||||
port: params.port,
|
||||
gatewayPort: params.gatewayPort,
|
||||
});
|
||||
if (!start.started) {
|
||||
return {
|
||||
ready: false,
|
||||
reason: "managed web runtime is missing after install",
|
||||
};
|
||||
}
|
||||
|
||||
const probe = await waitForWebRuntime(params.port);
|
||||
return {
|
||||
ready: probe.ok,
|
||||
reason: probe.reason,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveOpenClawCommandOrThrow(): string {
|
||||
try {
|
||||
const locator = process.platform === "win32" ? "where" : "which";
|
||||
const output = execFileSync(locator, ["openclaw"], { encoding: "utf-8" }).trim();
|
||||
const first = output
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find(Boolean);
|
||||
if (first) {
|
||||
return first;
|
||||
}
|
||||
throw new Error("openclaw command not found");
|
||||
} catch {
|
||||
throw new Error(
|
||||
"Global `openclaw` CLI was not found on PATH. Install it with: npm install -g openclaw",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function runOpenClawCommand(params: {
|
||||
openclawCommand: string;
|
||||
args: string[];
|
||||
timeoutMs: number;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): Promise<{ code: number; stdout: string; stderr: string }> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const child = spawn(resolveCommandForPlatform(params.openclawCommand), params.args, {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env: params.env ? { ...process.env, ...params.env } : process.env,
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let settled = false;
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
child.kill("SIGKILL");
|
||||
}, params.timeoutMs);
|
||||
|
||||
child.stdout?.on("data", (chunk: Buffer | string) => {
|
||||
stdout += String(chunk);
|
||||
});
|
||||
child.stderr?.on("data", (chunk: Buffer | string) => {
|
||||
stderr += String(chunk);
|
||||
});
|
||||
child.once("error", (error) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
});
|
||||
child.once("close", (code) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve({
|
||||
code: typeof code === "number" ? code : 1,
|
||||
stdout,
|
||||
stderr,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user