dsh-m 0.9.17 → 0.9.19

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.
@@ -14,6 +14,9 @@
14
14
  * 重试把 pending 名单原样传回 `approvedBuilds`(键格式由官方管理器自己写),
15
15
  * dsh-m 绝不直接编辑 desktop profile 的 pnpm-workspace.yaml,绝不全量放行;
16
16
  * - 成功判定 = 官方结果无 error + `listBundles()` 复读目标 bundle 在装,二者同时满足。
17
+ * - 0.9.19:供应链等待期在委派前预检(release-age.ts,零写操作 fail-open);失败翻译解析
18
+ * 全部违规条目并复读「账实分裂」(pnpm 11 锁文件校验失败前目标已写入 node_modules、
19
+ * 官方管理器只回滚 manifest/lockfile 的实证形态——0:15 quota-watch 事件)。
17
20
  *
18
21
  * 禁止事项:desktop 路径零 Web fallback——不做文件级装后守卫(app.asar 探测盲区)、
19
22
  * 不写 allowBuilds、不跑 dsh-m 自实现 pnpm 编排、服务缺席一律结构化拒绝。
@@ -26,39 +29,21 @@ import { listInstalledPlugins as defaultListInstalled } from './installed.js';
26
29
  import { precheckNpmCompat, IncompatibleError } from './compat-check.js';
27
30
  import { npmLatest as defaultNpmLatest, githubLatestTag as defaultGithubLatestTag } from './versions.js';
28
31
  import { togglePlugin, ToggleError } from './toggle.js';
