@shendeguize/remote-dsh-center 0.4.0

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.
Files changed (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.en.md +197 -0
  3. package/README.md +174 -0
  4. package/package.json +48 -0
  5. package/scripts/install.mjs +208 -0
  6. package/src/api.js +725 -0
  7. package/src/cli.js +1445 -0
  8. package/src/config-sync.js +157 -0
  9. package/src/daemon.js +362 -0
  10. package/src/defaults.js +89 -0
  11. package/src/dsh-workspace.js +467 -0
  12. package/src/launcher.js +627 -0
  13. package/src/lib/bundle.js +82 -0
  14. package/src/lib/bus.js +109 -0
  15. package/src/lib/capture.js +53 -0
  16. package/src/lib/clock.js +18 -0
  17. package/src/lib/entry.js +27 -0
  18. package/src/lib/errors.js +88 -0
  19. package/src/lib/logfile.js +65 -0
  20. package/src/lib/machine.js +63 -0
  21. package/src/lib/origin-guard.js +64 -0
  22. package/src/lib/pool.js +88 -0
  23. package/src/lib/proto.js +457 -0
  24. package/src/lib/semver.js +103 -0
  25. package/src/lib/shq.js +112 -0
  26. package/src/lib/ssh.js +647 -0
  27. package/src/lib/validate.js +363 -0
  28. package/src/monitor.js +145 -0
  29. package/src/patchsync.js +310 -0
  30. package/src/ports.js +93 -0
  31. package/src/prober.js +185 -0
  32. package/src/server.js +449 -0
  33. package/src/settings-file.js +550 -0
  34. package/src/ssh-config.js +152 -0
  35. package/src/store.js +772 -0
  36. package/src/tunnel.js +589 -0
  37. package/src/updater.js +450 -0
  38. package/src/web/actions.js +409 -0
  39. package/src/web/api.js +262 -0
  40. package/src/web/app.js +347 -0
  41. package/src/web/components/config-sync-dialog.js +469 -0
  42. package/src/web/components/confirm-dialog.js +61 -0
  43. package/src/web/components/defaults-card.js +216 -0
  44. package/src/web/components/event-panel.js +98 -0
  45. package/src/web/components/host-drawer.js +1039 -0
  46. package/src/web/components/host-table.js +317 -0
  47. package/src/web/components/hub.js +143 -0
  48. package/src/web/components/iframe-pane.js +377 -0
  49. package/src/web/components/manager-card.js +65 -0
  50. package/src/web/components/setup-wizard.js +726 -0
  51. package/src/web/components/tabbar.js +577 -0
  52. package/src/web/components/toast-region.js +107 -0
  53. package/src/web/favicon.svg +7 -0
  54. package/src/web/form.js +220 -0
  55. package/src/web/host-presentation.js +73 -0
  56. package/src/web/host-rules.js +76 -0
  57. package/src/web/index.html +17 -0
  58. package/src/web/router.js +118 -0
  59. package/src/web/setup-schema.js +203 -0
  60. package/src/web/sse.js +118 -0
  61. package/src/web/store.js +405 -0
  62. package/src/web/style.css +813 -0
  63. package/src/web/utils.js +210 -0
package/src/updater.js ADDED
@@ -0,0 +1,450 @@
1
+ /**
2
+ * 版本自证与自更新(模块层)。
3
+ *
4
+ * 三种安装通道,判据是落地物而不是猜:
5
+ * git —— 仓库 clone(软链安装 / 开发机):`<root>/.git` 在
6
+ * bundle —— 自带 Node 运行时的发布包:`<root>/../BUNDLE_INFO.json` 在
7
+ * npm —— `npm i -g @shendeguize/remote-dsh-center` 装出来的包:上级目录叫
8
+ * `node_modules`,或上级是 `@scope` 且上上级叫 `node_modules`
9
+ * (npm / pnpm 全局与本地装置的共同形态);更新归 npm 管,这里不代跑
10
+ * 认不出通道时一律拒绝更新而不是挑一条试——猜错要么白跑,要么把用户的目录搞坏。
11
+ *
12
+ * 更新的两条硬纪律:
13
+ * 1. git 通道只快进(工作区脏、或目标不是当前提交的后代,都拒绝,不用 merge 糊过去);
14
+ * 2. bundle 通道下载物必须过 SHA256 校验才落盘,且换目录是「先解包到 .new、
15
+ * 再原子改名」——中途失败时原安装仍是完整的。
16
+ *
17
+ * 重启由调用方(cli.js)决定:更新完不自动重启,因为重启会瞬断所有隧道页签,
18
+ * 什么时候断该由人挑时机。
19
+ */
20
+
21
+ import fs from 'node:fs';
22
+ import path from 'node:path';
23
+ import { createHash } from 'node:crypto';
24
+ import { spawn } from 'node:child_process';
25
+ import { fileURLToPath } from 'node:url';
26
+
27
+ import { DshError } from './lib/errors.js';
28
+ import {
29
+ BUNDLE_INFO_FILE, SUMS_FILE, assetName, bundleDirName, normalizeArch, parseSums,
30
+ } from './lib/bundle.js';
31
+ import {
32
+ compareVersions, isPrerelease, parseVersion, pickLatest,
33
+ } from './lib/semver.js';
34
+
35
+ /** 仓库根(含 package.json)。bundle 安装下它是 `<bundle 根>/app`。 */
36
+ export const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
37
+
38
+ /** git 通道默认跟的分支——稳定消费口径(尝鲜者显式 `--ref main`)。 */
39
+ export const DEFAULT_GIT_REF = 'release';
40
+
41
+ // ── 通道识别与版本自证 ───────────────────────────────────────────────────
42
+
43
+ /**
44
+ * @param {string} [repoRoot] 含 package.json 的目录
45
+ * @returns {{channel:'git'|'bundle'|'npm'|'unknown', root:string, repoRoot:string,
46
+ * bundleInfo:object|null, reason:string|null}}
47
+ * `root` = 更新时要替换/前进的那个目录:bundle 是 bundle 根,git 是仓库本身,
48
+ * npm 是包目录(只作展示,更新走 npm 自己)
49
+ */
50
+ export function resolveInstall(repoRoot = REPO_ROOT, deps = {}) {
51
+ const exists = deps.existsSync ?? fs.existsSync;
52
+ const read = deps.readFileSync ?? fs.readFileSync;
53
+
54
+ const bundleRoot = path.dirname(repoRoot);
55
+ const infoPath = path.join(bundleRoot, BUNDLE_INFO_FILE);
56
+ if (exists(infoPath)) {
57
+ let bundleInfo = null;
58
+ try {
59
+ bundleInfo = JSON.parse(read(infoPath, 'utf8'));
60
+ } catch (err) {
61
+ return {
62
+ channel: 'unknown', root: bundleRoot, repoRoot, bundleInfo: null,
63
+ reason: `${infoPath} 读不出来(${err.message})——发布包被改过?重装一次最省事`,
64
+ };
65
+ }
66
+ return { channel: 'bundle', root: bundleRoot, repoRoot, bundleInfo, reason: null };
67
+ }
68
+
69
+ if (exists(path.join(repoRoot, '.git'))) {
70
+ return { channel: 'git', root: repoRoot, repoRoot, bundleInfo: null, reason: null };
71
+ }
72
+
73
+ // npm / pnpm 装置(全局或本地)的共同落地形态:包目录躺在 node_modules 下;
74
+ // scoped 包(@scope/name)中间多一层 @scope 目录
75
+ const parent = path.dirname(repoRoot);
76
+ const parentName = path.basename(parent);
77
+ const inNodeModules = parentName === 'node_modules'
78
+ || (parentName.startsWith('@') && path.basename(path.dirname(parent)) === 'node_modules');
79
+ if (inNodeModules) {
80
+ return { channel: 'npm', root: repoRoot, repoRoot, bundleInfo: null, reason: null };
81
+ }
82
+
83
+ return {
84
+ channel: 'unknown',
85
+ root: repoRoot,
86
+ repoRoot,
87
+ bundleInfo: null,
88
+ reason: `${repoRoot} 既不是 git clone(没有 .git),不是发布包(上层没有 ${BUNDLE_INFO_FILE}),`
89
+ + '也不是 npm 装的包(不在 node_modules 下)',
90
+ };
91
+ }
92
+
93
+ function readPackageVersion(repoRoot, read = fs.readFileSync) {
94
+ try {
95
+ return JSON.parse(read(path.join(repoRoot, 'package.json'), 'utf8')).version ?? null;
96
+ } catch {
97
+ return null;
98
+ }
99
+ }
100
+
101
+ function run(cmd, args, { cwd = process.cwd(), timeoutMs = 120_000 } = {}) {
102
+ return new Promise((resolve) => {
103
+ const child = spawn(cmd, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
104
+ let stdout = '';
105
+ let stderr = '';
106
+ const timer = setTimeout(() => child.kill('SIGKILL'), timeoutMs);
107
+ child.stdout.setEncoding('utf8');
108
+ child.stderr.setEncoding('utf8');
109
+ child.stdout.on('data', (c) => { stdout += c; });
110
+ child.stderr.on('data', (c) => { stderr += c; });
111
+ child.on('error', (err) => {
112
+ clearTimeout(timer);
113
+ resolve({ code: 127, stdout, stderr: err.message });
114
+ });
115
+ child.on('close', (code) => {
116
+ clearTimeout(timer);
117
+ resolve({ code: code ?? 1, stdout: stdout.trim(), stderr: stderr.trim() });
118
+ });
119
+ });
120
+ }
121
+
122
+ /** git 事实采集:失败一律给 null,`dshc version` 不该因为没装 git 就报错。 */
123
+ async function gitFacts(dir, exec = run) {
124
+ const sha = await exec('git', ['-C', dir, 'rev-parse', '--short', 'HEAD']);
125
+ if (sha.code !== 0) return null;
126
+ const ref = await exec('git', ['-C', dir, 'rev-parse', '--abbrev-ref', 'HEAD']);
127
+ const contains = await exec('git', ['-C', dir, 'branch', '-r', '--contains', 'HEAD']);
128
+ const branches = contains.code === 0
129
+ ? contains.stdout.split('\n').map((l) => l.trim().replace(/^origin\//, '')).filter(Boolean)
130
+ : [];
131
+ return {
132
+ sha: sha.stdout,
133
+ // 软链安装是 detached HEAD(install.sh 直接 checkout FETCH_HEAD),
134
+ // 此时 abbrev-ref 给 "HEAD",得靠远端分支反查才知道自己在跟哪条线
135
+ ref: ref.code === 0 && ref.stdout !== 'HEAD' ? ref.stdout : (branches[0] ?? 'detached'),
136
+ };
137
+ }
138
+
139
+ /**
140
+ * `dshc version` 的全部事实。
141
+ * @returns {Promise<{version:string|null, channel:string, channelDetail:string,
142
+ * node:{version:string, execPath:string}, root:string, repoRoot:string,
143
+ * bundle:object|null, git:object|null}>}
144
+ */
145
+ export async function collectVersionInfo({
146
+ repoRoot = REPO_ROOT, node = process, exec = run, deps = {},
147
+ } = {}) {
148
+ const install = resolveInstall(repoRoot, deps);
149
+ const version = readPackageVersion(install.repoRoot, deps.readFileSync);
150
+ const git = install.channel === 'git' ? await gitFacts(install.repoRoot, exec) : null;
151
+
152
+ let channelDetail;
153
+ if (install.channel === 'bundle') {
154
+ const info = install.bundleInfo ?? {};
155
+ channelDetail = `bundle ${info.tag ?? `v${info.version ?? '?'}`}(${info.arch ?? '?'})`;
156
+ } else if (install.channel === 'git') {
157
+ channelDetail = git ? `git ${git.sha}(${git.ref})` : 'git(取不到提交信息)';
158
+ } else if (install.channel === 'npm') {
159
+ channelDetail = 'npm 全局包(更新走 npm i -g @shendeguize/remote-dsh-center@latest)';
160
+ } else {
161
+ channelDetail = `认不出(${install.reason})`;
162
+ }
163
+
164
+ return {
165
+ version,
166
+ channel: install.channel,
167
+ channelDetail,
168
+ node: { version: node.versions?.node ?? null, execPath: node.execPath ?? null },
169
+ root: install.root,
170
+ repoRoot: install.repoRoot,
171
+ bundle: install.bundleInfo,
172
+ git,
173
+ };
174
+ }
175
+
176
+ // ── 目标版本决策(纯函数) ───────────────────────────────────────────────
177
+
178
+ /**
179
+ * Release 列表 → 可安装候选。
180
+ * @param {Array<{tag_name?:string, prerelease?:boolean, draft?:boolean, assets?:Array}>} releases
181
+ * @returns {Array<{tag:string, version:string, prerelease:boolean, assets:string[]}>}
182
+ */
183
+ export function usableReleases(releases) {
184
+ const out = [];
185
+ for (const r of releases ?? []) {
186
+ if (r?.draft) continue;
187
+ const parsed = parseVersion(r?.tag_name);
188
+ if (!parsed) continue;
189
+ out.push({
190
+ tag: r.tag_name,
191
+ version: parsed.version,
192
+ prerelease: Boolean(r.prerelease) || parsed.prerelease.length > 0,
193
+ assets: (r.assets ?? []).map((a) => a?.name).filter(Boolean),
194
+ });
195
+ }
196
+ return out;
197
+ }
198
+
199
+ /**
200
+ * 该不该更新、更到哪个版本。
201
+ * @param {object} input
202
+ * @param {string|null} input.current 当前版本
203
+ * @param {Array} input.releases usableReleases 的产出
204
+ * @param {boolean} [input.includePrerelease] `--pre`
205
+ * @param {string|null} [input.pinned] `--ref vX.Y.Z`:点名 tag,跳过挑选
206
+ * @returns {{action:'update'|'up-to-date'|'none', target:object|null, reason:string|null}}
207
+ */
208
+ export function chooseTarget({
209
+ current, releases, includePrerelease = false, pinned = null,
210
+ }) {
211
+ if (pinned) {
212
+ const hit = releases.find((r) => r.tag === pinned || r.version === parseVersion(pinned)?.version);
213
+ if (!hit) {
214
+ return { action: 'none', target: null, reason: `Release 里没有 ${pinned}` };
215
+ }
216
+ return { action: 'update', target: hit, reason: null };
217
+ }
218
+
219
+ const candidates = releases.filter((r) => includePrerelease || !r.prerelease);
220
+ const latest = pickLatest(candidates.map((r) => r.version), { includePrerelease });
221
+ if (!latest) {
222
+ return {
223
+ action: 'none',
224
+ target: null,
225
+ reason: includePrerelease ? '仓库还没有任何 Release' : '仓库还没有正式版 Release(想装预发布加 --pre)',
226
+ };
227
+ }
228
+
229
+ const target = candidates.find((r) => r.version === latest);
230
+ if (current && compareVersions(latest, current) <= 0) {
231
+ return {
232
+ action: 'up-to-date', target, reason: null, newerPrerelease: newerPrereleaseThan(current, releases),
233
+ };
234
+ }
235
+ return { action: 'update', target, reason: null, newerPrerelease: null };
236
+ }
237
+
238
+ /**
239
+ * 跟着预发布的人,稳定口径下会一直停在旧 rc 上——正式版比 rc 旧,`update` 只会说
240
+ * 「已是最新」。所以在这种情形下把更新的预发布报出来;装正式版的人不受打扰。
241
+ * @returns {string|null} 更新的预发布版本号,没有则 null
242
+ */
243
+ function newerPrereleaseThan(current, releases) {
244
+ if (!isPrerelease(current)) return null;
245
+ const newer = releases
246
+ .filter((r) => r.prerelease && compareVersions(r.version, current) > 0)
247
+ .map((r) => r.version);
248
+ return pickLatest(newer, { includePrerelease: true });
249
+ }
250
+
251
+ /** bundle 通道换目录用的三个路径。只留一代 `.prev`,够回滚又不攒垃圾。 */
252
+ export function swapPaths(root) {
253
+ return { root, staging: `${root}.new`, previous: `${root}.prev` };
254
+ }
255
+
256
+ // ── git 通道 ────────────────────────────────────────────────────────────
257
+
258
+ /**
259
+ * @returns {Promise<{ok:boolean, action:'updated'|'up-to-date', from:string, to:string,
260
+ * fromVersion:string|null, toVersion:string|null, problem:string|null}>}
261
+ */
262
+ export async function updateGit({
263
+ root, ref = DEFAULT_GIT_REF, exec = run, deps = {},
264
+ }) {
265
+ const git = (...args) => exec('git', ['-C', root, ...args]);
266
+ const fail = (problem) => ({
267
+ ok: false, action: 'up-to-date', from: '', to: '', fromVersion: null, toVersion: null, problem,
268
+ });
269
+
270
+ const dirty = await git('status', '--porcelain');
271
+ if (dirty.code !== 0) return fail(`${root} 不像个能用的 git 仓库:${dirty.stderr || dirty.stdout}`);
272
+ if (dirty.stdout !== '') {
273
+ return fail(`${root} 有未提交的改动,先自行处理再更新:\n${dirty.stdout}`);
274
+ }
275
+
276
+ const fetched = await git('fetch', '--quiet', 'origin', ref);
277
+ if (fetched.code !== 0) return fail(`拉不到 origin/${ref}:${fetched.stderr || fetched.stdout}`);
278
+
279
+ const before = await git('rev-parse', 'HEAD');
280
+ const target = await git('rev-parse', 'FETCH_HEAD');
281
+ if (before.code !== 0 || target.code !== 0) return fail('取不到当前提交或目标提交');
282
+
283
+ const fromVersion = readPackageVersion(root, deps.readFileSync);
284
+ if (before.stdout === target.stdout) {
285
+ return {
286
+ ok: true,
287
+ action: 'up-to-date',
288
+ from: before.stdout,
289
+ to: target.stdout,
290
+ fromVersion,
291
+ toVersion: fromVersion,
292
+ problem: null,
293
+ };
294
+ }
295
+
296
+ // 只许快进:目标必须是当前提交的后代,否则就是本地有独有提交或指到了更旧的地方
297
+ const ancestor = await git('merge-base', '--is-ancestor', before.stdout, target.stdout);
298
+ if (ancestor.code !== 0) {
299
+ return fail(
300
+ `origin/${ref}(${target.stdout.slice(0, 8)})不是当前提交(${before.stdout.slice(0, 8)})的后代,`
301
+ + '不是快进就不动——本地有独有提交,或者 ref 指到了更旧的位置',
302
+ );
303
+ }
304
+
305
+ // 软链安装本就是 detached HEAD(install.sh 的模型),沿用同一形态
306
+ const moved = await git('checkout', '--quiet', '--detach', target.stdout);
307
+ if (moved.code !== 0) return fail(`切到目标提交失败:${moved.stderr || moved.stdout}`);
308
+
309
+ return {
310
+ ok: true,
311
+ action: 'updated',
312
+ from: before.stdout,
313
+ to: target.stdout,
314
+ fromVersion,
315
+ toVersion: readPackageVersion(root, deps.readFileSync),
316
+ problem: null,
317
+ };
318
+ }
319
+
320
+ // ── bundle 通道 ─────────────────────────────────────────────────────────
321
+
322
+ async function fetchOk(url, fetchImpl) {
323
+ const res = await fetchImpl(url, { headers: { accept: 'application/vnd.github+json' } });
324
+ if (!res.ok) {
325
+ throw new DshError('SSH_UNREACHABLE', `取 ${url} 失败:HTTP ${res.status}`);
326
+ }
327
+ return res;
328
+ }
329
+
330
+ /** @returns {Promise<Array>} GitHub Release 列表原始 JSON */
331
+ export async function fetchReleases(url, { fetchImpl = fetch } = {}) {
332
+ const res = await fetchOk(url, fetchImpl);
333
+ return res.json();
334
+ }
335
+
336
+ export function sha256(buffer) {
337
+ return createHash('sha256').update(buffer).digest('hex');
338
+ }
339
+
340
+ /**
341
+ * 下载并核对校验和。校验不过就不落盘——安装与更新的第一道闸。
342
+ * @returns {Promise<Buffer>}
343
+ */
344
+ export async function downloadVerified({
345
+ assetUrl: url, sumsUrl, name, fetchImpl = fetch,
346
+ }) {
347
+ const sumsText = await (await fetchOk(sumsUrl, fetchImpl)).text();
348
+ const expected = parseSums(sumsText).get(name);
349
+ if (!expected) {
350
+ throw new DshError('VALIDATION', `${SUMS_FILE} 里没有 ${name} 的校验和,不敢装`);
351
+ }
352
+
353
+ const bytes = Buffer.from(await (await fetchOk(url, fetchImpl)).arrayBuffer());
354
+ const actual = sha256(bytes);
355
+ if (actual !== expected) {
356
+ throw new DshError('VALIDATION', `${name} 校验和不符,已丢弃`, {
357
+ detail: `期望 ${expected}\n实际 ${actual}`,
358
+ });
359
+ }
360
+ return bytes;
361
+ }
362
+
363
+ /**
364
+ * 解包 → 原子换目录。失败时保证原安装还在原地。
365
+ * @returns {Promise<{previous:string}>}
366
+ */
367
+ export async function installBundle({
368
+ root, tarball, version, arch, exec = run,
369
+ }) {
370
+ const { staging, previous } = swapPaths(root);
371
+ fs.rmSync(staging, { recursive: true, force: true });
372
+ fs.mkdirSync(staging, { recursive: true });
373
+
374
+ const unpacked = await exec('tar', ['-xzf', tarball, '-C', staging]);
375
+ if (unpacked.code !== 0) {
376
+ fs.rmSync(staging, { recursive: true, force: true });
377
+ throw new DshError('INTERNAL', `解包失败:${unpacked.stderr || unpacked.stdout}`);
378
+ }
379
+
380
+ // tar 内是带版本号的顶层目录;容错:真出意外时按目录里唯一一项走
381
+ const expectedDir = bundleDirName({ version, arch });
382
+ const entries = fs.readdirSync(staging);
383
+ const inner = entries.includes(expectedDir) ? expectedDir : (entries.length === 1 ? entries[0] : null);
384
+ if (!inner) {
385
+ fs.rmSync(staging, { recursive: true, force: true });
386
+ throw new DshError('INTERNAL', `产物结构不认识:解包后得到 ${entries.join(', ') || '空目录'}`);
387
+ }
388
+ if (!fs.existsSync(path.join(staging, inner, BUNDLE_INFO_FILE))) {
389
+ fs.rmSync(staging, { recursive: true, force: true });
390
+ throw new DshError('INTERNAL', `产物里没有 ${BUNDLE_INFO_FILE},不像发布包`);
391
+ }
392
+
393
+ fs.rmSync(previous, { recursive: true, force: true });
394
+ fs.renameSync(root, previous);
395
+ try {
396
+ fs.renameSync(path.join(staging, inner), root);
397
+ } catch (err) {
398
+ fs.renameSync(previous, root); // 换一半失败要放回去,不能留个空位
399
+ throw new DshError('INTERNAL', `换目录失败,已还原原安装:${err.message}`);
400
+ }
401
+ fs.rmSync(staging, { recursive: true, force: true });
402
+ return { previous };
403
+ }
404
+
405
+ /**
406
+ * bundle 通道的完整更新流程(查 → 比 → 下 → 校 → 换)。
407
+ * @returns {Promise<{action:'updated'|'up-to-date'|'none', from:string|null,
408
+ * to:string|null, previous:string|null, reason:string|null}>}
409
+ */
410
+ export async function updateBundle({
411
+ root, bundleInfo, releasesUrl: listUrl, assetUrlFor, sumsUrlFor,
412
+ includePrerelease = false, pinned = null, fetchImpl = fetch, exec = run, tmpDir,
413
+ }) {
414
+ const current = bundleInfo?.version ?? null;
415
+ const arch = normalizeArch(bundleInfo?.arch ?? process.arch);
416
+ if (!arch) {
417
+ throw new DshError('VALIDATION', `不支持的 CPU 架构 ${bundleInfo?.arch ?? process.arch}:发布包只有 arm64 与 x64`);
418
+ }
419
+
420
+ const releases = usableReleases(await fetchReleases(listUrl, { fetchImpl }));
421
+ const decision = chooseTarget({ current, releases, includePrerelease, pinned });
422
+ if (decision.action !== 'update') {
423
+ return {
424
+ action: decision.action, from: current, to: decision.target?.version ?? null,
425
+ previous: null, reason: decision.reason, newerPrerelease: decision.newerPrerelease ?? null,
426
+ };
427
+ }
428
+
429
+ const { tag, version } = decision.target;
430
+ const name = assetName({ version, arch });
431
+ if (decision.target.assets.length > 0 && !decision.target.assets.includes(name)) {
432
+ throw new DshError('NOT_FOUND', `${tag} 没有 ${arch} 的产物(${name})`, {
433
+ detail: `该 Release 的附件:${decision.target.assets.join(', ')}`,
434
+ });
435
+ }
436
+
437
+ const bytes = await downloadVerified({
438
+ assetUrl: assetUrlFor({ tag, name }), sumsUrl: sumsUrlFor({ tag }), name, fetchImpl,
439
+ });
440
+
441
+ const scratch = fs.mkdtempSync(path.join(tmpDir ?? path.dirname(root), '.dshc-update-'));
442
+ try {
443
+ const tarball = path.join(scratch, name);
444
+ fs.writeFileSync(tarball, bytes);
445
+ const { previous } = await installBundle({ root, tarball, version, arch, exec });
446
+ return { action: 'updated', from: current, to: version, previous, reason: null };
447
+ } finally {
448
+ fs.rmSync(scratch, { recursive: true, force: true });
449
+ }
450
+ }