@wendongfly/myhi 1.3.188 → 1.3.190
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/bin/daemon.js +196 -170
- package/bin/install-check.js +25 -0
- package/dist/admin.html +30 -7
- package/dist/chat.html +82 -33
- package/dist/index.html +37 -6
- package/dist/index.js +1 -1
- package/dist/index.min.js +10 -15
- package/dist/lib/machine.js +8 -1
- package/dist/terminal.html +1 -1
- package/package.json +1 -1
package/bin/daemon.js
CHANGED
|
@@ -1,170 +1,196 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* myhi 守护进程
|
|
4
|
-
* 负责 fork/管理 server 子进程,处理升级和重启
|
|
5
|
-
* daemon 本身不会被升级影响,保证服务连续性
|
|
6
|
-
*/
|
|
7
|
-
import { fork, execSync,
|
|
8
|
-
import { fileURLToPath } from 'url';
|
|
9
|
-
import { dirname, join } from 'path';
|
|
10
|
-
import { writeFileSync, readFileSync, mkdirSync, unlinkSync } from 'fs';
|
|
11
|
-
import { homedir } from 'os';
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
const
|
|
15
|
-
const
|
|
16
|
-
const
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
(
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
let
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
if (
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
if (
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
//
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* myhi 守护进程
|
|
4
|
+
* 负责 fork/管理 server 子进程,处理升级和重启
|
|
5
|
+
* daemon 本身不会被升级影响,保证服务连续性
|
|
6
|
+
*/
|
|
7
|
+
import { fork, execSync, exec } from 'child_process';
|
|
8
|
+
import { fileURLToPath } from 'url';
|
|
9
|
+
import { dirname, join } from 'path';
|
|
10
|
+
import { writeFileSync, readFileSync, mkdirSync, unlinkSync } from 'fs';
|
|
11
|
+
import { homedir } from 'os';
|
|
12
|
+
import { checkInstall, upgradeStatusFile } from './install-check.js';
|
|
13
|
+
|
|
14
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
15
|
+
const __dirname = dirname(__filename);
|
|
16
|
+
const configDir = join(homedir(), '.myhi');
|
|
17
|
+
const pidFile = join(configDir, 'daemon.pid');
|
|
18
|
+
const serverEntry = join(__dirname, '../dist/index.js');
|
|
19
|
+
const pkgRoot = join(__dirname, '..');
|
|
20
|
+
// 告诉 server 本 daemon 支持哪些能力(server 按它选升级方式;老 daemon 没这个变量 → server 走旧路)
|
|
21
|
+
const DAEMON_CAPS = 'graceful,upgrade-v2';
|
|
22
|
+
|
|
23
|
+
mkdirSync(configDir, { recursive: true });
|
|
24
|
+
|
|
25
|
+
// 单例守卫:如果已有活 daemon,先 SIGTERM 它再启动。否则双 daemon 会让各自的
|
|
26
|
+
// server child 因互读 server.pid 而互杀,触发"连续崩溃 5 次"熔断(pc41 1.3.51 升级踩到)
|
|
27
|
+
(function killOldDaemon() {
|
|
28
|
+
let oldPid;
|
|
29
|
+
try { oldPid = parseInt(readFileSync(pidFile, 'utf8').trim(), 10); } catch { return; }
|
|
30
|
+
if (!oldPid || oldPid === process.pid) return;
|
|
31
|
+
let alive = false;
|
|
32
|
+
try { process.kill(oldPid, 0); alive = true; } catch {}
|
|
33
|
+
if (!alive) return;
|
|
34
|
+
process.stderr.write(`[daemon] 检测到已运行的 daemon (PID ${oldPid}),先停止...\n`);
|
|
35
|
+
try {
|
|
36
|
+
if (process.platform === 'win32') execSync(`taskkill /PID ${oldPid} /F /T`, { stdio: 'pipe', timeout: 5000 });
|
|
37
|
+
else process.kill(oldPid, 'SIGTERM');
|
|
38
|
+
} catch {}
|
|
39
|
+
// 等最多 5 秒
|
|
40
|
+
const deadline = Date.now() + 5000;
|
|
41
|
+
while (Date.now() < deadline) {
|
|
42
|
+
try { process.kill(oldPid, 0); } catch { return; }
|
|
43
|
+
const end = Date.now() + 200;
|
|
44
|
+
while (Date.now() < end) {} // busy-wait, 此处无事件循环可用
|
|
45
|
+
}
|
|
46
|
+
let stillAlive = false;
|
|
47
|
+
try { process.kill(oldPid, 0); stillAlive = true; } catch {}
|
|
48
|
+
if (stillAlive) {
|
|
49
|
+
process.stderr.write(`[daemon] 无法停止旧 daemon (PID ${oldPid}),本进程退出避免冲突\n`);
|
|
50
|
+
process.exit(1);
|
|
51
|
+
}
|
|
52
|
+
})();
|
|
53
|
+
|
|
54
|
+
writeFileSync(pidFile, String(process.pid));
|
|
55
|
+
|
|
56
|
+
let child = null;
|
|
57
|
+
let restarting = false;
|
|
58
|
+
let stopping = false;
|
|
59
|
+
let crashCount = 0;
|
|
60
|
+
let childStartedAt = 0;
|
|
61
|
+
let upgrading = false;
|
|
62
|
+
|
|
63
|
+
function log(msg) {
|
|
64
|
+
const ts = new Date().toISOString().slice(11, 19);
|
|
65
|
+
process.stderr.write(`[daemon ${ts}] ${msg}\n`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function startServer() {
|
|
69
|
+
log('启动 server 子进程...');
|
|
70
|
+
childStartedAt = Date.now();
|
|
71
|
+
child = fork(serverEntry, process.argv.slice(2), {
|
|
72
|
+
stdio: ['ignore', 'inherit', 'inherit', 'ipc'],
|
|
73
|
+
env: { ...process.env, MYHI_DAEMON: '1', MYHI_DAEMON_PID: String(process.pid), MYHI_DAEMON_CAPS: DAEMON_CAPS },
|
|
74
|
+
windowsHide: true,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
child.on('message', (msg) => {
|
|
78
|
+
if (msg?.type === 'upgrade') doUpgrade(msg.target);
|
|
79
|
+
else if (msg?.type === 'restart') doRestart();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const thisChild = child;
|
|
83
|
+
child.on('exit', (code, signal) => {
|
|
84
|
+
if (child === thisChild) child = null; // 只清自己的引用
|
|
85
|
+
if (restarting || stopping || upgrading) return; // 主动重启/停止/升级中,不自动恢复
|
|
86
|
+
|
|
87
|
+
// 崩溃退避:起来跑满 60 秒算稳定,计数清零;否则 1s→2s→4s…封顶 60s 一直重试。
|
|
88
|
+
// 以前是「10 秒内崩 5 次 daemon 就退出」——升级装出半成品时整个服务彻底停掉,只能上机器手动救;
|
|
89
|
+
// 现在 daemon 永不放弃,修好文件(重装)后自己就能起来。
|
|
90
|
+
if (Date.now() - childStartedAt > 60000) crashCount = 0;
|
|
91
|
+
crashCount++;
|
|
92
|
+
const delay = Math.min(60000, 1000 * 2 ** (crashCount - 1));
|
|
93
|
+
log(`server 退出 (code=${code}, signal=${signal}),第 ${crashCount} 次,${Math.round(delay / 1000)} 秒后重启...`);
|
|
94
|
+
if (crashCount >= 3) {
|
|
95
|
+
const chk = checkInstall(pkgRoot);
|
|
96
|
+
if (!chk.ok) log(`⚠ ${chk.error}——请重新安装:npm i -g @wendongfly/myhi@latest(Windows 先把 myhi 目录加入杀毒软件信任区)`);
|
|
97
|
+
}
|
|
98
|
+
setTimeout(startServer, delay);
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// 让 server 自己收尾后退出(保存会话、停掉 claude 子进程)。Windows 上 kill('SIGTERM') 等于直接结束进程,
|
|
103
|
+
// server 的 SIGTERM 处理根本不执行 → 改走 IPC 让它自己退;4 秒没退再 SIGTERM,8 秒强杀。
|
|
104
|
+
function stopChild(cb) {
|
|
105
|
+
const c = child;
|
|
106
|
+
child = null; // 清引用,防止 exit handler 当成崩溃再拉起
|
|
107
|
+
if (!c) { cb?.(); return; }
|
|
108
|
+
let done = false;
|
|
109
|
+
c.once('exit', () => { if (!done) { done = true; cb?.(); } });
|
|
110
|
+
try { if (c.connected) c.send({ type: 'shutdown' }); else c.kill('SIGTERM'); } catch { try { c.kill('SIGTERM'); } catch {} }
|
|
111
|
+
setTimeout(() => { if (!done) { try { c.kill('SIGTERM'); } catch {} } }, 4000);
|
|
112
|
+
setTimeout(() => { if (!done) { try { c.kill('SIGKILL'); } catch {} } }, 8000);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function doRestart() {
|
|
116
|
+
if (upgrading) { log('升级进行中,忽略重启请求'); return; }
|
|
117
|
+
log('收到重启请求');
|
|
118
|
+
restarting = true;
|
|
119
|
+
crashCount = 0;
|
|
120
|
+
stopChild(() => { restarting = false; setTimeout(startServer, 500); });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// 停服升级:先让 server 退出(释放 node-pty 的 pty.node,Windows 上它被占用时 npm 覆盖会 EBUSY、
|
|
124
|
+
// 装出半成品),再 npm install,装完校验文件齐全 + 版本对,最后拉起 server。
|
|
125
|
+
// 不论成败都拉起 server(失败时 npm 一般已回滚,旧版照常跑);结果写 upgrade-status.json 给网页看。
|
|
126
|
+
function doUpgrade(target) {
|
|
127
|
+
if (upgrading) { log('升级已在进行中,忽略'); return; }
|
|
128
|
+
upgrading = true;
|
|
129
|
+
const from = (() => { try { return JSON.parse(readFileSync(join(pkgRoot, 'package.json'), 'utf8')).version; } catch { return null; } })();
|
|
130
|
+
log(`收到升级请求(${from} → ${target || 'latest'}),先停 server 释放文件占用...`);
|
|
131
|
+
stopChild(() => {
|
|
132
|
+
const cmd = (process.platform === 'win32' ? 'npm.cmd' : 'npm') +
|
|
133
|
+
' install -g @wendongfly/myhi@latest --registry https://registry.npmjs.org';
|
|
134
|
+
log('执行: ' + cmd);
|
|
135
|
+
exec(cmd, { timeout: 600000, encoding: 'utf8', windowsHide: true }, (err, stdout, stderr) => {
|
|
136
|
+
let result;
|
|
137
|
+
if (err) {
|
|
138
|
+
const tail = String(stderr || stdout || err.message).split('\n').filter((l) => l.trim()).slice(-5).join('\n');
|
|
139
|
+
result = { ok: false, from, to: target || null, error: 'npm install 失败:\n' + tail };
|
|
140
|
+
} else {
|
|
141
|
+
const chk = checkInstall(pkgRoot, target);
|
|
142
|
+
result = chk.ok ? { ok: true, from, to: chk.version } : { ok: false, from, to: target || null, error: chk.error };
|
|
143
|
+
}
|
|
144
|
+
result.at = Date.now();
|
|
145
|
+
log(result.ok ? `升级成功:${from} → ${result.to}` : `升级失败:${result.error}`);
|
|
146
|
+
try { writeFileSync(upgradeStatusFile(configDir), JSON.stringify(result, null, 2)); } catch {}
|
|
147
|
+
upgrading = false;
|
|
148
|
+
crashCount = 0;
|
|
149
|
+
startServer();
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function cleanup() {
|
|
155
|
+
try {
|
|
156
|
+
const savedPid = parseInt(readFileSync(pidFile, 'utf8').trim(), 10);
|
|
157
|
+
if (savedPid === process.pid) unlinkSync(pidFile);
|
|
158
|
+
} catch {}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// 信号处理
|
|
162
|
+
function shutdown(sig) {
|
|
163
|
+
log(`收到 ${sig},停止...`);
|
|
164
|
+
stopping = true;
|
|
165
|
+
cleanup();
|
|
166
|
+
stopChild(() => process.exit(0));
|
|
167
|
+
setTimeout(() => process.exit(0), 9000);
|
|
168
|
+
}
|
|
169
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
170
|
+
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
171
|
+
|
|
172
|
+
// Windows: 通过 stdin 消息(无 SIGUSR2 支持)
|
|
173
|
+
if (process.platform === 'win32') {
|
|
174
|
+
process.on('message', (msg) => {
|
|
175
|
+
if (msg === 'upgrade') doUpgrade();
|
|
176
|
+
else if (msg === 'restart') doRestart();
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
log(`daemon 启动 (PID ${process.pid})`);
|
|
181
|
+
// 前台运行的 myhi 网页升级/重启时会拉起本 daemon 接班(MYHI_WAIT_PID=旧进程):
|
|
182
|
+
// 等旧进程退出、端口释放后再起 server,否则新 server 撞端口会改用下一个端口
|
|
183
|
+
const waitPid = parseInt(process.env.MYHI_WAIT_PID || '0', 10);
|
|
184
|
+
if (waitPid) {
|
|
185
|
+
delete process.env.MYHI_WAIT_PID;
|
|
186
|
+
const t0 = Date.now();
|
|
187
|
+
const tick = () => {
|
|
188
|
+
let alive = false;
|
|
189
|
+
try { process.kill(waitPid, 0); alive = true; } catch {}
|
|
190
|
+
if (alive && Date.now() - t0 < 20000) return setTimeout(tick, 300);
|
|
191
|
+
startServer();
|
|
192
|
+
};
|
|
193
|
+
tick();
|
|
194
|
+
} else {
|
|
195
|
+
startServer();
|
|
196
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// 升级后校验安装是否完整。npm 被中断、Windows 上文件被锁(EBUSY)、杀毒软件(360 等)拦截,
|
|
2
|
+
// 都会留下缺文件的半成品——带着它重启,新进程起不来,服务就挂了(测试机缺 dist/login.html 即此类)。
|
|
3
|
+
// server(网页升级)和 daemon(Windows 停服升级)共用。
|
|
4
|
+
import { existsSync, readFileSync } from 'fs';
|
|
5
|
+
import { join } from 'path';
|
|
6
|
+
|
|
7
|
+
export const REQUIRED_FILES = [
|
|
8
|
+
'package.json', 'bin/myhi.js', 'bin/daemon.js',
|
|
9
|
+
'dist/index.js', 'dist/lib',
|
|
10
|
+
'dist/login.html', 'dist/index.html', 'dist/chat.html', 'dist/admin.html', 'dist/grid.html', 'dist/terminal.html',
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
// pkgRoot:@wendongfly/myhi 安装目录;expectVersion:期望装上的版本(不知道就不比)
|
|
14
|
+
export function checkInstall(pkgRoot, expectVersion) {
|
|
15
|
+
const missing = REQUIRED_FILES.filter((f) => !existsSync(join(pkgRoot, f)));
|
|
16
|
+
let version = null;
|
|
17
|
+
try { version = JSON.parse(readFileSync(join(pkgRoot, 'package.json'), 'utf8')).version; } catch {}
|
|
18
|
+
let error = null;
|
|
19
|
+
if (missing.length) error = `安装不完整,缺少 ${missing.join('、')}(可能被杀毒软件拦截或安装被中断)`;
|
|
20
|
+
else if (expectVersion && version !== expectVersion) error = `安装后版本是 ${version || '未知'},不是期望的 ${expectVersion}(npm 可能装到了别的目录或镜像未同步)`;
|
|
21
|
+
return { ok: !error, version, missing, error };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// 上次升级结果,落 ~/.myhi/upgrade-status.json:重启后的新进程(或 daemon 停服升级时)靠它把成败告诉网页
|
|
25
|
+
export function upgradeStatusFile(configDir) { return join(configDir, 'upgrade-status.json'); }
|
package/dist/admin.html
CHANGED
|
@@ -442,15 +442,38 @@ async function changePwd() {
|
|
|
442
442
|
} catch { msg.className = 'msg msg-err'; msg.textContent = '连接失败'; }
|
|
443
443
|
}
|
|
444
444
|
|
|
445
|
-
|
|
445
|
+
// 等升级落地:轮询 /api/version(重启/停服期间连不上是正常的)。
|
|
446
|
+
// 成功 = 版本变了(且等于目标);失败 = 服务端报了本次升级的失败原因;10 分钟兜底超时。
|
|
447
|
+
async function waitUpgrade(from, target, onTick) {
|
|
448
|
+
const t0 = Date.now();
|
|
449
|
+
while (Date.now() - t0 < 10 * 60 * 1000) {
|
|
450
|
+
await new Promise(r => setTimeout(r, 3000));
|
|
451
|
+
let v = null;
|
|
452
|
+
try { const r = await fetch('/api/version', { cache: 'no-store' }); if (r.ok) v = await r.json(); } catch {}
|
|
453
|
+
onTick?.(!!v, Math.round((Date.now() - t0) / 1000));
|
|
454
|
+
if (!v) continue;
|
|
455
|
+
if (v.current !== from && (!target || v.current === target)) return { ok: true, version: v.current };
|
|
456
|
+
const last = v.upgrade?.last;
|
|
457
|
+
if (!v.upgrade?.running && last && !last.ok && last.at >= t0 - 5000) return { ok: false, error: last.error };
|
|
458
|
+
}
|
|
459
|
+
return { ok: false, error: '等了 10 分钟仍未完成,请稍后刷新页面查看版本,或到服务器上查看 myhi log' };
|
|
460
|
+
}
|
|
461
|
+
async function doUpgrade(force) {
|
|
446
462
|
const btn = document.getElementById('btn-up'), msg = document.getElementById('op-msg');
|
|
447
|
-
|
|
463
|
+
const fail = (t) => { msg.textContent = '失败: ' + t; btn.disabled = false; btn.textContent = '重试'; };
|
|
464
|
+
btn.disabled = true; btn.textContent = '升级中...'; msg.textContent = '正在提交升级...'; msg.style.display = '';
|
|
448
465
|
try {
|
|
449
|
-
const r = await fetch('/api/admin/upgrade', { method: 'POST', headers: H() });
|
|
450
|
-
const d = await r.json();
|
|
451
|
-
if (d.ok) {
|
|
452
|
-
|
|
453
|
-
|
|
466
|
+
const r = await fetch('/api/admin/upgrade', { method: 'POST', headers: { ...H(), 'Content-Type': 'application/json' }, body: JSON.stringify({ force: !!force }) });
|
|
467
|
+
const d = await r.json().catch(() => ({}));
|
|
468
|
+
if (!d.ok) {
|
|
469
|
+
if (d.busy?.length && confirm(d.error + '\n\n仍然现在升级吗?')) return doUpgrade(true);
|
|
470
|
+
return fail(d.error || ('HTTP ' + r.status));
|
|
471
|
+
}
|
|
472
|
+
msg.textContent = d.message || '升级中...';
|
|
473
|
+
const res = await waitUpgrade(d.from, d.target, (up, sec) => { msg.textContent = (up ? '正在安装新版本… ' : '服务重启中… ') + sec + 's'; });
|
|
474
|
+
if (res.ok) { msg.textContent = '已升级到 v' + res.version + ',即将刷新'; setTimeout(() => location.reload(), 800); }
|
|
475
|
+
else fail(res.error);
|
|
476
|
+
} catch { fail('连接失败'); }
|
|
454
477
|
}
|
|
455
478
|
|
|
456
479
|
async function doClaudeUpgrade() {
|