agenthalo 1.0.4 → 1.0.5
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 +2 -2
- package/bin/agenthalo.js +165 -47
- package/bin/checksums.json +2 -2
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -11,9 +11,9 @@ npx agenthalo
|
|
|
11
11
|
## 它做了什么
|
|
12
12
|
|
|
13
13
|
1. 识别芯片(Apple Silicon / Intel),选对应的包。
|
|
14
|
-
2. 从对应版本的 GitHub release 下载 zip
|
|
14
|
+
2. 从对应版本的 GitHub release 下载 zip;30 秒收不到数据就放弃。
|
|
15
15
|
3. 核对 SHA-256,和本包内固定的校验和比对。
|
|
16
|
-
4. 用 `ditto`
|
|
16
|
+
4. 用 `ditto` 解压,先复制到 `/Applications` 里的临时位置,退出旧版后原子替换并启动。旧版退不掉时不做替换,已安装的版本保持原样。
|
|
17
17
|
|
|
18
18
|
安装到别处:
|
|
19
19
|
|
package/bin/agenthalo.js
CHANGED
|
@@ -29,13 +29,20 @@ const BASE = process.env.AGENTHALO_DOWNLOAD_BASE
|
|
|
29
29
|
// 每次发版由 scripts/sync-checksums.js 从 dist/ 重新写入。
|
|
30
30
|
const CHECKSUMS = require("./checksums.json");
|
|
31
31
|
|
|
32
|
+
// 多久收不到任何数据就放弃下载。按停顿计时而不是按总时长:慢网络下 120MB
|
|
33
|
+
// 可以下很久,但一个不再来数据的连接只会让人一直干等。
|
|
34
|
+
const DOWNLOAD_STALL_TIMEOUT_MS = 30000;
|
|
35
|
+
|
|
36
|
+
// 安装过程中的可预期失败。抛出而不是直接 process.exit,这样 finally 里的清理
|
|
37
|
+
// (临时目录、目标旁边的半成品)在每条失败路径上都会执行。
|
|
38
|
+
class InstallError extends Error {}
|
|
39
|
+
|
|
32
40
|
function log(msg) {
|
|
33
41
|
process.stdout.write(`${msg}\n`);
|
|
34
42
|
}
|
|
35
43
|
|
|
36
44
|
function fail(msg) {
|
|
37
|
-
|
|
38
|
-
process.exit(1);
|
|
45
|
+
throw new InstallError(msg);
|
|
39
46
|
}
|
|
40
47
|
|
|
41
48
|
// process.arch 在 Rosetta 下的 Node 里会报 x64,哪怕机器是 Apple Silicon。
|
|
@@ -56,62 +63,138 @@ function formatMB(bytes) {
|
|
|
56
63
|
return (bytes / 1024 / 1024).toFixed(1);
|
|
57
64
|
}
|
|
58
65
|
|
|
59
|
-
async function download(url, dest) {
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
fail(`下载返回 HTTP ${res.status}。\n ${url}\n 如果是 404,说明这个版本的 release 还没发布。`);
|
|
63
|
-
}
|
|
64
|
-
const total = Number(res.headers.get("content-length")) || 0;
|
|
65
|
-
let seen = 0;
|
|
66
|
-
let lastShown = 0;
|
|
67
|
-
|
|
68
|
-
const source = Readable.fromWeb(res.body);
|
|
66
|
+
async function download(url, dest, options = {}) {
|
|
67
|
+
const fetchImpl = options.fetch || fetch;
|
|
68
|
+
const stallTimeoutMs = options.stallTimeoutMs || DOWNLOAD_STALL_TIMEOUT_MS;
|
|
69
69
|
// \r 只在终端里能原地覆盖。被重定向或管道接走时每一行都会留下,
|
|
70
70
|
// 120MB 就是几百行噪声,所以非终端环境只在结束时报一次。
|
|
71
|
-
const interactive = process.stdout.isTTY;
|
|
72
|
-
|
|
71
|
+
const interactive = options.interactive !== undefined ? options.interactive : process.stdout.isTTY;
|
|
72
|
+
const write = options.write || ((text) => process.stdout.write(text));
|
|
73
|
+
|
|
74
|
+
const controller = new AbortController();
|
|
75
|
+
let stallTimer = null;
|
|
76
|
+
const armStallTimer = () => {
|
|
77
|
+
clearTimeout(stallTimer);
|
|
78
|
+
stallTimer = setTimeout(() => controller.abort(), stallTimeoutMs);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
armStallTimer();
|
|
82
|
+
try {
|
|
83
|
+
const res = await fetchImpl(url, { redirect: "follow", signal: controller.signal });
|
|
84
|
+
if (!res.ok) {
|
|
85
|
+
fail(`下载返回 HTTP ${res.status}。\n ${url}\n 如果是 404,说明这个版本的 release 还没发布。`);
|
|
86
|
+
}
|
|
87
|
+
const total = Number(res.headers.get("content-length")) || 0;
|
|
88
|
+
let seen = 0;
|
|
89
|
+
let lastShown = 0;
|
|
90
|
+
|
|
91
|
+
const source = Readable.fromWeb(res.body);
|
|
73
92
|
source.on("data", (chunk) => {
|
|
93
|
+
armStallTimer();
|
|
74
94
|
seen += chunk.length;
|
|
95
|
+
if (!interactive) return;
|
|
75
96
|
const now = Date.now();
|
|
76
97
|
if (now - lastShown < 200 && seen !== total) return;
|
|
77
98
|
lastShown = now;
|
|
78
99
|
const pct = total ? ` ${Math.floor((seen / total) * 100)}%` : "";
|
|
79
|
-
|
|
100
|
+
write(`\r 已下载 ${formatMB(seen)}MB${total ? ` / ${formatMB(total)}MB` : ""}${pct} `);
|
|
80
101
|
});
|
|
81
|
-
}
|
|
82
102
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
103
|
+
await pipeline(source, fs.createWriteStream(dest));
|
|
104
|
+
if (interactive) write("\n");
|
|
105
|
+
else write(` 完成 ${formatMB(fs.statSync(dest).size)}MB\n`);
|
|
106
|
+
} catch (err) {
|
|
107
|
+
if (controller.signal.aborted) {
|
|
108
|
+
fail(`下载 ${Math.round(stallTimeoutMs / 1000)} 秒没有进展,已放弃。请检查网络后重试。\n ${url}`);
|
|
109
|
+
}
|
|
110
|
+
throw err;
|
|
111
|
+
} finally {
|
|
112
|
+
clearTimeout(stallTimer);
|
|
113
|
+
}
|
|
86
114
|
}
|
|
87
115
|
|
|
88
|
-
|
|
116
|
+
// 边读边算,不把 100 多 MB 的压缩包整个读进内存。
|
|
117
|
+
async function sha256File(file) {
|
|
89
118
|
const hash = crypto.createHash("sha256");
|
|
90
|
-
|
|
119
|
+
for await (const chunk of fs.createReadStream(file)) hash.update(chunk);
|
|
91
120
|
return hash.digest("hex");
|
|
92
121
|
}
|
|
93
122
|
|
|
94
|
-
function
|
|
123
|
+
function isAppRunning(appPath) {
|
|
95
124
|
const running = spawnSync("/usr/bin/pgrep", ["-f", `${appPath}/Contents/MacOS/`], {
|
|
96
125
|
stdio: ["ignore", "pipe", "ignore"],
|
|
97
126
|
});
|
|
98
|
-
|
|
127
|
+
return running.status === 0;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function quitRunningApp(appPath) {
|
|
131
|
+
if (!isAppRunning(appPath)) return;
|
|
99
132
|
log(" 检测到正在运行,先退出旧版本");
|
|
100
133
|
spawnSync("/usr/bin/osascript", ["-e", 'quit app "AgentHalo"'], { stdio: "ignore" });
|
|
101
134
|
for (let i = 0; i < 20; i += 1) {
|
|
102
|
-
|
|
103
|
-
stdio: ["ignore", "pipe", "ignore"],
|
|
104
|
-
});
|
|
105
|
-
if (still.status !== 0) return;
|
|
135
|
+
if (!isAppRunning(appPath)) return;
|
|
106
136
|
spawnSync("/bin/sleep", ["0.5"], { stdio: "ignore" });
|
|
107
137
|
}
|
|
108
138
|
}
|
|
109
139
|
|
|
110
|
-
|
|
140
|
+
// 目标旁边的隐藏临时名:和目标在同一个目录(同一个卷),rename 才是原子的。
|
|
141
|
+
function siblingPath(target, label) {
|
|
142
|
+
const suffix = `${process.pid}-${crypto.randomBytes(4).toString("hex")}`;
|
|
143
|
+
return path.join(path.dirname(target), `.${path.basename(target)}.${label}-${suffix}`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// 用两次同目录 rename 把 incoming 换到 target:旧版先让位,新版就位后才删旧版。
|
|
147
|
+
// 第二次 rename 失败就把旧版挪回去,所以失败时 target 要么是完整的旧版,要么是
|
|
148
|
+
// 完整的新版,不会是复制到一半的目录。唯一的空窗是两次 rename 之间那一瞬间;
|
|
149
|
+
// 它们是紧挨着的同步调用,Ctrl-C 的处理也插不进去。
|
|
150
|
+
function swapIntoPlace(incoming, target, fsApi = fs) {
|
|
151
|
+
const previous = fsApi.existsSync(target) ? siblingPath(target, "previous") : null;
|
|
152
|
+
if (previous) fsApi.renameSync(target, previous);
|
|
153
|
+
try {
|
|
154
|
+
fsApi.renameSync(incoming, target);
|
|
155
|
+
} catch (err) {
|
|
156
|
+
if (previous) fsApi.renameSync(previous, target);
|
|
157
|
+
throw err;
|
|
158
|
+
}
|
|
159
|
+
if (previous) {
|
|
160
|
+
try {
|
|
161
|
+
fsApi.rmSync(previous, { recursive: true, force: true });
|
|
162
|
+
} catch {
|
|
163
|
+
// 新版已经就位;留下的隐藏旧目录不影响使用,下次安装也不会碰到它。
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// 先把新版完整复制到目标旁边,再退出旧版并确认它真的退出了,最后原子替换。
|
|
169
|
+
// 复制失败、旧版退不掉,都不会动已安装的那一份。
|
|
170
|
+
function installApp(staged, target, deps = {}) {
|
|
171
|
+
const copy = deps.copy || ((from, to) => execFileSync("/usr/bin/ditto", [from, to], { stdio: "inherit" }));
|
|
172
|
+
const quit = deps.quit || quitRunningApp;
|
|
173
|
+
const isRunning = deps.isRunning || isAppRunning;
|
|
174
|
+
const pending = deps.pending || new Set();
|
|
175
|
+
|
|
176
|
+
const incoming = siblingPath(target, "incoming");
|
|
177
|
+
pending.add(incoming);
|
|
178
|
+
try {
|
|
179
|
+
copy(staged, incoming);
|
|
180
|
+
if (fs.existsSync(target)) {
|
|
181
|
+
quit(target);
|
|
182
|
+
if (isRunning(target)) {
|
|
183
|
+
fail("AgentHalo 还在运行,没有替换已安装的版本。请从菜单栏图标退出 AgentHalo 后重新运行 npx agenthalo。");
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
swapIntoPlace(incoming, target);
|
|
187
|
+
} finally {
|
|
188
|
+
fs.rmSync(incoming, { recursive: true, force: true });
|
|
189
|
+
pending.delete(incoming);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function resolveInstallDir(argv = process.argv) {
|
|
111
194
|
// --dir 给受管机器和想装到别处的人留的出口,也让安装流程可以在临时目录里验证。
|
|
112
|
-
const flag =
|
|
195
|
+
const flag = argv.indexOf("--dir");
|
|
113
196
|
if (flag !== -1) {
|
|
114
|
-
const dir =
|
|
197
|
+
const dir = argv[flag + 1];
|
|
115
198
|
if (!dir) fail("--dir 后面要跟一个目录路径。");
|
|
116
199
|
fs.mkdirSync(dir, { recursive: true });
|
|
117
200
|
return path.resolve(dir);
|
|
@@ -127,30 +210,51 @@ function resolveInstallDir() {
|
|
|
127
210
|
}
|
|
128
211
|
}
|
|
129
212
|
|
|
130
|
-
|
|
131
|
-
|
|
213
|
+
// Ctrl-C / 终端关闭时 finally 不会执行:先删掉临时文件,再按原信号退出。
|
|
214
|
+
function removeOnSignal(pending) {
|
|
215
|
+
const signals = ["SIGINT", "SIGTERM", "SIGHUP"];
|
|
216
|
+
const handler = (signal) => {
|
|
217
|
+
for (const target of pending) {
|
|
218
|
+
try { fs.rmSync(target, { recursive: true, force: true }); } catch {}
|
|
219
|
+
}
|
|
220
|
+
for (const other of signals) process.removeListener(other, handler);
|
|
221
|
+
process.kill(process.pid, signal);
|
|
222
|
+
};
|
|
223
|
+
for (const signal of signals) process.on(signal, handler);
|
|
224
|
+
return () => {
|
|
225
|
+
for (const signal of signals) process.removeListener(signal, handler);
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function main(options = {}) {
|
|
230
|
+
const platform = options.platform || process.platform;
|
|
231
|
+
const argv = options.argv || process.argv;
|
|
232
|
+
if (platform !== "darwin") {
|
|
132
233
|
fail(
|
|
133
|
-
`当前只提供 macOS 版本(检测到 ${
|
|
234
|
+
`当前只提供 macOS 版本(检测到 ${platform})。\n`
|
|
134
235
|
+ ` 其它平台请到 https://github.com/${REPO}/releases 查看。`
|
|
135
236
|
);
|
|
136
237
|
}
|
|
137
238
|
|
|
138
|
-
const arch = detectArch();
|
|
239
|
+
const arch = options.arch || detectArch();
|
|
139
240
|
const asset = `AgentHalo-${VERSION}-${arch}.zip`;
|
|
140
|
-
const
|
|
241
|
+
const checksums = options.checksums || CHECKSUMS;
|
|
242
|
+
const expected = checksums[asset];
|
|
141
243
|
if (!expected) fail(`这个包里没有 ${asset} 的校验和,安装脚本和版本对不上。`);
|
|
142
244
|
|
|
143
245
|
log(`AgentHalo ${VERSION}(${arch === "arm64" ? "Apple Silicon" : "Intel"})`);
|
|
144
246
|
|
|
145
|
-
const work = fs.mkdtempSync(path.join(os.tmpdir(), "agenthalo-"));
|
|
247
|
+
const work = fs.mkdtempSync(path.join(options.tmpDir || os.tmpdir(), "agenthalo-"));
|
|
248
|
+
const pending = new Set([work]);
|
|
249
|
+
const stopWatchingSignals = removeOnSignal(pending);
|
|
146
250
|
const zipPath = path.join(work, asset);
|
|
147
251
|
|
|
148
252
|
try {
|
|
149
253
|
log("下载中");
|
|
150
|
-
await download(`${BASE}/${asset}`, zipPath);
|
|
254
|
+
await download(`${options.base || BASE}/${asset}`, zipPath, options.download);
|
|
151
255
|
|
|
152
256
|
log("校验");
|
|
153
|
-
const actual =
|
|
257
|
+
const actual = await sha256File(zipPath);
|
|
154
258
|
if (actual !== expected) {
|
|
155
259
|
fail(`校验和不匹配,文件可能没下完或已被改动。\n 期望 ${expected}\n 实际 ${actual}`);
|
|
156
260
|
}
|
|
@@ -158,26 +262,40 @@ async function main() {
|
|
|
158
262
|
log("解压");
|
|
159
263
|
// 用 ditto 而不是 unzip:.app 里的 Frameworks 有符号链接和特殊权限,
|
|
160
264
|
// unzip 会弄坏它们,ditto 是 macOS 原生、electron-builder 的 zip 就是按它打的。
|
|
161
|
-
|
|
265
|
+
const extract = options.extract
|
|
266
|
+
|| ((zip, into) => execFileSync("/usr/bin/ditto", ["-x", "-k", zip, into], { stdio: "inherit" }));
|
|
267
|
+
extract(zipPath, work);
|
|
162
268
|
const staged = path.join(work, APP_NAME);
|
|
163
269
|
if (!fs.existsSync(staged)) fail("压缩包里没有找到 AgentHalo.app。");
|
|
164
270
|
|
|
165
|
-
const installDir = resolveInstallDir();
|
|
271
|
+
const installDir = resolveInstallDir(argv);
|
|
166
272
|
const target = path.join(installDir, APP_NAME);
|
|
167
273
|
log(`安装到 ${target}`);
|
|
168
|
-
|
|
169
|
-
quitRunningApp(target);
|
|
170
|
-
fs.rmSync(target, { recursive: true, force: true });
|
|
171
|
-
}
|
|
172
|
-
execFileSync("/usr/bin/ditto", [staged, target], { stdio: "inherit" });
|
|
274
|
+
installApp(staged, target, { ...options.install, pending });
|
|
173
275
|
|
|
174
|
-
spawnSync("/usr/bin/open", ["-a", target], { stdio: "ignore" });
|
|
276
|
+
if (!options.skipLaunch) spawnSync("/usr/bin/open", ["-a", target], { stdio: "ignore" });
|
|
175
277
|
log("");
|
|
176
278
|
log("装好了,点击 Dock 图标可打开设置,菜单栏也保留入口。");
|
|
177
279
|
log("下一步:设置 → 连接应用,给你在用的 AI 工具装上 hook。");
|
|
280
|
+
return target;
|
|
178
281
|
} finally {
|
|
179
282
|
fs.rmSync(work, { recursive: true, force: true });
|
|
283
|
+
stopWatchingSignals();
|
|
180
284
|
}
|
|
181
285
|
}
|
|
182
286
|
|
|
183
|
-
|
|
287
|
+
if (require.main === module) {
|
|
288
|
+
main().catch((err) => {
|
|
289
|
+
process.stderr.write(`\nAgentHalo 安装失败:${err && err.message ? err.message : String(err)}\n`);
|
|
290
|
+
process.exitCode = 1;
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
module.exports = {
|
|
295
|
+
InstallError,
|
|
296
|
+
download,
|
|
297
|
+
installApp,
|
|
298
|
+
main,
|
|
299
|
+
sha256File,
|
|
300
|
+
swapIntoPlace,
|
|
301
|
+
};
|
package/bin/checksums.json
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
{
|
|
2
|
-
"AgentHalo-1.0.
|
|
3
|
-
"AgentHalo-1.0.
|
|
2
|
+
"AgentHalo-1.0.5-arm64.zip": "47d2337f3c74d69bcd9dc7fb609df7d6f77922389c221038e9db91d3ab68c6e5",
|
|
3
|
+
"AgentHalo-1.0.5-x64.zip": "b19eb910d3d7db7ce31c18860065d5811341acb1452b4c58ea6a6c5d0ce326b5"
|
|
4
4
|
}
|
package/package.json
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agenthalo",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.5",
|
|
4
4
|
"description": "一条命令装好 AgentHalo:跟着 AI 任务状态动的桌面伙伴。Install AgentHalo, a desktop companion that follows your AI agents' state.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"agenthalo": "bin/agenthalo.js"
|
|
7
7
|
},
|
|
8
|
+
"scripts": {
|
|
9
|
+
"test": "node --test"
|
|
10
|
+
},
|
|
8
11
|
"files": [
|
|
9
12
|
"bin",
|
|
10
13
|
"README.md"
|