@baize-ai/core 0.3.10 → 0.3.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/Dockerfile +21 -14
- package/docker/entrypoint.sh +15 -7
- package/package.json +1 -1
- package/scripts/build-push-acr.sh +38 -0
- package/scripts/install-docker.sh +35 -0
- package/scripts/install.sh +13 -0
- package/skills/web-console/public/app.js +179 -0
- package/skills/web-console/public/index.html +18 -4
- package/skills/web-console/scripts/channel-admin.js +15 -1
- package/skills/web-console/scripts/diagnostics.js +45 -1
- package/skills/web-console/scripts/server.js +290 -2
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,22 @@ All notable changes to baize-core will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.3.12] - 2026-08-28
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- web 控制台 runtime 未安装时异步安装(D39):切换 claude/codex 检测 CLI 缺失 → 后台安装(install.sh/npm,900s 超时 + 重试)→ 装完自动切换;状态端点 + 前端进度轮询(修复 240s/300s 双超时导致的切 cc 失败)
|
|
12
|
+
- 初始化失败 web 可见(D40):entrypoint 写 init-state.json,诊断卡显示「初始化未完成」+ 引导
|
|
13
|
+
- runtime 安装状态可见(D41):模型设置页每个 runtime 显示安装徽章(✅ 版本 / ❌ 未装 + 失败原因)+ [安装] 按钮(复用 D39 异步安装)+ 当前 runtime 缺失红色警告
|
|
14
|
+
- 环境修复闭环(D42):环境初始化卡置顶(未就绪缺口人话 + [重新初始化] 一键幂等 init + 失败人话原因 + web 内日志展开)——web 是唯一恢复入口,无需 SSH
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
- Docker 镜像:不捆绑 claude(单架构二进制导致另一平台 exec format error);npm/apt 源跟随推送目标(ghcr 默认官方源,ACR 构建全 npmmirror/阿里云);entrypoint 首次默认 codex(与 install.sh 统一,已初始化尊重现有 runtime)
|
|
18
|
+
|
|
19
|
+
## [0.3.11] - 2026-08-27
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
- web-console 渠道安装:`cliPath()` 增加大陆 install.sh 布局候选(`~/.local/node/lib/node_modules/@baize-ai/core/cli/baize.js`)+ `npm root -g` 动态兜底——此前候选硬编码 `~/.npm-global`,大陆安装(Node 在 `~/.local/node`)报「baize CLI not found」,web 安装飞书/渠道失败
|
|
23
|
+
|
|
8
24
|
## [0.3.10] - 2026-08-27
|
|
9
25
|
|
|
10
26
|
### Fixed
|
package/Dockerfile
CHANGED
|
@@ -14,8 +14,21 @@ FROM node:22-slim
|
|
|
14
14
|
|
|
15
15
|
LABEL org.opencontainers.image.source="https://github.com/baize01-ai/baize-core"
|
|
16
16
|
LABEL org.opencontainers.image.description="Baize — autonomous AI agent infrastructure"
|
|
17
|
+
# Build flags: official sources by default (npmjs + Debian). Set to "aliyun"
|
|
18
|
+
# when building for the Aliyun ACR (mainland distribution) — npmmirror npm +
|
|
19
|
+
# Aliyun Debian mirror, baked into the image so docker run npm also uses the
|
|
20
|
+
# mainland source. ARG must precede the RUNs that reference them.
|
|
21
|
+
ARG BAIZE_APT_MIRROR=off
|
|
22
|
+
ARG BAIZE_NPM_MIRROR=off
|
|
17
23
|
|
|
18
24
|
# ── System packages ───────────────────────────────────────────────────────────
|
|
25
|
+
# Point apt at the Aliyun Debian mirror when building on mainland servers
|
|
26
|
+
# (deb.debian.org is unreachable/slow there; env flag lets non-CN builds keep
|
|
27
|
+
# the official source).
|
|
28
|
+
RUN if [ "${BAIZE_APT_MIRROR:-off}" = "aliyun" ]; then \
|
|
29
|
+
sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources; \
|
|
30
|
+
sed -i 's|security.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources; \
|
|
31
|
+
fi
|
|
19
32
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
20
33
|
git \
|
|
21
34
|
curl \
|
|
@@ -35,22 +48,16 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
|
35
48
|
g++ \
|
|
36
49
|
&& rm -rf /var/lib/apt/lists/*
|
|
37
50
|
|
|
38
|
-
#
|
|
51
|
+
# Mainland: registry.npmjs.org is unreachable — point npm at npmmirror
|
|
52
|
+
# (build-arg BAIZE_NPM_MIRROR=off keeps the official registry).
|
|
53
|
+
RUN if [ "${BAIZE_NPM_MIRROR:-off}" = "aliyun" ]; then \
|
|
54
|
+
npm config set registry https://registry.npmmirror.com --global; \
|
|
55
|
+
# Also drop a system-wide npmrc so the runtime user (baize) and any
|
|
56
|
+
# docker exec npm use the mainland mirror too.
|
|
57
|
+
mkdir -p /usr/etc && printf 'registry=https://registry.npmmirror.com\n' > /usr/etc/npmrc; \
|
|
58
|
+
fi
|
|
39
59
|
RUN npm install -g pm2@latest
|
|
40
60
|
|
|
41
|
-
# ── Claude Code CLI (pre-bundled, pinned version) ─────────────────────────────
|
|
42
|
-
# The official install.sh downloads a ~340MB binary at container runtime, which
|
|
43
|
-
# is unreliable inside container networks (fails ~90% through on Docker
|
|
44
|
-
# Desktop). Bundle a PINNED, verified binary in the image instead:
|
|
45
|
-
# curl -fsSL https://downloads.claude.ai/claude-code-releases/<ver>/linux-arm64/claude
|
|
46
|
-
# (verify against .../manifest.json platforms.linux-arm64 checksum)
|
|
47
|
-
# echo <ver> > .claude-local/claude.version
|
|
48
|
-
# Upgrade = replace the binary + bump claude.version, then rebuild the image
|
|
49
|
-
# (explicit release decision — never auto-fetch latest at build time).
|
|
50
|
-
COPY .claude-local/claude /usr/local/bin/claude
|
|
51
|
-
RUN chmod +x /usr/local/bin/claude
|
|
52
|
-
COPY .claude-local/claude.version /usr/local/bin/claude.version
|
|
53
|
-
LABEL org.opencontainers.image.claude-version="$(cat .claude-local/claude.version 2>/dev/null || echo unknown)"
|
|
54
61
|
|
|
55
62
|
# ── Create baize user (non-root) ──────────────────────────────────────────────
|
|
56
63
|
RUN useradd -m -s /bin/bash baize \
|
package/docker/entrypoint.sh
CHANGED
|
@@ -65,15 +65,14 @@ if [ -n "${AUTH_TOKEN}" ]; then
|
|
|
65
65
|
fi
|
|
66
66
|
fi
|
|
67
67
|
|
|
68
|
-
# Detect runtime —
|
|
69
|
-
#
|
|
68
|
+
# Detect runtime — default to codex ONLY on first boot (mainland default;
|
|
69
|
+
# the image no longer bundles claude, which is installed manually when
|
|
70
|
+
# needed). If the workspace is already initialized (config.json exists) the
|
|
71
|
+
# existing runtime is respected — init would otherwise reset a runtime the
|
|
72
|
+
# user switched to in the web console. BAIZE_RUNTIME env always wins.
|
|
70
73
|
RUNTIME_FLAG=""
|
|
71
74
|
if [ -z "${BAIZE_RUNTIME:-}" ]; then
|
|
72
|
-
|
|
73
|
-
HAS_CODEX_AUTH=false
|
|
74
|
-
[ -n "${ANTHROPIC_API_KEY:-}" ] || [ -n "${CLAUDE_CODE_OAUTH_TOKEN:-}" ] && HAS_CLAUDE_AUTH=true
|
|
75
|
-
[ -n "${OPENAI_API_KEY:-}" ] || [ -n "${CODEX_API_KEY:-}" ] && HAS_CODEX_AUTH=true
|
|
76
|
-
if [ "${HAS_CODEX_AUTH}" = true ] && [ "${HAS_CLAUDE_AUTH}" = false ]; then
|
|
75
|
+
if [ ! -f "${BAIZE_DIR}/.baize/config.json" ]; then
|
|
77
76
|
RUNTIME_FLAG="--runtime codex"
|
|
78
77
|
fi
|
|
79
78
|
fi
|
|
@@ -87,7 +86,16 @@ INIT_ARGS="--yes --quiet"
|
|
|
87
86
|
# shellcheck disable=SC2086
|
|
88
87
|
if ! baize init ${INIT_ARGS}; then
|
|
89
88
|
warn "baize init exited with errors (may be partial). Check logs."
|
|
89
|
+
# Surface init failure to the web console (diagnostics card) so the user can
|
|
90
|
+
# discover and fix it in-browser (e.g. a runtime CLI that failed to install).
|
|
91
|
+
mkdir -p "${BAIZE_DIR}/.baize"
|
|
92
|
+
printf '{"status":"failed","error":"baize init exited with errors","at":"%s"}\n' "$(date -Iseconds)" > "${BAIZE_DIR}/.baize/init-state.json"
|
|
93
|
+
exit 1
|
|
90
94
|
fi
|
|
95
|
+
ok "Workspace ready"
|
|
96
|
+
mkdir -p "${BAIZE_DIR}/.baize"
|
|
97
|
+
printf '{"status":"ok","at":"%s"}\n' "$(date -Iseconds)" > "${BAIZE_DIR}/.baize/init-state.json"
|
|
98
|
+
|
|
91
99
|
|
|
92
100
|
ok "Workspace ready"
|
|
93
101
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Multi-platform build + push to Aliyun ACR for the mainland distribution.
|
|
3
|
+
# Usage: bash build-push-acr.sh [VERSION] (default 0.3.11)
|
|
4
|
+
# Requires: docker login already done, qemu-arm64 registered
|
|
5
|
+
# (apt install qemu-user-binfmt), registry mirror configured.
|
|
6
|
+
set -euo pipefail
|
|
7
|
+
|
|
8
|
+
VERSION="${1:-0.3.11}"
|
|
9
|
+
ACR="crpi-8bg62qbrjs3cr3as.cn-hangzhou.personal.cr.aliyuncs.com/baize01/baize-core"
|
|
10
|
+
|
|
11
|
+
echo "== Configure buildkit registry mirror (mainland Docker Hub access) =="
|
|
12
|
+
mkdir -p /etc/buildkit
|
|
13
|
+
cat > /etc/buildkit/buildkit.toml <<'EOF'
|
|
14
|
+
[registry."docker.io"]
|
|
15
|
+
mirrors = ["https://docker.m.daocloud.io"]
|
|
16
|
+
EOF
|
|
17
|
+
|
|
18
|
+
echo "== Ensure multi-platform builder (reuse for layer cache) =="
|
|
19
|
+
if ! docker buildx inspect multi >/dev/null 2>&1; then
|
|
20
|
+
docker buildx create --name multi --driver docker-container \
|
|
21
|
+
--config /etc/buildkit/buildkit.toml --use
|
|
22
|
+
fi
|
|
23
|
+
docker buildx use multi 2>/dev/null || true
|
|
24
|
+
|
|
25
|
+
echo "== Build + push $VERSION (amd64 + arm64) =="
|
|
26
|
+
docker buildx build \
|
|
27
|
+
--platform linux/amd64,linux/arm64 \
|
|
28
|
+
--provenance=false --sbom=false \
|
|
29
|
+
--build-arg BAZE_CORE_VERSION="$VERSION" \
|
|
30
|
+
--build-arg BAIZE_APT_MIRROR=aliyun \
|
|
31
|
+
--build-arg BAIZE_NPM_MIRROR=aliyun \
|
|
32
|
+
-t "$ACR:$VERSION" \
|
|
33
|
+
-t "$ACR:latest" \
|
|
34
|
+
--push .
|
|
35
|
+
|
|
36
|
+
echo "== Verify =="
|
|
37
|
+
docker buildx imagetools inspect "$ACR:$VERSION" | grep -E "Platform|Architecture" | head -4
|
|
38
|
+
echo "== Done =="
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Install Docker Engine (with buildx) on a mainland Ubuntu server.
|
|
3
|
+
# Uses the Aliyun docker-ce mirror; falls back from the local Ubuntu
|
|
4
|
+
# codename to noble (24.04) when the mirror has not synced it yet.
|
|
5
|
+
# Run as root: bash install-docker.sh
|
|
6
|
+
set -euo pipefail
|
|
7
|
+
|
|
8
|
+
echo "== 1/4 Download Docker GPG key =="
|
|
9
|
+
curl -fsSL -o /tmp/docker.gpg https://mirrors.aliyun.com/docker-ce/linux/ubuntu/gpg
|
|
10
|
+
gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg /tmp/docker.gpg
|
|
11
|
+
|
|
12
|
+
echo "== 2/4 Write apt source (deb822) =="
|
|
13
|
+
CODENAME=$(lsb_release -cs)
|
|
14
|
+
{
|
|
15
|
+
echo "Types: deb"
|
|
16
|
+
echo "URIs: https://mirrors.aliyun.com/docker-ce/linux/ubuntu"
|
|
17
|
+
echo "Suites: $CODENAME"
|
|
18
|
+
echo "Components: stable"
|
|
19
|
+
echo "Signed-By: /usr/share/keyrings/docker-archive-keyring.gpg"
|
|
20
|
+
} > /etc/apt/sources.list.d/docker.sources
|
|
21
|
+
|
|
22
|
+
if ! apt-get update; then
|
|
23
|
+
echo "== $CODENAME not on the mirror yet, falling back to noble =="
|
|
24
|
+
sed -i "s/Suites: $CODENAME/Suites: noble/" /etc/apt/sources.list.d/docker.sources
|
|
25
|
+
apt-get update
|
|
26
|
+
fi
|
|
27
|
+
|
|
28
|
+
echo "== 3/4 Install Docker =="
|
|
29
|
+
apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
|
|
30
|
+
|
|
31
|
+
echo "== 4/4 Start and verify =="
|
|
32
|
+
systemctl enable --now docker
|
|
33
|
+
docker --version
|
|
34
|
+
docker buildx version
|
|
35
|
+
echo "== Done =="
|
package/scripts/install.sh
CHANGED
|
@@ -314,6 +314,7 @@ _ensure_path_in_profile() {
|
|
|
314
314
|
# Added by baize installer
|
|
315
315
|
fish_add_path -g $HOME/.local/bin
|
|
316
316
|
fish_add_path -g $HOME/baize/bin
|
|
317
|
+
fish_add_path -g $HOME/.local/node/bin
|
|
317
318
|
FISH_EOF
|
|
318
319
|
ok "PATH configured in conf.d/baize.fish"
|
|
319
320
|
fi
|
|
@@ -331,15 +332,26 @@ FISH_EOF
|
|
|
331
332
|
local baize_marker='# baize-managed: bin PATH'
|
|
332
333
|
local baize_bin_export="export PATH=\"\$HOME/baize/bin:\$PATH\""
|
|
333
334
|
|
|
335
|
+
# 3. ~/.local/node/bin — Node/npm installed from the npmmirror binary
|
|
336
|
+
# mirror (or a bundled Node). Idempotency: dedicated marker.
|
|
337
|
+
local node_marker='# baize-managed: node bin PATH'
|
|
338
|
+
local node_bin_export="export PATH=\"\$HOME/.local/node/bin:\$PATH\""
|
|
339
|
+
|
|
334
340
|
# Write to ~/.profile (login shells + non-interactive shells)
|
|
335
341
|
if ! grep -q 'baize-managed: bin PATH' "$HOME/.profile" 2>/dev/null; then
|
|
336
342
|
printf '\n%s\n%s\n' "$baize_marker" "$baize_bin_export" >> "$HOME/.profile"
|
|
337
343
|
fi
|
|
344
|
+
if ! grep -q 'baize-managed: node bin PATH' "$HOME/.profile" 2>/dev/null; then
|
|
345
|
+
printf '\n%s\n%s\n' "$node_marker" "$node_bin_export" >> "$HOME/.profile"
|
|
346
|
+
fi
|
|
338
347
|
# Write to shell rc file (interactive shells)
|
|
339
348
|
if [ "$shell_rc" != "$HOME/.profile" ]; then
|
|
340
349
|
if ! grep -q 'baize-managed: bin PATH' "$shell_rc" 2>/dev/null; then
|
|
341
350
|
printf '\n%s\n%s\n' "$baize_marker" "$baize_bin_export" >> "$shell_rc"
|
|
342
351
|
fi
|
|
352
|
+
if ! grep -q 'baize-managed: node bin PATH' "$shell_rc" 2>/dev/null; then
|
|
353
|
+
printf '\n%s\n%s\n' "$node_marker" "$node_bin_export" >> "$shell_rc"
|
|
354
|
+
fi
|
|
343
355
|
fi
|
|
344
356
|
|
|
345
357
|
ok "PATH configured in $(basename "$shell_rc")"
|
|
@@ -347,6 +359,7 @@ FISH_EOF
|
|
|
347
359
|
|
|
348
360
|
# Export for the running script (so baize init can find binaries)
|
|
349
361
|
export PATH="$HOME/.local/bin:$HOME/baize/bin:$PATH"
|
|
362
|
+
export PATH="$HOME/.local/node/bin:$PATH"
|
|
350
363
|
}
|
|
351
364
|
|
|
352
365
|
# ── Install Baize ─────────────────────────────────────────────
|
|
@@ -972,6 +972,13 @@ async function switchRuntimeFromAdmin(basePath) {
|
|
|
972
972
|
setAdminMsg('未登录或会话过期,请重新登录', true);
|
|
973
973
|
return;
|
|
974
974
|
}
|
|
975
|
+
if (body.installing) {
|
|
976
|
+
// D39: runtime binary missing — async install in progress, poll status.
|
|
977
|
+
const label = target === 'claude' ? 'Claude Code' : 'Codex';
|
|
978
|
+
setAdminMsg(`正在安装 ${label}(下载约 340MB,首次约 2-5 分钟)...`);
|
|
979
|
+
pollRuntimeInstall(basePath, target);
|
|
980
|
+
return;
|
|
981
|
+
}
|
|
975
982
|
if (body.success) {
|
|
976
983
|
const restartNote = body.restart?.restarted ? ',会话已自动重启' : (body.restart?.reason === 'no_session' ? '(无运行中会话)' : '');
|
|
977
984
|
setAdminMsg(`已切换到 ${target}${restartNote}`);
|
|
@@ -981,6 +988,165 @@ async function switchRuntimeFromAdmin(basePath) {
|
|
|
981
988
|
loadAdminStatus(basePath);
|
|
982
989
|
}
|
|
983
990
|
|
|
991
|
+
async function pollRuntimeInstall(basePath, runtime) {
|
|
992
|
+
const label = runtime === 'claude' ? 'Claude Code' : 'Codex';
|
|
993
|
+
let attempts = 0;
|
|
994
|
+
while (attempts < 400) { // ~20 min cap
|
|
995
|
+
attempts += 1;
|
|
996
|
+
const { status, body } = await adminFetch(basePath, `/api/admin/runtime/install-status?runtime=${encodeURIComponent(runtime)}`);
|
|
997
|
+
if (status !== 200 || !body) {
|
|
998
|
+
await new Promise((r) => setTimeout(r, 3000));
|
|
999
|
+
continue;
|
|
1000
|
+
}
|
|
1001
|
+
if (body.status === 'done') {
|
|
1002
|
+
setAdminMsg(`${label} 安装完成,已切换到 ${runtime},会话重启中`);
|
|
1003
|
+
renderRuntimeInstallBadges(basePath);
|
|
1004
|
+
loadAdminStatus(basePath);
|
|
1005
|
+
return;
|
|
1006
|
+
}
|
|
1007
|
+
if (body.status === 'failed') {
|
|
1008
|
+
setAdminMsg(`${label} 安装失败:${body.error || '未知错误'}(可稍后重试)`, true);
|
|
1009
|
+
renderRuntimeInstallBadges(basePath);
|
|
1010
|
+
return;
|
|
1011
|
+
}
|
|
1012
|
+
await new Promise((r) => setTimeout(r, 3000));
|
|
1013
|
+
}
|
|
1014
|
+
setAdminMsg(`${label} 安装仍在进行,请稍后查看状态`, true);
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
// D41: per-runtime install badges (installed/version/failure) + install button
|
|
1018
|
+
// (reuses D39 async install) + a warning when the ACTIVE runtime is missing.
|
|
1019
|
+
async function renderRuntimeInstallBadges(basePath) {
|
|
1020
|
+
const badgeClaude = document.getElementById('rt-install-claude');
|
|
1021
|
+
const badgeCodex = document.getElementById('rt-install-codex');
|
|
1022
|
+
const warnEl = document.getElementById('rt-install-warning');
|
|
1023
|
+
if (!badgeClaude || !badgeCodex) return;
|
|
1024
|
+
const { status, body } = await adminFetch(basePath, '/api/admin/diagnostics');
|
|
1025
|
+
if (status !== 200 || !body?.runtimes) return;
|
|
1026
|
+
const rt = body.runtime?.runtime || 'claude';
|
|
1027
|
+
const rtInfo = body.runtimes || {};
|
|
1028
|
+
const badge = (name, info) => {
|
|
1029
|
+
const el = name === 'claude' ? badgeClaude : badgeCodex;
|
|
1030
|
+
if (info.installed) {
|
|
1031
|
+
el.innerHTML = `<span class="channel-state ok">✅ 已安装${info.version ? ` ${escapeHtml(info.version)}` : ''}</span>`;
|
|
1032
|
+
return;
|
|
1033
|
+
}
|
|
1034
|
+
const reason = info.installError ? `(上次失败:${escapeHtml(info.installError)})` : '';
|
|
1035
|
+
el.innerHTML = `<span class="channel-state warn">❌ 未安装${reason}</span> `
|
|
1036
|
+
+ `<button type="button" class="small-btn" data-install="${name}">安装</button>`;
|
|
1037
|
+
};
|
|
1038
|
+
badge('claude', rtInfo.claude || {});
|
|
1039
|
+
badge('codex', rtInfo.codex || {});
|
|
1040
|
+
// Warn when the active runtime CLI is missing — the session cannot start.
|
|
1041
|
+
if (warnEl) {
|
|
1042
|
+
const active = rtInfo[rt] || {};
|
|
1043
|
+
if (!active.installed) {
|
|
1044
|
+
const label = rt === 'claude' ? 'Claude Code' : 'Codex';
|
|
1045
|
+
warnEl.innerHTML = `<span class="channel-state warn">⚠ 会话无法启动:当前运行时 ${escapeHtml(rt)} 未安装——请点上方[安装]或[切换到 ${rt === 'claude' ? 'codex' : 'claude'}]</span>`;
|
|
1046
|
+
} else {
|
|
1047
|
+
warnEl.textContent = '';
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
// Wire install buttons (delegate once per render is fine — small page).
|
|
1051
|
+
document.querySelectorAll('[data-install]').forEach((btn) => {
|
|
1052
|
+
btn.onclick = async () => {
|
|
1053
|
+
const r = btn.getAttribute('data-install');
|
|
1054
|
+
btn.disabled = true;
|
|
1055
|
+
btn.textContent = '安装中...';
|
|
1056
|
+
setAdminMsg(`正在安装 ${r === 'claude' ? 'Claude Code' : 'Codex'}(下载约 340MB,首次约 2-5 分钟)...`);
|
|
1057
|
+
const rr = await adminFetch(basePath, '/api/admin/runtime', {
|
|
1058
|
+
method: 'POST',
|
|
1059
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1060
|
+
body: JSON.stringify({ runtime: r }),
|
|
1061
|
+
});
|
|
1062
|
+
if (rr.body?.installing) {
|
|
1063
|
+
pollRuntimeInstall(basePath, r);
|
|
1064
|
+
} else if (rr.body?.success) {
|
|
1065
|
+
setAdminMsg(`${r === 'claude' ? 'Claude Code' : 'Codex'} 已安装`);
|
|
1066
|
+
renderRuntimeInstallBadges(basePath);
|
|
1067
|
+
} else {
|
|
1068
|
+
setAdminMsg(`安装触发失败:${rr.body?.error || '未知错误'}`, true);
|
|
1069
|
+
btn.disabled = false;
|
|
1070
|
+
btn.textContent = '安装';
|
|
1071
|
+
}
|
|
1072
|
+
};
|
|
1073
|
+
});
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
// D42: environment status card — gaps in plain language, one-click re-init,
|
|
1077
|
+
// failure reason (human) + in-web log.
|
|
1078
|
+
async function renderEnvironmentStatus(basePath) {
|
|
1079
|
+
const badge = document.getElementById('env-state-badge');
|
|
1080
|
+
const detail = document.getElementById('env-state-detail');
|
|
1081
|
+
const actions = document.getElementById('env-reinit-actions');
|
|
1082
|
+
if (!badge || !detail) return;
|
|
1083
|
+
const { status, body } = await adminFetch(basePath, '/api/admin/environment/status');
|
|
1084
|
+
if (status !== 200 || !body) { detail.textContent = '环境状态检测失败'; return; }
|
|
1085
|
+
const rt = body.runtime || 'codex';
|
|
1086
|
+
const claudeOk = body.runtimes?.claude?.installed ? '✅' : '❌';
|
|
1087
|
+
const codexOk = body.runtimes?.codex?.installed ? '✅' : '❌';
|
|
1088
|
+
const initOk = body.initComplete ? '✅' : '❌';
|
|
1089
|
+
if (body.ready) {
|
|
1090
|
+
badge.textContent = '✅ 就绪';
|
|
1091
|
+
badge.className = 'channel-state ok';
|
|
1092
|
+
detail.innerHTML = `运行时 ${escapeHtml(rt)} · CLI ${rt === 'claude' ? claudeOk : codexOk} · 初始化 ${initOk}`;
|
|
1093
|
+
actions.style.display = 'none';
|
|
1094
|
+
return;
|
|
1095
|
+
}
|
|
1096
|
+
badge.textContent = '⚠ 未就绪';
|
|
1097
|
+
badge.className = 'channel-state warn';
|
|
1098
|
+
const gapLines = (body.gaps || []).map((g) => `<li>${escapeHtml(g)}</li>`).join('');
|
|
1099
|
+
detail.innerHTML = `<ul style="margin:4px 0;padding-left:18px">${gapLines || '<li>环境未就绪</li>'}</ul>`
|
|
1100
|
+
+ `<span style="font-size:12px;opacity:.8">Codex ${codexOk} · Claude ${claudeOk} · 初始化 ${initOk}</span>`;
|
|
1101
|
+
actions.style.display = 'block';
|
|
1102
|
+
renderReinitStatus(basePath);
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
async function renderReinitStatus(basePath) {
|
|
1106
|
+
const prog = document.getElementById('env-reinit-progress');
|
|
1107
|
+
const logBox = document.getElementById('env-reinit-log');
|
|
1108
|
+
const logBody = document.getElementById('env-reinit-log-body');
|
|
1109
|
+
if (!prog) return;
|
|
1110
|
+
const { status, body } = await adminFetch(basePath, '/api/admin/environment/reinit-status');
|
|
1111
|
+
if (status !== 200 || !body || body.status === 'idle') { prog.textContent = ''; return; }
|
|
1112
|
+
if (body.status === 'running') {
|
|
1113
|
+
prog.textContent = '正在重新初始化(补全环境配置,约 1-2 分钟)...';
|
|
1114
|
+
logBox.style.display = 'none';
|
|
1115
|
+
} else if (body.status === 'done') {
|
|
1116
|
+
prog.innerHTML = '<span class="channel-state ok">✅ 初始化完成,环境已就绪,会话拉起中...</span>';
|
|
1117
|
+
logBox.style.display = 'none';
|
|
1118
|
+
setTimeout(() => renderEnvironmentStatus(basePath), 5000);
|
|
1119
|
+
loadAdminStatus(basePath);
|
|
1120
|
+
} else if (body.status === 'failed') {
|
|
1121
|
+
prog.innerHTML = `<span class="channel-state warn">⚠ 初始化失败:${escapeHtml(body.human || '未知原因')}</span>`;
|
|
1122
|
+
if (body.logTail) {
|
|
1123
|
+
logBody.textContent = body.logTail;
|
|
1124
|
+
logBox.style.display = 'block';
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
function startReinitFromAdmin(basePath) {
|
|
1130
|
+
const btn = document.getElementById('btn-env-reinit');
|
|
1131
|
+
if (!btn) return;
|
|
1132
|
+
btn.disabled = true;
|
|
1133
|
+
btn.textContent = '重新初始化中...';
|
|
1134
|
+
setAdminMsg('正在重新初始化环境(幂等,不会清除已有数据)...');
|
|
1135
|
+
adminFetch(basePath, '/api/admin/environment/reinit', { method: 'POST' }).then(({ body }) => {
|
|
1136
|
+
if (!body?.reinit) { setAdminMsg('重新初始化未能启动', true); btn.disabled = false; btn.textContent = '重新初始化(修复环境)'; return; }
|
|
1137
|
+
const iv = setInterval(async () => {
|
|
1138
|
+
const { status, body: b } = await adminFetch(basePath, '/api/admin/environment/reinit-status');
|
|
1139
|
+
if (status !== 200 || !b) return;
|
|
1140
|
+
renderReinitStatus(basePath);
|
|
1141
|
+
if (b.status === 'done' || b.status === 'failed') {
|
|
1142
|
+
clearInterval(iv);
|
|
1143
|
+
btn.disabled = false;
|
|
1144
|
+
btn.textContent = '重新初始化(修复环境)';
|
|
1145
|
+
}
|
|
1146
|
+
}, 3000);
|
|
1147
|
+
});
|
|
1148
|
+
}
|
|
1149
|
+
|
|
984
1150
|
async function restartSessionFromAdmin(basePath) {
|
|
985
1151
|
setAdminMsg('正在重启 agent 会话...');
|
|
986
1152
|
const { body } = await adminFetch(basePath, '/api/admin/restart', { method: 'POST' });
|
|
@@ -2319,6 +2485,16 @@ async function renderDiagnostics(basePath) {
|
|
|
2319
2485
|
}
|
|
2320
2486
|
const rt = body.runtime || {};
|
|
2321
2487
|
if (stateEl) stateEl.textContent = `runtime: ${escapeHtml(rt.runtime || '?')}`;
|
|
2488
|
+
// init state (D40): surface init failure so the user can act in-browser
|
|
2489
|
+
const init = body.init || null;
|
|
2490
|
+
if (init && init.status === 'failed') {
|
|
2491
|
+
const initEl = document.getElementById('diag-init-state');
|
|
2492
|
+
if (initEl) {
|
|
2493
|
+
initEl.innerHTML = '<span class="channel-state warn">⚠ 初始化未完成:'
|
|
2494
|
+
+ `${escapeHtml(init.error || '未知原因')}</span> —— 请在「模型设置」安装/重试对应 runtime(Claude Code 或 Codex),然后重启 agent 会话。`
|
|
2495
|
+
+ (init.at ? ` <small>(${escapeHtml(String(init.at))})</small>` : '');
|
|
2496
|
+
}
|
|
2497
|
+
}
|
|
2322
2498
|
// runtime + auth
|
|
2323
2499
|
const auth = body.auth || {};
|
|
2324
2500
|
const authLine = [];
|
|
@@ -2382,6 +2558,8 @@ function showAppView(basePath, view) {
|
|
|
2382
2558
|
schedulerView.hidden = true;
|
|
2383
2559
|
modelView.hidden = false;
|
|
2384
2560
|
renderDiagnostics(basePath);
|
|
2561
|
+
renderRuntimeInstallBadges(basePath);
|
|
2562
|
+
renderEnvironmentStatus(basePath);
|
|
2385
2563
|
setNav(navModel, [navChat, navChannels, navA2a, navScheduler]);
|
|
2386
2564
|
renderModelSettings(basePath);
|
|
2387
2565
|
} else if (view === 'channels') {
|
|
@@ -2587,6 +2765,7 @@ function initViews(basePath) {
|
|
|
2587
2765
|
});
|
|
2588
2766
|
document.getElementById('btn-switch-runtime').addEventListener('click', () => switchRuntimeFromAdmin(basePath));
|
|
2589
2767
|
document.getElementById('btn-restart-session').addEventListener('click', () => restartSessionFromAdmin(basePath));
|
|
2768
|
+
document.getElementById('btn-env-reinit')?.addEventListener('click', () => startReinitFromAdmin(basePath));
|
|
2590
2769
|
|
|
2591
2770
|
document.getElementById('form-provider').addEventListener('submit', (e) => {
|
|
2592
2771
|
e.preventDefault();
|
|
@@ -109,14 +109,27 @@
|
|
|
109
109
|
<!-- Section 0: Current connection summary -->
|
|
110
110
|
<section class="settings-section">
|
|
111
111
|
<div class="settings-section-head">
|
|
112
|
-
<h2
|
|
113
|
-
<p
|
|
112
|
+
<h2>运行时与连接</h2>
|
|
113
|
+
<p>环境未就绪时,请先完成「环境初始化」(可自动安装缺失组件并补全配置),再配置运行时接入方式。</p>
|
|
114
|
+
</div>
|
|
115
|
+
<div class="settings-card">
|
|
116
|
+
<div class="card-title">环境初始化 <span class="channel-state" id="env-state-badge"></span></div>
|
|
117
|
+
<div class="cred-state" id="env-state-detail" aria-live="polite">检测中...</div>
|
|
118
|
+
<div class="settings-actions" id="env-reinit-actions" style="display:none">
|
|
119
|
+
<button type="button" class="btn btn-primary" id="btn-env-reinit">重新初始化(一键修复环境)</button>
|
|
120
|
+
</div>
|
|
121
|
+
<div class="cred-state" id="env-reinit-progress" aria-live="polite"></div>
|
|
122
|
+
<details id="env-reinit-log" style="display:none;margin-top:8px">
|
|
123
|
+
<summary>查看初始化日志</summary>
|
|
124
|
+
<pre style="font-size:12px;background:var(--bg-2,#1a1a1a);padding:8px;border-radius:6px;white-space:pre-wrap;max-height:300px;overflow:auto" id="env-reinit-log-body"></pre>
|
|
125
|
+
</details>
|
|
114
126
|
</div>
|
|
115
127
|
<div class="settings-card">
|
|
116
128
|
<div class="conn-summary" id="conn-summary" role="status" aria-live="polite">
|
|
117
|
-
<div class="conn-summary-row"><span class="conn-summary-name">Codex</span><span class="conn-summary-detail" id="summary-codex">加载中...</span></div>
|
|
118
|
-
<div class="conn-summary-row"><span class="conn-summary-name">Claude</span><span class="conn-summary-detail" id="summary-claude">加载中...</span></div>
|
|
129
|
+
<div class="conn-summary-row"><span class="conn-summary-name">Codex</span><span class="conn-summary-detail" id="summary-codex">加载中...</span><span class="runtime-install-badge" id="rt-install-codex"></span></div>
|
|
130
|
+
<div class="conn-summary-row"><span class="conn-summary-name">Claude</span><span class="conn-summary-detail" id="summary-claude">加载中...</span><span class="runtime-install-badge" id="rt-install-claude"></span></div>
|
|
119
131
|
</div>
|
|
132
|
+
<div class="cred-state" id="rt-install-warning" aria-live="polite"></div>
|
|
120
133
|
<div class="settings-actions">
|
|
121
134
|
<button type="button" class="btn btn-outline" id="btn-switch-runtime">切换运行时</button>
|
|
122
135
|
<button type="button" class="btn btn-outline" id="btn-restart-session">重启 agent 会话</button>
|
|
@@ -239,6 +252,7 @@
|
|
|
239
252
|
<div class="settings-card">
|
|
240
253
|
<div class="card-title">运行时与认证 <span class="channel-state" id="diag-runtime-state"></span></div>
|
|
241
254
|
<div class="cred-state" id="diag-runtime" aria-live="polite">加载中...</div>
|
|
255
|
+
<div class="cred-state" id="diag-init-state" aria-live="polite"></div>
|
|
242
256
|
</div>
|
|
243
257
|
<div class="settings-card">
|
|
244
258
|
<div class="card-title">服务状态 <button type="button" class="small-btn" id="btn-diag-refresh">刷新</button></div>
|
|
@@ -495,8 +495,22 @@ export function cliPath() {
|
|
|
495
495
|
path.join(__dirname, '../../../cli/baize.js'),
|
|
496
496
|
path.join(__dirname, '../../../node_modules/@baize-ai/core/cli/baize.js'),
|
|
497
497
|
path.join(os.homedir(), '.npm-global', 'lib', 'node_modules', '@baize-ai', 'core', 'cli', 'baize.js'),
|
|
498
|
+
// Mainland install.sh layout: Node installed from the npmmirror binary
|
|
499
|
+
// mirror into ~/.local/node, so the global core package lives there.
|
|
500
|
+
path.join(os.homedir(), '.local', 'node', 'lib', 'node_modules', '@baize-ai', 'core', 'cli', 'baize.js'),
|
|
498
501
|
];
|
|
499
|
-
|
|
502
|
+
const found = candidates.find((c) => fs.existsSync(c));
|
|
503
|
+
if (found) return found;
|
|
504
|
+
// Dynamic fallback: resolve via the user's actual global npm root — covers
|
|
505
|
+
// any prefix (mainland ~/.local/node, homebrew, nvm, custom) when npm is on
|
|
506
|
+
// PATH. Static candidates take precedence so source-tree / skills-copy
|
|
507
|
+
// layouts always win.
|
|
508
|
+
try {
|
|
509
|
+
const root = execFileSync('npm', ['root', '-g'], { encoding: 'utf8', timeout: 10000, stdio: 'pipe' }).trim();
|
|
510
|
+
const p = path.join(root, '@baize-ai', 'core', 'cli', 'baize.js');
|
|
511
|
+
if (fs.existsSync(p)) return p;
|
|
512
|
+
} catch { /* npm unavailable */ }
|
|
513
|
+
return null;
|
|
500
514
|
}
|
|
501
515
|
|
|
502
516
|
function runCli(args, deps = {}) {
|
|
@@ -126,13 +126,57 @@ function readErrors() {
|
|
|
126
126
|
return errors;
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
+
/** Init state from .baize/init-state.json (written by entrypoint/init). */
|
|
130
|
+
function readInitState() {
|
|
131
|
+
try {
|
|
132
|
+
const p = path.join(process.env.BAIZE_DIR || path.join(os.homedir(), 'baize'), '.baize', 'init-state.json');
|
|
133
|
+
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
134
|
+
} catch { return null; }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Detect whether a runtime CLI is installed (PATH) + its version.
|
|
138
|
+
* Note: gate on `command -v` — a plain `<bin> --version | head -1` pipeline
|
|
139
|
+
* exits with head's status (0), so a missing binary would read as installed. */
|
|
140
|
+
function checkRuntimeBinary(bin) {
|
|
141
|
+
return new Promise((resolve) => {
|
|
142
|
+
execFile('sh', ['-c', `command -v ${bin} >/dev/null 2>&1 && ${bin} --version 2>/dev/null | head -1`], { timeout: 5000 }, (err, stdout) => {
|
|
143
|
+
resolve({ installed: !err, version: err ? null : (String(stdout).trim().split('\n')[0] || null) });
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Read the D39 install state file (failure reason from an async web install). */
|
|
149
|
+
function readRuntimeInstallState(runtime) {
|
|
150
|
+
try {
|
|
151
|
+
const p = path.join(process.env.BAIZE_DIR || path.join(os.homedir(), 'baize'), '.baize', `runtime-install-${runtime}.json`);
|
|
152
|
+
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
153
|
+
} catch { return null; }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Per-runtime install status: installed + version, plus the last install
|
|
158
|
+
* failure reason (D39 state file) so the console can tell the user exactly
|
|
159
|
+
* which runtime is missing and why.
|
|
160
|
+
*/
|
|
161
|
+
export async function readRuntimes() {
|
|
162
|
+
const [claude, codex] = await Promise.all([checkRuntimeBinary('claude'), checkRuntimeBinary('codex')]);
|
|
163
|
+
const enrich = (status, runtime) => {
|
|
164
|
+
const st = readRuntimeInstallState(runtime);
|
|
165
|
+
if (st && st.status === 'failed') status.installError = st.error || null;
|
|
166
|
+
return status;
|
|
167
|
+
};
|
|
168
|
+
return { claude: enrich(claude, 'claude'), codex: enrich(codex, 'codex') };
|
|
169
|
+
}
|
|
170
|
+
|
|
129
171
|
/** Full diagnostics payload. */
|
|
130
172
|
export async function readDiagnostics() {
|
|
131
|
-
const [services, sessions] = await Promise.all([readServices(), readSessions()]);
|
|
173
|
+
const [services, sessions, runtimes] = await Promise.all([readServices(), readSessions(), readRuntimes()]);
|
|
132
174
|
return {
|
|
133
175
|
success: true,
|
|
134
176
|
at: new Date().toISOString(),
|
|
135
177
|
runtime: readRuntime(),
|
|
178
|
+
init: readInitState(),
|
|
179
|
+
runtimes,
|
|
136
180
|
services,
|
|
137
181
|
sessions,
|
|
138
182
|
auth: readAuth(),
|
|
@@ -15,7 +15,7 @@ import crypto from 'crypto';
|
|
|
15
15
|
import path from 'path';
|
|
16
16
|
import os from 'os';
|
|
17
17
|
import fs from 'fs';
|
|
18
|
-
import { spawn } from 'child_process';
|
|
18
|
+
import { spawn, execFile } from 'child_process';
|
|
19
19
|
import Database from 'better-sqlite3';
|
|
20
20
|
import { fileURLToPath } from 'url';
|
|
21
21
|
import {
|
|
@@ -834,9 +834,122 @@ app.get('/api/admin/diagnostics', async (req, res) => {
|
|
|
834
834
|
}
|
|
835
835
|
});
|
|
836
836
|
|
|
837
|
+
// ── Runtime install (D39): async install when the target runtime binary is
|
|
838
|
+
// missing — detached spawn (900s timeout + 2 retries), then auto-switch.
|
|
839
|
+
// No task framework: a state JSON + log file are the status.
|
|
840
|
+
const RUNTIME_INSTALL = {
|
|
841
|
+
claude: { bin: 'claude', cmd: 'curl -fsSL https://claude.ai/install.sh | bash' },
|
|
842
|
+
codex: { bin: 'codex', cmd: 'npm install -g @openai/codex' },
|
|
843
|
+
};
|
|
844
|
+
|
|
845
|
+
function runtimeInstallStateFile(runtime) {
|
|
846
|
+
return path.join(BAIZE_DIR, '.baize', `runtime-install-${runtime}.json`);
|
|
847
|
+
}
|
|
848
|
+
function runtimeInstallLogFile(runtime) {
|
|
849
|
+
return path.join(BAIZE_DIR, '.baize', `runtime-install-${runtime}.log`);
|
|
850
|
+
}
|
|
851
|
+
function readRuntimeInstallState(runtime) {
|
|
852
|
+
try { return JSON.parse(fs.readFileSync(runtimeInstallStateFile(runtime), 'utf8')); } catch { return null; }
|
|
853
|
+
}
|
|
854
|
+
function writeRuntimeInstallState(runtime, state) {
|
|
855
|
+
try {
|
|
856
|
+
fs.mkdirSync(path.join(BAIZE_DIR, '.baize'), { recursive: true });
|
|
857
|
+
fs.writeFileSync(runtimeInstallStateFile(runtime), JSON.stringify(state));
|
|
858
|
+
} catch { /* non-fatal */ }
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
function runtimeInstalled(runtime) {
|
|
862
|
+
const bin = RUNTIME_INSTALL[runtime]?.bin;
|
|
863
|
+
if (!bin) return Promise.resolve(true); // unknown runtime: don't gate
|
|
864
|
+
return new Promise((resolve) => {
|
|
865
|
+
execFile('sh', ['-c', `command -v ${bin}`], { timeout: 5000 }, (err) => resolve(!err));
|
|
866
|
+
});
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
function startRuntimeInstall(runtime) {
|
|
870
|
+
const spec = RUNTIME_INSTALL[runtime];
|
|
871
|
+
if (!spec) return false;
|
|
872
|
+
const cur = readRuntimeInstallState(runtime);
|
|
873
|
+
if (cur && cur.status === 'installing') {
|
|
874
|
+
// Stale-guard: a crashed/killed installer (OOM, container restart) would
|
|
875
|
+
// otherwise wedge the target forever. Budget = 3 attempts x 900s + 240s
|
|
876
|
+
// switch + buffer.
|
|
877
|
+
const started = Date.parse(cur.startedAt || '');
|
|
878
|
+
if (Number.isNaN(started) || Date.now() - started < 55 * 60 * 1000) {
|
|
879
|
+
return false; // genuinely running (or recent) — don't duplicate
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
writeRuntimeInstallState(runtime, { status: 'installing', startedAt: new Date().toISOString() });
|
|
884
|
+
|
|
885
|
+
const script = `
|
|
886
|
+
const { spawn } = require('child_process');
|
|
887
|
+
const fs = require('fs');
|
|
888
|
+
const LOG = ${JSON.stringify(runtimeInstallLogFile(runtime))};
|
|
889
|
+
const STATE = ${JSON.stringify(runtimeInstallStateFile(runtime))};
|
|
890
|
+
const CMD = ${JSON.stringify(spec.cmd)};
|
|
891
|
+
const RUNTIME = ${JSON.stringify(runtime)};
|
|
892
|
+
const log = (s) => { try { fs.appendFileSync(LOG, s + '\\n'); } catch {} };
|
|
893
|
+
const run = (cmd, timeoutMs) => new Promise((resolve, reject) => {
|
|
894
|
+
const child = spawn('bash', ['-c', cmd], { detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
895
|
+
let out = '';
|
|
896
|
+
child.stdout.on('data', (d) => { out += d; if (out.length > 4000) { log(out); out = ''; } });
|
|
897
|
+
child.stderr.on('data', (d) => { out += d; if (out.length > 4000) { log(out); out = ''; } });
|
|
898
|
+
const killTree = () => { try { process.kill(-child.pid, 'SIGKILL'); } catch { try { child.kill('SIGKILL'); } catch {} } };
|
|
899
|
+
const t = setTimeout(() => { killTree(); reject(new Error('timeout ' + timeoutMs + 'ms')); }, timeoutMs);
|
|
900
|
+
child.on('close', (code) => { clearTimeout(t); if (out) log(out); code === 0 ? resolve() : reject(new Error('exit ' + code)); });
|
|
901
|
+
});
|
|
902
|
+
const update = (s) => { try { fs.writeFileSync(STATE, JSON.stringify(s)); } catch {} };
|
|
903
|
+
(async () => {
|
|
904
|
+
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
905
|
+
log('[install] attempt ' + attempt + ' starting: ' + CMD);
|
|
906
|
+
try {
|
|
907
|
+
await run(CMD, 900000);
|
|
908
|
+
log('[install] installed OK');
|
|
909
|
+
break;
|
|
910
|
+
} catch (e) {
|
|
911
|
+
log('[install] attempt ' + attempt + ' failed: ' + e.message);
|
|
912
|
+
if (attempt === 3) {
|
|
913
|
+
update({ status: 'failed', error: e.message, doneAt: new Date().toISOString() });
|
|
914
|
+
process.exit(1);
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
log('[install] switching runtime to ' + RUNTIME);
|
|
919
|
+
try {
|
|
920
|
+
await run('baize runtime ' + RUNTIME + ' --no-validate', 240000);
|
|
921
|
+
update({ status: 'done', doneAt: new Date().toISOString() });
|
|
922
|
+
log('[install] switch complete');
|
|
923
|
+
} catch (e) {
|
|
924
|
+
update({ status: 'failed', error: 'switch failed: ' + e.message, doneAt: new Date().toISOString() });
|
|
925
|
+
log('[install] switch failed: ' + e.message);
|
|
926
|
+
}
|
|
927
|
+
})();
|
|
928
|
+
`;
|
|
929
|
+
try {
|
|
930
|
+
const scriptPath = path.join(BAIZE_DIR, '.baize', `runtime-install-${runtime}.cjs`);
|
|
931
|
+
fs.mkdirSync(path.join(BAIZE_DIR, '.baize'), { recursive: true });
|
|
932
|
+
fs.writeFileSync(scriptPath, script);
|
|
933
|
+
const child = spawn(process.execPath, [scriptPath], { detached: true, stdio: 'ignore' });
|
|
934
|
+
child.unref();
|
|
935
|
+
return true;
|
|
936
|
+
} catch {
|
|
937
|
+
writeRuntimeInstallState(runtime, null); // don't leave a stuck 'installing'
|
|
938
|
+
return false;
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
|
|
837
942
|
app.post('/api/admin/runtime', async (req, res) => {
|
|
838
943
|
try {
|
|
839
|
-
const
|
|
944
|
+
const runtime = req.body?.runtime;
|
|
945
|
+
if (!['claude', 'codex'].includes(runtime)) {
|
|
946
|
+
return res.status(400).json({ success: false, error: `Invalid runtime: ${runtime}` });
|
|
947
|
+
}
|
|
948
|
+
if (!(await runtimeInstalled(runtime))) {
|
|
949
|
+
startRuntimeInstall(runtime);
|
|
950
|
+
return res.json({ success: true, installing: true, runtime });
|
|
951
|
+
}
|
|
952
|
+
const result = await switchRuntime(runtime);
|
|
840
953
|
if (result.success) result.restart = await restartAgentSession();
|
|
841
954
|
res.status(result.success ? 200 : 400).json(result);
|
|
842
955
|
} catch (err) {
|
|
@@ -844,6 +957,181 @@ app.post('/api/admin/runtime', async (req, res) => {
|
|
|
844
957
|
}
|
|
845
958
|
});
|
|
846
959
|
|
|
960
|
+
// ── Environment status + one-click re-init (D42) ───────────────────────────
|
|
961
|
+
// Web is the only recovery surface: detect init gaps, trigger the idempotent
|
|
962
|
+
// `baize init`, and surface failure reasons (human summary + raw log) in-browser.
|
|
963
|
+
const REINIT_LOG = path.join(BAIZE_DIR, '.baize', 'reinit.log');
|
|
964
|
+
const REINIT_STATE = path.join(BAIZE_DIR, '.baize', 'reinit.json');
|
|
965
|
+
|
|
966
|
+
function readReinitState() {
|
|
967
|
+
try { return JSON.parse(fs.readFileSync(REINIT_STATE, 'utf8')); } catch { return null; }
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
function runtimeBinInstalled(bin) {
|
|
971
|
+
return new Promise((resolve) => {
|
|
972
|
+
execFile('sh', ['-c', `command -v ${bin} >/dev/null 2>&1`], { timeout: 5000 }, (err) => resolve(!err));
|
|
973
|
+
});
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
/** Init completeness: config.json with a runtime. The instruction file may be
|
|
977
|
+
* in the split-instruction pending-migration state without blocking the
|
|
978
|
+
* session (Guardian still launches), so it is not a readiness condition. */
|
|
979
|
+
function initComplete() {
|
|
980
|
+
try {
|
|
981
|
+
const cfg = JSON.parse(fs.readFileSync(path.join(BAIZE_DIR, '.baize', 'config.json'), 'utf8'));
|
|
982
|
+
return !!(cfg && cfg.runtime);
|
|
983
|
+
} catch { return false; }
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
function readCurrentRuntime() {
|
|
987
|
+
try {
|
|
988
|
+
const cfg = JSON.parse(fs.readFileSync(path.join(BAIZE_DIR, '.baize', 'config.json'), 'utf8'));
|
|
989
|
+
if (cfg && (cfg.runtime === 'codex' || cfg.runtime === 'claude')) return cfg.runtime;
|
|
990
|
+
} catch {}
|
|
991
|
+
return 'codex'; // mainland-safe default (init falls back to claude otherwise)
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
/** Map common failure text to a human-readable (Chinese) summary. */
|
|
995
|
+
function humanError(summary) {
|
|
996
|
+
const map = [
|
|
997
|
+
[/claude\.ai/i, '无法连接 Claude 官方服务(大陆不可达)——请使用 Codex 或第三方兼容端点'],
|
|
998
|
+
[/ETIMEDOUT|ECONNRESET|ECONNREFUSED|network.*(timeout|refused)|i\/o timeout/i, '网络连接失败(超时/被拒绝)——请检查网络后重试'],
|
|
999
|
+
[/registry\.npmjs\.org|npmmirror/i, '无法连接 npm 源——请检查网络或 npm 源配置'],
|
|
1000
|
+
[/Failed to install (Codex|claude)|Failed to install/i, '运行时 CLI 安装失败——请检查网络后重试'],
|
|
1001
|
+
[/permission denied|EACCES/i, '权限不足——请检查目录权限后重试'],
|
|
1002
|
+
[/command not found/i, '缺少必要命令——请检查环境'],
|
|
1003
|
+
];
|
|
1004
|
+
for (const [re, msg] of map) {
|
|
1005
|
+
if (re.test(summary)) return msg;
|
|
1006
|
+
}
|
|
1007
|
+
return null;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
function startReinit() {
|
|
1011
|
+
const cur = readReinitState();
|
|
1012
|
+
if (cur && cur.status === 'running') {
|
|
1013
|
+
// Stale-guard: a crashed/killed reinit would otherwise wedge the button
|
|
1014
|
+
// forever. Budget = init install window (900s) + buffer.
|
|
1015
|
+
const started = Date.parse(cur.startedAt || '');
|
|
1016
|
+
if (Number.isNaN(started) || Date.now() - started < 30 * 60 * 1000) {
|
|
1017
|
+
return false;
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
const runtime = readCurrentRuntime();
|
|
1021
|
+
const now = new Date().toISOString();
|
|
1022
|
+
try {
|
|
1023
|
+
fs.mkdirSync(path.join(BAIZE_DIR, '.baize'), { recursive: true });
|
|
1024
|
+
fs.writeFileSync(REINIT_STATE, JSON.stringify({ status: 'running', runtime, startedAt: now }));
|
|
1025
|
+
fs.writeFileSync(REINIT_LOG, `[reinit] started at ${now} (runtime=${runtime})
|
|
1026
|
+
`);
|
|
1027
|
+
} catch { return false; }
|
|
1028
|
+
const script = `
|
|
1029
|
+
const { spawn } = require('child_process');
|
|
1030
|
+
const fs = require('fs');
|
|
1031
|
+
const LOG = ${JSON.stringify(REINIT_LOG)};
|
|
1032
|
+
const STATE = ${JSON.stringify(REINIT_STATE)};
|
|
1033
|
+
const RUNTIME = ${JSON.stringify(runtime)};
|
|
1034
|
+
const log = (s) => { try { fs.appendFileSync(LOG, s + '\\n'); } catch {} };
|
|
1035
|
+
const run = (cmd, timeoutMs) => new Promise((resolve, reject) => {
|
|
1036
|
+
const child = spawn('bash', ['-c', cmd], { detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
1037
|
+
let out = '';
|
|
1038
|
+
child.stdout.on('data', (d) => { out += d; if (out.length > 8000) { log(out); out = ''; } });
|
|
1039
|
+
child.stderr.on('data', (d) => { out += d; if (out.length > 8000) { log(out); out = ''; } });
|
|
1040
|
+
const killTree = () => { try { process.kill(-child.pid, 'SIGKILL'); } catch { try { child.kill('SIGKILL'); } catch {} } };
|
|
1041
|
+
const t = setTimeout(() => { killTree(); reject(new Error('timeout ' + timeoutMs + 'ms')); }, timeoutMs);
|
|
1042
|
+
child.on('close', (code) => { clearTimeout(t); if (out) log(out); code === 0 ? resolve() : reject(new Error('exit ' + code)); });
|
|
1043
|
+
});
|
|
1044
|
+
const update = (s) => { try { fs.writeFileSync(STATE, JSON.stringify(s)); } catch {} };
|
|
1045
|
+
(async () => {
|
|
1046
|
+
log('[reinit] running baize init (idempotent)...');
|
|
1047
|
+
try {
|
|
1048
|
+
await run('baize init --yes --quiet --runtime ' + RUNTIME, 900000);
|
|
1049
|
+
log('[reinit] init OK');
|
|
1050
|
+
update({ status: 'done', doneAt: new Date().toISOString(), runtime: RUNTIME });
|
|
1051
|
+
} catch (e) {
|
|
1052
|
+
log('[reinit] init failed: ' + e.message);
|
|
1053
|
+
update({ status: 'failed', error: e.message, doneAt: new Date().toISOString(), runtime: RUNTIME });
|
|
1054
|
+
}
|
|
1055
|
+
})();
|
|
1056
|
+
`;
|
|
1057
|
+
try {
|
|
1058
|
+
const scriptPath = path.join(BAIZE_DIR, '.baize', 'reinit.cjs');
|
|
1059
|
+
fs.writeFileSync(scriptPath, script);
|
|
1060
|
+
const child = spawn(process.execPath, [scriptPath], { detached: true, stdio: 'ignore' });
|
|
1061
|
+
child.unref();
|
|
1062
|
+
return true;
|
|
1063
|
+
} catch {
|
|
1064
|
+
try { fs.writeFileSync(REINIT_STATE, JSON.stringify({ status: 'failed', error: '无法启动初始化进程', doneAt: new Date().toISOString() })); } catch {}
|
|
1065
|
+
return false;
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
// Environment status: gaps in plain language + readiness.
|
|
1070
|
+
app.get('/api/admin/environment/status', async (req, res) => {
|
|
1071
|
+
try {
|
|
1072
|
+
const [claude, codex] = await Promise.all([runtimeBinInstalled('claude'), runtimeBinInstalled('codex')]);
|
|
1073
|
+
const runtime = readCurrentRuntime();
|
|
1074
|
+
const initDone = initComplete();
|
|
1075
|
+
const gaps = [];
|
|
1076
|
+
if (!claude && !codex) gaps.push('运行时 CLI(Claude Code / Codex)均未安装');
|
|
1077
|
+
else if (runtime === 'claude' && !claude) gaps.push('当前运行时 Claude Code 未安装');
|
|
1078
|
+
else if (runtime === 'codex' && !codex) gaps.push('当前运行时 Codex 未安装');
|
|
1079
|
+
if (!initDone) gaps.push('环境初始化未完成(配置缺失)');
|
|
1080
|
+
res.json({
|
|
1081
|
+
success: true,
|
|
1082
|
+
runtime,
|
|
1083
|
+
runtimes: { claude: { installed: claude }, codex: { installed: codex } },
|
|
1084
|
+
initComplete: initDone,
|
|
1085
|
+
ready: (runtime === 'claude' ? claude : codex) && initDone,
|
|
1086
|
+
gaps,
|
|
1087
|
+
});
|
|
1088
|
+
} catch (err) {
|
|
1089
|
+
jsonError(res, err);
|
|
1090
|
+
}
|
|
1091
|
+
});
|
|
1092
|
+
|
|
1093
|
+
// Trigger the idempotent re-init (async; poll reinit-status).
|
|
1094
|
+
app.post('/api/admin/environment/reinit', (req, res) => {
|
|
1095
|
+
try {
|
|
1096
|
+
const started = startReinit();
|
|
1097
|
+
res.json({ success: true, reinit: started });
|
|
1098
|
+
} catch (err) {
|
|
1099
|
+
jsonError(res, err);
|
|
1100
|
+
}
|
|
1101
|
+
});
|
|
1102
|
+
|
|
1103
|
+
// Re-init progress/result + human-readable failure reason + log tail.
|
|
1104
|
+
app.get('/api/admin/environment/reinit-status', (req, res) => {
|
|
1105
|
+
try {
|
|
1106
|
+
const state = readReinitState();
|
|
1107
|
+
if (!state) return res.json({ success: true, status: 'idle' });
|
|
1108
|
+
let logTail = '';
|
|
1109
|
+
try { logTail = fs.readFileSync(REINIT_LOG, 'utf8').split('\n').filter(Boolean).slice(-20).join('\n'); } catch {}
|
|
1110
|
+
const human = state.status === 'failed' ? (humanError((state.error || '') + '\n' + logTail) || '初始化失败(详见日志)') : null;
|
|
1111
|
+
res.json({ success: true, status: state.status, error: state.error, human, logTail, runtime: state.runtime });
|
|
1112
|
+
} catch (err) {
|
|
1113
|
+
jsonError(res, err);
|
|
1114
|
+
}
|
|
1115
|
+
});
|
|
1116
|
+
|
|
1117
|
+
app.get('/api/admin/runtime/install-status', (req, res) => {
|
|
1118
|
+
try {
|
|
1119
|
+
const runtime = req.query?.runtime;
|
|
1120
|
+
if (!['claude', 'codex'].includes(runtime)) {
|
|
1121
|
+
return res.status(400).json({ success: false, error: `Invalid runtime: ${runtime}` });
|
|
1122
|
+
}
|
|
1123
|
+
const state = readRuntimeInstallState(runtime);
|
|
1124
|
+
if (!state) return res.json({ success: true, status: 'idle' });
|
|
1125
|
+
let logTail = '';
|
|
1126
|
+
try {
|
|
1127
|
+
logTail = fs.readFileSync(runtimeInstallLogFile(runtime), 'utf8').split('\n').filter(Boolean).slice(-10).join('\n');
|
|
1128
|
+
} catch {}
|
|
1129
|
+
res.json({ success: true, status: state.status, error: state.error, logTail });
|
|
1130
|
+
} catch (err) {
|
|
1131
|
+
jsonError(res, err);
|
|
1132
|
+
}
|
|
1133
|
+
});
|
|
1134
|
+
|
|
847
1135
|
app.post('/api/admin/restart', async (req, res) => {
|
|
848
1136
|
try {
|
|
849
1137
|
res.json(await restartAgentSession());
|