@mrrisega/dsh-remote 0.8.0 → 0.8.1-beta.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/package.json +1 -1
- package/packages/dsh-remote-web/lib/client.js +25 -1
- package/packages/dsh-remote-web/lib/index.js +130 -4
- package/packages/dsh-remote-web/lib/live-update.js +238 -0
- package/packages/dsh-remote-web/lib/self-update.js +1204 -0
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/apply-pending-plugin.mjs +171 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dingtalk-bot.mjs +34 -31
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/feishu-bot.mjs +26 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/im-format.mjs +105 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/wecom-bot.mjs +54 -100
- package/packages/dsh-remote-web/runtime/dsh-setup.mjs +1 -1
- package/packages/dsh-remote-web/test/live-update.test.mjs +114 -0
- package/packages/dsh-remote-web/test/self-update.test.mjs +920 -0
|
@@ -0,0 +1,1204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 自升级核心模块(ESM,零第三方依赖)。
|
|
3
|
+
*
|
|
4
|
+
* 目标:把「把 npm 上更新的插件装进当前 profile 的 dsh-remote-web-plugin 目录」这件事
|
|
5
|
+
* 收敛成一个**可单测、可回滚、不打网络赌**的纯函数集合。面板只负责:选版本 → 调这里 →
|
|
6
|
+
* 提示用户 → apply → 让宿主重载。
|
|
7
|
+
*
|
|
8
|
+
* 为什么默认走 npmmirror:
|
|
9
|
+
* 生产遥测里反复出现 `npm_unreachable`(用户网络打不开 registry.npmjs.org,尤其是国内
|
|
10
|
+
* 移动网络/公司网),而插件过去的一键更新只认官方源 —— 于是"有新版但装不上"。
|
|
11
|
+
* 这里默认 `registry.npmmirror.com` 优先、官方源兜底,并允许 `DSH_REMOTE_REGISTRY`
|
|
12
|
+
* 用逗号分隔覆盖顺序(自建 registry 也能接)。
|
|
13
|
+
*
|
|
14
|
+
* 为什么不在本模块里联网校验"最新版":resolve 与下载是同一份 packument(`GET <registry>/<name>/<tag>`),
|
|
15
|
+
* 一次请求同时拿到版本号 + tarball + integrity,少一次往返、也少一个"版本号与包不一致"的窗口。
|
|
16
|
+
*
|
|
17
|
+
* 安全红线(与 tarball-update.js 同级,不放松):
|
|
18
|
+
* · tar 条目含 `..` 段、绝对路径、或解析后不在目标目录内 → 拒绝(BAD_TARBALL);
|
|
19
|
+
* · 符号链接 / 硬链接 / 设备 / FIFO → **直接拒绝**(比 tarball-update.js 的"跳过"更严,
|
|
20
|
+
* 因为升级路径上宁可整包失败也不要留一个"内容不全但看起来装好了"的目录);
|
|
21
|
+
* · 条目必须位于 `package/` 前缀下(npm tarball 的固定布局),否则拒绝 ——
|
|
22
|
+
* 否则 stripPrefix 失效时会把文件写到 staging 根目录;
|
|
23
|
+
* · integrity(sha512 等)先校验再解包;`dist.shasum`(sha1)作为兜底;
|
|
24
|
+
* · 下载有超时与大小上限(默认 64MB),边下边判,防止坏镜像把内存打爆。
|
|
25
|
+
*
|
|
26
|
+
* 原子性:apply 只用 rename(同盘时是原子的):先把旧目录整段 rename 成 `<target>.bak-<stamp>`,
|
|
27
|
+
* 再把 staging 里的 `package/` rename 到目标位置;任何一步失败都自动回滚(把 bak 换回去)。
|
|
28
|
+
*
|
|
29
|
+
* 约定:**失败一律 throw**,错误对象带稳定 `code`(见 ERROR_CODES);成功返回 `{ok:true, ...}`。
|
|
30
|
+
* 这样调用方(面板)可以用 `err.code` 直接决定文案,而不是去 match 错误字符串。
|
|
31
|
+
*/
|
|
32
|
+
import crypto from "node:crypto";
|
|
33
|
+
import fs from "node:fs";
|
|
34
|
+
import path from "node:path";
|
|
35
|
+
|
|
36
|
+
import { extractTarGz, readTarEntries } from "./tarball-update.js";
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// 常量
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* registry 上的包名:单一来源,installer / 面板 / doctor 都靠它对齐。
|
|
44
|
+
* 注意这是**仓库根包**(发布产物里含 dsh-setup.mjs 与 packages/…)。
|
|
45
|
+
*/
|
|
46
|
+
export const PACKAGE_NAME = "@mrrisega/dsh-remote";
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* profile 里真正被替换的那个包(`~/.dsh/profiles/<p>/dsh-remote-web-plugin`)。
|
|
50
|
+
* 它不在 npm 上单独发布,只能从根包的 tarball 里取。
|
|
51
|
+
*/
|
|
52
|
+
export const PLUGIN_PACKAGE_NAME = "dsh-remote-web";
|
|
53
|
+
|
|
54
|
+
/** 插件在根包 tarball 里的位置(解包后:`<stageDir>/package/packages/dsh-remote-web/`)。 */
|
|
55
|
+
export const PLUGIN_SUBDIR = "packages/dsh-remote-web";
|
|
56
|
+
|
|
57
|
+
/** 默认源顺序:国内镜像优先,官方源兜底。 */
|
|
58
|
+
export const DEFAULT_REGISTRIES = Object.freeze([
|
|
59
|
+
"https://registry.npmmirror.com",
|
|
60
|
+
"https://registry.npmjs.org",
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
/** 单个 HTTP 请求(含下载)默认上限。 */
|
|
64
|
+
export const DEFAULT_TIMEOUT_MS = 20000;
|
|
65
|
+
|
|
66
|
+
/** tarball 大小上限(防御性)。 */
|
|
67
|
+
export const DEFAULT_MAX_BYTES = 64 * 1024 * 1024;
|
|
68
|
+
|
|
69
|
+
/** staging 里 npm tarball 被 strip 之后的顶层目录名。 */
|
|
70
|
+
export const STAGE_PACKAGE_DIR = "package";
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 稳定错误码。面板文案、重试策略、遥测归因都以这里为准。
|
|
74
|
+
* 前 8 个是需求约定的核心码;其余是同一族里**必须区分**的边界情况(文档里单列)。
|
|
75
|
+
*/
|
|
76
|
+
export const ERROR_CODES = Object.freeze({
|
|
77
|
+
/** 所有源都连不上(DNS/连接/超时/TLS/非 JSON 响应) */
|
|
78
|
+
REGISTRY_UNREACHABLE: "REGISTRY_UNREACHABLE",
|
|
79
|
+
/** 源返回了非 2xx:`HTTP_404` / `HTTP_500` … */
|
|
80
|
+
BAD_STATUS: "HTTP_",
|
|
81
|
+
/** tarball 无法解析/布局不对/含危险条目/超限 */
|
|
82
|
+
BAD_TARBALL: "BAD_TARBALL",
|
|
83
|
+
/** integrity / shasum 不符 */
|
|
84
|
+
INTEGRITY_MISMATCH: "INTEGRITY_MISMATCH",
|
|
85
|
+
/** staging 里的版本与解析到的版本(或调用方要求的版本)不一致 */
|
|
86
|
+
VERSION_MISMATCH: "VERSION_MISMATCH",
|
|
87
|
+
/** staging 里的包名不是我们的包 */
|
|
88
|
+
NAME_MISMATCH: "NAME_MISMATCH",
|
|
89
|
+
/** 目标目录被占用(rename 返回 EBUSY/ETXTBSY) */
|
|
90
|
+
TARGET_BUSY: "TARGET_BUSY",
|
|
91
|
+
/** 落盘失败(含回滚信息) */
|
|
92
|
+
APPLY_FAILED: "APPLY_FAILED",
|
|
93
|
+
/** 参数不合法(调用方 bug,不该重试) */
|
|
94
|
+
BAD_ARGUMENTS: "BAD_ARGUMENTS",
|
|
95
|
+
/** 源 200 但包里没有该 tag/版本的 tarball */
|
|
96
|
+
SPEC_NOT_FOUND: "SPEC_NOT_FOUND",
|
|
97
|
+
/** 下载超过 maxBytes */
|
|
98
|
+
TARBALL_TOO_BIG: "TARBALL_TOO_BIG",
|
|
99
|
+
/** rollback 时备份目录不存在/恢复失败 */
|
|
100
|
+
ROLLBACK_FAILED: "ROLLBACK_FAILED",
|
|
101
|
+
/** verifyInstalled:目标目录不是有效安装 */
|
|
102
|
+
NOT_INSTALLED: "NOT_INSTALLED",
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const HTTP_PREFIX = ERROR_CODES.BAD_STATUS;
|
|
106
|
+
|
|
107
|
+
/** 允许出现在 npm tarball 里的条目类型:普通文件 `0`、目录 `5`,其余一律拒绝。 */
|
|
108
|
+
const SAFE_ENTRY_TYPES = new Set(["0", "5"]);
|
|
109
|
+
|
|
110
|
+
/** 聚合错误码的"严重度":这些码比"连不上"更值得原样抛给用户。 */
|
|
111
|
+
const SEVERE_CODES = new Set([
|
|
112
|
+
ERROR_CODES.INTEGRITY_MISMATCH,
|
|
113
|
+
ERROR_CODES.BAD_TARBALL,
|
|
114
|
+
ERROR_CODES.TARBALL_TOO_BIG,
|
|
115
|
+
ERROR_CODES.VERSION_MISMATCH,
|
|
116
|
+
ERROR_CODES.NAME_MISMATCH,
|
|
117
|
+
]);
|
|
118
|
+
|
|
119
|
+
const SUPPORTED_INTEGRITY_ALGOS = new Set(["sha512", "sha384", "sha256", "sha1"]);
|
|
120
|
+
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// 错误
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* 造一个带稳定 code 的错误。
|
|
127
|
+
* @param {string} code 见 ERROR_CODES
|
|
128
|
+
* @param {string} message 人读的信息(会进日志/面板)
|
|
129
|
+
* @param {object} [extra] 额外字段(attempts/detail/rollback/url…)
|
|
130
|
+
*/
|
|
131
|
+
export function updateError(code, message, extra = {}) {
|
|
132
|
+
const err = new Error(message);
|
|
133
|
+
err.name = "SelfUpdateError";
|
|
134
|
+
err.code = code;
|
|
135
|
+
if (extra && typeof extra === "object") {
|
|
136
|
+
for (const [k, v] of Object.entries(extra)) {
|
|
137
|
+
if (v === undefined) continue;
|
|
138
|
+
err[k] = v;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return err;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** 是不是本模块定义的稳定码(`HTTP_404` 这类模板码也算)。 */
|
|
145
|
+
function isOwnCode(code) {
|
|
146
|
+
if (typeof code !== "string") return false;
|
|
147
|
+
if (/^HTTP_\d+$/.test(code)) return true;
|
|
148
|
+
return Object.values(ERROR_CODES).includes(code);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* 把任意异常收敛成带 code 的 SelfUpdateError。
|
|
153
|
+
* 只保留"我们自己的"稳定码:`ECONNREFUSED` / `EACCES` 这类系统码不能直接当成对外错误码
|
|
154
|
+
* (面板要按 err.code 分支,混进 OS 码就不可枚举了)。
|
|
155
|
+
*/
|
|
156
|
+
function normalizeError(e) {
|
|
157
|
+
if (e && e.name === "SelfUpdateError" && isOwnCode(e.code)) return e;
|
|
158
|
+
const code = e && isOwnCode(e.code) ? e.code : ERROR_CODES.APPLY_FAILED;
|
|
159
|
+
return updateError(code, (e && e.message) || String(e), {
|
|
160
|
+
cause: e,
|
|
161
|
+
errno: e && e.errno,
|
|
162
|
+
syscall: e && e.syscall,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** HTTP 状态码 → 稳定错误码(`HTTP_404` 等)。 */
|
|
167
|
+
function statusError(res, url) {
|
|
168
|
+
const status = res && Number.isFinite(Number(res.status)) ? Number(res.status) : 0;
|
|
169
|
+
return updateError(`${HTTP_PREFIX}${status}`, `源返回 HTTP ${status || "?"}:${url}`, {
|
|
170
|
+
status,
|
|
171
|
+
url,
|
|
172
|
+
detail: "http_status",
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
// registry 顺序
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
/** 作用域包按 npm 规范编码:`@scope/name` → `@scope%2fname`(保留 @,斜杠转义)。 */
|
|
181
|
+
function encodePackageName(name) {
|
|
182
|
+
return encodeURIComponent(String(name).trim()).replace(/^%40/i, "@").replace(/%2F/g, "%2f");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function trimRegistry(value) {
|
|
186
|
+
return String(value == null ? "" : value).trim().replace(/\/+$/, "");
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function dedupe(list) {
|
|
190
|
+
const seen = new Set();
|
|
191
|
+
const out = [];
|
|
192
|
+
for (const item of list) {
|
|
193
|
+
if (!item || seen.has(item)) continue;
|
|
194
|
+
seen.add(item);
|
|
195
|
+
out.push(item);
|
|
196
|
+
}
|
|
197
|
+
return out;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* 解析可用的 registry 顺序。
|
|
202
|
+
*
|
|
203
|
+
* 默认 `['https://registry.npmmirror.com', 'https://registry.npmjs.org']`;
|
|
204
|
+
* `DSH_REMOTE_REGISTRY`(逗号分隔,允许前后空格与尾部 `/`)可覆盖;
|
|
205
|
+
* 非 `http(s)://` 的项被忽略,全部无效时回落到默认(回归默认比"直接失败"更有用)。
|
|
206
|
+
*
|
|
207
|
+
* @param {Record<string, string|undefined>} [env]
|
|
208
|
+
* @returns {string[]} 至少一项
|
|
209
|
+
*/
|
|
210
|
+
export function resolveRegistryOrder(env = process.env) {
|
|
211
|
+
const raw = env && typeof env.DSH_REMOTE_REGISTRY === "string" ? env.DSH_REMOTE_REGISTRY : "";
|
|
212
|
+
const list = dedupe(
|
|
213
|
+
raw
|
|
214
|
+
.split(",")
|
|
215
|
+
.map(trimRegistry)
|
|
216
|
+
.filter((s) => /^https?:\/\//i.test(s)),
|
|
217
|
+
);
|
|
218
|
+
return list.length > 0 ? list : [...DEFAULT_REGISTRIES];
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** registries 入参归一化:数组 / 逗号串 / 空 → 默认。 */
|
|
222
|
+
function normalizeRegistries(registries, env) {
|
|
223
|
+
if (registries == null || registries === "") return resolveRegistryOrder(env);
|
|
224
|
+
const list = dedupe(
|
|
225
|
+
(Array.isArray(registries) ? registries : String(registries).split(","))
|
|
226
|
+
.map(trimRegistry)
|
|
227
|
+
.filter(Boolean),
|
|
228
|
+
);
|
|
229
|
+
if (list.length === 0) {
|
|
230
|
+
throw updateError(ERROR_CODES.BAD_ARGUMENTS, "registries 为空:至少给一个 http(s) 源", {
|
|
231
|
+
detail: "bad_arguments",
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
return list;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* `version` / `tag` 归一成"要问源的 selector"。
|
|
239
|
+
*
|
|
240
|
+
* 语义:**显式给的 version 优先**,`tag` 只是没指定版本时的通道。
|
|
241
|
+
* 面板常见写法是 `{ tag: "latest", version: 用户选的版本 }`(tag 有默认值),
|
|
242
|
+
* 所以这里不把"两个都传"当错误 —— 否则选版本这条路径会被一个默认值拦死。
|
|
243
|
+
*/
|
|
244
|
+
function normalizeSelector(version, tag) {
|
|
245
|
+
const v = version == null ? "" : String(version).trim();
|
|
246
|
+
const t = tag == null ? "" : String(tag).trim();
|
|
247
|
+
const selector = v || t || "latest";
|
|
248
|
+
if (/[\s/]/.test(selector)) {
|
|
249
|
+
throw updateError(ERROR_CODES.BAD_ARGUMENTS, `版本/tag 不合法:「${selector}」`, {
|
|
250
|
+
detail: "bad_arguments",
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
return selector;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
// 小工具
|
|
258
|
+
// ---------------------------------------------------------------------------
|
|
259
|
+
|
|
260
|
+
function pad(n, width = 2) {
|
|
261
|
+
return String(n).padStart(width, "0");
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** `20261111-000435-123`:可排序、可人读、同秒多次调用也能区分。 */
|
|
265
|
+
function makeStamp(date = new Date()) {
|
|
266
|
+
return [
|
|
267
|
+
`${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}`,
|
|
268
|
+
`${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`,
|
|
269
|
+
pad(date.getMilliseconds(), 3),
|
|
270
|
+
].join("-");
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* 给 promise 套超时上限。
|
|
275
|
+
* 用 AbortController 尽量让底层请求真的取消;同时 race 一个定时器,
|
|
276
|
+
* 这样即使 fetchImpl 忽略 signal(测试替身/老实现)也不会挂死。
|
|
277
|
+
*/
|
|
278
|
+
async function runWithTimeout(factory, timeoutMs, what) {
|
|
279
|
+
const ms = Number(timeoutMs) > 0 ? Number(timeoutMs) : DEFAULT_TIMEOUT_MS;
|
|
280
|
+
const controller = typeof AbortController === "function" ? new AbortController() : null;
|
|
281
|
+
let timer = null;
|
|
282
|
+
try {
|
|
283
|
+
const work = Promise.resolve().then(() => factory(controller ? controller.signal : undefined));
|
|
284
|
+
const timeout = new Promise((_, reject) => {
|
|
285
|
+
timer = setTimeout(() => {
|
|
286
|
+
try {
|
|
287
|
+
if (controller) controller.abort();
|
|
288
|
+
} catch {
|
|
289
|
+
/* abort 失败无所谓 */
|
|
290
|
+
}
|
|
291
|
+
reject(
|
|
292
|
+
updateError(ERROR_CODES.REGISTRY_UNREACHABLE, `请求超时(>${ms}ms):${what}`, {
|
|
293
|
+
detail: "timeout",
|
|
294
|
+
what,
|
|
295
|
+
timeoutMs: ms,
|
|
296
|
+
}),
|
|
297
|
+
);
|
|
298
|
+
}, ms);
|
|
299
|
+
if (timer && typeof timer.unref === "function") timer.unref();
|
|
300
|
+
});
|
|
301
|
+
return await Promise.race([work, timeout]);
|
|
302
|
+
} finally {
|
|
303
|
+
if (timer) clearTimeout(timer);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* 发一次请求,把"连不上/DNS/TLS/被 abort"统一成 REGISTRY_UNREACHABLE。
|
|
309
|
+
* 超时错误已经带 code,原样抛出(面板文案要区分"超时"和"连不上"仍可用 detail)。
|
|
310
|
+
*/
|
|
311
|
+
async function fetchOnce(fetchImpl, url, init, timeoutMs, what) {
|
|
312
|
+
try {
|
|
313
|
+
return await runWithTimeout((signal) => fetchImpl(url, { ...init, signal }), timeoutMs, what);
|
|
314
|
+
} catch (e) {
|
|
315
|
+
if (e && e.name === "SelfUpdateError") throw e;
|
|
316
|
+
throw updateError(ERROR_CODES.REGISTRY_UNREACHABLE, `请求失败(连不上/传输中断):${what}(${e && e.message})`, {
|
|
317
|
+
detail: "fetch_failed",
|
|
318
|
+
url: what,
|
|
319
|
+
cause: e,
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function headerValue(res, name) {
|
|
325
|
+
try {
|
|
326
|
+
const headers = res && res.headers;
|
|
327
|
+
if (!headers) return null;
|
|
328
|
+
if (typeof headers.get === "function") return headers.get(name);
|
|
329
|
+
const lower = String(name).toLowerCase();
|
|
330
|
+
for (const [k, v] of Object.entries(headers)) if (String(k).toLowerCase() === lower) return v;
|
|
331
|
+
return null;
|
|
332
|
+
} catch {
|
|
333
|
+
return null;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** 把响应体读成 Buffer,边读边判 maxBytes(不信 content-length)。 */
|
|
338
|
+
async function readBodyLimited(res, limit, url) {
|
|
339
|
+
const tooBig = (got) =>
|
|
340
|
+
updateError(ERROR_CODES.TARBALL_TOO_BIG, `tarball 过大:${got} > 上限 ${limit} 字节(${url})`, {
|
|
341
|
+
detail: "too_big",
|
|
342
|
+
bytes: got,
|
|
343
|
+
maxBytes: limit,
|
|
344
|
+
url,
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
const declared = Number(headerValue(res, "content-length"));
|
|
348
|
+
if (Number.isFinite(declared) && declared > limit) throw tooBig(declared);
|
|
349
|
+
|
|
350
|
+
const chunks = [];
|
|
351
|
+
let total = 0;
|
|
352
|
+
const push = (chunk) => {
|
|
353
|
+
const c = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
354
|
+
total += c.length;
|
|
355
|
+
if (total > limit) throw tooBig(total);
|
|
356
|
+
chunks.push(c);
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
const body = res && res.body;
|
|
360
|
+
if (body && typeof body.getReader === "function") {
|
|
361
|
+
const reader = body.getReader();
|
|
362
|
+
try {
|
|
363
|
+
for (;;) {
|
|
364
|
+
const { done, value } = await reader.read();
|
|
365
|
+
if (done) break;
|
|
366
|
+
if (value) push(value);
|
|
367
|
+
}
|
|
368
|
+
} catch (e) {
|
|
369
|
+
try {
|
|
370
|
+
await reader.cancel();
|
|
371
|
+
} catch {
|
|
372
|
+
/* 取消失败无所谓 */
|
|
373
|
+
}
|
|
374
|
+
throw e;
|
|
375
|
+
}
|
|
376
|
+
} else if (body && typeof body[Symbol.asyncIterator] === "function") {
|
|
377
|
+
for await (const chunk of body) push(chunk);
|
|
378
|
+
} else if (typeof res.arrayBuffer === "function") {
|
|
379
|
+
push(await res.arrayBuffer());
|
|
380
|
+
} else if (typeof res.text === "function") {
|
|
381
|
+
push(Buffer.from(await res.text(), "utf8"));
|
|
382
|
+
} else {
|
|
383
|
+
throw updateError(ERROR_CODES.REGISTRY_UNREACHABLE, `响应体不可读:${url}`, { detail: "no_body", url });
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
return Buffer.concat(chunks, total);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** 校验 integrity(优先)或 shasum(兜底)。返回 `{checked, algo}`。 */
|
|
390
|
+
function verifyIntegrity(buffer, dist, url) {
|
|
391
|
+
const integrity = dist && typeof dist.integrity === "string" ? dist.integrity.trim() : "";
|
|
392
|
+
for (const token of integrity ? integrity.split(/\s+/).filter(Boolean) : []) {
|
|
393
|
+
const dash = token.indexOf("-");
|
|
394
|
+
if (dash <= 0) continue;
|
|
395
|
+
const algo = token.slice(0, dash).toLowerCase();
|
|
396
|
+
const b64 = token.slice(dash + 1);
|
|
397
|
+
if (!SUPPORTED_INTEGRITY_ALGOS.has(algo) || !b64) continue;
|
|
398
|
+
let want;
|
|
399
|
+
try {
|
|
400
|
+
want = Buffer.from(b64, "base64");
|
|
401
|
+
} catch {
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
const got = crypto.createHash(algo).update(buffer).digest();
|
|
405
|
+
if (want.length !== got.length || !crypto.timingSafeEqual(want, got)) {
|
|
406
|
+
throw updateError(ERROR_CODES.INTEGRITY_MISMATCH, `tarball 完整性校验失败(${algo}):${url}`, {
|
|
407
|
+
detail: "integrity_mismatch",
|
|
408
|
+
algo,
|
|
409
|
+
url,
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
return { checked: true, algo, source: "integrity" };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const shasum = dist && typeof dist.shasum === "string" ? dist.shasum.trim().toLowerCase() : "";
|
|
416
|
+
if (/^[0-9a-f]{40}$/.test(shasum)) {
|
|
417
|
+
const got = crypto.createHash("sha1").update(buffer).digest("hex");
|
|
418
|
+
if (got !== shasum) {
|
|
419
|
+
throw updateError(ERROR_CODES.INTEGRITY_MISMATCH, `tarball shasum 校验失败(sha1):${url}`, {
|
|
420
|
+
detail: "integrity_mismatch",
|
|
421
|
+
algo: "sha1",
|
|
422
|
+
url,
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
return { checked: true, algo: "sha1", source: "shasum" };
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
return { checked: false, algo: null };
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** 解包前的整体安全校验:布局必须是 `package/…`,且只允许普通文件与目录。 */
|
|
432
|
+
function assertSafeEntries(entries, url) {
|
|
433
|
+
for (const e of entries) {
|
|
434
|
+
const type = e && typeof e.type === "string" && e.type ? e.type : "0";
|
|
435
|
+
if (!SAFE_ENTRY_TYPES.has(type)) {
|
|
436
|
+
const kind = type === "1" ? "硬链接" : type === "2" ? "符号链接" : `类型 ${JSON.stringify(type)}`;
|
|
437
|
+
throw updateError(ERROR_CODES.BAD_TARBALL, `拒绝解包:tarball 含${kind}条目「${e && e.name}」(${url})`, {
|
|
438
|
+
detail: "unsafe_entry",
|
|
439
|
+
entry: e && e.name,
|
|
440
|
+
entryType: type,
|
|
441
|
+
url,
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
const raw = String((e && e.name) || "").replace(/\\/g, "/").replace(/^\.\//, "");
|
|
445
|
+
if (!raw || raw.includes("\0")) {
|
|
446
|
+
throw updateError(ERROR_CODES.BAD_TARBALL, `拒绝解包:tarball 条目名不合法(${url})`, {
|
|
447
|
+
detail: "bad_entry",
|
|
448
|
+
entry: e && e.name,
|
|
449
|
+
url,
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
if (!(raw === STAGE_PACKAGE_DIR || raw.startsWith(`${STAGE_PACKAGE_DIR}/`))) {
|
|
453
|
+
throw updateError(
|
|
454
|
+
ERROR_CODES.BAD_TARBALL,
|
|
455
|
+
`拒绝解包:tarball 条目「${raw}」不在 package/ 下(npm tarball 布局异常,${url})`,
|
|
456
|
+
{ detail: "bad_layout", entry: raw, url },
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function readJsonFile(file, fsImpl = fs) {
|
|
463
|
+
try {
|
|
464
|
+
return JSON.parse(fsImpl.readFileSync(file, "utf8"));
|
|
465
|
+
} catch {
|
|
466
|
+
return null;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// ---------------------------------------------------------------------------
|
|
471
|
+
// 下载 → staging
|
|
472
|
+
// ---------------------------------------------------------------------------
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* 从单个 registry 完成"解析版本 → 下载 → 校验 → 解包到 staging"。
|
|
476
|
+
* 任何异常都抛带 code 的 SelfUpdateError,由上层决定是否换源。
|
|
477
|
+
*/
|
|
478
|
+
async function fetchFromRegistry({ registry, selector, stage, timeout, limit, fetchImpl, requestedVersion }) {
|
|
479
|
+
const base = registry.replace(/\/+$/, "");
|
|
480
|
+
const metaUrl = `${base}/${encodePackageName(PACKAGE_NAME)}/${encodeURIComponent(selector)}`;
|
|
481
|
+
|
|
482
|
+
const metaRes = await fetchOnce(fetchImpl, metaUrl, { headers: { accept: "application/json" } }, timeout, metaUrl);
|
|
483
|
+
if (!metaRes) {
|
|
484
|
+
throw updateError(ERROR_CODES.REGISTRY_UNREACHABLE, `源没有返回响应:${metaUrl}`, {
|
|
485
|
+
detail: "no_response",
|
|
486
|
+
url: metaUrl,
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
if (!metaRes.ok) throw statusError(metaRes, metaUrl);
|
|
490
|
+
|
|
491
|
+
let doc;
|
|
492
|
+
try {
|
|
493
|
+
doc = await runWithTimeout(() => metaRes.json(), timeout, `${metaUrl}(body)`);
|
|
494
|
+
} catch (e) {
|
|
495
|
+
if (e && e.name === "SelfUpdateError") throw e;
|
|
496
|
+
throw updateError(ERROR_CODES.REGISTRY_UNREACHABLE, `源返回的 packument 不是 JSON:${metaUrl}`, {
|
|
497
|
+
detail: "bad_json",
|
|
498
|
+
url: metaUrl,
|
|
499
|
+
cause: e,
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// 两种响应形状都要吃:
|
|
504
|
+
// ① 完整 packument(GET /<name>):有 dist-tags / versions;
|
|
505
|
+
// ② 单版本清单(GET /<name>/<tag|version>,**npm 实际返回的就是这个**):
|
|
506
|
+
// 形如 { name, version, dist: { tarball, integrity, shasum } },既没有 dist-tags 也没有 versions。
|
|
507
|
+
// 2026-11-11 的实测才发现:只按 packument 解析会让"真实下载"永远 SPEC_NOT_FOUND。
|
|
508
|
+
const distTags = (doc && doc["dist-tags"]) || {};
|
|
509
|
+
const versions = (doc && doc.versions) || {};
|
|
510
|
+
let version = typeof distTags[selector] === "string" ? distTags[selector] : "";
|
|
511
|
+
let dist = null;
|
|
512
|
+
if (version) dist = ((versions[version] || {}).dist) || null;
|
|
513
|
+
if (!version && versions[selector]) {
|
|
514
|
+
version = selector; // 传的是精确版本号
|
|
515
|
+
dist = versions[selector].dist || null;
|
|
516
|
+
}
|
|
517
|
+
if (!version && requestedVersion && versions[requestedVersion]) {
|
|
518
|
+
version = requestedVersion;
|
|
519
|
+
dist = versions[requestedVersion].dist || null;
|
|
520
|
+
}
|
|
521
|
+
if (!version && doc && typeof doc.version === "string" && doc.version) {
|
|
522
|
+
version = doc.version; // 单版本清单
|
|
523
|
+
dist = (doc && doc.dist) || null;
|
|
524
|
+
}
|
|
525
|
+
if (!version) {
|
|
526
|
+
throw updateError(ERROR_CODES.SPEC_NOT_FOUND, `源里没有 ${selector} 对应的版本:${metaUrl}`, {
|
|
527
|
+
detail: "spec_not_found",
|
|
528
|
+
selector,
|
|
529
|
+
url: metaUrl,
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
if (requestedVersion && requestedVersion !== version) {
|
|
533
|
+
throw updateError(
|
|
534
|
+
ERROR_CODES.VERSION_MISMATCH,
|
|
535
|
+
`源返回的版本(${version})与请求的(${requestedVersion})不一致:${metaUrl}`,
|
|
536
|
+
{ detail: "version_mismatch", requestedVersion, version, url: metaUrl },
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
dist = dist || {};
|
|
541
|
+
const tarball = typeof dist.tarball === "string" ? dist.tarball.trim() : "";
|
|
542
|
+
if (!tarball) {
|
|
543
|
+
throw updateError(ERROR_CODES.SPEC_NOT_FOUND, `源里 ${version} 没有 dist.tarball:${metaUrl}`, {
|
|
544
|
+
detail: "no_tarball",
|
|
545
|
+
version,
|
|
546
|
+
url: metaUrl,
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const tarRes = await fetchOnce(fetchImpl, tarball, { headers: { accept: "application/octet-stream" } }, timeout, tarball);
|
|
551
|
+
if (!tarRes || !tarRes.ok) throw statusError(tarRes, tarball);
|
|
552
|
+
|
|
553
|
+
let buffer;
|
|
554
|
+
try {
|
|
555
|
+
buffer = await runWithTimeout(() => readBodyLimited(tarRes, limit, tarball), timeout, `${tarball}(body)`);
|
|
556
|
+
} catch (e) {
|
|
557
|
+
if (e && e.name === "SelfUpdateError") throw e;
|
|
558
|
+
throw updateError(ERROR_CODES.REGISTRY_UNREACHABLE, `下载中断:${tarball}(${e && e.message})`, {
|
|
559
|
+
detail: "download_failed",
|
|
560
|
+
url: tarball,
|
|
561
|
+
cause: e,
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
const integrity = verifyIntegrity(buffer, dist, tarball);
|
|
565
|
+
|
|
566
|
+
if (buffer.length < 2 || buffer[0] !== 0x1f || buffer[1] !== 0x8b) {
|
|
567
|
+
throw updateError(ERROR_CODES.BAD_TARBALL, `下载内容不是 gzip(可能是镜像的错误页):${tarball}`, {
|
|
568
|
+
detail: "not_gzip",
|
|
569
|
+
bytes: buffer.length,
|
|
570
|
+
url: tarball,
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
let entries;
|
|
575
|
+
try {
|
|
576
|
+
entries = readTarEntries(buffer);
|
|
577
|
+
} catch (e) {
|
|
578
|
+
throw updateError(ERROR_CODES.BAD_TARBALL, `tarball 解析失败:${tarball}(${e.message})`, {
|
|
579
|
+
detail: "bad_tar",
|
|
580
|
+
url: tarball,
|
|
581
|
+
cause: e,
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
assertSafeEntries(entries, tarball);
|
|
585
|
+
|
|
586
|
+
// staging 是本模块独占的临时目录:先清空,避免上一次失败的残留混进来
|
|
587
|
+
try {
|
|
588
|
+
fs.rmSync(stage, { recursive: true, force: true });
|
|
589
|
+
} catch {
|
|
590
|
+
/* 清不掉就在下面 mkdir 时暴露问题 */
|
|
591
|
+
}
|
|
592
|
+
fs.mkdirSync(stage, { recursive: true });
|
|
593
|
+
|
|
594
|
+
// extractTarGz 的 stripPrefix 是"去掉这一层再落到 destDir",所以 destDir 要**直接给
|
|
595
|
+
// <stageDir>/package**,而不是 stageDir 根(否则文件会散落在 staging 根下)。
|
|
596
|
+
const stageRootDir = path.join(stage, STAGE_PACKAGE_DIR);
|
|
597
|
+
let files = 0;
|
|
598
|
+
try {
|
|
599
|
+
files = extractTarGz(buffer, stageRootDir, { stripPrefix: STAGE_PACKAGE_DIR });
|
|
600
|
+
} catch (e) {
|
|
601
|
+
// extractTarGz 对路径穿越抛 ETRAVERSAL,其它是 tar 结构问题 —— 对外统一 BAD_TARBALL
|
|
602
|
+
throw updateError(ERROR_CODES.BAD_TARBALL, `解包被拒绝:${tarball}(${e.message})`, {
|
|
603
|
+
detail: e && e.code === "ETRAVERSAL" ? "traversal" : "extract_failed",
|
|
604
|
+
url: tarball,
|
|
605
|
+
cause: e,
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
const stageRoot = stageRootDir;
|
|
610
|
+
const pkg = readJsonFile(path.join(stageRoot, "package.json"));
|
|
611
|
+
if (!pkg || typeof pkg !== "object") {
|
|
612
|
+
throw updateError(ERROR_CODES.BAD_TARBALL, `解包后没有 package/package.json:${tarball}`, {
|
|
613
|
+
detail: "no_package_json",
|
|
614
|
+
url: tarball,
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
if (pkg.name !== PACKAGE_NAME) {
|
|
618
|
+
throw updateError(ERROR_CODES.NAME_MISMATCH, `包名不符:期望 ${PACKAGE_NAME},实际 ${pkg.name}`, {
|
|
619
|
+
detail: "name_mismatch",
|
|
620
|
+
expected: PACKAGE_NAME,
|
|
621
|
+
actual: pkg.name,
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
if (pkg.version !== version) {
|
|
625
|
+
throw updateError(
|
|
626
|
+
ERROR_CODES.VERSION_MISMATCH,
|
|
627
|
+
`包内版本(${pkg.version})与源解析出的版本(${version})不一致`,
|
|
628
|
+
{ detail: "version_mismatch", expected: version, actual: pkg.version },
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// 根包版本对了还不够:真正会被装进 profile 的是 packages/dsh-remote-web,
|
|
633
|
+
// 它的 name/version 也必须对得上(发布脚本漏改版本时会在这里被拦下)。
|
|
634
|
+
const payloadRoot = resolveStagePayload({ stageRoot });
|
|
635
|
+
const payloadPkg = readJsonFile(path.join(payloadRoot, "package.json"));
|
|
636
|
+
if (!payloadPkg || payloadPkg.version !== version) {
|
|
637
|
+
throw updateError(
|
|
638
|
+
ERROR_CODES.VERSION_MISMATCH,
|
|
639
|
+
`插件包版本(${payloadPkg && payloadPkg.version})与源解析出的版本(${version})不一致`,
|
|
640
|
+
{ detail: "version_mismatch", expected: version, actual: payloadPkg && payloadPkg.version, payloadRoot },
|
|
641
|
+
);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
return { version, stageRoot, payloadRoot, bytes: buffer.length, tarball, integrity, files };
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* 按 registry 顺序下载 tarball 并解包到 `<stageDir>/package/`。
|
|
649
|
+
*
|
|
650
|
+
* 成功:`{ok:true, registry, version, stageRoot, bytes, ms, attempts, requested, tarball, integrity, files}`
|
|
651
|
+
* 失败:throw,`err.code` 见 ERROR_CODES,且带 `err.attempts`(逐个源的耗时与原因)。
|
|
652
|
+
*
|
|
653
|
+
* 语义细节:
|
|
654
|
+
* · `version` 显式优先,`tag` 只在没给 version 时生效;都不给 = `latest`;
|
|
655
|
+
* · `tag` 也可以是精确版本号(`0.8.0`),registry 对两者返回同一份 packument;
|
|
656
|
+
* · 每个源失败后都会清空 staging,避免把半个包交给下一个源;
|
|
657
|
+
* · 单请求(含下载)超时 = `timeoutMs`(默认 20s),按请求计,不是总预算;
|
|
658
|
+
* · 不重写 tarball URL 的 host:镜像给的 URL 就打镜像,官方源给的打官方源 —— 避免
|
|
659
|
+
* "镜像返回了官方 URL 但我们偷偷换 host"导致 integrity 假设失效。
|
|
660
|
+
*
|
|
661
|
+
* @param {object} options
|
|
662
|
+
* @param {string} [options.version] 精确版本
|
|
663
|
+
* @param {string} [options.tag] dist-tag(latest/beta/…)
|
|
664
|
+
* @param {string} options.stageDir staging 根目录(本模块独占,会被清空重建)
|
|
665
|
+
* @param {string[]|string} [options.registries]
|
|
666
|
+
* @param {number} [options.timeoutMs]
|
|
667
|
+
* @param {number} [options.maxBytes]
|
|
668
|
+
* @param {typeof fetch} [options.fetchImpl]
|
|
669
|
+
* @param {Record<string,string|undefined>} [options.env]
|
|
670
|
+
*/
|
|
671
|
+
export async function fetchTarballToStaging(options = {}) {
|
|
672
|
+
const {
|
|
673
|
+
version,
|
|
674
|
+
tag,
|
|
675
|
+
stageDir,
|
|
676
|
+
registries,
|
|
677
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
678
|
+
maxBytes = DEFAULT_MAX_BYTES,
|
|
679
|
+
fetchImpl,
|
|
680
|
+
env = process.env,
|
|
681
|
+
} = options || {};
|
|
682
|
+
|
|
683
|
+
const fetchFn = typeof fetchImpl === "function" ? fetchImpl : globalThis.fetch;
|
|
684
|
+
if (typeof fetchFn !== "function") {
|
|
685
|
+
throw updateError(ERROR_CODES.BAD_ARGUMENTS, "fetchTarballToStaging: 没有可用的 fetch(Node ≥18)", {
|
|
686
|
+
detail: "bad_arguments",
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
const selector = normalizeSelector(version, tag);
|
|
690
|
+
if (!stageDir) {
|
|
691
|
+
throw updateError(ERROR_CODES.BAD_ARGUMENTS, "fetchTarballToStaging: 缺少 stageDir", {
|
|
692
|
+
detail: "bad_arguments",
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
const stage = path.resolve(stageDir);
|
|
696
|
+
const list = normalizeRegistries(registries, env);
|
|
697
|
+
const limit = Number(maxBytes) > 0 ? Number(maxBytes) : DEFAULT_MAX_BYTES;
|
|
698
|
+
const timeout = Number(timeoutMs) > 0 ? Number(timeoutMs) : DEFAULT_TIMEOUT_MS;
|
|
699
|
+
const requestedVersion = version == null || String(version).trim() === "" ? null : String(version).trim();
|
|
700
|
+
|
|
701
|
+
const attempts = [];
|
|
702
|
+
const failures = [];
|
|
703
|
+
const startedAll = Date.now();
|
|
704
|
+
|
|
705
|
+
for (const registry of list) {
|
|
706
|
+
const started = Date.now();
|
|
707
|
+
try {
|
|
708
|
+
const got = await fetchFromRegistry({
|
|
709
|
+
registry,
|
|
710
|
+
selector,
|
|
711
|
+
stage,
|
|
712
|
+
timeout,
|
|
713
|
+
limit,
|
|
714
|
+
fetchImpl: fetchFn,
|
|
715
|
+
requestedVersion,
|
|
716
|
+
});
|
|
717
|
+
attempts.push({ registry, ok: true, ms: Date.now() - started, error: null, code: null });
|
|
718
|
+
return {
|
|
719
|
+
ok: true,
|
|
720
|
+
registry,
|
|
721
|
+
version: got.version,
|
|
722
|
+
stageRoot: got.stageRoot,
|
|
723
|
+
payloadRoot: got.payloadRoot,
|
|
724
|
+
bytes: got.bytes,
|
|
725
|
+
ms: Date.now() - startedAll,
|
|
726
|
+
attempts,
|
|
727
|
+
requested: selector,
|
|
728
|
+
tarball: got.tarball,
|
|
729
|
+
integrity: got.integrity,
|
|
730
|
+
files: got.files,
|
|
731
|
+
};
|
|
732
|
+
} catch (e) {
|
|
733
|
+
const err = normalizeError(e);
|
|
734
|
+
failures.push(err);
|
|
735
|
+
attempts.push({
|
|
736
|
+
registry,
|
|
737
|
+
ok: false,
|
|
738
|
+
ms: Date.now() - started,
|
|
739
|
+
error: `${err.code}: ${err.message}`,
|
|
740
|
+
code: err.code,
|
|
741
|
+
detail: err.detail,
|
|
742
|
+
});
|
|
743
|
+
try {
|
|
744
|
+
fs.rmSync(stage, { recursive: true, force: true });
|
|
745
|
+
} catch {
|
|
746
|
+
/* 清不掉不影响结论 */
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// 全部源失败:选一个"最能说明问题"的码
|
|
752
|
+
const severeIdx = attempts.findIndex((a) => SEVERE_CODES.has(a.code));
|
|
753
|
+
const allUnreachable = attempts.every((a) => a.code === ERROR_CODES.REGISTRY_UNREACHABLE);
|
|
754
|
+
const code = severeIdx >= 0
|
|
755
|
+
? attempts[severeIdx].code
|
|
756
|
+
: allUnreachable
|
|
757
|
+
? ERROR_CODES.REGISTRY_UNREACHABLE
|
|
758
|
+
: attempts[attempts.length - 1].code;
|
|
759
|
+
|
|
760
|
+
// 把"那个最能说明问题"的失败的细节带出来:expected/actual/algo/status/bytes 让面板不用解析字符串
|
|
761
|
+
const chosen = severeIdx >= 0 ? failures[severeIdx] : failures[failures.length - 1];
|
|
762
|
+
const summary = attempts.map((a) => `${a.registry} → ${a.error || "ok"}`).join(";");
|
|
763
|
+
throw updateError(code, `所有源都失败(${attempts.length} 个):${summary}`, {
|
|
764
|
+
attempts,
|
|
765
|
+
allRegistriesFailed: true,
|
|
766
|
+
detail: chosen ? chosen.detail : "all_registries_failed",
|
|
767
|
+
expected: chosen && chosen.expected,
|
|
768
|
+
actual: chosen && chosen.actual,
|
|
769
|
+
algo: chosen && chosen.algo,
|
|
770
|
+
status: chosen && chosen.status,
|
|
771
|
+
bytes: chosen && chosen.bytes,
|
|
772
|
+
requested: selector,
|
|
773
|
+
registries: list,
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// ---------------------------------------------------------------------------
|
|
778
|
+
// 校验 / 应用 / 回滚
|
|
779
|
+
// ---------------------------------------------------------------------------
|
|
780
|
+
|
|
781
|
+
/**
|
|
782
|
+
* 从 staging 里定位"真正要装进 profile 的那个目录"。
|
|
783
|
+
*
|
|
784
|
+
* 两种情况都要支持:
|
|
785
|
+
* · stageRoot 直接就是插件包(package.json.name === `dsh-remote-web`):
|
|
786
|
+
* 手工准备 staging / 单元测试会这样用;
|
|
787
|
+
* · stageRoot 是根包(name === `@mrrisega/dsh-remote`):
|
|
788
|
+
* 真实 npm tarball 解出来就是这个形状,插件在 `<stageRoot>/packages/dsh-remote-web/`。
|
|
789
|
+
*
|
|
790
|
+
* 只认 `package.json` 里的 name 来判断,不靠目录名猜 —— 目录名可以被包里任何内容伪造。
|
|
791
|
+
*
|
|
792
|
+
* @returns {string} 插件目录的绝对路径
|
|
793
|
+
* @throws NAME_MISMATCH / BAD_TARBALL
|
|
794
|
+
*/
|
|
795
|
+
export function resolveStagePayload({ stageRoot, fsImpl = fs } = {}) {
|
|
796
|
+
if (!stageRoot) {
|
|
797
|
+
throw updateError(ERROR_CODES.BAD_ARGUMENTS, "resolveStagePayload: 缺少 stageRoot", {
|
|
798
|
+
detail: "bad_arguments",
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
const root = path.resolve(stageRoot);
|
|
802
|
+
const rootPkg = readJsonFile(path.join(root, "package.json"), fsImpl);
|
|
803
|
+
if (!rootPkg || typeof rootPkg !== "object") {
|
|
804
|
+
throw updateError(ERROR_CODES.APPLY_FAILED, `staging 里没有可用的 package.json:${root}`, {
|
|
805
|
+
detail: "stage_invalid",
|
|
806
|
+
stageRoot: root,
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
if (rootPkg.name === PLUGIN_PACKAGE_NAME) return root;
|
|
810
|
+
|
|
811
|
+
if (rootPkg.name === PACKAGE_NAME) {
|
|
812
|
+
const sub = path.join(root, ...PLUGIN_SUBDIR.split("/"));
|
|
813
|
+
const subPkg = readJsonFile(path.join(sub, "package.json"), fsImpl);
|
|
814
|
+
if (!subPkg) {
|
|
815
|
+
throw updateError(
|
|
816
|
+
ERROR_CODES.BAD_TARBALL,
|
|
817
|
+
`根包里没有 ${PLUGIN_SUBDIR}/package.json(发布产物布局异常):${root}`,
|
|
818
|
+
{ detail: "missing_plugin", stageRoot: root },
|
|
819
|
+
);
|
|
820
|
+
}
|
|
821
|
+
if (subPkg.name !== PLUGIN_PACKAGE_NAME) {
|
|
822
|
+
throw updateError(
|
|
823
|
+
ERROR_CODES.NAME_MISMATCH,
|
|
824
|
+
`${PLUGIN_SUBDIR} 里的包名不符:期望 ${PLUGIN_PACKAGE_NAME},实际 ${subPkg.name}`,
|
|
825
|
+
{ detail: "name_mismatch", expected: PLUGIN_PACKAGE_NAME, actual: subPkg.name, stageRoot: root },
|
|
826
|
+
);
|
|
827
|
+
}
|
|
828
|
+
return sub;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
throw updateError(
|
|
832
|
+
ERROR_CODES.NAME_MISMATCH,
|
|
833
|
+
`staging 包名不符:期望 ${PACKAGE_NAME}(根包)或 ${PLUGIN_PACKAGE_NAME}(插件包),实际 ${rootPkg.name}`,
|
|
834
|
+
{ detail: "name_mismatch", expected: PACKAGE_NAME, actual: rootPkg.name, stageRoot: root },
|
|
835
|
+
);
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
/**
|
|
839
|
+
* 读取目标目录里已安装的包信息(不抛错)。
|
|
840
|
+
* @returns {{installed: boolean, dir: string, name: string|null, version: string|null, pkg: object|null}}
|
|
841
|
+
*/
|
|
842
|
+
export function readInstalled({ targetDir, fsImpl = fs } = {}) {
|
|
843
|
+
if (!targetDir) {
|
|
844
|
+
throw updateError(ERROR_CODES.BAD_ARGUMENTS, "readInstalled: 缺少 targetDir", { detail: "bad_arguments" });
|
|
845
|
+
}
|
|
846
|
+
const dir = path.resolve(targetDir);
|
|
847
|
+
const pkg = readJsonFile(path.join(dir, "package.json"), fsImpl);
|
|
848
|
+
if (!pkg || typeof pkg !== "object") return { installed: false, dir, name: null, version: null, pkg: null };
|
|
849
|
+
return {
|
|
850
|
+
installed: true,
|
|
851
|
+
dir,
|
|
852
|
+
name: typeof pkg.name === "string" ? pkg.name : null,
|
|
853
|
+
version: typeof pkg.version === "string" ? pkg.version : null,
|
|
854
|
+
pkg,
|
|
855
|
+
};
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/**
|
|
859
|
+
* 校验目标目录确实装上了期望版本(apply 之后的收尾检查,面板也可单独调用)。
|
|
860
|
+
* @returns {{ok:true, dir:string, name:string, version:string}}
|
|
861
|
+
* @throws NOT_INSTALLED / NAME_MISMATCH / VERSION_MISMATCH
|
|
862
|
+
*/
|
|
863
|
+
export function verifyInstalled({ targetDir, expectedVersion, expectedName = PLUGIN_PACKAGE_NAME, fsImpl = fs } = {}) {
|
|
864
|
+
const info = readInstalled({ targetDir, fsImpl });
|
|
865
|
+
if (!info.installed) {
|
|
866
|
+
throw updateError(ERROR_CODES.NOT_INSTALLED, `目标目录里没有可读的 package.json:${info.dir}`, {
|
|
867
|
+
detail: "not_installed",
|
|
868
|
+
targetDir: info.dir,
|
|
869
|
+
});
|
|
870
|
+
}
|
|
871
|
+
if (expectedName && info.name !== expectedName) {
|
|
872
|
+
throw updateError(ERROR_CODES.NAME_MISMATCH, `已安装的包名不符:期望 ${expectedName},实际 ${info.name}`, {
|
|
873
|
+
detail: "name_mismatch",
|
|
874
|
+
expected: expectedName,
|
|
875
|
+
actual: info.name,
|
|
876
|
+
targetDir: info.dir,
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
if (expectedVersion != null && String(expectedVersion).trim() !== "" && info.version !== String(expectedVersion).trim()) {
|
|
880
|
+
throw updateError(
|
|
881
|
+
ERROR_CODES.VERSION_MISMATCH,
|
|
882
|
+
`已安装版本(${info.version})不是期望的(${expectedVersion})`,
|
|
883
|
+
{ detail: "version_mismatch", expected: String(expectedVersion).trim(), actual: info.version, targetDir: info.dir },
|
|
884
|
+
);
|
|
885
|
+
}
|
|
886
|
+
return { ok: true, dir: info.dir, name: info.name, version: info.version };
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/** rename 失败时区分"被占用"与"真失败"。 */
|
|
890
|
+
function classifyRenameError(e, what) {
|
|
891
|
+
const code = e && e.code;
|
|
892
|
+
const map = { EBUSY: ERROR_CODES.TARGET_BUSY, ETXTBSY: ERROR_CODES.TARGET_BUSY };
|
|
893
|
+
const mapped = map[code];
|
|
894
|
+
if (mapped) {
|
|
895
|
+
return updateError(mapped, `${what}:目标被占用(${code})${e.message ? `:${e.message}` : ""}`, {
|
|
896
|
+
detail: "busy",
|
|
897
|
+
syscall: e && e.syscall,
|
|
898
|
+
errno: e && e.errno,
|
|
899
|
+
cause: e,
|
|
900
|
+
});
|
|
901
|
+
}
|
|
902
|
+
return updateError(ERROR_CODES.APPLY_FAILED, `${what} 失败(${code || "?"})${e.message ? `:${e.message}` : ""}`, {
|
|
903
|
+
detail: "rename_failed",
|
|
904
|
+
syscall: e && e.syscall,
|
|
905
|
+
errno: e && e.errno,
|
|
906
|
+
cause: e,
|
|
907
|
+
});
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/** 找 backupDir 名字:撞名就加序号(同一毫秒内连续升级两次也不会互相覆盖)。 */
|
|
911
|
+
function uniqueBackupDir(target, fsImpl) {
|
|
912
|
+
const stamp = makeStamp();
|
|
913
|
+
let candidate = `${target}.bak-${stamp}`;
|
|
914
|
+
let n = 1;
|
|
915
|
+
while (fsImpl.existsSync(candidate)) candidate = `${target}.bak-${stamp}-${n++}`;
|
|
916
|
+
return candidate;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
function removeTree(dir, fsImpl = fs) {
|
|
920
|
+
try {
|
|
921
|
+
fsImpl.rmSync(dir, { recursive: true, force: true });
|
|
922
|
+
return true;
|
|
923
|
+
} catch {
|
|
924
|
+
return false;
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
function copyTree(src, dest, fsImpl = fs) {
|
|
929
|
+
if (typeof fsImpl.cpSync === "function") {
|
|
930
|
+
fsImpl.cpSync(src, dest, { recursive: true, force: true });
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
fsImpl.mkdirSync(dest, { recursive: true });
|
|
934
|
+
for (const entry of fsImpl.readdirSync(src, { withFileTypes: true })) {
|
|
935
|
+
const s = path.join(src, entry.name);
|
|
936
|
+
const d = path.join(dest, entry.name);
|
|
937
|
+
if (entry.isDirectory()) copyTree(s, d, fsImpl);
|
|
938
|
+
else if (entry.isSymbolicLink()) throw updateError(ERROR_CODES.APPLY_FAILED, `拒绝复制符号链接:${s}`, { detail: "symlink" });
|
|
939
|
+
else fsImpl.copyFileSync(s, d);
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
/**
|
|
944
|
+
* 把 staging 里的插件目录原子地换到 profile 的插件目录。
|
|
945
|
+
*
|
|
946
|
+
* stageRoot 可以直接是插件包目录,也可以是根包解出来的 `package/`(会自动定位
|
|
947
|
+
* `packages/dsh-remote-web/`,用 package.json 的 name 判断,不靠目录名)。
|
|
948
|
+
*
|
|
949
|
+
* 流程:定位并校验 payload 的 name/version → 旧目录 rename 成 `<target>.bak-<stamp>` →
|
|
950
|
+
* payload rename 到 target → 再 verifyInstalled 一次。任一步失败都会回滚(把 bak 换回去),
|
|
951
|
+
* 所以调用方看到的要么是"旧的完好",要么是"新的完好",不会出现半个安装。
|
|
952
|
+
*
|
|
953
|
+
* 幂等:target 已有相同版本时直接返回 `{ok:true, applied:false, noop:true}`(不动磁盘);
|
|
954
|
+
* 想强制重装传 `force:true`。
|
|
955
|
+
*
|
|
956
|
+
* @param {object} options
|
|
957
|
+
* @param {string} options.stageRoot staging 根(`<stageDir>/package`)或插件包目录
|
|
958
|
+
* @param {string} options.targetDir profile 下的插件目录(如 `~/.dsh/profiles/desktop/dsh-remote-web-plugin`)
|
|
959
|
+
* @param {boolean} [options.keepBackup=true] 成功后是否保留 bak(保留便于人工回滚)
|
|
960
|
+
* @param {string} [options.expectedVersion] 期望落到 target 的版本(默认取 stage 的 version)
|
|
961
|
+
* @param {boolean} [options.force=false] 同版本也强制重装
|
|
962
|
+
* @param {object} [options.fsImpl] 仅测试注入用(同步 fs 兼容接口)
|
|
963
|
+
* @returns {{ok:true, applied:boolean, noop:boolean, backupDir:string|null, fromVersion:string|null, toVersion:string, targetDir:string, payloadRoot:string}}
|
|
964
|
+
*/
|
|
965
|
+
export function applyStagedToProfile(options = {}) {
|
|
966
|
+
const { stageRoot, targetDir, keepBackup = true, expectedVersion, force = false, fsImpl = fs } = options || {};
|
|
967
|
+
if (!stageRoot || !targetDir) {
|
|
968
|
+
throw updateError(ERROR_CODES.BAD_ARGUMENTS, "applyStagedToProfile: 缺少 stageRoot 或 targetDir", {
|
|
969
|
+
detail: "bad_arguments",
|
|
970
|
+
});
|
|
971
|
+
}
|
|
972
|
+
const stage = path.resolve(stageRoot);
|
|
973
|
+
const target = path.resolve(targetDir);
|
|
974
|
+
|
|
975
|
+
// 校验的是"真正要装进去的插件包",不是根包
|
|
976
|
+
const payload = resolveStagePayload({ stageRoot: stage, fsImpl });
|
|
977
|
+
const stagePkg = readJsonFile(path.join(payload, "package.json"), fsImpl);
|
|
978
|
+
const toVersion = typeof (stagePkg && stagePkg.version) === "string" ? stagePkg.version.trim() : "";
|
|
979
|
+
if (!toVersion) {
|
|
980
|
+
throw updateError(ERROR_CODES.VERSION_MISMATCH, `staging 的插件包没有 version:${payload}`, {
|
|
981
|
+
detail: "version_mismatch",
|
|
982
|
+
stageRoot: stage,
|
|
983
|
+
payloadRoot: payload,
|
|
984
|
+
});
|
|
985
|
+
}
|
|
986
|
+
if (expectedVersion != null && String(expectedVersion).trim() !== "" && toVersion !== String(expectedVersion).trim()) {
|
|
987
|
+
throw updateError(
|
|
988
|
+
ERROR_CODES.VERSION_MISMATCH,
|
|
989
|
+
`staging 版本(${toVersion})不是期望的(${expectedVersion})`,
|
|
990
|
+
{ detail: "version_mismatch", expected: String(expectedVersion).trim(), actual: toVersion },
|
|
991
|
+
);
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
const current = readInstalled({ targetDir: target, fsImpl });
|
|
995
|
+
const fromVersion = current.version;
|
|
996
|
+
const targetExists = fsImpl.existsSync(target);
|
|
997
|
+
|
|
998
|
+
if (targetExists && !force && fromVersion === toVersion) {
|
|
999
|
+
return {
|
|
1000
|
+
ok: true,
|
|
1001
|
+
applied: false,
|
|
1002
|
+
noop: true,
|
|
1003
|
+
backupDir: null,
|
|
1004
|
+
fromVersion,
|
|
1005
|
+
toVersion,
|
|
1006
|
+
targetDir: target,
|
|
1007
|
+
payloadRoot: payload,
|
|
1008
|
+
};
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
fsImpl.mkdirSync(path.dirname(target), { recursive: true });
|
|
1012
|
+
|
|
1013
|
+
const backupDir = targetExists ? uniqueBackupDir(target, fsImpl) : null;
|
|
1014
|
+
let movedOld = false;
|
|
1015
|
+
// wroteInto:target 这个位置上已经有"我们写的(可能写了一半的)内容"。
|
|
1016
|
+
// 没写过就绝不能删 target —— 否则一次 EBUSY 会把用户装好的插件删掉。
|
|
1017
|
+
let wroteInto = false;
|
|
1018
|
+
|
|
1019
|
+
// 回滚:目标位置先清空(可能是写了一半的新目录),再把备份换回去
|
|
1020
|
+
const restore = () => {
|
|
1021
|
+
const report = { ok: false, restored: false, removedNew: false, error: null };
|
|
1022
|
+
try {
|
|
1023
|
+
if (wroteInto && fsImpl.existsSync(target)) {
|
|
1024
|
+
report.removedNew = removeTree(target, fsImpl);
|
|
1025
|
+
if (!report.removedNew) {
|
|
1026
|
+
// 删不掉就别硬来:留个可诊断的结论,避免把备份也弄丢
|
|
1027
|
+
report.error = `清理目标目录失败:${target}`;
|
|
1028
|
+
report.code = "EBUSY";
|
|
1029
|
+
return report;
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
if (movedOld && backupDir) {
|
|
1033
|
+
fsImpl.renameSync(backupDir, target);
|
|
1034
|
+
report.restored = true;
|
|
1035
|
+
} else {
|
|
1036
|
+
report.restored = true; // 本来就没有目标目录:清掉半成品即回到原状
|
|
1037
|
+
}
|
|
1038
|
+
report.ok = report.restored;
|
|
1039
|
+
} catch (e) {
|
|
1040
|
+
report.error = (e && e.message) || String(e);
|
|
1041
|
+
report.code = e && e.code;
|
|
1042
|
+
}
|
|
1043
|
+
return report;
|
|
1044
|
+
};
|
|
1045
|
+
|
|
1046
|
+
try {
|
|
1047
|
+
if (targetExists) {
|
|
1048
|
+
try {
|
|
1049
|
+
fsImpl.renameSync(target, backupDir);
|
|
1050
|
+
} catch (e) {
|
|
1051
|
+
throw classifyRenameError(e, `把旧目录挪到备份(${backupDir})`);
|
|
1052
|
+
}
|
|
1053
|
+
movedOld = true;
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
try {
|
|
1057
|
+
fsImpl.renameSync(payload, target);
|
|
1058
|
+
wroteInto = true;
|
|
1059
|
+
} catch (e) {
|
|
1060
|
+
if (e && e.code === "EXDEV") {
|
|
1061
|
+
// stage 与 target 不在同一个文件系统(/tmp 与 home 常见):退化成复制,
|
|
1062
|
+
// 失败由下面的回滚兜底;此时"原子"退化为"可回滚"。
|
|
1063
|
+
wroteInto = true; // 复制可能已经写了一部分
|
|
1064
|
+
try {
|
|
1065
|
+
copyTree(payload, target, fsImpl);
|
|
1066
|
+
} catch (e2) {
|
|
1067
|
+
throw updateError(ERROR_CODES.APPLY_FAILED, `跨盘复制 staging 失败:${e2.message}`, {
|
|
1068
|
+
detail: "copy_failed",
|
|
1069
|
+
cause: e2,
|
|
1070
|
+
});
|
|
1071
|
+
}
|
|
1072
|
+
removeTree(payload, fsImpl);
|
|
1073
|
+
} else {
|
|
1074
|
+
throw classifyRenameError(e, `把 staging 插件目录挪到目标(${target})`);
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
verifyInstalled({ targetDir: target, expectedVersion: toVersion, fsImpl });
|
|
1078
|
+
} catch (e) {
|
|
1079
|
+
const err = normalizeError(e);
|
|
1080
|
+
const rb = restore();
|
|
1081
|
+
throw updateError(err.code, err.message, {
|
|
1082
|
+
detail: err.detail,
|
|
1083
|
+
stageRoot: stage,
|
|
1084
|
+
payloadRoot: payload,
|
|
1085
|
+
targetDir: target,
|
|
1086
|
+
backupDir,
|
|
1087
|
+
fromVersion,
|
|
1088
|
+
toVersion,
|
|
1089
|
+
rollback: rb,
|
|
1090
|
+
cause: err,
|
|
1091
|
+
});
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
let keptBackup = backupDir;
|
|
1095
|
+
if (backupDir && !keepBackup) {
|
|
1096
|
+
removeTree(backupDir, fsImpl);
|
|
1097
|
+
keptBackup = null;
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
return {
|
|
1101
|
+
ok: true,
|
|
1102
|
+
applied: true,
|
|
1103
|
+
noop: false,
|
|
1104
|
+
backupDir: keptBackup,
|
|
1105
|
+
fromVersion,
|
|
1106
|
+
toVersion,
|
|
1107
|
+
targetDir: target,
|
|
1108
|
+
payloadRoot: payload,
|
|
1109
|
+
};
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
/**
|
|
1113
|
+
* 用备份目录换回旧版本(`applyStagedToProfile` 失败时已自动做过一次;这里给"升级后
|
|
1114
|
+
* 发现新版有问题"的手动回滚用)。
|
|
1115
|
+
*
|
|
1116
|
+
* 当前 target(新版本)不会被删除,而是挪到 `<target>.failed-<stamp>`,方便事后取证。
|
|
1117
|
+
*
|
|
1118
|
+
* @returns {{ok:true, restored:true, version:string|null, targetDir:string, movedAside:string|null, consumedBackup:string}}
|
|
1119
|
+
* @throws ROLLBACK_FAILED
|
|
1120
|
+
*/
|
|
1121
|
+
export function rollback(options = {}) {
|
|
1122
|
+
const { backupDir, targetDir, fsImpl = fs } = options || {};
|
|
1123
|
+
if (!backupDir || !targetDir) {
|
|
1124
|
+
throw updateError(ERROR_CODES.BAD_ARGUMENTS, "rollback: 缺少 backupDir 或 targetDir", {
|
|
1125
|
+
detail: "bad_arguments",
|
|
1126
|
+
});
|
|
1127
|
+
}
|
|
1128
|
+
const backup = path.resolve(backupDir);
|
|
1129
|
+
const target = path.resolve(targetDir);
|
|
1130
|
+
|
|
1131
|
+
if (!fsImpl.existsSync(backup)) {
|
|
1132
|
+
throw updateError(ERROR_CODES.ROLLBACK_FAILED, `备份目录不存在:${backup}`, {
|
|
1133
|
+
detail: "no_backup",
|
|
1134
|
+
backupDir: backup,
|
|
1135
|
+
});
|
|
1136
|
+
}
|
|
1137
|
+
const backupInfo = readInstalled({ targetDir: backup, fsImpl });
|
|
1138
|
+
if (!backupInfo.installed) {
|
|
1139
|
+
throw updateError(ERROR_CODES.ROLLBACK_FAILED, `备份目录里没有可读的 package.json:${backup}`, {
|
|
1140
|
+
detail: "bad_backup",
|
|
1141
|
+
backupDir: backup,
|
|
1142
|
+
});
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
let movedAside = null;
|
|
1146
|
+
if (fsImpl.existsSync(target)) {
|
|
1147
|
+
movedAside = `${target}.failed-${makeStamp()}`;
|
|
1148
|
+
try {
|
|
1149
|
+
fsImpl.renameSync(target, movedAside);
|
|
1150
|
+
} catch (e) {
|
|
1151
|
+
throw updateError(ERROR_CODES.ROLLBACK_FAILED, `先把当前版本挪走时失败(${e.code || "?"}):${e.message}`, {
|
|
1152
|
+
detail: "move_aside_failed",
|
|
1153
|
+
cause: e,
|
|
1154
|
+
});
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
try {
|
|
1159
|
+
fsImpl.renameSync(backup, target);
|
|
1160
|
+
} catch (e) {
|
|
1161
|
+
if (e && e.code === "EXDEV") {
|
|
1162
|
+
try {
|
|
1163
|
+
copyTree(backup, target, fsImpl);
|
|
1164
|
+
removeTree(backup, fsImpl);
|
|
1165
|
+
} catch (e2) {
|
|
1166
|
+
if (movedAside) {
|
|
1167
|
+
removeTree(target, fsImpl); // 复制可能留了半个目录,先清掉再放回旧版本
|
|
1168
|
+
try {
|
|
1169
|
+
fsImpl.renameSync(movedAside, target);
|
|
1170
|
+
} catch {
|
|
1171
|
+
/* 尽力而为 */
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
throw updateError(ERROR_CODES.ROLLBACK_FAILED, `跨盘恢复备份失败:${e2.message}`, {
|
|
1175
|
+
detail: "copy_failed",
|
|
1176
|
+
cause: e2,
|
|
1177
|
+
movedAside,
|
|
1178
|
+
});
|
|
1179
|
+
}
|
|
1180
|
+
} else {
|
|
1181
|
+
if (movedAside) {
|
|
1182
|
+
try {
|
|
1183
|
+
fsImpl.renameSync(movedAside, target);
|
|
1184
|
+
} catch {
|
|
1185
|
+
/* 尽力而为 */
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
throw updateError(ERROR_CODES.ROLLBACK_FAILED, `恢复备份失败(${e.code || "?"}):${e.message}`, {
|
|
1189
|
+
detail: "restore_failed",
|
|
1190
|
+
cause: e,
|
|
1191
|
+
movedAside,
|
|
1192
|
+
});
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
return {
|
|
1197
|
+
ok: true,
|
|
1198
|
+
restored: true,
|
|
1199
|
+
version: backupInfo.version,
|
|
1200
|
+
targetDir: target,
|
|
1201
|
+
movedAside,
|
|
1202
|
+
consumedBackup: backup,
|
|
1203
|
+
};
|
|
1204
|
+
}
|