29
- function humanDuration(ms) {
30
- if (!Number.isFinite(ms) || ms <= 0)
31
- return '不到 1 分钟';
32
- const minutes = Math.round(ms / 60_000);
33
- if (minutes < 60)
34
- return `${minutes} 分钟`;
35
- const hours = Math.floor(minutes / 60);
36
- const rest = minutes % 60;
37
- return rest ? `${hours} 小时 ${rest} 分钟` : `${hours} 小时`;
38
- }
39
- /**
40
- * 0.9.10:desktop profile 的 pnpm 供应链策略(minimumReleaseAge)拒绝「太新鲜」的版本——
41
- * 这是策略在正确工作(防供应链攻击的发布等待期),不是环境故障。解析出条目/发布时刻/
42
- * 策略截止,翻译成「何时可重试」的诚实指引,而不是甩一屏 pnpm 原文(dsh-market #732 同态度)。
43
- */
44
- function releaseAgeWaitMessage(diagnostic) {
45
- const m = /(\S+@\S+) was published at ([^,]+), within the minimumReleaseAge cutoff \(([^)]+)\)/.exec(diagnostic);
46
- if (!m) {
47
- return '官方管理器的供应链策略(minimumReleaseAge)未满足:新发布的版本需满等待期才能装入 desktop profile。稍后再点升级即可;等待期内也可改用 DSH Web 安装';
48
- }
49
- const entry = m[1];
50
- const publishedAt = Date.parse(m[2]);
51
- const cutoff = Date.parse(m[3]);
52
- const deadline = Number.isFinite(publishedAt) && Number.isFinite(cutoff) ? publishedAt + (Date.now() - cutoff) : NaN;
53
- const when = Number.isFinite(deadline) ? `约 ${humanDuration(deadline - Date.now())}后(${new Date(deadline).toLocaleString('zh-CN', { hour12: false })})` : '稍后';
54
- return `官方管理器的供应链策略(minimumReleaseAge)要求新发布版本满等待期才能安装:${entry} 尚在等待期内,预计 ${when}可重试。等待期内也可改用 DSH Web 安装同版本`;
55
- }
32
+ import { describeReleaseAgeFailure, releaseAgePrecheck } from './release-age.js';
33
+ // 0.9.19:等待期失败翻译移入 release-age.ts(describeReleaseAgeFailure)——全量违规解析 +
34
+ // 目标/旁包分述。0.9.10 版只取第一条违规并把旁包名字当成本次目标、给「改用 DSH Web」的
35
+ // 失配指引(本机 quota-watch 0.1.15 事件实证:被拦的是旁包 dsh-m@0.9.18,文案却让用户等
36
+ // 旁包满期、去装 web profile)。
56
37
  export class DesktopOpsError extends Error {
57
38
  code;
58
- constructor(code, message) {
39
+ /** 0.9.19:结构化细节(release-age:violations/targetViolating/splitState;precheck:blockers)。 */
40
+ details;
41
+ constructor(code, message, details) {
59
42
  super(message);
60
43
  this.name = 'DesktopOpsError';
61
44
  this.code = code;
45
+ if (details !== undefined)
46
+ this.details = details;
62
47
  }
63
48
  }
64
49
  /** 结构化信号:build-blocked + pendingBuilds(desktopInstall/Upgrade 共享,调用方映射各自返回形态)。 */
@@ -135,10 +120,24 @@ async function desktopInstallLocked(id, cfg, opts, deps) {
135
120
  else {
136
121
  throw new DesktopOpsError('install-refused', `收录条目 ${entry.id} 缺少 npm/github 来源,无法在 desktop 安装`);
137
122
  }
123
+ // 0.9.19:供应链等待期预检——pnpm 11 锁文件校验会拦死整次安装,且失败前目标已写入
124
+ // node_modules(账实分裂);目标太新或锁内「独立精确排除条目」未满期 → 委派前结构化拒绝。
125
+ if (entry.source === 'npm' && version !== undefined) {
126
+ const gate = await releaseAgePrecheck({
127
+ pkg,
128
+ version,
129
+ profileDir: opts.profileDir,
130
+ timeoutMs,
131
+ signal: opts.signal,
132
+ deps: { packumentTimes: deps.packumentTimes, workspacePolicy: deps.workspacePolicy },
133
+ });
134
+ if (gate.blocked)
135
+ throw new DesktopOpsError('release-age-wait', gate.message, { blockers: gate.blockers });
136
+ }
138
137
  let change;
139
138
  let bundleName;
140
139
  try {
141
- ({ change, bundleName } = await runManagedInstall(service, pkg, spec, opts.approvedBuilds));
140
+ ({ change, bundleName } = await runManagedInstall(service, { pkg, spec, version, profileDir: opts.profileDir, approvedBuilds: opts.approvedBuilds, listInstalled: deps.listInstalled }));
142
141
  }
143
142
  catch (err) {
144
143
  if (err instanceof DesktopBuildApprovalNeeded) {
@@ -179,6 +178,9 @@ export async function desktopToggle(pkg, enabled, deps = {}) {
179
178
  * desktopInstall / desktopUpgrade 共用;build-blocked 以 DesktopBuildApprovalNeeded 抛出,
180
179
  * 由调用方映射各自的返回形态。判定纪律沿用文件头「报告 §5.2」(dsh-market #703/#772 实证):
181
180
  * 以 application/stage 为准,绝不只看 packageResult.exitCode;overridden 非失败。
181
+ * 0.9.19:release-age 失败翻译换 release-age.ts(全量违规解析 + 目标/旁包分述),并复读
182
+ * 「账实分裂」(pnpm 11 锁文件校验失败前目标可能已写入 node_modules,官方管理器只回滚
183
+ * manifest/lockfile),把回退风险明示进错误信息。
182
184
  *
183
185
  * 本地增强(2026-10-02,未随计划发版):enable 阶段失败自动重试一次 + 文案按 packageResult 精确化。
184
186
  * 背景:desktop 高频装卸/插件树重载窗口下,官方管理器 enable 阶段与应用侧操作竞速,返回
@@ -205,42 +207,63 @@ function enableStageFailureMessage(pkg, change) {
205
207
  }
206
208
  return `安装后启用阶段失败(${pkg};application=failed、stage=enable${change.error?.code ? `、error=${change.error.code}` : ''}):${writtenText},请在官方 Desktop 插件页查看状态或重试`;
207
209
  }
208
- async function runManagedInstall(service, pkg, spec, approvedBuilds) {
209
- const installOnce = async () => changeOf(await service.installBundle(spec, {
210
+ async function runManagedInstall(service, ctx) {
211
+ const installOnce = async () => changeOf(await service.installBundle(ctx.spec, {
210
212
  enabled: true,
211
- ...(approvedBuilds && approvedBuilds.length > 0 ? { approvedBuilds } : {}),
213
+ ...(ctx.approvedBuilds && ctx.approvedBuilds.length > 0 ? { approvedBuilds: ctx.approvedBuilds } : {}),
212
214
  }));
213
215
  /** 失败映射(既有语义不变:cancelled / build-blocked 审批 / release-age 等待 / enable / 通用)。 */
214
- const mapFailure = (change) => {
216
+ const mapFailure = async (change) => {
215
217
  if (change.application === 'cancelled') {
216
- throw new DesktopOpsError('install-refused', `安装已被取消(${pkg}):desktop profile 文件已由官方管理器恢复,未安装`);
218
+ throw new DesktopOpsError('install-refused', `安装已被取消(${ctx.pkg}):desktop profile 文件已由官方管理器恢复,未安装`);
217
219
  }
218
220
  const pending = Array.isArray(change.pendingBuilds) ? change.pendingBuilds.filter((n) => typeof n === 'string') : [];
219
221
  if (change.packageResult?.kind === 'build-blocked' && pending.length > 0) {
220
222
  throw new DesktopBuildApprovalNeeded(pending);
221
223
  }
222
- // 0.9.10:供应链等待期(minimumReleaseAge)——策略正确工作,翻译成「何时可重试」
224
+ // 0.9.19:供应链等待期——解析全部违规条目(0.9.10 只取第一条,旁包被当成被拦目标)、
225
+ // 目标/旁包分述 + 账实分裂复读(pnpm 11 校验失败前已写入 node_modules 的实证形态)。
223
226
  const diagnosticText = `${change.error?.diagnostic ?? ''}\n${change.error?.code ?? ''}`;
224
227
  if (/ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION|minimumReleaseAge/i.test(diagnosticText)) {
225
- throw new DesktopOpsError('release-age-wait', releaseAgeWaitMessage(diagnosticText));
228
+ const view = describeReleaseAgeFailure({ pkg: ctx.pkg, version: ctx.version, diagnostic: diagnosticText });
229
+ let message = view.message;
230
+ let splitState;
231
+ if (ctx.profileDir && ctx.version) {
232
+ try {
233
+ const items = (await ctx.listInstalled?.(ctx.profileDir) ?? { items: [] }).items;
234
+ const row = items.find((it) => it.pkg === ctx.pkg);
235
+ if (row && row.version === ctx.version && typeof row.spec === 'string' && row.spec !== '') {
236
+ splitState = { installedVersion: row.version, manifestSpec: row.spec };
237
+ message += `\n注意:${row.version} 已写入 node_modules 且在运行,但 manifest/lockfile 仍记录 ${row.spec}(官方管理器失败回滚所致,「账实分裂」)——官方管理器下次任何包操作都会把它回退到 ${row.spec};等待期满后重新升级即可对齐。`;
238
+ }
239
+ }
240
+ catch {
241
+ // 复读失败不遮蔽原错误
242
+ }
243
+ }
244
+ throw new DesktopOpsError('release-age-wait', message, {
245
+ violations: view.violations,
246
+ targetViolating: view.targetViolating,
247
+ ...(splitState !== undefined ? { splitState } : {}),
248
+ });
226
249
  }
227
250
  if (change.stage === 'enable') {
228
- throw new DesktopOpsError('enable-failed', enableStageFailureMessage(pkg, change));
251
+ throw new DesktopOpsError('enable-failed', enableStageFailureMessage(ctx.pkg, change));
229
252
  }
230
- throw new DesktopOpsError('install-refused', `官方管理器安装失败(${pkg};application=${change.application ?? 'failed'}${change.error?.code ? `、error=${change.error.code}` : ''}${change.error?.diagnostic ? `:${change.error.diagnostic}` : ''})`);
253
+ throw new DesktopOpsError('install-refused', `官方管理器安装失败(${ctx.pkg};application=${change.application ?? 'failed'}${change.error?.code ? `、error=${change.error.code}` : ''}${change.error?.diagnostic ? `:${change.error.diagnostic}` : ''})`);
231
254
  };
232
255
  /** 复读校验:listBundles 可用必须见目标在装;不可用(老管理器)至少要求官方宣称产出 bundle。 */
233
256
  const verifyAndBundle = async (change) => {
234
- const bundleName = change.bundle ?? pkg;
257
+ const bundleName = change.bundle ?? ctx.pkg;
235
258
  if (typeof service.listBundles === 'function') {
236
259
  const bundles = await service.listBundles();
237
260
  const found = (bundles ?? []).find((b) => b && b.name === bundleName);
238
261
  if (!found || found.installed === false) {
239
- throw new DesktopOpsError('verify-failed', `安装复读失败(${pkg}):官方结果为 ${change.application ?? 'unknown'} 但 listBundles 未見目标 bundle 在装——不冒充安装成功`);
262
+ throw new DesktopOpsError('verify-failed', `安装复读失败(${ctx.pkg}):官方结果为 ${change.application ?? 'unknown'} 但 listBundles 未見目标 bundle 在装——不冒充安装成功`);
240
263
  }
241
264
  }
242
265
  else if (!change.bundle) {
243
- throw new DesktopOpsError('verify-failed', `安装复读不可用(${pkg}):官方结果未携带 bundle 字段且 listBundles 缺席——不冒充安装成功`);
266
+ throw new DesktopOpsError('verify-failed', `安装复读不可用(${ctx.pkg}):官方结果未携带 bundle 字段且 listBundles 缺席——不冒充安装成功`);
244
267
  }
245
268
  return { change, bundleName };
246
269
  };
@@ -253,7 +276,7 @@ async function runManagedInstall(service, pkg, spec, approvedBuilds) {
253
276
  change = await installOnce();
254
277
  }
255
278
  if (change.application === 'failed' || change.application === 'cancelled' || change.error)
256
- mapFailure(change);
279
+ await mapFailure(change);
257
280
  return verifyAndBundle(change);
258
281
  }
259
282
  /**
@@ -359,10 +382,23 @@ async function desktopUpgradeLocked(pkg, cfg, opts, deps) {
359
382
  else {
360
383
  throw new DesktopOpsError('install-refused', `收录条目 ${entry.id} 缺少 npm/github 来源,无法在 desktop 升级`);
361
384
  }
385
+ // 0.9.19:供应链等待期预检(同 desktopInstallLocked;0:15 quota-watch 事件即此形态实证)
386
+ if (entry.source === 'npm' && version !== undefined) {
387
+ const gate = await releaseAgePrecheck({
388
+ pkg,
389
+ version,
390
+ profileDir: opts.profileDir,
391
+ timeoutMs,
392
+ signal: opts.signal,
393
+ deps: { packumentTimes: deps.packumentTimes, workspacePolicy: deps.workspacePolicy },
394
+ });
395
+ if (gate.blocked)
396
+ throw new DesktopOpsError('release-age-wait', gate.message, { blockers: gate.blockers });
397
+ }
362
398
  let change;
363
399
  let bundleName;
364
400
  try {
365
- ({ change, bundleName } = await runManagedInstall(service, pkg, spec, opts.approvedBuilds));
401
+ ({ change, bundleName } = await runManagedInstall(service, { pkg, spec, version, profileDir: opts.profileDir, approvedBuilds: opts.approvedBuilds, listInstalled: deps.listInstalled }));
366
402
  }
367
403
  catch (err) {
368
404
  if (err instanceof DesktopBuildApprovalNeeded) {
@@ -0,0 +1,267 @@
1
+ /**
2
+ * Desktop 供应链等待期(minimumReleaseAge)预检与失败翻译(0.9.19)。
3
+ *
4
+ * pnpm 11.7 实机实证(2026-10-03,本机 desktop profile,官方管理器操作日志
5
+ * operation-hNjgaN / operation-l5fXEd):
6
+ * 1. 显式安装「太新」目标:非严格模式放行,pnpm 自动向 pnpm-workspace.yaml 的
7
+ * `minimumReleaseAgeExclude` 追加一条「独立精确条目」(`pkg@ver` 形态);
8
+ * 2. 但后续任何包操作的锁文件级供应链校验(`Verifying lockfile against
9
+ * supply-chain policies (N entries)`)不认可 pnpm 自己追加的这种条目——
10
+ * 任何一条未满期的 `pkg@ver` 都会拦死整个安装,且与本次目标无关(旁包能拦住
11
+ * 所有升级,0.9.18 拦住 quota-watch 0.1.15 升级即实证);
12
+ * 3. 校验失败前目标包可能已写入 node_modules,官方管理器只回滚 manifest/lockfile
13
+ * →「账实分裂」:界面实装新版 active,账本仍旧版,下次包操作静默回退。
14
+ *
15
+ * 对策(零文件级红线不破:只读 profile 文件,绝不写):
16
+ * ① 委派前预检(releaseAgePrecheck):目标版本或锁内「不被校验认可的独立精确
17
+ * 排除条目」任一未满等待期 → 结构化拒绝,不委派官方管理器;
18
+ * ② 失败翻译(describeReleaseAgeFailure):解析全部违规条目,按「本次目标 vs
19
+ * 锁内旁包」分别给出发布时刻与可重试时刻——不再把旁包名字冒充成本次目标被拦。
20
+ *
21
+ * fail-open 纪律:发布时刻不可得 / 策略文件不可读 / profileDir 缺席 / 命中有效排除
22
+ * 条目(包名级、`||` 复合、scoped 独立精确)→ 一律放行;pnpm 仍是最终执行者。
23
+ */
24
+ import { readFile } from 'node:fs/promises';
25
+ import { join } from 'node:path';
26
+ import { isMap, isScalar, isSeq, parseDocument } from 'yaml';
27
+ import { fetchJsonLimited } from './httpx.js';
28
+ import { isExactVersion } from './versions.js';
29
+ /** pnpm 11.7 实机测得的策略窗口:24h(operation-l5fXEd 的 cutoff = 检查时刻 − 1440min)。 */
30
+ export const DEFAULT_MINIMUM_RELEASE_AGE_MIN = 1440;
31
+ /** 锁内「独立精确排除条目」旁包探测上限(防清单失控;超出部分放弃预判,交 pnpm 兜底)。 */
32
+ export const MAX_EXCLUDE_PROBES = 8;
33
+ function scalarNumber(value) {
34
+ if (typeof value === 'number' && Number.isFinite(value))
35
+ return value;
36
+ if (typeof value === 'string' && value.trim() !== '' && Number.isFinite(Number(value)))
37
+ return Number(value);
38
+ return null;
39
+ }
40
+ /**
41
+ * 最小解析 pnpm-workspace.yaml 的两个策略键。
42
+ * 保守口径:坏 YAML / 根非映射 / 结构不符 → 空策略(minimumReleaseAgeMin=null、excludes=[]),
43
+ * 调用方按默认窗口 + 无排除处理,绝不抛。
44
+ */
45
+ export function parseWorkspacePolicy(text) {
46
+ const empty = { minimumReleaseAgeMin: null, excludes: [] };
47
+ let document;
48
+ try {
49
+ document = parseDocument(text);
50
+ }
51
+ catch {
52
+ return empty;
53
+ }
54
+ if (document.errors.length > 0 || !isMap(document.contents))
55
+ return empty;
56
+ const excludes = [];
57
+ const rawExcludes = document.contents.get('minimumReleaseAgeExclude');
58
+ if (isSeq(rawExcludes)) {
59
+ for (const item of rawExcludes.items) {
60
+ if (isScalar(item) && typeof item.value === 'string' && item.value.trim() !== '') {
61
+ excludes.push(item.value.trim());
62
+ }
63
+ }
64
+ }
65
+ // yaml 库的 Map.get() 对标量返回已解包的原始值(number/string),对集合返回节点——两种形态都兜住
66
+ const rawAge = document.contents.get('minimumReleaseAge');
67
+ const age = isScalar(rawAge) ? scalarNumber(rawAge.value) : scalarNumber(rawAge);
68
+ return { minimumReleaseAgeMin: age !== null && age > 0 ? age : null, excludes };
69
+ }
70
+ export function excludeMatch(selector, pkg, version) {
71
+ const s = selector.trim();
72
+ if (s === '')
73
+ return 'no';
74
+ if (s.includes('||')) {
75
+ for (const part of s.split('||')) {
76
+ if (excludeMatch(part.trim(), pkg, version) !== 'no')
77
+ return 'effective';
78
+ }
79
+ return 'no';
80
+ }
81
+ const at = s.lastIndexOf('@');
82
+ if (at <= 0) {
83
+ // 包名级(dshmarket)或裸版本号段(只应出现在复合条目内,顶层出现视为包名比较)
84
+ return s === pkg || s === version ? 'effective' : 'no';
85
+ }
86
+ const namePart = s.slice(0, at);
87
+ const verPart = s.slice(at + 1);
88
+ if (namePart !== pkg)
89
+ return 'no';
90
+ if (verPart === version) {
91
+ return pkg.startsWith('@') ? 'effective' : 'unreliable-unscoped-exact';
92
+ }
93
+ return 'no';
94
+ }
95
+ /** 从独立精确条目提取 (pkg, version);复合/包名级/版本段非精确 semver → null。 */
96
+ export function splitExactSelector(selector) {
97
+ const s = selector.trim();
98
+ if (s === '' || s.includes('||'))
99
+ return null;
100
+ const at = s.lastIndexOf('@');
101
+ if (at <= 0)
102
+ return null;
103
+ const pkg = s.slice(0, at);
104
+ const version = s.slice(at + 1);
105
+ if (!isExactVersion(version))
106
+ return null;
107
+ if (!/^@?[A-Za-z0-9-._~]+(\/[A-Za-z0-9-._~]+)?$/.test(pkg))
108
+ return null;
109
+ return { pkg, version, reliable: pkg.startsWith('@') };
110
+ }
111
+ const VIOLATION_RE = /(\S+@\S+) was published at ([^,]+), within the minimumReleaseAge cutoff \(([^)]+)\)/g;
112
+ /** 解析 pnpm 诊断里的全部等待期违规条目(0.9.10 只取第一条是「报错报错包」的根因之一)。 */
113
+ export function parseViolations(diagnostic, nowMs = Date.now()) {
114
+ const out = [];
115
+ for (const m of diagnostic.matchAll(VIOLATION_RE)) {
116
+ const entry = m[1] ?? '';
117
+ const publishedAt = Date.parse(m[2] ?? '');
118
+ const cutoff = Date.parse(m[3] ?? '');
119
+ if (entry === '' || !Number.isFinite(publishedAt) || !Number.isFinite(cutoff))
120
+ continue;
121
+ const at = entry.lastIndexOf('@');
122
+ out.push({
123
+ entry,
124
+ pkg: at > 0 ? entry.slice(0, at) : entry,
125
+ version: at > 0 ? entry.slice(at + 1) : null,
126
+ publishedAt,
127
+ cutoff,
128
+ deadline: publishedAt + (nowMs - cutoff),
129
+ });
130
+ }
131
+ return out;
132
+ }
133
+ function fmtLocal(ms) {
134
+ return new Date(ms).toLocaleString('zh-CN', { hour12: false });
135
+ }
136
+ /**
137
+ * 0.9.19 失败翻译:替代 0.9.10 的 releaseAgeWaitMessage(只取第一条违规、把旁包
138
+ * 名字当成被拦对象、给「DSH Web 安装同版本」的失配指引)。多行文案进操作记录面板。
139
+ */
140
+ export function describeReleaseAgeFailure(input) {
141
+ const now = input.nowMs ?? Date.now();
142
+ const violations = parseViolations(input.diagnostic, now);
143
+ if (violations.length === 0) {
144
+ return {
145
+ message: '官方管理器的供应链策略(minimumReleaseAge)未满足:新发布的版本需满等待期才能装入 desktop profile。稍后再点升级即可',
146
+ violations,
147
+ targetViolating: false,
148
+ };
149
+ }
150
+ const targetEntry = input.version ? `${input.pkg}@${input.version}` : input.pkg;
151
+ const targetViol = violations.find((v) => v.pkg === input.pkg);
152
+ const others = violations.filter((v) => v !== targetViol);
153
+ const lines = [`官方管理器的供应链策略(minimumReleaseAge)拦截了本次安装(${input.pkg}):锁文件供应链校验未通过`];
154
+ if (targetViol) {
155
+ lines.push(`· 本次目标 ${targetViol.entry}:发布于 ${fmtLocal(targetViol.publishedAt)},预计 ${fmtLocal(targetViol.deadline)} 后满等待期`);
156
+ }
157
+ else {
158
+ lines.push(`· 本次目标 ${targetEntry}:不在违规名单(被锁内其他未满期条目连坐)`);
159
+ }
160
+ for (const v of others) {
161
+ lines.push(`· 锁内未满期条目 ${v.entry}:发布于 ${fmtLocal(v.publishedAt)},预计 ${fmtLocal(v.deadline)} 后满等待期(pnpm 安装它时自动追加的独立精确排除条目不被锁文件校验认可,等待期内任何官方包操作都会被拦)`);
162
+ }
163
+ const maxDeadline = Math.max(...violations.map((v) => v.deadline));
164
+ lines.push(`预计 ${fmtLocal(maxDeadline)} 后可重试;等待期内也可由操作员把 pnpm-workspace.yaml 中对应的独立精确排除条目改为包名级或复合条目以解除拦截`);
165
+ return { message: lines.join('\n'), violations, targetViolating: targetViol !== undefined };
166
+ }
167
+ /** registry packument 的 time 映射(与 npmPackument 同源;失败返回 null,不抛)。 */
168
+ export async function npmPackumentTimes(pkg, timeoutMs = 20_000, signal) {
169
+ if (!/^@?[A-Za-z0-9-._~]+(\/[A-Za-z0-9-._~]+)?$/.test(pkg))
170
+ return null;
171
+ try {
172
+ const data = await fetchJsonLimited(`https://registry.npmjs.org/${encodeURIComponent(pkg)}`, { timeoutMs, signal, maxBytes: 8 * 1024 * 1024 });
173
+ if (!data || typeof data !== 'object' || data.time === null || typeof data.time !== 'object')
174
+ return null;
175
+ const out = {};
176
+ for (const [k, v] of Object.entries(data.time)) {
177
+ if (typeof v === 'string')
178
+ out[k] = v;
179
+ }
180
+ return out;
181
+ }
182
+ catch {
183
+ return null;
184
+ }
185
+ }
186
+ /** 读 profile 的 pnpm-workspace.yaml 策略;缺席/不可读 → null(调用方 fail-open)。 */
187
+ export async function readWorkspacePolicy(profileDir) {
188
+ if (!profileDir)
189
+ return null;
190
+ try {
191
+ return parseWorkspacePolicy(await readFile(join(profileDir, 'pnpm-workspace.yaml'), 'utf8'));
192
+ }
193
+ catch {
194
+ return null;
195
+ }
196
+ }
197
+ const packumentCache = new Map();
198
+ const PACKUMENT_CACHE_TTL_MS = 5 * 60_000;
199
+ /** 测试钩子:清空 packument 进程内缓存。 */
200
+ export function _resetReleaseAgeCachesForTests() {
201
+ packumentCache.clear();
202
+ }
203
+ /**
204
+ * 委派前预检:目标版本与锁内「不被校验认可的独立精确排除条目」逐个对发布时刻,
205
+ * 任一未满等待期 → 结构化拒绝(blocked + 多行理由)。零写操作;不确定一律放行。
206
+ */
207
+ export async function releaseAgePrecheck(input) {
208
+ // profileDir 缺席 = 读不到操作员排除条目 → 不预判(避免误拦操作员显式 pin 的安装)
209
+ if (!input.profileDir)
210
+ return { blocked: false };
211
+ const now = input.nowMs ?? Date.now();
212
+ const policy = input.deps?.workspacePolicy
213
+ ? await input.deps.workspacePolicy(input.profileDir)
214
+ : await readWorkspacePolicy(input.profileDir);
215
+ const policyMin = policy?.minimumReleaseAgeMin ?? DEFAULT_MINIMUM_RELEASE_AGE_MIN;
216
+ const probe = async (pkg) => {
217
+ const hit = packumentCache.get(pkg);
218
+ if (hit && Date.now() - hit.at < PACKUMENT_CACHE_TTL_MS)
219
+ return hit.times;
220
+ const times = await (input.deps?.packumentTimes ?? npmPackumentTimes)(pkg, Math.min(input.timeoutMs ?? 20_000, 12_000), input.signal);
221
+ packumentCache.set(pkg, { at: Date.now(), times });
222
+ return times;
223
+ };
224
+ const blockers = [];
225
+ // ① 本次目标版本的发布时刻(有效排除条目覆盖 → 放行)
226
+ const times = await probe(input.pkg);
227
+ const publishedAt = times ? Date.parse(times[input.version] ?? '') : NaN;
228
+ if (Number.isFinite(publishedAt)) {
229
+ const exempt = (policy?.excludes ?? []).some((sel) => excludeMatch(sel, input.pkg, input.version) === 'effective');
230
+ if (!exempt) {
231
+ const deadline = publishedAt + policyMin * 60_000;
232
+ if (now < deadline) {
233
+ blockers.push({ entry: `${input.pkg}@${input.version}`, role: 'target', publishedAt, deadline });
234
+ }
235
+ }
236
+ }
237
+ // ② 锁内「不被校验认可的独立精确排除条目」(非 scoped)——任何一条未满期都会拦死整次安装
238
+ const seen = new Set([`${input.pkg}@${input.version}`]);
239
+ const candidates = (policy?.excludes ?? [])
240
+ .map((sel) => splitExactSelector(sel))
241
+ .filter((x) => x !== null && !x.reliable);
242
+ for (const ex of candidates.slice(0, MAX_EXCLUDE_PROBES)) {
243
+ const entry = `${ex.pkg}@${ex.version}`;
244
+ if (seen.has(entry))
245
+ continue;
246
+ seen.add(entry);
247
+ const t2 = await probe(ex.pkg);
248
+ const pub = t2 ? Date.parse(t2[ex.version] ?? '') : NaN;
249
+ if (!Number.isFinite(pub))
250
+ continue;
251
+ const deadline = pub + policyMin * 60_000;
252
+ if (now < deadline) {
253
+ blockers.push({ entry, role: 'lockfile-exclude', publishedAt: pub, deadline });
254
+ }
255
+ }
256
+ if (blockers.length === 0)
257
+ return { blocked: false };
258
+ const lines = ['官方管理器的供应链策略(minimumReleaseAge)将拦截本次安装,已提前拒绝(未触碰 profile 文件):'];
259
+ for (const b of blockers) {
260
+ lines.push(b.role === 'target'
261
+ ? `· 目标 ${b.entry}:发布于 ${fmtLocal(b.publishedAt)},预计 ${fmtLocal(b.deadline)} 后满等待期`
262
+ : `· 锁内排除条目 ${b.entry}:发布于 ${fmtLocal(b.publishedAt)},预计 ${fmtLocal(b.deadline)} 后满等待期(pnpm 自动追加的独立精确条目不被锁文件校验认可,等待期内任何官方包操作都会被拦)`);
263
+ }
264
+ const maxDeadline = Math.max(...blockers.map((b) => b.deadline));
265
+ lines.push(`预计 ${fmtLocal(maxDeadline)} 后可重试;等待期内也可由操作员修正 pnpm-workspace.yaml 的排除条目形态(包名级/复合)解除拦截`);
266
+ return { blocked: true, message: lines.join('\n'), blockers };
267
+ }
package/lib/tools.js CHANGED
@@ -255,7 +255,7 @@ export function registerTools(ctx, cfg, deps = {}, profile = resolveActiveProfil
255
255
  if (profile.kind !== 'web') {
256
256
  assertWriteAllowed(profile, 'install');
257
257
  // 0.9.8:补传服务解析 deps(此前漏传 → desktop 安装恒 no-manager,实机 2026-10-01)
258
- return cloneJson(await m.desktopInstall(id, cfg, { version, forceIncompatible: force, namespace: 'host', profile: profile.name }, { getService: getService, ensureService: ensureService }));
258
+ return cloneJson(await m.desktopInstall(id, cfg, { version, forceIncompatible: force, namespace: 'host', profile: profile.name, profileDir: profile.dir }, { getService: getService, ensureService: ensureService }));
259
259
  }
260
260
  try {
261
261
  return cloneJson(await m.installFromRegistry(id, cfg, { version, forceIncompatible: force, namespace: 'host' }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-m",
3
- "version": "0.9.17",
3
+ "version": "0.9.19",
4
4
  "description": "可自定义收录清单的 DSH 插件市场:收录、安装、卸载、升级一站完成,附 agent 工具与 dshm CLI。",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -20,6 +20,7 @@
20
20
  "registry.json",
21
21
  "README.md",
22
22
  "README.en.md",
23
+ "CHANGELOG.md",
23
24
  "docs/DESIGN.md",
24
25
  "LICENSE"
25
26
  ],