refactor: unify macos setup through ansible

This commit is contained in:
Haitao Pan 2026-06-18 14:48:03 +08:00
parent 9351fdcc11
commit 8b558fab39

View File

@ -820,7 +820,7 @@ resolve_unified_auth_token() {
require_or_install_macos_cmds() {
local missing=()
for cmd in git node npm go curl lsof python3; do
for cmd in git node npm go curl lsof python3 ansible-playbook; do
if ! command -v "$cmd" >/dev/null 2>&1; then
missing+=("$cmd")
fi
@ -839,271 +839,12 @@ require_or_install_macos_cmds() {
go) brew install go ;;
python3) brew install python@3.13 ;;
curl) brew install curl ;;
ansible-playbook) brew install ansible ;;
lsof) error "lsof is part of macOS; it is missing from PATH." ;;
esac
done
}
macos_litellm_python() {
for py in python3.13 python3.12 python3.11; do
if command -v "$py" >/dev/null 2>&1; then
command -v "$py"
return
fi
done
if command -v brew >/dev/null 2>&1; then
info "Installing python@3.13 for LiteLLM compatibility..."
brew install python@3.13
command -v python3.13
return
fi
error "LiteLLM requires Python 3.11-3.13 on macOS. Install python3.13 or Homebrew."
}
macos_openclaw_bin() {
if command -v openclaw >/dev/null 2>&1; then
command -v openclaw
return
fi
local prefix="$HOME/.local/share/xworkspace/node"
info "Installing OpenClaw CLI locally under $prefix..."
mkdir -p "$prefix"
npm install --prefix "$prefix" openclaw@2026.6.1 @openclaw/codex@2026.6.1
printf '%s/bin/openclaw\n' "$prefix"
}
ensure_macos_openclaw_multi_session_plugin() {
local openclaw_bin=$1
local package_spec=${OPENCLAW_MULTI_SESSION_PLUGIN_PACKAGE_SPEC:-}
local plugin_dir=${OPENCLAW_MULTI_SESSION_PLUGIN_DIR:-}
local plugin_npm_dir="$HOME/.openclaw/npm"
if [ -n "$plugin_dir" ] && [ -d "$plugin_dir" ] && [ -f "$plugin_dir/openclaw.plugin.json" ]; then
info "Ensuring OpenClaw XWorkMate multi-session plugin is linked from ${plugin_dir} ..."
if [ -f "$plugin_dir/package.json" ] && [ -d "$plugin_dir/node_modules" ]; then
(cd "$plugin_dir" && npm run build --if-present)
fi
"$openclaw_bin" plugins install --link "$plugin_dir"
"$openclaw_bin" plugins enable openclaw-multi-session-plugins
"$openclaw_bin" plugins registry --refresh >/dev/null 2>&1 || true
return
fi
if [ -z "$package_spec" ]; then
return
fi
info "Ensuring OpenClaw XWorkMate multi-session plugin is installed from ${package_spec} ..."
mkdir -p "$plugin_npm_dir"
npm install --omit=dev --no-audit --no-fund --save-exact \
--prefix "$plugin_npm_dir" "$package_spec"
"$openclaw_bin" plugins enable openclaw-multi-session-plugins
"$openclaw_bin" plugins registry --refresh >/dev/null 2>&1 || true
}
macos_vault_bin() {
if command -v vault >/dev/null 2>&1; then
command -v vault
return
fi
if command -v brew >/dev/null 2>&1; then
info "Installing Vault CLI/server with Homebrew..."
brew install hashicorp/tap/vault || brew install vault
command -v vault
return
fi
error "Vault is required for local macOS deployment. Install vault or Homebrew."
}
macos_ttyd_bin() {
if command -v ttyd >/dev/null 2>&1; then
command -v ttyd
return
fi
if command -v brew >/dev/null 2>&1; then
info "Installing ttyd with Homebrew..."
brew install ttyd
command -v ttyd
return
fi
error "ttyd is required for the local Portal terminal. Install ttyd or Homebrew."
}
macos_xworkmate_bridge_bin() {
local bridge_dir="$HOME/.local/src/xworkmate-bridge"
info "Building XWorkmate Bridge locally under $bridge_dir..."
mkdir -p "$HOME/.local/src"
if [ ! -d "$bridge_dir/.git" ]; then
git clone "${XWORKMATE_BRIDGE_REPO_URL:-https://github.com/ai-workspace-lab/xworkmate-bridge.git}" "$bridge_dir" >&2
fi
(cd "$bridge_dir" && git fetch origin >&2 && git reset --quiet --hard "origin/${XWORKMATE_BRIDGE_BRANCH:-main}" >&2 && go build -o xworkmate-go-core)
echo "$bridge_dir/xworkmate-go-core"
}
macos_qmd_bin() {
local qmd_dir="$HOME/.local/src/qmd"
local qmd_bin="$qmd_dir/bin/qmd"
info "Building QMD locally under $qmd_dir..."
mkdir -p "$HOME/.local/src"
if [ ! -d "$qmd_dir/.git" ]; then
local qmd_repo_url="${QMD_SOURCE_REPO:-https://github.com/ai-workspace-lab/qmd.git}"
qmd_repo_url="${qmd_repo_url#file://}"
git clone "$qmd_repo_url" "$qmd_dir" >&2
fi
(cd "$qmd_dir" && npm install >&2 && npm run build >&2)
printf '%s\n' "$qmd_bin"
}
macos_hermes_bin() {
local hermes_dir="$HOME/.local/share/xworkspace/bin"
local hermes_bin="$hermes_dir/hermes"
if [ -x "$hermes_bin" ]; then
printf '%s\n' "$hermes_bin"
return
fi
info "Creating Hermes Python shim at $hermes_bin..."
mkdir -p "$hermes_dir"
cat << 'EOF' > "$hermes_bin"
#!/usr/bin/env python3
import json
import sys
import uuid
def respond(request, result=None, error=None):
payload = {"jsonrpc": "2.0", "id": request.get("id")}
if error is not None:
payload["error"] = {"code": -32000, "message": str(error)}
else:
payload["result"] = result if result is not None else {}
print(json.dumps(payload, separators=(",", ":")), flush=True)
for line in sys.stdin:
try:
request = json.loads(line)
except Exception:
continue
method = request.get("method")
if method == "initialize":
respond(request, {
"protocolVersion": 1,
"authMethods": [],
"agentCapabilities": {
"loadSession": True,
"promptCapabilities": {"embeddedContext": True, "image": False},
"sessionCapabilities": {"resume": {}, "fork": {}, "list": {}},
},
})
elif method == "session/new":
respond(request, {"sessionId": "hermes-shim-" + uuid.uuid4().hex})
elif method in ("session/prompt", "session/start", "session/message"):
params = request.get("params") or {}
prompt = params.get("prompt") or params.get("taskPrompt") or ""
text = "pong" if "pong" in str(prompt).lower() else "Hermes ACP shim is online."
respond(request, {"output": text, "text": text})
else:
respond(request, {"ok": True})
EOF
chmod +x "$hermes_bin"
printf '%s\n' "$hermes_bin"
}
install_macos_agent_clis() {
local prefix="$HOME/.local/share/xworkspace/node"
info "Installing local Agent CLIs (opencode-ai, gemini-cli, codex, claude) to $prefix..."
mkdir -p "$prefix"
npm install --prefix "$prefix" opencode-ai @google/gemini-cli @openai/codex @anthropic-ai/claude-code >/dev/null 2>&1 || {
warn "Failed to install NPM CLI tools. You may need to install them manually."
}
}
macos_postgres_tool() {
local tool=$1
if command -v "$tool" >/dev/null 2>&1; then
command -v "$tool"
return
fi
for base in /opt/homebrew/opt/postgresql@16/bin /usr/local/opt/postgresql@16/bin /opt/homebrew/opt/postgresql@15/bin /usr/local/opt/postgresql@15/bin /opt/homebrew/opt/postgresql@14/bin /usr/local/opt/postgresql@14/bin /opt/homebrew/bin /usr/local/bin; do
if [ -x "$base/$tool" ]; then
printf '%s/%s\n' "$base" "$tool"
return
fi
done
if command -v brew >/dev/null 2>&1; then
info "Installing PostgreSQL for LiteLLM local UI storage..."
brew install postgresql@16 || brew install postgresql
if command -v "$tool" >/dev/null 2>&1; then
command -v "$tool"
return
fi
for base in /opt/homebrew/opt/postgresql@16/bin /usr/local/opt/postgresql@16/bin /opt/homebrew/opt/postgresql/bin /usr/local/opt/postgresql/bin; do
if [ -x "$base/$tool" ]; then
printf '%s/%s\n' "$base" "$tool"
return
fi
done
fi
error "PostgreSQL tool '$tool' is required for local LiteLLM UI login."
}
resolve_console_dir() {
if [ -n "$XWORKSPACE_CONSOLE_DIR" ]; then
[ -d "$XWORKSPACE_CONSOLE_DIR/dashboard" ] && [ -d "$XWORKSPACE_CONSOLE_DIR/api" ] || \
error "XWORKSPACE_CONSOLE_DIR must contain dashboard/ and api/: $XWORKSPACE_CONSOLE_DIR"
cd "$XWORKSPACE_CONSOLE_DIR"
pwd
return
fi
local script_dir=""
if [ -n "${BASH_SOURCE[0]:-}" ]; then
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd -P || true)"
fi
if [ -n "$script_dir" ] && [ -d "$script_dir/../dashboard" ] && [ -d "$script_dir/../api" ]; then
cd "$script_dir/.."
pwd
return
fi
if [ -d "$PWD/dashboard" ] && [ -d "$PWD/api" ]; then
pwd
return
fi
local checkout_dir="${XWORKSPACE_CONSOLE_CHECKOUT_DIR:-$HOME/xworkspace-console}"
if [ -d "$checkout_dir/.git" ]; then
info "Updating xworkspace-console checkout at $checkout_dir..."
git -C "$checkout_dir" fetch origin >/dev/null 2>&1
git -C "$checkout_dir" reset --hard origin/main >/dev/null 2>&1
else
info "Cloning xworkspace-console to $checkout_dir..."
git clone "$XWORKSPACE_CONSOLE_REPO_URL" "$checkout_dir" >/dev/null 2>&1
fi
cd "$checkout_dir"
pwd
}
write_litellm_config() {
local config_file=$1
mkdir -p "$(dirname "$config_file")"
cat > "$config_file" <<'YAML'
model_list: []
general_settings:
master_key: "os.environ/LITELLM_MASTER_KEY"
database_url: "os.environ/DATABASE_URL"
store_model_in_db: true
drop_rate_limit_requests: true
router_settings:
routing_strategy: simple-shuffle
num_retries: 2
retry_after: 30
fallbacks: []
litellm_settings:
drop_params: true
set_verbose: false
request_timeout: 600
telemetry: false
YAML
}
ensure_secret_file() {
local file=$1
mkdir -p "$(dirname "$file")"
@ -1116,50 +857,6 @@ ensure_secret_file() {
cat "$file"
}
ensure_litellm_venv() {
local venv_dir=$1
local py_bin=$2
if [ ! -x "$venv_dir/bin/litellm" ]; then
info "Creating LiteLLM virtualenv at $venv_dir..."
rm -rf "$venv_dir"
"$py_bin" -m venv "$venv_dir"
"$venv_dir/bin/python" -m pip install --upgrade pip
if [ -d "/Users/shenlan/workspaces/ai-workspace-service/litellm" ]; then
info "Installing local LiteLLM from /Users/shenlan/workspaces/ai-workspace-service/litellm ..."
"$venv_dir/bin/python" -m pip install -e "/Users/shenlan/workspaces/ai-workspace-service/litellm[proxy,extra-proxy]"
else
"$venv_dir/bin/python" -m pip install 'litellm[proxy,extra-proxy]'
fi
return
fi
if [ -d "/Users/shenlan/workspaces/ai-workspace-service/litellm" ]; then
info "Ensuring local LiteLLM is installed and updated..."
"$venv_dir/bin/python" -m pip install -e "/Users/shenlan/workspaces/ai-workspace-service/litellm[proxy,extra-proxy]"
elif ! "$venv_dir/bin/python" -c 'import prisma' >/dev/null 2>&1; then
info "Adding LiteLLM database dependencies to existing virtualenv..."
"$venv_dir/bin/python" -m pip install 'litellm[extra-proxy]'
fi
}
ensure_litellm_prisma_client() {
local venv_dir=$1
local database_url=$2
local schema_file
schema_file="$("$venv_dir/bin/python" - <<'PY'
import importlib.util
import pathlib
spec = importlib.util.find_spec("litellm.proxy")
if spec is None or spec.origin is None:
raise SystemExit("Unable to locate litellm.proxy schema")
print(pathlib.Path(spec.origin).with_name("schema.prisma"))
PY
)"
info "Syncing LiteLLM Prisma client and database schema..."
PATH="$venv_dir/bin:$PATH" DATABASE_URL="$database_url" "$venv_dir/bin/prisma" db push --schema "$schema_file"
}
write_local_portal_config() {
local token=$1
local config_dir=$2
@ -1590,25 +1287,6 @@ prebuild_independent_runtimes() {
success "Runtime prebuild completed."
}
wait_for_url() {
local url=$1
local header=${2:-}
local attempts=120
local status
for _ in $(seq 1 "$attempts"); do
if [ -n "$header" ]; then
status="$(curl -sS -o /dev/null -w '%{http_code}' -H "$header" "$url" 2>/dev/null || true)"
else
status="$(curl -sS -o /dev/null -w '%{http_code}' "$url" 2>/dev/null || true)"
fi
case "$status" in
2*|3*|401|400) return 0 ;;
esac
sleep 0.5
done
error "Timed out waiting for $url"
}
wait_for_postgres() {
local pg_isready_bin=$1
local socket_dir=$2
@ -1738,23 +1416,6 @@ print_parallel_service_statuses() {
rm -rf "$status_dir"
}
cli_status_line() {
local label=$1
local bin=$2
local state="missing"
local detail="not in PATH"
if command -v "$bin" >/dev/null 2>&1; then
state="available"
detail="$("$bin" --version 2>/dev/null | head -n 1 || command -v "$bin")"
elif [ -x "$HOME/.local/share/xworkspace/node/bin/$bin" ]; then
state="available"
detail="$HOME/.local/share/xworkspace/node/bin/$bin"
fi
printf ' %-28s : %-9s (%s)\n' "$label" "$state" "$detail"
}
print_deployment_summary() {
local domain=${SERVER_DOMAIN:-${XWORKMATE_BRIDGE_DOMAIN:-${BRIDGE_DOMAIN:-${ACP_BRIDGE_DOMAIN:-xworkmate-bridge.svc.plus}}}}
local token=$1
@ -1814,340 +1475,6 @@ Save the one-time credentials above in a private location.
EOF
}
deploy_launch_agent() {
local label=$1
local workdir=$2
local command=$3
local stdout_log=$4
local stderr_log=$5
local plist_dir="$HOME/Library/LaunchAgents"
local plist="$plist_dir/$label.plist"
local domain
domain="gui/$(id -u)"
mkdir -p "$plist_dir" "$(dirname "$stdout_log")" "$(dirname "$stderr_log")"
cat > "$plist" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>$label</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>-lc</string>
<string>$command</string>
</array>
<key>WorkingDirectory</key>
<string>$workdir</string>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>$stdout_log</string>
<key>StandardErrorPath</key>
<string>$stderr_log</string>
</dict>
</plist>
EOF
launchctl bootout "$domain" "$plist" >/dev/null 2>&1 || true
sleep 0.2
launchctl bootstrap "$domain" "$plist" >/dev/null
launchctl kickstart -k "$domain/$label" >/dev/null
}
ensure_macos_litellm_database() {
local config_dir=$1
local state_dir=$2
local tool_path=$3
local pg_port="${AI_WORKSPACE_LITELLM_POSTGRES_PORT:-5432}"
local pg_data="$state_dir/postgres-data"
local pg_socket_dir="$state_dir/postgres-socket"
local db_name="litellm"
local db_user="litellm"
local db_password_file="$config_dir/litellm-db-password"
local db_password postgres_bin initdb_bin psql_bin pg_isready_bin
db_password="$(ensure_secret_file "$db_password_file")"
psql_bin="$(macos_postgres_tool psql)"
pg_isready_bin="$(macos_postgres_tool pg_isready)"
# MacOS 不支持 dockerMacOS 单机模式不适用自建的 plus.svc.xworkspace.postgres.plist
# 和独立的 $HOME/.local/share/xworkspace/postgres-data 数据目录。
# Linux VPS 支持 docker默认使用自建封装好的 PostgreSQL 部署逻辑。
# 因此这里直接调用原生 brew services start 来启动持久化后台服务。
if command -v brew >/dev/null 2>&1; then
info "Starting local PostgreSQL via Homebrew services..."
if brew list postgresql@16 >/dev/null 2>&1; then
brew services start postgresql@16 >/dev/null
else
brew services start postgresql >/dev/null
fi
else
error "Homebrew is required to start PostgreSQL on macOS"
fi
# Wait for postgres to be ready on 127.0.0.1:5432
local attempts=60
for _ in $(seq 1 "$attempts"); do
if "$pg_isready_bin" -h 127.0.0.1 -p "$pg_port" >/dev/null 2>&1; then
break
fi
sleep 0.5
done
"$psql_bin" -h 127.0.0.1 -p "$pg_port" -d postgres -v ON_ERROR_STOP=1 >/dev/null <<SQL
DO \$\$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '$db_user') THEN
CREATE ROLE "$db_user" LOGIN PASSWORD '$db_password';
ELSE
ALTER ROLE "$db_user" LOGIN PASSWORD '$db_password';
END IF;
END
\$\$;
SELECT format('CREATE DATABASE %I OWNER %I', '$db_name', '$db_user')
WHERE NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = '$db_name') \gexec
ALTER DATABASE "$db_name" OWNER TO "$db_user";
SQL
PGPASSWORD="$db_password" "$psql_bin" -h 127.0.0.1 -p "$pg_port" -U "$db_user" -d "$db_name" -v ON_ERROR_STOP=1 -Atc 'select 1' >/dev/null
printf 'postgresql://%s:%s@127.0.0.1:%s/%s?sslmode=disable\n' "$db_user" "$db_password" "$pg_port" "$db_name"
}
start_macos_target_services() {
local config_dir=$1
local state_dir=$2
local tool_path=$3
local litellm_py litellm_venv litellm_config litellm_bin openclaw_bin vault_bin ttyd_bin litellm_database_url
chmod 700 "$state_dir"
litellm_py="$(macos_litellm_python)"
litellm_venv="$HOME/.local/share/xworkspace/litellm-venv"
litellm_config="$config_dir/litellm-config.yaml"
ensure_litellm_venv "$litellm_venv" "$litellm_py"
litellm_database_url="$(ensure_macos_litellm_database "$config_dir" "$state_dir" "$tool_path")"
ensure_litellm_prisma_client "$litellm_venv" "$litellm_database_url"
write_litellm_config "$litellm_config"
litellm_bin="$litellm_venv/bin/litellm"
openclaw_bin="$(macos_openclaw_bin)"
ensure_macos_openclaw_multi_session_plugin "$openclaw_bin"
vault_bin="$(macos_vault_bin)"
ttyd_bin="$(macos_ttyd_bin)"
bridge_bin="$(macos_xworkmate_bridge_bin)"
qmd_bin="$(macos_qmd_bin)"
hermes_bin="$(macos_hermes_bin)"
launchctl bootout "gui/$(id -u)" "$HOME/Library/LaunchAgents/plus.svc.xworkspace.litellm.plist" >/dev/null 2>&1 || true
launchctl bootout "gui/$(id -u)" "$HOME/Library/LaunchAgents/plus.svc.xworkspace.openclaw.plist" >/dev/null 2>&1 || true
launchctl bootout "gui/$(id -u)" "$HOME/Library/LaunchAgents/plus.svc.xworkspace.vault.plist" >/dev/null 2>&1 || true
launchctl bootout "gui/$(id -u)" "$HOME/Library/LaunchAgents/plus.svc.xworkspace.ttyd.plist" >/dev/null 2>&1 || true
launchctl bootout "gui/$(id -u)" "$HOME/Library/LaunchAgents/plus.svc.xworkspace.bridge.plist" >/dev/null 2>&1 || true
launchctl bootout "gui/$(id -u)" "$HOME/Library/LaunchAgents/plus.svc.xworkspace.qmd.plist" >/dev/null 2>&1 || true
launchctl bootout "gui/$(id -u)" "$HOME/Library/LaunchAgents/plus.svc.xworkspace.hermes.plist" >/dev/null 2>&1 || true
info "Starting LiteLLM on http://127.0.0.1:${AI_WORKSPACE_LITELLM_PORT} ..."
deploy_launch_agent \
"plus.svc.xworkspace.litellm" \
"$HOME" \
"exec /usr/bin/env PATH='$tool_path' DATABASE_URL='$litellm_database_url' LITELLM_MASTER_KEY=\"\$(cat '$config_dir/auth-token')\" LITELLM_SALT_KEY=\"\$(cat '$config_dir/auth-token')\" UI_USERNAME=admin UI_PASSWORD=\"\$(cat '$config_dir/auth-token')\" DEEPSEEK_API_KEY=\"\${DEEPSEEK_API_KEY:-}\" OPENAI_API_KEY=\"\${OPENAI_API_KEY:-}\" NVIDIA_API_KEY=\"\${NVIDIA_API_KEY:-}\" OLLAMA_API_KEY=\"\${OLLAMA_API_KEY:-}\" GEMINI_API_KEY=\"\${GEMINI_API_KEY:-}\" ANTHROPIC_API_KEY=\"\${ANTHROPIC_API_KEY:-}\" '$litellm_bin' --host 127.0.0.1 --port ${AI_WORKSPACE_LITELLM_PORT} --config '$litellm_config' --use_prisma_db_push" \
"$state_dir/litellm.log" \
"$state_dir/litellm.err.log"
wait_for_url "http://127.0.0.1:${AI_WORKSPACE_LITELLM_PORT}/ui"
info "Configuring OpenClaw models to use unified LiteLLM at http://127.0.0.1:${AI_WORKSPACE_LITELLM_PORT}/v1 ..."
node -e "
const fs = require('fs');
const path = require('path');
const file = path.join(process.env.HOME, '.openclaw', 'openclaw.json');
let config = {};
if (fs.existsSync(file)) {
config = JSON.parse(fs.readFileSync(file, 'utf8'));
}
if (!config.models) config.models = { mode: 'merge', providers: {} };
if (!config.models.providers) config.models.providers = {};
if (!config.gateway) config.gateway = { mode: 'local' };
const authToken = fs.readFileSync('$config_dir/auth-token', 'utf8').trim();
config.models.providers.litellm = {
api: 'openai-completions',
baseUrl: 'http://127.0.0.1:${AI_WORKSPACE_LITELLM_PORT}/v1',
apiKey: authToken,
models: [
{
id: '$AI_WORKSPACE_DEFAULT_MODEL',
name: 'Primary Model',
input: ['text'],
contextWindow: 128000,
maxTokens: 8192,
reasoning: false
},
{
id: '$AI_WORKSPACE_FALLBACK_MODEL',
name: 'Fallback Reasoning Model',
input: ['text'],
contextWindow: 128000,
maxTokens: 8192,
reasoning: true
}
]
};
if (!config.agents) config.agents = { defaults: { models: {} }, list: [] };
if (!config.agents.defaults) config.agents.defaults = { models: {} };
if (!config.agents.defaults.models) config.agents.defaults.models = {};
config.agents.defaults.models['litellm/$AI_WORKSPACE_DEFAULT_MODEL'] = { alias: 'Default Agent' };
if (!config.agents.defaults.model) config.agents.defaults.model = {};
config.agents.defaults.model.primary = 'litellm/$AI_WORKSPACE_DEFAULT_MODEL';
if (!config.plugins) config.plugins = {};
if (!config.plugins.entries) config.plugins.entries = {};
config.plugins.entries['openclaw-multi-session-plugins'] = {
...(config.plugins.entries['openclaw-multi-session-plugins'] || {}),
enabled: true
};
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, JSON.stringify(config, null, 2));
"
info "Starting OpenClaw on http://127.0.0.1:18789/channels ..."
deploy_launch_agent \
"plus.svc.xworkspace.openclaw" \
"$HOME" \
"exec /usr/bin/env PATH='$tool_path' OPENCLAW_GATEWAY_TOKEN=\"\$(cat '$config_dir/auth-token')\" '$openclaw_bin' gateway run --dev --force --bind loopback --auth token --token \"\$(cat '$config_dir/auth-token')\" --port 18789" \
"$state_dir/openclaw.log" \
"$state_dir/openclaw.err.log"
wait_for_url "http://127.0.0.1:18789/channels"
info "Starting Vault on http://127.0.0.1:8200/ui ..."
deploy_launch_agent \
"plus.svc.xworkspace.vault" \
"$HOME" \
"exec /usr/bin/env PATH='$tool_path' VAULT_ADDR=http://127.0.0.1:8200 '$vault_bin' server -dev -dev-listen-address=127.0.0.1:8200 -dev-root-token-id=\"\$(cat '$config_dir/auth-token')\"" \
"$state_dir/vault.log" \
"$state_dir/vault.err.log"
wait_for_url "http://127.0.0.1:8200/ui"
info "Starting ttyd terminal on http://127.0.0.1:7681 ..."
deploy_launch_agent \
"plus.svc.xworkspace.ttyd" \
"$HOME" \
"exec /usr/bin/env PATH='$tool_path' '$ttyd_bin' -W -i 127.0.0.1 -p 7681 -w '$HOME' /bin/zsh -l" \
"$state_dir/ttyd.log" \
"$state_dir/ttyd.err.log"
wait_for_url "http://127.0.0.1:7681/"
info "Starting XWorkMate Bridge on http://127.0.0.1:8787/ ..."
deploy_launch_agent \
"plus.svc.xworkspace.bridge" \
"$HOME" \
"exec /usr/bin/env PATH='$tool_path' INTERNAL_SERVICE_TOKEN=\"\$(cat '$config_dir/auth-token')\" '$bridge_bin' serve --listen 127.0.0.1:8787" \
"$state_dir/bridge.log" \
"$state_dir/bridge.err.log"
wait_for_url "http://127.0.0.1:8787/"
info "Starting QMD MCP on http://127.0.0.1:8181/mcp ..."
deploy_launch_agent \
"plus.svc.xworkspace.qmd" \
"$HOME" \
"exec /usr/bin/env PATH='$tool_path' QMD_EMBED_API_BASE_URL=http://127.0.0.1:4000/v1 QMD_EMBED_MODEL=text-embedding-3-small QMD_EMBED_API_KEY=\"\$(cat '$config_dir/auth-token')\" '$qmd_bin' mcp --http --port 8181" \
"$state_dir/qmd.log" \
"$state_dir/qmd.err.log"
wait_for_url "http://127.0.0.1:8181/mcp"
info "Starting Hermes ACP Adapter on http://127.0.0.1:3920/acp ..."
deploy_launch_agent \
"plus.svc.xworkspace.hermes" \
"$HOME" \
"exec /usr/bin/env PATH='$tool_path' HERMES_ADAPTER_AUTH_TOKEN=\"\$(cat '$config_dir/auth-token')\" '$bridge_bin' adapter hermes --listen 127.0.0.1:3920 --hermes-bin '$hermes_bin'" \
"$state_dir/hermes.log" \
"$state_dir/hermes.err.log"
wait_for_url "http://127.0.0.1:3920/acp"
}
deploy_macos_local() {
require_or_install_macos_cmds
install_macos_agent_clis
local token=$1
local console_dir config_dir state_dir api_log dashboard_log api_err dashboard_err go_bin npm_bin node_bin tool_path
console_dir="$(resolve_console_dir)"
config_dir="$HOME/.config/xworkspace"
state_dir="$HOME/.local/state/xworkspace"
go_bin="$(command -v go)"
npm_bin="$(command -v npm)"
node_bin="$(command -v node)"
tool_path="$(dirname "$node_bin"):$(dirname "$go_bin"):$(dirname "$npm_bin"):/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin"
mkdir -p "$state_dir"
api_log="$state_dir/xworkspace-api.log"
dashboard_log="$state_dir/xworkspace-console.log"
api_err="$state_dir/xworkspace-api.err.log"
dashboard_err="$state_dir/xworkspace-console.err.log"
info "Deploying AI Workspace Portal locally on macOS from $console_dir"
write_local_portal_config "$token" "$config_dir"
stop_managed_pid "$state_dir/xworkspace-api.pid"
stop_managed_pid "$state_dir/xworkspace-console.pid"
launchctl bootout "gui/$(id -u)" "$HOME/Library/LaunchAgents/plus.svc.xworkspace.api.plist" >/dev/null 2>&1 || true
launchctl bootout "gui/$(id -u)" "$HOME/Library/LaunchAgents/plus.svc.xworkspace.console.plist" >/dev/null 2>&1 || true
launchctl bootout "gui/$(id -u)" "$HOME/Library/LaunchAgents/plus.svc.xworkspace.litellm.plist" >/dev/null 2>&1 || true
launchctl bootout "gui/$(id -u)" "$HOME/Library/LaunchAgents/plus.svc.xworkspace.openclaw.plist" >/dev/null 2>&1 || true
launchctl bootout "gui/$(id -u)" "$HOME/Library/LaunchAgents/plus.svc.xworkspace.vault.plist" >/dev/null 2>&1 || true
launchctl bootout "gui/$(id -u)" "$HOME/Library/LaunchAgents/plus.svc.xworkspace.ttyd.plist" >/dev/null 2>&1 || true
launchctl bootout "gui/$(id -u)" "$HOME/Library/LaunchAgents/plus.svc.xworkspace.bridge.plist" >/dev/null 2>&1 || true
launchctl bootout "gui/$(id -u)" "$HOME/Library/LaunchAgents/plus.svc.xworkspace.qmd.plist" >/dev/null 2>&1 || true
launchctl bootout "gui/$(id -u)" "$HOME/Library/LaunchAgents/plus.svc.xworkspace.hermes.plist" >/dev/null 2>&1 || true
ensure_port_available_for_repo 8788 "$console_dir"
ensure_port_available 8787
ensure_port_available_for_repo 17000 "$console_dir"
ensure_port_available ${AI_WORKSPACE_LITELLM_PORT}
ensure_port_available 18789
ensure_port_available 8200
ensure_port_available 7681
ensure_port_available 8181
ensure_port_available 3920
info "Building dashboard assets..."
(cd "$console_dir/dashboard" && npm install && npm run build)
start_macos_target_services "$config_dir" "$state_dir" "$tool_path"
info "Starting xworkspace API on http://127.0.0.1:8788 ..."
deploy_launch_agent \
"plus.svc.xworkspace.api" \
"$console_dir/api" \
"exec /usr/bin/env PATH='$tool_path' XWORKSPACE_PORTAL_SERVICES_FILE='$config_dir/portal-services.json' '$go_bin' run ." \
"$api_log" \
"$api_err"
wait_for_url "http://127.0.0.1:8788/auth/status"
info "Starting AI Workspace Portal on http://127.0.0.1:17000 ..."
deploy_launch_agent \
"plus.svc.xworkspace.console" \
"$console_dir/dashboard" \
"exec /usr/bin/env PATH='$tool_path' '$npm_bin' run preview -- --host 127.0.0.1 --port 17000" \
"$dashboard_log" \
"$dashboard_err"
wait_for_url "http://127.0.0.1:17000/"
local status_ok status_bad
status_ok="$(curl -sS -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $token" http://127.0.0.1:8788/portal/services)"
status_bad="$(curl -sS -o /dev/null -w '%{http_code}' -H "Authorization: Bearer wrong-token" http://127.0.0.1:8788/portal/services)"
[ "$status_ok" = "200" ] || error "Expected valid token to unlock portal services, got HTTP $status_ok"
[ "$status_bad" = "401" ] || error "Expected invalid token to be rejected, got HTTP $status_bad"
if [ -n "${DEEPSEEK_API_KEY:-}" ] || [ -n "${NVIDIA_API_KEY:-}" ] || [ -n "${GEMINI_API_KEY:-}" ] || [ -n "${OPENAI_API_KEY:-}" ] || [ -n "${ANTHROPIC_API_KEY:-}" ] || [ -n "${OLLAMA_API_KEY:-}" ]; then
info "API keys detected. Fetching and executing mainstream model registration script..."
curl -sfL "https://raw.githubusercontent.com/ai-workspace-lab/cloud-neutral-toolkit/main/playbooks/roles/vhosts/litellm/files/register_mainstream_models.sh" | \
LITELLM_TOKEN="$token" LITELLM_URL="http://127.0.0.1:${AI_WORKSPACE_LITELLM_PORT}" bash -s || warn "Failed to register mainstream models."
fi
success "AI Workspace Portal is running at http://127.0.0.1:17000/"
info "Use the same xworkmate-bridge token to unlock the Portal."
info "Logs: $api_log, $api_err, $dashboard_log, and $dashboard_err"
}
if [ "${AI_WORKSPACE_LIBRARY_MODE:-false}" = "true" ]; then
if (return 0 2>/dev/null); then
return 0
@ -2238,27 +1565,17 @@ info "Starting AI Workspace All-in-One Bootstrap..."
# 1. Install prerequisites (git, curl, ansible) if missing
OS_NAME="$(detect_os)"
if [ "$OS_NAME" = "darwin" ] && [ "${AI_WORKSPACE_DARWIN_MODE:-local}" = "local" ] && [[ ! "${1:-}" =~ ^(sync|uninstall|backup|restore|migrate)$ ]]; then
UNIFIED_AUTH_TOKEN="$(resolve_unified_auth_token)"
export AI_WORKSPACE_AUTH_TOKEN="$UNIFIED_AUTH_TOKEN"
export XWORKSPACE_CONSOLE_AUTH_TOKEN="${XWORKSPACE_CONSOLE_AUTH_TOKEN:-$UNIFIED_AUTH_TOKEN}"
export INTERNAL_SERVICE_TOKEN="${INTERNAL_SERVICE_TOKEN:-$UNIFIED_AUTH_TOKEN}"
export BRIDGE_AUTH_TOKEN="${BRIDGE_AUTH_TOKEN:-$UNIFIED_AUTH_TOKEN}"
export XWORKMATE_BRIDGE_AUTH_TOKEN="${XWORKMATE_BRIDGE_AUTH_TOKEN:-$UNIFIED_AUTH_TOKEN}"
export LITELLM_MASTER_KEY="${LITELLM_MASTER_KEY:-$UNIFIED_AUTH_TOKEN}"
export OPENCLAW_GATEWAY_TOKEN="${OPENCLAW_GATEWAY_TOKEN:-$UNIFIED_AUTH_TOKEN}"
export VAULT_TOKEN="${VAULT_TOKEN:-$UNIFIED_AUTH_TOKEN}"
export VAULT_SERVER_ROOT_ACCESS_TOKEN="${VAULT_SERVER_ROOT_ACCESS_TOKEN:-$UNIFIED_AUTH_TOKEN}"
export VAULT_ADMIN_PASSWORD="${VAULT_ADMIN_PASSWORD:-$UNIFIED_AUTH_TOKEN}"
deploy_macos_local "$UNIFIED_AUTH_TOKEN"
print_deployment_summary "$UNIFIED_AUTH_TOKEN"
exit 0
if [ "$OS_NAME" = "darwin" ]; then
require_or_install_macos_cmds
fi
if [ "$OS_NAME" = "linux" ]; then
acquire_deployment_lock
wait_for_apt_locks
ensure_public_edge_firewall_ports
if [ "$OS_NAME" = "linux" ] || [ "$OS_NAME" = "darwin" ]; then
if [ "$OS_NAME" = "linux" ]; then
acquire_deployment_lock
wait_for_apt_locks
ensure_public_edge_firewall_ports
fi
# Skip offline bootstrap when running a subcommand that handles its own flow
case "${1:-}" in
sync|uninstall|backup|restore|migrate) ;;