@xcanwin/manyoyo 7.1.5 → 8.0.1
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 +24 -14
- package/bin/manyoyo.js +490 -83
- package/docker/manyoyo.Dockerfile +10 -4
- package/lib/app-launcher.js +220 -0
- package/lib/app-update.js +325 -0
- package/lib/container-run.js +3 -0
- package/lib/container-runtime.js +121 -0
- package/lib/doctor.js +77 -14
- package/lib/download-verified.js +48 -0
- package/lib/error-hints.js +96 -0
- package/lib/global-config.js +4 -4
- package/lib/headless.js +53 -0
- package/lib/image-build.js +11 -9
- package/lib/image-prune.js +44 -0
- package/lib/image-pull.js +76 -0
- package/lib/image-version-policy.js +26 -0
- package/lib/init-config.js +79 -19
- package/lib/login-token.js +92 -0
- package/lib/mirrors.js +71 -0
- package/lib/offline-import.js +60 -0
- package/lib/plugin/playwright.js +15 -3
- package/lib/podman-passthrough.js +80 -0
- package/lib/proxy-config.js +138 -0
- package/lib/runtime-heal.js +175 -0
- package/lib/runtime-normalizers.js +50 -0
- package/lib/secure-file.js +38 -0
- package/lib/setup-cli.js +215 -0
- package/lib/setup-config.js +32 -0
- package/lib/setup.js +188 -0
- package/lib/uninstall.js +296 -0
- package/lib/update-check.js +91 -0
- package/lib/web/frontend/shadcn.html +86 -86
- package/lib/web/server.js +641 -14
- package/lib/worktrees.js +15 -0
- package/manyoyo.example.json +13 -2
- package/package.json +9 -9
|
@@ -3,12 +3,14 @@
|
|
|
3
3
|
# ==============================================================================
|
|
4
4
|
# 镜像源参数化(默认使用阿里云,可按需覆盖),两个阶段共享同一默认值
|
|
5
5
|
ARG APT_MIRROR=https://mirrors.aliyun.com
|
|
6
|
+
ARG NODEJS_MIRROR=https://mirrors.tencent.com/nodejs-release/
|
|
6
7
|
|
|
7
8
|
FROM ubuntu:24.04 AS cache-stage
|
|
8
9
|
|
|
9
10
|
ARG TARGETARCH
|
|
10
11
|
ARG TOOL="common"
|
|
11
12
|
ARG APT_MIRROR
|
|
13
|
+
ARG NODEJS_MIRROR
|
|
12
14
|
|
|
13
15
|
# 复制缓存目录(可能为空)
|
|
14
16
|
COPY ./docker/cache/ /cache/
|
|
@@ -26,7 +28,7 @@ RUN <<EOX
|
|
|
26
28
|
ensure_curl() {
|
|
27
29
|
if command -v curl > /dev/null 2>&1; then return 0; fi
|
|
28
30
|
echo "安装 curl(缓存缺失,需联网下载)"
|
|
29
|
-
sed -i "s|http://[^/]*\.ubuntu\.com|${APT_MIRROR}|g" /etc/apt/sources.list.d/ubuntu.sources
|
|
31
|
+
if [ -n "${APT_MIRROR}" ]; then sed -i "s|http://[^/]*\.ubuntu\.com|${APT_MIRROR}|g" /etc/apt/sources.list.d/ubuntu.sources; fi
|
|
30
32
|
apt-get -o Acquire::https::Verify-Peer=false update -y
|
|
31
33
|
apt-get -o Acquire::https::Verify-Peer=false install -y --no-install-recommends curl ca-certificates
|
|
32
34
|
}
|
|
@@ -41,7 +43,7 @@ RUN <<EOX
|
|
|
41
43
|
else
|
|
42
44
|
echo "下载 Node.js"
|
|
43
45
|
ensure_curl
|
|
44
|
-
NVM_NODEJS_ORG_MIRROR
|
|
46
|
+
NVM_NODEJS_ORG_MIRROR=${NODEJS_MIRROR%/}
|
|
45
47
|
NODE_TAR=$(curl -sL ${NVM_NODEJS_ORG_MIRROR}/latest-v24.x/SHASUMS256.txt | grep linux-${ARCH_NODE}.tar.gz | awk '{print $2}')
|
|
46
48
|
curl -fsSL ${NVM_NODEJS_ORG_MIRROR}/latest-v24.x/${NODE_TAR} | tar -xz -C /opt/node --strip-components=1 --exclude='*.md' --exclude='LICENSE'
|
|
47
49
|
fi
|
|
@@ -99,7 +101,7 @@ ENV LANG=C.UTF-8 \
|
|
|
99
101
|
RUN <<EOX
|
|
100
102
|
# 配置 APT 镜像源
|
|
101
103
|
set -eu
|
|
102
|
-
sed -i "s|http://[^/]*\.ubuntu\.com|${APT_MIRROR}|g" /etc/apt/sources.list.d/ubuntu.sources
|
|
104
|
+
if [ -n "${APT_MIRROR}" ]; then sed -i "s|http://[^/]*\.ubuntu\.com|${APT_MIRROR}|g" /etc/apt/sources.list.d/ubuntu.sources; fi
|
|
103
105
|
ln -fs /usr/share/zoneinfo/Asia/Shanghai /etc/localtime
|
|
104
106
|
|
|
105
107
|
# 安装所有基础依赖
|
|
@@ -119,6 +121,10 @@ RUN <<EOX
|
|
|
119
121
|
# 更新 CA 证书
|
|
120
122
|
update-ca-certificates
|
|
121
123
|
|
|
124
|
+
# ssh 元包会带上 openssh-server 并在安装时生成主机私钥;公开镜像里不能带每个人都一样的固定私钥,
|
|
125
|
+
# 删掉后用到 sshd 的人在容器里 ssh-keygen -A 自己生成
|
|
126
|
+
rm -f /etc/ssh/ssh_host_*_key /etc/ssh/ssh_host_*_key.pub
|
|
127
|
+
|
|
122
128
|
# 安装 podman(条件)
|
|
123
129
|
case ",$TOOL," in *,full,*|*,podman,*)
|
|
124
130
|
apt-get install -y --no-install-recommends podman
|
|
@@ -283,7 +289,7 @@ RUN <<EOX
|
|
|
283
289
|
ln -sf /usr/local/share/manyoyo-gopls/gopls /usr/local/bin/gopls
|
|
284
290
|
else
|
|
285
291
|
# 下载编译
|
|
286
|
-
go install golang.org/x/tools/gopls@latest
|
|
292
|
+
go install -trimpath golang.org/x/tools/gopls@latest
|
|
287
293
|
ln -sf ~/go/bin/gopls /usr/local/bin/gopls
|
|
288
294
|
rm -rf /usr/local/share/manyoyo-gopls
|
|
289
295
|
fi
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const http = require('http');
|
|
5
|
+
const net = require('net');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const { spawnSync } = require('child_process');
|
|
9
|
+
|
|
10
|
+
const APP_HOST = '127.0.0.1';
|
|
11
|
+
const READY_TIMEOUT_MS = 30000;
|
|
12
|
+
const LOCK_STALE_MS = 60000;
|
|
13
|
+
const SERVE_MARKER_HEADER = 'x-manyoyo-serve';
|
|
14
|
+
const SPAWN_ATTEMPTS = 2;
|
|
15
|
+
|
|
16
|
+
function getAppStatePath(homeDir = os.homedir()) {
|
|
17
|
+
return path.join(homeDir, '.manyoyo', 'serve', 'app.json');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function readAppState(filePath) {
|
|
21
|
+
try {
|
|
22
|
+
const state = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
23
|
+
if (state && state.host === APP_HOST && Number.isInteger(state.port) && Number.isInteger(state.pid)) {
|
|
24
|
+
return state;
|
|
25
|
+
}
|
|
26
|
+
} catch (e) {
|
|
27
|
+
// 不存在或损坏都按“没有实例”处理
|
|
28
|
+
}
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function writeAppState(filePath, state) {
|
|
33
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
34
|
+
fs.writeFileSync(filePath, `${JSON.stringify(state)}\n`, { mode: 0o600 });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function canConnect(host, port, timeoutMs = 500) {
|
|
38
|
+
return new Promise(resolve => {
|
|
39
|
+
const socket = net.connect({ host, port });
|
|
40
|
+
const done = ok => {
|
|
41
|
+
socket.destroy();
|
|
42
|
+
resolve(ok);
|
|
43
|
+
};
|
|
44
|
+
socket.setTimeout(timeoutMs, () => done(false));
|
|
45
|
+
socket.once('connect', () => done(true));
|
|
46
|
+
socket.once('error', () => done(false));
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// TCP 能连上不代表是 manyoyo:端口可能被别的程序(甚至别的用户)占了,一次性令牌只能发给 manyoyo serve。
|
|
51
|
+
// serve 的每个响应都带 X-Manyoyo-Serve 头(包括未登录的 401),请求本身不带任何凭据。
|
|
52
|
+
function probeManyoyoServe(host, port, timeoutMs = 1500) {
|
|
53
|
+
return new Promise(resolve => {
|
|
54
|
+
const req = http.get({ host, port, path: '/api/sessions', timeout: timeoutMs, headers: { Connection: 'close' } }, res => {
|
|
55
|
+
res.resume();
|
|
56
|
+
resolve(Boolean(res.headers[SERVE_MARKER_HEADER]));
|
|
57
|
+
});
|
|
58
|
+
req.on('timeout', () => { req.destroy(); resolve(false); });
|
|
59
|
+
req.on('error', () => resolve(false));
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function readLockPid(lockDir) {
|
|
64
|
+
try {
|
|
65
|
+
return Number(fs.readFileSync(path.join(lockDir, 'pid'), 'utf-8').trim());
|
|
66
|
+
} catch (e) {
|
|
67
|
+
return NaN;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 同一时刻只让一个 manyoyo 启动器去选端口/启动 serve(mkdir 是原子的)。
|
|
73
|
+
* 锁的持有者已退出或锁太旧就当作残留清掉。拿不到锁说明另一个终端正在启动,等它结束再复用其结果。
|
|
74
|
+
*/
|
|
75
|
+
async function acquireStartLock(lockDir, deps) {
|
|
76
|
+
const { isProcessRunning, sleep, now, timeoutMs } = deps;
|
|
77
|
+
const deadline = now() + timeoutMs;
|
|
78
|
+
fs.mkdirSync(path.dirname(lockDir), { recursive: true });
|
|
79
|
+
for (;;) {
|
|
80
|
+
try {
|
|
81
|
+
fs.mkdirSync(lockDir);
|
|
82
|
+
fs.writeFileSync(path.join(lockDir, 'pid'), `${process.pid}\n`);
|
|
83
|
+
return () => fs.rmSync(lockDir, { recursive: true, force: true });
|
|
84
|
+
} catch (error) {
|
|
85
|
+
if (error.code !== 'EEXIST') throw error;
|
|
86
|
+
}
|
|
87
|
+
const pid = readLockPid(lockDir);
|
|
88
|
+
let age = 0;
|
|
89
|
+
try { age = Date.now() - fs.statSync(lockDir).mtimeMs; } catch (e) { continue; }
|
|
90
|
+
const holderAlive = Number.isInteger(pid) && pid > 0 && isProcessRunning(pid);
|
|
91
|
+
if (!holderAlive || age > LOCK_STALE_MS + timeoutMs) {
|
|
92
|
+
fs.rmSync(lockDir, { recursive: true, force: true });
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (now() > deadline) {
|
|
96
|
+
throw new Error('另一个 manyoyo 正在启动本机服务,请稍后重试');
|
|
97
|
+
}
|
|
98
|
+
await sleep(200);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function pickFreePort() {
|
|
103
|
+
return new Promise((resolve, reject) => {
|
|
104
|
+
const server = net.createServer();
|
|
105
|
+
server.once('error', reject);
|
|
106
|
+
server.listen(0, APP_HOST, () => {
|
|
107
|
+
const { port } = server.address();
|
|
108
|
+
server.close(err => (err ? reject(err) : resolve(port)));
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// macOS 用 open,Linux 用 xdg-open;都没有返回 false(由调用方只打印地址)
|
|
114
|
+
function openBrowser(url, deps = {}) {
|
|
115
|
+
const platform = deps.platform || process.platform;
|
|
116
|
+
const run = deps.run || ((command, args) => spawnSync(command, args, { stdio: 'ignore' }));
|
|
117
|
+
const command = platform === 'darwin' ? 'open' : (platform === 'linux' ? 'xdg-open' : '');
|
|
118
|
+
if (!command) return false;
|
|
119
|
+
const result = run(command, [url]);
|
|
120
|
+
return Boolean(result) && !result.error && result.status === 0;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* 无参数 manyoyo:复用或后台启动本机 serve,签发一次性令牌并打开浏览器。
|
|
125
|
+
* 所有副作用都从 deps 注入,便于测试。
|
|
126
|
+
*/
|
|
127
|
+
async function launchApp(deps) {
|
|
128
|
+
const {
|
|
129
|
+
statePath, isProcessRunning, spawnServe, issueToken, open, log
|
|
130
|
+
} = deps;
|
|
131
|
+
const connect = deps.canConnect || canConnect;
|
|
132
|
+
const pickPort = deps.pickPort || pickFreePort;
|
|
133
|
+
const sleep = deps.sleep || (ms => new Promise(resolve => setTimeout(resolve, ms)));
|
|
134
|
+
const now = deps.now || Date.now;
|
|
135
|
+
const timeoutMs = deps.readyTimeoutMs || READY_TIMEOUT_MS;
|
|
136
|
+
|
|
137
|
+
const probe = deps.probe || (async (host, port) => (await connect(host, port)) && probeManyoyoServe(host, port));
|
|
138
|
+
const release = await acquireStartLock(path.join(path.dirname(statePath), 'app.lock'), {
|
|
139
|
+
isProcessRunning, sleep, now, timeoutMs
|
|
140
|
+
});
|
|
141
|
+
let state;
|
|
142
|
+
let reused = false;
|
|
143
|
+
try {
|
|
144
|
+
state = readAppState(statePath);
|
|
145
|
+
if (state && isProcessRunning(state.pid) && await probe(APP_HOST, state.port)) {
|
|
146
|
+
reused = true;
|
|
147
|
+
} else {
|
|
148
|
+
state = await startFresh();
|
|
149
|
+
}
|
|
150
|
+
} finally {
|
|
151
|
+
release();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// 选端口与 serve 绑定之间有空档,端口可能被抢;serve 因此立即退出时换端口重试一次
|
|
155
|
+
async function startFresh() {
|
|
156
|
+
let lastError = null;
|
|
157
|
+
for (let attempt = 0; attempt < SPAWN_ATTEMPTS; attempt += 1) {
|
|
158
|
+
const port = await pickPort();
|
|
159
|
+
const child = spawnServe(port);
|
|
160
|
+
let spawnError = null;
|
|
161
|
+
let exited = false;
|
|
162
|
+
if (child && typeof child.on === 'function') {
|
|
163
|
+
child.on('error', error => { spawnError = error; });
|
|
164
|
+
child.on('exit', () => { exited = true; });
|
|
165
|
+
}
|
|
166
|
+
if (child && !Number.isInteger(child.pid)) {
|
|
167
|
+
await new Promise(resolve => setImmediate(resolve)); // 让异步的 spawn error(如 ENOENT)先到
|
|
168
|
+
}
|
|
169
|
+
if (!child || !Number.isInteger(child.pid)) {
|
|
170
|
+
throw new Error(`无法启动本机服务${spawnError ? `: ${spawnError.message}` : ''}${deps.logPathHint ? `,请查看日志: ${deps.logPathHint}` : ''}`);
|
|
171
|
+
}
|
|
172
|
+
const next = { host: APP_HOST, port, pid: child.pid, startedAt: new Date(now()).toISOString() };
|
|
173
|
+
writeAppState(statePath, next);
|
|
174
|
+
|
|
175
|
+
const deadline = now() + timeoutMs;
|
|
176
|
+
while (now() < deadline && !spawnError && !exited) {
|
|
177
|
+
if (await probe(APP_HOST, port)) return next;
|
|
178
|
+
await sleep(200);
|
|
179
|
+
}
|
|
180
|
+
if (spawnError) throw new Error(`无法启动本机服务: ${spawnError.message}`);
|
|
181
|
+
lastError = exited
|
|
182
|
+
? new Error(`本机服务启动后立即退出(端口可能被占用)${deps.logPathHint ? `,请查看日志: ${deps.logPathHint}` : ''}`)
|
|
183
|
+
: new Error(`本机服务在 ${Math.round(timeoutMs / 1000)} 秒内未就绪${deps.logPathHint ? `,请查看日志: ${deps.logPathHint}` : ''}`);
|
|
184
|
+
if (!exited) break;
|
|
185
|
+
}
|
|
186
|
+
throw lastError;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const baseUrl = `http://${APP_HOST}:${state.port}`;
|
|
190
|
+
if (deps.headless) {
|
|
191
|
+
// 无头环境没有浏览器:不签发一次性令牌(用户要先建立端口转发,60 秒不够),用密码登录
|
|
192
|
+
const commandName = deps.commandName || 'manyoyo';
|
|
193
|
+
log(`${reused ? '已复用运行中的服务' : '已启动本机服务'}(无头环境,不打开浏览器): ${baseUrl}`);
|
|
194
|
+
log(` 在你自己的电脑上转发端口: ssh -L ${state.port}:127.0.0.1:${state.port} <用户名>@<这台机器的地址>,然后浏览器访问 ${baseUrl}`);
|
|
195
|
+
log(` 登录用户名: ${deps.authUser || 'admin'};密码是你用 ${commandName} setup 设置的那个(还没设置请先运行 ${commandName} setup)`);
|
|
196
|
+
log(` 关闭服务: ${commandName} serve ${APP_HOST}:${state.port} --stop`);
|
|
197
|
+
return { reused, opened: false, headless: true, baseUrl, loginUrl: '', pid: state.pid };
|
|
198
|
+
}
|
|
199
|
+
const loginUrl = `${baseUrl}/auth/login?token=${issueToken()}`;
|
|
200
|
+
log(`${reused ? '已复用运行中的服务' : '已启动本机服务'}: ${baseUrl}`);
|
|
201
|
+
// 服务在后台一直运行,必须告诉用户怎么关
|
|
202
|
+
log(` 关闭服务: ${deps.commandName || 'manyoyo'} serve ${APP_HOST}:${state.port} --stop`);
|
|
203
|
+
const opened = open(loginUrl);
|
|
204
|
+
if (!opened) {
|
|
205
|
+
// 没有浏览器打开器:一次性地址只在这里打印(60 秒内、用一次即失效)
|
|
206
|
+
log(`未能自动打开浏览器,请在 60 秒内访问: ${loginUrl}`);
|
|
207
|
+
}
|
|
208
|
+
return { reused, opened, baseUrl, loginUrl, pid: state.pid };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
module.exports = {
|
|
212
|
+
APP_HOST,
|
|
213
|
+
getAppStatePath,
|
|
214
|
+
readAppState,
|
|
215
|
+
canConnect,
|
|
216
|
+
probeManyoyoServe,
|
|
217
|
+
SERVE_MARKER_HEADER,
|
|
218
|
+
openBrowser,
|
|
219
|
+
launchApp
|
|
220
|
+
};
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// 离线包安装的 manyoyo 的增量升级:只下载 -app.tar.gz(Node + manyoyo,数十 MB),校验后放进 app/<版本>/,
|
|
4
|
+
// 原子切换 app/current,保留上一版本以便回滚。npm 安装的用户走原来的 npm 逻辑,不经过这里。
|
|
5
|
+
// 所有网络与进程副作用都从参数注入,便于用本地 HTTP 服务替身做测试。
|
|
6
|
+
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const os = require('os');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const { spawnSync } = require('child_process');
|
|
11
|
+
const { downloadVerified, sha256File } = require('./download-verified');
|
|
12
|
+
|
|
13
|
+
const REPO = 'xcanwin/manyoyo';
|
|
14
|
+
const API_BASE = 'https://api.github.com';
|
|
15
|
+
// 不依赖 PATH 查找 tar(macOS 与常见 Linux 都在 /usr/bin)
|
|
16
|
+
const TAR_COMMAND = fs.existsSync('/usr/bin/tar') ? '/usr/bin/tar' : 'tar';
|
|
17
|
+
const VERSION_PATTERN = /^\d+\.\d+\.\d+$/;
|
|
18
|
+
const FETCH_TIMEOUT_MS = 15000;
|
|
19
|
+
|
|
20
|
+
class UpdateError extends Error {
|
|
21
|
+
constructor(code, message) {
|
|
22
|
+
super(message);
|
|
23
|
+
this.code = code;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function parseVersion(text) {
|
|
28
|
+
const match = String(text || '').trim().replace(/^v/, '').match(/^(\d+)\.(\d+)\.(\d+)/);
|
|
29
|
+
return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function compareVersions(a, b) {
|
|
33
|
+
const left = parseVersion(a);
|
|
34
|
+
const right = parseVersion(b);
|
|
35
|
+
if (!left || !right) return 0;
|
|
36
|
+
for (let i = 0; i < 3; i += 1) {
|
|
37
|
+
if (left[i] !== right[i]) return left[i] < right[i] ? -1 : 1;
|
|
38
|
+
}
|
|
39
|
+
return 0;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 程序真实路径在 ~/.manyoyo/app/<版本>/ 下 → 离线包安装;否则按 npm 安装处理。
|
|
44
|
+
*/
|
|
45
|
+
function detectInstallMode({ scriptPath, homeDir = os.homedir() }) {
|
|
46
|
+
let real = scriptPath;
|
|
47
|
+
try {
|
|
48
|
+
real = fs.realpathSync(scriptPath);
|
|
49
|
+
} catch (error) {
|
|
50
|
+
// 路径不存在就按原样比较
|
|
51
|
+
}
|
|
52
|
+
const appRoot = path.join(homeDir, '.manyoyo', 'app');
|
|
53
|
+
const relative = path.relative(appRoot, real);
|
|
54
|
+
if (!relative.startsWith('..') && !path.isAbsolute(relative)) {
|
|
55
|
+
const version = relative.split(path.sep)[0];
|
|
56
|
+
if (VERSION_PATTERN.test(version)) {
|
|
57
|
+
return { mode: 'offline', appRoot, version };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return { mode: 'npm' };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function arch() {
|
|
64
|
+
return process.arch === 'arm64' ? 'arm64' : 'x64';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Release 资产名里的平台段:macos | linux
|
|
68
|
+
function platformOs() {
|
|
69
|
+
return process.platform === 'linux' ? 'linux' : 'macos';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function request(fetchImpl, url, options = {}) {
|
|
73
|
+
try {
|
|
74
|
+
return await fetchImpl(url, {
|
|
75
|
+
redirect: 'follow',
|
|
76
|
+
// 下载整包不能套短超时(慢网络下几十 MB 要好一会儿),查询类请求才限时
|
|
77
|
+
signal: options.noTimeout ? undefined : AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
78
|
+
...options,
|
|
79
|
+
// 固定的请求头,不附带任何本机信息(用户名、主机名、版本之外的任何东西)
|
|
80
|
+
headers: { Accept: 'application/vnd.github+json', 'User-Agent': 'manyoyo-update', ...(options.headers || {}) }
|
|
81
|
+
});
|
|
82
|
+
} catch (error) {
|
|
83
|
+
throw new UpdateError('NETWORK', `无法连接 GitHub(${error && error.cause && error.cause.code || error.name || '网络错误'})。请检查网络或代理后重试。`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* 查询最新 Release(匿名 API)。返回 { version, tag, assets: { name: url } }。
|
|
89
|
+
*/
|
|
90
|
+
async function fetchLatestRelease({ fetchImpl = fetch, apiBase = API_BASE, now = Date.now } = {}) {
|
|
91
|
+
const response = await request(fetchImpl, `${apiBase}/repos/${REPO}/releases/latest`);
|
|
92
|
+
if (response.status === 403 || response.status === 429) {
|
|
93
|
+
const remaining = response.headers.get('x-ratelimit-remaining');
|
|
94
|
+
if (response.status === 429 || remaining === '0') {
|
|
95
|
+
const reset = Number(response.headers.get('x-ratelimit-reset'));
|
|
96
|
+
const when = reset > 0 ? new Date(reset * 1000).toLocaleTimeString('zh-CN', { hour12: false }) : '';
|
|
97
|
+
throw new UpdateError('RATE_LIMIT', `GitHub 对匿名请求做了频率限制,${when ? `请在 ${when} 之后重试` : '请稍后重试'}。`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (response.status === 404) throw new UpdateError('NO_RELEASE', '仓库还没有发布的 Release,暂时无法升级。');
|
|
101
|
+
if (!response.ok) throw new UpdateError('HTTP', `查询最新版本失败(HTTP ${response.status})。`);
|
|
102
|
+
let release;
|
|
103
|
+
try {
|
|
104
|
+
release = await response.json();
|
|
105
|
+
} catch (error) {
|
|
106
|
+
throw new UpdateError('HTTP', '查询最新版本失败(返回内容不是有效的 JSON)。');
|
|
107
|
+
}
|
|
108
|
+
const version = String(release.tag_name || '').replace(/^v/, '');
|
|
109
|
+
if (!VERSION_PATTERN.test(version)) throw new UpdateError('HTTP', `最新 Release 的版本号无法识别: ${release.tag_name}`);
|
|
110
|
+
// 默认 API 下,下载地址必须在本仓库的 Release 下载路径内(测试注入自定义 apiBase 时不限制)
|
|
111
|
+
const allowedPrefix = apiBase === API_BASE ? `https://github.com/${REPO}/releases/download/` : '';
|
|
112
|
+
const assets = {};
|
|
113
|
+
for (const asset of Array.isArray(release.assets) ? release.assets : []) {
|
|
114
|
+
if (asset && typeof asset.name === 'string' && typeof asset.browser_download_url === 'string' && asset.browser_download_url.startsWith(allowedPrefix)) assets[asset.name] = asset.browser_download_url;
|
|
115
|
+
}
|
|
116
|
+
return { version, tag: release.tag_name, assets };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function parseSha256Sums(text) {
|
|
120
|
+
const result = {};
|
|
121
|
+
for (const line of String(text).split('\n')) {
|
|
122
|
+
const match = line.trim().match(/^([0-9a-f]{64})\s+\*?(.+)$/i);
|
|
123
|
+
if (match) result[match[2].trim()] = match[1].toLowerCase();
|
|
124
|
+
}
|
|
125
|
+
return result;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function releaseAssetNames(version, targetArch, targetOs = 'macos') {
|
|
129
|
+
return {
|
|
130
|
+
app: `manyoyo-${version}-${targetOs}-${targetArch}-app.tar.gz`,
|
|
131
|
+
sums: `SHA256SUMS-${targetOs}-${targetArch}`,
|
|
132
|
+
manifest: `release-manifest-${targetOs}-${targetArch}.json`
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function fetchText(fetchImpl, url) {
|
|
137
|
+
const response = await request(fetchImpl, url);
|
|
138
|
+
if (!response.ok) throw new UpdateError('HTTP', `下载 ${url.split('/').pop()} 失败(HTTP ${response.status})。`);
|
|
139
|
+
return response.text();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function switchCurrent(appRoot, version) {
|
|
143
|
+
const link = path.join(appRoot, 'current');
|
|
144
|
+
const staging = path.join(appRoot, `.current.${process.pid}.tmp`);
|
|
145
|
+
fs.rmSync(staging, { force: true });
|
|
146
|
+
fs.symlinkSync(version, staging);
|
|
147
|
+
try {
|
|
148
|
+
if (fs.existsSync(link) && !fs.lstatSync(link).isSymbolicLink()) {
|
|
149
|
+
throw new UpdateError('BAD_LAYOUT', `${link} 不是符号链接,拒绝覆盖。`);
|
|
150
|
+
}
|
|
151
|
+
fs.renameSync(staging, link); // rename 覆盖符号链接是原子的
|
|
152
|
+
} catch (error) {
|
|
153
|
+
fs.rmSync(staging, { force: true });
|
|
154
|
+
throw error;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function currentVersion(appRoot) {
|
|
159
|
+
try {
|
|
160
|
+
return path.basename(fs.readlinkSync(path.join(appRoot, 'current')));
|
|
161
|
+
} catch (error) {
|
|
162
|
+
return '';
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function writeUpdateRecord(appRoot, record) {
|
|
167
|
+
fs.writeFileSync(path.join(appRoot, '.update.json'), `${JSON.stringify(record)}\n`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function readUpdateRecord(appRoot) {
|
|
171
|
+
try {
|
|
172
|
+
return JSON.parse(fs.readFileSync(path.join(appRoot, '.update.json'), 'utf-8'));
|
|
173
|
+
} catch (error) {
|
|
174
|
+
return {};
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// 只保留当前与上一版本,其它版本目录与残留的临时目录一起清掉
|
|
179
|
+
function pruneApps(appRoot, keep) {
|
|
180
|
+
for (const name of fs.readdirSync(appRoot)) {
|
|
181
|
+
if (keep.includes(name) || name === 'current' || name === '.update.json') continue;
|
|
182
|
+
if (VERSION_PATTERN.test(name) || name.startsWith('.tmp-')) fs.rmSync(path.join(appRoot, name), { recursive: true, force: true });
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* 下载并安装 -app.tar.gz,成功后切换 current。校验失败/包不完整都在切换前中止,不留半成品。
|
|
188
|
+
*/
|
|
189
|
+
async function installAppUpdate({ appRoot, release, fetchImpl = fetch, targetArch = arch(), targetOs = 'macos', run = defaultRun, log = () => {}, tmpRoot = os.tmpdir() }) {
|
|
190
|
+
const names = releaseAssetNames(release.version, targetArch, targetOs);
|
|
191
|
+
const appUrl = release.assets[names.app];
|
|
192
|
+
const sumsUrl = release.assets[names.sums] || release.assets.SHA256SUMS;
|
|
193
|
+
if (!appUrl || !sumsUrl) {
|
|
194
|
+
throw new UpdateError('NO_ASSET', `Release ${release.version} 里没有适合这台机器(${targetOs}-${targetArch})的升级包(需要 ${names.app} 与 ${names.sums})。`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
log(`校验清单: ${names.sums}`);
|
|
198
|
+
const sums = parseSha256Sums(await fetchText(fetchImpl, sumsUrl));
|
|
199
|
+
const expected = sums[names.app];
|
|
200
|
+
if (!expected) throw new UpdateError('CHECKSUM', `校验清单里没有 ${names.app},已中止升级。`);
|
|
201
|
+
|
|
202
|
+
const workDir = fs.mkdtempSync(path.join(tmpRoot, 'manyoyo-update-'));
|
|
203
|
+
const staging = path.join(appRoot, `.tmp-${release.version}-${process.pid}`);
|
|
204
|
+
try {
|
|
205
|
+
log(`下载升级包: ${names.app}`);
|
|
206
|
+
const archive = path.join(workDir, names.app);
|
|
207
|
+
try {
|
|
208
|
+
await downloadVerified({ url: appUrl, sha256: expected, dest: archive, fetchImpl: (url, options) => request(fetchImpl, url, { ...options, noTimeout: true }), log });
|
|
209
|
+
} catch (error) {
|
|
210
|
+
if (/SHA256 校验失败/.test(error.message)) throw new UpdateError('CHECKSUM', `升级包 SHA256 校验失败,已中止升级,当前版本不受影响。`);
|
|
211
|
+
if (error instanceof UpdateError) throw error;
|
|
212
|
+
throw new UpdateError('NETWORK', `下载升级包失败:${error.message}`);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
fs.rmSync(staging, { recursive: true, force: true });
|
|
216
|
+
fs.mkdirSync(staging, { recursive: true });
|
|
217
|
+
run(TAR_COMMAND, ['-xzf', archive, '-C', staging]);
|
|
218
|
+
|
|
219
|
+
for (const required of ['node/bin/node', 'manyoyo/bin/manyoyo.js', 'manifest.json']) {
|
|
220
|
+
if (!fs.existsSync(path.join(staging, required))) throw new UpdateError('BAD_PACKAGE', `升级包不完整(缺少 ${required}),已中止升级。`);
|
|
221
|
+
}
|
|
222
|
+
let manifest;
|
|
223
|
+
try {
|
|
224
|
+
manifest = JSON.parse(fs.readFileSync(path.join(staging, 'manifest.json'), 'utf-8'));
|
|
225
|
+
} catch (error) {
|
|
226
|
+
throw new UpdateError('BAD_PACKAGE', '升级包的 manifest.json 无法解析,已中止升级。');
|
|
227
|
+
}
|
|
228
|
+
if (manifest.version !== release.version || manifest.kind !== 'app' || manifest.arch !== targetArch || (manifest.os && manifest.os !== targetOs)) {
|
|
229
|
+
throw new UpdateError('BAD_PACKAGE', `升级包内容与预期不符(版本 ${manifest.version}、类型 ${manifest.kind}、架构 ${manifest.arch}、系统 ${manifest.os || targetOs}),已中止升级。`);
|
|
230
|
+
}
|
|
231
|
+
fs.writeFileSync(path.join(staging, '.installed'), await sha256File(path.join(staging, 'manifest.json')));
|
|
232
|
+
|
|
233
|
+
const previous = currentVersion(appRoot);
|
|
234
|
+
const target = path.join(appRoot, release.version);
|
|
235
|
+
if (release.version !== previous) fs.rmSync(target, { recursive: true, force: true });
|
|
236
|
+
if (release.version !== previous) fs.renameSync(staging, target);
|
|
237
|
+
switchCurrent(appRoot, release.version);
|
|
238
|
+
writeUpdateRecord(appRoot, { previous: previous && previous !== release.version ? previous : readUpdateRecord(appRoot).previous || '', current: release.version, updatedAt: new Date().toISOString() });
|
|
239
|
+
const record = readUpdateRecord(appRoot);
|
|
240
|
+
pruneApps(appRoot, [release.version, record.previous].filter(Boolean));
|
|
241
|
+
return { version: release.version, previous: record.previous || '', manifest };
|
|
242
|
+
} finally {
|
|
243
|
+
fs.rmSync(workDir, { recursive: true, force: true });
|
|
244
|
+
fs.rmSync(staging, { recursive: true, force: true });
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function rollbackApp({ appRoot }) {
|
|
249
|
+
const current = currentVersion(appRoot);
|
|
250
|
+
const record = readUpdateRecord(appRoot);
|
|
251
|
+
let previous = record.previous;
|
|
252
|
+
if (!previous || !VERSION_PATTERN.test(previous) || !fs.existsSync(path.join(appRoot, previous))) {
|
|
253
|
+
const candidates = fs.existsSync(appRoot) ? fs.readdirSync(appRoot).filter(name => VERSION_PATTERN.test(name) && name !== current && fs.existsSync(path.join(appRoot, name, 'manyoyo', 'bin', 'manyoyo.js'))) : [];
|
|
254
|
+
candidates.sort((a, b) => compareVersions(b, a));
|
|
255
|
+
previous = candidates[0];
|
|
256
|
+
}
|
|
257
|
+
if (!previous) throw new UpdateError('NO_PREVIOUS', '没有可回滚的上一版本。');
|
|
258
|
+
switchCurrent(appRoot, previous);
|
|
259
|
+
writeUpdateRecord(appRoot, { previous: current, current: previous, updatedAt: new Date().toISOString() });
|
|
260
|
+
return { from: current, to: previous };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Podman / VM 磁盘版本变化只提示,不自动替换(需要下载新的完整包)。
|
|
265
|
+
* installed:安装器写的 ~/.manyoyo/.install/installed.json;remoteManifest:Release 里的 release-manifest(含完整包 manifest)。
|
|
266
|
+
*/
|
|
267
|
+
function describeRuntimeChange(installed, remoteManifest) {
|
|
268
|
+
const remote = remoteManifest && remoteManifest.manifest && remoteManifest.manifest.components;
|
|
269
|
+
if (!installed || !installed.podmanVersion || !remote || !remote.podman) return '';
|
|
270
|
+
const podmanChanged = remote.podman.version !== installed.podmanVersion;
|
|
271
|
+
const vmChanged = Boolean(installed.vmDiskSha256) && remote.vmDisk && remote.vmDisk.sha256 !== installed.vmDiskSha256;
|
|
272
|
+
if (!podmanChanged && !vmChanged) return '';
|
|
273
|
+
const parts = [];
|
|
274
|
+
if (podmanChanged) parts.push(`Podman ${installed.podmanVersion} → ${remote.podman.version}`);
|
|
275
|
+
if (vmChanged) parts.push('虚拟机磁盘有更新');
|
|
276
|
+
return `新版本捆绑的运行环境有变化(${parts.join(',')})。本次只升级了 manyoyo 本体;要换 Podman / 虚拟机,请下载新的完整安装包重新安装。`;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function readInstalledRecord(homeDir) {
|
|
280
|
+
try {
|
|
281
|
+
return JSON.parse(fs.readFileSync(path.join(homeDir, '.manyoyo', '.install', 'installed.json'), 'utf-8'));
|
|
282
|
+
} catch (error) {
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** 仍在使用旧镜像的 manyoyo 容器(ref 为新版本要求的镜像)。 */
|
|
288
|
+
function findOutdatedContainers({ run, runtime, imageRef }) {
|
|
289
|
+
let output = '';
|
|
290
|
+
try {
|
|
291
|
+
output = String(run(runtime.command, ['ps', '-a', '--filter', 'label=manyoyo.default_cmd', '--format', '{{.Names}}\t{{.Image}}'], { env: runtime.env }));
|
|
292
|
+
} catch (error) {
|
|
293
|
+
return [];
|
|
294
|
+
}
|
|
295
|
+
return output.split('\n').map(line => line.trim()).filter(Boolean).map(line => {
|
|
296
|
+
const [name, image] = line.split('\t');
|
|
297
|
+
return { name, image: image || '' };
|
|
298
|
+
}).filter(item => item.name && item.image && item.image !== imageRef);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function defaultRun(command, args, options = {}) {
|
|
302
|
+
const result = spawnSync(command, args, { encoding: 'utf-8', env: options.env });
|
|
303
|
+
if (result.error) throw result.error;
|
|
304
|
+
if (result.status !== 0) throw new Error(String(result.stderr || '').trim() || `${command} 退出码 ${result.status}`);
|
|
305
|
+
return result.stdout || '';
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
module.exports = {
|
|
309
|
+
UpdateError,
|
|
310
|
+
parseVersion,
|
|
311
|
+
compareVersions,
|
|
312
|
+
detectInstallMode,
|
|
313
|
+
fetchLatestRelease,
|
|
314
|
+
parseSha256Sums,
|
|
315
|
+
releaseAssetNames,
|
|
316
|
+
installAppUpdate,
|
|
317
|
+
rollbackApp,
|
|
318
|
+
currentVersion,
|
|
319
|
+
describeRuntimeChange,
|
|
320
|
+
readInstalledRecord,
|
|
321
|
+
findOutdatedContainers,
|
|
322
|
+
fetchText,
|
|
323
|
+
arch,
|
|
324
|
+
platformOs
|
|
325
|
+
};
|
package/lib/container-run.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const { buildMirrorEnvArgs } = require('./mirrors');
|
|
4
|
+
|
|
3
5
|
function sanitizeDefaultCommand(defaultCommand) {
|
|
4
6
|
return String(defaultCommand || '').replace(/[\r\n]/g, ' ');
|
|
5
7
|
}
|
|
@@ -12,6 +14,7 @@ function buildContainerRunArgs(options) {
|
|
|
12
14
|
...(options.contModeArgs || []),
|
|
13
15
|
...(options.containerExtraArgs || []),
|
|
14
16
|
...(options.containerEnvs || []),
|
|
17
|
+
...buildMirrorEnvArgs(options.mirrors, options.containerEnvs),
|
|
15
18
|
...(options.containerVolumes || []),
|
|
16
19
|
...(options.containerPorts || []),
|
|
17
20
|
'--volume', `${options.hostPath}:${options.containerPath}`,
|