dsh-m 0.2.8 → 0.2.10

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.
@@ -201,6 +201,46 @@ function syncProgress(tracker) {
201
201
  if (snap.error !== null)
202
202
  progress.error = snap.error;
203
203
  }
204
+ export const PNPM_OUTCOME_CODES = {
205
+ CONFIG_MISMATCH: 'ERR_PNPM_LOCKFILE_CONFIG_MISMATCH',
206
+ OUTDATED_LOCKFILE: 'ERR_PNPM_OUTDATED_LOCKFILE',
207
+ NO_MATCHING_VERSION: 'ERR_PNPM_NO_MATCHING_VERSION',
208
+ UNUSED_PATCH: 'ERR_PNPM_UNUSED_PATCH',
209
+ PUBLIC_HOIST_PATTERN_DIFF: 'ERR_PNPM_PUBLIC_HOIST_PATTERN_DIFF',
210
+ };
211
+ /**
212
+ * 对 pnpm/dsh 原始输出文本做六类归一解释。分类发生在任何文案改写之前;
213
+ * 上层(事务/market)只消费 class + code 常量,永不 regex 原始输出。
214
+ */
215
+ export function classifyPnpmError(text) {
216
+ const raw = String(text ?? '');
217
+ if (raw.includes(PNPM_OUTCOME_CODES.NO_MATCHING_VERSION)) {
218
+ return { class: 'retryable-lag', code: PNPM_OUTCOME_CODES.NO_MATCHING_VERSION };
219
+ }
220
+ if (raw.includes(PNPM_OUTCOME_CODES.CONFIG_MISMATCH)) {
221
+ return { class: 'config-drift', code: PNPM_OUTCOME_CODES.CONFIG_MISMATCH };
222
+ }
223
+ if (raw.includes(PNPM_OUTCOME_CODES.OUTDATED_LOCKFILE)) {
224
+ return { class: 'config-drift', code: PNPM_OUTCOME_CODES.OUTDATED_LOCKFILE };
225
+ }
226
+ if (raw.includes(PNPM_OUTCOME_CODES.UNUSED_PATCH)) {
227
+ return { class: 'unused-patch', code: PNPM_OUTCOME_CODES.UNUSED_PATCH };
228
+ }
229
+ if (isPrepareBlocked(raw))
230
+ return { class: 'needs-builds' };
231
+ const m = /(ERR_PNPM_[A-Z0-9_]+)/.exec(raw);
232
+ return { class: 'hard-fail', code: m?.[1] };
233
+ }
234
+ /** 统一的命令取消错误形态(runCommand 调用前已取消与运行中取消同款)。 */
235
+ function commandAbortError() {
236
+ const err = new Error('命令已取消');
237
+ err.name = 'AbortError';
238
+ return err;
239
+ }
240
+ function truncateOutput(text) {
241
+ const raw = String(text ?? '');
242
+ return raw.length <= 800 ? raw : raw.slice(-800);
243
+ }
204
244
  export function webProfileName() {
205
245
  return WEB_PROFILE;
206
246
  }
@@ -274,19 +314,6 @@ function writeDangerouslyAllowAllBuilds(profileDirectory) {
274
314
  writeFileSync(file, next);
275
315
  return true;
276
316
  }
