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.
- package/README.en.md +104 -0
- package/README.md +61 -23
- package/docs/DESIGN.md +172 -0
- package/lib/cli.js +134 -63
- package/lib/client.js +1254 -217
- package/lib/core/dsh-cli.js +118 -10
- package/lib/core/host-api.js +285 -0
- package/lib/core/httpx.js +93 -36
- package/lib/core/installed.js +69 -4
- package/lib/core/market.js +481 -104
- package/lib/core/npm-integrity.js +141 -0
- package/lib/core/progress.js +113 -0
- package/lib/core/registry-check.js +111 -0
- package/lib/core/registry-controller.js +321 -0
- package/lib/core/registry.js +634 -98
- package/lib/core/versions.js +80 -9
- package/lib/host.js +22 -161
- package/lib/tools.js +56 -41
- package/package.json +4 -2
- package/registry.json +95 -7
- package/DESIGN.md +0 -140
package/lib/core/market.js
CHANGED
|
@@ -1,15 +1,157 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 市场编排层:registry × profile × 最新版本 →
|
|
3
|
-
* 安装 /
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
60
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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 (
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
item.
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
|
112
|
-
|
|
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
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
pkg
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
|
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
|
|
143
|
-
const pkgKey = Object.keys(
|
|
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
|
|
167
|
-
|
|
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) {
|