feat: add guided one-command installer
This commit is contained in:
+55
-111
@@ -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 配置,它应说明能力限制并按本文生成逐步人工安装指令,而不能声称已经安装。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`。这些写工具只允许预先定义的项目字段,不提供删除或任意资源访问能力。
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
"entrypoints": {
|
||||
"guide": "INSTALL.md",
|
||||
"helper": "scripts/agent_install.py",
|
||||
"guidedCommand": "install",
|
||||
"configTemplate": "config.example.json"
|
||||
},
|
||||
"securityDefaults": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]
|
||||
@@ -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"])
|
||||
|
||||
Reference in New Issue
Block a user