277
- export function rewritePnpmError(err) {
278
- const text = err instanceof Error ? err.message : String(err);
279
- if (/ERR_PNPM_UNUSED_PATCH/.test(text)) {
280
- return new Error('profile 的补丁配置(patchedDependencies)里存在不再使用的条目,pnpm 拒绝执行。卸载时 dsh-m 会自动摘除目标包自己的补丁条目;仍报此错通常是其他包留有失效补丁,请手工清理 profile 的 pnpm-workspace.yaml。');
281
- }
282
- if (isPrepareBlocked(text)) {
283
- return new Error('该插件需要执行构建脚本(prepare),pnpm 默认拦截。dsh-m 已写入 profile 的 dangerouslyAllowAllBuilds 并重试;若仍失败请检查 web profile 是否可写。');
284
- }
285
- if (/ERR_PNPM_PUBLIC_HOIST_PATTERN_DIFF/.test(text)) {
286
- return new Error('当前 profile 的 node_modules 由不同主版本的 pnpm 生成,安装前需要先重建依赖。');
287
- }
288
- return err instanceof Error ? err : new Error(text);
289
- }
290
317
  /**
291
318
  * 失败摘要:命令失败时从完整输出里提取可诊断的行,而不是盲取末尾。
292
319
  * 2026-09-05 实证(dsh-better-sidebar 安装失败):pnpm ndjson 错误行 ~1.2KB,
@@ -327,8 +354,33 @@ export function errorDigest(out, maxChars = 800) {
327
354
  }
328
355
  return text.slice(-maxChars);
329
356
  }
357
+ /**
358
+ * 停止宽限(毫秒)归一:允许显式 0(立即 SIGKILL);负数/NaN/Infinity/垃圾值一律回落
359
+ * 缺省。Y1(第四轮复审):`Number(raw) || 5000` 会把 -1 当真值、把显式 0 吞成缺省。
360
+ */
361
+ const KILL_GRACE_DEFAULT_MS = 5_000;
362
+ function normalizeKillGrace(raw) {
363
+ const value = typeof raw === 'number' ? raw : Number(raw);
364
+ return Number.isFinite(value) && value >= 0 ? value : null;
365
+ }
366
+ export { normalizeKillGrace };
367
+ function killGraceDefault() {
368
+ const raw = process.env.DSH_KILL_GRACE_MS;
369
+ if (raw === undefined || raw.trim() === '')
370
+ return KILL_GRACE_DEFAULT_MS;
371
+ return normalizeKillGrace(raw) ?? KILL_GRACE_DEFAULT_MS;
372
+ }
373
+ /** SIGKILL 后组存活轮询的硬上限(毫秒):防 D 态进程导致永不 settle。 */
374
+ const GROUP_POLL_CAP_MS = 10_000;
330
375
  export async function runCommand(command, args, options) {
331
376
  return new Promise((resolvePromise, reject) => {
377
+ // R4a(终审复审):AbortSignal 的事件不会对后注册的 listener 重放——调用前已取消的
378
+ // signal 必须在 spawn 前拒绝,否则命令会完整执行副作用。检查与 listener 注册之间为
379
+ // 同步代码,无交织窗口。
380
+ if (options.signal?.aborted) {
381
+ reject(commandAbortError());
382
+ return;
383
+ }
332
384
  const child = spawn(command, args, {
333
385
  cwd: options.cwd,
334
386
  env: { ...process.env, ...options.env, CI: 'true' },
@@ -338,41 +390,89 @@ export async function runCommand(command, args, options) {
338
390
  });
339
391
  let out = '';
340
392
  let settled = false;
393
+ let stopping = false;
394
+ let pendingError;
395
+ let killTimer;
396
+ let pollTimer;
397
+ let pollDeadline = 0;
398
+ // 组语义仅 POSIX + detached(child 是组长)才成立;Windows/非 detached 只保证 direct child
399
+ const groupSupported = process.platform !== 'win32' && child.pid !== undefined;
400
+ const groupAlive = () => {
401
+ if (!groupSupported)
402
+ return false;
403
+ try {
404
+ process.kill(-child.pid, 0);
405
+ return true;
406
+ }
407
+ catch (err) {
408
+ // ESRCH = 组不存在;EPERM 等保守视为仍存活
409
+ return err.code !== 'ESRCH';
410
+ }
411
+ };
341
412
  const finish = (err) => {
342
413
  if (settled)
343
414
  return;
344
415
  settled = true;
345
416
  clearTimeout(timer);
417
+ if (killTimer !== undefined)
418
+ clearTimeout(killTimer);
419
+ if (pollTimer !== undefined)
420
+ clearTimeout(pollTimer);
346
421
  options.signal?.removeEventListener('abort', onAbort);
347
422
  if (err)
348
423
  reject(err);
349
424
  else
350
425
  resolvePromise(out);
351
426
  };
352
- const killChild = () => {
353
- if (process.platform !== 'win32' && child.pid !== undefined) {
427
+ const signalGroup = (sig) => {
428
+ if (groupSupported) {
354
429
  try {
355
- process.kill(-child.pid, 'SIGTERM');
430
+ process.kill(-child.pid, sig);
356
431
  return;
357
432
  }
358
433
  catch {
359
- /* fall through */
434
+ /* fall through:组可能已消失 */
360
435
  }
361
436
  }
362
437
  try {
363
- child.kill('SIGTERM');
438
+ child.kill(sig);
364
439
  }
365
440
  catch {
366
441
  /* already gone */
367
442
  }
368
443
  };
444
+ const pollGroupGone = () => {
445
+ if (settled)
446
+ return;
447
+ // SIGKILL 已发:组消失(或超过轮询硬上限,防 D 态进程)才 settle
448
+ if (!groupAlive() || Date.now() > pollDeadline) {
449
+ finish(pendingError);
450
+ return;
451
+ }
452
+ pollTimer = setTimeout(pollGroupGone, 25);
453
+ };
454
+ /**
455
+ * F1-R(第三轮复审):停止协议以「进程组不存在」为 settle 前提——SIGTERM 进程组 →
456
+ * 宽限后 SIGKILL → 轮询确认组消失。direct child 提前 close 不清除 kill 定时器
457
+ * (同组后代可能仍忽略 SIGTERM 并写 profile)。
458
+ */
459
+ const stopChild = (err) => {
460
+ if (settled || stopping)
461
+ return;
462
+ stopping = true;
463
+ pendingError = err;
464
+ signalGroup('SIGTERM');
465
+ killTimer = setTimeout(() => {
466
+ signalGroup('SIGKILL');
467
+ pollDeadline = Date.now() + GROUP_POLL_CAP_MS;
468
+ pollGroupGone();
469
+ }, normalizeKillGrace(options.killGraceMs) ?? killGraceDefault());
470
+ };
369
471
  const timer = setTimeout(() => {
370
- killChild();
371
- finish(new Error(`命令超时 ${options.timeoutMs}ms`));
472
+ stopChild(new Error(`命令超时 ${options.timeoutMs}ms`));
372
473
  }, options.timeoutMs);
373
474
  const onAbort = () => {
374
- killChild();
375
- finish(new Error('命令已取消'));
475
+ stopChild(commandAbortError());
376
476
  };
377
477
  options.signal?.addEventListener('abort', onAbort, { once: true });
378
478
  child.stdout?.on('data', (chunk) => {
@@ -385,8 +485,20 @@ export async function runCommand(command, args, options) {
385
485
  out = (out + text).slice(-256 * 1024);
386
486
  options.onChunk?.(text);
387
487
  });
388
- child.on('error', (err) => finish(err));
488
+ child.on('error', (err) => {
489
+ // F1-R:停止协议进行中,迟到的 error(如 kill 竞态)只作诊断,不得提前收口
490
+ if (stopping)
491
+ return;
492
+ finish(err);
493
+ });
389
494
  child.on('close', (code) => {
495
+ if (stopping) {
496
+ // direct child close ≠ 进程组停止:组仍存活 → 保留 SIGKILL 定时器/轮询,不 settle
497
+ if (groupAlive())
498
+ return;
499
+ finish(pendingError);
500
+ return;
501
+ }
390
502
  if (code === 0)
391
503
  finish();
392
504
  else
@@ -409,6 +521,7 @@ export async function runDshPlugin(profile, pluginArgs, deps = {}) {
409
521
  return await run(argv.file, [...argv.args, 'plugin', '--profile', profile, ...prepared], {
410
522
  cwd: argv.cwd,
411
523
  timeoutMs: deps.timeoutMs ?? installTimeoutMs(),
524
+ signal: deps.signal,
412
525
  env: { CI: 'true' },
413
526
  viaShell: argv.viaShell,
414
527
  detached: process.platform !== 'win32',
@@ -430,47 +543,127 @@ export async function runDshPlugin(profile, pluginArgs, deps = {}) {
430
543
  }
431
544
  }
432
545
  /**
433
- * 安装。返回 usedAllowAllBuilds 供 UI 明确报告「该插件执行了构建脚本」。
434
- * source 形如:`pkg@1.2.3`(npm 精确锁定)或 `github:owner/repo#sha`(锁 SHA)。
546
+ * 加装阶梯工厂(导出、可注入、可测试;生产与测试共用同一实现)。
547
+ * 阶梯:add → prepare 被拦时写 dangerouslyAllowAllBuilds 重试 →
548
+ * PUBLIC_HOIST_PATTERN_DIFF 时 `install --no-frozen-lockfile` 重建后重试 →
549
+ * 耗尽归类返回。对**原始错误文本**分类;永不 throw,一律返回 RunnerOutcome。
435
550
  */
436
- export async function addDshPlugin(source, deps = {}) {
437
- const run = deps.runDshPlugin ?? runDshPlugin;
551
+ export function makeAddViaLadder(deps) {
552
+ const run = deps.runDshPlugin;
438
553
  const allowAllBuilds = deps.allowAllBuilds ?? writeDangerouslyAllowAllBuilds;
439
- const retryAfterPrepare = async () => {
440
- const changed = allowAllBuilds(deps.profileDir ?? webProfileDir());
441
- try {
442
- return { output: await run(WEB_PROFILE, ['add', source]), usedAllowAllBuilds: true };
443
- }
444
- catch (retryErr) {
445
- throw rewritePnpmError(retryErr);
446
- }
447
- };
448
- try {
449
- return { output: await run(WEB_PROFILE, ['add', source]), usedAllowAllBuilds: false };
450
- }
451
- catch (err) {
452
- const text = err instanceof Error ? err.message : String(err);
453
- if (text.includes('ERR_PNPM_PUBLIC_HOIST_PATTERN_DIFF')) {
454
- await run(WEB_PROFILE, ['install', '--no-frozen-lockfile']);
554
+ const opts = (signal) => (signal !== undefined ? { signal } : undefined);
555
+ return async (source, profileDir, signal) => {
556
+ const retryAfterPrepare = async () => {
557
+ // Y2(终审复审):allowAll 写入本身失败也必须转换为结果,维持「永不 throw」契约
455
558
  try {
456
- return { output: await run(WEB_PROFILE, ['add', source]), usedAllowAllBuilds: false };
559
+ allowAllBuilds(profileDir);
560
+ }
561
+ catch (err) {
562
+ const text = errText(err);
563
+ return { class: 'hard-fail', output: truncateOutput(text) };
564
+ }
565
+ try {
566
+ const output = await run(WEB_PROFILE, ['add', source], opts(signal));
567
+ return { class: 'ok', output: truncateOutput(output), usedAllowAllBuilds: true };
457
568
  }
458
569
  catch (retryErr) {
459
- if (!isPrepareBlocked(retryErr instanceof Error ? retryErr.message : String(retryErr))) {
460
- throw rewritePnpmError(retryErr);
570
+ return { ...classifyPnpmError(errText(retryErr)), output: truncateOutput(errText(retryErr)) };
571
+ }
572
+ };
573
+ try {
574
+ const output = await run(WEB_PROFILE, ['add', source], opts(signal));
575
+ return { class: 'ok', output: truncateOutput(output), usedAllowAllBuilds: false };
576
+ }
577
+ catch (err) {
578
+ const text = errText(err);
579
+ if (text.includes(PNPM_OUTCOME_CODES.PUBLIC_HOIST_PATTERN_DIFF)) {
580
+ try {
581
+ await run(WEB_PROFILE, ['install', '--no-frozen-lockfile'], opts(signal));
582
+ }
583
+ catch (rebuildErr) {
584
+ const rebuildText = errText(rebuildErr);
585
+ return { ...classifyPnpmError(rebuildText), output: truncateOutput(rebuildText) };
461
586
  }
462
- return retryAfterPrepare();
587
+ try {
588
+ const output = await run(WEB_PROFILE, ['add', source], opts(signal));
589
+ return { class: 'ok', output: truncateOutput(output), usedAllowAllBuilds: false };
590
+ }
591
+ catch (retryErr) {
592
+ const retryText = errText(retryErr);
593
+ if (isPrepareBlocked(retryText))
594
+ return retryAfterPrepare();
595
+ return { ...classifyPnpmError(retryText), output: truncateOutput(retryText) };
596
+ }
597
+ }
598
+ if (!isPrepareBlocked(text)) {
599
+ return { ...classifyPnpmError(text), output: truncateOutput(text) };
463
600
  }
601
+ return retryAfterPrepare();
464
602
  }
465
- if (!isPrepareBlocked(text))
466
- throw rewritePnpmError(err);
467
- return retryAfterPrepare();
468
- }
603
+ };
604
+ }
605
+ function errText(err) {
606
+ return err instanceof Error ? err.message : String(err);
469
607
  }
470
608
  /** 卸载(转发 pnpm remove;调用方须先做 live-disable)。 */
471
609
  export async function removeDshPlugin(pkg, deps = {}) {
472
610
  if (!isSafePluginTarget(pkg))
473
611
  throw new Error(`无效插件包名: ${pkg}`);
474
612
  const run = deps.runDshPlugin ?? runDshPlugin;
475
- return run(WEB_PROFILE, ['remove', pkg]);
613
+ return run(WEB_PROFILE, ['remove', pkg], deps.profileDir !== undefined || deps.signal !== undefined
614
+ ? { profileDir: deps.profileDir, signal: deps.signal }
615
+ : undefined);
616
+ }
617
+ function rawOutcome(text) {
618
+ const out = String(text ?? '');
619
+ return { ...classifyPnpmError(out), output: out.length <= 800 ? out : out.slice(-800) };
620
+ }
621
+ /**
622
+ * 生产 runner:add 走 makeAddViaLadder(prepare/hoist 在途自愈在 adapter 内耗尽);
623
+ * remove 包 removeDshPlugin(原始文本分类);frozen/rebuild 直接 spawn pnpm。
624
+ * 四操作统一收 signal;output 截 800。
625
+ */
626
+ export function makeDshRunner(profileDir) {
627
+ const ladder = makeAddViaLadder({ runDshPlugin });
628
+ return {
629
+ async add(spec, signal) {
630
+ return ladder(spec, profileDir, signal);
631
+ },
632
+ async remove(pkg, signal) {
633
+ try {
634
+ const output = await removeDshPlugin(pkg, { profileDir, signal });
635
+ return { class: 'ok', output: output.length <= 800 ? output : output.slice(-800) };
636
+ }
637
+ catch (err) {
638
+ return rawOutcome(errText(err));
639
+ }
640
+ },
641
+ async frozenInstall(signal) {
642
+ try {
643
+ // F1-R:detached 使 pnpm(及其后代)进入独立进程组,abort/超时可整组终止
644
+ const output = await runCommand('pnpm', ['--dir', profileDir, 'install', '--frozen-lockfile'], {
645
+ timeoutMs: installTimeoutMs(),
646
+ signal,
647
+ detached: process.platform !== 'win32',
648
+ });
649
+ return { class: 'ok', output: output.length <= 800 ? output : output.slice(-800) };
650
+ }
651
+ catch (err) {
652
+ return rawOutcome(errText(err));
653
+ }
654
+ },
655
+ async rebuildInstall(signal) {
656
+ try {
657
+ const output = await runCommand('pnpm', ['--dir', profileDir, 'install', '--no-frozen-lockfile'], {
658
+ timeoutMs: installTimeoutMs(),
659
+ signal,
660
+ detached: process.platform !== 'win32',
661
+ });
662
+ return { class: 'ok', output: output.length <= 800 ? output : output.slice(-800) };
663
+ }
664
+ catch (err) {
665
+ return rawOutcome(errText(err));
666
+ }
667
+ },
668
+ };
476
669
  }
@@ -6,8 +6,9 @@
6
6
  * 顶层必须是非 null/非数组对象且有 method → `ping` 跳过 guard,否则
7
7
  * trustedRestartRequest host-equivalence guard → typed method/业务错误映射 → 其他 500。
8
8
  */
9
- import { BOOT_ID, addDshPlugin, publicInstallStatus } from './dsh-cli.js';
9
+ import { BOOT_ID, publicInstallStatus } from './dsh-cli.js';
10
10
  import { listInstalledWithMeta, listMarket, installFromRegistry, uninstallPlugin, upgradePlugin, } from './market.js';
11
+ import { runProfileTransaction, TransactionError, makeNpmWarmPackument } from './profile-transaction.js';
11
12
  import { readInstalledPluginReadme } from './installed.js';
12
13
  import { isNewerVersion, npmLatest } from './versions.js';
13
14
  import { RegistryConfigError } from './registry-controller.js';
@@ -129,6 +130,25 @@ function errorStatus(err) {
129
130
  }
130
131
  if (err instanceof ApiProtocolError)
131
132
  return { status: err.status, payload: { ok: false, error: err.message } };
133
+ if (err instanceof TransactionError) {
134
+ // detail 白名单投影:结构化事实,不含 raw output(GUI 只读 error,零改动)
135
+ const r = err.result;
136
+ return {
137
+ status: 500,
138
+ payload: {
139
+ ok: false,
140
+ error: err.message,
141
+ detail: {
142
+ status: r.status,
143
+ kind: r.kind,
144
+ failure: r.failure,
145
+ healActions: r.healActions,
146
+ snapshotRestoreVerified: r.snapshotRestoreVerified,
147
+ profileConverged: r.profileConverged,
148
+ },
149
+ },
150
+ };
151
+ }
132
152
  return { status: 500, payload: { ok: false, error: err instanceof Error ? err.message : String(err) } };
133
153
  }
134
154
  export function createApiDispatcher(ctx) {
@@ -140,6 +160,7 @@ export function createApiDispatcher(ctx) {
140
160
  upgradePlugin: ctx.deps?.upgradePlugin ?? upgradePlugin,
141
161
  checkRegistryEntries: ctx.deps?.checkRegistryEntries ?? checkRegistryEntries,
142
162
  npmLatest: ctx.deps?.npmLatest ?? npmLatest,
163
+ runTransaction: ctx.deps?.runTransaction ?? runProfileTransaction,
143
164
  };
144
165
  const cfg = () => ctx.controller.config;
145
166
  return async function handleApi(req, res) {
@@ -165,8 +186,19 @@ export function createApiDispatcher(ctx) {
165
186
  }
166
187
  case 'self-upgrade': {
167
188
  const latest = await d.npmLatest(ctx.pkg.name, cfg().timeoutMs ?? 20_000);
168
- const result = await ctx.onMutation(() => addDshPlugin(`${ctx.pkg.name}@${latest.version}`));
169
- payload = { pkg: ctx.pkg.name, version: latest.version, usedAllowAllBuilds: result.usedAllowAllBuilds, needsRestart: true };
189
+ // 缺 integrity 一律 fail closed,不进事务
190
+ if (!latest.integrity) {
191
+ throw new Error(`npm metadata 缺少 dist integrity:${ctx.pkg.name}@${latest.version},拒绝升级`);
192
+ }
193
+ const result = await d.runTransaction({ kind: 'install-npm', pkg: ctx.pkg.name, version: latest.version, integrity: latest.integrity, signal }, { warmPackument: makeNpmWarmPackument(cfg().timeoutMs ?? 20_000) });
194
+ if (!result.ok)
195
+ throw new TransactionError(result);
196
+ payload = {
197
+ pkg: ctx.pkg.name,
198
+ version: latest.version,
199
+ usedAllowAllBuilds: result.usedAllowAllBuilds === true,
200
+ needsRestart: true,
201
+ };
170
202
  break;
171
203
  }
172
204
  case 'registry': {
@@ -220,7 +252,7 @@ export function createApiDispatcher(ctx) {
220
252
  if (!id)
221
253
  throw new ApiProtocolError(400, '缺少 id');
222
254
  const version = typeof body.version === 'string' ? body.version : undefined;
223
- const result = await ctx.onMutation(() => d.installFromRegistry(id, cfg(), { version, namespace: 'host' }));
255
+ const result = await d.installFromRegistry(id, cfg(), { version, namespace: 'host', signal });
224
256
  payload = { ...result };
225
257
  break;
226
258
  }
@@ -228,7 +260,7 @@ export function createApiDispatcher(ctx) {
228
260
  const target = strArg(body, 'pkg');
229
261
  if (!target)
230
262
  throw new ApiProtocolError(400, '缺少 pkg');
231
- const result = await ctx.onMutation(() => d.uninstallPlugin(target, cfg(), { namespace: 'host' }));
263
+ const result = await d.uninstallPlugin(target, cfg(), { namespace: 'host', signal });
232
264
  payload = { ...result };
233
265
  break;
234
266
  }
@@ -236,7 +268,7 @@ export function createApiDispatcher(ctx) {
236
268
  const target = strArg(body, 'pkg');
237
269
  if (!target)
238
270
  throw new ApiProtocolError(400, '缺少 pkg');
239
- const result = await ctx.onMutation(() => d.upgradePlugin(target, cfg(), { namespace: 'host' }));
271
+ const result = await d.upgradePlugin(target, cfg(), { namespace: 'host', signal });
240
272
  payload = { ...result };
241
273
  break;
242
274
  }
@@ -1,10 +1,16 @@
1
1
  /**
2
2
  * 已装插件识别(DESIGN.md §3):profile 的 package.json 是唯一事实源,
3
3
  * 不引入额外状态文件。移植自 skillhub installed-plugins.ts(去 README 暂缓)。
4
+ *
5
+ * 完整性契约(2026-09-09):枚举结果必含 `complete`——只有当顶层 manifest 与每个
6
+ * 依赖的 package.json 都可读、可解析为非数组对象、且每项都能归类(DSH 插件 → items /
7
+ * 确认非 DSH → others)时才为 true;任一项「无法判断」即 complete:false(partial 结果
8
+ * 保留,该依赖不计入 others)。当前 complete 仅被 listMarket → dshm_search 消费;
9
+ * 已装列表(listInstalledWithMeta)/ dshm_list / CLI list·outdated / 升级路径的
10
+ * incomplete 展示与处理为后续独立任务。
4
11
  */
5
12
  import { open, readFile } from 'node:fs/promises';
6
13
  import { join, resolve } from 'node:path';
7
- import { isSafePluginTarget, removeDshPlugin } from './dsh-cli.js';
8
14
  import { webProfileDir } from './env.js';
9
15
  const PKG_NAME_RE = /^(@[A-Za-z0-9-*~][A-Za-z0-9-*._~]*\/)?[A-Za-z0-9-._~]+$/;
10
16
  export function isSafePkgName(raw) {
@@ -58,30 +64,74 @@ export function githubRepoFromRepository(raw) {
58
64
  const m = /github\.com[/:]([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+?)(?:\.git)?$/i.exec(url.trim());
59
65
  return m ? `${m[1]}/${m[2]}` : null;
60
66
  }
61
- export async function readPkgJson(dir) {
67
+ /** JSON 合法根:非 null、非数组的对象(typeof [] === 'object',数组必须显式排除)。 */
68
+ function isRecord(value) {
69
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
70
+ }
71
+ /** 单包 package.json 唯一读取实现:读不到 / 坏 JSON / 根非对象一律 ok:false。 */
72
+ async function readPkgJsonResult(dir) {
73
+ let raw;
62
74
  try {
63
- const raw = JSON.parse(await readFile(join(dir, 'package.json'), 'utf8'));
64
- return raw && typeof raw === 'object' ? raw : null;
75
+ raw = JSON.parse(await readFile(join(dir, 'package.json'), 'utf8'));
65
76
  }
66
77
  catch {
67
- return null;
78
+ return { ok: false };
68
79
  }
80
+ if (!isRecord(raw))
81
+ return { ok: false };
82
+ return { ok: true, value: raw };
69
83
  }
70
- export async function readProfileDeps(profileDir) {
84
+ export async function readPkgJson(dir) {
85
+ const result = await readPkgJsonResult(dir);
86
+ return result.ok ? result.value ?? null : null;
87
+ }
88
+ /** 空 deps 统一无原型容器:与逐项解析产物保持同一原型语义,继承属性不得伪装成依赖成员。 */
89
+ function emptyDeps() {
90
+ return Object.create(null);
91
+ }
92
+ /**
93
+ * 顶层 profile package.json 唯一读取实现(完整性 + partial 语义):
94
+ * - 读不到 / 坏 JSON / 根非数组对象 → complete:false, deps:{}(manifest 缺失 ≠ 合法空 profile);
95
+ * - 合法但无 dependencies 字段,或 dependencies 为空对象 → complete:true, deps:{}(唯一合法空形态);
96
+ * - dependencies 存在但类型非法(null/数组/标量)→ complete:false, deps:{};
97
+ * - 逐项:key 不安全(isSafePkgName 拒绝 `__proto__`、`_`/`.` 开头分段等)或 spec 非字符串/纯空白
98
+ * → 该项计入 incomplete(complete:false)并跳过,其余合法项保留。
99
+ * 容器为无原型对象(Object.create(null))——第二层防御:特殊属性名不受 Object.prototype
100
+ * setter/继承语义影响,数据结构与早退路径的空容器保持一致。
101
+ */
102
+ async function readProfileDepsResult(profileDir) {
103
+ let raw;
71
104
  try {
72
- const raw = JSON.parse(await readFile(join(profileDir, 'package.json'), 'utf8'));
73
- if (!raw || typeof raw !== 'object' || !raw.dependencies || typeof raw.dependencies !== 'object')
74
- return {};
75
- const out = {};
76
- for (const [name, spec] of Object.entries(raw.dependencies)) {
77
- if (typeof spec === 'string' && spec !== '')
78
- out[name] = spec;
79
- }
80
- return out;
105
+ raw = JSON.parse(await readFile(join(profileDir, 'package.json'), 'utf8'));
81
106
  }
82
107
  catch {
83
- return {};
108
+ return { complete: false, deps: emptyDeps() };
109
+ }
110
+ if (!isRecord(raw))
111
+ return { complete: false, deps: emptyDeps() };
112
+ if (!Object.prototype.hasOwnProperty.call(raw, 'dependencies'))
113
+ return { complete: true, deps: emptyDeps() };
114
+ const dependencies = raw.dependencies;
115
+ if (!isRecord(dependencies))
116
+ return { complete: false, deps: emptyDeps() };
117
+ let complete = true;
118
+ const deps = emptyDeps();
119
+ for (const [name, spec] of Object.entries(dependencies)) {
120
+ if (!isSafePkgName(name)) {
121
+ complete = false;
122
+ continue;
123
+ }
124
+ if (typeof spec !== 'string' || spec.trim() === '') {
125
+ complete = false;
126
+ continue;
127
+ }
128
+ deps[name] = spec;
84
129
  }
130
+ return { complete, deps };
131
+ }
132
+ /** 宽松读取(README 路径):只返回可确认的依赖项,个别非法项被跳过而不是整体丢弃。 */
133
+ export async function readProfileDeps(profileDir) {
134
+ return (await readProfileDepsResult(profileDir)).deps;
85
135
  }
86
136
  function sanitizePkgJson(raw, fallbackName) {
87
137
  return {
@@ -93,17 +143,31 @@ function sanitizePkgJson(raw, fallbackName) {
93
143
  githubRepo: githubRepoFromRepository(raw.repository),
94
144
  };
95
145
  }
96
- /** 枚举 web profile 已安装插件(只读)。 */
146
+ /** 枚举 web profile 已安装插件(只读)。complete:false 时 items/others 为 partial 结果(已确认部分保留)。 */
97
147
  export async function listInstalledPlugins(profileDir = webProfileDir()) {
98
148
  const root = resolve(profileDir);
99
- const deps = await readProfileDeps(root);
149
+ const result = await readProfileDepsResult(root);
150
+ let complete = result.complete;
151
+ const deps = result.deps;
100
152
  const items = [];
101
153
  let others = 0;
102
154
  for (const pkg of Object.keys(deps).sort()) {
103
155
  const spec = deps[pkg];
104
156
  const dir = resolvePluginDir(root, pkg, spec);
105
- const raw = dir ? await readPkgJson(dir) : null;
106
- if (!raw || !('dsh' in raw)) {
157
+ if (!dir) {
158
+ // 依赖键不安全、目录无法解析 → 无法判断(不冒充非 DSH)
159
+ complete = false;
160
+ continue;
161
+ }
162
+ const rawResult = await readPkgJsonResult(dir);
163
+ if (!rawResult.ok) {
164
+ // package.json 缺失/不可读/坏 JSON/根非对象 → 无法判断
165
+ complete = false;
166
+ continue;
167
+ }
168
+ const raw = rawResult.value;
169
+ if (!('dsh' in raw)) {
170
+ // 确认非 DSH 依赖
107
171
  others += 1;
108
172
  continue;
109
173
  }
@@ -121,23 +185,7 @@ export async function listInstalledPlugins(profileDir = webProfileDir()) {
121
185
  githubRepo: githubRepoFromRepository(raw.repository),
122
186
  });
123
187
  }
124
- return { items, others, profileDir: root };
125
- }
126
- /** 从 web profile 卸载已安装的 dsh 插件。pkg 必须来自 profile 依赖(先 live-disable,见 market.ts)。 */
127
- export async function removeInstalledPlugin(pkg, profileDir = webProfileDir(), deps = {}) {
128
- const key = String(pkg || '').trim();
129
- if (!isSafePkgName(key) || !isSafePluginTarget(key))
130
- throw new Error(`无效插件包名: ${pkg}`);
131
- const root = resolve(profileDir);
132
- const listed = await readProfileDeps(root);
133
- if (!(key in listed))
134
- throw new Error(`web profile 未安装该插件: ${key}`);
135
- const dir = resolvePluginDir(root, key, listed[key]);
136
- const raw = dir ? await readPkgJson(dir) : null;
137
- if (!raw || !('dsh' in raw))
138
- throw new Error(`不是 dsh 插件: ${key}`);
139
- await removeDshPlugin(key, deps);
140
- return { pkg: key };
188
+ return { items, others, complete, profileDir: root };
141
189
  }
142
190
  // ---------- README 预览(借鉴 skillhub,64KB 截断) ----------
143
191
  const README_MAX_BYTES = 64 * 1024;
@@ -170,8 +218,11 @@ export async function readInstalledPluginReadme(pkg, profileDir = webProfileDir(
170
218
  throw new Error(`无效插件包名: ${pkg}`);
171
219
  const root = resolve(profileDir);
172
220
  const deps = await readProfileDeps(root);
173
- if (!(key in deps))
221
+ // 授权边界必须用 own-property 判定:`in` 会沿原型链命中继承属性(如 'constructor'),
222
+ // 绕过「pkg 必须来自 profile dependencies」的成员约束
223
+ if (!Object.hasOwn(deps, key)) {
174
224
  throw new Error(`web profile 未安装该插件: ${key}`);
225
+ }
175
226
  const dir = resolvePluginDir(root, key, deps[key]);
176
227
  if (!dir)
177
228
  throw new Error(`无法解析插件目录: ${key}`);