@marktrue/dsh-mobile-remote 3.2.1-alpha.3 → 3.2.1-rc.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/CHANGELOG.md +25 -0
- package/lib/app-carrier.js +7 -0
- package/lib/client.js +468 -0
- package/lib/endpoint-registry.js +5 -2
- package/lib/index.js +69 -23
- package/lib/npm-update-source.js +226 -0
- package/lib/plugin-self-update.js +313 -74
- package/lib/plugin-update-controller.js +200 -21
- package/lib/plugin-update-log.js +164 -0
- package/lib/plugin-update-transaction.js +4 -4
- package/lib/tool-process.js +20 -0
- package/lib/update-lock-arbiter.js +15 -3
- package/lib/update-supervisor.js +48 -14
- package/package.json +2 -2
|
@@ -10,13 +10,16 @@
|
|
|
10
10
|
// - 下载地址由 `resolvePluginUrl()` 解析后接入执行链(R4);
|
|
11
11
|
// - 期望内容标识以**解包后复算的值**为准(合成清单没有它也能正确收尾)(R5);
|
|
12
12
|
// - 安装/解包用**异步子进程**,不在宿主主线程上阻塞 HTTP/SSE/agent(R6)。
|
|
13
|
-
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
14
|
-
import { dirname, join } from "node:path";
|
|
13
|
+
import { createReadStream, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { basename, dirname, join } from "node:path";
|
|
15
15
|
import { createHash } from "node:crypto";
|
|
16
16
|
import { spawn, spawnSync } from "node:child_process";
|
|
17
|
+
import { appCarrierAlias, discoverAppCarrier } from "./app-carrier.js";
|
|
17
18
|
import { pluginContentDigest } from "./plugin-content-digest.js";
|
|
18
19
|
import { fetchUpdateResource } from "./update-fetch.js";
|
|
19
20
|
import { decidePluginUpdate, normalizeReleaseManifest } from "./plugin-update.js";
|
|
21
|
+
import { NPM_CHECK_LOG_LIMIT, createNpmPackageView, resolveNpmUpdateCandidate } from "./npm-update-source.js";
|
|
22
|
+
import { quoteShellArgument, shellOptions } from "./tool-process.js";
|
|
20
23
|
import {
|
|
21
24
|
TRANSACTION_PHASE,
|
|
22
25
|
acquireLock,
|
|
@@ -62,7 +65,7 @@ export const UPSTREAM_MANIFEST_URL = `https://github.com/${UPSTREAM_REPO}/releas
|
|
|
62
65
|
export const UPSTREAM_RELEASES_API = `https://api.github.com/repos/${UPSTREAM_REPO}/releases/latest`;
|
|
63
66
|
|
|
64
67
|
/** 更新来源的三种配置形态。 */
|
|
65
|
-
export const UPDATE_SOURCE_KIND = { UPSTREAM: "upstream", OFF: "off", CUSTOM: "custom", INVALID: "invalid" };
|
|
68
|
+
export const UPDATE_SOURCE_KIND = { NPM: "npm", UPSTREAM: "upstream", OFF: "off", CUSTOM: "custom", INVALID: "invalid" };
|
|
66
69
|
|
|
67
70
|
/** 检查结果(会随 bootstrap/diagnostics 下发,也是 PC 端提示的依据)。 */
|
|
68
71
|
export const UPDATE_CHECK_STATE = {
|
|
@@ -88,44 +91,57 @@ export const UPDATE_STEP = {
|
|
|
88
91
|
/** 回滚最多一次:避免"重启风暴"。 */
|
|
89
92
|
export const MAX_ROLLBACK_ATTEMPTS = 1;
|
|
90
93
|
|
|
91
|
-
/**
|
|
92
|
-
export function runCommand(cmd, args, { cwd, timeoutMs = 10 * 60 * 1000 } = {}) {
|
|
94
|
+
/** 异步跑一个子进程;两个输出管道始终并行 drain,避免高输出安装被管道背压卡住。 */
|
|
95
|
+
export function runCommand(cmd, args, { cwd, timeoutMs = 10 * 60 * 1000, maxOutputBytes = 16 * 1024, onOutput, platform = process.platform } = {}) {
|
|
93
96
|
return new Promise((resolve) => {
|
|
94
97
|
let child;
|
|
95
98
|
try {
|
|
96
|
-
//
|
|
97
|
-
|
|
98
|
-
child = spawn(cmd, args, { cwd, stdio: ["ignore", "ignore", "pipe"] });
|
|
99
|
+
// Windows 上 npm/pnpm 是 .cmd,必须经 shell 才能解析(见 tool-process.js)。
|
|
100
|
+
child = spawn(cmd, args, { cwd, stdio: ["ignore", "pipe", "pipe"], ...shellOptions(platform) });
|
|
99
101
|
} catch (e) {
|
|
100
|
-
resolve({ ok: false, error: String(e?.message ?? e) });
|
|
102
|
+
resolve({ ok: false, error: String(e?.message ?? e), exitCode: null, stdout: "", stderr: "" });
|
|
101
103
|
return;
|
|
102
104
|
}
|
|
103
|
-
const
|
|
104
|
-
let stderr =
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
105
|
+
const cap = Number.isSafeInteger(maxOutputBytes) && maxOutputBytes > 0 ? maxOutputBytes : 16 * 1024;
|
|
106
|
+
let stdout = Buffer.alloc(0), stderr = Buffer.alloc(0), timedOut = false, callbackFailed = false, settled = false;
|
|
107
|
+
const appendTail = (prior, chunk) => {
|
|
108
|
+
const next = Buffer.concat([prior, Buffer.from(chunk)]);
|
|
109
|
+
return next.length > cap ? next.subarray(next.length - cap) : next;
|
|
110
|
+
};
|
|
111
|
+
const onChunk = (stream, buffer) => {
|
|
112
|
+
if (stream === "stdout") stdout = appendTail(stdout, buffer);
|
|
113
|
+
else stderr = appendTail(stderr, buffer);
|
|
114
|
+
if (typeof onOutput === "function") {
|
|
115
|
+
try { onOutput(stream, Buffer.from(buffer).toString("utf8")); }
|
|
116
|
+
catch { callbackFailed = true; }
|
|
112
117
|
}
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
if (
|
|
117
|
-
|
|
118
|
-
child.on("error", (e) => {
|
|
118
|
+
};
|
|
119
|
+
let timer;
|
|
120
|
+
const finish = (result) => {
|
|
121
|
+
if (settled) return;
|
|
122
|
+
settled = true;
|
|
119
123
|
clearTimeout(timer);
|
|
120
|
-
resolve({
|
|
121
|
-
}
|
|
124
|
+
resolve({ ...result, stdout: stdout.toString("utf8"), stderr: stderr.toString("utf8") });
|
|
125
|
+
};
|
|
126
|
+
timer = setTimeout(() => {
|
|
127
|
+
timedOut = true;
|
|
128
|
+
try { child.kill("SIGKILL"); } catch { /* already exited */ }
|
|
129
|
+
}, timeoutMs);
|
|
130
|
+
child.stdout?.on("data", chunk => onChunk("stdout", chunk));
|
|
131
|
+
child.stderr?.on("data", chunk => onChunk("stderr", chunk));
|
|
132
|
+
child.on("error", (e) => finish({ ok: false, error: String(e?.message ?? e), exitCode: null }));
|
|
122
133
|
child.on("close", (code) => {
|
|
123
|
-
clearTimeout(timer);
|
|
124
134
|
if (timedOut) {
|
|
125
|
-
|
|
135
|
+
finish({ ok: false, error: `timeout-after-${Math.round(timeoutMs / 1000)}s`, exitCode: code });
|
|
126
136
|
return;
|
|
127
137
|
}
|
|
128
|
-
|
|
138
|
+
if (callbackFailed) {
|
|
139
|
+
finish({ ok: false, error: "output-capture-failed", exitCode: code });
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
finish(code === 0
|
|
143
|
+
? { ok: true, exitCode: 0 }
|
|
144
|
+
: { ok: false, error: `exit-${code ?? "unknown"}`, exitCode: code });
|
|
129
145
|
});
|
|
130
146
|
});
|
|
131
147
|
}
|
|
@@ -151,6 +167,24 @@ export function resolveManifestSource(configValue) {
|
|
|
151
167
|
return { kind: UPDATE_SOURCE_KIND.INVALID, value: v };
|
|
152
168
|
}
|
|
153
169
|
|
|
170
|
+
/** Resolve the fork's fixed NPM source; legacy source values migrate to NPM, only off disables. */
|
|
171
|
+
export function resolvePluginUpdateSource(configValue) {
|
|
172
|
+
const value = typeof configValue === "string" ? configValue.trim() : "";
|
|
173
|
+
if (value === "off") return { kind: UPDATE_SOURCE_KIND.OFF };
|
|
174
|
+
if (value === "" || value === "npm" || value === "upstream" || /^https:\/\/\S+$/.test(value)) {
|
|
175
|
+
return { kind: UPDATE_SOURCE_KIND.NPM, migrated: value !== "" && value !== "npm" };
|
|
176
|
+
}
|
|
177
|
+
try {
|
|
178
|
+
const url = new URL(value);
|
|
179
|
+
if (url.protocol === "http:" && (url.hostname === "127.0.0.1" || url.hostname === "localhost") && url.pathname.startsWith("/")) {
|
|
180
|
+
return { kind: UPDATE_SOURCE_KIND.NPM, migrated: true };
|
|
181
|
+
}
|
|
182
|
+
} catch {
|
|
183
|
+
// invalid values are rejected below
|
|
184
|
+
}
|
|
185
|
+
return { kind: UPDATE_SOURCE_KIND.INVALID, value };
|
|
186
|
+
}
|
|
187
|
+
|
|
154
188
|
async function fetchJson(url, fetchImpl, userAgent, auth = {}) {
|
|
155
189
|
try {
|
|
156
190
|
const { response: res, finalUrl } = await fetchUpdateResource(url, { fetchImpl, ...auth,
|
|
@@ -301,7 +335,10 @@ export function planForShape(shape, { specifier = "", dirPath = "", canSelfUpdat
|
|
|
301
335
|
|
|
302
336
|
/** 检查是否有新版(**不下载、不改动任何东西**)。 */
|
|
303
337
|
export async function checkForUpdate({
|
|
304
|
-
configValue = "
|
|
338
|
+
configValue = "npm",
|
|
339
|
+
packageName = "",
|
|
340
|
+
npmPackageView = null,
|
|
341
|
+
npmCwd,
|
|
305
342
|
localVersion = "",
|
|
306
343
|
localContentSha256 = "",
|
|
307
344
|
fetchImpl = fetch,
|
|
@@ -310,38 +347,48 @@ export async function checkForUpdate({
|
|
|
310
347
|
auth = {},
|
|
311
348
|
} = {}) {
|
|
312
349
|
const withCaps = (r) => (capabilities ? { ...r, ...capabilities } : r);
|
|
313
|
-
|
|
350
|
+
// 检查日志(脱敏、限行长):和手机端日志一样可展开/复制,避免“只给一个错误码”的黑盒。
|
|
351
|
+
const checkLog = [];
|
|
352
|
+
const finish = (result) => withCaps({
|
|
353
|
+
...result,
|
|
354
|
+
source: "npm",
|
|
355
|
+
checkedAt: new Date().toISOString(),
|
|
356
|
+
checkLog: checkLog.slice(-NPM_CHECK_LOG_LIMIT),
|
|
357
|
+
});
|
|
358
|
+
const source = resolvePluginUpdateSource(configValue);
|
|
314
359
|
if (source.kind === UPDATE_SOURCE_KIND.OFF) {
|
|
315
|
-
return
|
|
360
|
+
return finish({ state: UPDATE_CHECK_STATE.DISABLED, reason: "update-source-off" });
|
|
316
361
|
}
|
|
317
362
|
if (source.kind === UPDATE_SOURCE_KIND.INVALID) {
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
363
|
+
checkLog.push("更新来源配置无效(应固定为宿主 npm registry)");
|
|
364
|
+
return finish({ state: UPDATE_CHECK_STATE.UNAVAILABLE, reason: "update-source-invalid" });
|
|
365
|
+
}
|
|
366
|
+
const view = npmPackageView ?? createNpmPackageView({ packageName, cwd: npmCwd });
|
|
367
|
+
const resolved = await resolveNpmUpdateCandidate({ packageName, localVersion, packageView: view, log: checkLog });
|
|
368
|
+
if (!resolved.ok) {
|
|
369
|
+
checkLog.push(`检查失败:${resolved.reason}`);
|
|
370
|
+
return finish({ state: UPDATE_CHECK_STATE.UNAVAILABLE, reason: resolved.reason });
|
|
324
371
|
}
|
|
325
|
-
const
|
|
326
|
-
|
|
327
|
-
|
|
372
|
+
const remote = { version: resolved.plugin.version, contentSha256: resolved.plugin.contentSha256 || "" };
|
|
373
|
+
const decision = decidePluginUpdate({ localVersion, localContentSha256, remote });
|
|
374
|
+
// NPM versions are immutable. Never turn a same-version content mutation into an update.
|
|
375
|
+
if (decision.reason === "content-differs-same-version") {
|
|
376
|
+
checkLog.push(`检查失败:${remote.version} 内容标识与已安装内容不一致(不可变版本不得覆盖)`);
|
|
377
|
+
return finish({ state: UPDATE_CHECK_STATE.UNAVAILABLE, reason: "npm-version-content-mismatch" });
|
|
328
378
|
}
|
|
329
|
-
|
|
330
|
-
return
|
|
379
|
+
checkLog.push(`结论:${decision.action}(${decision.reason})`);
|
|
380
|
+
return finish({
|
|
331
381
|
state: decision.action,
|
|
332
382
|
reason: decision.reason,
|
|
333
|
-
|
|
334
|
-
synthesized: Boolean(fetched.synthesized),
|
|
335
|
-
manifestUrl: fetched.manifestUrl ?? "",
|
|
336
|
-
release: fixedReleaseSnapshot(fetched.manifest, fetched.manifestUrl),
|
|
383
|
+
candidate: resolved,
|
|
337
384
|
remote: {
|
|
338
|
-
version:
|
|
339
|
-
contentSha256:
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
385
|
+
version: resolved.plugin.version,
|
|
386
|
+
contentSha256: resolved.plugin.contentSha256 || "",
|
|
387
|
+
integrity: resolved.plugin.integrity,
|
|
388
|
+
carrierVersion: resolved.carrier.version,
|
|
389
|
+
appVersion: resolved.carrier.appVersion,
|
|
390
|
+
legacyCarrierMetadata: resolved.carrier.legacyMetadataMissing,
|
|
343
391
|
},
|
|
344
|
-
checkedAt: new Date().toISOString(),
|
|
345
392
|
});
|
|
346
393
|
}
|
|
347
394
|
|
|
@@ -405,8 +452,41 @@ export function defaultRunTar(tgzPath, destDir) {
|
|
|
405
452
|
}
|
|
406
453
|
|
|
407
454
|
/** 默认安装器:包管理器(异步、带超时)。 */
|
|
408
|
-
export function defaultRunInstall(profileDir) {
|
|
409
|
-
return runCommand("pnpm", ["install"], { cwd: profileDir });
|
|
455
|
+
export function defaultRunInstall(profileDir, { onOutput, platform = process.platform } = {}) {
|
|
456
|
+
return runCommand("pnpm", ["install"], { cwd: profileDir, onOutput, platform });
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
async function fileIntegrity(file, algorithm = "sha512") {
|
|
460
|
+
const digest = createHash(algorithm);
|
|
461
|
+
for await (const chunk of createReadStream(file)) digest.update(chunk);
|
|
462
|
+
return `${algorithm}-${digest.digest("base64")}`;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** Pack an exact NPM version through the host npm config and verify its immutable SRI. */
|
|
466
|
+
export async function packNpmTarball({ packageName, version, expectedIntegrity, destDir, cwd, run = runCommand, platform = process.platform } = {}) {
|
|
467
|
+
if (!packageName || !version || !expectedIntegrity || !destDir) return { ok: false, error: "npm-pack-input-invalid" };
|
|
468
|
+
mkdirSync(destDir, { recursive: true });
|
|
469
|
+
// Windows shell 下含空白的临时目录必须引用,否则会被拆成多个参数。
|
|
470
|
+
const result = await run("npm", ["pack", "--json", "--ignore-scripts", "--pack-destination", quoteShellArgument(destDir, platform), `${packageName}@${version}`], {
|
|
471
|
+
cwd, timeoutMs: 120_000, maxOutputBytes: 256 * 1024, ...shellOptions(platform),
|
|
472
|
+
});
|
|
473
|
+
if (!result?.ok) return { ok: false, error: "npm-pack-failed" };
|
|
474
|
+
let entries;
|
|
475
|
+
try { entries = JSON.parse(result.stdout); } catch { return { ok: false, error: "npm-pack-json-invalid" }; }
|
|
476
|
+
const entry = Array.isArray(entries) && entries.length === 1 ? entries[0] : null;
|
|
477
|
+
if (!entry || entry.name !== packageName || entry.version !== version || entry.integrity !== expectedIntegrity) {
|
|
478
|
+
return { ok: false, error: "npm-pack-metadata-mismatch" };
|
|
479
|
+
}
|
|
480
|
+
const filename = typeof entry.filename === "string" ? basename(entry.filename) : "";
|
|
481
|
+
if (filename === "" || !filename.endsWith(".tgz")) return { ok: false, error: "npm-pack-filename-invalid" };
|
|
482
|
+
const file = join(destDir, filename);
|
|
483
|
+
try {
|
|
484
|
+
if (!statSync(file).isFile()) return { ok: false, error: "npm-pack-tarball-missing" };
|
|
485
|
+
} catch { return { ok: false, error: "npm-pack-tarball-missing" }; }
|
|
486
|
+
let actualIntegrity;
|
|
487
|
+
try { actualIntegrity = await fileIntegrity(file); } catch { return { ok: false, error: "npm-pack-tarball-unreadable" }; }
|
|
488
|
+
if (actualIntegrity !== expectedIntegrity) return { ok: false, error: "npm-pack-integrity-mismatch" };
|
|
489
|
+
return { ok: true, file, filename, integrity: actualIntegrity, size: statSync(file).size };
|
|
410
490
|
}
|
|
411
491
|
|
|
412
492
|
/**
|
|
@@ -428,6 +508,103 @@ export async function extractVerified({ tgzPath, workDir, contentSha256, runTar
|
|
|
428
508
|
return { ok: true, digest, packageDir };
|
|
429
509
|
}
|
|
430
510
|
|
|
511
|
+
async function fileSha256(file) {
|
|
512
|
+
const digest = createHash("sha256");
|
|
513
|
+
for await (const chunk of createReadStream(file)) digest.update(chunk);
|
|
514
|
+
return digest.digest("hex");
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
async function safeTarListing(tarball, run = runCommand) {
|
|
518
|
+
const listing = await run("tar", ["-tzf", tarball], { timeoutMs: 30_000, maxOutputBytes: 256 * 1024 });
|
|
519
|
+
if (!listing?.ok) return { ok: false, error: "npm-tar-list-failed" };
|
|
520
|
+
const entries = listing.stdout.split(/\r?\n/).filter(Boolean);
|
|
521
|
+
if (entries.length === 0 || entries.length > 2048) return { ok: false, error: "npm-tar-list-invalid" };
|
|
522
|
+
for (const entry of entries) {
|
|
523
|
+
const normalized = entry.replace(/\\/g, "/");
|
|
524
|
+
if (normalized.startsWith("/") || normalized.split("/").includes("..") || (normalized !== "package" && !normalized.startsWith("package/"))) {
|
|
525
|
+
return { ok: false, error: "npm-tar-path-invalid" };
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
return { ok: true };
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/** Download and validate the exact NPM pair before touching the profile. */
|
|
532
|
+
export async function prepareNpmArtifacts({ npmCandidate, packageName, profileDir, workDir, runCommandImpl = runCommand, runTar = defaultRunTar } = {}) {
|
|
533
|
+
const name = packageName || npmCandidate?.plugin?.name || "";
|
|
534
|
+
if (!npmCandidate?.plugin?.version || !npmCandidate?.carrier?.version || !name || !profileDir || !workDir) {
|
|
535
|
+
return { ok: false, error: "npm-candidate-incomplete" };
|
|
536
|
+
}
|
|
537
|
+
const pluginPackDir = join(workDir, "npm-plugin");
|
|
538
|
+
const carrierPackDir = join(workDir, "npm-carrier");
|
|
539
|
+
const plugin = await packNpmTarball({ packageName: name, version: npmCandidate.plugin.version,
|
|
540
|
+
expectedIntegrity: npmCandidate.plugin.integrity, destDir: pluginPackDir, cwd: profileDir, run: runCommandImpl });
|
|
541
|
+
if (!plugin.ok) return plugin;
|
|
542
|
+
const carrier = await packNpmTarball({ packageName: name, version: npmCandidate.carrier.version,
|
|
543
|
+
expectedIntegrity: npmCandidate.carrier.integrity, destDir: carrierPackDir, cwd: profileDir, run: runCommandImpl });
|
|
544
|
+
if (!carrier.ok) return carrier;
|
|
545
|
+
|
|
546
|
+
const pluginListing = await safeTarListing(plugin.file, runCommandImpl);
|
|
547
|
+
if (!pluginListing.ok) return pluginListing;
|
|
548
|
+
const carrierListing = await safeTarListing(carrier.file, runCommandImpl);
|
|
549
|
+
if (!carrierListing.ok) return carrierListing;
|
|
550
|
+
const pluginExtract = await extractVerified({ tgzPath: plugin.file, workDir: join(workDir, "plugin-extract"),
|
|
551
|
+
contentSha256: npmCandidate.plugin.contentSha256 || "", runTar });
|
|
552
|
+
if (!pluginExtract.ok) return { ok: false, error: `npm-plugin-${pluginExtract.error}` };
|
|
553
|
+
const carrierExtractDir = join(workDir, "carrier-extract");
|
|
554
|
+
rmSync(carrierExtractDir, { recursive: true, force: true });
|
|
555
|
+
mkdirSync(carrierExtractDir, { recursive: true });
|
|
556
|
+
const carrierUnpack = await runTar(carrier.file, carrierExtractDir);
|
|
557
|
+
if (!carrierUnpack?.ok) return { ok: false, error: "npm-carrier-extract-failed" };
|
|
558
|
+
const carrierPackageDir = join(carrierExtractDir, "package");
|
|
559
|
+
let manifest;
|
|
560
|
+
try { manifest = JSON.parse(readFileSync(join(carrierPackageDir, "manifest.json"), "utf8")); }
|
|
561
|
+
catch { return { ok: false, error: "npm-carrier-manifest-unreadable" }; }
|
|
562
|
+
// npm pack 会把 tarball 名规范化(加 scope 前缀、去掉发布产物里的 `-v`),
|
|
563
|
+
// 因此不能拿 manifest.plugin.file 与下载文件名逐字比较;身份由版本、大小与
|
|
564
|
+
// 后续 sha256/contentSha256 校验共同钉死。
|
|
565
|
+
if (manifest?.schema !== 1 || manifest?.plugin?.version !== npmCandidate.plugin.version
|
|
566
|
+
|| manifest?.plugin?.size !== plugin.size) {
|
|
567
|
+
return { ok: false, error: "npm-carrier-plugin-identity-mismatch" };
|
|
568
|
+
}
|
|
569
|
+
let tarballSha256;
|
|
570
|
+
try { tarballSha256 = await fileSha256(plugin.file); } catch { return { ok: false, error: "npm-plugin-tarball-unreadable" }; }
|
|
571
|
+
if (manifest.plugin.sha256 !== tarballSha256) return { ok: false, error: "npm-plugin-tarball-sha256-mismatch" };
|
|
572
|
+
const pluginContentSha256 = pluginExtract.digest;
|
|
573
|
+
if (manifest.plugin.contentSha256 !== pluginContentSha256) return { ok: false, error: "npm-plugin-content-sha256-mismatch" };
|
|
574
|
+
if (npmCandidate.plugin.contentSha256 && npmCandidate.plugin.contentSha256 !== pluginContentSha256) {
|
|
575
|
+
return { ok: false, error: "npm-packument-content-sha256-mismatch" };
|
|
576
|
+
}
|
|
577
|
+
if (npmCandidate.carrier.appVersion && npmCandidate.carrier.appVersion !== manifest.app?.version) {
|
|
578
|
+
return { ok: false, error: "npm-packument-app-version-mismatch" };
|
|
579
|
+
}
|
|
580
|
+
if (npmCandidate.carrier.pluginContentSha256 && npmCandidate.carrier.pluginContentSha256 !== manifest.plugin.contentSha256) {
|
|
581
|
+
return { ok: false, error: "npm-packument-content-sha256-mismatch" };
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
const alias = appCarrierAlias(name);
|
|
585
|
+
const link = join(pluginExtract.packageDir, "node_modules", alias);
|
|
586
|
+
try {
|
|
587
|
+
mkdirSync(dirname(link), { recursive: true });
|
|
588
|
+
symlinkSync(carrierPackageDir, link, "dir");
|
|
589
|
+
} catch { return { ok: false, error: "npm-carrier-link-failed" }; }
|
|
590
|
+
const inspected = discoverAppCarrier({ pluginRoot: pluginExtract.packageDir, dshHome: join(workDir, "isolated-dsh") });
|
|
591
|
+
if (!inspected.ok || inspected.pluginContent?.matches !== true) {
|
|
592
|
+
return { ok: false, error: inspected.reason || "npm-carrier-validation-failed" };
|
|
593
|
+
}
|
|
594
|
+
return {
|
|
595
|
+
ok: true,
|
|
596
|
+
workDir,
|
|
597
|
+
pluginTgzPath: plugin.file,
|
|
598
|
+
carrierTgzPath: carrier.file,
|
|
599
|
+
pluginPackageDir: pluginExtract.packageDir,
|
|
600
|
+
carrierPackageDir,
|
|
601
|
+
pluginContentSha256,
|
|
602
|
+
pluginTarballSha256: tarballSha256,
|
|
603
|
+
carrierAppVersion: inspected.manifest.appVersion,
|
|
604
|
+
manifest,
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
|
|
431
608
|
/** 把解包好的版本目录记进 profile 的依赖声明(持久生效的关键,见 ADR 0019)。 */
|
|
432
609
|
export function switchDependencySpecifier({ profileDir, nextSpecifier, keys = [LEGACY_DEPENDENCY_KEY], readJson = readJsonFile, writeJson = writeJsonFile } = {}) {
|
|
433
610
|
const file = join(profileDir, "package.json");
|
|
@@ -623,11 +800,12 @@ export async function waitForSupervisorReady({
|
|
|
623
800
|
* 由调用方启动外部监督器接管(重启 + 健康确认 + 失败回滚)。
|
|
624
801
|
*/
|
|
625
802
|
export async function runSelfUpdate({
|
|
626
|
-
home, plugin, release = null, manifestUrl = "", profileDir, restartForm = "unsupported", hostPid = process.pid,
|
|
803
|
+
home, plugin, release = null, npmCandidate = null, packageName = "", manifestUrl = "", profileDir, restartForm = "unsupported", hostPid = process.pid,
|
|
627
804
|
port = 3080, basePath = "/m", token = "", profile = "web", dshCommand = "dsh", installedRoot = "", pluginRoot = "",
|
|
628
805
|
needsEscape = false, supervisorTimeoutMs = 15_000, freezeImpl = freezeSupervisor, spawnArmedImpl = spawnSupervisorArmed,
|
|
629
806
|
awaitReadyImpl = waitForSupervisorReady, opId = newOperationId(), fetchImpl = fetch, auth = {}, runTar = defaultRunTar,
|
|
630
|
-
runInstall = defaultRunInstall, onStep = () => {},
|
|
807
|
+
runInstall = defaultRunInstall, onStep = () => {}, onOutput = () => {}, prepareNpmArtifactsImpl = prepareNpmArtifacts,
|
|
808
|
+
preparedNpmArtifacts = null, log = () => {}, now = () => Date.now(),
|
|
631
809
|
lease: admittedLease, initialTransaction, stateFile: operationStateFile, currentFile = transactionFile(home),
|
|
632
810
|
lockPath = updateLockPath(home),
|
|
633
811
|
} = {}) {
|
|
@@ -638,11 +816,14 @@ export async function runSelfUpdate({
|
|
|
638
816
|
const lease = grant.lease, id = lease.generation;
|
|
639
817
|
const stateFile = operationStateFile || serverOperationPath(home, id);
|
|
640
818
|
let tx = initialTransaction ?? { opId, lease, id, phase: "staging", startedAt: new Date(now()).toISOString(),
|
|
641
|
-
profileDir, installedRoot, manifestUrl, release, restartForm, hostPid, port, basePath, token, profile, dshCommand,
|
|
819
|
+
profileDir, installedRoot, manifestUrl, release, npmCandidate, restartForm, hostPid, port, basePath, token, profile, dshCommand,
|
|
642
820
|
lockPath, currentFile, targetVersion: plugin?.version ?? "", targetContentSha256: plugin?.contentSha256 ?? "", attempts: 0 };
|
|
643
|
-
let destructive = false, step = "supervisor";
|
|
821
|
+
let destructive = false, step = "supervisor", npmWorkDir = "";
|
|
644
822
|
const save = (patch, expectedPhases) => { tx = writeOwnedTransaction(stateFile, { ...tx, ...patch }, { lockPath, lease, currentFile, expectedPhases }); return tx; };
|
|
645
823
|
const fail = error => {
|
|
824
|
+
if (npmCandidate && npmWorkDir) {
|
|
825
|
+
try { rmSync(npmWorkDir, { recursive: true, force: true }); } catch { /* recovery snapshot is independent of NPM scratch */ }
|
|
826
|
+
}
|
|
646
827
|
// 一旦可能动过 profile/安装字节,只有已 armed 的监督器能结束这个事务(C5)。
|
|
647
828
|
if (destructive) {
|
|
648
829
|
save({ phase: "rolling-back", rolledBack: true, result: { ok: false, reason: error }, recoveryCause: error }, ["installing"]);
|
|
@@ -666,19 +847,39 @@ export async function runSelfUpdate({
|
|
|
666
847
|
assertUpdateLease(lockPath, lease);
|
|
667
848
|
|
|
668
849
|
const work = join(workRoot(home), id); mkdirSync(work, { recursive: true });
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
850
|
+
if (npmCandidate && !preparedNpmArtifacts) npmWorkDir = work;
|
|
851
|
+
let staged, npmPrepared = null;
|
|
852
|
+
step = UPDATE_STEP.DOWNLOAD; onStep(step);
|
|
853
|
+
if (npmCandidate) {
|
|
854
|
+
if (npmCandidate.carrier?.legacyMetadataMissing && !preparedNpmArtifacts) return fail("legacy-carrier-preparation-required");
|
|
855
|
+
npmPrepared = preparedNpmArtifacts ?? await prepareNpmArtifactsImpl({ npmCandidate, packageName: packageName || npmCandidate.plugin?.name,
|
|
856
|
+
profileDir, workDir: work, runCommandImpl: runCommand, runTar });
|
|
857
|
+
assertUpdateLease(lockPath, lease);
|
|
858
|
+
if (!npmPrepared?.ok) return fail(npmPrepared?.error ?? "npm-artifact-preparation-failed");
|
|
859
|
+
if (npmPrepared.manifest?.plugin?.version !== npmCandidate.plugin.version
|
|
860
|
+
|| npmPrepared.manifest?.plugin?.contentSha256 !== npmPrepared.pluginContentSha256
|
|
861
|
+
|| npmPrepared.manifest?.app?.version !== npmPrepared.carrierAppVersion) return fail("npm-prepared-candidate-mismatch");
|
|
862
|
+
staged = { ok: true, digest: npmPrepared.pluginContentSha256, packageDir: npmPrepared.pluginPackageDir };
|
|
863
|
+
} else {
|
|
864
|
+
const tgzPath = join(work, "plugin.tgz");
|
|
865
|
+
const downloaded = await downloadVerified({ url: resolvePluginUrl(manifestUrl, plugin), destPath: tgzPath,
|
|
866
|
+
sha256: plugin.sha256, size: plugin.size ?? null, fetchImpl, auth });
|
|
867
|
+
assertUpdateLease(lockPath, lease);
|
|
868
|
+
if (!downloaded.ok) return fail(downloaded.error);
|
|
869
|
+
onStep(UPDATE_STEP.VERIFY); step = UPDATE_STEP.EXTRACT; onStep(step);
|
|
870
|
+
staged = await extractVerified({ tgzPath, workDir: join(work, "extract"), contentSha256: plugin.contentSha256, runTar });
|
|
871
|
+
assertUpdateLease(lockPath, lease);
|
|
872
|
+
if (!staged.ok) return fail(staged.error);
|
|
873
|
+
}
|
|
874
|
+
if (!staged?.digest || !staged.packageDir) return fail("staged-plugin-invalid");
|
|
678
875
|
const destDir = artifactDir(artifactRoot(home), staged.digest);
|
|
679
876
|
if (existsSync(destDir)) {
|
|
680
877
|
if (pluginContentDigest(destDir) !== staged.digest) return fail("artifact-integrity-mismatch");
|
|
681
|
-
} else {
|
|
878
|
+
} else {
|
|
879
|
+
mkdirSync(dirname(destDir), { recursive: true });
|
|
880
|
+
if (npmCandidate) rmSync(join(staged.packageDir, "node_modules"), { recursive: true, force: true });
|
|
881
|
+
renameSync(staged.packageDir, destDir);
|
|
882
|
+
}
|
|
682
883
|
|
|
683
884
|
step = UPDATE_STEP.SNAPSHOT; onStep(step);
|
|
684
885
|
if (!installedRoot || !existsSync(installedRoot)) return fail("installed-root-missing");
|
|
@@ -696,18 +897,56 @@ export async function runSelfUpdate({
|
|
|
696
897
|
const dependencyKeys = dependencyKeysFor(pluginRoot);
|
|
697
898
|
const dependencyKey = dependencyKeyIn(JSON.parse(packageFile), dependencyKeys) || LEGACY_DEPENDENCY_KEY;
|
|
698
899
|
const oldSpecifier = JSON.parse(packageFile).dependencies?.[dependencyKey];
|
|
900
|
+
const npmPackageName = npmCandidate?.plugin?.name || packageName;
|
|
901
|
+
const targetAppVersion = npmPrepared?.carrierAppVersion || npmPrepared?.manifest?.app?.version || npmCandidate?.carrier?.appVersion || null;
|
|
902
|
+
const resolvedNpmCandidate = npmCandidate ? {
|
|
903
|
+
...npmCandidate,
|
|
904
|
+
plugin: { ...npmCandidate.plugin, contentSha256: staged.digest },
|
|
905
|
+
carrier: { ...npmCandidate.carrier, appVersion: targetAppVersion,
|
|
906
|
+
pluginContentSha256: staged.digest, legacyMetadataMissing: false },
|
|
907
|
+
} : null;
|
|
699
908
|
// Durable recovery metadata precedes the first destructive write, so crashes also recover.
|
|
700
|
-
save({ phase: "installing", artifactDir: destDir, targetContentSha256: staged.digest,
|
|
701
|
-
previousSnapshotDir: previousSnapshot, previousSpecifier: oldSpecifier, dependencyKey,
|
|
702
|
-
profileSnapshot: { packageJson: packageFile, pnpmLock, dependencyKey },
|
|
909
|
+
save({ phase: "installing", artifactDir: destDir, targetContentSha256: staged.digest, targetAppVersion,
|
|
910
|
+
previousContentSha256: previousContent, previousSnapshotDir: previousSnapshot, previousSpecifier: oldSpecifier, dependencyKey,
|
|
911
|
+
profileSnapshot: { packageJson: packageFile, pnpmLock, dependencyKey }, npmCandidate: resolvedNpmCandidate,
|
|
703
912
|
release: tx.release ? { ...tx.release, plugin: { ...tx.release.plugin, contentSha256: staged.digest } } : null }, ["staging"]);
|
|
704
913
|
destructive = true; step = UPDATE_STEP.SWITCH; onStep(step);
|
|
705
|
-
const
|
|
914
|
+
const exactNpmSpecifier = npmCandidate
|
|
915
|
+
? (dependencyKey === npmPackageName ? npmCandidate.plugin.version : `npm:${npmPackageName}@${npmCandidate.plugin.version}`)
|
|
916
|
+
: `file:${destDir}`;
|
|
917
|
+
const switched = switchOwnedDependency({ profileDir, nextSpecifier: exactNpmSpecifier, key: dependencyKey, lockPath, lease });
|
|
706
918
|
if (!switched.ok) return fail(switched.error);
|
|
707
919
|
step = UPDATE_STEP.INSTALL; onStep(step); assertUpdateLease(lockPath, lease);
|
|
708
|
-
const installed = await runInstall(profileDir);
|
|
920
|
+
const installed = await runInstall(profileDir, { onOutput });
|
|
709
921
|
assertUpdateLease(lockPath, lease);
|
|
710
922
|
if (!installed?.ok) return fail(installed?.error ?? "install-failed");
|
|
923
|
+
if (npmCandidate) {
|
|
924
|
+
const targetPath = join(profileDir, "node_modules", ...dependencyKey.split("/"));
|
|
925
|
+
let resolvedRoot;
|
|
926
|
+
try { resolvedRoot = realpathSync(targetPath); } catch { return fail("npm-installed-plugin-missing"); }
|
|
927
|
+
let installedPackage;
|
|
928
|
+
try { installedPackage = JSON.parse(readFileSync(join(resolvedRoot, "package.json"), "utf8")); }
|
|
929
|
+
catch { return fail("npm-installed-package-invalid"); }
|
|
930
|
+
if (installedPackage.name !== npmPackageName || installedPackage.version !== npmCandidate.plugin.version) return fail("npm-installed-package-identity-mismatch");
|
|
931
|
+
let installedDigest;
|
|
932
|
+
try { installedDigest = pluginContentDigest(resolvedRoot); } catch { return fail("npm-installed-content-unreadable"); }
|
|
933
|
+
if (installedDigest !== staged.digest) return fail("npm-installed-content-sha256-mismatch");
|
|
934
|
+
const installedCarrier = discoverAppCarrier({ pluginRoot: resolvedRoot, dshHome: join(home, ".dsh") });
|
|
935
|
+
if (!installedCarrier.ok || installedCarrier.pluginContent?.matches !== true) return fail(`npm-installed-carrier-invalid:${installedCarrier.reason ?? "content-mismatch"}`);
|
|
936
|
+
if (installedCarrier.carrier.version !== npmCandidate.carrier.version || installedCarrier.manifest.appVersion !== targetAppVersion) {
|
|
937
|
+
return fail("npm-installed-carrier-pairing-mismatch");
|
|
938
|
+
}
|
|
939
|
+
if (installedCarrier.manifest.pluginTgzSha256 !== npmPrepared.manifest.plugin.sha256
|
|
940
|
+
|| installedCarrier.manifest.contentSha256 !== staged.digest
|
|
941
|
+
|| installedCarrier.apk.sha256 !== npmPrepared.manifest.app?.sha256
|
|
942
|
+
|| installedCarrier.apk.size !== npmPrepared.manifest.app?.size) {
|
|
943
|
+
return fail("npm-installed-carrier-integrity-mismatch");
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
if (npmCandidate) {
|
|
947
|
+
// The package-manager install is complete; discard its NPM tarball/extraction scratch before restart.
|
|
948
|
+
try { rmSync(work, { recursive: true, force: true }); } catch { /* cleanup cannot invalidate an installed package */ }
|
|
949
|
+
}
|
|
711
950
|
step = UPDATE_STEP.RESTART; onStep(step);
|
|
712
951
|
save({ phase: "restart-pending", installedAt: new Date(now()).toISOString(), result: null }, ["installing"]);
|
|
713
952
|
log(`mobile-remote: 已落位,监督器将确认内容 ${staged.digest.slice(0,12)}`);
|