dsh-m 0.9.23 → 0.9.25
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/CHANGELOG.md +28 -0
- package/README.en.md +2 -2
- package/README.md +2 -2
- package/docs/DESIGN.md +5 -3
- package/lib/client.js +118 -75
- package/lib/core/dsh-cli.js +98 -31
- package/lib/core/exclude-governance.js +264 -0
- package/lib/core/market.js +5 -0
- package/lib/core/profile-ops.js +124 -19
- package/lib/core/registry.js +11 -0
- package/lib/core/release-age.js +72 -96
- package/package.json +1 -1
- package/registry.json +3 -3
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 排除条目代管(ADR-0009):pnpm-workspace.yaml `minimumReleaseAgeExclude` 块的
|
|
3
|
+
* 纯文本变换(解析 / 治理合并 / 登记)与落盘封装(原子写 + 官方同款锁 + fail-open)。
|
|
4
|
+
*
|
|
5
|
+
* 规范形态:每包一条、版本并集复合(`name@a || b`);包名级保持裸名;scoped 名在
|
|
6
|
+
* YAML 中必须单引号包裹(`@` 不能作 plain scalar 开头)。治理只修不建;登记可为
|
|
7
|
+
* 无块的文件创建块;解析失败(含 `#` 注释行)一律弃写——pnpm 仍是最终执行者。
|
|
8
|
+
*
|
|
9
|
+
* 机理(首条规则生效):pnpm evaluateVersionPolicy 每包名只认第一条排除规则,
|
|
10
|
+
* 同名后续规则死亡(dshmarket #732 实证)——同名合并即消除死规则。
|
|
11
|
+
* 语义对照(只读参考):.dsh-research/dsh-market-clone/src/profile.ts:1310-1385(merge)。
|
|
12
|
+
*/
|
|
13
|
+
import { readFile as readFileFs } from 'node:fs/promises';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
import { writeFileAtomic, withFileLock } from '@deepseek-ai/dsh-atomic-write';
|
|
16
|
+
import { isExactVersion } from './versions.js';
|
|
17
|
+
import { cachedPackumentTimes, DEFAULT_MINIMUM_RELEASE_AGE_MIN, readWorkspacePolicy } from './release-age.js';
|
|
18
|
+
/** 块形态(`minimumReleaseAgeExclude:` + 块内 `- <条目>` 行);行内数组等其它形态不在此列。 */
|
|
19
|
+
const BLOCK_RE = /^minimumReleaseAgeExclude:[ \t]*\r?\n((?:[ \t]+-[^\r\n]*\r?\n?)*)/m;
|
|
20
|
+
/** 任意形态的键存在性(含行内);登记据此避免产生重复键。 */
|
|
21
|
+
const KEY_ANY_FORM_RE = /^[ \t]*minimumReleaseAgeExclude[ \t]*:/m;
|
|
22
|
+
const WS_FILE = 'pnpm-workspace.yaml';
|
|
23
|
+
function splitEntry(raw) {
|
|
24
|
+
let text = raw.trim();
|
|
25
|
+
if (text.length >= 2 && ((text.startsWith("'") && text.endsWith("'")) || (text.startsWith('"') && text.endsWith('"')))) {
|
|
26
|
+
text = text.slice(1, -1);
|
|
27
|
+
}
|
|
28
|
+
const at = text.startsWith('@') ? text.indexOf('@', 1) : text.indexOf('@');
|
|
29
|
+
if (at === -1)
|
|
30
|
+
return text === '' ? null : { name: text, selectors: null };
|
|
31
|
+
const name = text.slice(0, at);
|
|
32
|
+
const selector = text.slice(at + 1).trim();
|
|
33
|
+
if (name === '' || selector === '')
|
|
34
|
+
return null;
|
|
35
|
+
return { name, selectors: selector.split('||').map((s) => s.trim()) };
|
|
36
|
+
}
|
|
37
|
+
/** 解析排除块:absent = 键缺失;unparseable = 块内含 `#` 或无法精确读取的条目(调用方弃写)。 */
|
|
38
|
+
export function parseExcludeBlock(yaml) {
|
|
39
|
+
const block = BLOCK_RE.exec(yaml);
|
|
40
|
+
if (block === null)
|
|
41
|
+
return { kind: 'absent' };
|
|
42
|
+
const eol = /\r\n/.test(yaml) ? '\r\n' : '\n';
|
|
43
|
+
const indentMatch = /^([ \t]+)-/.exec(block[1]);
|
|
44
|
+
const indent = indentMatch === null ? ' ' : indentMatch[1];
|
|
45
|
+
const entries = [];
|
|
46
|
+
const rawLines = [];
|
|
47
|
+
for (const line of block[1].split(/\r?\n/)) {
|
|
48
|
+
if (line.trim() === '')
|
|
49
|
+
continue;
|
|
50
|
+
const m = /^[ \t]+-[ \t]*(.*?)[ \t]*$/.exec(line);
|
|
51
|
+
if (m === null || m[1].includes('#'))
|
|
52
|
+
return { kind: 'unparseable' };
|
|
53
|
+
const split = splitEntry(m[1]);
|
|
54
|
+
if (split === null)
|
|
55
|
+
return { kind: 'unparseable' };
|
|
56
|
+
entries.push({ name: split.name, selectors: split.selectors, quoted: /^['"]/.test(m[1]), raw: line });
|
|
57
|
+
rawLines.push(line);
|
|
58
|
+
}
|
|
59
|
+
return { kind: 'ok', entries, rawLines, eol, indent };
|
|
60
|
+
}
|
|
61
|
+
/** 规范行渲染:单选择器 `name@v`、多选择器 `name@a || b`、包名级裸名;scoped 名加单引号。 */
|
|
62
|
+
function renderEntryLine(name, selectors, indent) {
|
|
63
|
+
const text = selectors === null || selectors.length === 0
|
|
64
|
+
? name
|
|
65
|
+
: selectors.length === 1
|
|
66
|
+
? `${name}@${selectors[0]}`
|
|
67
|
+
: `${name}@${selectors.join(' || ')}`;
|
|
68
|
+
return `${indent}- ${text.startsWith('@') ? `'${text.replaceAll("'", "''")}'` : text}`;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* 治理:同名规则合并为规范形态(每包一条、版本并集、原首现顺序);只修不建;
|
|
72
|
+
* 块缺失 → changed=false;解析失败 → null(弃写)。changed 的判定逐名比较
|
|
73
|
+
* 「合并后渲染行 vs 原始行」,只有实际被改写的名字进入 mergedNames。
|
|
74
|
+
*/
|
|
75
|
+
export function mergeExcludeRules(yaml) {
|
|
76
|
+
const parse = parseExcludeBlock(yaml);
|
|
77
|
+
if (parse.kind === 'absent')
|
|
78
|
+
return { yaml, changed: false, mergedNames: [], blockAbsent: true };
|
|
79
|
+
if (parse.kind === 'unparseable')
|
|
80
|
+
return null;
|
|
81
|
+
const { entries, rawLines, eol, indent } = parse;
|
|
82
|
+
const groups = new Map();
|
|
83
|
+
const order = [];
|
|
84
|
+
entries.forEach((e, i) => {
|
|
85
|
+
let g = groups.get(e.name);
|
|
86
|
+
if (g === undefined) {
|
|
87
|
+
g = { selectors: [], seen: new Set(), firstRaw: rawLines[i], count: 0 };
|
|
88
|
+
groups.set(e.name, g);
|
|
89
|
+
order.push(e.name);
|
|
90
|
+
}
|
|
91
|
+
g.count += 1;
|
|
92
|
+
if (e.selectors === null) {
|
|
93
|
+
// 包名级吞掉同名一切版本段
|
|
94
|
+
g.selectors = null;
|
|
95
|
+
g.seen.clear();
|
|
96
|
+
}
|
|
97
|
+
else if (g.selectors !== null) {
|
|
98
|
+
for (const s of e.selectors) {
|
|
99
|
+
if (!g.seen.has(s)) {
|
|
100
|
+
g.seen.add(s);
|
|
101
|
+
g.selectors.push(s);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
const mergedNames = order.filter((name) => {
|
|
107
|
+
const g = groups.get(name);
|
|
108
|
+
if (g === undefined)
|
|
109
|
+
return false;
|
|
110
|
+
return g.count > 1 || renderEntryLine(name, g.selectors, indent) !== g.firstRaw;
|
|
111
|
+
});
|
|
112
|
+
if (mergedNames.length === 0)
|
|
113
|
+
return { yaml, changed: false, mergedNames: [], blockAbsent: false };
|
|
114
|
+
const lines = order.map((name) => {
|
|
115
|
+
const g = groups.get(name);
|
|
116
|
+
return renderEntryLine(name, g?.selectors ?? null, indent);
|
|
117
|
+
});
|
|
118
|
+
const blockText = `minimumReleaseAgeExclude:${eol}${lines.join(eol)}${eol}`;
|
|
119
|
+
return { yaml: yaml.replace(BLOCK_RE, () => blockText), changed: true, mergedNames, blockAbsent: false };
|
|
120
|
+
}
|
|
121
|
+
function newRuleLine(target, indent) {
|
|
122
|
+
if (target.pkg.startsWith('@'))
|
|
123
|
+
return { line: renderEntryLine(target.pkg, [target.version], indent), form: 'created-scoped-exact' };
|
|
124
|
+
if (target.previousVersion !== undefined && target.previousVersion !== target.version) {
|
|
125
|
+
return { line: renderEntryLine(target.pkg, [target.version, target.previousVersion], indent), form: 'created-composite' };
|
|
126
|
+
}
|
|
127
|
+
return { line: renderEntryLine(target.pkg, [target.version], indent), form: 'created-exact' };
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* 登记:把目标精确版本并入该包**首条**规则(首条规则生效口径);无既有规则时按
|
|
131
|
+
* scoped → 精确单条、非 scoped + 异版 previousVersion → 双选择器复合、其余 → 精确单条
|
|
132
|
+
* 新建;块缺失的文件在尾部创建块(键以行内等其它形式存在 → null,避免重复键)。
|
|
133
|
+
* 目标已被覆盖(包名级 / 选择器含该版本)→ noop。
|
|
134
|
+
*/
|
|
135
|
+
export function registerExclusionInYaml(yaml, target) {
|
|
136
|
+
const parse = parseExcludeBlock(yaml);
|
|
137
|
+
if (parse.kind === 'unparseable')
|
|
138
|
+
return null;
|
|
139
|
+
if (parse.kind === 'absent') {
|
|
140
|
+
if (KEY_ANY_FORM_RE.test(yaml))
|
|
141
|
+
return null;
|
|
142
|
+
const eol = /\r\n/.test(yaml) ? '\r\n' : '\n';
|
|
143
|
+
const { line, form } = newRuleLine(target, ' ');
|
|
144
|
+
const base = yaml === '' || yaml.endsWith(eol) ? yaml : yaml + eol;
|
|
145
|
+
return { yaml: `${base}minimumReleaseAgeExclude:${eol}${line}${eol}`, changed: true, form };
|
|
146
|
+
}
|
|
147
|
+
const { entries, rawLines, eol, indent } = parse;
|
|
148
|
+
const lines = [...rawLines];
|
|
149
|
+
const idx = entries.findIndex((e) => e.name === target.pkg);
|
|
150
|
+
if (idx >= 0) {
|
|
151
|
+
const e = entries[idx];
|
|
152
|
+
if (e.selectors === null)
|
|
153
|
+
return { yaml, changed: false, form: 'noop' };
|
|
154
|
+
if (e.selectors.includes(target.version))
|
|
155
|
+
return { yaml, changed: false, form: 'noop' };
|
|
156
|
+
lines[idx] = renderEntryLine(target.pkg, [...e.selectors, target.version], indent);
|
|
157
|
+
const blockText = `minimumReleaseAgeExclude:${eol}${lines.join(eol)}${eol}`;
|
|
158
|
+
return { yaml: yaml.replace(BLOCK_RE, () => blockText), changed: true, form: 'merged' };
|
|
159
|
+
}
|
|
160
|
+
const { line, form } = newRuleLine(target, indent);
|
|
161
|
+
lines.push(line);
|
|
162
|
+
const blockText = `minimumReleaseAgeExclude:${eol}${lines.join(eol)}${eol}`;
|
|
163
|
+
return { yaml: yaml.replace(BLOCK_RE, () => blockText), changed: true, form };
|
|
164
|
+
}
|
|
165
|
+
export function parseNpmSpec(spec) {
|
|
166
|
+
let text = spec.trim();
|
|
167
|
+
if (text.length >= 2 && ((text.startsWith("'") && text.endsWith("'")) || (text.startsWith('"') && text.endsWith('"')))) {
|
|
168
|
+
text = text.slice(1, -1);
|
|
169
|
+
}
|
|
170
|
+
if (text === '' || text.includes(':'))
|
|
171
|
+
return null;
|
|
172
|
+
const at = text.startsWith('@') ? text.indexOf('@', 1) : text.indexOf('@');
|
|
173
|
+
if (at <= 0)
|
|
174
|
+
return null;
|
|
175
|
+
const pkg = text.slice(0, at);
|
|
176
|
+
const version = text.slice(at + 1);
|
|
177
|
+
if (pkg === '' || version === '' || !isExactVersion(version))
|
|
178
|
+
return null;
|
|
179
|
+
return { pkg, version };
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* 委派前治理:读-改-写全程持官方同款锁(`withFileLock(<profile>/package.json)`,
|
|
183
|
+
* toggle 同先例),changed 才原子写。fail-open:任何失败返回结构化 reason,不抛——
|
|
184
|
+
* pnpm 是最终执行者。锁只串行持锁写者,不防不持锁的 pnpm 追加(残余风险见 ADR-0009)。
|
|
185
|
+
*/
|
|
186
|
+
export async function governExcludeBlock(profileDir, deps = {}) {
|
|
187
|
+
if (!profileDir)
|
|
188
|
+
return { ok: true, changed: false, reason: 'no-dir' };
|
|
189
|
+
const doRead = deps.readFile ?? ((path) => readFileFs(path, 'utf8'));
|
|
190
|
+
const doWrite = deps.writeFile ??
|
|
191
|
+
(async (path, text) => {
|
|
192
|
+
await writeFileAtomic(path, text, { mode: 0o600 });
|
|
193
|
+
});
|
|
194
|
+
const file = join(profileDir, WS_FILE);
|
|
195
|
+
try {
|
|
196
|
+
return await withFileLock(join(profileDir, 'package.json'), async () => {
|
|
197
|
+
let yaml;
|
|
198
|
+
try {
|
|
199
|
+
yaml = await doRead(file);
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
return { ok: false, changed: false, reason: 'no-file' };
|
|
203
|
+
}
|
|
204
|
+
if (!KEY_ANY_FORM_RE.test(yaml))
|
|
205
|
+
return { ok: true, changed: false, reason: 'no-block' };
|
|
206
|
+
const merged = mergeExcludeRules(yaml);
|
|
207
|
+
if (merged === null)
|
|
208
|
+
return { ok: false, changed: false, reason: 'unparseable' };
|
|
209
|
+
if (!merged.changed)
|
|
210
|
+
return { ok: true, changed: false, reason: 'clean' };
|
|
211
|
+
await doWrite(file, merged.yaml);
|
|
212
|
+
return { ok: true, changed: true, mergedNames: merged.mergedNames };
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
return { ok: false, changed: false, reason: 'write-failed' };
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* 成功后登记:仅对发布时刻可判「窗口内」(显式 age 值优先,缺省 1440 分钟)的目标
|
|
221
|
+
* 触发;时刻不可得一律不登记(不发明)。本地检查(文件缺席 → no-file)先于 registry
|
|
222
|
+
* 探测——缺省绑定下无块文件零网络。与治理同锁同 fail-open 纪律。
|
|
223
|
+
*/
|
|
224
|
+
export async function registerExclusion(profileDir, target, deps = {}) {
|
|
225
|
+
if (!profileDir)
|
|
226
|
+
return { applied: false, reason: 'no-dir' };
|
|
227
|
+
const doRead = deps.readFile ?? ((path) => readFileFs(path, 'utf8'));
|
|
228
|
+
const doWrite = deps.writeFile ??
|
|
229
|
+
(async (path, text) => {
|
|
230
|
+
await writeFileAtomic(path, text, { mode: 0o600 });
|
|
231
|
+
});
|
|
232
|
+
const file = join(profileDir, WS_FILE);
|
|
233
|
+
try {
|
|
234
|
+
return await withFileLock(join(profileDir, 'package.json'), async () => {
|
|
235
|
+
// 本地检查先行:文件缺席直接 no-file(不发 registry 探测——缺省绑定下测试离线可跑)
|
|
236
|
+
let yaml;
|
|
237
|
+
try {
|
|
238
|
+
yaml = await doRead(file);
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
return { applied: false, reason: 'no-file' };
|
|
242
|
+
}
|
|
243
|
+
const times = await (deps.packumentTimes ?? cachedPackumentTimes)(target.pkg, 12_000);
|
|
244
|
+
const now = deps.nowMs ?? Date.now();
|
|
245
|
+
const publishedAt = times ? Date.parse(times[target.version] ?? '') : NaN;
|
|
246
|
+
if (!Number.isFinite(publishedAt))
|
|
247
|
+
return { applied: false, reason: 'not-young' };
|
|
248
|
+
const policy = await readWorkspacePolicy(profileDir);
|
|
249
|
+
const windowMin = policy?.minimumReleaseAgeMin ?? DEFAULT_MINIMUM_RELEASE_AGE_MIN;
|
|
250
|
+
if (now >= publishedAt + windowMin * 60_000)
|
|
251
|
+
return { applied: false, reason: 'not-young' };
|
|
252
|
+
const next = registerExclusionInYaml(yaml, target);
|
|
253
|
+
if (next === null)
|
|
254
|
+
return { applied: false, reason: 'unparseable' };
|
|
255
|
+
if (!next.changed)
|
|
256
|
+
return { applied: false, reason: 'up-to-date', form: next.form };
|
|
257
|
+
await doWrite(file, next.yaml);
|
|
258
|
+
return { applied: true, form: next.form };
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
return { applied: false, reason: 'write-failed' };
|
|
263
|
+
}
|
|
264
|
+
}
|
package/lib/core/market.js
CHANGED
|
@@ -455,6 +455,7 @@ export async function listMarket(cfg = {}, opts = {}, deps = {}) {
|
|
|
455
455
|
offset: 0,
|
|
456
456
|
limit: maxLimit,
|
|
457
457
|
categoryCounts: zeroCounts(),
|
|
458
|
+
sourceCounts: { primary: 0, community: 0 },
|
|
458
459
|
registryState: timeoutRegistryState(cfg),
|
|
459
460
|
installedComplete: false,
|
|
460
461
|
latestComplete: false,
|
|
@@ -509,6 +510,9 @@ export async function listMarket(cfg = {}, opts = {}, deps = {}) {
|
|
|
509
510
|
.map((s) => s.entry)
|
|
510
511
|
: afterSort;
|
|
511
512
|
const total = ordered.length;
|
|
513
|
+
// sourceCounts 分桶(0.9.25 跨区搜索摘要行):filtered(分区 ∩ query ∩ category)计数,与排序/分页无关
|
|
514
|
+
const communityHitCount = filtered.reduce((n, entry) => n + (isCommunityEntry(entry) ? 1 : 0), 0);
|
|
515
|
+
const sourceCounts = { primary: filtered.length - communityHitCount, community: communityHitCount };
|
|
512
516
|
const limit = clampLimit(opts.limit, maxLimit);
|
|
513
517
|
let offset = normalizeOffset(opts.offset);
|
|
514
518
|
if (total > 0 && offset >= total)
|
|
@@ -584,6 +588,7 @@ export async function listMarket(cfg = {}, opts = {}, deps = {}) {
|
|
|
584
588
|
offset,
|
|
585
589
|
limit,
|
|
586
590
|
categoryCounts: counts,
|
|
591
|
+
sourceCounts,
|
|
587
592
|
registryState,
|
|
588
593
|
installedComplete,
|
|
589
594
|
latestComplete,
|
package/lib/core/profile-ops.js
CHANGED
|
@@ -31,6 +31,7 @@ import { npmLatest as defaultNpmLatest, githubLatestTag as defaultGithubLatestTa
|
|
|
31
31
|
import { classifyUpgradeActivation } from './activation.js';
|
|
32
32
|
import { togglePlugin, ToggleError } from './toggle.js';
|
|
33
33
|
import { describeReleaseAgeFailure, releaseAgePrecheck } from './release-age.js';
|
|
34
|
+
import { governExcludeBlock, registerExclusion } from './exclude-governance.js';
|
|
34
35
|
// 0.9.19:等待期失败翻译移入 release-age.ts(describeReleaseAgeFailure)——全量违规解析 +
|
|
35
36
|
// 目标/旁包分述。0.9.10 版只取第一条违规并把旁包名字当成本次目标、给「改用 DSH Web」的
|
|
36
37
|
// 失配指引(本机 quota-watch 0.1.15 事件实证:被拦的是旁包 dsh-m@0.9.18,文案却让用户等
|
|
@@ -121,9 +122,23 @@ async function desktopInstallLocked(id, cfg, opts, deps) {
|
|
|
121
122
|
else {
|
|
122
123
|
throw new DesktopOpsError('install-refused', `收录条目 ${entry.id} 缺少 npm/github 来源,无法在 desktop 安装`);
|
|
123
124
|
}
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
|
|
125
|
+
// ADR-0009:委派前治理(排除块规范形态,fail-open)+ 供应链等待期预检收敛——默认策略
|
|
126
|
+
// 放行并附陈述(官方管理器对显式点名的新版本直接安装并自动登记);显式设置 age 或
|
|
127
|
+
// strict 开启才拒绝。治理已落盘的编辑在成功 output 与失败 message 双路留痕。
|
|
128
|
+
let governanceNote = '';
|
|
129
|
+
let releaseAgeNotice = '';
|
|
130
|
+
let governedMergedNames;
|
|
131
|
+
if (entry.source === 'npm' && version !== undefined && opts.profileDir) {
|
|
132
|
+
try {
|
|
133
|
+
const gov = await (deps.governExclude ?? governExcludeBlock)(opts.profileDir);
|
|
134
|
+
if (gov.changed && gov.mergedNames?.length) {
|
|
135
|
+
governanceNote = `;排除条目治理:合并 ${gov.mergedNames.join('、')}`;
|
|
136
|
+
governedMergedNames = gov.mergedNames;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
// fail-open:治理失败不阻塞委派(pnpm 仍是最终执行者)
|
|
141
|
+
}
|
|
127
142
|
const gate = await releaseAgePrecheck({
|
|
128
143
|
pkg,
|
|
129
144
|
version,
|
|
@@ -132,20 +147,35 @@ async function desktopInstallLocked(id, cfg, opts, deps) {
|
|
|
132
147
|
signal: opts.signal,
|
|
133
148
|
deps: { packumentTimes: deps.packumentTimes, workspacePolicy: deps.workspacePolicy },
|
|
134
149
|
});
|
|
135
|
-
if (gate.blocked)
|
|
136
|
-
throw new DesktopOpsError('release-age-wait', gate.message, { blockers: gate.blockers });
|
|
150
|
+
if (gate.blocked) {
|
|
151
|
+
throw new DesktopOpsError('release-age-wait', gate.message + governanceNote, { blockers: gate.blockers, ...(governedMergedNames !== undefined ? { governanceMerged: governedMergedNames } : {}) });
|
|
152
|
+
}
|
|
153
|
+
if (gate.notice)
|
|
154
|
+
releaseAgeNotice = `;${gate.notice}`;
|
|
137
155
|
}
|
|
138
156
|
let change;
|
|
139
157
|
let bundleName;
|
|
158
|
+
let registerNote;
|
|
159
|
+
let managed;
|
|
140
160
|
try {
|
|
141
|
-
|
|
161
|
+
managed = await runManagedInstall(service, { pkg, spec, version, profileDir: opts.profileDir, approvedBuilds: opts.approvedBuilds, listInstalled: deps.listInstalled }, { governExclude: deps.governExclude, registerExclude: deps.registerExclude, packumentTimes: deps.packumentTimes });
|
|
162
|
+
change = managed.change;
|
|
163
|
+
bundleName = managed.bundleName;
|
|
164
|
+
registerNote = managed.registerNote;
|
|
142
165
|
}
|
|
143
166
|
catch (err) {
|
|
144
167
|
if (err instanceof DesktopBuildApprovalNeeded) {
|
|
145
|
-
return { ok: false, needsBuildApproval: true, id: entry.id, pkg, spec, pendingBuilds: err.pendingBuilds, message: err.message };
|
|
168
|
+
return { ok: false, needsBuildApproval: true, id: entry.id, pkg, spec, pendingBuilds: err.pendingBuilds, message: err.message + governanceNote };
|
|
169
|
+
}
|
|
170
|
+
if (err instanceof Error) {
|
|
171
|
+
if (governanceNote !== '' && !err.message.includes(governanceNote))
|
|
172
|
+
err.message += governanceNote;
|
|
173
|
+
if (managed?.governRetryNote !== undefined && !err.message.includes(managed.governRetryNote))
|
|
174
|
+
err.message += managed.governRetryNote;
|
|
146
175
|
}
|
|
147
176
|
throw err;
|
|
148
177
|
}
|
|
178
|
+
const retryNote = managed.governRetryNote !== undefined && !governanceNote.includes(managed.governRetryNote) ? managed.governRetryNote : '';
|
|
149
179
|
return {
|
|
150
180
|
id: entry.id,
|
|
151
181
|
pkg,
|
|
@@ -158,7 +188,7 @@ async function desktopInstallLocked(id, cfg, opts, deps) {
|
|
|
158
188
|
...(compat !== null ? { compat } : {}),
|
|
159
189
|
...(compatSkipped ? { compatSkipped } : {}),
|
|
160
190
|
needsRestart: true,
|
|
161
|
-
output: `official pluginManager: application=${change.application ?? 'applied'}, bundle=${bundleName}${change.changed === false ? ', changed=false' : ''}`,
|
|
191
|
+
output: `official pluginManager: application=${change.application ?? 'applied'}, bundle=${bundleName}${change.changed === false ? ', changed=false' : ''}${governanceNote}${retryNote}${releaseAgeNotice}${registerNote ?? ''}`,
|
|
162
192
|
via: 'desktop-manager',
|
|
163
193
|
...(change.application === 'overridden' ? { overridden: true } : {}),
|
|
164
194
|
};
|
|
@@ -208,7 +238,17 @@ function enableStageFailureMessage(pkg, change) {
|
|
|
208
238
|
}
|
|
209
239
|
return `安装后启用阶段失败(${pkg};application=failed、stage=enable${change.error?.code ? `、error=${change.error.code}` : ''}):${writtenText},请在官方 Desktop 插件页查看状态或重试`;
|
|
210
240
|
}
|
|
211
|
-
|
|
241
|
+
const DUAL_CODE_RE = /ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION|ERR_PNPM_NO_MATURE_MATCHING_VERSION/;
|
|
242
|
+
/** 可治理的失败形态:cancelled / build-blocked 不治理不重试(与 mapFailure 分类一致)。 */
|
|
243
|
+
function isGovableFailure(change) {
|
|
244
|
+
if (change.application === 'cancelled')
|
|
245
|
+
return false;
|
|
246
|
+
const pending = Array.isArray(change.pendingBuilds) ? change.pendingBuilds.filter((n) => typeof n === 'string') : [];
|
|
247
|
+
if (change.packageResult?.kind === 'build-blocked' && pending.length > 0)
|
|
248
|
+
return false;
|
|
249
|
+
return change.application === 'failed' || change.error !== undefined;
|
|
250
|
+
}
|
|
251
|
+
async function runManagedInstall(service, ctx, deps = {}) {
|
|
212
252
|
const installOnce = async () => changeOf(await service.installBundle(ctx.spec, {
|
|
213
253
|
enabled: true,
|
|
214
254
|
...(ctx.approvedBuilds && ctx.approvedBuilds.length > 0 ? { approvedBuilds: ctx.approvedBuilds } : {}),
|
|
@@ -276,9 +316,46 @@ async function runManagedInstall(service, ctx) {
|
|
|
276
316
|
await sleepMs(enableRetryDelayMs);
|
|
277
317
|
change = await installOnce();
|
|
278
318
|
}
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
319
|
+
// ADR-0009 挂点3:双码失败 → 治理 → changed 则重试该命令一次(每次调用至多一次;
|
|
320
|
+
// cancelled / build-blocked 不治理不重试)。仍败走既有 mapFailure(含账实分裂复读);
|
|
321
|
+
// 挂点3 已落盘的合并以短语并入失败 message 与成功 output(留痕契约含挂点3)。
|
|
322
|
+
let governRetryNote;
|
|
323
|
+
if (isGovableFailure(change) && DUAL_CODE_RE.test(`${change.error?.diagnostic ?? ''}\n${change.error?.code ?? ''}`)) {
|
|
324
|
+
const gov = await (deps.governExclude ?? governExcludeBlock)(ctx.profileDir ?? '');
|
|
325
|
+
if (gov.changed) {
|
|
326
|
+
if (gov.mergedNames?.length)
|
|
327
|
+
governRetryNote = `;排除条目治理:合并 ${gov.mergedNames.join('、')}`;
|
|
328
|
+
change = await installOnce();
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
if (change.application === 'failed' || change.application === 'cancelled' || change.error) {
|
|
332
|
+
try {
|
|
333
|
+
await mapFailure(change);
|
|
334
|
+
}
|
|
335
|
+
catch (err) {
|
|
336
|
+
if (err instanceof Error && governRetryNote !== undefined && !err.message.includes(governRetryNote))
|
|
337
|
+
err.message += governRetryNote;
|
|
338
|
+
throw err;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
const verified = await verifyAndBundle(change);
|
|
342
|
+
// ADR-0009 挂点2:成功后登记(仅 npm 源窗口内目标;fail-open,applied 短语由调用方并入 output)
|
|
343
|
+
let registerNote;
|
|
344
|
+
if (ctx.profileDir && ctx.version) {
|
|
345
|
+
try {
|
|
346
|
+
const reg = await (deps.registerExclude ?? registerExclusion)(ctx.profileDir, {
|
|
347
|
+
pkg: ctx.pkg,
|
|
348
|
+
version: ctx.version,
|
|
349
|
+
...(ctx.previousVersion !== undefined ? { previousVersion: ctx.previousVersion } : {}),
|
|
350
|
+
}, deps.packumentTimes ? { packumentTimes: deps.packumentTimes } : {});
|
|
351
|
+
if (reg.applied && reg.form)
|
|
352
|
+
registerNote = `;排除条目已登记 ${ctx.pkg}@${ctx.version}(${reg.form})`;
|
|
353
|
+
}
|
|
354
|
+
catch {
|
|
355
|
+
// fail-open:登记失败不影响安装/升级成功态
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return { ...verified, ...(registerNote !== undefined || governRetryNote !== undefined ? { registerNote, governRetryNote } : {}) };
|
|
282
359
|
}
|
|
283
360
|
/**
|
|
284
361
|
* Desktop 卸载(0.9.8,dsh-market 同策略):removeBundle 委派 + ChangeResult 判定 +
|
|
@@ -383,8 +460,21 @@ async function desktopUpgradeLocked(pkg, cfg, opts, deps) {
|
|
|
383
460
|
else {
|
|
384
461
|
throw new DesktopOpsError('install-refused', `收录条目 ${entry.id} 缺少 npm/github 来源,无法在 desktop 升级`);
|
|
385
462
|
}
|
|
386
|
-
//
|
|
387
|
-
|
|
463
|
+
// ADR-0009:委派前治理 + 预检收敛(同 desktopInstallLocked;0:15 quota-watch 事件为治理前的旧形态实证)
|
|
464
|
+
let governanceNote = '';
|
|
465
|
+
let releaseAgeNotice = '';
|
|
466
|
+
let governedMergedNames;
|
|
467
|
+
if (entry.source === 'npm' && version !== undefined && opts.profileDir) {
|
|
468
|
+
try {
|
|
469
|
+
const gov = await (deps.governExclude ?? governExcludeBlock)(opts.profileDir);
|
|
470
|
+
if (gov.changed && gov.mergedNames?.length) {
|
|
471
|
+
governanceNote = `;排除条目治理:合并 ${gov.mergedNames.join('、')}`;
|
|
472
|
+
governedMergedNames = gov.mergedNames;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
catch {
|
|
476
|
+
// fail-open:治理失败不阻塞委派
|
|
477
|
+
}
|
|
388
478
|
const gate = await releaseAgePrecheck({
|
|
389
479
|
pkg,
|
|
390
480
|
version,
|
|
@@ -393,20 +483,35 @@ async function desktopUpgradeLocked(pkg, cfg, opts, deps) {
|
|
|
393
483
|
signal: opts.signal,
|
|
394
484
|
deps: { packumentTimes: deps.packumentTimes, workspacePolicy: deps.workspacePolicy },
|
|
395
485
|
});
|
|
396
|
-
if (gate.blocked)
|
|
397
|
-
throw new DesktopOpsError('release-age-wait', gate.message, { blockers: gate.blockers });
|
|
486
|
+
if (gate.blocked) {
|
|
487
|
+
throw new DesktopOpsError('release-age-wait', gate.message + governanceNote, { blockers: gate.blockers, ...(governedMergedNames !== undefined ? { governanceMerged: governedMergedNames } : {}) });
|
|
488
|
+
}
|
|
489
|
+
if (gate.notice)
|
|
490
|
+
releaseAgeNotice = `;${gate.notice}`;
|
|
398
491
|
}
|
|
399
492
|
let change;
|
|
400
493
|
let bundleName;
|
|
494
|
+
let registerNote;
|
|
495
|
+
let managed;
|
|
401
496
|
try {
|
|
402
|
-
|
|
497
|
+
managed = await runManagedInstall(service, { pkg, spec, version, previousVersion: target.version, profileDir: opts.profileDir, approvedBuilds: opts.approvedBuilds, listInstalled: deps.listInstalled }, { governExclude: deps.governExclude, registerExclude: deps.registerExclude, packumentTimes: deps.packumentTimes });
|
|
498
|
+
change = managed.change;
|
|
499
|
+
bundleName = managed.bundleName;
|
|
500
|
+
registerNote = managed.registerNote;
|
|
403
501
|
}
|
|
404
502
|
catch (err) {
|
|
405
503
|
if (err instanceof DesktopBuildApprovalNeeded) {
|
|
406
|
-
return { ok: false, needsBuildApproval: true, id: entry.id, pkg, spec, pendingBuilds: err.pendingBuilds, message: err.message };
|
|
504
|
+
return { ok: false, needsBuildApproval: true, id: entry.id, pkg, spec, pendingBuilds: err.pendingBuilds, message: err.message + governanceNote };
|
|
505
|
+
}
|
|
506
|
+
if (err instanceof Error) {
|
|
507
|
+
if (governanceNote !== '' && !err.message.includes(governanceNote))
|
|
508
|
+
err.message += governanceNote;
|
|
509
|
+
if (managed?.governRetryNote !== undefined && !err.message.includes(managed.governRetryNote))
|
|
510
|
+
err.message += managed.governRetryNote;
|
|
407
511
|
}
|
|
408
512
|
throw err;
|
|
409
513
|
}
|
|
514
|
+
const retryNote = managed.governRetryNote !== undefined && !governanceNote.includes(managed.governRetryNote) ? managed.governRetryNote : '';
|
|
410
515
|
// 0.9.22 生效判定:npm 源升级在成功点分类;任何异常 fail-open 到 unknown,绝不影响升级成功态
|
|
411
516
|
let activation;
|
|
412
517
|
let needsRestart = true;
|
|
@@ -432,7 +537,7 @@ async function desktopUpgradeLocked(pkg, cfg, opts, deps) {
|
|
|
432
537
|
...(compat !== null ? { compat } : {}),
|
|
433
538
|
...(compatSkipped ? { compatSkipped } : {}),
|
|
434
539
|
needsRestart,
|
|
435
|
-
output: `official pluginManager: application=${change.application ?? 'applied'}, bundle=${bundleName}${change.changed === false ? ', changed=false' : ''}`,
|
|
540
|
+
output: `official pluginManager: application=${change.application ?? 'applied'}, bundle=${bundleName}${change.changed === false ? ', changed=false' : ''}${governanceNote}${retryNote}${releaseAgeNotice}${registerNote ?? ''}`,
|
|
436
541
|
via: 'desktop-manager',
|
|
437
542
|
...(change.application === 'overridden' ? { overridden: true } : {}),
|
|
438
543
|
fromVersion: target.version,
|
package/lib/core/registry.js
CHANGED
|
@@ -825,3 +825,14 @@ export async function loadDefaultRegistry(cfg = {}, opts = {}) {
|
|
|
825
825
|
}
|
|
826
826
|
return loadDefaultChain(cfg, opts, { namespace, profile, includeBundled: true });
|
|
827
827
|
}
|
|
828
|
+
/**
|
|
829
|
+
* verified 污染检测(know-how 022):`verified` 只记 **DSH 运行时**装机实测版本;
|
|
830
|
+
* 历史上反复出现把**插件自身版本号**误填进 verified 的事故(实证:dsh-quota-watch
|
|
831
|
+
* 条目曾出现 "0.1.0"——那是 quota-watch 的插件版本,不是任何 DSH 运行时版本)。
|
|
832
|
+
* 把条目 verified 与该插件自身 npm 已发布版本求交集,非空即高度可疑(warn 不 fail:
|
|
833
|
+
* 极小概率某代 DSH 与插件版本字面重合,人工复核后放行)。
|
|
834
|
+
*/
|
|
835
|
+
export function verifiedPollution(verified, publishedVersions) {
|
|
836
|
+
const own = new Set(publishedVersions);
|
|
837
|
+
return (verified ?? []).filter((v) => own.has(v));
|
|
838
|
+
}
|