@xcanwin/manyoyo 7.1.6 → 8.1.0
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 +33 -169
- package/bin/manyoyo.js +604 -231
- package/docker/manyoyo.Dockerfile +10 -4
- package/lib/app-launcher.js +220 -0
- package/lib/app-update.js +324 -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/AGENTS.md +4 -6
- package/lib/web/index.html +213 -0
- package/lib/web/server.js +653 -51
- package/lib/worktrees.js +15 -0
- package/manyoyo.example.json +14 -3
- package/package.json +14 -10
- package/lib/web/frontend/shadcn.html +0 -213
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
|
+
};
|
package/lib/uninstall.js
ADDED
|
@@ -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 };
|
package/lib/web/AGENTS.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# lib/web/ 协作指引
|
|
2
2
|
|
|
3
|
-
根目录 `AGENTS.md` 的补充,仅在改动 Web 服务端时适用。前端在 `frontend
|
|
3
|
+
根目录 `AGENTS.md` 的补充,仅在改动 Web 服务端时适用。前端在 `frontend/`,见该目录的 `AGENTS.md`。
|
|
4
4
|
|
|
5
5
|
## lib/web/server.js(6000+ 行单文件)
|
|
6
6
|
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
- `resolveYoloCommand()` 委托到 `lib/agent-adapters/index.js`,与 `bin/manyoyo.js` 共用同一份映射,无需分别维护。
|
|
10
10
|
- Agent 会话恢复参数:Claude/Gemini → `-r`,Codex → `resume`,OpenCode → `-c`。
|
|
11
11
|
- 会话控制事件:`/agent/stream`、`/agent/stop` 通过 `createWebStreamEmitter()` / `appendWebSessionControlEvent()` 写入 `lib/core/event-store.js`(`FileEventStore`),`GET /api/sessions/:name/audit` 导出该会话的事件与投影。
|
|
12
|
-
- **`/agent/stream` 的 NDJSON 事件协议是跨前后端的契约**,改一处必须同步另一处:服务端 `lib/web/server.js`、前端 `frontend
|
|
12
|
+
- **`/agent/stream` 的 NDJSON 事件协议是跨前后端的契约**,改一处必须同步另一处:服务端 `lib/web/server.js`、前端 `frontend/src/lib/api.ts` 的 `StreamEvent` + `workspace-panel.tsx` 的事件分支
|
|
13
13
|
- `content_chunk`:token 级增量,前端**追加**;`reset: true` 表示换了一条 assistant 消息、从空白重新开始。只发新增片段,不要改成重发累计全文(几千个增量就是 O(n²) 流量)。
|
|
14
14
|
- `content_delta`:每条 assistant 消息落地时下发一次的权威全文,前端**整体覆盖**。
|
|
15
15
|
- `trace`:**不保证带结构化 `traceEvent`**。stderr 行(`emitStderrTraceLine`)和解析不出 JSON 的 stdout 行只有 `text` + `stream`,前端靠 `toTraceEvent()` 兜底成 `kind="output"` 的事件展示。新增只认 `traceEvent` 的分支等于把自定义 CLI、CLI 报错的输出全吞掉。
|
|
@@ -21,11 +21,9 @@
|
|
|
21
21
|
- 空闲保活是 `/agent/stream` 那条心跳规则的同类问题,**两个入口都要守**:终端侧服务端每 `WEB_TERMINAL_PING_INTERVAL_MS`(30s)发 WebSocket ping 帧,连续 `WEB_TERMINAL_MAX_MISSED_PONGS`(3 次,约 90s)无 pong 才判死;前端另发应用层 `ping`(浏览器 JS 发不出 ping 帧,上行需要自己造流量)。判死阈值不要收紧到一个周期:手机切后台会让连接短暂挂起,误杀等于用户的 shell 没了。升级后的 socket 还要 `setTimeout(0)` + `setKeepAlive`,解除 HTTP 侧空闲超时。
|
|
22
22
|
- 新增接口/页面必须走全局认证网关,禁止在业务路由里零散补认证;匿名白名单见根 `AGENTS.md` 的安全约束。
|
|
23
23
|
|
|
24
|
-
## lib/web/
|
|
24
|
+
## lib/web/index.html
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
目录里没有任何被跟踪的文件,而 git 不记录空目录——**全新检出(CI、别人第一次 clone)是没有 `lib/web/frontend/` 这个目录的**。任何往这里写文件的构建步骤都必须自己 `mkdir -p`(见 `frontend-shadcn/scripts/emit-shadcn-html.mjs`),否则本地有目录跑得好好的,CI 上直接 ENOENT。
|
|
26
|
+
前端构建产物(`npm run build:web`,已 `.gitignore`),由 `loadIndexHtml()` 在每次请求时读取,`/` 与登录页 `/auth/login` 共用。服务端不托管其它前端静态资源,别往 `lib/web/` 里放散装 js/css。
|
|
29
27
|
|
|
30
28
|
## 测试
|
|
31
29
|
|