feat: add guided one-command installer

This commit is contained in:
nac
2026-08-15 03:47:17 +00:00
parent 6316732b23
commit 42851a3d90
10 changed files with 283 additions and 125 deletions
+55 -111
View File
@@ -1,153 +1,97 @@
# Agent 安装入口:NocoBase CRM MCP
# 安装 NocoBase CRM MCP
这是一份供任意终端型 Agent 执行、也供用户审阅的安装协议。它不依赖 Codex、Claude Code、Cursor、OpenCode 或其他单一产品的插件格式。MCP 是可移植的功能层;Agent 只需把最后生成的 stdio 启动配置转换成自己客户端支持的格式。
这是一份供终端型 AI Agent 执行的通用安装协议,不依赖 Codex、Claude Code、Cursor、OpenCode 或其他特定产品。用户只需要提供本链接、确认一次本机变更,并在隐藏提示中输入自己的 NocoBase API Key。
如果你是收到本链接的 Agent:请完整阅读本文后再执行命令,按顺序持续工作到“完成条件”全部满足。不要把本文当成需要原样转发给用户的普通说明;你应根据操作系统、Shell、已有软件和当前 Agent 客户端自适应执行,并向用户报告关键检查点。
如果你是安装 Agent,请直接完成下面的流程,不要把命令转交给用户逐条尝试。只有在需要用户确认安装内容、提供 NocoBase 地址或在隐藏提示中粘贴 Key 时才打断用户。
## 不可变安全规则
## 完成目标
1. 只安装 [`install-manifest.json`](install-manifest.json) 指定的仓库和 `releaseRef`,不得默认安装 `master`、`main`、开发分支或其他未固定版本。
2. 不使用 `curl | sh`、`wget | sh` 或同类远程脚本管道。缺少全局依赖时,先说明来源、版本、影响范围并取得用户同意,再采用该工具的官方安装方式。
3. 不使用 `sudo`,不删除或覆盖未知目录,不丢弃已有 Git 改动。目标目录已存在时先识别它;不是本项目或工作区不干净时停止覆盖并向用户说明。
4. 认证默认使用用户自己的个人 API Key(Authorize Key),不使用账号密码。Agent 可以引导用户在 NocoBase 页面创建 Key,但不得要求用户把 Key 发到聊天;Key 只能由用户粘贴到本机、用户可控、隐藏回显的安全输入提示中。
5. 默认只读。除非用户明确要求且管理员已确认 NocoBase ACL,否则不得启用 `--enable-writes`、附件上传或原始数据输出。
6. 写入 Agent/MCP 客户端配置前,显示目标文件和将新增的 server 名称,创建可恢复备份,并只合并 `nocobase-crm` 条目;不得覆盖其他 MCP 配置。
7. 不把本机 MCP 端口暴露到局域网或公网。通用安装默认使用 stdio,不创建后台服务,也不生成共享业务凭证。
安装完成时必须同时满足:
## 安装输入
- 使用固定 Release `v0.2.0`,不是开发分支;
- MCP 在用户本机以 stdio 运行,默认只读;
- NocoBase 认证属于当前用户自己的个人 Key 和业务角色;
- 当前 MCP 客户端已注册 `nocobase-crm`;
- `crm_health` 返回 `ok=true`。
从用户或当前上下文确定以下内容;已有明确答案时不要重复提问:
## 不可违反的安全规则
- NocoBase 应用 URL,例如 `https://nocobase.example`。安装器会规范化为以 `/api` 结尾;远程环境必须使用 HTTPS。
- 安装目录。使用该操作系统的普通用户应用/数据目录作为默认值,并在写入前告知用户;不要默认放在系统目录。
- 当前 Agent/MCP 客户端的名称及其本机 MCP 配置位置。
- 功能模式。未明确选择时必须使用“只读”。
- 不索取账号密码,不使用共享服务账号或管理员 Key。
- 不让用户把 Key 发到聊天、命令参数、环境变量或临时文件。
- Key 只能由用户粘贴到 `nb` CLI 的本机隐藏 TTY 提示。
- 不使用 `sudo`,不执行 `curl | sh` / `wget | sh`。
- 写 MCP 客户端配置前必须展示目标路径、创建备份并结构化合并,不能覆盖其他 server。
- 除非用户明确要求且管理员已确认 ACL,否则不启用写入、上传或原始数据。
- 默认不创建后台服务、不监听端口、不暴露到局域网或公网。
不要要求用户在 Agent 对话中提供 NocoBase Key、密码、Token、Cookie 或 `~/.nocobase` 目录。Key 只进入 `nb` CLI 自己的本机隐藏提示。
## 一次安装流程
## 执行协议
### 1. 确认输入和访问权限
### 1. 读取并锁定发布版本
从用户或上下文取得:
读取同版本的 `install-manifest.json`。本版本的预期来源是:
- NocoBase 应用 URL,例如 `https://nocobase.example`;
- 普通用户安装目录;
- 当前 Agent/MCP 客户端及其配置路径。
```text
repository: https://github.com/Donaldttt/nocobase-mcp.git
releaseRef: v0.2.0
```
本项目暂时通过 Private GitHub 仓库分发。当前 Agent 必须已经连接一个对 `Donaldttt/nocobase-mcp` 有只读权限的 GitHub 账号;无权读取时停止,不得退回 `master`。
若 `v0.2.0` 尚不存在,或 Agent 无权读取私有仓库,停止安装并清楚报告“发布物/访问权限尚未就绪”,不得退回开发分支。面向普通外部用户发布时,安装方必须能匿名读取本页和发布物;私有分发则需要用户已连接有权访问该仓库的 GitHub 账号。
### 2. 一次性说明本机变更
首次安装使用等效于以下语义的 Git 命令,并根据当前 Shell 正确转义路径:
检查 Git、uv、Python 3.12+ 和 NocoBase `nb` CLI 2.1+。若缺失,向用户一次性说明将安装的工具、官方来源和位置,并取得一次确认后再处理。只有缺少 `nb` 时才可能额外需要 Node.js/npm;官方 CLI 包为 `@nocobase/cli`。
用户安装会留下:固定版本仓库副本、项目 `.venv`、不含 Key 的 `config.json`、`nb` CLI 用户登录环境、MCP 客户端配置和配置备份。不会默认创建 systemd 服务或 HTTP Token。
### 3. 取得固定版本
安全克隆并验证 Release:
```bash
git clone --branch v0.2.0 --depth 1 https://github.com/Donaldttt/nocobase-mcp.git <install-dir>
git -C <install-dir> describe --tags --exact-match HEAD
```
第二条必须精确输出 `v0.2.0`。已有安装的更新不得直接 `git pull` 开发分支;先保存当前版本和 `config.json`,确认工作区干净,再获取并切换到用户指定的新 Release Tag。
第二条必须精确输出 `v0.2.0`。目标目录已存在但不是本项目、包含未提交修改或版本不明确时停止覆盖。
### 2. 环境检查与确定性准备
### 4. 只运行一个安装命令
需要:Git、`uv`、Python 3.12+、NocoBase `nb` CLI 2.1+。Git 只用于取得和校验发布版本;`uv` 用于创建锁定的项目环境以及后续诊断/更新。若机器尚未安装 `nb`,安装官方 CLI 还会条件性需要 Node.js 与 npm。先运行只读检查:
先引导用户在 NocoBase 个人设置中创建一个可撤销、有合理有效期、绑定本人普通业务角色的个人 API Key。不要让用户把 Key 发给你。
```bash
uv run --no-project --python 3.12 python <install-dir>/scripts/agent_install.py --project-root <install-dir> inspect --json
```
若 `uv`、`nb` 或安装 `nb` 所需的 Node.js/npm 不存在,先向用户说明来源、安装位置和影响范围并取得同意。NocoBase CLI 当前官方包入口是 `npm install -g @nocobase/cli`;安装后必须重新运行检查,不要通过修改 PATH 输出伪造成功。
然后运行幂等准备步骤:
```bash
uv run --no-project --python 3.12 python <install-dir>/scripts/agent_install.py --project-root <install-dir> prepare --json
```
该脚本只会校验项目文件、在缺失时从示例创建 `config.json`,并把 `uv.lock` 中的**运行时依赖**同步到项目自己的 `.venv`。它使用 `--no-dev`,不会给终端用户安装 `mypy`、`ruff`、`pip-audit` 或其传递依赖。已有 `config.json` 会被保留。
安装会在用户电脑留下以下内容:固定 Release 的仓库副本及浅层 `.git`、项目 `.venv`、不含 Key 的 `config.json`、由 `nb` CLI 管理的个人登录环境,以及合并后的 MCP 客户端配置和原配置备份。`uv` 可能保留下载缓存,缓存不是 MCP 运行所必需;安装器不会自动删除用户的共享缓存。默认 stdio 流程不会创建 systemd 服务、监听端口或 `.runtime` Token。
### 3. 连接用户自己的 NocoBase 账号
先引导用户在目标 NocoBase 网页中打开头像或个人设置,寻找当前版本中名为 `API Keys`、`Access Tokens`、`访问令牌` 或类似名称的入口。让用户创建一个:
- 绑定用户本人,而不是共享服务账号;
- 只使用其正常业务角色,不选择管理员/root 角色;
- 有合理过期时间、可单独撤销;
- 仅用于这台电脑上的 MCP。
如果用户看不到个人 Key 功能,Agent 应提示联系 NocoBase 管理员开通或代为签发绑定该用户普通业务角色的 Key;不得改用账号密码或共享管理员 Key。
取得 Key 后,Agent 必须在**用户能直接控制输入的本机终端**中启动以下流程,而不是把命令交给用户手动尝试:
然后在用户可直接控制输入的本机终端中运行:
```bash
uv run --no-project --python 3.12 python <install-dir>/scripts/agent_install.py \
--project-root <install-dir> connect-key \
--project-root <install-dir> install \
--api-base-url <nocobase-url> --env-name nb-mcp --json
```
安装器会自动:
这个命令会一次完成运行环境准备、隐藏 Key 登录、配置写入、doctor 检查和通用 MCP 客户端配置生成。用户只在 `nb` 的隐藏提示出现时粘贴 Key;Agent 不读取输入。
1. 规范化并验证 HTTPS URL;
2. 脱敏检查已有环境,拒绝覆盖指向其他服务器的同名环境;
3. 调用 `nb env add/auth --auth-type token`,但故意不传 `--access-token`,让 `nb` 在 TTY 内隐藏提示;
4. 由用户本人把 Key 粘贴到这个本机提示中,Agent 不读取输入;
5. 自动执行环境读回、把选定环境写入 `config.json`,并在修改前保存 `config.json.pre-agent-install.bak`。
若终端不支持用户私密输入,命令会在写入前停止。此时报告缺少安全 TTY,不得让用户改为在聊天中发送 Key。
若环境名冲突,安装器会给出新名称建议;Agent 使用建议名称重启同一流程,不覆盖旧环境。若当前 Agent 无法提供用户可直接输入的本机 TTY,必须停止并报告“缺少安全 Key 输入通道”;不得要求用户把 Key 粘到聊天、命令参数、环境变量或临时文件中,也不得把 `nb` 命令交给用户反复尝试。
### 5. 自动完成客户端注册和验证
Key 过期或撤销后,Agent 重新启动 `connect-key`,用户在新的隐私提示中输入新 Key。不得退回共享凭证。
读取命令返回的 `clientConfig`:
### 4. 注册到当前 Agent/MCP 客户端
1. 展示当前客户端配置文件路径和将新增的 `nocobase-crm` 条目;
2. 创建可恢复备份;
3. 结构化合并该 server,保留所有其他配置;
4. 按客户端能力重载配置或重启客户端;
5. 调用 `crm_health`,要求 `ok=true`。
生成不含 NocoBase Token 的通用配置:
只有完成以上验证后才能向用户报告成功。最终只报告安装目录、Release、NocoBase 环境名、客户端配置备份位置和启用模式,不显示任何 Token。
```bash
uv run --no-project --python 3.12 python <install-dir>/scripts/agent_install.py --project-root <install-dir> client-config
```
## 默认模式与可选写入
输出采用常见的 `mcpServers` JSON 形状,包含项目 `.venv` 中 Python、项目配置和 `nb` 的绝对路径。MCP 客户端直接启动已准备好的虚拟环境,不在每次调用时经过 `uv` 或同步依赖。Agent 应查明自己客户端当前版本支持的 MCP 配置格式,将其中的 `command`、`args`、`env` 等价映射过去。
未明确选择时必须保持只读。只有用户明确要求项目创建/更新,且管理员确认该用户角色具有相应 NocoBase ACL 时,才在同一个安装命令末尾加 `--enable-writes`。这仍不会启用附件上传、删除或原始数据。
写入前:
## 故障处理
1. 向用户显示目标客户端配置文件路径和新增的 `nocobase-crm` 条目。
2. 备份原文件;解析后做结构化合并,不能用字符串替换或整文件覆盖。
3. 保留所有其他 server、设置和注释(客户端格式允许时)。
4. 按客户端要求重载 MCP 配置或提醒用户重启客户端。
不要让普通用户手动尝试 `nb` 或拆分安装子命令。由 Agent 根据结构化错误处理;需要详细恢复、更新或高级 HTTP 部署时,阅读同版本的 [`docs/LOCAL_DELIVERY.md`](docs/LOCAL_DELIVERY.md)。Key 过期或撤销时,重新运行同一个 `install` 命令,让用户在新的隐藏提示中输入新 Key。
只有用户明确要求项目创建/更新,且其管理员确认对应角色 ACL 后,才可重新生成含写工具的配置:
```bash
uv run --no-project --python 3.12 python <install-dir>/scripts/agent_install.py --project-root <install-dir> client-config --enable-writes
```
这仍不启用上传、删除或原始数据。附件上传属于单独的 Linux 本机 HTTP 高级部署,不是通用安装流程的一部分。
### 5. 验证
先检查本机安装,不读取 NocoBase 秘密:
```bash
uv run --no-project --python 3.12 python <install-dir>/scripts/agent_install.py --project-root <install-dir> doctor --json
```
然后通过已注册的 MCP 调用 `crm_health`。只有同时满足以下条件才可向用户报告安装完成:
- `doctor` 返回 `ok: true`;
- `connect-key` 返回 `ok: true`、`authType: token`,且目标 URL 正确;
- 客户端已发现 `nocobase-crm`;
- `crm_health` 返回 `ok: true`,不是 `nb_auth_failed`、`NO_PERMISSIONS` 或其他结构化错误;
- 默认状态仍是只读、uploads disabled、raw data disabled。
最后报告安装目录、Release Tag、NocoBase 环境名、客户端配置备份位置、启用的能力以及一条最小使用示例。不要报告或回显任何 Token。
## 给用户的最短用法
普通情况下,用户只需把当前**不可变 Release 版本**的 `INSTALL.md` 链接发给具备本机终端和文件权限的 Agent,并说:
## 用户可发送给 Agent 的最短指令
```text
请完整阅读这个安装协议,根据我的系统和当前 Agent 客户端自适应安装。默认只读;认证使用我本人的个人 API Key,不使用账号密码。请引导我在 NocoBase 获取 Key,并只让我在本机隐藏提示中粘贴;不要让我把 Key 发到聊天,也不要让我手动尝试 nb 命令。安装后必须通过 doctor 和 crm_health:
<version-pinned INSTALL.md URL>
请按这个协议帮我完成本机安装。默认只读,使用我自己的 NocoBase API Key;只让我在本机隐藏提示中输入 Key,不要让我手动执行安装或 nb 命令。安装完成前必须验证 crm_health:
<固定版本 INSTALL.md 链接>
```
如果 Agent 只能聊天、不能执行本机命令或写 MCP 配置,它应说明能力限制并按本文生成逐步人工安装指令,而不能声称已经安装。
+5 -5
View File
@@ -12,11 +12,11 @@ The default stdio server is read-only. Add `--enable-writes` for fixed-schema pr
## Configure
The supported end-user flow uses a personal NocoBase API Key (Authorize Key), not a username/password or a shared service credential. The installing Agent asks the user to create a revocable Key in NocoBase, then starts this helper in a user-controlled local terminal:
The supported end-user flow uses a personal NocoBase API Key (Authorize Key), not a username/password or a shared service credential. The installing Agent asks the user to create a revocable Key in NocoBase, then runs the complete guided installation once in a user-controlled local terminal:
```bash
uv run --no-project --python 3.12 python scripts/agent_install.py \
--project-root . connect-key --api-base-url https://nocobase.example --env-name nb-mcp --json
--project-root . install --api-base-url https://nocobase.example --env-name nb-mcp --json
```
The user pastes the Key only into `nb` CLI's private TTY prompt. The helper never accepts the Key as an argument, environment variable, chat value, file, captured output, or return value. Do not copy another user's Key or `nb` configuration directory. The Key must represent that user's normal business identity/role, so NocoBase can apply the intended roles, row-level filters, and field permissions to every operation.
@@ -188,10 +188,10 @@ Queries and writes use the saved `nb-mcp` personal Key login. For multipart uplo
First-install flow:
```bash
# The Agent first runs connect-key in a user-controlled TTY. The user pastes a
# personal NocoBase API Key only into the hidden prompt; it never enters argv.
# The Agent runs the guided installer in a user-controlled TTY. The user pastes
# a personal NocoBase API Key only into the hidden prompt; it never enters argv.
uv run --no-project --python 3.12 python scripts/agent_install.py \
--project-root . connect-key --api-base-url https://nocobase.example --env-name nb-mcp --json
--project-root . install --api-base-url https://nocobase.example --env-name nb-mcp --json
# Create and start the private, loopback-only user service.
bash scripts/mcp_service.sh start
+1 -1
View File
@@ -17,7 +17,7 @@ The local `nb` environment is the identity boundary. The MCP does not keep a sha
## Bootstrap and advanced deployment
- `scripts/agent_install.py` is a compatibility entrypoint. Its standard-library-only implementation lives in `scripts/agent_installer/`, so `inspect` and secure Key connection can run before project dependencies are synchronized.
- `scripts/agent_install.py` is a compatibility entrypoint. Its standard-library-only implementation lives in `scripts/agent_installer/`; `flow.py` composes the public one-command `install` journey, while the lower-level inspect, prepare, authentication, diagnostics, and client-config functions remain independently testable recovery primitives.
- `scripts/manage_mcp_service.py` is the compatibility entrypoint for optional Linux loopback HTTP mode. Configuration serialization, runtime operations, and CLI parsing live in separate `mcp_service_*` modules.
Default installation remains stdio, read-only, without a background service, listener port, uploads, raw rows, or shared credentials.
+6 -8
View File
@@ -28,23 +28,21 @@ MCP 不保存公共业务账号,也不需要部署一个多人共用的远程
这个通用流程默认使用 stdio,因此不需要后台服务或本机 MCP Bearer Token。Linux systemd/HTTP 方式只用于确有附件上传需求的高级场景。
## 首次安装
## 首次安装与统一引导
```bash
git clone https://github.com/Donaldttt/nocobase-mcp.git
git clone --branch v0.2.0 --depth 1 https://github.com/Donaldttt/nocobase-mcp.git
cd nocobase-mcp
uv sync --locked --no-dev
cp config.example.json config.json
```
`config.json` 中的 `env` 默认是 `nb-mcp`。正式终端用户不应手动尝试 `nb env add/auth`;安装 Agent 在用户可直接控制的本机终端中运行:
正式终端用户不应手动尝试依赖同步或 `nb env add/auth`。安装 Agent 在用户可直接控制的本机终端中只运行统一安装命令:
```bash
uv run --no-project --python 3.12 python scripts/agent_install.py \
--project-root . connect-key --api-base-url https://nocobase.example --env-name nb-mcp --json
--project-root . install --api-base-url https://nocobase.example --env-name nb-mcp --json
```
Agent 先引导用户在 NocoBase 个人设置中创建可撤销、有合理有效期、绑定本人普通业务角色的 API Key。用户只把 Key 粘贴到 `nb` CLI 的隐藏 TTY 提示;脚本不接收或回显 Key,也不会把它放入命令参数、聊天、环境变量、临时文件或 MCP 配置。不要分发共享管理员 Key,也不要复制其他人的 `nb` 配置目录。
该命令依次准备运行环境、连接个人 Key、运行 doctor,并返回通用 `clientConfig`。Agent 先引导用户在 NocoBase 个人设置中创建可撤销、有合理有效期、绑定本人普通业务角色的 API Key。用户只把 Key 粘贴到 `nb` CLI 的隐藏 TTY 提示;脚本不接收或回显 Key,也不会把它放入命令参数、聊天、环境变量、临时文件或 MCP 配置。不要分发共享管理员 Key,也不要复制其他人的 `nb` 配置目录。
先进行健康检查:
@@ -77,7 +75,7 @@ Agent 先引导用户在 NocoBase 个人设置中创建可撤销、有合理有
}
```
不要手写猜测虚拟环境路径;优先使用 `scripts/agent_install.py client-config` 的输出。上例是 POSIX 路径,Windows 输出会自动使用 `.venv\\Scripts\\python.exe`。
不要手写猜测虚拟环境路径;优先使用统一 `install` 结果中的 `clientConfig`。上例是 POSIX 路径,Windows 输出会自动使用 `.venv\\Scripts\\python.exe`。`inspect`、`prepare`、`connect-key`、`client-config` 和 `doctor` 子命令仅为排障和旧流程兼容保留,不应让普通用户逐条运行。
默认只读。如需项目创建和更新,在参数末尾加入 `--enable-writes`。这些写工具只允许预先定义的项目字段,不提供删除或任意资源访问能力。
+1
View File
@@ -45,6 +45,7 @@
"entrypoints": {
"guide": "INSTALL.md",
"helper": "scripts/agent_install.py",
"guidedCommand": "install",
"configTemplate": "config.example.json"
},
"securityDefaults": {
+2
View File
@@ -17,6 +17,7 @@ from agent_installer.common import (
Which,
)
from agent_installer.diagnostics import doctor_project
from agent_installer.flow import install_project
from agent_installer.project import (
inspect_project,
prepare_project,
@@ -38,6 +39,7 @@ __all__ = [
"connect_with_personal_key",
"doctor_project",
"inspect_project",
"install_project",
"main",
"normalize_api_base_url",
"prepare_project",
+2
View File
@@ -1,12 +1,14 @@
from .auth import connect_with_personal_key, normalize_api_base_url
from .cli import main
from .diagnostics import doctor_project
from .flow import install_project
from .project import inspect_project, prepare_project, render_client_config, set_config_env
__all__ = [
"connect_with_personal_key",
"doctor_project",
"inspect_project",
"install_project",
"main",
"normalize_api_base_url",
"prepare_project",
+21
View File
@@ -11,6 +11,7 @@ from .common import (
json,
)
from .diagnostics import doctor_project
from .flow import install_project
from .project import inspect_project, prepare_project, render_client_config, set_config_env
@@ -24,6 +25,11 @@ def _print_result(payload: dict[str, Any], *, as_json: bool) -> None:
for check in payload.get("checks", []):
marker = "PASS" if check["ok"] else ("FAIL" if check["required"] else "INFO")
print(f"[{marker}] {check['name']}: {check['detail']}")
if "status" in payload:
print(f"Status: {payload['status']}")
if "clientConfig" in payload:
print("Client configuration:")
print(json.dumps(payload["clientConfig"], ensure_ascii=False, indent=2))
def build_parser() -> argparse.ArgumentParser:
@@ -31,6 +37,12 @@ def build_parser() -> argparse.ArgumentParser:
parser.add_argument("--project-root", type=Path, default=None)
subparsers = parser.add_subparsers(dest="command", required=True)
install = subparsers.add_parser("install", help="Run the complete guided local installation flow.")
install.add_argument("--api-base-url", required=True)
install.add_argument("--env-name", default=DEFAULT_ENV_NAME)
install.add_argument("--enable-writes", action="store_true")
install.add_argument("--json", action="store_true", dest="as_json")
for name in ("inspect", "prepare", "doctor"):
command = subparsers.add_parser(name)
command.add_argument("--json", action="store_true", dest="as_json")
@@ -53,6 +65,15 @@ def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
try:
if args.command == "install":
payload = install_project(
args.project_root,
args.api_base_url,
env_name=args.env_name,
enable_writes=args.enable_writes,
)
_print_result(payload, as_json=args.as_json)
return 0 if payload["ok"] else 1
if args.command == "inspect":
payload = inspect_project(args.project_root)
_print_result(payload, as_json=args.as_json)
+107
View File
@@ -0,0 +1,107 @@
from __future__ import annotations
from .auth import connect_with_personal_key, normalize_api_base_url
from .common import (
DEFAULT_ENV_NAME,
ENV_NAME_PATTERN,
SERVER_NAME,
Any,
Callable,
InstallerError,
Path,
Runner,
Which,
_project_root,
shutil,
subprocess,
sys,
)
from .diagnostics import doctor_project
from .project import inspect_project, prepare_project, render_client_config
def install_project(
project_root: str | Path | None,
api_base_url: str,
*,
env_name: str = DEFAULT_ENV_NAME,
enable_writes: bool = False,
runner: Runner = subprocess.run,
which: Which = shutil.which,
python_version: tuple[int, int, int] | None = None,
stdin_isatty: Callable[[], bool] = sys.stdin.isatty,
) -> dict[str, Any]:
"""Prepare, authenticate, diagnose, and describe one local installation.
The returned client configuration is intentionally not written here: the
installation Agent must merge it into its own client-specific format after
showing the destination and creating a recoverable backup.
"""
root = _project_root(project_root)
normalized_url = normalize_api_base_url(api_base_url)
if not ENV_NAME_PATTERN.fullmatch(env_name):
raise InstallerError(
"Environment name must start with a letter or digit and contain only letters, digits, dot, underscore, or dash"
)
if not stdin_isatty():
raise InstallerError(
"Secure API Key entry requires a user-controlled local terminal. "
"Do not paste the Key into Agent chat or pass it with --access-token."
)
inspection = inspect_project(root, which=which, python_version=python_version)
failed_prerequisites = [check["detail"] for check in inspection["checks"] if check["required"] and not check["ok"]]
if failed_prerequisites:
raise InstallerError("Installation prerequisites are not ready: " + "; ".join(failed_prerequisites))
prepared = prepare_project(
root,
runner=runner,
which=which,
python_version=python_version,
)
authentication = connect_with_personal_key(
root,
normalized_url,
env_name=env_name,
runner=runner,
which=which,
stdin_isatty=stdin_isatty,
)
diagnosis = doctor_project(
root,
runner=runner,
which=which,
python_version=python_version,
)
client_config = render_client_config(root, enable_writes=enable_writes, which=which)
local_ready = bool(diagnosis["ok"])
return {
"ok": local_ready,
"status": "ready-for-client-registration" if local_ready else "local-checks-failed",
"projectRoot": str(root),
"mode": "project-writes" if enable_writes else "read-only",
"prepared": {
"environmentPath": prepared["environmentPath"],
"dependencyProfile": prepared["dependencies"],
"configCreated": prepared["configCreated"],
},
"authentication": authentication,
"checks": diagnosis["checks"],
"clientConfig": client_config,
"clientRegistration": {
"required": True,
"serverName": SERVER_NAME,
"backupRequired": True,
"action": "merge the generated server entry, reload the client, then call crm_health",
},
"remainingActions": [
"Merge clientConfig into the current MCP client without replacing other servers.",
"Reload or restart the MCP client.",
"Call crm_health and require ok=true before reporting installation complete.",
],
}
__all__ = ["install_project"]
+83
View File
@@ -52,6 +52,88 @@ class AgentInstallTests(unittest.TestCase):
self.assertFalse(checks["config"]["ok"])
self.assertFalse(checks["config"]["required"])
def test_guided_install_composes_runtime_key_doctor_and_client_config(self) -> None:
calls = []
info_reads = 0
def runner(command, **kwargs):
nonlocal info_reads
calls.append((command, kwargs))
if command[1:4] == ["env", "info", "nb-mcp"]:
info_reads += 1
if info_reads == 1:
return subprocess.CompletedProcess(command, 1, stdout="", stderr='Env "nb-mcp" is not configured')
return subprocess.CompletedProcess(
command,
0,
stdout=json.dumps({"api": {"apiBaseUrl": "https://nb.example/api", "auth": {"type": "token"}}}),
stderr="",
)
return subprocess.CompletedProcess(command, 0, stdout="", stderr="")
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
make_project(root)
result = agent_install.install_project(
root,
"https://nb.example",
runner=runner,
which=fake_which,
python_version=(3, 12, 4),
stdin_isatty=lambda: True,
)
configured = json.loads((root / "config.json").read_text(encoding="utf-8"))
commands = [command for command, _kwargs in calls]
self.assertTrue(result["ok"])
self.assertEqual(result["status"], "ready-for-client-registration")
self.assertEqual(result["mode"], "read-only")
self.assertEqual(result["prepared"]["dependencyProfile"], "locked-runtime-only")
self.assertEqual(result["authentication"]["keyExposure"], "private-tty-only")
self.assertEqual(configured["env"], "nb-mcp")
self.assertEqual(
result["clientConfig"]["mcpServers"]["nocobase-crm"]["command"],
str(root.resolve() / ".venv" / "bin" / "python"),
)
self.assertEqual(commands[0][1:5], ["sync", "--locked", "--no-dev", "--python"])
self.assertTrue(any("crm_health" in action for action in result["remainingActions"]))
self.assertNotIn("access-token", json.dumps(result))
def test_guided_install_refuses_non_tty_before_writing(self) -> None:
calls = []
def runner(command, **kwargs):
calls.append(command)
return subprocess.CompletedProcess(command, 0, stdout="", stderr="")
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
make_project(root)
with self.assertRaisesRegex(agent_install.InstallerError, "user-controlled local terminal"):
agent_install.install_project(
root,
"https://nb.example",
runner=runner,
which=fake_which,
python_version=(3, 12, 4),
stdin_isatty=lambda: False,
)
self.assertFalse((root / "config.json").exists())
self.assertEqual(calls, [])
def test_cli_exposes_one_guided_install_command(self) -> None:
args = agent_install.build_parser().parse_args(["install", "--api-base-url", "https://nb.example", "--json"])
self.assertEqual(args.command, "install")
self.assertEqual(args.env_name, "nb-mcp")
self.assertFalse(args.enable_writes)
self.assertTrue(args.as_json)
def test_prepare_is_idempotent_and_never_overwrites_existing_config(self) -> None:
calls = []
@@ -291,6 +373,7 @@ class AgentInstallTests(unittest.TestCase):
self.assertFalse(manifest["authentication"]["allowCommandLineSecret"])
self.assertEqual(manifest["runtime"]["launcher"], "project-venv-python")
self.assertEqual(manifest["runtime"]["dependencyProfile"], "runtime-only-no-dev")
self.assertEqual(manifest["entrypoints"]["guidedCommand"], "install")
self.assertFalse(manifest["installation"]["defaultBackgroundService"])
self.assertTrue(manifest["securityDefaults"]["readOnly"])
self.assertFalse(manifest["securityDefaults"]["uploads"])