dsh-m 0.1.0 → 0.2.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.
@@ -1,15 +1,157 @@
1
1
  /**
2
- * 市场编排层:registry × profile × 最新版本 → 市场列表 / 已装列表 / outdated /
3
- * 安装 / 卸载 / 升级。安装语义见 DESIGN.md §3(npm 精确锁定、GitHub 锁 SHA)。
2
+ * 市场编排层:registry × profile × 最新版本 → 市场列表(服务端分页)/ 已装列表 /
3
+ * 安装 / 升级。安装语义见 DESIGN.md §3(npm 精确锁定、GitHub 锁 SHA)。
4
+ * Task 3:服务端 query/category/offset/limit 过滤;只对当前页查 latest(并发 ≤8、
5
+ * TTL cache、共享全局 deadline);unavailable 返回结构化空页;host/cli namespace 贯穿。
4
6
  */
5
7
  import { existsSync } from 'node:fs';
8
+ import { readFile } from 'node:fs/promises';
6
9
  import { join } from 'node:path';
7
- import { addDshPlugin } from './dsh-cli.js';
8
- import { dshHome, webProfileDir } from './env.js';
9
- import { listInstalledPlugins, readProfileDeps, removeInstalledPlugin, } from './installed.js';
10
+ import { addDshPlugin, removeDshPlugin, runCommand } from './dsh-cli.js';
11
+ import { dshHome, installTimeoutMs, webProfileDir } from './env.js';
12
+ import { assertNpmIntegrity, readPnpmLockIntegrity, restoreSnapshots, snapshotFiles } from './npm-integrity.js';
13
+ import { listInstalledPlugins as defaultListInstalledPlugins, readProfileDeps, removeInstalledPlugin, } from './installed.js';
10
14
  import { setLivePluginDisabled } from './live-plugin.js';
