agent-hub-runner 0.1.1 → 0.1.3
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/README.md +9 -0
- package/autostart.mjs +2 -1
- package/changes.mjs +195 -0
- package/ops.mjs +53 -11
- package/package.json +27 -5
- package/runner.mjs +96 -7
package/README.md
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
<!-- @意图#R690(2026-11-01): 加「改动记录」一节:存在哪、打码、64 KB、30 天、怎么删 -->
|
|
1
2
|
<!-- @意图#R666(2026-10-29): 改成不用全局安装的 npx 一条命令(码@地址)、Mac 开机自启、stop / uninstall、常见报错 -->
|
|
2
3
|
<!-- @意图#R664(2026-10-29): npm 包 agent-hub-runner 的说明(随包发布):给客户看的安装、配对、再连、查看、断开、卸载和安全提示 -->
|
|
3
4
|
# agent-hub-runner
|
|
@@ -51,6 +52,8 @@ npx -y agent-hub-runner@latest uninstall # 关掉开机自启、删掉复
|
|
|
51
52
|
|
|
52
53
|
开机自启时,程序复制到 `~/.agent-runner/bin/<版本>/`,日志在 `~/.agent-runner/logs/runner.log`(超过 5MB 自动清空)。网页上提示「有新版本」时,按提示运行那一条升级命令。
|
|
53
54
|
|
|
55
|
+
仓库开发者(R699):要让仓库里的源码 `runner/runner.mjs` 直接连测试服、开机自启,用 `agent-hub/scripts/runner-test.sh autostart on|off | start | stop | status | log`(launchd 服务 `com.agenthub.runner.src`,日志 `~/.agent-runner/logs/runner-src.log`,不复制程序,改了代码 `start` 一下就生效)。普通用户不用管这个。
|
|
56
|
+
|
|
54
57
|
## 配置文件
|
|
55
58
|
|
|
56
59
|
- 默认在 `~/.agent-runner/config.json`(权限 600),里面有服务器地址、设备 id、长期令牌、电脑名和每个 bot 的目录。别发给别人。
|
|
@@ -71,6 +74,12 @@ npx -y agent-hub-runner@latest uninstall # 关掉开机自启、删掉复
|
|
|
71
74
|
- 在网页抽屉「连着的电脑」里点「断开」:令牌立刻作废,再连要重新配对;开机自启也会自己关掉。
|
|
72
75
|
- 卸载:`npx -y agent-hub-runner@latest uninstall`(加 `--purge` 连配置一起删,不再问)。
|
|
73
76
|
|
|
77
|
+
## 改动记录(0.1.2 起)
|
|
78
|
+
|
|
79
|
+
- bot 每次写文件、改文件,运行器在配置文件旁边的 `edits/` 文件夹(默认 `~/.agent-runner/edits/`,只有你能读)存一份改动记录,网页上点「已改文件 / 已写文件(自动允许)」那行灰字就能看到改了什么。内容只在你电脑上,服务器不存。
|
|
80
|
+
- 存之前会把像密钥的字符串换成 `***`(`sk-…`、`ghp_…`、连接串里的密码、私钥、`XXX_KEY=…` 这类赋值);每条最多 64 KB;只留 30 天,过期自动删。电脑不在线时网页上看不到。
|
|
81
|
+
- 不想留:直接删掉 `edits/` 文件夹,不影响别的。
|
|
82
|
+
|
|
74
83
|
## 安全提示
|
|
75
84
|
|
|
76
85
|
- 别把放着密钥、令牌的文件夹授权出去:bot 在授权文件夹里读、写、改文件不用你批准。
|
package/autostart.mjs
CHANGED
|
@@ -17,7 +17,8 @@ export const LABEL = "com.agenthub.runner";
|
|
|
17
17
|
export const LOG_MAX_BYTES = 5 * 1024 * 1024;
|
|
18
18
|
export const THROTTLE_S = 10;
|
|
19
19
|
/** 复制到 ~/.agent-runner/bin/<版本>/ 的文件(package.json 用来读版本号) */
|
|
20
|
-
|
|
20
|
+
// @意图#R690(2026-11-01): 多复制 changes.mjs(runner.mjs 要 import 它,少了开机自启的副本起不来)
|
|
21
|
+
export const COPY_FILES = ["runner.mjs", "ops.mjs", "edit.mjs", "changes.mjs", "autostart.mjs", "package.json"];
|
|
21
22
|
/** 优先用的稳定 node 路径(Homebrew 升级 node 后这两个软链还在;nvm 的带版本号路径卸了就没了) */
|
|
22
23
|
export const STABLE_NODES = ["/opt/homebrew/bin/node", "/usr/local/bin/node"];
|
|
23
24
|
export const ONLY_MAC = "开机自启目前只支持 Mac";
|
package/changes.mjs
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// @意图#R690(2026-11-01): 私聊里「已改文件 / 已写文件(自动允许)」点开看改动(king 同意 R690):改动内容只存在你电脑上(运行器配置旁边的 edits/<auto_id>.json,权限 600),
|
|
2
|
+
// 服务器库里只记 id。改文件存按行对比(带改前、改后行号和前后 3 行,网页左右对照用),写文件存写进去的内容;存之前先打码(和服务器 redactSecrets 同一套规则,
|
|
3
|
+
// 共用测试样例 test/fixtures/redact-cases.json);每条最多 64 KB(超出截断并标记);只留 30 天(启动时和每次存时顺手清,最多一小时一次)。存失败只记日志,不影响写改本身。
|
|
4
|
+
import fs from "node:fs/promises";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
|
|
7
|
+
export const CHANGE_ID_RE = /^auto-[0-9a-f]{32}$/;
|
|
8
|
+
export const MAX_RECORD_BYTES = 64 * 1024;
|
|
9
|
+
export const KEEP_MS = 30 * 24 * 60 * 60_000;
|
|
10
|
+
export const CLEAN_EVERY_MS = 60 * 60_000;
|
|
11
|
+
export const CONTEXT_LINES = 3;
|
|
12
|
+
/** 中间改动部分(去掉首尾相同的行后)行数乘积超过这个就不细算,整块当作删了再加 */
|
|
13
|
+
export const MAX_DIFF_CELLS = 4_000_000;
|
|
14
|
+
|
|
15
|
+
/** 改动记录放在配置文件旁边的 edits/ 里(跟着 AGENT_RUNNER_CONFIG 走,dev 和正式配置各管各的文件,名字是 auto_id 不会撞)。 */
|
|
16
|
+
// @意图#R690(2026-11-01): king 定甲:放配置旁边
|
|
17
|
+
export const changesDirFor = (configFile) => path.join(path.dirname(configFile), "edits");
|
|
18
|
+
|
|
19
|
+
// 变量名以 KEY / TOKEN / SECRET / PASSWORD / PASSWD 结尾(API_KEY、db.password、apiKey、token 都算;monkey、tokens 不算)
|
|
20
|
+
const NAME = String.raw`(?:(?:[A-Za-z0-9]+[_.-])*(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD|key|token|secret|password|passwd|Key|Token|Secret|Password)|[a-z][A-Za-z0-9]*(?:Key|Token|Secret|Password))`;
|
|
21
|
+
const QUOTED_ASSIGN = new RegExp(String.raw`(^|[^\w.-])(["']?)(${NAME})\2([ \t]*[=:][ \t]*)(["'])([^"'\r\n]+)\5`, "gm");
|
|
22
|
+
const LINE_ASSIGN = new RegExp(String.raw`^([ \t]*(?:export[ \t]+)?)(${NAME})([ \t]*[=:][ \t]*)([^\s"'#][^\s#]*)`, "gm");
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 打码(和服务器 server/src/model-keys.ts 的 redactSecrets 规则一字不差,样例见 test/fixtures/redact-cases.json):
|
|
26
|
+
* sk- / ghp_ 开头的串、连接串里的密码、私钥块(BEGIN/END 两行留着,中间每行换成 ***,行数不变)、
|
|
27
|
+
* 变量名像密钥的赋值(带引号的值任何位置都打;不带引号的只认行首的 .env / yaml 写法)。
|
|
28
|
+
*/
|
|
29
|
+
// @意图#R690(2026-11-01): king 定乙:服务器三条规则 + 私钥块 + .env 式 XXX_KEY / TOKEN / SECRET / PASSWORD = 值;打码不改变行数(行号对得上)
|
|
30
|
+
export function redactSecrets(text) {
|
|
31
|
+
let s = String(text);
|
|
32
|
+
s = s.replace(/(-----BEGIN [A-Z0-9 ]*-----)([\s\S]*?)(-----END [A-Z0-9 ]*-----)/g, (_m, a, body, b) => a + body.replace(/[^\r\n]+/g, "***") + b);
|
|
33
|
+
s = s.replace(/(-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----)((?:(?!-----END )[\s\S])*)$/, (_m, a, body) => a + body.replace(/[^\r\n]+/g, "***")); // 截断了没有 END 的私钥块
|
|
34
|
+
s = s.replace(/(?<![A-Za-z0-9])sk-[A-Za-z0-9_-]+/g, "sk-***").replace(/(?<![A-Za-z0-9])ghp_[A-Za-z0-9_]+/g, "ghp_***")
|
|
35
|
+
.replace(/([a-z][a-z0-9+.-]*:\/\/[^\s:/@]+):[^\s@/]+@/gi, "$1:***@");
|
|
36
|
+
s = s.replace(QUOTED_ASSIGN, (m, pre, q1, name, eq, q2, val) => (val === "***" ? m : `${pre}${q1}${name}${q1}${eq}${q2}***${q2}`));
|
|
37
|
+
s = s.replace(LINE_ASSIGN, "$1$2$3***");
|
|
38
|
+
return s;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 按行拆(去 BOM、换行统一成 LF;末尾换行不算多一行)。 */
|
|
42
|
+
export function splitLines(text) {
|
|
43
|
+
let t = String(text);
|
|
44
|
+
if (t.startsWith("\uFEFF")) t = t.slice(1);
|
|
45
|
+
t = t.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
46
|
+
if (t === "") return [];
|
|
47
|
+
const a = t.split("\n");
|
|
48
|
+
if (a[a.length - 1] === "") a.pop();
|
|
49
|
+
return a;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 逐行对比:返回每一行 {t:" "|"-"|"+", o:改前行号|null, n:改后行号|null, s:内容};连着的一段改动里先删后加。 */
|
|
53
|
+
// @意图#R690(2026-11-01): 带两边行号,网页既能画左右对照也能画红绿一栏
|
|
54
|
+
export function diffLines(a, b) {
|
|
55
|
+
let p = 0;
|
|
56
|
+
while (p < a.length && p < b.length && a[p] === b[p]) p++;
|
|
57
|
+
let q = 0;
|
|
58
|
+
while (q < a.length - p && q < b.length - p && a[a.length - 1 - q] === b[b.length - 1 - q]) q++;
|
|
59
|
+
const A = a.slice(p, a.length - q), B = b.slice(p, b.length - q);
|
|
60
|
+
const mid = []; // " " / "-" / "+" 序列
|
|
61
|
+
if (A.length && B.length && A.length * B.length <= MAX_DIFF_CELLS) {
|
|
62
|
+
const n = A.length, m = B.length, w = m + 1;
|
|
63
|
+
const dp = new Uint32Array((n + 1) * w);
|
|
64
|
+
for (let i = n - 1; i >= 0; i--) for (let j = m - 1; j >= 0; j--) {
|
|
65
|
+
dp[i * w + j] = A[i] === B[j] ? dp[(i + 1) * w + j + 1] + 1 : Math.max(dp[(i + 1) * w + j], dp[i * w + j + 1]);
|
|
66
|
+
}
|
|
67
|
+
let i = 0, j = 0;
|
|
68
|
+
while (i < n || j < m) {
|
|
69
|
+
if (i < n && j < m && A[i] === B[j]) { mid.push(" "); i++; j++; }
|
|
70
|
+
else if (j >= m || (i < n && dp[(i + 1) * w + j] >= dp[i * w + j + 1])) { mid.push("-"); i++; }
|
|
71
|
+
else { mid.push("+"); j++; }
|
|
72
|
+
}
|
|
73
|
+
} else {
|
|
74
|
+
for (let i = 0; i < A.length; i++) mid.push("-");
|
|
75
|
+
for (let j = 0; j < B.length; j++) mid.push("+");
|
|
76
|
+
}
|
|
77
|
+
// 连着的改动里先删后加
|
|
78
|
+
const tags = [];
|
|
79
|
+
for (let k = 0; k < mid.length;) {
|
|
80
|
+
if (mid[k] === " ") { tags.push(" "); k++; continue; }
|
|
81
|
+
let d = 0, ad = 0;
|
|
82
|
+
while (k < mid.length && mid[k] !== " ") { if (mid[k] === "-") d++; else ad++; k++; }
|
|
83
|
+
for (let x = 0; x < d; x++) tags.push("-");
|
|
84
|
+
for (let x = 0; x < ad; x++) tags.push("+");
|
|
85
|
+
}
|
|
86
|
+
const out = [];
|
|
87
|
+
let o = 0, nn = 0;
|
|
88
|
+
for (let k = 0; k < p; k++) { out.push({ t: " ", o: o + 1, n: nn + 1, s: a[o] }); o++; nn++; }
|
|
89
|
+
for (const t of tags) {
|
|
90
|
+
if (t === " ") { out.push({ t, o: o + 1, n: nn + 1, s: a[o] }); o++; nn++; }
|
|
91
|
+
else if (t === "-") { out.push({ t, o: o + 1, n: null, s: a[o] }); o++; }
|
|
92
|
+
else { out.push({ t, o: null, n: nn + 1, s: b[nn] }); nn++; }
|
|
93
|
+
}
|
|
94
|
+
for (let k = 0; k < q; k++) { out.push({ t: " ", o: o + 1, n: nn + 1, s: a[o] }); o++; nn++; }
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** 把逐行对比切成几处改动,每处前后带 ctx 行没变的;两处之间没变的不超过 2×ctx 行就并成一处。 */
|
|
99
|
+
export function hunksOf(rows, ctx = CONTEXT_LINES) {
|
|
100
|
+
const idx = [];
|
|
101
|
+
rows.forEach((r, i) => { if (r.t !== " ") idx.push(i); });
|
|
102
|
+
const hunks = [];
|
|
103
|
+
let k = 0;
|
|
104
|
+
while (k < idx.length) {
|
|
105
|
+
let start = idx[k], end = idx[k];
|
|
106
|
+
while (k + 1 < idx.length && idx[k + 1] - end - 1 <= 2 * ctx) { k++; end = idx[k]; }
|
|
107
|
+
k++;
|
|
108
|
+
const from = Math.max(0, start - ctx), to = Math.min(rows.length, end + ctx + 1);
|
|
109
|
+
const lines = rows.slice(from, to);
|
|
110
|
+
let oStart = 1, nStart = 1;
|
|
111
|
+
for (let i = 0; i < from; i++) { if (rows[i].t !== "+") oStart++; if (rows[i].t !== "-") nStart++; }
|
|
112
|
+
hunks.push({ old_start: oStart, new_start: nStart, lines });
|
|
113
|
+
}
|
|
114
|
+
return hunks;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const jsonBytes = (v) => Buffer.byteLength(JSON.stringify(v), "utf8") + 1; // 存进去占的字节(加逗号)
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* 生成一条改动记录(存之前先打码、截到 64 KB)。
|
|
121
|
+
* edit:{ v, id, tool, path, at, hunks, added, removed, truncated };write:{ v, id, tool, path, at, lines, total_lines, chars, truncated }
|
|
122
|
+
*/
|
|
123
|
+
// @意图#R690(2026-11-01): 改文件存红绿对比(king 定),写文件只存写进去的内容、不存旧文件(king 定乙);每条最多 64 KB(king 定甲)
|
|
124
|
+
export function buildChangeRecord({ id, tool, path: p, before, after, content, at = new Date().toISOString() }) {
|
|
125
|
+
const budget = MAX_RECORD_BYTES - 1024;
|
|
126
|
+
const head = { v: 1, id, tool, path: String(p ?? ""), at };
|
|
127
|
+
if (tool === "write") {
|
|
128
|
+
const text = String(content ?? "");
|
|
129
|
+
const all = splitLines(redactSecrets(text));
|
|
130
|
+
const lines = [];
|
|
131
|
+
let used = 0, truncated = false;
|
|
132
|
+
for (const l of all) {
|
|
133
|
+
const b = jsonBytes(l);
|
|
134
|
+
if (used + b > budget) { truncated = true; break; }
|
|
135
|
+
lines.push(l); used += b;
|
|
136
|
+
}
|
|
137
|
+
return { ...head, lines, total_lines: all.length, chars: text.length, truncated };
|
|
138
|
+
}
|
|
139
|
+
const rows = diffLines(splitLines(redactSecrets(before ?? "")), splitLines(redactSecrets(after ?? "")));
|
|
140
|
+
let added = 0, removed = 0;
|
|
141
|
+
for (const r of rows) { if (r.t === "+") added++; else if (r.t === "-") removed++; }
|
|
142
|
+
const hunks = [];
|
|
143
|
+
let used = 0, truncated = false;
|
|
144
|
+
for (const h of hunksOf(rows)) {
|
|
145
|
+
used += jsonBytes({ old_start: h.old_start, new_start: h.new_start, lines: [] });
|
|
146
|
+
const keep = [];
|
|
147
|
+
for (const l of h.lines) {
|
|
148
|
+
const b = jsonBytes(l);
|
|
149
|
+
if (used + b > budget) { truncated = true; break; }
|
|
150
|
+
keep.push(l); used += b;
|
|
151
|
+
}
|
|
152
|
+
if (keep.length) hunks.push({ ...h, lines: keep });
|
|
153
|
+
if (truncated) break;
|
|
154
|
+
}
|
|
155
|
+
return { ...head, hunks, added, removed, truncated };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** 改动记录的存取和清理(目录用到时才建,700;文件 600)。 */
|
|
159
|
+
// @意图#R690(2026-11-01): 只留 30 天(king 定甲):启动时清一次,每次存时顺手清(最多一小时一次),按文件修改时间算
|
|
160
|
+
export function createChangeStore(dir, { now = Date.now, log = () => {} } = {}) {
|
|
161
|
+
let lastClean = 0;
|
|
162
|
+
const fileOf = (id) => path.join(dir, `${id}.json`);
|
|
163
|
+
async function clean(force = false) {
|
|
164
|
+
if (!force && now() - lastClean < CLEAN_EVERY_MS) return 0;
|
|
165
|
+
lastClean = now();
|
|
166
|
+
let names;
|
|
167
|
+
try { names = await fs.readdir(dir); } catch { return 0; }
|
|
168
|
+
let removed = 0;
|
|
169
|
+
for (const f of names) {
|
|
170
|
+
if (!/^auto-[0-9a-f]{32}\.json$/.test(f)) continue;
|
|
171
|
+
try {
|
|
172
|
+
const st = await fs.stat(path.join(dir, f));
|
|
173
|
+
if (now() - st.mtimeMs > KEEP_MS) { await fs.unlink(path.join(dir, f)); removed++; }
|
|
174
|
+
} catch { /* 别的进程删了 */ }
|
|
175
|
+
}
|
|
176
|
+
return removed;
|
|
177
|
+
}
|
|
178
|
+
return {
|
|
179
|
+
dir,
|
|
180
|
+
clean,
|
|
181
|
+
async save(id, record) {
|
|
182
|
+
if (!CHANGE_ID_RE.test(String(id))) throw new Error("改动记录 id 不对");
|
|
183
|
+
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
|
|
184
|
+
const f = fileOf(id);
|
|
185
|
+
await fs.writeFile(f, JSON.stringify(record), { mode: 0o600 });
|
|
186
|
+
await fs.chmod(f, 0o600);
|
|
187
|
+
await clean().catch((e) => log(`清理旧改动记录失败:${e?.message ?? e}`));
|
|
188
|
+
},
|
|
189
|
+
/** 找不到(没存、已清理、id 不对)返回 null */
|
|
190
|
+
async get(id) {
|
|
191
|
+
if (!CHANGE_ID_RE.test(String(id))) return null;
|
|
192
|
+
try { return JSON.parse(await fs.readFile(fileOf(id), "utf8")); } catch { return null; }
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
}
|
package/ops.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// @意图#R636(2026-10-27): 运行器在你电脑上真正执行 read / write / edit / bash。路径只在这里判断(S6 结论:服务器不解析用户电脑的路径):
|
|
2
2
|
// 先按授权目录换算成真实路径(解开符号链接,包括指向不存在目标的符号链接),不在授权目录里的一律拒绝;bash 的工作目录就是授权目录。
|
|
3
3
|
// 第一轮没有沙箱(king 定的范围):bash 里的命令本身能碰到目录外的东西,只能靠你看清批准卡片再点。
|
|
4
|
-
import { spawn } from "node:child_process";
|
|
4
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
5
5
|
import fs from "node:fs/promises";
|
|
6
6
|
import os from "node:os";
|
|
7
7
|
import path from "node:path";
|
|
@@ -68,7 +68,7 @@ function imageMime(buf) {
|
|
|
68
68
|
export async function opRead(args, root) {
|
|
69
69
|
const real = await resolveInside(args.path, root);
|
|
70
70
|
const st = await fs.stat(real);
|
|
71
|
-
if (st.isDirectory()) throw new Error(`${args.path} 是目录,不是文件(看目录里有什么用 bash 的 ls)`);
|
|
71
|
+
if (st.isDirectory()) throw new Error(`${args.path} 是目录,不是文件(看目录里有什么用 ${process.platform === "win32" ? "PowerShell 的 Get-ChildItem" : "bash 的 ls"})`);
|
|
72
72
|
if (st.size > MAX_READ_BYTES) throw new Error(`${args.path} 太大(${st.size} 字节),超过 ${MAX_READ_BYTES} 字节;用 bash 的 head/sed 看一部分`);
|
|
73
73
|
const buf = await fs.readFile(real);
|
|
74
74
|
const mime = imageMime(buf);
|
|
@@ -77,39 +77,79 @@ export async function opRead(args, root) {
|
|
|
77
77
|
}
|
|
78
78
|
|
|
79
79
|
/** write:建好上级目录再整份写入。 */
|
|
80
|
+
// @意图#R690(2026-11-01): 写成功后交给 onChange 存一份改动记录(写进去的内容),存记录的事不影响写本身
|
|
80
81
|
// @意图#R636(2026-10-27): 写文件(已经过批准,只执行这一次)
|
|
81
|
-
export async function opWrite(args, root) {
|
|
82
|
+
export async function opWrite(args, root, { onChange } = {}) {
|
|
82
83
|
if (typeof args.content !== "string") throw new Error("content 必须是文字");
|
|
83
84
|
const real = await resolveInside(args.path, root);
|
|
84
85
|
await fs.mkdir(path.dirname(real), { recursive: true });
|
|
85
86
|
await fs.writeFile(real, args.content, "utf8");
|
|
87
|
+
await onChange?.({ tool: "write", path: args.path, content: args.content });
|
|
86
88
|
return { bytes: Buffer.byteLength(args.content, "utf8") };
|
|
87
89
|
}
|
|
88
90
|
|
|
89
91
|
/** edit:和 pi 原版同样的精确替换。 */
|
|
92
|
+
// @意图#R690(2026-11-01): 写回成功后把改前(读到的原文)、改后(写回的内容)交给 onChange 算对比、存记录:同一个函数里拿到的两份,不会和别的改动串
|
|
90
93
|
// @意图#R636(2026-10-27): 改文件(已经过批准,只执行这一次)
|
|
91
|
-
export async function opEdit(args, root) {
|
|
94
|
+
export async function opEdit(args, root, { onChange } = {}) {
|
|
92
95
|
if (!Array.isArray(args.edits) || args.edits.length === 0) throw new Error("Edit tool input is invalid. edits must contain at least one replacement.");
|
|
93
96
|
const real = await resolveInside(args.path, root);
|
|
94
97
|
let raw;
|
|
95
98
|
try { raw = await fs.readFile(real, "utf8"); } catch (e) { throw new Error(`Could not edit file: ${args.path}. Error code: ${e?.code ?? "unknown"}.`); }
|
|
96
99
|
const next = editText(raw, args.edits, args.path);
|
|
97
100
|
await fs.writeFile(real, next, "utf8");
|
|
101
|
+
await onChange?.({ tool: "edit", path: args.path, before: raw, after: next });
|
|
98
102
|
return { replaced: args.edits.length };
|
|
99
103
|
}
|
|
100
104
|
|
|
101
|
-
function killGroup(child) {
|
|
105
|
+
function killGroup(child, platform = process.platform) {
|
|
102
106
|
if (!child.pid) return;
|
|
107
|
+
// @意图#R721(2026-11-02): Windows 没有负进程组,要 taskkill /T /F 才能连子进程一起杀
|
|
108
|
+
if (platform === "win32") {
|
|
109
|
+
try { spawnSync("taskkill", ["/PID", String(child.pid), "/T", "/F"], { stdio: "ignore", windowsHide: true }); } catch { /* 进程已经没了 */ }
|
|
110
|
+
try { child.kill("SIGKILL"); } catch { /* ignore */ }
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
103
113
|
try { process.kill(-child.pid, "SIGKILL"); } catch { /* 进程组已经没了 */ }
|
|
104
114
|
try { child.kill("SIGKILL"); } catch { /* ignore */ }
|
|
105
115
|
}
|
|
106
116
|
|
|
117
|
+
/**
|
|
118
|
+
* 按平台选执行器。
|
|
119
|
+
* Windows 上没有 bash:用 PowerShell,整段脚本转 UTF-16LE base64 传给 -EncodedCommand。
|
|
120
|
+
* 这样一次解决两个实现时踩到的坑:① 中文参数走 -Command 会按 ANSI 代码页解码成乱码;
|
|
121
|
+
* ② powershell -Command 的退出码恒为 0,得靠脚本尾巴的 exit $LASTEXITCODE 才拿得到真实结果。
|
|
122
|
+
*/
|
|
123
|
+
// @意图#R721(2026-11-02): Windows 用 PowerShell 跑命令(客户是小白,不能要求装 Git Bash)
|
|
124
|
+
export function shellFor(command, platform = process.platform) {
|
|
125
|
+
if (platform === "win32") {
|
|
126
|
+
// $ProgressPreference 必须关:不加的话 PowerShell 会把进度流序列化成 #< CLIXML ... 写到 stderr,模型会看到一堆垃圾。
|
|
127
|
+
// `2>&1 | ForEach-Object { "$_" }`:错误也要转成纯字符串,否则 ErrorRecord 会被序列化成 CLIXML;
|
|
128
|
+
// 用管道逐条处理,保持边跑边输出(不缓冲)。
|
|
129
|
+
const script = [
|
|
130
|
+
"$ProgressPreference = 'SilentlyContinue'",
|
|
131
|
+
"[Console]::OutputEncoding=[Text.Encoding]::UTF8",
|
|
132
|
+
"$OutputEncoding=[Text.Encoding]::UTF8",
|
|
133
|
+
"& {",
|
|
134
|
+
command,
|
|
135
|
+
'} 2>&1 | ForEach-Object { "$_" }',
|
|
136
|
+
"exit $LASTEXITCODE",
|
|
137
|
+
"",
|
|
138
|
+
].join("\n");
|
|
139
|
+
return {
|
|
140
|
+
file: "powershell.exe",
|
|
141
|
+
args: ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-OutputFormat", "Text", "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64")],
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
return { file: "bash", args: ["-c", command] };
|
|
145
|
+
}
|
|
146
|
+
|
|
107
147
|
/**
|
|
108
148
|
* bash:在授权目录里用你终端的环境变量跑(服务器的环境变量不下发)。输出边跑边交给 onData;
|
|
109
149
|
* 超时或 signal 中止时杀掉整个进程组。返回 { exitCode, timedOut, aborted }。
|
|
110
150
|
*/
|
|
111
151
|
// @意图#R636(2026-10-27): 跑命令(已经过批准,只执行这一次),有超时,超时杀进程
|
|
112
|
-
export function opBash(args, root, { onData, signal } = {}) {
|
|
152
|
+
export function opBash(args, root, { onData, signal, platform = process.platform } = {}) {
|
|
113
153
|
const command = args.command;
|
|
114
154
|
if (typeof command !== "string" || !command.trim()) return Promise.reject(new Error("command 是空的"));
|
|
115
155
|
let timeoutS = DEFAULT_BASH_TIMEOUT_S;
|
|
@@ -118,12 +158,13 @@ export function opBash(args, root, { onData, signal } = {}) {
|
|
|
118
158
|
if (!Number.isFinite(t) || t <= 0) return Promise.reject(new Error("Invalid timeout: must be a finite number of seconds"));
|
|
119
159
|
timeoutS = t;
|
|
120
160
|
}
|
|
161
|
+
const shell = shellFor(command, platform);
|
|
121
162
|
return new Promise((resolve, reject) => {
|
|
122
|
-
const child = spawn(
|
|
163
|
+
const child = spawn(shell.file, shell.args, { cwd: root, detached: platform !== "win32", stdio: ["ignore", "pipe", "pipe"], env: process.env, windowsHide: true });
|
|
123
164
|
let timedOut = false;
|
|
124
165
|
let aborted = false;
|
|
125
|
-
const timer = setTimeout(() => { timedOut = true; killGroup(child); }, timeoutS * 1000);
|
|
126
|
-
const onAbort = () => { aborted = true; killGroup(child); };
|
|
166
|
+
const timer = setTimeout(() => { timedOut = true; killGroup(child, platform); }, timeoutS * 1000);
|
|
167
|
+
const onAbort = () => { aborted = true; killGroup(child, platform); };
|
|
127
168
|
if (signal) {
|
|
128
169
|
if (signal.aborted) onAbort();
|
|
129
170
|
else signal.addEventListener("abort", onAbort, { once: true });
|
|
@@ -141,12 +182,13 @@ export function opBash(args, root, { onData, signal } = {}) {
|
|
|
141
182
|
}
|
|
142
183
|
|
|
143
184
|
/** 按 op 分派;未知 op 拒绝。 */
|
|
185
|
+
// @意图#R690(2026-11-01): write、edit 也收 extra(onChange 存改动记录)
|
|
144
186
|
// @意图#R636(2026-10-27): 协议里只有这四种工具调用
|
|
145
187
|
export function runOp(op, args, root, extra) {
|
|
146
188
|
const a = args && typeof args === "object" ? args : {};
|
|
147
189
|
if (op === "read") return opRead(a, root);
|
|
148
|
-
if (op === "write") return opWrite(a, root);
|
|
149
|
-
if (op === "edit") return opEdit(a, root);
|
|
190
|
+
if (op === "write") return opWrite(a, root, extra);
|
|
191
|
+
if (op === "edit") return opEdit(a, root, extra);
|
|
150
192
|
if (op === "bash") return opBash(a, root, extra);
|
|
151
193
|
return Promise.reject(new Error(`未知操作 ${op}`));
|
|
152
194
|
}
|
package/package.json
CHANGED
|
@@ -1,10 +1,32 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-hub-runner",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "agent-hub 的电脑运行器:配对后让你的 bot 在这台电脑上读写授权目录里的文件、跑命令(跑命令要你在网页上批准)",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"bin": {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
"
|
|
6
|
+
"bin": {
|
|
7
|
+
"agent-hub-runner": "bin/agent-hub-runner.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"runner.mjs",
|
|
11
|
+
"ops.mjs",
|
|
12
|
+
"edit.mjs",
|
|
13
|
+
"changes.mjs",
|
|
14
|
+
"autostart.mjs",
|
|
15
|
+
"bin/agent-hub-runner.mjs",
|
|
16
|
+
"README.md"
|
|
17
|
+
],
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=22"
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"test": "node --test"
|
|
23
|
+
},
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/cs1145994973/agent-hub-runner.git"
|
|
27
|
+
},
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public",
|
|
30
|
+
"provenance": true
|
|
31
|
+
}
|
|
10
32
|
}
|
package/runner.mjs
CHANGED
|
@@ -23,9 +23,10 @@ import fs from "node:fs/promises";
|
|
|
23
23
|
import os from "node:os";
|
|
24
24
|
import path from "node:path";
|
|
25
25
|
import readline from "node:readline/promises";
|
|
26
|
-
import { execFile } from "node:child_process";
|
|
26
|
+
import { execFile, spawn, spawnSync } from "node:child_process";
|
|
27
27
|
import { fileURLToPath } from "node:url";
|
|
28
28
|
import { runOp } from "./ops.mjs";
|
|
29
|
+
import { buildChangeRecord, changesDirFor, CHANGE_ID_RE, createChangeStore } from "./changes.mjs";
|
|
29
30
|
import { autostartOff, autostartOn, autostartStatus, logFileFor, ONLY_MAC, otherAutostarts, runnerHome, truncateLogIfBig } from "./autostart.mjs";
|
|
30
31
|
|
|
31
32
|
export const PROTOCOL_VERSION = 1;
|
|
@@ -97,10 +98,19 @@ function checkNode() {
|
|
|
97
98
|
|
|
98
99
|
export const PICK_PROMPT = "选择要授权给 bot 的文件夹";
|
|
99
100
|
export const PICK_SCRIPT = `POSIX path of (choose folder with prompt "${PICK_PROMPT}")`;
|
|
101
|
+
/** Windows 上的弹窗脚本:系统自带的 .NET 文件夹选择框,不需要任何额外安装。 */
|
|
102
|
+
export const PICK_SCRIPT_WIN = [
|
|
103
|
+
"[Console]::OutputEncoding=[Text.Encoding]::UTF8",
|
|
104
|
+
"Add-Type -AssemblyName System.Windows.Forms",
|
|
105
|
+
"$d = New-Object System.Windows.Forms.FolderBrowserDialog",
|
|
106
|
+
`$d.Description = '${PICK_PROMPT}'`,
|
|
107
|
+
"$d.ShowNewFolderButton = $true",
|
|
108
|
+
"if ($d.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { [Console]::Out.Write($d.SelectedPath) }",
|
|
109
|
+
].join("\n");
|
|
100
110
|
/** R640:这个 bot 在对照表里没有目录时,调用一律回这句。 */
|
|
101
111
|
// @意图#R666(2026-10-29): 「右栏」是 R663 以前的位置,改成输入框下面「我的电脑」那一行(和服务器 devices.ts 的 NO_DIR 一字不差)
|
|
102
112
|
export const NO_DIR = "还没给这个 bot 选目录,请在这个 bot 的私聊里点输入框下面「我的电脑」那一行,再点「选择目录」";
|
|
103
|
-
export const PICK_ONLY_MAC = "只支持 Mac:选目录的窗口只在 Mac 上弹";
|
|
113
|
+
export const PICK_ONLY_MAC = "只支持 Mac 和 Windows:选目录的窗口只在 Mac / Windows 上弹";
|
|
104
114
|
/** R640:pair 带了 --dir 时的提示(不报错,照常配对,目录不用)。 */
|
|
105
115
|
export const PAIR_DIR_IGNORED = "目录请连上后在每个 bot 的私聊里选(--dir 已不用,这次没用它)";
|
|
106
116
|
|
|
@@ -137,6 +147,43 @@ export function pickDirMac({ signal } = {}) {
|
|
|
137
147
|
|
|
138
148
|
/** 检查授权目录:存在、换成真实路径、是目录、不是磁盘根目录。返回真实路径。 */
|
|
139
149
|
// @意图#R640(2026-10-27): 每个 bot 选的目录都走这套检查
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Windows 上弹系统「选文件夹」窗口(.NET FolderBrowserDialog,系统自带,不用装任何东西)。
|
|
153
|
+
* 选了返回路径;点取消返回 null;signal 中止时 taskkill 关掉窗口并抛 AbortError;弹不出来(没有桌面会话)抛错。
|
|
154
|
+
*/
|
|
155
|
+
// @意图#R716(2026-11-02): Windows 客户是小白,不能要求装 Git Bash,也不能靠手填路径;弹窗用系统自带的 .NET 窗口
|
|
156
|
+
export function pickDirWindows({ signal, spawn: spawnFn = spawn } = {}) {
|
|
157
|
+
return new Promise((resolve, reject) => {
|
|
158
|
+
const b64 = Buffer.from(PICK_SCRIPT_WIN + "\n", "utf16le").toString("base64");
|
|
159
|
+
// 带 -STA:FolderBrowserDialog 要求单线程公寓;不带 -NonInteractive:要的就是交互窗口
|
|
160
|
+
const child = spawnFn("powershell.exe", ["-NoProfile", "-STA", "-ExecutionPolicy", "Bypass", "-OutputFormat", "Text", "-EncodedCommand", b64], {
|
|
161
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
162
|
+
windowsHide: true,
|
|
163
|
+
});
|
|
164
|
+
let out = "";
|
|
165
|
+
let err = "";
|
|
166
|
+
let settled = false;
|
|
167
|
+
const settle = (fn, v) => { if (!settled) { settled = true; signal?.removeEventListener("abort", onAbort); fn(v); } };
|
|
168
|
+
const killIt = () => {
|
|
169
|
+
if (child.pid) { try { spawnSync("taskkill", ["/PID", String(child.pid), "/T", "/F"], { stdio: "ignore", windowsHide: true }); } catch { /* 已退出 */ } }
|
|
170
|
+
try { child.kill("SIGKILL"); } catch { /* ignore */ }
|
|
171
|
+
};
|
|
172
|
+
const onAbort = () => { killIt(); settle(reject, Object.assign(new Error("已中止"), { name: "AbortError" })); };
|
|
173
|
+
if (signal) { if (signal.aborted) return onAbort(); signal.addEventListener("abort", onAbort, { once: true }); }
|
|
174
|
+
child.stdout?.on("data", (d) => { out += d.toString("utf8"); });
|
|
175
|
+
child.stderr?.on("data", (d) => { err += d.toString("utf8"); });
|
|
176
|
+
child.on("error", (e) => settle(reject, new Error(`弹不出选文件夹的窗口(${e?.message ?? e})`)));
|
|
177
|
+
child.on("close", () => {
|
|
178
|
+
const picked = out.trim();
|
|
179
|
+
if (picked) return settle(resolve, picked);
|
|
180
|
+
const why = err.trim().split("\n")[0].trim();
|
|
181
|
+
if (why) return settle(reject, new Error(`弹不出选文件夹的窗口(${why})`));
|
|
182
|
+
settle(resolve, null); // 点了取消:对话框没点确定,没有输出
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
140
187
|
// @意图#R639(2026-10-27): 配对时的 --dir 和网页上选的目录走同一套检查
|
|
141
188
|
export async function checkDir(dir) {
|
|
142
189
|
const real = await fs.realpath(path.resolve(dir)).catch(() => { throw new Error(`授权目录不存在:${dir}`); });
|
|
@@ -254,10 +301,26 @@ const wsUrlOf = (server) => `${server.replace(/^http/, "ws")}/devices/ws`;
|
|
|
254
301
|
// @意图#R640(2026-10-27): 目录按 bot 分:call 按 bot_id 查对照表,没有就拒绝 NO_DIR;pick_dir 带 bot_id,选完检查后记进对照表、写回配置(600),
|
|
255
302
|
// 回 dir_result {id, ok, bot_id, dir};老配置的单一 dir 忽略
|
|
256
303
|
// @意图#R639(2026-10-27已废弃): 目录不再是连接时固定的常量;没目录时 call 一律拒绝;pick_dir 选完检查、写回配置(600)、回 dir_result(R640 改成按 bot 分)
|
|
257
|
-
|
|
304
|
+
// @意图#R690(2026-11-01): write / edit 带 change_id(服务器记灰字时生成的 auto_id)就在写改成功后存一份改动记录(存失败只记日志);
|
|
305
|
+
// 新消息 change_get {id, change_id} → change_result {id, ok:true, found, record},不看这个 bot 有没有目录(换了目录也能看旧记录)
|
|
306
|
+
export function createSession(cfg, { log = say, platform = process.platform, pickDir = platform === "win32" ? pickDirWindows : pickDirMac, file = configPath(), changes } = {}) {
|
|
258
307
|
let dirs = botDirsOf(cfg); // bot_id → 授权目录
|
|
259
308
|
const jobs = new Map(); // 调用 id → AbortController
|
|
260
309
|
const picks = new Map(); // 弹窗 id → AbortController
|
|
310
|
+
let store = changes; // 改动记录(R690),用到时才建
|
|
311
|
+
const changeStore = () => (store ??= createChangeStore(changesDirFor(file), { log }));
|
|
312
|
+
const recorder = (args) => {
|
|
313
|
+
const id = typeof args?.change_id === "string" ? args.change_id : "";
|
|
314
|
+
if (!CHANGE_ID_RE.test(id)) return undefined;
|
|
315
|
+
return async (info) => {
|
|
316
|
+
try { await changeStore().save(id, buildChangeRecord({ id, ...info })); } catch (e) { log(`改动记录没存上(不影响这次写改):${e?.message ?? e}`); }
|
|
317
|
+
};
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
async function handleChangeGet(send, msg) {
|
|
321
|
+
const record = await changeStore().get(msg.change_id).catch(() => null);
|
|
322
|
+
send({ type: "change_result", id: msg.id, ok: true, found: !!record, ...(record ? { record } : {}) });
|
|
323
|
+
}
|
|
261
324
|
|
|
262
325
|
async function handleCall(send, msg) {
|
|
263
326
|
const { id, op, args } = msg;
|
|
@@ -268,7 +331,8 @@ export function createSession(cfg, { log = say, pickDir = pickDirMac, platform =
|
|
|
268
331
|
jobs.set(id, ac);
|
|
269
332
|
log(`[${bot}] ${op === "bash" ? `bash:${args?.command}` : `${op}:${args?.path}`}`);
|
|
270
333
|
try {
|
|
271
|
-
const
|
|
334
|
+
const onChange = op === "write" || op === "edit" ? recorder(args) : undefined;
|
|
335
|
+
const res = await runOp(op, args, dir, { signal: ac.signal, onChange, onData: (d) => send({ type: "output", id, data: Buffer.from(d).toString("base64") }) });
|
|
272
336
|
if (ac.signal.aborted) send({ type: "result", id, ok: false, error: "已中止" });
|
|
273
337
|
else send({ type: "result", id, ok: true, ...res });
|
|
274
338
|
} catch (e) {
|
|
@@ -282,7 +346,7 @@ export function createSession(cfg, { log = say, pickDir = pickDirMac, platform =
|
|
|
282
346
|
const { id } = msg;
|
|
283
347
|
const bot = typeof msg.bot_id === "string" ? msg.bot_id : "";
|
|
284
348
|
if (!bot) { send({ type: "dir_result", id, ok: false, error: "缺 bot_id" }); return; }
|
|
285
|
-
if (platform !== "darwin") { send({ type: "dir_result", id, ok: false, bot_id: bot, error: PICK_ONLY_MAC }); return; }
|
|
349
|
+
if (platform !== "darwin" && platform !== "win32") { send({ type: "dir_result", id, ok: false, bot_id: bot, error: PICK_ONLY_MAC }); return; }
|
|
286
350
|
const ac = new AbortController();
|
|
287
351
|
picks.set(id, ac);
|
|
288
352
|
log(`网页上给 bot ${bot} 点了选择目录:请在弹出的窗口里选文件夹`);
|
|
@@ -307,9 +371,12 @@ export function createSession(cfg, { log = say, pickDir = pickDirMac, platform =
|
|
|
307
371
|
|
|
308
372
|
return {
|
|
309
373
|
get botDirs() { return { ...dirs }; },
|
|
374
|
+
/** R690:清掉 30 天前的改动记录(startRunner 启动时调一次) */
|
|
375
|
+
cleanChanges() { return changeStore().clean(true).catch(() => 0); },
|
|
310
376
|
onMessage(msg, send) {
|
|
311
377
|
if (msg.type === "call") return handleCall(send, msg);
|
|
312
378
|
if (msg.type === "pick_dir") return handlePick(send, msg);
|
|
379
|
+
if (msg.type === "change_get") return handleChangeGet(send, msg);
|
|
313
380
|
if (msg.type === "abort") { jobs.get(msg.id)?.abort(); picks.get(msg.id)?.abort(); }
|
|
314
381
|
return undefined;
|
|
315
382
|
},
|
|
@@ -332,10 +399,11 @@ export function createSession(cfg, { log = say, pickDir = pickDirMac, platform =
|
|
|
332
399
|
// @意图#R640(2026-10-27): hello 报 bot→目录 对照表(bot_dirs),不再报单一 dir
|
|
333
400
|
// @意图#R639(2026-10-27): 没目录也能连上;目录由 createSession 管(网页上选完马上生效),重连时 hello 带当前目录
|
|
334
401
|
// @意图#R636(2026-10-27): 前台运行、心跳、执行工具调用;断线时杀掉正在跑的命令,重连后不重放
|
|
335
|
-
export function startRunner(cfg, { log = say, retryMs = 2000,
|
|
402
|
+
export function startRunner(cfg, { log = say, retryMs = 2000, platform = process.platform, pickDir = platform === "win32" ? pickDirWindows : pickDirMac, file = configPath(), background = process.env.AGENT_RUNNER_LAUNCHD === "1" } = {}) {
|
|
336
403
|
checkNode();
|
|
337
404
|
let fails = 0;
|
|
338
405
|
const session = createSession(cfg, { log, pickDir, platform, file });
|
|
406
|
+
void session.cleanChanges(); // R690:启动时清一次 30 天前的改动记录
|
|
339
407
|
let ws = null;
|
|
340
408
|
let stopped = false;
|
|
341
409
|
let beat = null;
|
|
@@ -490,10 +558,11 @@ const usage = (prog) => `用法:
|
|
|
490
558
|
${prog} start 连上服务器(终端窗口别关;Ctrl-C 断开)
|
|
491
559
|
${prog} stop 停掉在跑的运行器
|
|
492
560
|
${prog} status 看配置、各 bot 的目录、在不在跑、开机自启
|
|
561
|
+
${prog} set-dir <bot_id> <路径> 直接指定这个 bot 的授权目录(和网页上点「选择目录」走同一套检查;弹窗弹不出来时用)
|
|
493
562
|
${prog} autostart on|off|status Mac 开机自启(on 也用来升级到新版本)
|
|
494
563
|
${prog} uninstall [--purge] 关掉自启、删掉本地副本和日志(--purge 连配置一起删)
|
|
495
564
|
${prog} --version
|
|
496
|
-
目录请连上后在网页上每个 bot 的私聊里点输入框下面「我的电脑」那一行选,Mac 上会弹窗让你选。`;
|
|
565
|
+
目录请连上后在网页上每个 bot 的私聊里点输入框下面「我的电脑」那一行选,Mac / Windows 上会弹窗让你选。`;
|
|
497
566
|
|
|
498
567
|
// @意图#R666(2026-10-29): pair 默认配完直接 start(--no-start 只配对);配完按 decideAutostart 问 / 提示 / 开启开机自启,开了就不在前台跑;
|
|
499
568
|
// 新命令 stop、autostart on|off|status、uninstall、--version;status 多显示在不在跑、版本、开机自启;用法里的命令按运行方式写(npx 时写 npx 写法)
|
|
@@ -566,6 +635,26 @@ export async function main(argv, prog = progName(), deps = {}) {
|
|
|
566
635
|
console.log(`已卸载:开机自启关了、本地副本和日志删了。${purge ? `配置也删了(${file});记得在网页「我的电脑」里点「断开」作废令牌。` : `配置留着(${file});连配置一起删:${prog} uninstall --purge`}`);
|
|
567
636
|
return;
|
|
568
637
|
}
|
|
638
|
+
if (a.cmd === "set-dir") {
|
|
639
|
+
// @意图#R716(2026-11-02): 不靠弹窗也能设目录(无桌面会话 / 弹不出来时应急),走和网页选目录同一套检查
|
|
640
|
+
const file = configPath();
|
|
641
|
+
const bot = a.positional[0];
|
|
642
|
+
const dir = a.positional[1];
|
|
643
|
+
if (!bot || !dir) { console.log(`用法:${prog} set-dir <bot_id> <路径>\n bot_id 在网页这个 bot 的私聊里、输入框下面「我的电脑」那一行能看到`); process.exitCode = 1; return; }
|
|
644
|
+
let cfg;
|
|
645
|
+
try { cfg = loadConfig(file); } catch (e) { console.log(e?.message ?? String(e)); process.exitCode = 1; return; }
|
|
646
|
+
if (!cfg?.token) { console.log(`还没配对:${file} 里没有令牌。先在网页上配对:${prog} pair <配对码>@<服务器地址>`); process.exitCode = 1; return; }
|
|
647
|
+
let real;
|
|
648
|
+
try { real = await checkDir(dir); } catch (e) { console.log(e?.message ?? String(e)); process.exitCode = 1; return; }
|
|
649
|
+
cfg.bot_dirs = { ...botDirsOf(cfg), [bot]: real };
|
|
650
|
+
delete cfg.dir; // 老格式的单一 dir 不再写回
|
|
651
|
+
saveConfig(cfg, file);
|
|
652
|
+
const l = readLock(file);
|
|
653
|
+
const running = l && pidAlive(l.pid);
|
|
654
|
+
console.log(`已设:${bot} → ${real}`);
|
|
655
|
+
console.log(running ? `运行器正在跑(进程 ${l.pid}),重启后生效:${prog} stop && ${prog} start` : `配置文件:${file}`);
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
569
658
|
if (a.cmd === "status") {
|
|
570
659
|
const file = configPath();
|
|
571
660
|
const cfg = loadConfig(file);
|