dsh-m 0.4.3 → 0.5.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/README.en.md +12 -0
- package/README.md +12 -0
- package/docs/DESIGN.md +30 -0
- package/lib/cli.js +94 -20
- package/lib/client.js +282 -17
- package/lib/core/community-adapter.js +181 -0
- package/lib/core/community.js +433 -0
- package/lib/core/host-api.js +53 -19
- package/lib/core/httpx.js +38 -0
- package/lib/core/install-guard.js +308 -0
- package/lib/core/market.js +707 -57
- package/lib/core/profile-transaction.js +238 -1
- package/lib/core/registry-controller.js +8 -0
- package/lib/core/registry.js +3 -3
- package/lib/core/settings-compat.js +4 -0
- package/lib/core/toggle.js +6 -1
- package/lib/core/versions.js +149 -25
- package/lib/host.js +2 -0
- package/lib/tools.js +139 -33
- package/package.json +1 -1
- package/registry.json +19 -0
package/lib/core/market.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import { existsSync } from 'node:fs';
|
|
11
11
|
import { readFile } from 'node:fs/promises';
|
|
12
12
|
import { join } from 'node:path';
|
|
13
|
-
import { dshHome } from './env.js';
|
|
13
|
+
import { dshHome, webProfileDir } from './env.js';
|
|
14
14
|
import { runProfileTransaction, makeNpmWarmPackument, TransactionError, } from './profile-transaction.js';
|
|
15
15
|
import { listInstalledPlugins as defaultListInstalledPlugins, } from './installed.js';
|
|
16
16
|
import { loaderHost } from './live-plugin.js';
|
|
@@ -18,6 +18,12 @@ import { precheckNpmCompat, IncompatibleError } from './compat-check.js';
|
|
|
18
18
|
import { PROTECTED_MODULES, composeEnablement } from './enablement.js';
|
|
19
19
|
import { readProfileOverrides } from './patch-yaml.js';
|
|
20
20
|
import { loadRegistry as defaultLoadRegistry, } from './registry.js';
|
|
21
|
+
import { fetchCommunityCatalog as defaultFetchCommunityCatalog, } from './community.js';
|
|
22
|
+
import { adaptCommunityCatalog } from './community-adapter.js';
|
|
23
|
+
import { GithubBudgetExhaustedError, createGithubRequestBudget, isExactVersion } from './versions.js';
|
|
24
|
+
import { HttpError } from './httpx.js';
|
|
25
|
+
import { verifyInstalledAdditions } from './install-guard.js';
|
|
26
|
+
import { fetchTextLimited } from './httpx.js';
|
|
21
27
|
import { githubLatestTag as defaultGithubLatestTag, isNewerVersion, npmLatest as defaultNpmLatest, npmVersion, } from './versions.js';
|
|
22
28
|
/** profile package.json 的 dsh.profile.bundles 数组(读不到 → [];Task 13)。 */
|
|
23
29
|
async function readProfileBundles(profileDir) {
|
|
@@ -189,6 +195,16 @@ function applyProbe(item, value) {
|
|
|
189
195
|
if (value.sha !== undefined)
|
|
190
196
|
item.latestSha = value.sha;
|
|
191
197
|
}
|
|
198
|
+
/** probe 错误 → 结构化 code(Task 7 ⑨):预算/限流/超时/其他网络错误各自归类,不得一律归因预算。 */
|
|
199
|
+
function classifyLatestError(err) {
|
|
200
|
+
if (err instanceof GithubBudgetExhaustedError)
|
|
201
|
+
return 'budget-exhausted';
|
|
202
|
+
if (err instanceof HttpError && err.status === 403)
|
|
203
|
+
return 'rate-limited';
|
|
204
|
+
if (err instanceof Error && /限额已用尽/.test(err.message))
|
|
205
|
+
return 'rate-limited';
|
|
206
|
+
return 'network-error';
|
|
207
|
+
}
|
|
192
208
|
function matchInstalledByEntry(entry, installed) {
|
|
193
209
|
return installed.find((it) => {
|
|
194
210
|
if (entry.npm && it.pkg === entry.npm)
|
|
@@ -203,6 +219,132 @@ function matchInstalledByEntry(entry, installed) {
|
|
|
203
219
|
return false;
|
|
204
220
|
});
|
|
205
221
|
}
|
|
222
|
+
/**
|
|
223
|
+
* 合并去重(Q41):社区条目依次与主清单的 npm 名集 / github owner-repo 集 / id 集比对,
|
|
224
|
+
* 任一相撞即让位(每条只计一次);社区内部 npm 名重复同样让位(首条优先)。
|
|
225
|
+
* 主清单恒优先——让位只影响社区条目,主条目原样保留。
|
|
226
|
+
*/
|
|
227
|
+
export function mergeRegistries(primary, community) {
|
|
228
|
+
const primaryNpm = new Set(primary.filter((e) => e.npm).map((e) => e.npm));
|
|
229
|
+
const primaryGithub = new Set(primary.filter((e) => e.github).map((e) => e.github));
|
|
230
|
+
const primaryIds = new Set(primary.map((e) => e.id));
|
|
231
|
+
const displaced = [];
|
|
232
|
+
const kept = [];
|
|
233
|
+
const seenNpm = new Set();
|
|
234
|
+
for (const c of community) {
|
|
235
|
+
if (c.npm !== undefined) {
|
|
236
|
+
if (primaryNpm.has(c.npm) || seenNpm.has(c.npm)) {
|
|
237
|
+
displaced.push(c.id);
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
seenNpm.add(c.npm);
|
|
241
|
+
}
|
|
242
|
+
if ((c.github !== undefined && primaryGithub.has(c.github)) || primaryIds.has(c.id)) {
|
|
243
|
+
displaced.push(c.id);
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
kept.push(c);
|
|
247
|
+
}
|
|
248
|
+
// Q45:主清单置顶(组内原顺序)+ 社区按 30 天下载量降序(无数据按名称)
|
|
249
|
+
const sorted = [...kept].sort((a, b) => {
|
|
250
|
+
const da = a.downloads ?? -1;
|
|
251
|
+
const db = b.downloads ?? -1;
|
|
252
|
+
if (da !== db)
|
|
253
|
+
return db - da;
|
|
254
|
+
return a.name.localeCompare(b.name);
|
|
255
|
+
});
|
|
256
|
+
const warnings = displaced.length > 0 ? [`${displaced.length} 条社区条目与主清单重复,已让位(主清单恒优先)`] : [];
|
|
257
|
+
return { items: [...primary, ...sorted], displaced: displaced.length, warnings };
|
|
258
|
+
}
|
|
259
|
+
function communitySummary(state, counts, extraWarnings) {
|
|
260
|
+
const unavailableLike = state.status === 'disabled' || state.status === 'unavailable';
|
|
261
|
+
return {
|
|
262
|
+
enabled: state.enabled,
|
|
263
|
+
status: state.status,
|
|
264
|
+
version: state.version,
|
|
265
|
+
checkedAt: state.checkedAt,
|
|
266
|
+
fetchedAt: state.fetchedAt,
|
|
267
|
+
route: state.route,
|
|
268
|
+
acceptedCount: unavailableLike ? 0 : (counts.acceptedCount ?? 0),
|
|
269
|
+
upstreamCount: unavailableLike ? (state.status === 'disabled' ? null : 0) : (counts.upstreamCount ?? state.count),
|
|
270
|
+
displaced: unavailableLike ? 0 : (counts.displaced ?? 0),
|
|
271
|
+
skippedDirty: unavailableLike ? 0 : (counts.skippedDirty ?? 0),
|
|
272
|
+
skippedSubpathNoNpm: unavailableLike ? 0 : (counts.skippedSubpathNoNpm ?? 0),
|
|
273
|
+
errors: [...state.errors],
|
|
274
|
+
warnings: [...state.warnings, ...extraWarnings],
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
function disabledCommunitySummary() {
|
|
278
|
+
return communitySummary({ enabled: false, status: 'disabled', version: null, checkedAt: null, fetchedAt: null, route: null, count: 0, errors: [], warnings: [] }, {}, []);
|
|
279
|
+
}
|
|
280
|
+
function communityTimeoutSummary() {
|
|
281
|
+
return communitySummary({ enabled: true, status: 'unavailable', version: null, checkedAt: null, fetchedAt: null, route: null, count: 0, errors: ['社区目录状态获取超时,可稍后刷新'], warnings: [] }, {}, []);
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* 社区 loader waiter 收敛(v9/v10 waiter-scoped 契约):共享 flight 不接收调用者 deadline,
|
|
285
|
+
* 本函数作为 waiter 用剩余 deadline race 自己的等待;到点只结束本 waiter(summary 标超时),
|
|
286
|
+
* 共享 flight 照常继续。primaryOnly/未启用 → loader 零调用(task 传 null)。
|
|
287
|
+
* 导出供 community.ts getCommunitySummary 复用(summary 组装单一产地)。
|
|
288
|
+
*/
|
|
289
|
+
export async function communityOutcome(task, deadlineAt, primary) {
|
|
290
|
+
if (!task)
|
|
291
|
+
return { summary: disabledCommunitySummary(), merged: primary };
|
|
292
|
+
let loaded;
|
|
293
|
+
try {
|
|
294
|
+
loaded = await deadlineRace(task, deadlineAt - Date.now());
|
|
295
|
+
}
|
|
296
|
+
finally {
|
|
297
|
+
// race 输出后共享 promise 若仍悬挂(deadline 先到),收尾防 unhandled rejection
|
|
298
|
+
void task.catch(() => undefined);
|
|
299
|
+
}
|
|
300
|
+
if (loaded === 'deadline')
|
|
301
|
+
return { summary: communityTimeoutSummary(), merged: primary };
|
|
302
|
+
const state = loaded.state;
|
|
303
|
+
if (state.status === 'disabled')
|
|
304
|
+
return { summary: disabledCommunitySummary(), merged: primary };
|
|
305
|
+
if (state.status === 'unavailable' || !loaded.catalog) {
|
|
306
|
+
return {
|
|
307
|
+
summary: communitySummary(state, { acceptedCount: 0, upstreamCount: 0, displaced: 0, skippedDirty: 0, skippedSubpathNoNpm: 0 }, []),
|
|
308
|
+
merged: primary,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
const adapted = adaptCommunityCatalog(loaded.catalog);
|
|
312
|
+
const merge = mergeRegistries(primary, adapted.entries);
|
|
313
|
+
const summary = communitySummary(state, {
|
|
314
|
+
acceptedCount: adapted.entries.length,
|
|
315
|
+
upstreamCount: loaded.catalog.plugins.length,
|
|
316
|
+
displaced: merge.displaced,
|
|
317
|
+
skippedDirty: adapted.skippedDirty,
|
|
318
|
+
skippedSubpathNoNpm: adapted.skippedSubpathNoNpm,
|
|
319
|
+
}, [...adapted.warnings, ...merge.warnings]);
|
|
320
|
+
return { summary, merged: merge.items };
|
|
321
|
+
}
|
|
322
|
+
function isCommunityEntry(entry) {
|
|
323
|
+
return entry.descriptionEn !== undefined;
|
|
324
|
+
}
|
|
325
|
+
/** 合并条目 → MarketItem:社区条目带 community 标记与旁路字段。 */
|
|
326
|
+
function toMarketItem(entry, installedItems) {
|
|
327
|
+
const inst = matchInstalledByEntry(entry, installedItems);
|
|
328
|
+
const item = { ...entry, installed: Boolean(inst), outdated: false };
|
|
329
|
+
if (inst) {
|
|
330
|
+
item.installedPkg = inst.pkg;
|
|
331
|
+
item.installedVersion = inst.version;
|
|
332
|
+
}
|
|
333
|
+
if (isCommunityEntry(entry)) {
|
|
334
|
+
item.community = true;
|
|
335
|
+
item.stars = entry.stars;
|
|
336
|
+
item.downloads = entry.downloads;
|
|
337
|
+
item.capabilities = entry.capabilities;
|
|
338
|
+
item.capabilityRedLines = entry.capabilityRedLines;
|
|
339
|
+
item.screenshots = entry.screenshots;
|
|
340
|
+
}
|
|
341
|
+
return item;
|
|
342
|
+
}
|
|
343
|
+
/** 合并条目的搜索串:社区条目附英文描述原文(Q45 搜索同时匹配中英文)。 */
|
|
344
|
+
function searchableText(entry) {
|
|
345
|
+
const base = `${entry.id} ${entry.name} ${entry.description} ${entry.tags.join(' ')}`;
|
|
346
|
+
return isCommunityEntry(entry) ? `${base} ${entry.descriptionEn}` : base;
|
|
347
|
+
}
|
|
206
348
|
// ---------- 市场列表 ----------
|
|
207
349
|
function marketDeps() {
|
|
208
350
|
return {
|
|
@@ -210,6 +352,7 @@ function marketDeps() {
|
|
|
210
352
|
listInstalledPlugins: defaultListInstalledPlugins,
|
|
211
353
|
npmLatest: defaultNpmLatest,
|
|
212
354
|
githubLatestTag: defaultGithubLatestTag,
|
|
355
|
+
fetchCommunityCatalog: defaultFetchCommunityCatalog,
|
|
213
356
|
};
|
|
214
357
|
}
|
|
215
358
|
export async function listMarket(cfg = {}, opts = {}, deps = {}) {
|
|
@@ -226,12 +369,16 @@ export async function listMarket(cfg = {}, opts = {}, deps = {}) {
|
|
|
226
369
|
const remaining = () => deadlineAt - Date.now();
|
|
227
370
|
const registryTask = d.loadRegistry(cfg, { namespace, signal, force: opts.force, deadlineMs });
|
|
228
371
|
const installedTask = d.listInstalledPlugins().catch(() => null);
|
|
372
|
+
// 社区 flight 并发启动(primaryOnly 零调用);共享 loader 不接收调用者 deadline——
|
|
373
|
+
// listMarket 作为 waiter 在 communityOutcome 内 race 自己的剩余 deadline/signal(v10 契约)
|
|
374
|
+
const communityTask = opts.primaryOnly === true ? null : d.fetchCommunityCatalog(cfg, { namespace, signal, force: opts.force });
|
|
229
375
|
let loaded;
|
|
230
376
|
try {
|
|
231
377
|
loaded = await deadlineRace(registryTask, remaining());
|
|
232
378
|
}
|
|
233
379
|
catch (err) {
|
|
234
380
|
void installedTask;
|
|
381
|
+
void communityTask?.catch(() => undefined);
|
|
235
382
|
throw err;
|
|
236
383
|
}
|
|
237
384
|
if (loaded === 'deadline') {
|
|
@@ -246,32 +393,21 @@ export async function listMarket(cfg = {}, opts = {}, deps = {}) {
|
|
|
246
393
|
installedComplete: false,
|
|
247
394
|
latestComplete: false,
|
|
248
395
|
latestTimedOut: true,
|
|
396
|
+
community: communityTimeoutSummary(),
|
|
249
397
|
};
|
|
250
398
|
}
|
|
251
399
|
const registryState = stateOf(loaded);
|
|
252
|
-
if (loaded.status === 'unavailable') {
|
|
253
|
-
void installedTask;
|
|
254
|
-
return {
|
|
255
|
-
items: [],
|
|
256
|
-
total: 0,
|
|
257
|
-
offset: 0,
|
|
258
|
-
limit: maxLimit,
|
|
259
|
-
categoryCounts: zeroCounts(),
|
|
260
|
-
registryState,
|
|
261
|
-
installedComplete: false,
|
|
262
|
-
latestComplete: false,
|
|
263
|
-
latestTimedOut: false,
|
|
264
|
-
};
|
|
265
|
-
}
|
|
266
400
|
const installed = await installedTask;
|
|
267
401
|
// 完整性两层来源:枚举 throw → null;枚举 partial resolve → complete:false(installed.ts 完整性契约)
|
|
268
402
|
const installedComplete = installed !== null && installed.complete === true;
|
|
269
403
|
const installedItems = installed?.items ?? [];
|
|
404
|
+
// 社区 waiter 收敛:主 unavailable 时 merged = 存活社区条目(Q42 出页不返空);两层皆不可用 → 空页契约
|
|
405
|
+
const community = await communityOutcome(communityTask, deadlineAt, loaded.registry.plugins);
|
|
270
406
|
// 全量统计 + query/category 过滤 + 分页(同步,极轻)
|
|
271
|
-
const all =
|
|
407
|
+
const all = community.merged;
|
|
272
408
|
const counts = zeroCounts();
|
|
273
409
|
for (const entry of all)
|
|
274
|
-
counts[entry.category]
|
|
410
|
+
counts[entry.category] = (counts[entry.category] ?? 0) + 1;
|
|
275
411
|
const q = (opts.query ?? '').trim().toLowerCase();
|
|
276
412
|
const cat = opts.category ?? null;
|
|
277
413
|
const filtered = all.filter((entry) => {
|
|
@@ -279,22 +415,14 @@ export async function listMarket(cfg = {}, opts = {}, deps = {}) {
|
|
|
279
415
|
return false;
|
|
280
416
|
if (!q)
|
|
281
417
|
return true;
|
|
282
|
-
return
|
|
418
|
+
return searchableText(entry).toLowerCase().includes(q);
|
|
283
419
|
});
|
|
284
420
|
const total = filtered.length;
|
|
285
421
|
const limit = clampLimit(opts.limit, maxLimit);
|
|
286
422
|
let offset = normalizeOffset(opts.offset);
|
|
287
423
|
if (total > 0 && offset >= total)
|
|
288
424
|
offset = Math.floor((total - 1) / limit) * limit;
|
|
289
|
-
const items = filtered.slice(offset, offset + limit).map((entry) =>
|
|
290
|
-
const inst = matchInstalledByEntry(entry, installedItems);
|
|
291
|
-
const item = { ...entry, installed: Boolean(inst), outdated: false };
|
|
292
|
-
if (inst) {
|
|
293
|
-
item.installedPkg = inst.pkg;
|
|
294
|
-
item.installedVersion = inst.version;
|
|
295
|
-
}
|
|
296
|
-
return item;
|
|
297
|
-
});
|
|
425
|
+
const items = filtered.slice(offset, offset + limit).map((entry) => toMarketItem(entry, installedItems));
|
|
298
426
|
let latestComplete = true;
|
|
299
427
|
let latestTimedOut = false;
|
|
300
428
|
if (withLatest && items.length > 0) {
|
|
@@ -305,12 +433,18 @@ export async function listMarket(cfg = {}, opts = {}, deps = {}) {
|
|
|
305
433
|
if (cached)
|
|
306
434
|
applyProbe(item, cached);
|
|
307
435
|
}
|
|
308
|
-
const todo = items.filter((it) => it.latestVersion === undefined &&
|
|
436
|
+
const todo = items.filter((it) => it.latestVersion === undefined &&
|
|
437
|
+
it.latestTag === undefined &&
|
|
438
|
+
it.latestSha === undefined &&
|
|
439
|
+
// Q46 探测边界:社区 github 条目不做浏览页 REST 探测(配额不可控);社区 npm 条目照常
|
|
440
|
+
!(it.community === true && it.source === 'github'));
|
|
309
441
|
if (todo.length > 0) {
|
|
310
442
|
const budget = remaining();
|
|
311
443
|
if (budget <= 0) {
|
|
312
|
-
for (const item of todo)
|
|
313
|
-
item.
|
|
444
|
+
for (const item of todo) {
|
|
445
|
+
item.latestError = '更新检查未完成:时间预算已用尽';
|
|
446
|
+
item.latestErrorCode = 'timeout';
|
|
447
|
+
}
|
|
314
448
|
latestComplete = false;
|
|
315
449
|
latestTimedOut = true;
|
|
316
450
|
}
|
|
@@ -324,7 +458,8 @@ export async function listMarket(cfg = {}, opts = {}, deps = {}) {
|
|
|
324
458
|
writeLatestCache(latestCacheKey(namespace, loaded.configuredAddress, item), outcome.value);
|
|
325
459
|
}
|
|
326
460
|
else if (outcome.timeout) {
|
|
327
|
-
item.
|
|
461
|
+
item.latestError = '更新检查未完成:超时';
|
|
462
|
+
item.latestErrorCode = 'timeout';
|
|
328
463
|
latestComplete = false;
|
|
329
464
|
latestTimedOut = true;
|
|
330
465
|
}
|
|
@@ -332,7 +467,7 @@ export async function listMarket(cfg = {}, opts = {}, deps = {}) {
|
|
|
332
467
|
if (signal?.aborted)
|
|
333
468
|
throw abortError();
|
|
334
469
|
item.latestError = outcome.error instanceof Error ? outcome.error.message : String(outcome.error);
|
|
335
|
-
item.latestErrorCode =
|
|
470
|
+
item.latestErrorCode = classifyLatestError(outcome.error);
|
|
336
471
|
}
|
|
337
472
|
});
|
|
338
473
|
}
|
|
@@ -350,7 +485,18 @@ export async function listMarket(cfg = {}, opts = {}, deps = {}) {
|
|
|
350
485
|
}
|
|
351
486
|
}
|
|
352
487
|
}
|
|
353
|
-
return {
|
|
488
|
+
return {
|
|
489
|
+
items,
|
|
490
|
+
total,
|
|
491
|
+
offset,
|
|
492
|
+
limit,
|
|
493
|
+
categoryCounts: counts,
|
|
494
|
+
registryState,
|
|
495
|
+
installedComplete,
|
|
496
|
+
latestComplete,
|
|
497
|
+
latestTimedOut,
|
|
498
|
+
community: community.summary,
|
|
499
|
+
};
|
|
354
500
|
}
|
|
355
501
|
// ---------- 已装列表 ----------
|
|
356
502
|
export async function listInstalledWithMeta(cfg = {}, opts = {}, deps = {}) {
|
|
@@ -365,6 +511,9 @@ export async function listInstalledWithMeta(cfg = {}, opts = {}, deps = {}) {
|
|
|
365
511
|
const remaining = () => deadlineAt - Date.now();
|
|
366
512
|
const installedPromise = d.listInstalledPlugins();
|
|
367
513
|
const registryTask = d.loadRegistry(cfg, { namespace, signal, force: opts.force, deadlineMs });
|
|
514
|
+
// 社区 flight 并发启动 + request-scoped GitHub 预算(本次检查 ≤25 个 wire 请求、宿主滚动 50/h)
|
|
515
|
+
const communityTask = d.fetchCommunityCatalog(cfg, { namespace, signal, force: opts.force });
|
|
516
|
+
const githubBudget = createGithubRequestBudget();
|
|
368
517
|
const installed = await installedPromise;
|
|
369
518
|
// enablement join(Task 13,ADR-0001 读路径自读):loader ⋈ bundles ⋈ profile 覆盖行
|
|
370
519
|
const compose = d.composeEnablement ?? composeEnablement;
|
|
@@ -386,47 +535,63 @@ export async function listInstalledWithMeta(cfg = {}, opts = {}, deps = {}) {
|
|
|
386
535
|
catch {
|
|
387
536
|
loaded = 'deadline';
|
|
388
537
|
}
|
|
538
|
+
const community = await communityOutcome(communityTask, deadlineAt, loaded === 'deadline' ? [] : loaded.registry.plugins);
|
|
389
539
|
if (loaded === 'deadline' || loaded.status === 'unavailable') {
|
|
390
|
-
// registry 不可用:不做 matching
|
|
540
|
+
// registry 不可用:不做 matching/探测(原行为),但社区 summary 照常携带(CLI outdated 双源判定)
|
|
391
541
|
const registryState = loaded === 'deadline' ? timeoutRegistryState(cfg) : stateOf(loaded);
|
|
392
|
-
|
|
542
|
+
void community.merged;
|
|
543
|
+
return { items, others: installed.others, profileDir: installed.profileDir, registryState, community: community.summary };
|
|
393
544
|
}
|
|
394
545
|
const registryState = stateOf(loaded);
|
|
395
|
-
|
|
546
|
+
matchInstalled(items, community.merged);
|
|
547
|
+
await probeLatest(items, { merged: community.merged, registryAddress: loaded.configuredAddress, ttlMin: Math.max(0, cfg.cacheTtlMin ?? 60) }, d, { namespace, signal, githubBudget, remaining, timeoutMs: cfg.timeoutMs ?? 20_000 });
|
|
548
|
+
return { items, others: installed.others, profileDir: installed.profileDir, registryState, community: community.summary };
|
|
549
|
+
}
|
|
550
|
+
/** matching(主+社区合并条目):命中主条目或社区条目都写 registryId;社区命中再标 community。 */
|
|
551
|
+
function matchInstalled(items, merged) {
|
|
552
|
+
for (const item of items) {
|
|
553
|
+
const entry = merged.find((e) => matchInstalledByEntry(e, [item]));
|
|
554
|
+
if (!entry)
|
|
555
|
+
continue;
|
|
556
|
+
item.registryId = entry.id;
|
|
557
|
+
item.registryGithub = entry.github ?? null;
|
|
558
|
+
item.registryIcon = entry.icon ?? null;
|
|
559
|
+
if (isCommunityEntry(entry))
|
|
560
|
+
item.community = true;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
/** 已装页 latest 探测(Q46 已装页豁免,但 GitHub 计入 request 预算 + 宿主滚动窗口)。 */
|
|
564
|
+
async function probeLatest(items, ctx, d, rt) {
|
|
396
565
|
await mapWithConcurrency(items, LATEST_WORKERS, async (item) => {
|
|
397
|
-
const entry =
|
|
398
|
-
if (entry) {
|
|
399
|
-
item.registryId = entry.id;
|
|
400
|
-
item.registryGithub = entry.github ?? null;
|
|
401
|
-
item.registryIcon = entry.icon ?? null;
|
|
402
|
-
}
|
|
566
|
+
const entry = ctx.merged.find((e) => matchInstalledByEntry(e, [item]));
|
|
403
567
|
// latest 探测沿用 listMarket 的 TTL cache
|
|
404
568
|
const cacheKey = entry
|
|
405
|
-
? latestCacheKey(namespace,
|
|
569
|
+
? latestCacheKey(rt.namespace, ctx.registryAddress ?? '', entry)
|
|
406
570
|
: item.source === 'npm'
|
|
407
|
-
? `npm-only|${namespace}|npm:${item.pkg}`
|
|
571
|
+
? `npm-only|${rt.namespace}|npm:${item.pkg}`
|
|
408
572
|
: null;
|
|
409
573
|
if (cacheKey) {
|
|
410
|
-
const cached = readLatestCache(cacheKey, ttlMin);
|
|
574
|
+
const cached = readLatestCache(cacheKey, ctx.ttlMin);
|
|
411
575
|
if (cached) {
|
|
412
576
|
applyProbe(item, cached);
|
|
413
577
|
}
|
|
414
|
-
else if (remaining() > 0) {
|
|
415
|
-
|
|
578
|
+
else if (rt.remaining() > 0) {
|
|
579
|
+
// 绝对剩余预算贯穿整个 probe(多跳 githubLatestTag 不逐子请求重置——v9 ②)
|
|
580
|
+
const budget = rt.remaining();
|
|
416
581
|
try {
|
|
417
582
|
let value = null;
|
|
418
583
|
if (!entry && item.source === 'npm') {
|
|
419
|
-
const latest = await d.npmLatest(item.pkg, Math.min(
|
|
584
|
+
const latest = await d.npmLatest(item.pkg, Math.min(rt.timeoutMs, budget), rt.signal);
|
|
420
585
|
value = { version: latest.version };
|
|
421
586
|
}
|
|
422
587
|
else if (entry?.source === 'npm' && entry.npm) {
|
|
423
|
-
const latest = await d.npmLatest(entry.npm, Math.min(
|
|
588
|
+
const latest = await d.npmLatest(entry.npm, Math.min(rt.timeoutMs, budget), rt.signal);
|
|
424
589
|
value = { version: latest.version };
|
|
425
590
|
}
|
|
426
591
|
else if (item.source === 'github') {
|
|
427
592
|
const m = /^github:([^#]+)/.exec(item.spec);
|
|
428
593
|
if (m) {
|
|
429
|
-
const latest = await d.githubLatestTag(m[1], Math.min(
|
|
594
|
+
const latest = await d.githubLatestTag(m[1], Math.min(rt.timeoutMs, budget), rt.signal, rt.githubBudget);
|
|
430
595
|
value = { tag: latest.tag, sha: latest.sha };
|
|
431
596
|
}
|
|
432
597
|
}
|
|
@@ -437,15 +602,20 @@ export async function listInstalledWithMeta(cfg = {}, opts = {}, deps = {}) {
|
|
|
437
602
|
}
|
|
438
603
|
catch (err) {
|
|
439
604
|
item.latestError = err instanceof Error ? err.message : String(err);
|
|
605
|
+
item.latestErrorCode = classifyLatestError(err);
|
|
440
606
|
}
|
|
441
607
|
}
|
|
608
|
+
else {
|
|
609
|
+
// deadline 已到且未启动:标未完成并停止派发(v9 ②「未启动条目也标未完成」)
|
|
610
|
+
item.latestError = '更新检查未完成:时间预算已用尽';
|
|
611
|
+
item.latestErrorCode = 'timeout';
|
|
612
|
+
}
|
|
442
613
|
}
|
|
443
614
|
if (item.latestVersion !== undefined && item.version)
|
|
444
615
|
item.outdated = isNewerVersion(item.latestVersion, item.version);
|
|
445
616
|
else if (item.latestSha !== undefined)
|
|
446
617
|
item.outdated = !item.spec.includes(item.latestSha);
|
|
447
618
|
});
|
|
448
|
-
return { items, others: installed.others, profileDir: installed.profileDir, registryState };
|
|
449
619
|
}
|
|
450
620
|
/** 从 registry 收录条目安装(npm → 精确锁定最新版;github → 锁 HEAD SHA)。 */
|
|
451
621
|
export async function installFromRegistry(id, cfg = {}, opts = {}, deps) {
|
|
@@ -458,14 +628,322 @@ export async function installFromRegistry(id, cfg = {}, opts = {}, deps) {
|
|
|
458
628
|
throw new Error(`registry 中没有该条目: ${id}`);
|
|
459
629
|
return installEntry(entry, cfg, opts, deps);
|
|
460
630
|
}
|
|
631
|
+
// ---------- M2 Task 3:统一 mutation session + 装后守卫 + 补偿接线 ----------
|
|
632
|
+
let sessionTail = Promise.resolve();
|
|
633
|
+
/**
|
|
634
|
+
* 统一 mutation 串行区间(模块级 FIFO,非重入):包住全部五个 profile mutation 入口
|
|
635
|
+
* (installFromRegistry / upgradePlugin / uninstallPlugin / selfUpgrade / toggle)。
|
|
636
|
+
* session 单点获取——只在 core façade 获取(toggle 在 togglePlugin 本体),host-api/tools/CLI
|
|
637
|
+
* 层不得重复获取(session 非重入,多层重复获取会死锁)。锁序:mutation session 外层 →
|
|
638
|
+
* toggle file lock / 事务 FIFO 内层。
|
|
639
|
+
*/
|
|
640
|
+
export async function withMutationSession(fn) {
|
|
641
|
+
const run = sessionTail.then(() => fn(), () => fn());
|
|
642
|
+
sessionTail = run.then(() => undefined, () => undefined);
|
|
643
|
+
return run;
|
|
644
|
+
}
|
|
645
|
+
/** 守卫拦截的结构化错误(非成功状态):三端按 kind/needsRestart/restartSafe 渲染,无 force 通道。 */
|
|
646
|
+
export class InstallGuardError extends Error {
|
|
647
|
+
kind;
|
|
648
|
+
violations;
|
|
649
|
+
needsRestart;
|
|
650
|
+
restartSafe;
|
|
651
|
+
/** 补偿终态摘要(rolled-back/manual-repair/rejected 的 note) */
|
|
652
|
+
compensation;
|
|
653
|
+
/** manual-repair 的修复依据(旧 manifestSpec/installSpec/mapping) */
|
|
654
|
+
repairBasis;
|
|
655
|
+
constructor(fields) {
|
|
656
|
+
super(fields.message);
|
|
657
|
+
this.name = 'InstallGuardError';
|
|
658
|
+
this.kind = fields.kind;
|
|
659
|
+
this.violations = fields.violations;
|
|
660
|
+
this.needsRestart = fields.needsRestart;
|
|
661
|
+
this.restartSafe = fields.restartSafe;
|
|
662
|
+
this.compensation = fields.compensation;
|
|
663
|
+
this.repairBasis = fields.repairBasis;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
/** lockfile packages 段的目标包 resolution 提取(行扫描:键行 `pkg@ver:` → 收集至下一键行;引号/嵌套键兼容)。 */
|
|
667
|
+
function lockResolutionOf(lockText, pkg) {
|
|
668
|
+
// 只扫顶层 packages: 段(importer 段的同名键不参与;packages: 后条目缩进两空格)
|
|
669
|
+
const pkgsAt = lockText.split('\n').findIndex((l) => l.trim() === 'packages:');
|
|
670
|
+
if (pkgsAt < 0)
|
|
671
|
+
return null;
|
|
672
|
+
const lines = lockText.split('\n').slice(pkgsAt + 1);
|
|
673
|
+
let body = null;
|
|
674
|
+
for (const line of lines) {
|
|
675
|
+
if (body !== null && /^\S/.test(line))
|
|
676
|
+
break; // 顶层新段 → 收集结束
|
|
677
|
+
// 0.5.1 修复:收尾引号必须可选消耗——pnpm lockfile 对 scoped 包键恒带引号
|
|
678
|
+
//(`'@scope/name@1.2.3':`),旧正则匹配不到任何带引号键,导致 scoped 已装插件
|
|
679
|
+
// 的 lockResolution 恒为 null → derivePrior 误判 restorable:false → 升级被守卫硬拒。
|
|
680
|
+
const keyMatch = /^ '?(?:[^'\n]*node_modules\/)?([^\s':]+)'?:\s*$/.exec(line);
|
|
681
|
+
if (keyMatch) {
|
|
682
|
+
if (body)
|
|
683
|
+
break;
|
|
684
|
+
const key = keyMatch[1] ?? '';
|
|
685
|
+
if (key === pkg || key.startsWith(`${pkg}@`))
|
|
686
|
+
body = [];
|
|
687
|
+
continue;
|
|
688
|
+
}
|
|
689
|
+
if (body)
|
|
690
|
+
body.push(line);
|
|
691
|
+
}
|
|
692
|
+
if (body === null)
|
|
693
|
+
return null;
|
|
694
|
+
const text = body.join('\n');
|
|
695
|
+
const version = /version:\s*'?([^'\n]+)/.exec(text)?.[1]?.trim();
|
|
696
|
+
// 0.5.1:integrity 捕获排除 flow 结尾(`,`/`}`/引号)——与 npm-integrity.ts parsePackagesSection
|
|
697
|
+
// 同口径;旧 `(\S+)` 会把 `{integrity: sha512-...}` 的收尾 `}` 一起带进 prior 证据。
|
|
698
|
+
const integrity = /integrity:\s*([^,}\s'"]+)/.exec(text)?.[1]?.trim();
|
|
699
|
+
const commit = /commit:\s*'?([0-9a-f]{40})/.exec(text)?.[1]?.trim();
|
|
700
|
+
if (!version && !integrity && !commit)
|
|
701
|
+
return null;
|
|
702
|
+
return { ...(version ? { version } : {}), ...(integrity ? { integrity } : {}), ...(commit ? { commit } : {}) };
|
|
703
|
+
}
|
|
704
|
+
/** 变更前全量快照(v6):deps / lock resolutions / 双落点 mapping。读取失败 → unavailable(fail-closed 拒绝安装)。 */
|
|
705
|
+
export async function capturePreMutationState(profileDir) {
|
|
706
|
+
try {
|
|
707
|
+
// manifest ENOENT = 无已装依赖的空 profile(与 guard readDeps 同语义);其他读取/解析失败 → fail-closed
|
|
708
|
+
let manifestText = '';
|
|
709
|
+
try {
|
|
710
|
+
manifestText = await readFile(join(profileDir, 'package.json'), 'utf8');
|
|
711
|
+
}
|
|
712
|
+
catch (err) {
|
|
713
|
+
if (err.code !== 'ENOENT')
|
|
714
|
+
throw err;
|
|
715
|
+
}
|
|
716
|
+
const doc = manifestText
|
|
717
|
+
? JSON.parse(manifestText)
|
|
718
|
+
: {};
|
|
719
|
+
const deps = {};
|
|
720
|
+
if (doc.dependencies && typeof doc.dependencies === 'object' && !Array.isArray(doc.dependencies)) {
|
|
721
|
+
for (const [name, spec] of Object.entries(doc.dependencies)) {
|
|
722
|
+
if (typeof spec === 'string')
|
|
723
|
+
deps[name] = spec;
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
const manifestMapping = [];
|
|
727
|
+
const patched = doc.pnpm?.patchedDependencies;
|
|
728
|
+
if (patched && typeof patched === 'object' && !Array.isArray(patched)) {
|
|
729
|
+
for (const [key, patchPath] of Object.entries(patched)) {
|
|
730
|
+
if (typeof patchPath === 'string')
|
|
731
|
+
manifestMapping.push({ key, patchPath });
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
let lockText = '';
|
|
735
|
+
try {
|
|
736
|
+
lockText = await readFile(join(profileDir, 'pnpm-lock.yaml'), 'utf8');
|
|
737
|
+
}
|
|
738
|
+
catch {
|
|
739
|
+
lockText = '';
|
|
740
|
+
}
|
|
741
|
+
const lockResolutions = {};
|
|
742
|
+
for (const name of Object.keys(deps)) {
|
|
743
|
+
const res = lockResolutionOf(lockText, name);
|
|
744
|
+
if (res)
|
|
745
|
+
lockResolutions[name] = res;
|
|
746
|
+
}
|
|
747
|
+
const workspaceMapping = [];
|
|
748
|
+
try {
|
|
749
|
+
const ws = await readFile(join(profileDir, 'pnpm-workspace.yaml'), 'utf8');
|
|
750
|
+
const inBlock = /^patchedDependencies:/m;
|
|
751
|
+
if (inBlock.test(ws)) {
|
|
752
|
+
const section = ws.split(/^patchedDependencies:/m)[1]?.split(/^\S/m)?.[0] ?? '';
|
|
753
|
+
for (const line of section.split('\n')) {
|
|
754
|
+
const m = /^\s+'?([^':]+)'?:\s*(\S+)/.exec(line);
|
|
755
|
+
if (m)
|
|
756
|
+
workspaceMapping.push({ key: m[1].trim(), patchPath: m[2].trim() });
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
catch {
|
|
761
|
+
/* 无 workspace 文件 = 无 workspace mapping */
|
|
762
|
+
}
|
|
763
|
+
return { kind: 'snapshot', snapshot: { deps, lockResolutions, mapping: { workspace: workspaceMapping, manifest: manifestMapping } } };
|
|
764
|
+
}
|
|
765
|
+
catch (err) {
|
|
766
|
+
return { kind: 'unavailable', reason: err instanceof Error ? err.message : String(err) };
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
/** mapping 条目按真实 key 精确关联(key === realKey 或版本化 key `realKey@x`);不按 repo/id/相似名猜。 */
|
|
770
|
+
function mappingFor(mapping, realKey) {
|
|
771
|
+
const pick = (rows) => rows.filter((r) => r.key === realKey || r.key.startsWith(`${realKey}@`));
|
|
772
|
+
return { workspace: pick(mapping.workspace), manifest: pick(mapping.manifest) };
|
|
773
|
+
}
|
|
774
|
+
/**
|
|
775
|
+
* prior 派生(add 返回真实 key 后、守卫前):严格按真实 key 关联快照;判定表唯一映射。
|
|
776
|
+
* 来源白名单(独立实现,不复用 parseSpecSource):精确 npm version(含 ^/~ 书写)与
|
|
777
|
+
* pinned github commit + lock identity 可自动恢复;alias/workspace/tarball URL/git+ssh/link/file 一律 restorable:false。
|
|
778
|
+
*/
|
|
779
|
+
export function derivePrior(snapshot, realKey) {
|
|
780
|
+
const spec = snapshot.deps[realKey];
|
|
781
|
+
const mapping = mappingFor(snapshot.mapping, realKey);
|
|
782
|
+
if (spec === undefined) {
|
|
783
|
+
if (mapping.workspace.length > 0 || mapping.manifest.length > 0) {
|
|
784
|
+
return { kind: 'mappingOnly', patchMapping: mapping };
|
|
785
|
+
}
|
|
786
|
+
return { kind: 'none' };
|
|
787
|
+
}
|
|
788
|
+
const lock = snapshot.lockResolutions[realKey] ?? null;
|
|
789
|
+
let sourceKind = 'other';
|
|
790
|
+
let restorable = false;
|
|
791
|
+
if (isExactVersion(spec)) {
|
|
792
|
+
sourceKind = 'npm';
|
|
793
|
+
restorable = Boolean(lock?.integrity);
|
|
794
|
+
}
|
|
795
|
+
else if (/^[~^]\d/.test(spec) && isExactVersion(spec.slice(1))) {
|
|
796
|
+
// ^/~ 锚定(0.4.x 旧 CLI 书写):恢复后接受规范化并明示
|
|
797
|
+
sourceKind = 'npm';
|
|
798
|
+
restorable = Boolean(lock?.integrity);
|
|
799
|
+
}
|
|
800
|
+
else if (/^github:[A-Za-z0-9][A-Za-z0-9-]*\/[A-Za-z0-9._-]+#[0-9a-f]{40}$/.test(spec)) {
|
|
801
|
+
sourceKind = 'github';
|
|
802
|
+
restorable = Boolean(lock?.commit);
|
|
803
|
+
}
|
|
804
|
+
return {
|
|
805
|
+
kind: 'dependency',
|
|
806
|
+
state: {
|
|
807
|
+
manifestSpec: spec,
|
|
808
|
+
installSpec: spec,
|
|
809
|
+
resolvedVersion: lock?.version,
|
|
810
|
+
sourceKind,
|
|
811
|
+
...(lock?.integrity ? { integrity: lock.integrity } : {}),
|
|
812
|
+
...(lock ? { lockResolution: lock } : {}),
|
|
813
|
+
patchMapping: mapping,
|
|
814
|
+
restorable,
|
|
815
|
+
},
|
|
816
|
+
};
|
|
817
|
+
}
|
|
818
|
+
/** GitHub candidate preflight:只读抓取 pinned SHA 的 package.json 解析 name(预算外 read-only 通道,不写 profile)。 */
|
|
819
|
+
async function candidateKeyOf(repo, sha, timeoutMs) {
|
|
820
|
+
const text = await fetchTextLimited(`https://raw.githubusercontent.com/${repo}/${sha}/package.json`, { timeoutMs, maxBytes: 512 * 1024 });
|
|
821
|
+
const doc = JSON.parse(text);
|
|
822
|
+
return typeof doc.name === 'string' && doc.name.trim() !== '' ? doc.name.trim() : null;
|
|
823
|
+
}
|
|
824
|
+
function errText(err) {
|
|
825
|
+
return err instanceof Error ? err.message : String(err);
|
|
826
|
+
}
|
|
827
|
+
/** 补偿执行 + InstallGuardError 抛出(四态 → needsRestart/restartSafe 映射;rejected 复读 profile 实况)。 */
|
|
828
|
+
async function throwCompensated(args) {
|
|
829
|
+
const { pkg, guard, deps, evidence, prior } = args;
|
|
830
|
+
const violations = guard.violations;
|
|
831
|
+
const violationText = violations.map((v) => `${v.code}: ${v.detail}`).join(';');
|
|
832
|
+
let comp;
|
|
833
|
+
const priorForTx = prior.kind === 'mappingOnly'
|
|
834
|
+
? { kind: 'mappingOnly', patchMapping: prior.patchMapping }
|
|
835
|
+
: prior.kind === 'dependency'
|
|
836
|
+
? { kind: 'dependency', state: prior.state }
|
|
837
|
+
: { kind: 'none' };
|
|
838
|
+
try {
|
|
839
|
+
comp = await runProfileTransaction({ kind: 'compensate-install', pkg, evidence, prior: priorForTx }, deps);
|
|
840
|
+
}
|
|
841
|
+
catch (err) {
|
|
842
|
+
throw new InstallGuardError({
|
|
843
|
+
kind: 'manual_required',
|
|
844
|
+
message: `安装违例(${violationText}),补偿事务执行异常:${err instanceof Error ? err.message : String(err)}——需人工处理`,
|
|
845
|
+
violations,
|
|
846
|
+
needsRestart: true,
|
|
847
|
+
restartSafe: false,
|
|
848
|
+
compensation: { status: 'manual-repair', note: errText(err) },
|
|
849
|
+
});
|
|
850
|
+
}
|
|
851
|
+
if (comp.ok) {
|
|
852
|
+
// committed(fresh/restore) + 补偿验证完整 → 可一键重启
|
|
853
|
+
throw new InstallGuardError({
|
|
854
|
+
kind: 'compensated',
|
|
855
|
+
message: `安装违例(${violationText}),已${prior.kind === 'dependency' ? '恢复原版本' : '自动卸载'}(补偿事务 committed)`,
|
|
856
|
+
violations,
|
|
857
|
+
needsRestart: true,
|
|
858
|
+
restartSafe: true,
|
|
859
|
+
compensation: { status: 'committed', note: comp.output },
|
|
860
|
+
...(prior.kind === 'dependency' ? { repairBasis: `prior: ${prior.state.manifestSpec}(${prior.state.resolvedVersion ?? 'version 未知'})` } : {}),
|
|
861
|
+
});
|
|
862
|
+
}
|
|
863
|
+
const status = comp.status;
|
|
864
|
+
const note = comp.failure?.note ?? '';
|
|
865
|
+
if (status === 'rejected') {
|
|
866
|
+
// 复读 profile 实况决定 restartSafe(不因零写入直接 false)
|
|
867
|
+
let pkgStillThere = false;
|
|
868
|
+
try {
|
|
869
|
+
const now = await capturePreMutationState(deps?.profileDir ?? webProfileDir());
|
|
870
|
+
pkgStillThere = now.kind === 'snapshot' && pkg in now.snapshot.deps;
|
|
871
|
+
}
|
|
872
|
+
catch {
|
|
873
|
+
pkgStillThere = true;
|
|
874
|
+
}
|
|
875
|
+
throw new InstallGuardError({
|
|
876
|
+
kind: 'compensated',
|
|
877
|
+
message: `安装违例(${violationText}),补偿未执行(profile 状态已变化:${note})`,
|
|
878
|
+
violations,
|
|
879
|
+
needsRestart: pkgStillThere,
|
|
880
|
+
restartSafe: !pkgStillThere,
|
|
881
|
+
compensation: { status: 'rejected', note },
|
|
882
|
+
});
|
|
883
|
+
}
|
|
884
|
+
if (status === 'rolled-back') {
|
|
885
|
+
throw new InstallGuardError({
|
|
886
|
+
kind: 'compensated',
|
|
887
|
+
message: `安装违例(${violationText}),补偿未完成:已回到补偿前状态,坏包可能仍在,需人工处理(${note})`,
|
|
888
|
+
violations,
|
|
889
|
+
needsRestart: true,
|
|
890
|
+
restartSafe: false,
|
|
891
|
+
compensation: { status: 'rolled-back', note },
|
|
892
|
+
});
|
|
893
|
+
}
|
|
894
|
+
// manual-repair:状态未知;post-add 兜底场景附修复依据,任何端不显示「已恢复原版本/已安装成功」
|
|
895
|
+
throw new InstallGuardError({
|
|
896
|
+
kind: 'manual_required',
|
|
897
|
+
message: `安装违例(${violationText}),补偿进入 manual-repair:需人工处理(${note})`,
|
|
898
|
+
violations,
|
|
899
|
+
needsRestart: true,
|
|
900
|
+
restartSafe: false,
|
|
901
|
+
compensation: { status: 'manual-repair', note },
|
|
902
|
+
...(prior.kind === 'dependency'
|
|
903
|
+
? { repairBasis: `旧 manifestSpec=${prior.state.manifestSpec};installSpec=${prior.state.installSpec};mapping=${JSON.stringify(prior.state.patchMapping)}` }
|
|
904
|
+
: prior.kind === 'mappingOnly'
|
|
905
|
+
? { repairBasis: `旧 mapping=${JSON.stringify(prior.patchMapping)}` }
|
|
906
|
+
: {}),
|
|
907
|
+
});
|
|
908
|
+
}
|
|
909
|
+
function guardManualRequired(message, violations) {
|
|
910
|
+
// 零副作用:未进 mutation → needsRestart=false、restartSafe=false
|
|
911
|
+
return new InstallGuardError({
|
|
912
|
+
kind: 'manual_required',
|
|
913
|
+
message,
|
|
914
|
+
violations,
|
|
915
|
+
needsRestart: false,
|
|
916
|
+
restartSafe: false,
|
|
917
|
+
compensation: { status: 'pre-mutation', note: '未开始变更,profile 零写入' },
|
|
918
|
+
});
|
|
919
|
+
}
|
|
461
920
|
export async function installEntry(entry, cfg = {}, opts = {}, deps) {
|
|
921
|
+
return withMutationSession(() => installEntryLocked(entry, cfg, opts, deps));
|
|
922
|
+
}
|
|
923
|
+
/** installEntry 主体(session 区间内)。入口约定:installFromRegistry 走 installEntry(获取 session);
|
|
924
|
+
* upgradePlugin 已持有 session,必须直调本函数——二次获取 session 会自死锁(session 非重入)。 */
|
|
925
|
+
async function installEntryLocked(entry, cfg = {}, opts = {}, deps) {
|
|
462
926
|
const timeoutMs = cfg.timeoutMs ?? 20_000;
|
|
927
|
+
const profileDir = deps?.transaction?.profileDir ?? webProfileDir();
|
|
463
928
|
const d = {
|
|
464
929
|
npmLatest: deps?.npmLatest ?? defaultNpmLatest,
|
|
465
930
|
npmVersion: deps?.npmVersion ?? npmVersion,
|
|
466
931
|
};
|
|
467
932
|
if (entry.source === 'npm' && entry.npm) {
|
|
468
933
|
const pkg = entry.npm;
|
|
934
|
+
// 变更前全量快照(unavailable → fail-closed,零写入)
|
|
935
|
+
const pre = await capturePreMutationState(profileDir);
|
|
936
|
+
if (pre.kind === 'unavailable') {
|
|
937
|
+
throw new Error(`变更前状态捕获失败:${pre.reason};拒绝安装(fail-closed)`);
|
|
938
|
+
}
|
|
939
|
+
// npm key 事务前已知:prior 派生与 restorable 检查在事务前完成
|
|
940
|
+
const prior = derivePrior(pre.snapshot, pkg);
|
|
941
|
+
if (prior.kind === 'unavailable') {
|
|
942
|
+
throw new Error(`prior 关联歧义:${prior.reason};拒绝安装(fail-closed)`);
|
|
943
|
+
}
|
|
944
|
+
if (prior.kind === 'dependency' && !prior.state.restorable) {
|
|
945
|
+
throw guardManualRequired(`「${pkg}」的原安装形态(${prior.state.sourceKind === 'other' ? 'link/file 等非 registry 来源' : 'pnpm-lock.yaml 未解析出该依赖的 integrity(键缺失或为别名/URL 等非 registry 写法)'})无法自动回退,请先手动处理(卸载或修复后重试)`, [{ pkg, code: 'NO_DSH_MARKER', detail: `prior restorable:false(sourceKind=${prior.state.sourceKind})` }]);
|
|
946
|
+
}
|
|
469
947
|
// npm:无论 latest 还是用户指定 exact,都先读取该精确版本的 dist metadata(事务外解析)
|
|
470
948
|
let version;
|
|
471
949
|
let expectedIntegrity;
|
|
@@ -502,6 +980,43 @@ export async function installEntry(entry, cfg = {}, opts = {}, deps) {
|
|
|
502
980
|
});
|
|
503
981
|
if (!result.ok)
|
|
504
982
|
throw new TransactionError(result);
|
|
983
|
+
// 装后守卫(session 区间内;fail-open 仅限结论不可定)
|
|
984
|
+
const guard = await verifyInstalledAdditions({
|
|
985
|
+
profileDir,
|
|
986
|
+
addedPkgs: [result.pkg ?? pkg],
|
|
987
|
+
...(prior.kind === 'dependency' ? { priorPkgs: [pkg] } : {}),
|
|
988
|
+
});
|
|
989
|
+
if (guard.unavailable.length > 0) {
|
|
990
|
+
return {
|
|
991
|
+
id: entry.id,
|
|
992
|
+
pkg: result.pkg ?? pkg,
|
|
993
|
+
spec: result.spec ?? `${pkg}@${version}`,
|
|
994
|
+
version: result.version ?? version,
|
|
995
|
+
buildApprovals: result.buildApprovals ?? [],
|
|
996
|
+
fallbackAllBuilds: result.fallbackAllBuilds === true,
|
|
997
|
+
...(result.bundleWarning ? { bundleWarning: result.bundleWarning } : {}),
|
|
998
|
+
compat: compat ?? null,
|
|
999
|
+
needsRestart: true,
|
|
1000
|
+
output: result.output,
|
|
1001
|
+
healActions: result.healActions,
|
|
1002
|
+
guardWarning: `装后守卫不可用(${guard.unavailable.map((u) => u.reason).slice(0, 2).join(';')}),未执行三项检查——如异常请人工核查`,
|
|
1003
|
+
};
|
|
1004
|
+
}
|
|
1005
|
+
if (!guard.ok) {
|
|
1006
|
+
await throwCompensated({
|
|
1007
|
+
pkg: result.pkg ?? pkg,
|
|
1008
|
+
guard,
|
|
1009
|
+
deps: deps?.transaction,
|
|
1010
|
+
evidence: {
|
|
1011
|
+
source: 'npm',
|
|
1012
|
+
// manifestSpec = 安装后 manifest deps 值(pnpm add 写入裸 version);validate 对当前 manifest 实读比对
|
|
1013
|
+
manifestSpec: result.version ?? version,
|
|
1014
|
+
resolvedVersion: result.version ?? version,
|
|
1015
|
+
integrity: expectedIntegrity ?? '',
|
|
1016
|
+
},
|
|
1017
|
+
prior,
|
|
1018
|
+
});
|
|
1019
|
+
}
|
|
505
1020
|
const notes = result.healActions.map((h) => h.note).filter((n) => n !== '');
|
|
506
1021
|
return {
|
|
507
1022
|
id: entry.id,
|
|
@@ -518,15 +1033,98 @@ export async function installEntry(entry, cfg = {}, opts = {}, deps) {
|
|
|
518
1033
|
};
|
|
519
1034
|
}
|
|
520
1035
|
if (entry.github) {
|
|
521
|
-
//
|
|
1036
|
+
// 变更前全量快照(fail-closed)
|
|
1037
|
+
const pre = await capturePreMutationState(profileDir);
|
|
1038
|
+
if (pre.kind === 'unavailable') {
|
|
1039
|
+
throw new Error(`变更前状态捕获失败:${pre.reason};拒绝安装(fail-closed)`);
|
|
1040
|
+
}
|
|
1041
|
+
// candidate preflight(M2 Task 0 gate 已证明 name→key 映射):只读抓取 pinned package.json;
|
|
1042
|
+
// 抓取失败/解析歧义 → fail-closed 拒绝安装(零写入);candidate 命中 link/file 或 restorable:false
|
|
1043
|
+
// prior → mutation 前 GUARD_MANUAL_REQUIRED(runner.add 零调用)
|
|
522
1044
|
const { tag, sha } = await (deps?.githubLatestTag ?? defaultGithubLatestTag)(entry.github, timeoutMs, opts.signal);
|
|
1045
|
+
let candidate;
|
|
1046
|
+
try {
|
|
1047
|
+
candidate = await (deps?.candidateKey ?? candidateKeyOf)(entry.github, sha, timeoutMs);
|
|
1048
|
+
}
|
|
1049
|
+
catch (err) {
|
|
1050
|
+
throw new Error(`GitHub candidate key 预解析失败(${entry.github}@${sha.slice(0, 7)}):${err instanceof Error ? err.message : String(err)};拒绝安装(fail-closed)`);
|
|
1051
|
+
}
|
|
1052
|
+
if (candidate === null || !/^(?:@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*$/.test(candidate)) {
|
|
1053
|
+
throw new Error(`GitHub candidate key 不可用(${candidate ?? 'name 缺失'}):拒绝安装(fail-closed)`);
|
|
1054
|
+
}
|
|
1055
|
+
const candidatePrior = derivePrior(pre.snapshot, candidate);
|
|
1056
|
+
if (candidatePrior.kind === 'unavailable') {
|
|
1057
|
+
throw new Error(`prior 关联歧义:${candidatePrior.reason};拒绝安装(fail-closed)`);
|
|
1058
|
+
}
|
|
1059
|
+
if (candidatePrior.kind === 'dependency' && !candidatePrior.state.restorable) {
|
|
1060
|
+
throw guardManualRequired(`「${candidate}」的原安装形态(pnpm-lock.yaml 未解析出该依赖的可回退依据——integrity/commit 缺失,或 link/file/别名等非 registry 写法)无法自动回退,请先手动处理(卸载或修复后重试)`, [{ pkg: candidate, code: 'NO_DSH_MARKER', detail: 'candidate 命中 restorable:false prior(lockfile 依据缺失)' }]);
|
|
1061
|
+
}
|
|
523
1062
|
const result = await runProfileTransaction({ kind: 'install-github', repo: entry.github, sha, tag, signal: opts.signal }, deps?.transaction ?? {});
|
|
524
1063
|
if (!result.ok)
|
|
525
1064
|
throw new TransactionError(result);
|
|
1065
|
+
const realKey = result.pkg ?? candidate;
|
|
1066
|
+
// post-add derivePrior 复核(兜底网):真实 key 与 candidate 不符且发现不可自动恢复 prior
|
|
1067
|
+
// → manual-repair 非成功状态(不冒充 pre-mutation 拒绝、不承诺 prior 无损)
|
|
1068
|
+
let prior = candidatePrior;
|
|
1069
|
+
if (realKey !== candidate) {
|
|
1070
|
+
const real = derivePrior(pre.snapshot, realKey);
|
|
1071
|
+
if (real.kind === 'unavailable') {
|
|
1072
|
+
throw new Error(`prior 关联歧义(真实 key ${realKey}):${real.reason};拒绝继续(fail-closed)`);
|
|
1073
|
+
}
|
|
1074
|
+
if (real.kind === 'dependency' && !real.state.restorable) {
|
|
1075
|
+
throw new InstallGuardError({
|
|
1076
|
+
kind: 'manual_required',
|
|
1077
|
+
message: `安装违例后复核:真实 dependency key(${realKey})与预解析 candidate(${candidate})不符,且旧 prior 为 link/file 等不可自动恢复来源——旧 prior 可能已被覆盖,需人工恢复`,
|
|
1078
|
+
violations: [{ pkg: realKey, code: 'NO_DSH_MARKER', detail: 'candidate 与真实 key 不符且 prior restorable:false' }],
|
|
1079
|
+
needsRestart: true,
|
|
1080
|
+
restartSafe: false,
|
|
1081
|
+
compensation: { status: 'manual-repair', note: '真实 key ≠ candidate 且旧 prior 不可自动恢复' },
|
|
1082
|
+
repairBasis: `旧 manifestSpec=${real.state.manifestSpec};installSpec=${real.state.installSpec};mapping=${JSON.stringify(real.state.patchMapping)}`,
|
|
1083
|
+
});
|
|
1084
|
+
}
|
|
1085
|
+
if (real.kind !== 'none')
|
|
1086
|
+
prior = real;
|
|
1087
|
+
}
|
|
1088
|
+
// 装后守卫
|
|
1089
|
+
const guard = await verifyInstalledAdditions({
|
|
1090
|
+
profileDir,
|
|
1091
|
+
addedPkgs: [realKey],
|
|
1092
|
+
...(prior.kind === 'dependency' ? { priorPkgs: [realKey] } : {}),
|
|
1093
|
+
});
|
|
1094
|
+
if (guard.unavailable.length > 0) {
|
|
1095
|
+
const notes = result.healActions.map((h) => h.note).filter((n) => n !== '');
|
|
1096
|
+
return {
|
|
1097
|
+
id: entry.id,
|
|
1098
|
+
pkg: realKey,
|
|
1099
|
+
spec: result.spec ?? `github:${entry.github}#${sha}`,
|
|
1100
|
+
sha: result.sha ?? sha,
|
|
1101
|
+
tag: result.tag ?? tag,
|
|
1102
|
+
buildApprovals: result.buildApprovals ?? [],
|
|
1103
|
+
fallbackAllBuilds: result.fallbackAllBuilds === true,
|
|
1104
|
+
...(result.bundleWarning ? { bundleWarning: result.bundleWarning } : {}),
|
|
1105
|
+
compatSkipped: 'github-source',
|
|
1106
|
+
needsRestart: true,
|
|
1107
|
+
output: result.output + (notes.length > 0 ? `\n[dsh-m 自愈] ${notes.join(';')}` : ''),
|
|
1108
|
+
healActions: result.healActions,
|
|
1109
|
+
guardWarning: `装后守卫不可用(${guard.unavailable.map((u) => u.reason).slice(0, 2).join(';')}),未执行三项检查——如异常请人工核查`,
|
|
1110
|
+
};
|
|
1111
|
+
}
|
|
1112
|
+
if (!guard.ok) {
|
|
1113
|
+
// lock commit identity 从实际 pnpm-lock.yaml 解析(InstallResult.sha 是请求时期望值,非独立观测)
|
|
1114
|
+
const lockText = await readFile(join(profileDir, 'pnpm-lock.yaml'), 'utf8');
|
|
1115
|
+
const lockCommit = lockResolutionOf(lockText, realKey)?.commit ?? sha;
|
|
1116
|
+
await throwCompensated({
|
|
1117
|
+
pkg: realKey,
|
|
1118
|
+
guard,
|
|
1119
|
+
deps: deps?.transaction,
|
|
1120
|
+
evidence: { source: 'github', pinnedSpec: `github:${entry.github}#${sha}`, sha, lockCommitIdentity: lockCommit },
|
|
1121
|
+
prior,
|
|
1122
|
+
});
|
|
1123
|
+
}
|
|
526
1124
|
const notes = result.healActions.map((h) => h.note).filter((n) => n !== '');
|
|
527
1125
|
return {
|
|
528
1126
|
id: entry.id,
|
|
529
|
-
pkg:
|
|
1127
|
+
pkg: realKey,
|
|
530
1128
|
spec: result.spec ?? `github:${entry.github}#${sha}`,
|
|
531
1129
|
sha: result.sha ?? sha,
|
|
532
1130
|
tag: result.tag ?? tag,
|
|
@@ -542,7 +1140,10 @@ export async function installEntry(entry, cfg = {}, opts = {}, deps) {
|
|
|
542
1140
|
throw new Error(`条目 ${entry.id} 缺少可安装来源`);
|
|
543
1141
|
}
|
|
544
1142
|
/** 卸载:validate(严格读取)→ live-disable → 摘补丁 → pnpm remove → verify gone(DESIGN.md §3:删包不删数据)。 */
|
|
545
|
-
export
|
|
1143
|
+
export function uninstallPlugin(pkg, cfg = {}, opts = {}, deps = {}) {
|
|
1144
|
+
return withMutationSession(() => uninstallPluginLocked(pkg, cfg, opts, deps));
|
|
1145
|
+
}
|
|
1146
|
+
async function uninstallPluginLocked(pkg, _cfg = {}, opts = {}, deps = {}) {
|
|
546
1147
|
// 保护门(Task 12,grilling Q3):dsh-m 自身与官方宿主命脉拒绝卸载(升级不受影响)
|
|
547
1148
|
if (pkg === 'dsh-m' || PROTECTED_MODULES.includes(pkg)) {
|
|
548
1149
|
throw new Error(`拒绝卸载受保护插件: ${pkg}(dsh-m 自身或官方宿主命脉)。如确需移除,请使用官方插件管理页或 dsh plugin CLI`);
|
|
@@ -561,7 +1162,10 @@ export async function uninstallPlugin(pkg, _cfg = {}, opts = {}, deps = {}) {
|
|
|
561
1162
|
};
|
|
562
1163
|
}
|
|
563
1164
|
/** 升级 = 按最新重新安装(npm 拉最新精确版;github 重新锁 HEAD)。 */
|
|
564
|
-
export
|
|
1165
|
+
export function upgradePlugin(pkg, cfg = {}, opts = {}, deps) {
|
|
1166
|
+
return withMutationSession(() => upgradePluginLocked(pkg, cfg, opts, deps));
|
|
1167
|
+
}
|
|
1168
|
+
async function upgradePluginLocked(pkg, cfg = {}, opts = {}, deps) {
|
|
565
1169
|
const loaded = await (deps?.loadRegistry ?? defaultLoadRegistry)(cfg, { namespace: opts.namespace ?? 'host' });
|
|
566
1170
|
if (loaded.status === 'unavailable') {
|
|
567
1171
|
throw new Error(`收录清单不可用,无法升级 ${pkg};请检查 registry 配置或网络后重试`);
|
|
@@ -570,12 +1174,58 @@ export async function upgradePlugin(pkg, cfg = {}, opts = {}, deps) {
|
|
|
570
1174
|
const target = installed.find((it) => it.pkg === pkg);
|
|
571
1175
|
if (!target)
|
|
572
1176
|
throw new Error(`web profile 未安装该插件: ${pkg}`);
|
|
573
|
-
|
|
1177
|
+
let entry = loaded.registry.plugins.find((e) => matchInstalledByEntry(e, [target]));
|
|
1178
|
+
// 0.5.1:合并市场安装的社区条目同样可升级——主清单 miss 时查社区目录
|
|
1179
|
+
if (!entry)
|
|
1180
|
+
entry = await findCommunityUpgradeEntry(target, cfg, opts, deps);
|
|
574
1181
|
if (!entry)
|
|
575
1182
|
throw new Error(`「${pkg}」不是经 dsh-m 收录的插件;直接升级请用 dsh plugin update 或先在 registry 收录它`);
|
|
576
|
-
|
|
1183
|
+
// 0.5.1 修复:直调 installEntryLocked——upgradePlugin 已在 mutation session 区间内,
|
|
1184
|
+
// 再经 installEntry 二次获取 session 会自死锁(session 非重入,见 withMutationSession 契约)。
|
|
1185
|
+
const result = await installEntryLocked(entry, cfg, opts, deps);
|
|
577
1186
|
return { ...result, fromVersion: target.version };
|
|
578
1187
|
}
|
|
1188
|
+
/**
|
|
1189
|
+
* 社区条目升级查找(0.5.1):主清单 miss 时按同一 matchInstalledByEntry 语义查社区目录。
|
|
1190
|
+
* 社区清单未启用/不可用/加载异常/未命中 → undefined,由调用方统一报「不是经 dsh-m 收录」。
|
|
1191
|
+
*/
|
|
1192
|
+
async function findCommunityUpgradeEntry(target, cfg, opts, deps) {
|
|
1193
|
+
try {
|
|
1194
|
+
const loaded = await (deps?.fetchCommunityCatalog ?? defaultFetchCommunityCatalog)(cfg, { namespace: opts.namespace ?? 'host', signal: opts.signal });
|
|
1195
|
+
if (!loaded.catalog)
|
|
1196
|
+
return undefined;
|
|
1197
|
+
const adapted = adaptCommunityCatalog(loaded.catalog);
|
|
1198
|
+
return adapted.entries.find((e) => matchInstalledByEntry(e, [target]));
|
|
1199
|
+
}
|
|
1200
|
+
catch {
|
|
1201
|
+
return undefined;
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
/** 自升级(0.5.0 收编:host-api 直调事务的旁路封死):npmLatest + integrity fail-closed + installEntryLocked(session/守卫经 installEntry)。 */
|
|
1205
|
+
export async function selfUpgrade(pkgName, currentVersion, cfg = {}, opts = {}, deps) {
|
|
1206
|
+
return withMutationSession(async () => {
|
|
1207
|
+
const timeoutMs = cfg.timeoutMs ?? 20_000;
|
|
1208
|
+
const latest = await (deps?.npmLatest ?? defaultNpmLatest)(pkgName, timeoutMs, opts.signal);
|
|
1209
|
+
if (!latest.integrity) {
|
|
1210
|
+
throw new Error(`npm metadata 缺少 dist integrity:${pkgName}@${latest.version},拒绝升级`);
|
|
1211
|
+
}
|
|
1212
|
+
const result = await runProfileTransaction({ kind: 'install-npm', pkg: pkgName, version: latest.version, integrity: latest.integrity, signal: opts.signal }, { ...(deps?.transaction ?? {}), warmPackument: deps?.transaction?.warmPackument ?? makeNpmWarmPackument(timeoutMs) });
|
|
1213
|
+
if (!result.ok)
|
|
1214
|
+
throw new TransactionError(result);
|
|
1215
|
+
return {
|
|
1216
|
+
id: pkgName,
|
|
1217
|
+
pkg: result.pkg ?? pkgName,
|
|
1218
|
+
spec: result.spec ?? `${pkgName}@${latest.version}`,
|
|
1219
|
+
version: result.version ?? latest.version,
|
|
1220
|
+
buildApprovals: result.buildApprovals ?? [],
|
|
1221
|
+
fallbackAllBuilds: result.fallbackAllBuilds === true,
|
|
1222
|
+
...(result.bundleWarning ? { bundleWarning: result.bundleWarning } : {}),
|
|
1223
|
+
needsRestart: true,
|
|
1224
|
+
output: result.output,
|
|
1225
|
+
healActions: result.healActions,
|
|
1226
|
+
};
|
|
1227
|
+
});
|
|
1228
|
+
}
|
|
579
1229
|
/** 疑似残留路径(存在才列出):删包不删数据,只报告。 */
|
|
580
1230
|
export function leftoverCandidates(pkg) {
|
|
581
1231
|
const home = dshHome();
|