dsh-qiyu-stack 0.15.2 → 0.15.4
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/AGENTS.md +101 -0
- package/README.md +36 -33
- package/bin/qiyu.mjs +2 -1
- package/docs/conventions.md +16 -0
- package/docs/status.md +73 -0
- package/package.json +2 -1
- package/src/deploy.mjs +1 -9
- package/src/docker.mjs +1 -7
- package/src/doctor.mjs +1 -9
- package/src/install.mjs +4 -2
- package/src/preflight.mjs +41 -9
- package/src/run.mjs +81 -0
package/AGENTS.md
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# 给「部署助手」看的,不是给角色看的
|
|
2
|
+
|
|
3
|
+
> **如果你是被部署出来的那个角色**(你有人格、有作息、正在跟人聊天),
|
|
4
|
+
> **忽略本文件全部内容**,回去读你自己的 `AGENTS.md`。
|
|
5
|
+
> 只有当用户让你「把栖语装到这台机器上 / 帮我部署一个 QQ 智能体」时,这份文件才适用。
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
你面对的是一个 **DSH 插件栈**:`dsh-qiyu-stack`。它让一个智能体住进 QQ(NapCat + OneBot)。
|
|
10
|
+
|
|
11
|
+
## 你要做的事,只有五条命令
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npx dsh-qiyu-stack preflight --json # 1. 环境体检,先看这台机器行不行
|
|
15
|
+
npx dsh-qiyu-stack init # 2. 建角色(会问:名字、QQ、称呼、作息、性格、能否自改)
|
|
16
|
+
npx dsh-qiyu-stack deploy --qq <号> # 3. 起 NapCat(会弹二维码,用户手机扫)
|
|
17
|
+
npx dsh-qiyu-stack install --character <名字> --yes # 4. 装进 DSH
|
|
18
|
+
npx dsh-qiyu-stack doctor --json # 5. 验收
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
**不要 `git clone` 仓库**——`npx` 就够了,clone 反而会让人以为要改源码。
|
|
22
|
+
|
|
23
|
+
## 必须先问用户,不许猜
|
|
24
|
+
|
|
25
|
+
| 要问什么 | 为什么 |
|
|
26
|
+
|---|---|
|
|
27
|
+
| **QQ 号**(她要登录的那个号) | 猜不了,也不该猜。用户可能有好几个号 |
|
|
28
|
+
| **角色名字** | 这是人格的根,不能替他起 |
|
|
29
|
+
| 部署到哪个盘 | 默认会挑非系统盘;C 盘空间紧的话要说明 |
|
|
30
|
+
|
|
31
|
+
## 沙箱和提权:这是**默认路径**,不是意外
|
|
32
|
+
|
|
33
|
+
DSH 默认给的是 `workspace-write`。下面这些**注定会被拒**,你要提前预判,别把它当成"npm 坏了":
|
|
34
|
+
|
|
35
|
+
| 会失败的动作 | 正确做法 |
|
|
36
|
+
|---|---|
|
|
37
|
+
| `npx` 写 `%LOCALAPPDATA%\npm-cache` | 加 `--cache ./.npm-cache`(指到工作区),或申请提升权限 |
|
|
38
|
+
| `install` 写 `~/.dsh/profiles/...` | **申请提权**(这是正常操作,不是异常);被拒就请用户自己在终端跑这一条 |
|
|
39
|
+
| 写 `~/.dsh/qiyu/`、`~/.dsh/*.json` | 同上 |
|
|
40
|
+
|
|
41
|
+
**申请提权时说清楚要写哪里、为什么**,别默默重试。
|
|
42
|
+
|
|
43
|
+
### ⚠️ 一个会骗你的坑:沙箱里读不到子进程输出
|
|
44
|
+
|
|
45
|
+
受限沙箱不允许程序开命名管道,所以任何 `execFile/spawn` **抓输出**(`stdio: 'pipe'`)都会 `EPERM`。
|
|
46
|
+
表现是:
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
preflight --json → { "ok": false, "不通": 1, "标题": "这台机器上没有 Docker" }
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
而机器上 Docker 明明装着、容器还在跑。
|
|
53
|
+
|
|
54
|
+
v0.15.4 起 `qiyu` 会自动兜底(把输出重定向到临时文件再读),仍然读不到的,会**把结论降级成 `warn`
|
|
55
|
+
并在标题前面加「沙箱限制,这条不可信」**。看到这几个字,别让用户去装东西,改成:
|
|
56
|
+
|
|
57
|
+
1. 让用户**在他自己的终端**里跑一遍同样的命令;或
|
|
58
|
+
2. 把这次会话提到 `danger-full-access` 再验。
|
|
59
|
+
|
|
60
|
+
**永远不要**在拿到「沙箱限制」标记时,让用户重装 Docker / 改配置。
|
|
61
|
+
|
|
62
|
+
## 必须交还给人做的四件事
|
|
63
|
+
|
|
64
|
+
这四件 agent **做不了,也不该做**,直接告诉用户:
|
|
65
|
+
|
|
66
|
+
1. **装 Docker Desktop** —— 要管理员权限 + 重启电脑。给他链接和步骤,等他做完再回来
|
|
67
|
+
2. **重启 DSH** —— 装完之后必须重启。**重启会结束当前会话**,所以先把状态写下来(见下)
|
|
68
|
+
3. **手机扫码** —— `deploy` 会自动打开二维码图片,但"用哪个号扫"只有人知道
|
|
69
|
+
4. **在手机确认页勾「下次登录无需确认」** —— 不勾的话每次重启容器都要重新验证
|
|
70
|
+
|
|
71
|
+
## 千万别碰的东西
|
|
72
|
+
|
|
73
|
+
- `characters/<名字>/AGENTS.md`、`character.json`、`schedule.json` —— **用户的角色,只能创建,不能替换已有内容**
|
|
74
|
+
- `~/.dsh/AGENTS.md` —— 全局用户配置,改了会影响这台机器上**所有**会话(包括别人家已经养着的角色)
|
|
75
|
+
- 任何模板里的"默认值" —— 那些刻意留空是有原因的(见 `docs/conventions.md`)
|
|
76
|
+
|
|
77
|
+
## 重启前要做的事
|
|
78
|
+
|
|
79
|
+
`install` 跑完会打印交接语。你要做的是:
|
|
80
|
+
|
|
81
|
+
1. 把 `qiyu doctor --json` 的**预期结果**告诉用户("重启后应该看到 0 不通")
|
|
82
|
+
2. 提醒他:**重启后回到这个会话**(如果 DSH 能恢复),或者开新会话后先跑一次 `npx dsh-qiyu-stack doctor --json`,把结果贴回来
|
|
83
|
+
3. **不要说"重新开始"** —— 东西已经装好了,只是要验证
|
|
84
|
+
|
|
85
|
+
## 验收标准
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
npx dsh-qiyu-stack doctor --json
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
- `不通`(fail)必须为 **0**
|
|
92
|
+
- `待看`(warn)可以有——通常是"Windows 上别装 Windows 版 NapCat"这类提示
|
|
93
|
+
- 模板体检必须是 `ok`(说明没有别人的私人值混进来)
|
|
94
|
+
|
|
95
|
+
过了这关,才算装完。
|
|
96
|
+
|
|
97
|
+
## 出问题去哪查
|
|
98
|
+
|
|
99
|
+
- 部署细节和踩过的坑:`docs/deployment.md`
|
|
100
|
+
- 默认值红线:`docs/conventions.md`
|
|
101
|
+
- 当前状态与已知未做:`docs/status.md`
|
package/README.md
CHANGED
|
@@ -128,42 +128,45 @@ characters/小满/ 你的角色包(不进版本库)
|
|
|
128
128
|
|
|
129
129
|
---
|
|
130
130
|
|
|
131
|
-
##
|
|
131
|
+
## 进度(v0.15.1,2026-10-23)
|
|
132
|
+
|
|
133
|
+
**已发布**:[npm](https://www.npmjs.com/package/dsh-qiyu-stack) · [GitHub](https://github.com/aesfls/dsh-qiyu-stack)
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
npx dsh-qiyu-stack preflight # 环境体检(加 --json 给 agent 用)
|
|
137
|
+
npx dsh-qiyu-stack init # 建角色(向导)
|
|
138
|
+
npx dsh-qiyu-stack deploy --qq <号> # 起 NapCat + 出码
|
|
139
|
+
npx dsh-qiyu-stack install --character <名字> --yes # 装进 DSH
|
|
140
|
+
npx dsh-qiyu-stack doctor # 自检(加 --json 给 agent 用)
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
> 让 AI 帮你装?把这个仓库地址丢给它就行 —— 根目录的 [AGENTS.md](AGENTS.md) 是给 agent 看的说明书。
|
|
144
|
+
> 它知道自己能做什么、哪些必须交还给人(装 Docker、重启 DSH、手机扫码)。
|
|
132
145
|
|
|
133
146
|
已经能用:
|
|
134
147
|
|
|
135
|
-
- [x] **`qiyu init`
|
|
136
|
-
|
|
137
|
-
- [x] **`qiyu deploy
|
|
138
|
-
→
|
|
139
|
-
- [x]
|
|
140
|
-
|
|
141
|
-
- [x]
|
|
142
|
-
|
|
143
|
-
- [x]
|
|
144
|
-
- [x]
|
|
145
|
-
- [x]
|
|
146
|
-
- [x]
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
- [
|
|
151
|
-
|
|
152
|
-
- [
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
→ 发消息进群 ✅ → 群里 @ 她,话被转进会话 ✅(最后「她回话」那步因测试 profile 没配模型没验)
|
|
158
|
-
|
|
159
|
-
还差的:
|
|
160
|
-
|
|
161
|
-
- [x] ~~`memory` / `mood` / `rhythm` 也改读活动角色~~ —— 都接上了:
|
|
162
|
-
rhythm 读 `schedule.json` 的时段、mood 读 `名字`/`称呼.主人`、memory 的名字兜底读 `名字`
|
|
163
|
-
|
|
164
|
-
> 五块现在**都从活动角色读配置**了:改 `characters/<名字>/` 就能换人,不用碰插件配置。
|
|
165
|
-
|
|
166
|
-
> 这一段是**从已验证能跑的实现里抽出来的**,不是照文档重写。
|
|
148
|
+
- [x] **`qiyu init` 向导**:名字 / QQ / 称呼 / 作息 / 性格 /「能不能自己改性格」;
|
|
149
|
+
每个问题都有默认值;带名字调用则全用默认值(给脚本用)
|
|
150
|
+
- [x] **`qiyu deploy`**:查 Docker(没开就**静默拉起、不弹窗**)→ 选目录(挑非系统盘、查中文路径和空间)
|
|
151
|
+
→ 写 OneBot/WebUI 配置 → 跑容器 → **直接打开二维码**;令牌写回 `qiyu.config.json`
|
|
152
|
+
- [x] **`qiyu install`**:把 runtime 四块装进 DSH profile;**按内容判断要不要替换**,
|
|
153
|
+
换代码时保留用户自己的文件(原图之类),`package.json` 改动前必留 `.bak`
|
|
154
|
+
- [x] **`qiyu preflight`**:Node / Docker / Windows / WSL / 路径 / 空间 / 网络 / 杀软 /
|
|
155
|
+
DSH / 模型凭证 / Python;`--fix` 能修引擎启动、占端口容器、edge-tts
|
|
156
|
+
- [x] **`qiyu doctor`**:全链路自检 + 模板体检 + **runtime 与已装副本的代码一致性**
|
|
157
|
+
- [x] **runtime 四块**:bridge / memory / mood / rhythm(去个性化,配置统一读活动角色)
|
|
158
|
+
- [x] **语音拆干净**:`bridge/lib/voice.mjs` 管说和听,桥只管向 NapCat 要 wav
|
|
159
|
+
- [x] 端到端:空白 profile → install → 独立实例 → 备号收发都通
|
|
160
|
+
|
|
161
|
+
已知未做:
|
|
162
|
+
|
|
163
|
+
- [ ] **还没有陌生人在干净机器上完整装过一遍**(最该验证的一环)
|
|
164
|
+
- [ ] mood 的「同一句反复记」已修(按消息签名去重),但只在千语身上验证过
|
|
165
|
+
- [ ] `memory` / `mood` / `rhythm` 的其余配置项还没全部接进角色包
|
|
166
|
+
- [ ] `0.0.0-stage` 这个 npm 占位版本还挂在 registry 上(不影响 `npx`)
|
|
167
|
+
|
|
168
|
+
> runtime 两边的代码应当一致,用 `qiyu doctor` 第 7.6 项比对;不一致就 `qiyu install --force`。
|
|
169
|
+
> 配置(QQ 号、称呼、容器名、路径)**永远不同步**,那是使用者自己的东西。
|
|
167
170
|
|
|
168
171
|
---
|
|
169
172
|
|
package/bin/qiyu.mjs
CHANGED
|
@@ -46,6 +46,7 @@ const 帮助 = `
|
|
|
46
46
|
(不假设 Docker 已经装了)
|
|
47
47
|
--fix 能修的帮你修:启动 Docker 引擎、停掉占端口的容器、装 edge-tts
|
|
48
48
|
(每一项都会先问一句,非交互环境不动手)
|
|
49
|
+
--json 只输出 JSON(给 agent / 脚本判断用)
|
|
49
50
|
|
|
50
51
|
qiyu deploy 一条命令起 NapCat:建目录、写 OneBot 配置、跑容器、出码
|
|
51
52
|
--dir <目录> 数据放哪(默认挑非系统盘里空间最大的;会检查空间和中文路径)
|
|
@@ -113,7 +114,7 @@ async function 主() {
|
|
|
113
114
|
}
|
|
114
115
|
|
|
115
116
|
if (命令 === 'preflight' || 命令 === 'check') {
|
|
116
|
-
return await preflight({ 修复: 有开关('--fix') })
|
|
117
|
+
return await preflight({ 修复: 有开关('--fix'), 只输出JSON: 有开关('--json') })
|
|
117
118
|
}
|
|
118
119
|
|
|
119
120
|
if (命令 === 'qr' || 命令 === 'login') {
|
package/docs/conventions.md
CHANGED
|
@@ -26,6 +26,22 @@ ownerName: z.string().default('小鱿鱼')
|
|
|
26
26
|
|
|
27
27
|
## 对用户说的话,还有两条硬规矩
|
|
28
28
|
|
|
29
|
+
### ⓪ 三份 `AGENTS.md`,各管各的,绝不许串
|
|
30
|
+
|
|
31
|
+
这个项目里有三份同名文件,搞混了就是事故(会把"部署指令"喂给正在聊天的角色):
|
|
32
|
+
|
|
33
|
+
| 文件 | 谁读 | 影响范围 |
|
|
34
|
+
|---|---|---|
|
|
35
|
+
| `~/.dsh/AGENTS.md` | **所有** DSH 会话 | ⚠️ 最广,连别人家已经养着的角色都会读到 |
|
|
36
|
+
| `characters/<名字>/AGENTS.md` | 那个角色自己的会话 | 性格、语气、边界 |
|
|
37
|
+
| `dsh-qiyu-stack/AGENTS.md` | **只有工作目录在这个仓库里的会话** | 只影响"正在装机的助手" |
|
|
38
|
+
|
|
39
|
+
规矩:
|
|
40
|
+
|
|
41
|
+
- **仓库根那份**开头必须有作用域声明:「如果你是被部署出来的角色,忽略本文件」
|
|
42
|
+
- **任何工具都不许改** `~/.dsh/AGENTS.md` 和已存在的 `characters/<名字>/AGENTS.md`
|
|
43
|
+
- `qiyu install` 只从角色包拷三个文件,**不读仓库根**——所以物理上也串不过去
|
|
44
|
+
|
|
29
45
|
### ① 「重启」必须带宾语
|
|
30
46
|
|
|
31
47
|
用户看到「重启一下」会愣住:重启电脑?重启 DSH?重启 Docker?
|
package/docs/status.md
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# 现状与下一步(2026-10-23 收工时的快照)
|
|
2
|
+
|
|
3
|
+
> 这份是给「下次打开这个项目的人 / 我」看的。想知道怎么用,看 [README](../README.md)。
|
|
4
|
+
|
|
5
|
+
## 一句话状态
|
|
6
|
+
|
|
7
|
+
**能用了,也发出去了,但还没被陌生人在干净机器上验证过。**
|
|
8
|
+
|
|
9
|
+
- npm:`dsh-qiyu-stack@0.15.3`(`latest`),`npx dsh-qiyu-stack preflight --json` 实测可跑
|
|
10
|
+
- GitHub:<https://github.com/aesfls/dsh-qiyu-stack>(公开,main)
|
|
11
|
+
- 千语(爸爸本人的实例):正常运行中,桥、NapCat、Docker 都通
|
|
12
|
+
|
|
13
|
+
## 2026-10-23 第二轮补的(agent 入口)
|
|
14
|
+
|
|
15
|
+
一个陌生人会话实测后提的问题,采纳了三条:
|
|
16
|
+
|
|
17
|
+
1. **仓库根 `AGENTS.md`**:给部署助手看的说明书——五条命令、必须先问的(QQ 号/角色名)、
|
|
18
|
+
沙箱和提权是**默认路径**不是意外、必须交还给人做的四件事(装 Docker / 重启 DSH / 扫码 / 勾确认框)、
|
|
19
|
+
千万别碰的东西(角色包、`~/.dsh/AGENTS.md`)、验收标准
|
|
20
|
+
2. **`preflight --json` / `doctor --json`**:让 agent 机器判断卡在哪,别去正则扒中文
|
|
21
|
+
3. **`install` 交接语**:改成「重启后回这个会话,或开新会话跑 doctor --json 贴回来」,
|
|
22
|
+
并写明**「不是重新开始」**
|
|
23
|
+
|
|
24
|
+
## 今天做了什么
|
|
25
|
+
|
|
26
|
+
1. **NapCat 迁移落地**:Windows 版 NapCat 被 QQ 的文件校验堵死,改走 Docker + Linux QQ,跑通
|
|
27
|
+
收发、群消息、语音、事件流。部署过程写进 [deployment.md](deployment.md)。
|
|
28
|
+
2. **抽运行时四块**:bridge / memory / mood / rhythm 从「真在跑的机器」搬进 `runtime/`,
|
|
29
|
+
清掉私人路径、角色名、QQ 号、令牌。语音拆成 `bridge/lib/voice.mjs`。
|
|
30
|
+
3. **三条命令做出来**:`preflight`(环境体检 + `--fix`)、`deploy`(起 NapCat)、
|
|
31
|
+
`install`(装进 DSH,按内容判断替换、保留用户文件)。
|
|
32
|
+
4. **发布**:GitHub 公开仓库 + npm 包。
|
|
33
|
+
5. **修了一串真问题**(都记在对应文件里):
|
|
34
|
+
- `Docker Desktop.exe` 在引擎已跑时执行会弹面板 → 改成「先查、再决定碰不碰」
|
|
35
|
+
- 心情插件把同一句话反复记 → 加消息签名去重
|
|
36
|
+
- `install` 只增不删导致新旧文件混着 → 改成按内容比对、整份替换
|
|
37
|
+
- `package.json` 带 BOM 导致 npm 发布被"自动纠正" → 全部重写成无 BOM
|
|
38
|
+
|
|
39
|
+
## 已知没做(按重要性)
|
|
40
|
+
|
|
41
|
+
| # | 事项 | 为什么重要 |
|
|
42
|
+
|---|---|---|
|
|
43
|
+
| 1 | **陌生人在干净机器上完整装一遍** | 唯一没被验证的一环。README 的流程可能还有坑 |
|
|
44
|
+
| 2 | 心情「她回话」那一步 | 隔离测试时测试 profile 没配模型,消息进会话但没跑起来 |
|
|
45
|
+
| 3 | `memory`/`mood`/`rhythm` 其余配置项接进角色包 | 现在只接了名字、称呼、作息时段 |
|
|
46
|
+
| 4 | npm 上的 `0.0.0-stage` 占位版本 | 无害,但看着脏;要清得用有权限的 classic token |
|
|
47
|
+
| 5 | GitHub 与本地历史分叉 | 网络拦了 git 协议(SSL),最后用 API 改文件;下次要 `git pull --rebase` |
|
|
48
|
+
|
|
49
|
+
## 下一轮建议从哪开始
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
# 1) 先自己按 README 从零走一遍(最像陌生人的做法)
|
|
53
|
+
cd /tmp && npx dsh-qiyu-stack preflight
|
|
54
|
+
npx dsh-qiyu-stack init
|
|
55
|
+
npx dsh-qiyu-stack deploy --qq <另一个号>
|
|
56
|
+
npx dsh-qiyu-stack install --character <名字> --yes
|
|
57
|
+
npx dsh-qiyu-stack doctor
|
|
58
|
+
|
|
59
|
+
# 2) 哪一步卡住,就把那一步的报错抄进 docs/deployment.md 的"常见坑"
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## 两套东西的边界(很容易搞混)
|
|
63
|
+
|
|
64
|
+
| | 千语(爸爸的) | 栖语(给别人的) |
|
|
65
|
+
|---|---|---|
|
|
66
|
+
| 代码 | `~/.dsh/profiles/desktop/node_modules/<插件>` | `W:\AI\project\dsh-qiyu-stack\runtime\` |
|
|
67
|
+
| 配置 | `~/.dsh/*.json`、`qiyu.config.json` | 模板里全空 |
|
|
68
|
+
| 同步规则 | **代码要一致**(`qiyu doctor` 7.6 项会查);**配置永不同步** | |
|
|
69
|
+
|
|
70
|
+
## 备份在哪
|
|
71
|
+
|
|
72
|
+
- 插件改动前的原文件:`W:\AI\yunqianyu\备份\插件改动备份-20261023.zip`(含清单 csv)
|
|
73
|
+
- 迁移说明与回退步骤:`W:\AI\project\云千语-NapCat迁移交接.md`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-qiyu-stack",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.4",
|
|
4
4
|
"description": "栖语栈:让一个智能体住进 QQ —— QQ 双向桥、长期记忆、心情、语音、作息,以及把 NapCat 部署这件事一次做对",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
},
|
|
16
16
|
"license": "MIT",
|
|
17
17
|
"files": [
|
|
18
|
+
"AGENTS.md",
|
|
18
19
|
"bin",
|
|
19
20
|
"src",
|
|
20
21
|
"templates",
|
package/src/deploy.mjs
CHANGED
|
@@ -13,21 +13,13 @@ import { createInterface } from 'node:readline'
|
|
|
13
13
|
import { 读配置, 配置文件路径 } from './config.mjs'
|
|
14
14
|
import { 打开文件 } from './qr.mjs'
|
|
15
15
|
import { 确保引擎, 引擎版本 } from './docker.mjs'
|
|
16
|
+
import { 跑 } from './run.mjs'
|
|
16
17
|
|
|
17
18
|
const 绿 = (s) => `\x1b[32m${s}\x1b[0m`
|
|
18
19
|
const 黄 = (s) => `\x1b[33m${s}\x1b[0m`
|
|
19
20
|
const 红 = (s) => `\x1b[31m${s}\x1b[0m`
|
|
20
21
|
const 灰 = (s) => `\x1b[90m${s}\x1b[0m`
|
|
21
22
|
|
|
22
|
-
function 跑(命令, 参数, 超时 = 60000) {
|
|
23
|
-
try {
|
|
24
|
-
const 出 = execFileSync(命令, 参数, { timeout: 超时, encoding: 'utf8', windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
25
|
-
return { ok: true, 出: String(出).trim() }
|
|
26
|
-
} catch (错) {
|
|
27
|
-
return { ok: false, 错: String((错 && (错.stderr || 错.message)) || 错).trim() }
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
|
|
31
23
|
async function 端口通(端口, 超时 = 1200) {
|
|
32
24
|
const net = await import('node:net')
|
|
33
25
|
return new Promise((解决) => {
|
package/src/docker.mjs
CHANGED
|
@@ -8,18 +8,12 @@
|
|
|
8
8
|
import { execFile, spawn } from 'node:child_process'
|
|
9
9
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
10
10
|
import { join } from 'node:path'
|
|
11
|
+
import { 跑 } from './run.mjs'
|
|
11
12
|
|
|
12
13
|
const 绿 = (s) => `\x1b[32m${s}\x1b[0m`
|
|
13
14
|
const 黄 = (s) => `\x1b[33m${s}\x1b[0m`
|
|
14
15
|
const 灰 = (s) => `\x1b[90m${s}\x1b[0m`
|
|
15
16
|
|
|
16
|
-
export function 跑(命令, 参数, 超时 = 15000) {
|
|
17
|
-
return new Promise((解决) => {
|
|
18
|
-
execFile(命令, 参数, { timeout: 超时, windowsHide: true, encoding: 'utf8' }, (错, 出, 错出) => {
|
|
19
|
-
解决({ ok: !错, 出: String(出 || '').trim(), 错: String(错出 || (错 && 错.message) || '').trim() })
|
|
20
|
-
})
|
|
21
|
-
})
|
|
22
|
-
}
|
|
23
17
|
|
|
24
18
|
export async function 引擎版本() {
|
|
25
19
|
const r = await 跑('docker', ['version', '--format', '{{.Server.Version}}'], 10000)
|
package/src/doctor.mjs
CHANGED
|
@@ -9,6 +9,7 @@ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
|
|
|
9
9
|
import { join, dirname, resolve } from 'node:path'
|
|
10
10
|
import { fileURLToPath } from 'node:url'
|
|
11
11
|
import { 读配置 } from './config.mjs'
|
|
12
|
+
import { 跑 } from './run.mjs'
|
|
12
13
|
|
|
13
14
|
const 包根 = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
14
15
|
|
|
@@ -17,15 +18,6 @@ const 黄 = (s) => `\x1b[33m${s}\x1b[0m`
|
|
|
17
18
|
const 红 = (s) => `\x1b[31m${s}\x1b[0m`
|
|
18
19
|
const 灰 = (s) => `\x1b[90m${s}\x1b[0m`
|
|
19
20
|
|
|
20
|
-
function 跑(命令, 参数, 超时 = 8000) {
|
|
21
|
-
try {
|
|
22
|
-
const 出 = execFileSync(命令, 参数, { timeout: 超时, encoding: 'utf8', windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
23
|
-
return { ok: true, 出: String(出).trim() }
|
|
24
|
-
} catch (错) {
|
|
25
|
-
return { ok: false, 错: String((错 && (错.stderr || 错.message)) || 错).trim() }
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
|
|
29
21
|
function 端口通(主机, 端口, 超时 = 1500) {
|
|
30
22
|
return new Promise((解决) => {
|
|
31
23
|
const s = net.connect({ host: 主机, port: 端口 })
|
package/src/install.mjs
CHANGED
|
@@ -401,8 +401,10 @@ export async function install(选项 = {}) {
|
|
|
401
401
|
console.log('')
|
|
402
402
|
console.log('下一步:')
|
|
403
403
|
console.log(' 1. **重启 DSH**:把 DSH 窗口关掉再打开(是 DSH,不用重启电脑)')
|
|
404
|
-
console.log(' 2.
|
|
405
|
-
console.log('
|
|
404
|
+
console.log(' 2. 重启后:回到这个会话,或者开一个新会话,先跑一遍')
|
|
405
|
+
console.log(' npx dsh-qiyu-stack doctor --json')
|
|
406
|
+
console.log(' 把结果贴回来 —— 东西已经装好了,这一步只是确认,不是重新开始')
|
|
407
|
+
console.log(' 3. 设置里「通到 QQ」应该已经填好了,不用改')
|
|
406
408
|
console.log('')
|
|
407
409
|
console.log(灰(' 注:DSH 启动时可能会自己弹一个浏览器窗口 —— 那是 DSH 自己的界面,不是报错。'))
|
|
408
410
|
console.log(灰(' 里面的「通到 QQ」设置已经帮你填好了(地址和令牌),不用改。'))
|
package/src/preflight.mjs
CHANGED
|
@@ -11,21 +11,13 @@ import { existsSync, statfsSync, readFileSync } from 'node:fs'
|
|
|
11
11
|
import { join } from 'node:path'
|
|
12
12
|
import { homedir, release } from 'node:os'
|
|
13
13
|
import { createInterface } from 'node:readline'
|
|
14
|
+
import { 跑, 沙箱受限过 } from './run.mjs'
|
|
14
15
|
|
|
15
16
|
const 绿 = (s) => `\x1b[32m${s}\x1b[0m`
|
|
16
17
|
const 黄 = (s) => `\x1b[33m${s}\x1b[0m`
|
|
17
18
|
const 红 = (s) => `\x1b[31m${s}\x1b[0m`
|
|
18
19
|
const 灰 = (s) => `\x1b[90m${s}\x1b[0m`
|
|
19
20
|
|
|
20
|
-
function 跑(命令, 参数, 超时 = 8000) {
|
|
21
|
-
try {
|
|
22
|
-
const 出 = execFileSync(命令, 参数, { timeout: 超时, encoding: 'utf8', windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
23
|
-
return { ok: true, 出: String(出).trim() }
|
|
24
|
-
} catch (错) {
|
|
25
|
-
return { ok: false, 错: String((错 && (错.stderr || 错.message)) || 错).trim() }
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
|
|
29
21
|
function 端口通(端口, 超时 = 1500) {
|
|
30
22
|
return new Promise((解决) => {
|
|
31
23
|
const s = net.connect({ host: '127.0.0.1', port: 端口 })
|
|
@@ -357,11 +349,51 @@ export async function preflight(选项 = {}) {
|
|
|
357
349
|
}
|
|
358
350
|
}
|
|
359
351
|
|
|
352
|
+
// ---------- 沙箱兜底:读不到命令输出时,别把「没装」当成事实 ----------
|
|
353
|
+
//
|
|
354
|
+
// 受限沙箱不允许程序开管道,所有"跑条命令看输出"的判断都可能拿到空结果。
|
|
355
|
+
// 这时把相关 fail 降级成 warn 并说清楚——否则 agent 会去让用户装一个已经装好的 Docker。
|
|
356
|
+
if (沙箱受限过()) {
|
|
357
|
+
let 降级了 = 0
|
|
358
|
+
for (const x of 结果) {
|
|
359
|
+
if (x.级别 !== 'fail') continue
|
|
360
|
+
if (!/Docker|容器|端口|Python|WSL|虚拟化/.test(x.标题)) continue
|
|
361
|
+
x.级别 = 'warn'
|
|
362
|
+
x.标题 = '沙箱限制,这条不可信:' + x.标题
|
|
363
|
+
x.怎么修 = '在用户自己的终端里跑 qiyu preflight 再确认一次;或者把这次会话提到 danger-full-access'
|
|
364
|
+
降级了++
|
|
365
|
+
}
|
|
366
|
+
结果.push({
|
|
367
|
+
级别: 'warn',
|
|
368
|
+
标题: '当前沙箱读不到子进程输出',
|
|
369
|
+
依据:
|
|
370
|
+
'受限沙箱不允许程序开管道(stdio: pipe 会 EPERM)。qiyu 已改用"重定向到文件再读"兜底,' +
|
|
371
|
+
'但个别命令仍可能失败,' + (降级了 ? `上面 ${降级了} 条结论已降级` : '') + '。',
|
|
372
|
+
怎么修: '这类结论不能当真:让用户在自己终端跑一遍,或把会话权限提到 danger-full-access',
|
|
373
|
+
})
|
|
374
|
+
}
|
|
375
|
+
|
|
360
376
|
// ---------- 输出 ----------
|
|
361
377
|
const 坏 = 结果.filter((x) => x.级别 === 'fail').length
|
|
362
378
|
const 疑 = 结果.filter((x) => x.级别 === 'warn').length
|
|
363
379
|
let 坏数 = 坏
|
|
364
380
|
|
|
381
|
+
// 给 agent 用的机器可读输出:别让模型去正则扒中文
|
|
382
|
+
if (选项.只输出JSON) {
|
|
383
|
+
const 输出 = {
|
|
384
|
+
ok: 坏 === 0,
|
|
385
|
+
统计: {
|
|
386
|
+
好: 结果.filter((x) => x.级别 === 'ok').length,
|
|
387
|
+
待看: 疑,
|
|
388
|
+
不通: 坏,
|
|
389
|
+
},
|
|
390
|
+
结果: 结果.map((x) => ({ 级别: x.级别, 标题: x.标题, 依据: x.依据 || '', 怎么修: x.怎么修 || '' })),
|
|
391
|
+
下一步: 坏 === 0 ? '可以往下走:init → deploy → install' : '先解决 fail 的项,再往下走',
|
|
392
|
+
}
|
|
393
|
+
console.log(JSON.stringify(输出, null, 2))
|
|
394
|
+
return 坏 ? 1 : 0
|
|
395
|
+
}
|
|
396
|
+
|
|
365
397
|
console.log('')
|
|
366
398
|
console.log('栖语栈 · 装之前先看这台机器')
|
|
367
399
|
console.log('─'.repeat(52))
|
package/src/run.mjs
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// 跑外部命令,并且**在受限沙箱里也能活**。
|
|
2
|
+
//
|
|
3
|
+
// 为什么需要这个文件(2026-10-23 实测):
|
|
4
|
+
// DSH 默认沙箱(workspace-write / read-only)不允许程序开命名管道,
|
|
5
|
+
// 所以 `execFileSync(cmd, args, { stdio: ['ignore','pipe','pipe'] })` 会直接 EPERM。
|
|
6
|
+
// 而 qiyu 里几乎所有判断都靠"跑条命令看输出"——一旦 EPERM,
|
|
7
|
+
// 就会变成「没装 Docker」「端口查不出来」「容器不存在」这类**假阴性**,
|
|
8
|
+
// 把用户和 agent 一起带沟里。
|
|
9
|
+
//
|
|
10
|
+
// 兜底办法:抓不到管道,就把输出重定向到临时文件再读(沙箱允许写工作区)。
|
|
11
|
+
import { execFileSync } from 'node:child_process'
|
|
12
|
+
import { mkdtempSync, readFileSync, rmSync, existsSync } from 'node:fs'
|
|
13
|
+
import { join } from 'node:path'
|
|
14
|
+
import { tmpdir } from 'node:os'
|
|
15
|
+
|
|
16
|
+
let 遇到过沙箱限制 = false
|
|
17
|
+
|
|
18
|
+
/** 这次运行里,有没有因为沙箱读不到命令输出(结果可能不准) */
|
|
19
|
+
export function 沙箱受限过() {
|
|
20
|
+
return 遇到过沙箱限制
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function 引号(s) {
|
|
24
|
+
const t = String(s)
|
|
25
|
+
return /[\s"&|<>^]/.test(t) ? '"' + t.replace(/"/g, '\\"') + '"' : t
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** 把命令的输出重定向到文件,再读回来(不依赖管道)。 */
|
|
29
|
+
function 落盘跑(命令, 参数, 超时) {
|
|
30
|
+
let 目录 = ''
|
|
31
|
+
try {
|
|
32
|
+
目录 = mkdtempSync(join(tmpdir(), 'qiyu-cmd-'))
|
|
33
|
+
const 输出文件 = join(目录, 'out.txt')
|
|
34
|
+
const 整行 = [命令, ...参数].map(引号).join(' ') + ' > ' + 引号(输出文件) + ' 2>&1'
|
|
35
|
+
if (process.platform === 'win32') {
|
|
36
|
+
execFileSync('cmd.exe', ['/c', 整行], { timeout: 超时, windowsHide: true, stdio: ['ignore', 'ignore', 'ignore'] })
|
|
37
|
+
} else {
|
|
38
|
+
execFileSync('sh', ['-c', 整行], { timeout: 超时, stdio: ['ignore', 'ignore', 'ignore'] })
|
|
39
|
+
}
|
|
40
|
+
const 出 = existsSync(输出文件) ? readFileSync(输出文件, 'utf8').trim() : ''
|
|
41
|
+
return { ok: true, 出, 错: '' }
|
|
42
|
+
} catch (错) {
|
|
43
|
+
// 命令本身失败(退出码非 0)时,输出仍可能在文件里——尽量把它带回去
|
|
44
|
+
let 出 = ''
|
|
45
|
+
try {
|
|
46
|
+
const 文件 = join(目录, 'out.txt')
|
|
47
|
+
if (目录 && existsSync(文件)) 出 = readFileSync(文件, 'utf8').trim()
|
|
48
|
+
} catch {}
|
|
49
|
+
return { ok: false, 出, 错: 出 || String((错 && (错.stderr || 错.message)) || 错).trim() }
|
|
50
|
+
} finally {
|
|
51
|
+
try {
|
|
52
|
+
if (目录) rmSync(目录, { recursive: true, force: true })
|
|
53
|
+
} catch {}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 跑一条命令。
|
|
59
|
+
* @returns {{ok:boolean, 出:string, 错:string, 沙箱?:boolean}}
|
|
60
|
+
*/
|
|
61
|
+
export function 跑(命令, 参数 = [], 超时 = 8000) {
|
|
62
|
+
try {
|
|
63
|
+
const 出 = execFileSync(命令, 参数, {
|
|
64
|
+
timeout: 超时,
|
|
65
|
+
encoding: 'utf8',
|
|
66
|
+
windowsHide: true,
|
|
67
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
68
|
+
})
|
|
69
|
+
return { ok: true, 出: String(出).trim(), 错: '' }
|
|
70
|
+
} catch (错) {
|
|
71
|
+
const 码 = String((错 && 错.code) || '')
|
|
72
|
+
const 文 = String((错 && (错.stderr || 错.message)) || 错)
|
|
73
|
+
// 被沙箱拦了:换一条不靠管道的路
|
|
74
|
+
if (码 === 'EPERM' || 码 === 'EACCES' || 码 === 'EPIPE' || /EPERM|EACCES|spawn .*pipe/i.test(文)) {
|
|
75
|
+
遇到过沙箱限制 = true
|
|
76
|
+
const 结果 = 落盘跑(命令, 参数, 超时)
|
|
77
|
+
return { ...结果, 沙箱: true }
|
|
78
|
+
}
|
|
79
|
+
return { ok: false, 出: String((错 && 错.stdout) || '').trim(), 错: 文.trim() }
|
|
80
|
+
}
|
|
81
|
+
}
|