dsh-mobile-plugin 0.1.60 → 0.1.62
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/index.js +47 -31
- package/lib/panel/index.html +1 -1
- package/lib/routes/control-plane.js +126 -15
- package/lib/shell/updater.js +78 -9
- package/lib/update-install.cjs +438 -0
- package/lib/update-registry.cjs +142 -0
- package/package.json +1 -1
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 自己装新版:在 profile 里跑一次它**自己的**包管理器,把本插件换到指定版本。
|
|
5
|
+
*
|
|
6
|
+
* ## 为什么不借道 DSH 插件市场
|
|
7
|
+
*
|
|
8
|
+
* 插件的更新分两步:**查**(线上最新是几,见 `update-registry.cjs`)和**装**(把新版本真的
|
|
9
|
+
* 落到这台机器的 profile 里)。2026-10-11 之前「装」是请市场代劳的(打它的
|
|
10
|
+
* `/dsh-market/api/v1/updates`,等它跑完 pnpm 再问结果)。用户的判断是:这件事插件应该
|
|
11
|
+
* 自己完成,不该因为「另一个插件没装 / 太老 / 坏了」就整个失效。这里就是那一步。
|
|
12
|
+
*
|
|
13
|
+
* ## 关键:不自己实现包管理,只**驱动**它
|
|
14
|
+
*
|
|
15
|
+
* profile 里装插件用的是 pnpm,而且 profile 自己的 `pnpm-workspace.yaml` 已经写好了所有
|
|
16
|
+
* 关键设置(`node-linker=hoisted`、`auto-install-peers=false`、`minimum-release-age-exclude`
|
|
17
|
+
* 那些按包放行的条目)。所以这里是「cd 到 profile,跑一句 `pnpm add <包名>@<版本>`」——
|
|
18
|
+
* **不传布局类开关**,让它按 profile 的既有配置来,免得把别人的布局改写掉。市场那套
|
|
19
|
+
* (`pnpm-compat.js` + `install.js`)几千行都在处理 pnpm 各版本的旗标拼写、hoist 恢复、
|
|
20
|
+
* 镜像/超时/Windows shim 这些边角;这里刻意**只做我们自己这条路上必须的那两三个开关**,
|
|
21
|
+
* 剩下的交给 profile 自己的配置。
|
|
22
|
+
*
|
|
23
|
+
* ## 用哪个包管理器:**宿主给的那个优先**
|
|
24
|
+
*
|
|
25
|
+
* 2026-10-11 真机踩到:这台机器 PATH 上的 pnpm 是 10.x(store v10),而 profile 是**宿主
|
|
26
|
+
* 自带的** pnpm 11.x(store v11)装的,拿 PATH 那个去装直接 `ERR_PNPM_UNEXPECTED_STORE`。
|
|
27
|
+
* 所以顺序是「宿主发布的调用方式 → PATH 上的 pnpm → npm」,见 `normalizeInvocation`。
|
|
28
|
+
*
|
|
29
|
+
* ## 三个开关,每个都有理由
|
|
30
|
+
*
|
|
31
|
+
* · `--config.auto-install-peers=false`:本插件的 peer 里有一串 `@deepseek-ai/*`
|
|
32
|
+
* (`cordis`、`dsh-tools`、`dsh-client-runtime`…),它们是 **DSH 运行时注入的、npm 上
|
|
33
|
+
* 根本没有**。pnpm 8 起 `auto-install-peers` 默认开,会去 npm 上拉这些 peer 然后 404。
|
|
34
|
+
* profile 里通常已经关掉了,但那是市场帮它写的 —— 换台机器不一定有,所以这里显式带上。
|
|
35
|
+
* · `--config.minimum-release-age=0`:**只在强制更新时**带。pnpm 的「新发布先晾一会儿」
|
|
36
|
+
* 门槛会把刚发的版本挡在外面,`force` 就是「我知道,装它」。
|
|
37
|
+
* · `CI=1`:没有终端可交互。包管理器在无人值守的子进程里问问题是没法回答的。
|
|
38
|
+
*
|
|
39
|
+
* ## 装之前先看有没有 agent 在跑
|
|
40
|
+
*
|
|
41
|
+
* 装是**就地替换插件文件**,正在干活的对话可能读到半个包。市场那条路有一道这样的闸
|
|
42
|
+
* (`AGENTS_RUNNING`),这里也补上了 —— 判到有人跑就**不动盘**,如实报成「可再试」。
|
|
43
|
+
* `force`(面板上的「强制更新」)是用户明说「我知道,装吧」的那一挡,会跳过它。
|
|
44
|
+
*
|
|
45
|
+
* ## 边界(照实说)
|
|
46
|
+
*
|
|
47
|
+
* · **只动 profile 的依赖树**(`pnpm add` 会顺手把 profile 的 `package.json` 里的依赖声明
|
|
48
|
+
* 也更新掉——那正是「装上了」的凭据)。不碰仓库、不碰 `~/.dsh` 里别的东西。
|
|
49
|
+
* · **找不到包管理器就如实失败**,不猜、不回退到「假装装好了」。桌面端由 GUI 拉起时 PATH
|
|
50
|
+
* 往往很干净(LaunchServices 只给 `/usr/bin:/bin` 之类),所以这里照市场的做法补一组
|
|
51
|
+
* 常见 bin 目录再找,见 `toolSearchDirs`。
|
|
52
|
+
* · 装完**以盘上的版本号为准**:`installedVersion` 没变就是没装上,哪怕命令退出码是 0。
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
const fs = require('node:fs');
|
|
56
|
+
const os = require('node:os');
|
|
57
|
+
const path = require('node:path');
|
|
58
|
+
const { spawn } = require('node:child_process');
|
|
59
|
+
|
|
60
|
+
const { checkForUpdate } = require('./update-registry.cjs');
|
|
61
|
+
|
|
62
|
+
/** 本插件的 npm 包名(`package.json` 的 name)。 */
|
|
63
|
+
const DEFAULT_NAME = 'dsh-mobile-plugin';
|
|
64
|
+
|
|
65
|
+
/** 一次安装的上限:pnpm 装一个包 + 拉依赖,给足但别无限等。 */
|
|
66
|
+
const INSTALL_TIMEOUT_MS = 10 * 60 * 1000;
|
|
67
|
+
|
|
68
|
+
/** 命令输出留多少字符(日志与错误信息里给个人看得懂的尾巴)。 */
|
|
69
|
+
const OUTPUT_TAIL = 4000;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 去哪儿找包管理器。
|
|
73
|
+
*
|
|
74
|
+
* 与市场那份(dshmarket 的 `dsh-cli.js` `toolSearchDirs`)**故意保持一致**:桌面端由 GUI
|
|
75
|
+
* 或 Dock 拉起时继承不到 shell 的 PATH,pnpm 常常不在其中,而这一组目录覆盖了官方安装器
|
|
76
|
+
* 与 Homebrew / npm -g 的落点。`PNPM_HOME` 放最前面:安装器设了它,那是最准的答案。
|
|
77
|
+
*/
|
|
78
|
+
function toolSearchDirs({ platform = process.platform, env = process.env, home = os.homedir(), execDir = path.dirname(process.execPath) } = {}) {
|
|
79
|
+
const dirs = [];
|
|
80
|
+
const pnpmHome = typeof env.PNPM_HOME === 'string' ? env.PNPM_HOME.trim() : '';
|
|
81
|
+
if (pnpmHome) dirs.push(pnpmHome);
|
|
82
|
+
if (platform === 'win32') {
|
|
83
|
+
const local = typeof env.LOCALAPPDATA === 'string' ? env.LOCALAPPDATA.trim() : '';
|
|
84
|
+
const roaming = typeof env.APPDATA === 'string' ? env.APPDATA.trim() : '';
|
|
85
|
+
if (local) dirs.push(path.join(local, 'pnpm'));
|
|
86
|
+
if (roaming) dirs.push(path.join(roaming, 'npm'));
|
|
87
|
+
} else {
|
|
88
|
+
dirs.push('/opt/homebrew/bin', '/usr/local/bin', path.join(home, '.local', 'bin'));
|
|
89
|
+
dirs.push(path.join(home, 'Library', 'pnpm'), path.join(home, '.local', 'share', 'pnpm'));
|
|
90
|
+
dirs.push(path.join(home, '.npm-global', 'bin'));
|
|
91
|
+
}
|
|
92
|
+
dirs.push(execDir);
|
|
93
|
+
return [...new Set(dirs.filter(dir => typeof dir === 'string' && dir.trim() !== ''))];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** 找一个能跑的可执行文件:先给的那几组目录,再 PATH。Windows 上认 `.cmd` 后缀。 */
|
|
97
|
+
function resolveExecutable(command, dirs, { platform = process.platform, env = process.env } = {}) {
|
|
98
|
+
const names = platform === 'win32' ? [`${command}.cmd`, `${command}.exe`, command] : [command];
|
|
99
|
+
const fromPath = String(env.PATH || '')
|
|
100
|
+
.split(path.delimiter)
|
|
101
|
+
.filter(Boolean);
|
|
102
|
+
for (const dir of [...dirs, ...fromPath]) {
|
|
103
|
+
for (const name of names) {
|
|
104
|
+
const candidate = path.join(dir, name);
|
|
105
|
+
try {
|
|
106
|
+
const stat = fs.statSync(candidate);
|
|
107
|
+
if (stat.isFile()) return candidate;
|
|
108
|
+
} catch {
|
|
109
|
+
// 不存在就下一个。
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** 把补出来的目录接在 PATH 前面(子进程里的 pnpm 还要再 spawn 它自己的东西)。 */
|
|
117
|
+
function spawnEnv(dirs, env = process.env) {
|
|
118
|
+
const existing = String(env.PATH || '').split(path.delimiter).filter(Boolean);
|
|
119
|
+
return { ...env, PATH: [...dirs, ...existing].join(path.delimiter), CI: env.CI || '1' };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** profile 里装着的那份插件包的版本号(读不到返回 null)。 */
|
|
123
|
+
function installedVersion(profileNodeModules, name = DEFAULT_NAME) {
|
|
124
|
+
try {
|
|
125
|
+
const raw = JSON.parse(fs.readFileSync(path.join(profileNodeModules, name, 'package.json'), 'utf8'));
|
|
126
|
+
return typeof raw.version === 'string' && raw.version ? raw.version : null;
|
|
127
|
+
} catch {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* profile 的依赖声明里这个包是怎么装的(`profileDir` 是 profile 根,不是它的 node_modules)。
|
|
134
|
+
*
|
|
135
|
+
* 只有 `link:` 要拦:那是**故意**把插件指向某个本地目录(开发时),装一次 npm 版会把这个
|
|
136
|
+
* 指向悄悄改掉。`file:` 的 tgz 与普通版号都照装(前者会一并换成注册表来源,这是想要的)。
|
|
137
|
+
* 读不到就说不知道(null),不要瞎猜。
|
|
138
|
+
*/
|
|
139
|
+
function declaredSource(profileDir, name = DEFAULT_NAME) {
|
|
140
|
+
try {
|
|
141
|
+
const raw = JSON.parse(fs.readFileSync(path.join(profileDir, 'package.json'), 'utf8'));
|
|
142
|
+
const spec = raw?.dependencies?.[name] ?? raw?.devDependencies?.[name];
|
|
143
|
+
if (typeof spec !== 'string') return null;
|
|
144
|
+
if (spec.startsWith('link:')) return 'linked';
|
|
145
|
+
if (spec.startsWith('file:')) return 'file';
|
|
146
|
+
if (spec.startsWith('workspace:')) return 'workspace';
|
|
147
|
+
return 'npm';
|
|
148
|
+
} catch {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* 宿主**发布出来**的包管理器调用方式(DSH 桌面版把它挂在 `profileContext.packageManager`
|
|
155
|
+
* 上,形状 `{command, args, env?}`)。
|
|
156
|
+
*
|
|
157
|
+
* 为什么非得用它、而不是 PATH 上那个 pnpm:桌面版由 GUI 拉起时 PATH 很干净,而且**版本
|
|
158
|
+
* 可能对不上** —— 实测这台机器上 PATH 的 pnpm 是 10.x(store v10),而 profile 是宿主自带的
|
|
159
|
+
* pnpm 11.x(store v11)装的,用它装会直接 `ERR_PNPM_UNEXPECTED_STORE`。市场那边也是
|
|
160
|
+
* 「宿主给的那个优先,找不到才退回 PATH 和 corepack」(`dsh-cli.js` 的 `probePnpm`)。
|
|
161
|
+
*
|
|
162
|
+
* 形状有一格不对就整个丢掉(半份调用等于仍然跑不起来的工具);`env` 只收字符串值。
|
|
163
|
+
*/
|
|
164
|
+
function normalizeInvocation(published) {
|
|
165
|
+
if (published === null || typeof published !== 'object' || Array.isArray(published)) return null;
|
|
166
|
+
const { command, args, env } = published;
|
|
167
|
+
if (typeof command !== 'string' || command.trim() === '') return null;
|
|
168
|
+
if (!Array.isArray(args) || args.some(arg => typeof arg !== 'string')) return null;
|
|
169
|
+
const merged = {};
|
|
170
|
+
for (const [key, value] of Object.entries(env ?? {})) {
|
|
171
|
+
if (typeof value === 'string') merged[key] = value;
|
|
172
|
+
}
|
|
173
|
+
return { command: command.trim(), args: [...args], env: merged };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* 此刻正在跑的 agent(`status === 'running'` 的那些)。
|
|
178
|
+
*
|
|
179
|
+
* 判据与市场那份(`dshmarket` 的 `agents.js`)逐字一致:`agents.list()` 里 `status` 正好是
|
|
180
|
+
* `'running'` 的才算,列表读不到或抛错一律当「没有」—— 守卫宁可放过,也不能因为读不到状态
|
|
181
|
+
* 就把更新整个锁死。**不排除调用者自己**:这正是「有对话在跑就别换插件文件」的意思。
|
|
182
|
+
*/
|
|
183
|
+
function runningAgentIds(agents) {
|
|
184
|
+
if (agents === null || agents === undefined) return [];
|
|
185
|
+
let listed;
|
|
186
|
+
try {
|
|
187
|
+
listed = typeof agents.list === 'function' ? agents.list() : null;
|
|
188
|
+
} catch {
|
|
189
|
+
return [];
|
|
190
|
+
}
|
|
191
|
+
if (!Array.isArray(listed)) return [];
|
|
192
|
+
const ids = [];
|
|
193
|
+
for (const agent of listed) {
|
|
194
|
+
if (agent === null || typeof agent !== 'object' || agent.status !== 'running') continue;
|
|
195
|
+
const id = typeof agent.id === 'string' && agent.id !== '' ? agent.id : 'agent';
|
|
196
|
+
if (!ids.includes(id)) ids.push(id);
|
|
197
|
+
}
|
|
198
|
+
return ids;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** 跑一条命令,收 stdout+stderr 的尾巴。超时或非零退出码都如实带出来。 */
|
|
202
|
+
function run(command, args, { cwd, env, timeoutMs, spawnImpl = spawn }) {
|
|
203
|
+
return new Promise(resolve => {
|
|
204
|
+
let child;
|
|
205
|
+
try {
|
|
206
|
+
child = spawnImpl(command, args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
207
|
+
} catch (error) {
|
|
208
|
+
resolve({ ok: false, code: null, output: '', error: String(error?.message || error) });
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
let output = '';
|
|
212
|
+
const collect = chunk => {
|
|
213
|
+
output += String(chunk);
|
|
214
|
+
if (output.length > OUTPUT_TAIL * 2) output = output.slice(-OUTPUT_TAIL);
|
|
215
|
+
};
|
|
216
|
+
child.stdout?.on('data', collect);
|
|
217
|
+
child.stderr?.on('data', collect);
|
|
218
|
+
const timer = setTimeout(() => {
|
|
219
|
+
try {
|
|
220
|
+
child.kill('SIGKILL');
|
|
221
|
+
} catch {
|
|
222
|
+
// 已经退出了。
|
|
223
|
+
}
|
|
224
|
+
}, timeoutMs);
|
|
225
|
+
child.on('error', error => {
|
|
226
|
+
clearTimeout(timer);
|
|
227
|
+
resolve({ ok: false, code: null, output, error: String(error?.message || error) });
|
|
228
|
+
});
|
|
229
|
+
child.on('close', code => {
|
|
230
|
+
clearTimeout(timer);
|
|
231
|
+
resolve({ ok: code === 0, code, output: output.slice(-OUTPUT_TAIL), error: null });
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* 装一次:把 <name> 换到 <version>。
|
|
238
|
+
*
|
|
239
|
+
* @param {object} options
|
|
240
|
+
* @param {string} options.profileNodeModules profile 的 `node_modules` 目录
|
|
241
|
+
* @param {string} options.version 要装的版本(注册表给的)
|
|
242
|
+
* @param {boolean} [options.force] true = 跳过「新发布先晾一会儿」的门槛
|
|
243
|
+
* @returns {Promise<{installed, skipped, beforeVersion, installedVersion, source, command, output, failure}>}
|
|
244
|
+
*/
|
|
245
|
+
async function installLatest({
|
|
246
|
+
profileNodeModules,
|
|
247
|
+
version,
|
|
248
|
+
name = DEFAULT_NAME,
|
|
249
|
+
force = false,
|
|
250
|
+
timeoutMs = INSTALL_TIMEOUT_MS,
|
|
251
|
+
env = process.env,
|
|
252
|
+
platform = process.platform,
|
|
253
|
+
// 去哪儿找包管理器。默认那组见 `toolSearchDirs`;注入只为测试。
|
|
254
|
+
dirs = null,
|
|
255
|
+
// 宿主发布的调用方式(`profileContext.packageManager`)。**优先用它**,理由见
|
|
256
|
+
// `normalizeInvocation` 的文件注释(版本对不上会把 profile 的 store 认错)。
|
|
257
|
+
packageManager = null,
|
|
258
|
+
onEvent = () => {},
|
|
259
|
+
} = {}) {
|
|
260
|
+
const beforeVersion = installedVersion(profileNodeModules, name);
|
|
261
|
+
const profileDir = path.dirname(profileNodeModules);
|
|
262
|
+
const searchDirs = dirs ?? toolSearchDirs({ platform, env });
|
|
263
|
+
const hostInvocation = normalizeInvocation(packageManager);
|
|
264
|
+
const childEnv = spawnEnv(searchDirs, env);
|
|
265
|
+
|
|
266
|
+
const fail = (code, message) => ({
|
|
267
|
+
installed: false,
|
|
268
|
+
skipped: false,
|
|
269
|
+
beforeVersion,
|
|
270
|
+
installedVersion: installedVersion(profileNodeModules, name),
|
|
271
|
+
source: declaredSource(profileDir, name),
|
|
272
|
+
command: null,
|
|
273
|
+
output: '',
|
|
274
|
+
failure: { code, message },
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* 挑一个能用的包管理器:**宿主给的那个先试**(探一次 `--version`,跑不起来就当没有),
|
|
279
|
+
* 之后才是 PATH 上的 pnpm / npm。返回 `{command, prefix, manager, kind}`。
|
|
280
|
+
*/
|
|
281
|
+
async function pickManager() {
|
|
282
|
+
if (hostInvocation) {
|
|
283
|
+
const probe = await run(hostInvocation.command, [...hostInvocation.args, '--version'], {
|
|
284
|
+
cwd: profileDir,
|
|
285
|
+
env: { ...childEnv, ...hostInvocation.env },
|
|
286
|
+
timeoutMs: 20000,
|
|
287
|
+
});
|
|
288
|
+
if (probe.ok) {
|
|
289
|
+
return { command: hostInvocation.command, prefix: hostInvocation.args, manager: 'pnpm', kind: 'host', env: hostInvocation.env };
|
|
290
|
+
}
|
|
291
|
+
onEvent({ phase: 'host-manager-unusable', output: probe.output });
|
|
292
|
+
}
|
|
293
|
+
const pnpm = resolveExecutable('pnpm', searchDirs, { platform, env });
|
|
294
|
+
if (pnpm) return { command: pnpm, prefix: [], manager: 'pnpm', kind: 'path', env: {} };
|
|
295
|
+
const npm = resolveExecutable('npm', searchDirs, { platform, env });
|
|
296
|
+
if (npm) return { command: npm, prefix: [], manager: 'npm', kind: 'path', env: {} };
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const picked = await pickManager();
|
|
301
|
+
if (!picked) {
|
|
302
|
+
return fail(
|
|
303
|
+
'PACKAGE_MANAGER_MISSING',
|
|
304
|
+
'这台机器上找不到能用的包管理器(宿主没发布、PATH 上也没有 pnpm / npm),装不了新版。' +
|
|
305
|
+
'装一个 pnpm(或 npm)之后重试;插件不会自己去下载解包 —— 那等于把包管理器的活抄一遍,' +
|
|
306
|
+
'抄漏一处就是装出半个包。',
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const source = declaredSource(profileDir, name);
|
|
311
|
+
if (source === 'linked') {
|
|
312
|
+
return fail(
|
|
313
|
+
'LINKED_SOURCE',
|
|
314
|
+
'这个插件在 profile 里指向一个本地目录(link:),是有意为之。自己装会把那个指向改掉,' +
|
|
315
|
+
'所以这里不动它:要去掉这个指向,先在插件市场里按包名重装一次。',
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const spec = `${name}@${version}`;
|
|
320
|
+
const args = picked.manager === 'pnpm'
|
|
321
|
+
? ['add', spec, '--config.auto-install-peers=false', ...(force ? ['--config.minimum-release-age=0'] : [])]
|
|
322
|
+
: ['install', spec, '--no-audit', '--no-fund'];
|
|
323
|
+
const command = [picked.manager, ...args].join(' ');
|
|
324
|
+
onEvent({ phase: 'install-started', command, manager: picked.kind });
|
|
325
|
+
|
|
326
|
+
const result = await run(picked.command, [...picked.prefix, ...args], {
|
|
327
|
+
cwd: profileDir,
|
|
328
|
+
env: { ...childEnv, ...picked.env },
|
|
329
|
+
timeoutMs,
|
|
330
|
+
});
|
|
331
|
+
const after = installedVersion(profileNodeModules, name);
|
|
332
|
+
if (!result.ok) {
|
|
333
|
+
return {
|
|
334
|
+
installed: false,
|
|
335
|
+
skipped: false,
|
|
336
|
+
beforeVersion,
|
|
337
|
+
installedVersion: after,
|
|
338
|
+
source: declaredSource(profileDir, name),
|
|
339
|
+
command,
|
|
340
|
+
manager: picked.kind,
|
|
341
|
+
output: result.output,
|
|
342
|
+
// 超时与「真的失败了」分开说:前者可以再试,后者多半要看输出。
|
|
343
|
+
failure: {
|
|
344
|
+
code: result.code === null ? 'INSTALL_ABORTED' : 'INSTALL_FAILED',
|
|
345
|
+
message: `${command} ${result.code === null ? '没能跑完' : `退出码 ${result.code}`}${result.error ? `:${result.error}` : ''}`,
|
|
346
|
+
},
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
// 退出码 0 不算数:**盘上版本真的变了**才算装上(市场那份代码吃过这个亏)。
|
|
350
|
+
const changed = after !== null && after !== beforeVersion;
|
|
351
|
+
return {
|
|
352
|
+
installed: changed,
|
|
353
|
+
skipped: false,
|
|
354
|
+
beforeVersion,
|
|
355
|
+
installedVersion: after,
|
|
356
|
+
source: declaredSource(profileDir, name),
|
|
357
|
+
command,
|
|
358
|
+
manager: picked.kind,
|
|
359
|
+
output: result.output,
|
|
360
|
+
failure: changed
|
|
361
|
+
? null
|
|
362
|
+
: { code: 'VERSION_UNCHANGED', message: `${command} 跑完了,但盘上还是 ${after ?? '读不到版本'},这一次不算数` },
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* 「让我变成最新」:先看有没有人在干活,再查一次,最后决定装不装。
|
|
368
|
+
*
|
|
369
|
+
* 面板那颗按钮**与外壳的自动更新**都打这个入口 —— 两处走同一套判定,免得出现「手动说有
|
|
370
|
+
* 新版、自动说没有」这种最难查的偏差。已经是最新(`force` 为假)就什么都不做、如实报
|
|
371
|
+
* `skipped`。
|
|
372
|
+
*
|
|
373
|
+
* ## 为什么装之前先看有没有 agent 在跑
|
|
374
|
+
*
|
|
375
|
+
* 装会**就地换掉插件文件**(profile 里那一份),而正在干活的对话可能正好在装配/读取插件
|
|
376
|
+
* 目录 —— 换到一半就是「读到半个包」。市场那条路也有同一道闸(`AGENTS_RUNNING`),这条是
|
|
377
|
+
* 把它搬到插件自己这条路上来。判到有人跑就**不动盘**,如实报成「可再试」,下个 tick 再来。
|
|
378
|
+
*
|
|
379
|
+
* `force` 是**用户明说「我知道,装吧」**的那一挡(面板上那颗「强制更新」):它同时跳过
|
|
380
|
+
* 这道闸和「新发布先晾一会儿」的门槛。自动更新永远不带 force。
|
|
381
|
+
*/
|
|
382
|
+
async function installLatestIfNeeded(options = {}) {
|
|
383
|
+
const { profileNodeModules, force = false, name = DEFAULT_NAME, agents = null } = options;
|
|
384
|
+
const beforeVersion = installedVersion(profileNodeModules, name);
|
|
385
|
+
if (!force) {
|
|
386
|
+
const running = runningAgentIds(agents);
|
|
387
|
+
if (running.length > 0) {
|
|
388
|
+
return {
|
|
389
|
+
installed: false,
|
|
390
|
+
skipped: false,
|
|
391
|
+
beforeVersion,
|
|
392
|
+
installedVersion: beforeVersion,
|
|
393
|
+
source: declaredSource(path.dirname(profileNodeModules), name),
|
|
394
|
+
command: null,
|
|
395
|
+
output: '',
|
|
396
|
+
runningAgents: running,
|
|
397
|
+
retryable: true,
|
|
398
|
+
failure: {
|
|
399
|
+
code: 'AGENTS_RUNNING',
|
|
400
|
+
message:
|
|
401
|
+
`有 ${running.length} 个 agent 正在运行(${running.slice(0, 4).join(', ')})。` +
|
|
402
|
+
'装新版会就地替换插件文件,正在干活的对话可能读到半个包;等它完成(或先取消)再更新。' +
|
|
403
|
+
'确定要现在装的话,点面板上的「强制更新」。',
|
|
404
|
+
},
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
const check = await checkForUpdate({ installedVersion: beforeVersion, source: declaredSource(path.dirname(profileNodeModules), name) });
|
|
409
|
+
if (!force && !check.updateAvailable) {
|
|
410
|
+
return {
|
|
411
|
+
installed: false,
|
|
412
|
+
skipped: true,
|
|
413
|
+
beforeVersion,
|
|
414
|
+
installedVersion: beforeVersion,
|
|
415
|
+
source: check.source,
|
|
416
|
+
command: null,
|
|
417
|
+
output: '',
|
|
418
|
+
failure: null,
|
|
419
|
+
check,
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
const result = await installLatest({ ...options, version: check.latestVersion });
|
|
423
|
+
return { ...result, check };
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
module.exports = {
|
|
427
|
+
installLatest,
|
|
428
|
+
installLatestIfNeeded,
|
|
429
|
+
installedVersion,
|
|
430
|
+
declaredSource,
|
|
431
|
+
toolSearchDirs,
|
|
432
|
+
resolveExecutable,
|
|
433
|
+
normalizeInvocation,
|
|
434
|
+
runningAgentIds,
|
|
435
|
+
DEFAULT_NAME,
|
|
436
|
+
INSTALL_TIMEOUT_MS,
|
|
437
|
+
OUTPUT_TAIL,
|
|
438
|
+
};
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 「有没有新版」由插件自己回答:直接问 npm 注册表(`<registry>/<name>/latest`)。
|
|
5
|
+
*
|
|
6
|
+
* ## 为什么不再借道 DSH 插件市场
|
|
7
|
+
*
|
|
8
|
+
* 更新其实分两件事:**查**(线上最新是几、这台机器上是几)和**装**(在 profile 里跑依赖
|
|
9
|
+
* 安装、再把运行中的实现换掉)。装只能交给市场 —— 它握着 profile 的依赖树、版本核验与
|
|
10
|
+
* 回滚点,插件自己再 shell 出去装一遍等于把那些抄一遍,而抄漏一处就是「装了个不是最新
|
|
11
|
+
* 的版本」。但**查**不需要市场:这是个公开的 npm 包,一个 GET 就够了。
|
|
12
|
+
*
|
|
13
|
+
* 绕市场的代价很具体:市场(另一个插件)没装、太老或者坏了,「检查更新」就整个答不出来
|
|
14
|
+
* —— 一个插件得另一个插件在场,才能告诉你自己有没有新版。所以这一格拿回来自己做。
|
|
15
|
+
*
|
|
16
|
+
* ## 边界(别把这条读成「更新彻底不依赖市场了」)
|
|
17
|
+
*
|
|
18
|
+
* · 这里**只查不装**:从头到尾不碰 profile、不碰市场、不写盘。装仍然是
|
|
19
|
+
* `/api/relay/update/apply` 那条路(外壳问市场 → 市场装 → 外壳就地换代)。
|
|
20
|
+
* · `source`(npm / 本地 link / `file:` tgz)只有市场知道 —— 它读的是 profile 里的依赖
|
|
21
|
+
* 声明。所以那一格传进来的是外壳**上次**问市场的结果;市场不在时如实为空,本模块
|
|
22
|
+
* **不会**为了这一格去敲市场。
|
|
23
|
+
* · 判据是「线上版本严格大于盘上版本」。线上比盘上旧(回滚、本地构建跑在发布前面)一律
|
|
24
|
+
* 报「没有新版」——「这次更新会降级」必须由版本比较判定,不能由「两个号不相等」判定。
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** 默认注册表:官方源。私有镜像 / 测试用 `DSHM_NPM_REGISTRY` 覆盖。 */
|
|
28
|
+
const DEFAULT_REGISTRY = 'https://registry.npmjs.org';
|
|
29
|
+
|
|
30
|
+
/** 本插件的 npm 包名(`package.json` 的 name)。 */
|
|
31
|
+
const DEFAULT_NAME = 'dsh-mobile-plugin';
|
|
32
|
+
|
|
33
|
+
/** 一次查询的上限。查更新是背景动作,宁可如实报「连不上」也不要挂住面板。 */
|
|
34
|
+
const REQUEST_TIMEOUT_MS = 8000;
|
|
35
|
+
|
|
36
|
+
/** 注册表地址(去掉末尾斜杠;空串当没设)。 */
|
|
37
|
+
function registryBase(env = process.env) {
|
|
38
|
+
const value = env && typeof env.DSHM_NPM_REGISTRY === 'string' ? env.DSHM_NPM_REGISTRY.trim() : '';
|
|
39
|
+
return value ? value.replace(/\/+$/, '') : DEFAULT_REGISTRY;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 版本号比较:只认 `x.y.z` 的三段数字,返回正数表示 `a` 比 `b` 新。
|
|
44
|
+
*
|
|
45
|
+
* 与外壳那份(`lib/shell/manager.js` 的 `compareVersion`)**刻意各留一份**:代不许 require
|
|
46
|
+
* 外壳文件(外壳在进程启动时被缓存,代去读它会拿到另一份模块实例,而且换代清 CJS 缓存时
|
|
47
|
+
* 会被连着清掉)。两份都是十几行、语义必须一致:预发布后缀(`-rc.1`)按主段比,缺段补 0。
|
|
48
|
+
*/
|
|
49
|
+
function compareVersion(a, b) {
|
|
50
|
+
const left = String(a || '0').split('-')[0].split('.').map(Number);
|
|
51
|
+
const right = String(b || '0').split('-')[0].split('.').map(Number);
|
|
52
|
+
for (let i = 0; i < 3; i += 1) {
|
|
53
|
+
const l = Number.isFinite(left[i]) ? left[i] : 0;
|
|
54
|
+
const r = Number.isFinite(right[i]) ? right[i] : 0;
|
|
55
|
+
if (l !== r) return l - r;
|
|
56
|
+
}
|
|
57
|
+
return 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** 注册表的错误:带上一个稳定 code,路由与面板照着说人话。 */
|
|
61
|
+
class RegistryError extends Error {
|
|
62
|
+
constructor(code, message) {
|
|
63
|
+
super(message);
|
|
64
|
+
this.name = 'RegistryError';
|
|
65
|
+
this.code = code;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 问一次「线上最新是几」。
|
|
71
|
+
*
|
|
72
|
+
* `fetchImpl` 注入只为测试(真跑时用全局 fetch);`timeoutMs` 同理。
|
|
73
|
+
*/
|
|
74
|
+
async function latestVersion({ base, name = DEFAULT_NAME, fetchImpl, timeoutMs = REQUEST_TIMEOUT_MS } = {}) {
|
|
75
|
+
const doFetch = fetchImpl || globalThis.fetch;
|
|
76
|
+
if (typeof doFetch !== 'function') {
|
|
77
|
+
throw new RegistryError('registry-unavailable', '这个运行时里没有 fetch,问不了 npm 注册表');
|
|
78
|
+
}
|
|
79
|
+
const url = `${base || registryBase()}/${name}/latest`;
|
|
80
|
+
const controller = typeof AbortController === 'function' ? new AbortController() : null;
|
|
81
|
+
const timer = controller ? setTimeout(() => controller.abort(), timeoutMs) : null;
|
|
82
|
+
let response;
|
|
83
|
+
try {
|
|
84
|
+
response = await doFetch(url, { headers: { accept: 'application/json' }, signal: controller ? controller.signal : undefined });
|
|
85
|
+
} catch (error) {
|
|
86
|
+
throw new RegistryError('registry-unreachable', `连不上 npm 注册表:${error && error.message ? error.message : error}`);
|
|
87
|
+
} finally {
|
|
88
|
+
if (timer) clearTimeout(timer);
|
|
89
|
+
}
|
|
90
|
+
if (!response || response.ok !== true) {
|
|
91
|
+
const status = response && Number.isFinite(response.status) ? response.status : 0;
|
|
92
|
+
if (status === 404) throw new RegistryError('registry-not-found', `npm 上找不到 ${name}(404)`);
|
|
93
|
+
throw new RegistryError(`registry-http-${status || 'unknown'}`, `npm 注册表返回 HTTP ${status || '未知'}`);
|
|
94
|
+
}
|
|
95
|
+
let body = null;
|
|
96
|
+
try {
|
|
97
|
+
body = await response.json();
|
|
98
|
+
} catch {
|
|
99
|
+
body = null;
|
|
100
|
+
}
|
|
101
|
+
const version = body && typeof body.version === 'string' ? body.version.trim() : '';
|
|
102
|
+
if (!version) throw new RegistryError('registry-bad-response', 'npm 注册表没有给出可读的版本号');
|
|
103
|
+
return version;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* 查一次更新。
|
|
108
|
+
*
|
|
109
|
+
* `installedVersion` 由调用方给(外壳的「盘上版本」,没有外壳时是这一代自己的版本号);
|
|
110
|
+
* 给不出来时如实返回 `installedVersion: null` + `updateAvailable: false` —— 比不出来就
|
|
111
|
+
* 不喊「有新版」,免得把用户引去装一次什么都没变的更新。
|
|
112
|
+
*/
|
|
113
|
+
async function checkForUpdate({
|
|
114
|
+
installedVersion = null,
|
|
115
|
+
source = null,
|
|
116
|
+
base,
|
|
117
|
+
name = DEFAULT_NAME,
|
|
118
|
+
fetchImpl,
|
|
119
|
+
timeoutMs = REQUEST_TIMEOUT_MS,
|
|
120
|
+
now = Date.now,
|
|
121
|
+
} = {}) {
|
|
122
|
+
const latest = await latestVersion({ base, name, fetchImpl, timeoutMs });
|
|
123
|
+
const installed = typeof installedVersion === 'string' && installedVersion ? installedVersion : null;
|
|
124
|
+
return {
|
|
125
|
+
installedVersion: installed,
|
|
126
|
+
latestVersion: latest,
|
|
127
|
+
updateAvailable: installed !== null && compareVersion(latest, installed) > 0,
|
|
128
|
+
source: typeof source === 'string' && source ? source : null,
|
|
129
|
+
checkedAt: new Date(now()).toISOString(),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
module.exports = {
|
|
134
|
+
checkForUpdate,
|
|
135
|
+
compareVersion,
|
|
136
|
+
registryBase,
|
|
137
|
+
latestVersion,
|
|
138
|
+
RegistryError,
|
|
139
|
+
DEFAULT_REGISTRY,
|
|
140
|
+
DEFAULT_NAME,
|
|
141
|
+
REQUEST_TIMEOUT_MS,
|
|
142
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-mobile-plugin",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.62",
|
|
4
4
|
"description": "Mobile API (/api/mobile/v1) and relay client for the DSH mobile app — desktop profile build (0.2.0-rc.2 compatible, peers resolved by the host)",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|