11
- import { loadRegistry } from './registry.js';
12
- import { githubHeadSha, isNewerVersion, npmLatest } from './versions.js';
15
+ import { loadRegistry as defaultLoadRegistry, } from './registry.js';
16
+ import { githubLatestTag as defaultGithubLatestTag, isNewerVersion, npmLatest as defaultNpmLatest, npmVersion } from './versions.js';
17
+ // ---------- 通用工具 ----------
18
+ const DEFAULT_DEADLINE_MS = 60_000;
19
+ const WITH_LATEST_MAX = 50;
20
+ const METADATA_ONLY_MAX = 80;
21
+ const LATEST_WORKERS = 8;
22
+ export function abortError() {
23
+ const err = new Error('Aborted');
24
+ err.name = 'AbortError';
25
+ return err;
26
+ }
27
+ /** 稳定顺序的并发 map:结果顺序与输入一致,in-flight ≤ limit。 */
28
+ export async function mapWithConcurrency(items, limit, worker) {
29
+ const results = new Array(items.length);
30
+ let next = 0;
31
+ const size = Math.max(1, Math.min(Math.floor(limit) || 1, items.length));
32
+ const runners = Array.from({ length: size }, async () => {
33
+ for (;;) {
34
+ const index = next;
35
+ if (index >= items.length)
36
+ return;
37
+ next += 1;
38
+ results[index] = await worker(items[index], index);
39
+ }
40
+ });
41
+ await Promise.all(runners);
42
+ return results;
43
+ }
44
+ function zeroCounts() {
45
+ return { market: 0, tools: 0, ui: 0, search: 0, media: 0, other: 0 };
46
+ }
47
+ function clampLimit(raw, max) {
48
+ const n = typeof raw === 'number' && Number.isFinite(raw) ? Math.floor(raw) : max;
49
+ return Math.min(max, Math.max(1, n));
50
+ }
51
+ function normalizeOffset(raw) {
52
+ const n = typeof raw === 'number' && Number.isFinite(raw) ? Math.floor(raw) : 0;
53
+ return n > 0 ? n : 0;
54
+ }
55
+ function stateOf(loaded) {
56
+ return {
57
+ configuredAddress: loaded.configuredAddress,
58
+ activeAddress: loaded.activeAddress,
59
+ source: loaded.source,
60
+ status: loaded.status,
61
+ isDefault: loaded.isDefault,
62
+ stale: loaded.stale,
63
+ fetchedAt: loaded.fetchedAt,
64
+ errors: loaded.errors,
65
+ count: loaded.count,
66
+ };
67
+ }
68
+ function timeoutRegistryState(cfg) {
69
+ const configuredAddress = typeof cfg.registryUrl === 'string' ? cfg.registryUrl.trim() : '';
70
+ return {
71
+ configuredAddress,
72
+ activeAddress: null,
73
+ source: configuredAddress === '' ? 'default-cache' : 'custom-unavailable',
74
+ status: 'unavailable',
75
+ isDefault: configuredAddress === '',
76
+ stale: false,
77
+ fetchedAt: null,
78
+ errors: ['registry 加载超出 deadline'],
79
+ count: 0,
80
+ };
81
+ }
82
+ function deadlineRace(task, ms) {
83
+ return new Promise((resolve, reject) => {
84
+ const timer = setTimeout(() => resolve('deadline'), Math.max(0, ms));
85
+ task.then((value) => {
86
+ clearTimeout(timer);
87
+ resolve(value);
88
+ }, (err) => {
89
+ clearTimeout(timer);
90
+ reject(err);
91
+ });
92
+ });
93
+ }
94
+ const latestCache = new Map();
95
+ const LATEST_CACHE_MAX = 5000;
96
+ function latestCacheKey(namespace, registryKey, item) {
97
+ const id = item.source === 'npm' && item.npm ? `npm:${item.npm}` : item.github ? `gh:${item.github}` : item.id;
98
+ return `${namespace}|${registryKey}|${id}`;
99
+ }
100
+ function readLatestCache(key, ttlMin) {
101
+ const entry = latestCache.get(key);
102
+ if (!entry)
103
+ return null;
104
+ if (Date.now() - entry.at >= ttlMin * 60_000) {
105
+ latestCache.delete(key);
106
+ return null;
107
+ }
108
+ return entry.value;
109
+ }
110
+ function writeLatestCache(key, value) {
111
+ if (latestCache.size >= LATEST_CACHE_MAX) {
112
+ const oldest = latestCache.keys().next().value;
113
+ if (oldest !== undefined)
114
+ latestCache.delete(oldest);
115
+ }
116
+ latestCache.set(key, { at: Date.now(), value });
117
+ }
118
+ function probeTask(item, deps, timeoutMs, signal) {
119
+ if (item.source === 'npm' && item.npm) {
120
+ return deps.npmLatest(item.npm, timeoutMs, signal).then((r) => ({ version: r.version }));
121
+ }
122
+ return deps.githubLatestTag(item.github, timeoutMs, signal).then((r) => ({ tag: r.tag, sha: r.sha }));
123
+ }
124
+ /**
125
+ * 单个 probe 的 deadline 收敛:即使依赖忽略 AbortSignal 也通过 race 返回。
126
+ * 外部 signal abort → 抛 AbortError(由上层终止整次请求)。
127
+ */
128
+ async function probeWithBudget(task, budgetMs, signal) {
129
+ let timer;
130
+ const timeoutP = new Promise((resolve) => {
131
+ timer = setTimeout(() => resolve({ ok: false, timeout: true }), Math.max(1, budgetMs));
132
+ });
133
+ const safeTask = task.then((value) => ({ ok: true, value }), (error) => ({ ok: false, error }));
134
+ const abortP = signal
135
+ ? new Promise((_, reject) => {
136
+ signal.addEventListener('abort', () => reject(abortError()), { once: true });
137
+ })
138
+ : null;
139
+ try {
140
+ return await Promise.race(abortP ? [safeTask, timeoutP, abortP] : [safeTask, timeoutP]);
141
+ }
142
+ finally {
143
+ if (timer)
144
+ clearTimeout(timer);
145
+ }
146
+ }
147
+ function applyProbe(item, value) {
148
+ if (value.version !== undefined)
149
+ item.latestVersion = value.version;
150
+ if (value.tag !== undefined)
151
+ item.latestTag = value.tag;
152
+ if (value.sha !== undefined)
153
+ item.latestSha = value.sha;
154
+ }
13
155
  function matchInstalledByEntry(entry, installed) {
14
156
  return installed.find((it) => {
15
157
  if (entry.npm && it.pkg === entry.npm)
@@ -24,125 +166,356 @@ function matchInstalledByEntry(entry, installed) {
24
166
  return false;
25
167
  });
26
168
  }
27
- export async function listMarket(cfg = {}, opts = {}) {
28
- const [loaded, installed] = await Promise.all([
29
- loadRegistry(cfg, opts),
30
- listInstalledPlugins(),
31
- ]);
32
- const timeoutMs = cfg.timeoutMs ?? 20_000;
33
- const items = [];
34
- for (const entry of loaded.registry.plugins) {
35
- const inst = matchInstalledByEntry(entry, installed.items);
36
- const item = {
37
- ...entry,
38
- installed: Boolean(inst),
39
- outdated: false,
169
+ // ---------- 市场列表 ----------
170
+ function marketDeps() {
171
+ return {
172
+ loadRegistry: defaultLoadRegistry,
173
+ listInstalledPlugins: defaultListInstalledPlugins,
174
+ npmLatest: defaultNpmLatest,
175
+ githubLatestTag: defaultGithubLatestTag,
176
+ };
177
+ }
178
+ export async function listMarket(cfg = {}, opts = {}, deps = {}) {
179
+ const d = { ...marketDeps(), ...deps };
180
+ const startedAt = Date.now();
181
+ const deadlineMs = typeof opts.deadlineMs === 'number' && Number.isFinite(opts.deadlineMs) && opts.deadlineMs > 0
182
+ ? opts.deadlineMs
183
+ : DEFAULT_DEADLINE_MS;
184
+ const deadlineAt = startedAt + deadlineMs;
185
+ const namespace = opts.namespace ?? 'host';
186
+ const withLatest = opts.withLatest !== false;
187
+ const maxLimit = withLatest ? WITH_LATEST_MAX : METADATA_ONLY_MAX;
188
+ const signal = opts.signal;
189
+ const remaining = () => deadlineAt - Date.now();
190
+ const registryTask = d.loadRegistry(cfg, { namespace, signal, force: opts.force, deadlineMs });
191
+ const installedTask = d.listInstalledPlugins().catch(() => null);
192
+ let loaded;
193
+ try {
194
+ loaded = await deadlineRace(registryTask, remaining());
195
+ }
196
+ catch (err) {
197
+ void installedTask;
198
+ throw err;
199
+ }
200
+ if (loaded === 'deadline') {
201
+ void installedTask;
202
+ return {
203
+ items: [],
204
+ total: 0,
205
+ offset: 0,
206
+ limit: maxLimit,
207
+ categoryCounts: zeroCounts(),
208
+ registryState: timeoutRegistryState(cfg),
209
+ installedComplete: false,
210
+ latestComplete: false,
211
+ latestTimedOut: true,
212
+ };
213
+ }
214
+ const registryState = stateOf(loaded);
215
+ if (loaded.status === 'unavailable') {
216
+ void installedTask;
217
+ return {
218
+ items: [],
219
+ total: 0,
220
+ offset: 0,
221
+ limit: maxLimit,
222
+ categoryCounts: zeroCounts(),
223
+ registryState,
224
+ installedComplete: false,
225
+ latestComplete: false,
226
+ latestTimedOut: false,
40
227
  };
228
+ }
229
+ const installed = await installedTask;
230
+ const installedComplete = installed !== null;
231
+ const installedItems = installed?.items ?? [];
232
+ // 全量统计 + query/category 过滤 + 分页(同步,极轻)
233
+ const all = loaded.registry.plugins;
234
+ const counts = zeroCounts();
235
+ for (const entry of all)
236
+ counts[entry.category] += 1;
237
+ const q = (opts.query ?? '').trim().toLowerCase();
238
+ const cat = opts.category ?? null;
239
+ const filtered = all.filter((entry) => {
240
+ if (cat && entry.category !== cat)
241
+ return false;
242
+ if (!q)
243
+ return true;
244
+ return `${entry.id} ${entry.name} ${entry.description} ${entry.tags.join(' ')}`.toLowerCase().includes(q);
245
+ });
246
+ const total = filtered.length;
247
+ const limit = clampLimit(opts.limit, maxLimit);
248
+ let offset = normalizeOffset(opts.offset);
249
+ if (total > 0 && offset >= total)
250
+ offset = Math.floor((total - 1) / limit) * limit;
251
+ const items = filtered.slice(offset, offset + limit).map((entry) => {
252
+ const inst = matchInstalledByEntry(entry, installedItems);
253
+ const item = { ...entry, installed: Boolean(inst), outdated: false };
41
254
  if (inst) {
42
255
  item.installedPkg = inst.pkg;
43
256
  item.installedVersion = inst.version;
44
257
  }
45
- try {
46
- if (entry.source === 'npm' && entry.npm) {
47
- const latest = await npmLatest(entry.npm, timeoutMs);
48
- item.latestVersion = latest.version;
49
- if (inst?.version && isNewerVersion(latest.version, inst.version))
50
- item.outdated = true;
258
+ return item;
259
+ });
260
+ let latestComplete = true;
261
+ let latestTimedOut = false;
262
+ if (withLatest && items.length > 0) {
263
+ // 先吃 cache 命中
264
+ const ttlMin = Math.max(0, cfg.cacheTtlMin ?? 60);
265
+ for (const item of items) {
266
+ const cached = readLatestCache(latestCacheKey(namespace, loaded.configuredAddress, item), ttlMin);
267
+ if (cached)
268
+ applyProbe(item, cached);
269
+ }
270
+ const todo = items.filter((it) => it.latestVersion === undefined && it.latestTag === undefined && it.latestSha === undefined);
271
+ if (todo.length > 0) {
272
+ const budget = remaining();
273
+ if (budget <= 0) {
274
+ for (const item of todo)
275
+ item.latestErrorCode = 'LATEST_TIMEOUT';
276
+ latestComplete = false;
277
+ latestTimedOut = true;
51
278
  }
52
- else if (entry.github) {
53
- const sha = await githubHeadSha(entry.github, timeoutMs);
54
- item.latestSha = sha;
55
- if (inst && !inst.spec.includes(sha))
56
- item.outdated = true;
279
+ else {
280
+ await mapWithConcurrency(todo, LATEST_WORKERS, async (item) => {
281
+ const perBudget = Math.max(1, remaining());
282
+ const task = probeTask(item, d, Math.min(cfg.timeoutMs ?? 20_000, perBudget), signal);
283
+ const outcome = await probeWithBudget(task, perBudget, signal);
284
+ if (outcome.ok && outcome.value) {
285
+ applyProbe(item, outcome.value);
286
+ writeLatestCache(latestCacheKey(namespace, loaded.configuredAddress, item), outcome.value);
287
+ }
288
+ else if (outcome.timeout) {
289
+ item.latestErrorCode = 'LATEST_TIMEOUT';
290
+ latestComplete = false;
291
+ latestTimedOut = true;
292
+ }
293
+ else if (outcome.error !== undefined) {
294
+ if (signal?.aborted)
295
+ throw abortError();
296
+ item.latestError = outcome.error instanceof Error ? outcome.error.message : String(outcome.error);
297
+ item.latestErrorCode = 'LATEST_ERROR';
298
+ }
299
+ });
57
300
  }
58
301
  }
59
- catch (err) {
60
- item.latestError = err instanceof Error ? err.message : String(err);
302
+ // outdated 判定统一在 probe 后进行
303
+ for (const item of items) {
304
+ const inst = item.installedPkg !== undefined ? installedItems.find((i) => i.pkg === item.installedPkg) : undefined;
305
+ if (!inst)
306
+ continue;
307
+ if (item.latestVersion !== undefined && inst.version) {
308
+ item.outdated = isNewerVersion(item.latestVersion, inst.version);
309
+ }
310
+ else if (item.latestSha !== undefined) {
311
+ item.outdated = !inst.spec.includes(item.latestSha);
312
+ }
61
313
  }
62
- items.push(item);
63
314
  }
64
- return {
65
- items,
66
- registrySource: loaded.source,
67
- registryFetchedAt: loaded.fetchedAt,
68
- registryErrors: loaded.errors,
69
- };
315
+ return { items, total, offset, limit, categoryCounts: counts, registryState, installedComplete, latestComplete, latestTimedOut };
70
316
  }
71
- export async function listInstalledWithMeta(cfg = {}) {
72
- const [{ items: installed, others, profileDir }, loaded] = await Promise.all([
73
- listInstalledPlugins(),
74
- loadRegistry(cfg),
75
- ]);
76
- const timeoutMs = cfg.timeoutMs ?? 20_000;
77
- const items = [];
78
- for (const it of installed) {
79
- const entry = loaded.registry.plugins.find((e) => matchInstalledByEntry(e, [it]));
80
- const item = { ...it, outdated: false };
81
- if (entry)
317
+ // ---------- 已装列表 ----------
318
+ export async function listInstalledWithMeta(cfg = {}, opts = {}, deps = {}) {
319
+ const d = { ...marketDeps(), ...deps };
320
+ const startedAt = Date.now();
321
+ const deadlineMs = typeof opts.deadlineMs === 'number' && Number.isFinite(opts.deadlineMs) && opts.deadlineMs > 0
322
+ ? opts.deadlineMs
323
+ : DEFAULT_DEADLINE_MS;
324
+ const deadlineAt = startedAt + deadlineMs;
325
+ const namespace = opts.namespace ?? 'host';
326
+ const signal = opts.signal;
327
+ const remaining = () => deadlineAt - Date.now();
328
+ const installedPromise = d.listInstalledPlugins();
329
+ const registryTask = d.loadRegistry(cfg, { namespace, signal, force: opts.force, deadlineMs });
330
+ const installed = await installedPromise;
331
+ const items = installed.items.map((it) => ({ ...it, outdated: false }));
332
+ let loaded;
333
+ try {
334
+ loaded = await deadlineRace(registryTask, remaining());
335
+ }
336
+ catch {
337
+ loaded = 'deadline';
338
+ }
339
+ if (loaded === 'deadline' || loaded.status === 'unavailable') {
340
+ // registry 不可用:不做 matching,直接返回已装列表
341
+ const registryState = loaded === 'deadline' ? timeoutRegistryState(cfg) : stateOf(loaded);
342
+ return { items, others: installed.others, profileDir: installed.profileDir, registryState };
343
+ }
344
+ const registryState = stateOf(loaded);
345
+ const ttlMin = Math.max(0, cfg.cacheTtlMin ?? 60);
346
+ await mapWithConcurrency(items, LATEST_WORKERS, async (item) => {
347
+ const entry = loaded.registry.plugins.find((e) => matchInstalledByEntry(e, [item]));
348
+ if (entry) {
82
349
  item.registryId = entry.id;
83
- try {
84
- if (!entry && it.source === 'npm') {
85
- const latest = await npmLatest(it.pkg, timeoutMs);
86
- item.latestVersion = latest.version;
87
- item.outdated = isNewerVersion(latest.version, it.version);
88
- }
89
- else if (entry?.source === 'npm' && entry.npm) {
90
- const latest = await npmLatest(entry.npm, timeoutMs);
91
- item.latestVersion = latest.version;
92
- item.outdated = isNewerVersion(latest.version, it.version);
350
+ item.registryGithub = entry.github ?? null;
351
+ item.registryIcon = entry.icon ?? null;
352
+ }
353
+ // latest 探测沿用 listMarket 的 TTL cache
354
+ const cacheKey = entry
355
+ ? latestCacheKey(namespace, loaded.configuredAddress, entry)
356
+ : item.source === 'npm'
357
+ ? `npm-only|${namespace}|npm:${item.pkg}`
358
+ : null;
359
+ if (cacheKey) {
360
+ const cached = readLatestCache(cacheKey, ttlMin);
361
+ if (cached) {
362
+ applyProbe(item, cached);
93
363
  }
94
- else if (it.source === 'github') {
95
- const m = /^github:([^#]+)/.exec(it.spec);
96
- if (m) {
97
- const sha = await githubHeadSha(m[1], timeoutMs);
98
- item.outdated = !it.spec.includes(sha);
364
+ else if (remaining() > 0) {
365
+ const budget = Math.max(1, remaining());
366
+ try {
367
+ let value = null;
368
+ if (!entry && item.source === 'npm') {
369
+ const latest = await d.npmLatest(item.pkg, Math.min(cfg.timeoutMs ?? 20_000, budget), signal);
370
+ value = { version: latest.version };
371
+ }
372
+ else if (entry?.source === 'npm' && entry.npm) {
373
+ const latest = await d.npmLatest(entry.npm, Math.min(cfg.timeoutMs ?? 20_000, budget), signal);
374
+ value = { version: latest.version };
375
+ }
376
+ else if (item.source === 'github') {
377
+ const m = /^github:([^#]+)/.exec(item.spec);
378
+ if (m) {
379
+ const latest = await d.githubLatestTag(m[1], Math.min(cfg.timeoutMs ?? 20_000, budget), signal);
380
+ value = { tag: latest.tag, sha: latest.sha };
381
+ }
382
+ }
383
+ if (value) {
384
+ applyProbe(item, value);
385
+ writeLatestCache(cacheKey, value);
386
+ }
387
+ }
388
+ catch (err) {
389
+ item.latestError = err instanceof Error ? err.message : String(err);
99
390
  }
100
391
  }
101
392
  }
102
- catch {
103
- /* 查询失败不阻塞列表,outdated 维持 false */
104
- }
105
- items.push(item);
106
- }
107
- return { items, others, profileDir };
393
+ if (item.latestVersion !== undefined && item.version)
394
+ item.outdated = isNewerVersion(item.latestVersion, item.version);
395
+ else if (item.latestSha !== undefined)
396
+ item.outdated = !item.spec.includes(item.latestSha);
397
+ });
398
+ return { items, others: installed.others, profileDir: installed.profileDir, registryState };
399
+ }
400
+ async function defaultRestoreInstall(profileDir) {
401
+ return runCommand('pnpm', ['--dir', profileDir, 'install', '--frozen-lockfile'], { timeoutMs: installTimeoutMs() });
108
402
  }
109
403
  /** 从 registry 收录条目安装(npm → 精确锁定最新版;github → 锁 HEAD SHA)。 */
110
- export async function installFromRegistry(id, cfg = {}, opts = {}) {
111
- const { registry } = await loadRegistry(cfg);
112
- const entry = registry.plugins.find((e) => e.id === id);
404
+ export async function installFromRegistry(id, cfg = {}, opts = {}, deps) {
405
+ const loaded = await (deps?.loadRegistry ?? defaultLoadRegistry)(cfg, { namespace: opts.namespace ?? 'host' });
406
+ if (loaded.status === 'unavailable') {
407
+ throw new Error(`收录清单不可用,无法安装 ${id};请检查 registry 配置或网络后重试`);
408
+ }
409
+ const entry = loaded.registry.plugins.find((e) => e.id === id);
113
410
  if (!entry)
114
411
  throw new Error(`registry 中没有该条目: ${id}`);
115
- return installEntry(entry, cfg, opts);
412
+ return installEntry(entry, cfg, opts, deps);
116
413
  }
117
- export async function installEntry(entry, cfg = {}, opts = {}) {
414
+ export async function installEntry(entry, cfg = {}, opts = {}, deps) {
118
415
  const timeoutMs = cfg.timeoutMs ?? 20_000;
416
+ const d = {
417
+ npmLatest: deps?.npmLatest ?? defaultNpmLatest,
418
+ npmVersion: deps?.npmVersion ?? npmVersion,
419
+ addDshPlugin: deps?.addDshPlugin ?? addDshPlugin,
420
+ removeDshPlugin: deps?.removeDshPlugin ?? removeDshPlugin,
421
+ readProfileDeps: deps?.readProfileDeps ?? readProfileDeps,
422
+ readLockIntegrity: deps?.readLockIntegrity ?? readPnpmLockIntegrity,
423
+ restoreInstall: deps?.restoreInstall ?? defaultRestoreInstall,
424
+ };
425
+ const profileDir = deps?.profileDir ?? webProfileDir();
119
426
  if (entry.source === 'npm' && entry.npm) {
120
- const version = opts.version && /^\d+\.\d+\.\d+/.test(opts.version) ? opts.version : (await npmLatest(entry.npm, timeoutMs)).version;
121
- const spec = `${entry.npm}@${version}`;
122
- const res = await addDshPlugin(spec);
123
- // 安装后校验:落盘版本必须与意图一致(tarball 完整性由 pnpm 按 lock integrity 保证)
124
- const deps = await readProfileDeps(webProfileDir());
125
- const specInProfile = deps[entry.npm];
126
- if (specInProfile === undefined)
127
- throw new Error(`安装后未在 profile 依赖中找到 ${entry.npm}`);
128
- return {
129
- id: entry.id,
130
- pkg: entry.npm,
131
- spec,
132
- version,
133
- usedAllowAllBuilds: res.usedAllowAllBuilds,
134
- needsRestart: true,
135
- output: res.output.slice(-800),
136
- };
427
+ const pkg = entry.npm;
428
+ // npm:无论 latest 还是用户指定 exact,都先读取该精确版本的 dist metadata
429
+ let version;
430
+ let expectedIntegrity;
431
+ if (opts.version) {
432
+ const meta = await d.npmVersion(pkg, opts.version, timeoutMs, opts.signal);
433
+ version = meta.version;
434
+ expectedIntegrity = meta.integrity;
435
+ }
436
+ else {
437
+ const latest = await d.npmLatest(pkg, timeoutMs, opts.signal);
438
+ version = latest.version;
439
+ expectedIntegrity = latest.integrity;
440
+ }
441
+ if (!expectedIntegrity)
442
+ throw new Error(`npm metadata 缺少 dist integrity:${pkg}@${version},拒绝安装`);
443
+ const spec = `${pkg}@${version}`;
444
+ // 安装前快照:失败时 best-effort 依赖回滚的依据
445
+ const snapshots = await snapshotFiles([
446
+ join(profileDir, 'package.json'),
447
+ join(profileDir, 'pnpm-lock.yaml'),
448
+ join(profileDir, 'pnpm-workspace.yaml'),
449
+ ]);
450
+ try {
451
+ const res = await d.addDshPlugin(spec);
452
+ const depsNow = await d.readProfileDeps(profileDir);
453
+ if (depsNow[pkg] === undefined)
454
+ throw new Error(`安装后未在 profile 依赖中找到 ${pkg}`);
455
+ if (depsNow[pkg] !== version)
456
+ throw new Error(`profile 依赖版本 ${depsNow[pkg]} 与目标 ${version} 不一致`);
457
+ let lockText;
458
+ try {
459
+ lockText = await readFile(join(profileDir, 'pnpm-lock.yaml'), 'utf8');
460
+ }
461
+ catch {
462
+ throw new Error('安装后未找到 pnpm-lock.yaml,无法核对 integrity');
463
+ }
464
+ const actual = d.readLockIntegrity(lockText, pkg, version);
465
+ assertNpmIntegrity(expectedIntegrity, actual, pkg, version);
466
+ return {
467
+ id: entry.id,
468
+ pkg,
469
+ spec,
470
+ version,
471
+ usedAllowAllBuilds: res.usedAllowAllBuilds,
472
+ needsRestart: true,
473
+ output: res.output.slice(-800),
474
+ };
475
+ }
476
+ catch (err) {
477
+ // best-effort rollback:原子恢复 manifest/lock/workspace 快照,再 frozen-lockfile 重装
478
+ let rollbackError = null;
479
+ try {
480
+ await restoreSnapshots(snapshots);
481
+ await d.restoreInstall(profileDir);
482
+ }
483
+ catch (rerr) {
484
+ rollbackError = rerr instanceof Error ? rerr.message : String(rerr);
485
+ // 原先不存在该依赖且恢复安装失败:尝试移除
486
+ const originallyAbsent = snapshots[0] && snapshots[0].existed && (() => {
487
+ try {
488
+ return !JSON.parse(snapshots[0].bytes.toString('utf8'))?.dependencies?.[pkg];
489
+ }
490
+ catch {
491
+ return false;
492
+ }
493
+ })();
494
+ if (originallyAbsent) {
495
+ try {
496
+ await d.removeDshPlugin(pkg);
497
+ rollbackError = null;
498
+ }
499
+ catch (rmErr) {
500
+ rollbackError = `${rollbackError};移除 ${pkg} 也失败(${rmErr instanceof Error ? rmErr.message : String(rmErr)})`;
501
+ }
502
+ }
503
+ }
504
+ if (rollbackError) {
505
+ throw new Error(`integrity 校验失败(${err instanceof Error ? err.message : String(err)});依赖回滚也失败(${rollbackError}),profile 可能需要人工修复`);
506
+ }
507
+ throw new Error(`integrity 校验失败,已回滚到安装前状态:${err instanceof Error ? err.message : String(err)}`);
508
+ }
137
509
  }
138
510
  if (entry.github) {
139
- const sha = await githubHeadSha(entry.github, timeoutMs);
511
+ const { tag, sha } = await defaultGithubLatestTag(entry.github, timeoutMs, opts.signal);
140
512
  const spec = `github:${entry.github}#${sha}`;
141
- const res = await addDshPlugin(spec);
142
- const deps = await readProfileDeps(webProfileDir());
143
- const pkgKey = Object.keys(deps).find((k) => deps[k] === spec || deps[k].startsWith(`github:${entry.github}#`));
513
+ const res = await d.addDshPlugin(spec);
514
+ const depsNow = await d.readProfileDeps(profileDir);
515
+ const pkgKey = Object.keys(depsNow).find((k) => depsNow[k] === spec || depsNow[k].startsWith(`github:${entry.github}#`));
144
516
  if (!pkgKey)
145
517
  throw new Error(`安装后未在 profile 依赖中找到 ${entry.github}`);
518
+ void tag;
146
519
  return {
147
520
  id: entry.id,
148
521
  pkg: pkgKey,
@@ -156,24 +529,28 @@ export async function installEntry(entry, cfg = {}, opts = {}) {
156
529
  throw new Error(`条目 ${entry.id} 缺少可安装来源`);
157
530
  }
158
531
  /** 卸载:live-disable → pnpm remove → 报告疑似残留(DESIGN.md §3:删包不删数据)。 */
159
- export async function uninstallPlugin(pkg, _cfg = {}) {
532
+ export async function uninstallPlugin(pkg, _cfg = {}, _opts = {}) {
160
533
  const liveDisabled = await setLivePluginDisabled(pkg, true);
161
534
  await removeInstalledPlugin(pkg);
162
535
  return { pkg, liveDisabled, needsRestart: true, leftovers: leftoverCandidates(pkg) };
163
536
  }
164
537
  /** 升级 = 按最新重新安装(npm 拉最新精确版;github 重新锁 HEAD)。 */
165
- export async function upgradePlugin(pkg, cfg = {}) {
166
- const { registry } = await loadRegistry(cfg);
167
- const { items: installed } = await listInstalledPlugins();
538
+ export async function upgradePlugin(pkg, cfg = {}, opts = {}, deps) {
539
+ const loaded = await (deps?.loadRegistry ?? defaultLoadRegistry)(cfg, { namespace: opts.namespace ?? 'host' });
540
+ if (loaded.status === 'unavailable') {
541
+ throw new Error(`收录清单不可用,无法升级 ${pkg};请检查 registry 配置或网络后重试`);
542
+ }
543
+ const { items: installed } = await (deps?.listInstalledPlugins ?? defaultListInstalledPlugins)();
168
544
  const target = installed.find((it) => it.pkg === pkg);
169
545
  if (!target)
170
546
  throw new Error(`web profile 未安装该插件: ${pkg}`);
171
- const entry = registry.plugins.find((e) => matchInstalledByEntry(e, [target]));
547
+ const entry = loaded.registry.plugins.find((e) => matchInstalledByEntry(e, [target]));
172
548
  if (!entry)
173
549
  throw new Error(`「${pkg}」不是经 dsh-m 收录的插件;直接升级请用 dsh plugin update 或先在 registry 收录它`);
174
- const result = await installEntry(entry, cfg);
550
+ const result = await installEntry(entry, cfg, opts, deps);
175
551
  return { ...result, fromVersion: target.version };
176
552
  }
553
+ // ---------- 变更互斥 ----------
177
554
  /** 变更互斥:安装/卸载/升级串行执行(skillhub install-lock 同款思路)。 */
178
555
  let mutationTail = Promise.resolve();
179
556
  export function withMutationLock(task) {