@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.
@@ -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
- // stdout 直接丢弃(**不能**设成 pipe 又不消费:管道写满后子进程会阻塞到超时,F5)。
97
- // stderr 保留尾部若干字节用于报错。
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 STDERR_CAP = 16 * 1024;
104
- let stderr = "";
105
- let timedOut = false;
106
- const timer = setTimeout(() => {
107
- timedOut = true;
108
- try {
109
- child.kill("SIGKILL");
110
- } catch {
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
- }, timeoutMs);
114
- child.stderr?.on("data", (d) => {
115
- stderr += d.toString();
116
- if (stderr.length > STDERR_CAP) stderr = stderr.slice(-STDERR_CAP);
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({ ok: false, error: String(e?.message ?? e) });
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
- resolve({ ok: false, error: `timeout-after-${Math.round(timeoutMs / 1000)}s${stderr ? `: ${stderr.trim()}` : ""}` });
135
+ finish({ ok: false, error: `timeout-after-${Math.round(timeoutMs / 1000)}s`, exitCode: code });
126
136
  return;
127
137
  }
128
- resolve(code === 0 ? { ok: true } : { ok: false, error: (stderr || `exit ${code}`).trim() });
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 = "upstream",
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
- const source = resolveManifestSource(configValue);
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 withCaps({ state: UPDATE_CHECK_STATE.DISABLED, reason: "update-source-off", checkedAt: new Date().toISOString() });
360
+ return finish({ state: UPDATE_CHECK_STATE.DISABLED, reason: "update-source-off" });
316
361
  }
317
362
  if (source.kind === UPDATE_SOURCE_KIND.INVALID) {
318
- return withCaps({
319
- state: UPDATE_CHECK_STATE.UNAVAILABLE,
320
- reason: "update-source-invalid",
321
- source: String(source.value ?? ""),
322
- checkedAt: new Date().toISOString(),
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 fetched = await fetchReleaseManifest({ source, fetchImpl, userAgent, auth });
326
- if (!fetched.ok) {
327
- return withCaps({ state: UPDATE_CHECK_STATE.UNAVAILABLE, reason: fetched.error, source: source.kind, checkedAt: new Date().toISOString() });
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
- const decision = decidePluginUpdate({ localVersion, localContentSha256, remote: fetched.manifest.plugin });
330
- return withCaps({
379
+ checkLog.push(`结论:${decision.action}(${decision.reason})`);
380
+ return finish({
331
381
  state: decision.action,
332
382
  reason: decision.reason,
333
- source: source.kind,
334
- synthesized: Boolean(fetched.synthesized),
335
- manifestUrl: fetched.manifestUrl ?? "",
336
- release: fixedReleaseSnapshot(fetched.manifest, fetched.manifestUrl),
383
+ candidate: resolved,
337
384
  remote: {
338
- version: fetched.manifest.plugin.version,
339
- contentSha256: fetched.manifest.plugin.contentSha256 || "",
340
- sha256: fetched.manifest.plugin.sha256 || "",
341
- size: fetched.manifest.plugin.size ?? null,
342
- url: resolvePluginUrl(fetched.manifestUrl ?? "", fetched.manifest.plugin),
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 = () => {}, log = () => {}, now = () => Date.now(),
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
- const tgzPath = join(work, "plugin.tgz"); step = UPDATE_STEP.DOWNLOAD; onStep(step);
670
- const downloaded = await downloadVerified({ url: resolvePluginUrl(manifestUrl, plugin), destPath: tgzPath,
671
- sha256: plugin.sha256, size: plugin.size ?? null, fetchImpl, auth });
672
- assertUpdateLease(lockPath, lease);
673
- if (!downloaded.ok) return fail(downloaded.error);
674
- onStep(UPDATE_STEP.VERIFY); step = UPDATE_STEP.EXTRACT; onStep(step);
675
- const staged = await extractVerified({ tgzPath, workDir: join(work, "extract"), contentSha256: plugin.contentSha256, runTar });
676
- assertUpdateLease(lockPath, lease);
677
- if (!staged.ok) return fail(staged.error);
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 { mkdirSync(dirname(destDir), { recursive: true }); renameSync(staged.packageDir, destDir); }
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, previousContentSha256: previousContent,
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 switched = switchOwnedDependency({ profileDir, nextSpecifier: `file:${destDir}`, key: dependencyKey, lockPath, lease });
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)}`);