@xcanwin/manyoyo 7.1.6 → 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/lib/setup.js ADDED
@@ -0,0 +1,188 @@
1
+ 'use strict';
2
+
3
+ const { AGENT_ENV_SCHEMA } = require('./init-config');
4
+ const { parseEnvEntry } = require('./runtime-normalizers');
5
+
6
+ const SETUP_AGENTS = Object.keys(AGENT_ENV_SCHEMA);
7
+
8
+ function isPlainObject(value) {
9
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
10
+ }
11
+
12
+ function listSetupAgents() {
13
+ return SETUP_AGENTS.map(id => {
14
+ const spec = AGENT_ENV_SCHEMA[id];
15
+ return {
16
+ id,
17
+ label: spec.label,
18
+ yolo: spec.yolo,
19
+ modelKey: spec.modelKey,
20
+ requiredAnyOf: [...spec.requiredAnyOf],
21
+ baseUrlKey: spec.baseUrlKey,
22
+ baseUrlPresets: spec.baseUrlPresets.map(item => ({ ...item })),
23
+ env: spec.env.map(item => ({ ...item }))
24
+ };
25
+ });
26
+ }
27
+
28
+ function hasNonEmptyRequiredKey(spec, env) {
29
+ const source = isPlainObject(env) ? env : {};
30
+ return spec.requiredAnyOf.some(key => typeof source[key] === 'string' && source[key].trim() !== '');
31
+ }
32
+
33
+ // 已有可用 runs.<agent>:run 存在,且必填变量(任意一个)非空
34
+ function getConfiguredAgents(config) {
35
+ const runs = isPlainObject(config && config.runs) ? config.runs : {};
36
+ return SETUP_AGENTS.filter(id => isPlainObject(runs[id]) && hasNonEmptyRequiredKey(AGENT_ENV_SCHEMA[id], runs[id].env));
37
+ }
38
+
39
+ /**
40
+ * 校验请求里的 env 并合并到已有 run 上,返回要写入的 runs.<agent> 对象。
41
+ * - agent 必须在 schema 内,env key 必须属于该 agent,值按 parseEnvEntry 校验;
42
+ * - 空字符串表示“不改动/不设置”,已有 run 的其它字段(volumes、ports…)原样保留。
43
+ */
44
+ function buildAgentRunProfile(agent, envInput, options = {}) {
45
+ const spec = AGENT_ENV_SCHEMA[agent];
46
+ if (!spec) {
47
+ throw new Error(`不支持的 Agent: ${agent}`);
48
+ }
49
+ if (!isPlainObject(envInput)) {
50
+ throw new Error('env 必须是对象(map)');
51
+ }
52
+ const allowed = new Set(spec.env.map(item => item.name));
53
+ const provided = {};
54
+ for (const [key, rawValue] of Object.entries(envInput)) {
55
+ if (!allowed.has(key)) {
56
+ throw new Error(`${agent} 不支持的变量: ${key}`);
57
+ }
58
+ if (typeof rawValue !== 'string') {
59
+ throw new Error(`env 值必须是字符串: ${key}`);
60
+ }
61
+ const value = rawValue.trim();
62
+ if (!value) continue;
63
+ parseEnvEntry(`${key}=${value}`);
64
+ provided[key] = value;
65
+ }
66
+
67
+ const existing = isPlainObject(options.existingRun) ? options.existingRun : {};
68
+ const existingEnv = isPlainObject(existing.env) ? existing.env : {};
69
+ const profile = { ...existing };
70
+ profile.env = { ...existingEnv, ...provided };
71
+ if (!hasNonEmptyRequiredKey(spec, profile.env)) {
72
+ throw new Error(`请填写 ${spec.requiredAnyOf.join(' 或 ')}`);
73
+ }
74
+ if (profile.containerName === undefined) profile.containerName = `my-${agent}-{now}`;
75
+ if (profile.yolo === undefined) profile.yolo = spec.yolo;
76
+ if (options.hostPath) profile.hostPath = options.hostPath;
77
+ else if (profile.hostPath === undefined && options.defaultHostPath) profile.hostPath = options.defaultHostPath;
78
+ return profile;
79
+ }
80
+
81
+ const CONNECTION_RULES = [
82
+ {
83
+ category: 'auth',
84
+ pattern: /\b(401|403)\b|unauthori[sz]ed|invalid[ _-]?(x-)?api[ _-]?key|incorrect api key|authentication[ _](failed|error)|forbidden|permission_denied|invalid[ _]token/i,
85
+ message: '认证失败:Key 或令牌无效,请检查后重新填写'
86
+ },
87
+ {
88
+ category: 'network',
89
+ pattern: /ENOTFOUND|ECONNREFUSED|ECONNRESET|ETIMEDOUT|EAI_AGAIN|EHOSTUNREACH|ENETUNREACH|fetch failed|getaddrinfo|could not resolve|network (is )?unreachable|connection (refused|reset|timed out)|socket hang up|unable to connect/i,
90
+ message: '网络不可达:无法连接到 API 地址,请检查网络、代理或 Base URL'
91
+ }
92
+ ];
93
+
94
+ /**
95
+ * 把容器内最小请求的结果归类为 success / network / auth / other。
96
+ * 返回值只含固定文案和经过脱敏的尾部片段,不回传完整输出。
97
+ */
98
+ function classifyConnectionResult(result, secrets = []) {
99
+ const exitCode = result && Number.isInteger(result.exitCode) ? result.exitCode : 1;
100
+ const output = String(result && result.output || '');
101
+ if (exitCode === 0) {
102
+ return { category: 'success', message: '连接成功', detail: '' };
103
+ }
104
+ const detail = redactSecrets(output.trim().slice(-300), secrets);
105
+ if (exitCode === 124) {
106
+ return { category: 'network', message: '请求超时:60 秒内没有得到响应,请检查网络、代理或 Base URL', detail };
107
+ }
108
+ for (const rule of CONNECTION_RULES) {
109
+ if (rule.pattern.test(output)) {
110
+ return { category: rule.category, message: rule.message, detail };
111
+ }
112
+ }
113
+ return { category: 'other', message: '测试失败,请查看详情', detail };
114
+ }
115
+
116
+ function redactSecrets(text, secrets = []) {
117
+ let result = String(text || '');
118
+ for (const secret of secrets) {
119
+ const value = String(secret || '');
120
+ if (value.length >= 4) {
121
+ result = result.split(value).join('****');
122
+ }
123
+ }
124
+ return result
125
+ .replace(/\b(sk|pk|key)-[A-Za-z0-9_-]{8,}/g, '$1-****')
126
+ .replace(/(Bearer\s+)[A-Za-z0-9._~+/=-]{8,}/gi, '$1****')
127
+ .replace(/(api[_-]?key["'=:\s]+)[A-Za-z0-9._-]{8,}/gi, '$1****');
128
+ }
129
+
130
+ // 软件源预设(单一数据源,经 /api/setup/agents 下发给向导);“官方默认”与“自定义”由前端补。
131
+ // apt 只填镜像站主机(保留 /ubuntu、/ubuntu-ports 路径)。value 都必须通过 normalizeMirrors。
132
+ const MIRROR_PRESETS = {
133
+ apt: [
134
+ { label: '阿里云', value: 'https://mirrors.aliyun.com' },
135
+ { label: '清华', value: 'https://mirrors.tuna.tsinghua.edu.cn' },
136
+ { label: '中科大', value: 'https://mirrors.ustc.edu.cn' },
137
+ { label: '腾讯云', value: 'https://mirrors.tencent.com' }
138
+ ],
139
+ npm: [
140
+ { label: '阿里云', value: 'https://registry.npmmirror.com/' },
141
+ { label: '腾讯云', value: 'https://mirrors.tencent.com/npm/' },
142
+ { label: '华为云', value: 'https://repo.huaweicloud.com/repository/npm/' }
143
+ ],
144
+ pip: [
145
+ { label: '阿里云', value: 'https://mirrors.aliyun.com/pypi/simple/' },
146
+ { label: '清华', value: 'https://pypi.tuna.tsinghua.edu.cn/simple' },
147
+ { label: '中科大', value: 'https://pypi.mirrors.ustc.edu.cn/simple/' },
148
+ { label: '腾讯云', value: 'https://mirrors.tencent.com/pypi/simple' }
149
+ ]
150
+ };
151
+
152
+ function listMirrorPresets() {
153
+ return Object.fromEntries(Object.entries(MIRROR_PRESETS).map(([tool, items]) => [tool, items.map(item => ({ ...item }))]));
154
+ }
155
+
156
+ const PASSWORD_MIN_LENGTH = 8;
157
+ const PASSWORD_MAX_LENGTH = 128;
158
+
159
+ // 返回空字符串表示通过,否则是给用户看的原因
160
+ function validateSetupPassword(password) {
161
+ if (typeof password !== 'string') {
162
+ return '请填写登录密码';
163
+ }
164
+ if (password.length < PASSWORD_MIN_LENGTH) {
165
+ return `密码至少 ${PASSWORD_MIN_LENGTH} 位`;
166
+ }
167
+ if (password.length > PASSWORD_MAX_LENGTH) {
168
+ return `密码不能超过 ${PASSWORD_MAX_LENGTH} 位`;
169
+ }
170
+ if (/[\u0000-\u001f\u007f]/.test(password)) {
171
+ return '密码不能包含换行、制表符等控制字符';
172
+ }
173
+ return '';
174
+ }
175
+
176
+ module.exports = {
177
+ MIRROR_PRESETS,
178
+ listMirrorPresets,
179
+ PASSWORD_MIN_LENGTH,
180
+ PASSWORD_MAX_LENGTH,
181
+ validateSetupPassword,
182
+ SETUP_AGENTS,
183
+ listSetupAgents,
184
+ getConfiguredAgents,
185
+ buildAgentRunProfile,
186
+ classifyConnectionResult,
187
+ redactSecrets
188
+ };
@@ -0,0 +1,296 @@
1
+ 'use strict';
2
+
3
+ // manyoyo uninstall:卸载离线包安装的 MANYOYO(方案 4.4)。
4
+ // 所有副作用(询问、执行命令、杀进程、日志)都从参数注入,便于用临时 HOME 测试。
5
+ // 只会碰 ~/.manyoyo 下的约定目录和 shell 配置里带标记的 manyoyo 块,绝不碰其它路径。
6
+
7
+ const fs = require('fs');
8
+ const os = require('os');
9
+ const path = require('path');
10
+ const { spawnSync } = require('child_process');
11
+ const { isImportProcess } = require('./offline-import');
12
+
13
+ const MACHINE_NAME = 'podman-machine-manyoyo';
14
+ const BLOCK_START = '# >>> manyoyo >>>';
15
+ const BLOCK_END = '# <<< manyoyo <<<';
16
+ const SHELL_FILES = ['.zprofile', '.zshrc', '.bash_profile', '.bash_login', '.profile', '.bashrc'];
17
+ // run/ 里只有 serve -d 的 pid 文件,属于程序状态
18
+ const PROGRAM_DIRS = ['bin', 'app', 'runtime', '.install', 'serve', 'run'];
19
+ const CONFIG_AND_HISTORY = ['manyoyo.json', 'web-history', 'logs', 'plugin'];
20
+ const MANYOYO_IMAGE_REPOS = ['ghcr.io/xcanwin/manyoyo', 'localhost/xcanwin/manyoyo'];
21
+
22
+ /**
23
+ * 精确移除安装器写入的 PATH 块(含它前面那一个空行)。
24
+ * 只认独占一行的起止标记;用户自己的内容、仅仅提到 manyoyo 的行都不会被动。
25
+ */
26
+ function removeManagedBlock(text) {
27
+ const lines = String(text).split('\n');
28
+ const result = [];
29
+ let index = 0;
30
+ let removed = 0;
31
+ while (index < lines.length) {
32
+ if (lines[index] === BLOCK_START) {
33
+ const end = lines.indexOf(BLOCK_END, index + 1);
34
+ if (end !== -1) {
35
+ if (result.length > 0 && result[result.length - 1] === '') result.pop();
36
+ index = end + 1;
37
+ removed += 1;
38
+ continue;
39
+ }
40
+ }
41
+ result.push(lines[index]);
42
+ index += 1;
43
+ }
44
+ return { text: result.join('\n'), removed };
45
+ }
46
+
47
+ function defaultRun(command, args, options = {}) {
48
+ const result = spawnSync(command, args, { encoding: 'utf-8', timeout: options.timeout || 60000, env: options.env });
49
+ if (result.error) throw result.error;
50
+ if (result.status !== 0) throw new Error(String(result.stderr || result.stdout || '').trim() || `${command} 退出码 ${result.status}`);
51
+ return result.stdout || '';
52
+ }
53
+
54
+ function defaultIsManyoyoServe(pid) {
55
+ try {
56
+ const command = spawnSync('ps', ['-o', 'command=', '-p', String(pid)], { encoding: 'utf-8' }).stdout || '';
57
+ return /manyoyo/.test(command) && /\bserve\b/.test(command);
58
+ } catch (error) {
59
+ return false;
60
+ }
61
+ }
62
+
63
+ function defaultKill(pid) {
64
+ try {
65
+ process.kill(pid, 'SIGTERM');
66
+ return true;
67
+ } catch (error) {
68
+ return false;
69
+ }
70
+ }
71
+
72
+ function readPid(file) {
73
+ try {
74
+ const text = fs.readFileSync(file, 'utf-8');
75
+ const json = text.trim().startsWith('{') ? JSON.parse(text).pid : Number(text.trim());
76
+ return Number.isInteger(json) && json > 0 ? json : 0;
77
+ } catch (error) {
78
+ return 0;
79
+ }
80
+ }
81
+
82
+ function removePath(target) {
83
+ // symlink 只删链接本身,不会顺着删到目标
84
+ fs.rmSync(target, { recursive: true, force: true });
85
+ }
86
+
87
+ const isYes = answer => /^y(es)?$/i.test(String(answer || '').trim());
88
+
89
+ async function runUninstall(options = {}) {
90
+ const homeDir = options.homeDir || os.homedir();
91
+ const root = path.join(homeDir, '.manyoyo');
92
+ const ask = options.ask || (async () => '');
93
+ const log = options.log || (() => {});
94
+ const run = options.run || defaultRun;
95
+ const yes = Boolean(options.yes);
96
+ const isManyoyoServe = options.isManyoyoServe || defaultIsManyoyoServe;
97
+ const isImporter = options.isImporter || isImportProcess;
98
+ const kill = options.kill || defaultKill;
99
+ const selectExternalRuntime = options.selectExternalRuntime || (() => null);
100
+ const summary = { aborted: false, stopped: [], removed: [], kept: [], shellFiles: [], externalRemoved: { containers: [], images: [] } };
101
+
102
+ const privatePodmanRoot = path.join(root, 'runtime', 'podman');
103
+ const hasPrivatePodman = fs.existsSync(path.join(privatePodmanRoot, 'bin', 'podman'));
104
+
105
+ if (!fs.existsSync(root)) {
106
+ log(`没有找到 ${root},无需卸载。`);
107
+ summary.aborted = true;
108
+ return summary;
109
+ }
110
+
111
+ if (!yes) {
112
+ log(`将停止 MANYOYO 的后台服务${hasPrivatePodman ? '与私有 Podman 虚拟机' : ''},并删除:`);
113
+ log(` ${PROGRAM_DIRS.filter(dir => fs.existsSync(path.join(root, dir))).map(dir => `~/.manyoyo/${dir}`).join('、') || '(没有程序目录)'}`);
114
+ log(' shell 配置里由安装器添加的 PATH 块');
115
+ if (hasPrivatePodman) log(' 注意:私有 Podman 里的容器与镜像会一并删除(工作目录里的文件不受影响)。');
116
+ log('你的配置、会话历史、日志与工作目录会在后面逐项询问。');
117
+ if (!isYes(await ask('❔ 确认卸载? [y/N]: '))) {
118
+ log('已取消,什么都没有改动。');
119
+ summary.aborted = true;
120
+ return summary;
121
+ }
122
+ }
123
+
124
+ if (yes && hasPrivatePodman) {
125
+ log('提示:私有 Podman 里的容器与镜像会随虚拟机一起删除;需要保留里面的内容请先取消并导出。');
126
+ }
127
+
128
+ // 1. 停止后台服务(pid 必须确实是 manyoyo serve,避免 pid 复用误杀)
129
+ const pidFiles = [path.join(root, 'serve', 'app.json')];
130
+ const runServeDir = path.join(root, 'run', 'serve');
131
+ if (fs.existsSync(runServeDir)) {
132
+ for (const name of fs.readdirSync(runServeDir)) if (name.endsWith('.pid')) pidFiles.push(path.join(runServeDir, name));
133
+ }
134
+ for (const file of pidFiles) {
135
+ const pid = readPid(file);
136
+ if (pid && pid !== process.pid && isManyoyoServe(pid) && kill(pid)) {
137
+ summary.stopped.push(`serve(pid ${pid})`);
138
+ log(`已停止后台服务 (pid ${pid})`);
139
+ }
140
+ }
141
+ const importPid = readPid(path.join(root, 'runtime', 'import', 'loading.json'));
142
+ if (importPid && importPid !== process.pid && isImporter(importPid) && kill(importPid)) {
143
+ summary.stopped.push(`镜像导入(pid ${importPid})`);
144
+ log(`已停止后台镜像导入 (pid ${importPid})`);
145
+ }
146
+
147
+ // 2. 停止私有 Podman machine(尽力而为;之后整个 runtime/ 会被删除)
148
+ if (hasPrivatePodman) {
149
+ try {
150
+ run(path.join(privatePodmanRoot, 'bin', 'podman'), ['machine', 'stop', MACHINE_NAME], {
151
+ env: {
152
+ ...process.env,
153
+ XDG_CONFIG_HOME: path.join(privatePodmanRoot, 'config'),
154
+ XDG_DATA_HOME: path.join(privatePodmanRoot, 'data'),
155
+ CONTAINERS_CONF: path.join(privatePodmanRoot, 'containers.conf')
156
+ }
157
+ });
158
+ summary.stopped.push(`虚拟机 ${MACHINE_NAME}`);
159
+ log(`已停止虚拟机 ${MACHINE_NAME}`);
160
+ } catch (error) {
161
+ log(`提示:停止虚拟机时出错(${String(error.message).split('\n')[0]}),继续卸载。`);
162
+ }
163
+ }
164
+
165
+ // 3. 复用的外部 Docker/Podman:只处理 manyoyo 的容器与镜像,运行时本身不动
166
+ if (!hasPrivatePodman) {
167
+ const external = selectExternalRuntime();
168
+ if (external) {
169
+ const containers = String(safeRun(run, external.command, ['ps', '-a', '--filter', 'label=manyoyo.default_cmd', '--format', '{{.Names}}'], external)).split('\n').map(s => s.trim()).filter(Boolean);
170
+ const images = String(safeRun(run, external.command, ['images', '--format', '{{.Repository}}:{{.Tag}}'], external)).split('\n').map(s => s.trim())
171
+ .filter(ref => MANYOYO_IMAGE_REPOS.some(repo => ref.startsWith(`${repo}:`)));
172
+ if (containers.length + images.length > 0) {
173
+ log(`在你的 ${external.command} 里发现 ${containers.length} 个 manyoyo 容器、${images.length} 个 manyoyo 镜像。`);
174
+ // --yes 只确认程序本身的卸载;容器里可能有未保存的工作,不在 --yes 范围内
175
+ if (!yes && isYes(await ask('❔ 删除这些容器与镜像(不会动 Docker/Podman 本身)? [y/N]: '))) {
176
+ for (const name of containers) { if (tryRun(run, external.command, ['rm', '-f', name], external)) summary.externalRemoved.containers.push(name); }
177
+ for (const ref of images) { if (tryRun(run, external.command, ['rmi', ref], external)) summary.externalRemoved.images.push(ref); }
178
+ const failed = containers.length + images.length - summary.externalRemoved.containers.length - summary.externalRemoved.images.length;
179
+ log(failed > 0 ? `已删除 ${summary.externalRemoved.containers.length} 个容器、${summary.externalRemoved.images.length} 个镜像,${failed} 项删除失败(仍在使用或已不存在)。` : '已删除 manyoyo 的容器与镜像。');
180
+ } else {
181
+ summary.kept.push('外部运行时里的 manyoyo 容器与镜像');
182
+ }
183
+ }
184
+ }
185
+ }
186
+
187
+ // 4. 删除程序目录
188
+ for (const dir of PROGRAM_DIRS) {
189
+ const target = path.join(root, dir);
190
+ if (fs.existsSync(target) || isDanglingLink(target)) {
191
+ removePath(target);
192
+ summary.removed.push(`~/.manyoyo/${dir}`);
193
+ }
194
+ }
195
+
196
+ // 5. 移除 shell 配置里的 PATH 块
197
+ for (const name of SHELL_FILES) {
198
+ const file = path.join(homeDir, name);
199
+ if (!fs.existsSync(file)) continue;
200
+ const { text, removed } = removeManagedBlock(fs.readFileSync(file, 'utf-8'));
201
+ if (removed > 0) {
202
+ fs.writeFileSync(file, text);
203
+ summary.shellFiles.push(name);
204
+ }
205
+ }
206
+ if (summary.shellFiles.length > 0) log(`已从 ${summary.shellFiles.join('、')} 移除 PATH 块(新开终端后生效)`);
207
+
208
+ // 6. 用户数据:逐项询问,默认保留;--yes 不会删除任何用户数据
209
+ const dataItems = CONFIG_AND_HISTORY.filter(name => fs.existsSync(path.join(root, name)));
210
+ if (dataItems.length > 0) {
211
+ if (!yes && isYes(await ask(`❔ 同时删除配置、会话历史与日志(${dataItems.join('、')})? [y/N]: `))) {
212
+ dataItems.forEach(name => { removePath(path.join(root, name)); summary.removed.push(`~/.manyoyo/${name}`); });
213
+ } else {
214
+ summary.kept.push(...dataItems.map(name => `~/.manyoyo/${name}`));
215
+ }
216
+ }
217
+ // 默认工作目录:work(8.0 起),workpath 是 7.x 快捷对话留下的旧目录;都是用户的项目文件,逐个询问、默认保留
218
+ for (const name of ['work', 'workpath']) {
219
+ const dir = path.join(root, name);
220
+ if (!fs.existsSync(dir) && !isDanglingLink(dir)) continue;
221
+ if (!yes && isYes(await ask(`❔ 删除默认工作目录 ~/.manyoyo/${name}(里面是你的项目文件,默认保留)? [y/N]: `))) {
222
+ removePath(dir);
223
+ summary.removed.push(`~/.manyoyo/${name}`);
224
+ } else {
225
+ summary.kept.push(`~/.manyoyo/${name}`);
226
+ }
227
+ }
228
+ if (yes && summary.kept.length > 0) log('--yes 只确认程序本身的卸载,没有删除任何用户数据。');
229
+
230
+ // 收掉遗留的空目录(如日志按天分的空子目录);还有保留的数据就留着
231
+ pruneEmptyDirs(root);
232
+ try {
233
+ if (fs.readdirSync(root).length === 0) fs.rmdirSync(root);
234
+ } catch (error) {
235
+ // 保留
236
+ }
237
+ if (summary.kept.length > 0) log(`已保留:${summary.kept.join('、')}`);
238
+ log('卸载完成。');
239
+ return summary;
240
+ }
241
+
242
+ // 只删空目录,不碰任何文件和符号链接
243
+ function pruneEmptyDirs(dir) {
244
+ let entries;
245
+ try {
246
+ entries = fs.readdirSync(dir, { withFileTypes: true });
247
+ } catch (error) {
248
+ return;
249
+ }
250
+ for (const entry of entries) {
251
+ if (!entry.isDirectory() || entry.isSymbolicLink()) continue;
252
+ const child = path.join(dir, entry.name);
253
+ pruneEmptyDirs(child);
254
+ try {
255
+ if (fs.readdirSync(child).length === 0) fs.rmdirSync(child);
256
+ } catch (error) {
257
+ // 保留
258
+ }
259
+ }
260
+ }
261
+
262
+ function isDanglingLink(target) {
263
+ try {
264
+ return fs.lstatSync(target).isSymbolicLink();
265
+ } catch (error) {
266
+ return false;
267
+ }
268
+ }
269
+
270
+ function tryRun(run, command, args, external) {
271
+ try {
272
+ run(command, args, { env: external && external.env ? { ...process.env, ...external.env } : undefined });
273
+ return true;
274
+ } catch (error) {
275
+ return false;
276
+ }
277
+ }
278
+
279
+ function safeRun(run, command, args, external) {
280
+ try {
281
+ return run(command, args, { env: external && external.env ? { ...process.env, ...external.env } : undefined });
282
+ } catch (error) {
283
+ return '';
284
+ }
285
+ }
286
+
287
+ module.exports = {
288
+ runUninstall,
289
+ readPid,
290
+ defaultIsManyoyoServe,
291
+ defaultKill,
292
+ removeManagedBlock,
293
+ MACHINE_NAME,
294
+ BLOCK_START,
295
+ BLOCK_END
296
+ };
@@ -0,0 +1,91 @@
1
+ 'use strict';
2
+
3
+ // serve 的“有新版本”检查:每天最多问一次 GitHub Release,结果缓存在 ~/.manyoyo/serve/update-check.json。
4
+ // updateCheck=false 时完全不发请求;请求只带固定的 User-Agent,不附带任何本机信息。
5
+
6
+ const fs = require('fs');
7
+ const os = require('os');
8
+ const path = require('path');
9
+ const { compareVersions } = require('./app-update');
10
+
11
+ const DAY_MS = 24 * 60 * 60 * 1000;
12
+ const FAILURE_RETRY_MS = 60 * 60 * 1000;
13
+
14
+ function cachePath(homeDir) {
15
+ return path.join(homeDir, '.manyoyo', 'serve', 'update-check.json');
16
+ }
17
+
18
+ function readCache(homeDir) {
19
+ try {
20
+ return JSON.parse(fs.readFileSync(cachePath(homeDir), 'utf-8'));
21
+ } catch (error) {
22
+ return {};
23
+ }
24
+ }
25
+
26
+ function writeCache(homeDir, data) {
27
+ try {
28
+ fs.mkdirSync(path.dirname(cachePath(homeDir)), { recursive: true });
29
+ fs.writeFileSync(cachePath(homeDir), `${JSON.stringify(data)}\n`);
30
+ } catch (error) {
31
+ // 缓存写不进去不影响功能
32
+ }
33
+ }
34
+
35
+ /**
36
+ * @param {{homeDir?: string, currentVersion: string, installMode: string, enabled: boolean, fetchLatest: () => Promise<{version: string}>, now?: () => number}} options
37
+ */
38
+ function createUpdateChecker(options) {
39
+ const homeDir = options.homeDir || os.homedir();
40
+ const now = options.now || Date.now;
41
+ const enabled = options.enabled !== false;
42
+ let inflight = null;
43
+
44
+ function snapshot() {
45
+ const cache = readCache(homeDir);
46
+ const latest = typeof cache.latest === 'string' ? cache.latest : '';
47
+ return {
48
+ enabled,
49
+ installMode: options.installMode,
50
+ current: options.currentVersion,
51
+ latest,
52
+ updateAvailable: Boolean(latest) && compareVersions(latest, options.currentVersion) > 0,
53
+ checkedAt: cache.checkedAt || null,
54
+ error: cache.error || ''
55
+ };
56
+ }
57
+
58
+ function isStale(cache) {
59
+ const age = now() - (Number(cache.checkedAtMs) || 0);
60
+ return age >= (cache.error ? FAILURE_RETRY_MS : DAY_MS);
61
+ }
62
+
63
+ function refresh() {
64
+ if (inflight) return inflight;
65
+ inflight = (async () => {
66
+ const previous = readCache(homeDir);
67
+ try {
68
+ const release = await options.fetchLatest();
69
+ writeCache(homeDir, { latest: release.version, checkedAtMs: now(), checkedAt: new Date(now()).toISOString() });
70
+ } catch (error) {
71
+ // 失败也记一次检查时间,别每次打开页面都去敲 GitHub;上一次成功的版本信息保留
72
+ writeCache(homeDir, { latest: previous.latest || '', checkedAtMs: now(), checkedAt: new Date(now()).toISOString(), error: String(error.message || error).slice(0, 200) });
73
+ } finally {
74
+ inflight = null;
75
+ }
76
+ })();
77
+ return inflight;
78
+ }
79
+
80
+ return {
81
+ // 立即返回缓存;缓存过期就在后台刷新,不阻塞请求
82
+ getInfo() {
83
+ if (enabled && isStale(readCache(homeDir))) refresh();
84
+ return snapshot();
85
+ },
86
+ refresh,
87
+ snapshot
88
+ };
89
+ }
90
+
91
+ module.exports = { createUpdateChecker, cachePath, DAY_MS, FAILURE_RETRY_MS };