@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
package/lib/index.js
CHANGED
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
} from "./app-alignment.js";
|
|
40
40
|
export { APP_VERSION_HEADER } from "./app-alignment.js";
|
|
41
41
|
import { clientReleaseView, createPluginUpdateController, publicTransaction } from "./plugin-update-controller.js";
|
|
42
|
+
import { readOperationLog } from "./plugin-update-log.js";
|
|
42
43
|
import { pluginContentDigest } from "./plugin-content-digest.js";
|
|
43
44
|
import {
|
|
44
45
|
BRIDGE_PORT_CANDIDATES,
|
|
@@ -54,11 +55,10 @@ import {
|
|
|
54
55
|
TRANSACTION_PHASE,
|
|
55
56
|
checkForUpdate,
|
|
56
57
|
detectRestartForm,
|
|
57
|
-
fetchReleaseManifest,
|
|
58
58
|
isTerminalPhase,
|
|
59
59
|
planForShape,
|
|
60
60
|
reportPendingUpdate,
|
|
61
|
-
|
|
61
|
+
resolvePluginUpdateSource,
|
|
62
62
|
resolvePluginUrl,
|
|
63
63
|
restartPlan,
|
|
64
64
|
runRestart,
|
|
@@ -78,6 +78,7 @@ import {
|
|
|
78
78
|
releaseLock,
|
|
79
79
|
transactionFile,
|
|
80
80
|
updateLockFile,
|
|
81
|
+
validOperationId,
|
|
81
82
|
} from "./plugin-update-transaction.js";
|
|
82
83
|
import {
|
|
83
84
|
CARRIER_STATUS,
|
|
@@ -196,18 +197,11 @@ export const Config = z.object({
|
|
|
196
197
|
/** App 自动更新主机源目录;空值表示未配置。 */
|
|
197
198
|
updateDir: z.string().default(""),
|
|
198
199
|
/**
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
* - `"off"`:完全不检查(离线/隐私要求);
|
|
202
|
-
* - 其他必须是 `https://` 开头的清单地址(自建发布源)。
|
|
203
|
-
* 检查**只读**,不改动本机任何东西;真正更新只在用户从手机明确触发时发生。
|
|
200
|
+
* 插件自动更新来源固定为宿主 npm 配置;`off` 是管理员关闭开关。旧配置中的 upstream/自建源值会迁移到 npm,不再作为更新来源。
|
|
201
|
+
* 检查只读;安装仍需用户明确确认,并由宿主事务重启/健康核验。
|
|
204
202
|
*/
|
|
205
203
|
updateProfileDir: z.string().default("").description("显式指定插件依赖所在 profile;为空时验证式定位。"),
|
|
206
|
-
|
|
207
|
-
pluginUpdateManifest: z
|
|
208
|
-
.string()
|
|
209
|
-
.pattern(/^(upstream|off|https:\/\/\S+|http:\/\/(127\.0\.0\.1|localhost)(:\d+)?\/\S*)$/)
|
|
210
|
-
.default("upstream"),
|
|
204
|
+
pluginUpdateManifest: z.string().default("npm").description("固定使用宿主 npm registry;只有设置为 off 时停用自动更新。"),
|
|
211
205
|
/** SSE 连接数上限。 */
|
|
212
206
|
maxConnections: z.number().default(16),
|
|
213
207
|
/**
|
|
@@ -2242,13 +2236,20 @@ export function apply(ctx, config, runtime = {}) {
|
|
|
2242
2236
|
// 检查是**非破坏性**的:只读清单、只改内存状态。真正改动 profile、安装依赖、重启宿主
|
|
2243
2237
|
// 只在用户从手机明确触发时发生(见 POST /plugin-update),且由**外部监督器**收尾。
|
|
2244
2238
|
const pluginRootDir = resolve(fileURLToPath(new URL("..", import.meta.url)));
|
|
2239
|
+
const pluginPackageName = (() => {
|
|
2240
|
+
try { return JSON.parse(readFileSync(join(pluginRootDir, "package.json"), "utf8")).name ?? ""; }
|
|
2241
|
+
catch { return ""; }
|
|
2242
|
+
})();
|
|
2245
2243
|
// profile 目录:靠**验证式查找**(向上找声明了本插件依赖的 package.json),
|
|
2246
2244
|
// 不靠模块层级推断——pnpm virtual store 下"往上两层"必错(评审 R3)。
|
|
2247
2245
|
// 允许显式配置覆盖;两者都拿不到就只给降级指引,绝不动文件。
|
|
2248
2246
|
const pluginDependencyKeys = dependencyKeysFor(pluginRootDir);
|
|
2249
|
-
const
|
|
2250
|
-
|
|
2251
|
-
|
|
2247
|
+
const runtimeProfileDir = typeof runtime.pluginUpdates?.profileDir === "string" ? runtime.pluginUpdates.profileDir.trim() : undefined;
|
|
2248
|
+
const pluginProfileDir = runtimeProfileDir !== undefined
|
|
2249
|
+
? runtimeProfileDir
|
|
2250
|
+
: (typeof config.updateProfileDir === "string" && config.updateProfileDir.trim() !== "")
|
|
2251
|
+
? config.updateProfileDir.trim()
|
|
2252
|
+
: findProfileDir(pluginRootDir);
|
|
2252
2253
|
const selfContentSha256 = pluginContentDigest(pluginRootDir);
|
|
2253
2254
|
const pluginUpdateStateFile = transactionFile(homedir());
|
|
2254
2255
|
const pluginUpdateLockFile = updateLockFile(homedir());
|
|
@@ -2290,7 +2291,7 @@ export function apply(ctx, config, runtime = {}) {
|
|
|
2290
2291
|
const ensureUpdateCheck = () => {
|
|
2291
2292
|
if (pluginUpdateChecked) return;
|
|
2292
2293
|
pluginUpdateChecked = true;
|
|
2293
|
-
if (
|
|
2294
|
+
if (resolvePluginUpdateSource(config.pluginUpdateManifest).kind === UPDATE_SOURCE_KIND.OFF) return;
|
|
2294
2295
|
updates.check()
|
|
2295
2296
|
.then((r) => Object.assign(pluginUpdateStatus, r))
|
|
2296
2297
|
.catch((e) => Object.assign(pluginUpdateStatus, { state: "unavailable", reason: String(e?.message ?? e) }));
|
|
@@ -2316,7 +2317,7 @@ export function apply(ctx, config, runtime = {}) {
|
|
|
2316
2317
|
return {
|
|
2317
2318
|
clientOrigin: publicReleaseOrigin(_req),
|
|
2318
2319
|
basePath,
|
|
2319
|
-
loopback:
|
|
2320
|
+
loopback: resolvePluginUpdateSource(config.pluginUpdateManifest).loopback === true,
|
|
2320
2321
|
};
|
|
2321
2322
|
}
|
|
2322
2323
|
/** 事务摘要:手机据此判断"结果待确认/已完成/已回滚",而不是只比版本号(评审 R7)。 */
|
|
@@ -2335,7 +2336,7 @@ export function apply(ctx, config, runtime = {}) {
|
|
|
2335
2336
|
};
|
|
2336
2337
|
}
|
|
2337
2338
|
const updates = createPluginUpdateController({
|
|
2338
|
-
home: homedir(), configValue: config.pluginUpdateManifest, profileDir: pluginProfileDir,
|
|
2339
|
+
home: homedir(), configValue: config.pluginUpdateManifest, profileDir: pluginProfileDir, packageName: pluginPackageName,
|
|
2339
2340
|
capabilities: pluginUpdateCapabilities, localVersion: () => pluginVersion(), localContentSha256: selfContentSha256,
|
|
2340
2341
|
status: pluginUpdateStatus, log: m => ctx.logger.warn(m),
|
|
2341
2342
|
// basePath 必须一起传:helper 按精确挂载路径集合决定是否附口令,缺了它非默认挂载会 401(E3)
|
|
@@ -3931,10 +3932,21 @@ export function apply(ctx, config, runtime = {}) {
|
|
|
3931
3932
|
const tx = readTransaction(pluginUpdateStateFile);
|
|
3932
3933
|
const transaction = tx ? { opId: String(tx.opId ?? ""), generation: tx.lease?.generation ?? null } : null;
|
|
3933
3934
|
const base = { runningContentSha256: selfContentSha256, instanceId: pluginInstanceId, transaction };
|
|
3934
|
-
if (tx
|
|
3935
|
-
|
|
3935
|
+
if (tx) {
|
|
3936
|
+
const phase = String(tx.phase ?? "");
|
|
3937
|
+
const failedLike = tx.rolledBack === true || ["rolling-back", "rolled-back", "failed"].includes(phase);
|
|
3938
|
+
// 终态失败只在“那次更新尝试的目标内容仍等于当前运行内容”时才继续描述现状。
|
|
3939
|
+
// 回滚成功或用户之后手工升级/重启后,运行内容已不是该失败目标,历史失败不能永久
|
|
3940
|
+
// 污染 currentReleaseHealth(否则手机端的“插件先行”门禁会一直被挡住)。
|
|
3941
|
+
const targetContent = String(tx.targetContentSha256 ?? "").toLowerCase();
|
|
3942
|
+
const runningContent = String(selfContentSha256 ?? "").toLowerCase();
|
|
3943
|
+
const describesRunning = targetContent === "" || targetContent === runningContent;
|
|
3944
|
+
if (failedLike) {
|
|
3945
|
+
if (describesRunning) return { ...base, state: "failed" };
|
|
3946
|
+
} else if (!isTerminalPhase(phase)) {
|
|
3947
|
+
return { ...base, state: "waiting" };
|
|
3948
|
+
}
|
|
3936
3949
|
}
|
|
3937
|
-
if (tx && !isTerminalPhase(tx.phase)) return { ...base, state: "waiting" };
|
|
3938
3950
|
const current = resolveCurrentReleaseMeta();
|
|
3939
3951
|
return { ...base, state: current.ok ? "confirmed" : "waiting" };
|
|
3940
3952
|
}
|
|
@@ -5560,8 +5572,42 @@ export function apply(ctx, config, runtime = {}) {
|
|
|
5560
5572
|
}
|
|
5561
5573
|
|
|
5562
5574
|
|
|
5575
|
+
// 只读 NPM 检查:桌面设置页/手机端可按需刷新;不下载产物、不改 profile、不重启。
|
|
5576
|
+
if (rest === "/plugin-update/check") {
|
|
5577
|
+
if (method !== "GET") return error(res, 405, "method-not-allowed");
|
|
5578
|
+
try {
|
|
5579
|
+
Object.assign(pluginUpdateStatus, await updates.check());
|
|
5580
|
+
} catch {
|
|
5581
|
+
Object.assign(pluginUpdateStatus, { state: "unavailable", reason: "update-check-failed", source: "npm", checkedAt: new Date().toISOString() });
|
|
5582
|
+
}
|
|
5583
|
+
return sendJson(res, 200, { ok: true, pluginUpdate: publicUpdateStatus(req) });
|
|
5584
|
+
}
|
|
5585
|
+
|
|
5586
|
+
// 旧 carrier 缺少 App build metadata 时,用户明确触发的预检只下载/校验 NPM 产物,不改 profile。
|
|
5587
|
+
if (rest === "/plugin-update/prepare") {
|
|
5588
|
+
if (method !== "POST") return error(res, 405, "method-not-allowed");
|
|
5589
|
+
let body;
|
|
5590
|
+
try { const text = await readBody(req); body = text ? JSON.parse(text) : {}; }
|
|
5591
|
+
catch { return error(res, 400, "invalid-operation-id"); }
|
|
5592
|
+
const result = await updates.prepare(body);
|
|
5593
|
+
return sendJson(res, result.status, result.body);
|
|
5594
|
+
}
|
|
5595
|
+
|
|
5596
|
+
// Read-only, authenticated, cursor-based log replay after the host restarts.
|
|
5597
|
+
if (rest === "/plugin-update/log") {
|
|
5598
|
+
if (method !== "GET") return error(res, 405, "method-not-allowed");
|
|
5599
|
+
const opId = url.searchParams.get("opId") ?? "";
|
|
5600
|
+
if (!validOperationId(opId)) return error(res, 400, "invalid-operation-id");
|
|
5601
|
+
const rawCursor = url.searchParams.get("afterSeq") ?? "0";
|
|
5602
|
+
if (!/^(0|[1-9]\d*)$/.test(rawCursor) || !Number.isSafeInteger(Number(rawCursor))) return error(res, 400, "invalid-log-cursor");
|
|
5603
|
+
const tx = readTransaction(pluginUpdateStateFile);
|
|
5604
|
+
if (!tx || tx.opId !== opId || !tx.lease?.generation) return error(res, 404, "update-log-not-found");
|
|
5605
|
+
const page = readOperationLog({ home: homedir(), generation: tx.lease.generation, afterSeq: Number(rawCursor) });
|
|
5606
|
+
return sendJson(res, 200, { ok: true, opId, phase: tx.phase, ...page });
|
|
5607
|
+
}
|
|
5608
|
+
|
|
5563
5609
|
// 插件自更新:**手机可独立触发**(ADR 0019 的产品取舍——不加 PC 二次确认)。
|
|
5564
|
-
// 这是唯一会改动本机 profile 并重启宿主的端点,因此 fail-closed
|
|
5610
|
+
// 这是唯一会改动本机 profile 并重启宿主的端点,因此 fail-closed 且**异步**:
|
|
5565
5611
|
// - 先判方法/来源/能力/有无新版,任何一项不成立就拒绝,绝不开始;
|
|
5566
5612
|
// - 立即返回可查询、幂等的 operation id;下载/安装/重启在后台推进
|
|
5567
5613
|
// (评审 R6:原来同步阻塞到 pnpm 结束,会冻住全部 HTTP/SSE/agent,且响应丢失后
|
|
@@ -5569,7 +5615,7 @@ export function apply(ctx, config, runtime = {}) {
|
|
|
5569
5615
|
// - 回滚与健康确认交给**外部监督器**,不依赖新版插件能否加载(评审 R1)。
|
|
5570
5616
|
if (rest === "/plugin-update") {
|
|
5571
5617
|
if (method !== "POST") return error(res, 405, "method-not-allowed");
|
|
5572
|
-
const source =
|
|
5618
|
+
const source = resolvePluginUpdateSource(config.pluginUpdateManifest);
|
|
5573
5619
|
if (source.kind === UPDATE_SOURCE_KIND.OFF) return error(res, 503, "update-source-off");
|
|
5574
5620
|
if (source.kind === UPDATE_SOURCE_KIND.INVALID) return error(res, 400, "update-source-invalid");
|
|
5575
5621
|
if (!pluginProfileDir) return error(res, 409, "profile-not-found");
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { appCarrierAlias, appCarrierVersion } from "./app-carrier.js";
|
|
3
|
+
import { parseVersion } from "./plugin-update.js";
|
|
4
|
+
import { shellOptions } from "./tool-process.js";
|
|
5
|
+
|
|
6
|
+
const SRI_PATTERN = /^sha(?:256|384|512)-[A-Za-z0-9+/]+={0,2}$/;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Stable package follows latest; 预发布跟随其自身的通道(alpha / rc)。
|
|
10
|
+
* 例:3.2.2-alpha.15 查 alpha,3.2.2-rc.1 查 rc;绝不把预发布塞给 latest。
|
|
11
|
+
*/
|
|
12
|
+
export function npmUpdateChannel(localVersion) {
|
|
13
|
+
const parsed = parseVersion(localVersion);
|
|
14
|
+
if (!parsed) return { ok: false, reason: "npm-local-version-invalid" };
|
|
15
|
+
if (parsed.prerelease.length === 0) return { ok: true, channel: "latest" };
|
|
16
|
+
const channel = parsed.prerelease[0];
|
|
17
|
+
if (channel === "alpha" || channel === "rc") return { ok: true, channel };
|
|
18
|
+
return { ok: false, reason: "npm-prerelease-channel-unsupported" };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function packageIntegrity(pkg) {
|
|
22
|
+
const value = pkg?.dist?.integrity;
|
|
23
|
+
return typeof value === "string" && SRI_PATTERN.test(value) ? value : "";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* `npm view <pkg> dist-tags --json` prints the tag map itself
|
|
28
|
+
* (`{"latest":"...","alpha":"..."}`), while some adapters/tests hand back the
|
|
29
|
+
* whole packument field (`{"dist-tags":{...}}`). Accept both so the check never
|
|
30
|
+
* fails merely because of the projection shape.
|
|
31
|
+
*/
|
|
32
|
+
function distTagMap(tags) {
|
|
33
|
+
if (!tags || typeof tags !== "object" || Array.isArray(tags)) return null;
|
|
34
|
+
const nested = tags["dist-tags"];
|
|
35
|
+
if (nested && typeof nested === "object" && !Array.isArray(nested)) return nested;
|
|
36
|
+
return tags;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 检查日志只放包名/版本/稳定原因码;绝不写 registry URL、凭据或 npm stderr。 */
|
|
40
|
+
export const NPM_CHECK_LOG_LIMIT = 40;
|
|
41
|
+
const CHECK_LOG_LINE_LIMIT = 240;
|
|
42
|
+
|
|
43
|
+
function checkRecorder(log) {
|
|
44
|
+
if (!Array.isArray(log)) return () => {};
|
|
45
|
+
return (line) => {
|
|
46
|
+
const text = String(line ?? "").trim();
|
|
47
|
+
if (text === "") return;
|
|
48
|
+
log.push(text.length > CHECK_LOG_LINE_LIMIT ? `${text.slice(0, CHECK_LOG_LINE_LIMIT)}…` : text);
|
|
49
|
+
if (log.length > NPM_CHECK_LOG_LIMIT) log.splice(0, log.length - NPM_CHECK_LOG_LIMIT);
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Resolve an immutable plugin/carrier pair from NPM metadata only.
|
|
55
|
+
* `packageView(spec)` is the host NPM adapter; tests inject a deterministic registry.
|
|
56
|
+
* This function never downloads a tarball and never returns registry URLs or raw metadata.
|
|
57
|
+
* `log` (optional array) receives sanitized, bounded diagnostic lines for the settings UI.
|
|
58
|
+
*/
|
|
59
|
+
export async function resolveNpmUpdateCandidate({ packageName, localVersion, packageView, log } = {}) {
|
|
60
|
+
const record = checkRecorder(log);
|
|
61
|
+
if (typeof packageName !== "string" || packageName.trim() === "") {
|
|
62
|
+
record(`包名无效:${String(packageName ?? "")}`);
|
|
63
|
+
return { ok: false, reason: "npm-package-name-invalid" };
|
|
64
|
+
}
|
|
65
|
+
if (typeof packageView !== "function") {
|
|
66
|
+
record(`找不到 npm 元数据适配器(${packageName})`);
|
|
67
|
+
return { ok: false, reason: "npm-client-unavailable" };
|
|
68
|
+
}
|
|
69
|
+
const channel = npmUpdateChannel(localVersion);
|
|
70
|
+
if (!channel.ok) {
|
|
71
|
+
record(`# 本机版本 ${String(localVersion || "未知")} 无法确定 NPM 通道:${channel.reason}`);
|
|
72
|
+
return channel;
|
|
73
|
+
}
|
|
74
|
+
record(`# 本机版本 ${String(localVersion || "未知")} · NPM 通道 ${channel.channel}`);
|
|
75
|
+
|
|
76
|
+
let tags;
|
|
77
|
+
record(`$ npm view ${packageName} dist-tags --json`);
|
|
78
|
+
try {
|
|
79
|
+
tags = await packageView(packageName);
|
|
80
|
+
} catch (error) {
|
|
81
|
+
const reason = viewFailureReason(error);
|
|
82
|
+
record(`→ 失败:${reason}`);
|
|
83
|
+
return { ok: false, reason };
|
|
84
|
+
}
|
|
85
|
+
const candidateVersion = distTagMap(tags)?.[channel.channel];
|
|
86
|
+
if (typeof candidateVersion !== "string" || !parseVersion(candidateVersion)) {
|
|
87
|
+
record(`→ 缺少 ${channel.channel} 标签`);
|
|
88
|
+
return { ok: false, reason: "npm-channel-version-missing", channel: channel.channel };
|
|
89
|
+
}
|
|
90
|
+
record(`→ ${channel.channel}=${candidateVersion}`);
|
|
91
|
+
|
|
92
|
+
let plugin;
|
|
93
|
+
record(`$ npm view ${packageName}@${candidateVersion} --json`);
|
|
94
|
+
try {
|
|
95
|
+
plugin = await packageView(`${packageName}@${candidateVersion}`);
|
|
96
|
+
} catch (error) {
|
|
97
|
+
record(`→ 失败:${viewFailureReason(error)}`);
|
|
98
|
+
return { ok: false, reason: "npm-plugin-metadata-unavailable" };
|
|
99
|
+
}
|
|
100
|
+
if (!plugin || plugin.version !== candidateVersion) {
|
|
101
|
+
record(`→ 版本不符:${String(plugin?.version ?? "缺失")} ≠ ${candidateVersion}`);
|
|
102
|
+
return { ok: false, reason: "npm-plugin-version-mismatch" };
|
|
103
|
+
}
|
|
104
|
+
const pluginIntegrity = packageIntegrity(plugin);
|
|
105
|
+
if (!pluginIntegrity) {
|
|
106
|
+
record(`→ 元数据缺少 dist.integrity`);
|
|
107
|
+
return { ok: false, reason: "npm-plugin-integrity-missing" };
|
|
108
|
+
}
|
|
109
|
+
record(`→ 插件 v${candidateVersion}`);
|
|
110
|
+
|
|
111
|
+
const carrierVersion = appCarrierVersion(candidateVersion);
|
|
112
|
+
const alias = appCarrierAlias(packageName);
|
|
113
|
+
const expectedAliasSpec = `npm:${packageName}@${carrierVersion}`;
|
|
114
|
+
if (plugin.optionalDependencies?.[alias] !== expectedAliasSpec) {
|
|
115
|
+
record(`→ 载体别名不匹配:${String(plugin.optionalDependencies?.[alias] ?? "缺失")}(期望 ${expectedAliasSpec})`);
|
|
116
|
+
return { ok: false, reason: "npm-carrier-alias-mismatch" };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let carrier;
|
|
120
|
+
record(`$ npm view ${packageName}@${carrierVersion} --json`);
|
|
121
|
+
try {
|
|
122
|
+
carrier = await packageView(`${packageName}@${carrierVersion}`);
|
|
123
|
+
} catch (error) {
|
|
124
|
+
record(`→ 失败:${viewFailureReason(error)}`);
|
|
125
|
+
return { ok: false, reason: "npm-carrier-metadata-unavailable" };
|
|
126
|
+
}
|
|
127
|
+
if (!carrier || carrier.version !== carrierVersion) {
|
|
128
|
+
record(`→ 版本不符:${String(carrier?.version ?? "缺失")} ≠ ${carrierVersion}`);
|
|
129
|
+
return { ok: false, reason: "npm-carrier-version-mismatch" };
|
|
130
|
+
}
|
|
131
|
+
const carrierIntegrity = packageIntegrity(carrier);
|
|
132
|
+
if (!carrierIntegrity) {
|
|
133
|
+
record(`→ 元数据缺少 dist.integrity`);
|
|
134
|
+
return { ok: false, reason: "npm-carrier-integrity-missing" };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const appVersion = typeof carrier.appVersion === "string" ? carrier.appVersion : null;
|
|
138
|
+
if (appVersion !== null) {
|
|
139
|
+
const parsedApp = parseVersion(appVersion);
|
|
140
|
+
const parsedPlugin = parseVersion(candidateVersion);
|
|
141
|
+
if (!parsedApp || parsedApp.build === null) {
|
|
142
|
+
record(`→ App 版本格式无效:${appVersion}`);
|
|
143
|
+
return { ok: false, reason: "npm-app-version-invalid" };
|
|
144
|
+
}
|
|
145
|
+
if (parsedApp.version !== parsedPlugin.version) {
|
|
146
|
+
record(`→ App 版本配对不符:${appVersion} ↔ 插件 ${candidateVersion}`);
|
|
147
|
+
return { ok: false, reason: "npm-app-version-pairing-mismatch" };
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
const contentSha256 = typeof carrier.pluginContentSha256 === "string" ? carrier.pluginContentSha256.toLowerCase() : "";
|
|
151
|
+
if (contentSha256 !== "" && !/^[0-9a-f]{64}$/.test(contentSha256)) {
|
|
152
|
+
record(`→ 插件内容标识格式无效(${carrierVersion})`);
|
|
153
|
+
return { ok: false, reason: "npm-plugin-content-sha256-invalid" };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
record(`→ 载体 v${carrierVersion} · App build ${appVersion ?? "缺失(需预检)"}`);
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
ok: true,
|
|
160
|
+
channel: channel.channel,
|
|
161
|
+
plugin: {
|
|
162
|
+
name: packageName,
|
|
163
|
+
version: candidateVersion,
|
|
164
|
+
integrity: pluginIntegrity,
|
|
165
|
+
contentSha256,
|
|
166
|
+
},
|
|
167
|
+
carrier: {
|
|
168
|
+
name: packageName,
|
|
169
|
+
version: carrierVersion,
|
|
170
|
+
integrity: carrierIntegrity,
|
|
171
|
+
appVersion,
|
|
172
|
+
pluginContentSha256: contentSha256,
|
|
173
|
+
legacyMetadataMissing: appVersion === null,
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function runNpmView(args, { cwd = process.cwd(), timeoutMs = 20_000, execFileImpl = execFile, platform = process.platform } = {}) {
|
|
179
|
+
return new Promise((resolve) => {
|
|
180
|
+
execFileImpl("npm", args, {
|
|
181
|
+
cwd,
|
|
182
|
+
env: process.env,
|
|
183
|
+
timeout: timeoutMs,
|
|
184
|
+
maxBuffer: 1024 * 1024,
|
|
185
|
+
windowsHide: true,
|
|
186
|
+
// Windows 上 npm 是 npm.cmd,必须经 shell 解析;POSIX 直接 spawn。
|
|
187
|
+
...shellOptions(platform),
|
|
188
|
+
}, (error, stdout, stderr) => {
|
|
189
|
+
if (error) {
|
|
190
|
+
// npm stderr may contain private registry URLs; expose only a stable source error.
|
|
191
|
+
// 区分「找不到 npm 客户端」与「registry 不可用」,便于界面给出可执行提示:
|
|
192
|
+
// Windows shell 找不到 .cmd 时 cmd 会报「不是内部或外部命令」而不是 ENOENT。
|
|
193
|
+
const stderrText = String(stderr ?? "");
|
|
194
|
+
const clientMissing = error?.code === "ENOENT" || /not recognized|不是内部或外部命令|command not found/i.test(stderrText);
|
|
195
|
+
resolve({ ok: false, error: clientMissing ? "npm-client-unavailable" : "npm-view-failed" });
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
resolve({ ok: true, stdout: String(stdout ?? "") });
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** 检查失败原因:npm 客户端缺失是环境问题,不应伪装成 registry 故障。 */
|
|
204
|
+
function viewFailureReason(error) {
|
|
205
|
+
return error?.message === "npm-client-unavailable" ? "npm-client-unavailable" : "npm-registry-unavailable";
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Host-side npm view adapter. It deliberately supplies no --registry argument so npm
|
|
210
|
+
* applies the DSH process's project/user config, including scoped registry and credentials.
|
|
211
|
+
*/
|
|
212
|
+
export function createNpmPackageView({ packageName, cwd, run = runNpmView, platform = process.platform } = {}) {
|
|
213
|
+
return async (spec) => {
|
|
214
|
+
if (typeof spec !== "string" || spec === "") throw new Error("npm-package-spec-invalid");
|
|
215
|
+
const args = spec === packageName
|
|
216
|
+
? ["view", packageName, "dist-tags", "--json"]
|
|
217
|
+
: ["view", spec, "--json"];
|
|
218
|
+
const result = await run(args, { cwd, platform });
|
|
219
|
+
if (!result?.ok) throw new Error(result?.error ?? "npm-view-failed");
|
|
220
|
+
try {
|
|
221
|
+
return JSON.parse(result.stdout);
|
|
222
|
+
} catch {
|
|
223
|
+
throw new Error("npm-view-json-invalid");
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
}
|