dshmarket 1.66.22 → 1.66.24

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/client/client.js CHANGED
@@ -395,6 +395,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
395
395
  notesNpm: "版本发布时间",
396
396
  notesNone: "该插件未提供更新说明。",
397
397
  notesSource: "市场还没收录这个插件的更新说明。",
398
+ notesSourceUnreadable: "这个插件不是从 GitHub 装的,市场读不到它的改动记录。",
398
399
  notesSourceLink: "去仓库看改了什么",
399
400
  notesSourceNoLink: "可以问问插件作者改了什么。",
400
401
  notesLoadFail: "暂时读不到更新说明,稍后再试。",
@@ -557,6 +558,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
557
558
  marketUpdate: "更新插件市场",
558
559
  updateAll: "全部更新",
559
560
  heldReleaseNotice: "已安装,但不是最新版:这个 profile 的 minimumReleaseAge 挡住了 {0},安装的是 {1}。",
561
+ installedDependencyNote: "另有 {0} 个包是插件带进来的依赖,不是插件,未列在上方。",
560
562
  heldReleaseAction: "仍安装 {0}",
561
563
  ignoreUpdateNotice: "重启前不再提醒",
562
564
  ignoreAllUpdateNotices: "重启前都不再提醒",
@@ -588,6 +590,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
588
590
  webdavRestore: "从 WebDAV 恢复",
589
591
  autoBackup: "每天自动备份(打开市场时)",
590
592
  webdavNote: "WebDAV 地址与用户名仅保存在当前浏览器;密码只存于服务端,每次会话需重新输入。",
593
+ webdavPathHint: "填完整的文件地址(含文件名):备份写进这个文件,恢复也从它读。开启自动备份后每天最多上传一次。",
591
594
  localOnly: "WebDAV 地址和用户名只保存在此浏览器。",
592
595
  credsWarning: "请注意:备份包含 profile 配置,以及可能含密钥的文件(config.toml、.env 等)。本地导出的文件会原样包含这些内容,上传 WebDAV 前请确认目标可信。",
593
596
  gist: "GitHub Gist",
@@ -1111,6 +1114,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
1111
1114
  notesNpm: "Version publish times",
1112
1115
  notesNone: "This plugin does not publish update notes.",
1113
1116
  notesSource: "The market doesn't have update notes for this plugin yet.",
1117
+ notesSourceUnreadable: "This plugin was not installed from GitHub, so the market cannot read its change history.",
1114
1118
  notesSourceLink: "See what changed in the repository",
1115
1119
  notesSourceNoLink: "You can ask the plugin author what changed.",
1116
1120
  notesLoadFail: "Update notes are unavailable right now — try again later.",
@@ -1273,6 +1277,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
1273
1277
  marketUpdate: "Update the plugin market",
1274
1278
  updateAll: "Update all",
1275
1279
  heldReleaseNotice: "Installed, but not the newest: this profile's minimumReleaseAge held {0} back, so {1} was installed.",
1280
+ installedDependencyNote: "{0} more packages came in as dependencies of plugins — they are not plugins, so they are not listed above.",
1276
1281
  heldReleaseAction: "Install {0} anyway",
1277
1282
  ignoreUpdateNotice: "Don't remind until restart",
1278
1283
  ignoreAllUpdateNotices: "Don't remind any until restart",
@@ -1304,6 +1309,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
1304
1309
  webdavRestore: "Restore from WebDAV",
1305
1310
  autoBackup: "Back up daily (when the market opens)",
1306
1311
  webdavNote: "The WebDAV URL and username stay in this browser only; the password is kept server-side and must be re-entered each session.",
1312
+ webdavPathHint: "Give the whole file path, name included: the backup writes that file and restore reads it back. With automatic backup on it uploads at most once a day.",
1307
1313
  localOnly: "The WebDAV URL and username stay in this browser only.",
1308
1314
  credsWarning: "Note: backups include profile configuration and files that may hold secrets (config.toml, .env, …). Local exports are unmodified, so upload only to a WebDAV target you trust.",
1309
1315
  gist: "GitHub Gist",
@@ -8589,6 +8595,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
8589
8595
  const [restarting, setRestarting] = (0, react.useState)(false);
8590
8596
  const [showTop, setShowTop] = (0, react.useState)(false);
8591
8597
  const [backupBusy, setBackupBusy] = (0, react.useState)(false);
8598
+ const webdavInFlight = (0, react.useRef)(false);
8592
8599
  const [backupMessage, setBackupMessage] = (0, react.useState)(null);
8593
8600
  const [backupRestored, setBackupRestored] = (0, react.useState)(false);
8594
8601
  const [pendingBackup, setPendingBackup] = (0, react.useState)(null);
@@ -10641,7 +10648,19 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
10641
10648
  const reminderUpdatableNames = updatableNames.filter((name) => !quietUpdateSet.has(name) && !installedBlocked(name));
10642
10649
  const reminderBatchUpdatableNames = batchUpdatableNames.filter((name) => !quietUpdateSet.has(name) && !installedBlocked(name));
10643
10650
  const reminderUpdateNames = [...updates[selfName]?.updateAvailable === true && !updatedNames.includes(selfName) ? [selfName] : [], ...updatableNames].filter((name) => !quietUpdateSet.has(name) && !installedBlocked(name));
10644
- const installedOtherCount = Object.keys(installed).filter((name) => name !== selfName).length;
10651
+ /**
10652
+ * Installed packages that are not plugins at all: a plain library some
10653
+ * other plugin pulled in — zustand, bufferutil, a peer of ws (#634's
10654
+ * `dependencyOf`, #857). They were rendered as plugin cards with a
10655
+ * working uninstall, which is a real remove path for something a plugin
10656
+ * needs. Out of the list; the count stays visible in one quiet line, so the
10657
+ * list is not silently shorter than package.json.
10658
+ */
10659
+ const dependencyOnlyNames = (0, react.useMemo)(() => new Set(Object.keys(installed).filter((name) => {
10660
+ const act = activations[name];
10661
+ return act?.state === "inert" && act.dependencyOf !== void 0;
10662
+ })), [installed, activations]);
10663
+ const installedOtherCount = Object.keys(installed).filter((name) => name !== selfName && !dependencyOnlyNames.has(name)).length;
10645
10664
  const ignoreUpdateNotices = (0, react.useCallback)((names) => {
10646
10665
  if (bootId === null || names.length === 0) return;
10647
10666
  setIgnoredUpdateNames((current) => {
@@ -10729,7 +10748,8 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
10729
10748
  }).catch((error) => setBackupMessage(String(error))).finally(() => setBackupBusy(false));
10730
10749
  }, [finishRestore, pendingBackup]);
10731
10750
  const runWebdav = (0, react.useCallback)((action) => {
10732
- if (webdavUrl.trim() === "") return;
10751
+ if (webdavUrl.trim() === "" || webdavInFlight.current) return;
10752
+ webdavInFlight.current = true;
10733
10753
  setBackupBusy(true);
10734
10754
  setBackupMessage(null);
10735
10755
  setRestoreErrors([]);
@@ -10752,7 +10772,10 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
10752
10772
  } catch {}
10753
10773
  setBackupMessage(t("backupDone"));
10754
10774
  }
10755
- }).catch((error) => setBackupMessage(String(error))).finally(() => setBackupBusy(false));
10775
+ }).catch((error) => setBackupMessage(String(error))).finally(() => {
10776
+ webdavInFlight.current = false;
10777
+ setBackupBusy(false);
10778
+ });
10756
10779
  }, [
10757
10780
  previewBackup,
10758
10781
  t,
@@ -10876,6 +10899,10 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
10876
10899
  setExportIncludeConfig(false);
10877
10900
  setExportOpen(true);
10878
10901
  }, [installed, installedBundles]);
10902
+ const runWebdavRef = (0, react.useRef)(runWebdav);
10903
+ (0, react.useEffect)(() => {
10904
+ runWebdavRef.current = runWebdav;
10905
+ }, [runWebdav]);
10879
10906
  (0, react.useEffect)(() => {
10880
10907
  try {
10881
10908
  localStorage.setItem(WEBDAV_STORAGE_KEY, JSON.stringify({
@@ -10884,18 +10911,19 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
10884
10911
  auto: autoBackup
10885
10912
  }));
10886
10913
  } catch {}
10887
- if (!autoBackup || webdavUrl.trim() === "") return;
10888
- let last = 0;
10889
- try {
10890
- last = Number(localStorage.getItem("dshm-webdav-last")) || 0;
10891
- } catch {}
10892
- if (Date.now() - last >= 864e5) runWebdav("backup");
10893
10914
  }, [
10894
10915
  autoBackup,
10895
- runWebdav,
10896
10916
  webdavUrl,
10897
10917
  webdavUser
10898
10918
  ]);
10919
+ (0, react.useEffect)(() => {
10920
+ if (!autoBackup) return;
10921
+ let last = 0;
10922
+ try {
10923
+ last = Number(localStorage.getItem("dshm-webdav-last")) || 0;
10924
+ } catch {}
10925
+ if (Date.now() - last >= 864e5) runWebdavRef.current("backup");
10926
+ }, [autoBackup]);
10899
10927
  const sessionPendingRestart = doneUrls.length + restartNames.length + removedCount + toggleRestart + (backupRestored ? 1 : 0);
10900
10928
  /**
10901
10929
  * Plugins the HOST reports as restart-pending, independent of what this
@@ -10936,7 +10964,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
10936
10964
  const isInstalledListActive = tab === "installed" && installedView === "list";
10937
10965
  const updatesLoaded = Object.keys(updates).length > 0;
10938
10966
  const orderedInstalledEntries = (0, react.useMemo)(() => {
10939
- return Object.entries(displayedInstalled).filter(([name]) => name !== selfName).sort(([nameA, specA], [nameB, specB]) => {
10967
+ return Object.entries(displayedInstalled).filter(([name]) => name !== selfName).filter(([name]) => !dependencyOnlyNames.has(name)).sort(([nameA, specA], [nameB, specB]) => {
10940
10968
  const aOn = effectiveDisabledSet.has(nameA) ? 0 : 1;
10941
10969
  const bOn = effectiveDisabledSet.has(nameB) ? 0 : 1;
10942
10970
  if (aOn !== bOn) return bOn - aOn;
@@ -10948,7 +10976,8 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
10948
10976
  displayedInstalled,
10949
10977
  selfName,
10950
10978
  updatesLoaded,
10951
- effectiveDisabledSet
10979
+ effectiveDisabledSet,
10980
+ dependencyOnlyNames
10952
10981
  ]);
10953
10982
  const missingRestoreCount = Object.keys(pendingDependencies).filter((name) => !installedFiles.includes(name)).length;
10954
10983
  const hasUpdates = reminderUpdatableNames.length > 0;
@@ -11770,7 +11799,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
11770
11799
  */
11771
11800
  const pendingRefreshNames = (0, react.useMemo)(() => [.../* @__PURE__ */ new Set([...hotNames, ...refreshNames])], [hotNames, refreshNames]);
11772
11801
  /** Installed plugins the market itself cannot group (#60). */
11773
- const groupableNames = Object.keys(installed).filter((name) => name !== "dsh-market" && name !== "dshmarket");
11802
+ const groupableNames = Object.keys(installed).filter((name) => name !== "dsh-market" && name !== "dshmarket" && !dependencyOnlyNames.has(name));
11774
11803
  /** Names already inside some group; everything else shows under "ungrouped". */
11775
11804
  const groupedNames = (0, react.useMemo)(() => new Set(Object.values(groups).flat()), [groups]);
11776
11805
  const ungroupedNames = groupableNames.filter((name) => !groupedNames.has(name));
@@ -12445,6 +12474,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
12445
12474
  placeholder: t("webdavUrl"),
12446
12475
  onChange: (e) => setWebdavUrl(e.target.value)
12447
12476
  }),
12477
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: t("webdavPathHint") }),
12448
12478
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Input, {
12449
12479
  className: Market_module_css_default.backupInput,
12450
12480
  autoComplete: "username",
@@ -13641,6 +13671,10 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
13641
13671
  })]
13642
13672
  }, name);
13643
13673
  }
13674
+ }),
13675
+ dependencyOnlyNames.size > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
13676
+ className: Market_module_css_default.groupOrgHint,
13677
+ children: t("installedDependencyNote").replace("{0}", String(dependencyOnlyNames.size))
13644
13678
  })
13645
13679
  ] })
13646
13680
  }),
@@ -14264,7 +14298,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
14264
14298
  })]
14265
14299
  }) : updateNotes.kind === "source" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
14266
14300
  className: Market_module_css_default.spec,
14267
- children: [t(updateNotes.reason === "unavailable" ? "notesLoadFail" : "notesSource"), typeof updateNotes.sourceUrl === "string" && /^https?:\/\//i.test(updateNotes.sourceUrl) ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [lang === "zh" ? "" : " ", /* @__PURE__ */ (0, react_jsx_runtime.jsx)("a", {
14301
+ children: [t(updateNotes.reason === "unavailable" ? "notesLoadFail" : updateNotes.reason === "unreadable" ? "notesSourceUnreadable" : "notesSource"), typeof updateNotes.sourceUrl === "string" && /^https?:\/\//i.test(updateNotes.sourceUrl) ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [lang === "zh" ? "" : " ", /* @__PURE__ */ (0, react_jsx_runtime.jsx)("a", {
14268
14302
  className: Market_module_css_default.notesSourceLink,
14269
14303
  href: updateNotes.sourceUrl,
14270
14304
  target: "_blank",
package/lib/changelog.js CHANGED
@@ -341,9 +341,17 @@ export async function updateNotesFor(profile, explicitDir, name) {
341
341
  const specKey = repoKeyOf(spec) ?? lookupRepoFromUrl(spec);
342
342
  // The market holds no notes for this install, but its history is still
343
343
  // readable where it came from: say which, and point there.
344
+ //
345
+ // Notes come from the catalog's own release feed, which is built from
346
+ // GitHub, so a gitee or self-hosted git install has no feed to find:
347
+ // "the market has not collected this one yet" would promise something that
348
+ // cannot happen, and the reader waits for it (#819). Those say the source
349
+ // cannot be read instead. The url is the same either way — the repository
350
+ // is where the answer actually is.
351
+ const unreadableSource = githubIdentityOf(spec) === null && isGitHostedSpec(spec);
344
352
  const fromSource = (reason) => ({
345
353
  kind: 'source',
346
- reason,
354
+ reason: unreadableSource ? 'unreadable' : reason,
347
355
  sourceUrl: sourceWebUrl(spec) ?? specKey,
348
356
  });
349
357
  try {
package/lib/install.js CHANGED
@@ -328,10 +328,16 @@ export async function withHoistRecovery(run, profile, pluginArgs, profileDirecto
328
328
  // no options from us — but the classified message above says the market
329
329
  // retried. Say that it did not, or the user reads a retry that never
330
330
  // happened and looks for the wrong cause.
331
- result = {
332
- ...result,
333
- stderr: `${result.stderr}\n\n这台宿主(官方桌面端)自己执行安装,不接受市场附加的 pnpm 参数(${unavailableOption}),所以这一步没有自动重试。 / This host (the official desktop app) runs the install itself and takes no pnpm options from the market (${unavailableOption}), so that automatic retry could not be attempted.`,
334
- };
331
+ //
332
+ // The wait on a fresh release is the one of these where "no retry" leaves
333
+ // the user with nothing to do at all: the plugin IS published, pnpm just
334
+ // will not take it yet, and the door the market would open on a plain
335
+ // `dsh web` host is shut here — so say when the same button starts
336
+ // working, and what to use meanwhile (#852).
337
+ const note = unavailableOption === RELEASE_AGE_OVERRIDE
338
+ ? `这个插件的新版本比本 profile 的 minimumReleaseAge(新版本等待期)还新,pnpm 因此拒绝了本次改动(改别的插件也一样)。官方桌面端由应用自己执行安装,不接受市场的临时放行参数(${unavailableOption}),所以这里没有自动重试。**等这个版本过了等待期再更新一次即可**;想现在就更新,可以在普通的 dsh web 里装。 / The plugin's new version is younger than this profile's minimumReleaseAge, so pnpm refused this change (a change to another plugin too). The official desktop app runs the install itself and takes no one-shot bypass from the market (${unavailableOption}), so no automatic retry happens here. **Update again once the version is past the wait**; to update right now, install from plain dsh web.`
339
+ : `这台宿主(官方桌面端)自己执行安装,不接受市场附加的 pnpm 参数(${unavailableOption}),所以这一步没有自动重试。 / This host (the official desktop app) runs the install itself and takes no pnpm options from the market (${unavailableOption}), so that automatic retry could not be attempted.`;
340
+ result = { ...result, stderr: `${result.stderr}\n\n${note}` };
335
341
  }
336
342
  return result;
337
343
  }
@@ -426,7 +432,27 @@ export function failureDetail(result, limit = 300) {
426
432
  const code = result.pnpmErrorCode === undefined ? '' : `${result.pnpmErrorCode}: `;
427
433
  return `${code}${result.pnpmError}`.slice(0, limit);
428
434
  }
429
- return (result.stderr || result.stdout).slice(-limit);
435
+ const text = (result.stderr || result.stdout).trim();
436
+ if (text.length <= limit)
437
+ return text;
438
+ const tail = text.slice(-limit);
439
+ // Cut at a sentence end instead of mid-word, and say that something was
440
+ // dropped (#852). The market appends its own explanation to pnpm's output,
441
+ // and this tail is catalogued into user-facing sentences — including inside
442
+ // a parenthesis. A blunt cut landed inside the previous sentence and welded
443
+ // the fragment to the next one: the reporter's log read
444
+ // `(is, export the log and report it这台宿主…`, which is not a sentence in
445
+ // either language and says nothing about what to do.
446
+ const sentenceEnd = /[.!?。!?]["')\])]?[\s\n]+/gu;
447
+ const match = sentenceEnd.exec(tail);
448
+ if (match !== null) {
449
+ const rest = tail.slice(match.index + match[0].length).trim();
450
+ // A boundary so near the end would leave a word or two; the raw tail is
451
+ // more use than the stub.
452
+ if (rest.length >= 40)
453
+ return `… ${rest}`;
454
+ }
455
+ return `…${tail.trim()}`;
430
456
  }
431
457
  /**
432
458
  * Some registry entries point at collection repos whose actual plugin lives
@@ -106,9 +106,10 @@ export interface UpdateNotes {
106
106
  /**
107
107
  * Present for `source`: `missing` when the notes loaded without this
108
108
  * plugin, `unavailable` when they could not be loaded at all — a passing
109
- * failure, not a fact about the plugin.
109
+ * failure, not a fact about the plugin — and `unreadable` when the source is
110
+ * one the market can never read, so waiting will not help (#819).
110
111
  */
111
- reason?: 'missing' | 'unavailable';
112
+ reason?: 'missing' | 'unavailable' | 'unreadable';
112
113
  }
113
114
  /**
114
115
  * Resolve the notes for one installed plugin, or `{ kind: 'none' }`.
package/lib/verify.js CHANGED
@@ -26,14 +26,15 @@
26
26
  * or a declared entry artifact that is missing
27
27
  * missing – not present in node_modules
28
28
  */
29
- import { readFileSync } from 'node:fs';
29
+ import { readFileSync, realpathSync, statSync } from 'node:fs';
30
+ import { createRequire, isBuiltin } from 'node:module';
30
31
  import { Script } from 'node:vm';
31
32
  import { join } from 'node:path';
32
33
  import { listHotMounts, parseSimplePatch } from './hot.js';
33
34
  import { userPatchPackageReferences } from './patch.js';
34
35
  import { nameMatchesPackage } from './entry-identity.js';
35
36
  import { bundlePatchInsertedIds, declaredBundlePatchFiles, hasDshManifest, hasLoadableEntry, profileDir, readInstalled } from './profile.js';
36
- import { compareSemver, parsePatchFile, satisfiesRange } from './check.js';
37
+ import { compareSemver, composeLayers, parsePatchFile, satisfiesRange } from './check.js';
37
38
  import { dshHostInfo } from './dsh-install.js';
38
39
  /**
39
40
  * Whether every bundle patch the package declares parses to an empty list.
@@ -183,11 +184,15 @@ const isDshPeer = (peer) => peer === '@deepseek-ai/dsh' || peer.startsWith('@dee
183
184
  * peers fail at once.
184
185
  */
185
186
  export function hostPeerGate(activeProfileDir, name, facts) {
187
+ return peerGateAt(join(activeProfileDir, 'node_modules', name), name, facts);
188
+ }
189
+ /** Same prediction for a patch plugin resolved outside the profile's flat node_modules. */
190
+ function peerGateAt(packageDir, name, facts) {
186
191
  if (facts.runtimeVersion === null)
187
192
  return null;
188
193
  let manifest;
189
194
  try {
190
- manifest = JSON.parse(readFileSync(join(activeProfileDir, 'node_modules', name, 'package.json'), 'utf8'));
195
+ manifest = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8'));
191
196
  }
192
197
  catch {
193
198
  return null;
@@ -215,6 +220,74 @@ export function hostPeerGate(activeProfileDir, name, facts) {
215
220
  }
216
221
  return null;
217
222
  }
223
+ /**
224
+ * #867: only the bundle's own effective loader rows are evidence, never its
225
+ * ordinary dependency closure or arbitrary names in plugin configuration.
226
+ * Compose every declared file together so later disables take effect. An
227
+ * unreadable file or an expression-gated row gives no rejection evidence.
228
+ */
229
+ function bundleRowPeerGate(packageDir, activeProfileDir, facts) {
230
+ if (facts.runtimeVersion === null)
231
+ return null;
232
+ try {
233
+ const patches = declaredBundlePatchFiles(packageDir).map(parsePatchFile);
234
+ if (patches.some(rows => rows === null))
235
+ return null;
236
+ const { resolvableRows } = composeLayers([{
237
+ label: 'bundle', kind: 'bundle', patches: patches.flatMap(rows => rows ?? []), parseError: null,
238
+ }]);
239
+ const checked = new Set();
240
+ for (const row of resolvableRows) {
241
+ if (row.activation !== 'required' || typeof row.name !== 'string')
242
+ continue;
243
+ // Bare npm names and subpath entries only. Relative files, builtins,
244
+ // aliases and expressions need runtime resolution; do not guess.
245
+ if (/^[.#/\\]|[:\\]/u.test(row.name) || isBuiltin(row.name))
246
+ continue;
247
+ const parts = row.name.split('/');
248
+ const name = parts.slice(0, row.name.startsWith('@') ? 2 : 1).join('/');
249
+ if (!/^(?:@[\w.-]+\/)?[\w-][\w.-]*$/u.test(name) || checked.has(name))
250
+ continue;
251
+ checked.add(name);
252
+ const directory = resolveBundleRowPackage(packageDir, activeProfileDir, name);
253
+ if (directory === null)
254
+ continue;
255
+ const gate = peerGateAt(directory, name, facts);
256
+ if (gate !== null)
257
+ return { name, gate };
258
+ }
259
+ }
260
+ catch { /* malformed or cyclic patch data — no reliable prediction */ }
261
+ return null;
262
+ }
263
+ /**
264
+ * Probe Node's search paths, without resolving an entry or requiring a
265
+ * package.json export. The real bundle path reaches pnpm's adjacent and
266
+ * nested dependencies; the profile anchor also covers workspace hoisting.
267
+ * Stop at a present directory even if its manifest is missing: a broken
268
+ * nearer copy must not borrow an incompatible manifest from a fallback.
269
+ */
270
+ function resolveBundleRowPackage(packageDir, activeProfileDir, name) {
271
+ const bundleAnchor = join(realpathSync(packageDir), 'package.json');
272
+ const manifest = JSON.parse(readFileSync(bundleAnchor, 'utf8'));
273
+ // The host publishes dependencies carried by selected bundles into its
274
+ // profile resolution. A stray nested copy not declared by this bundle is
275
+ // no evidence of what a profile row will import.
276
+ const carried = manifest.name === name || Object.hasOwn(manifest.dependencies ?? {}, name)
277
+ || Object.hasOwn(manifest.peerDependencies ?? {}, name);
278
+ const profileAnchor = join(activeProfileDir, 'package.json');
279
+ for (const anchor of carried ? [bundleAnchor, profileAnchor] : [profileAnchor]) {
280
+ for (const searchPath of createRequire(anchor).resolve.paths(name) ?? []) {
281
+ const candidate = join(searchPath, name);
282
+ try {
283
+ if (statSync(candidate).isDirectory())
284
+ return candidate;
285
+ }
286
+ catch { /* try the next Node search path */ }
287
+ }
288
+ }
289
+ return null;
290
+ }
218
291
  /** The way out of a peer gate, in the direction the versions allow (#758). */
219
292
  export function peerGateRemedy(gate) {
220
293
  if (gate.hostTooOld) {
@@ -391,7 +464,8 @@ export function verifyActivation(profile, name, live = new Set(listHotMounts()),
391
464
  // range excludes the running runtime — predict that verdict here so
392
465
  // the plugin reads as incompatible instead of promising a restart
393
466
  // that cannot fix it.
394
- const gate = hostPeerGate(activeProfileDir, name, hostFacts ?? defaultHostRuntimeFacts(activeProfileDir));
467
+ const facts = hostFacts ?? defaultHostRuntimeFacts(activeProfileDir);
468
+ const gate = hostPeerGate(activeProfileDir, name, facts);
395
469
  if (gate !== null) {
396
470
  const remedy = peerGateRemedy(gate);
397
471
  return {
@@ -403,6 +477,21 @@ export function verifyActivation(profile, name, live = new Set(listHotMounts()),
403
477
  hot: false,
404
478
  };
405
479
  }
480
+ const child = bundleRowPeerGate(dir, activeProfileDir, facts);
481
+ if (child !== null) {
482
+ const { gate, name: plugin } = child;
483
+ const remedy = gate.hostTooOld
484
+ ? { zh: '升级 dsh,或更新此插件包以匹配当前 dsh', en: 'upgrade dsh, or update this bundle to match the current dsh' }
485
+ : { zh: '更新此插件包,或安装与当前 dsh 兼容的版本;升级 dsh 解决不了', en: 'update this bundle or install a version compatible with the current dsh — upgrading dsh will not help' };
486
+ return {
487
+ state: 'incompatible',
488
+ reasons: [
489
+ `它加载的插件 ${plugin} 要求 ${gate.peer} ${gate.range},当前 dsh 为 ${gate.runtimeVersion},重启也不会生效;${remedy.zh} / its plugin ${plugin} requires ${gate.peer} ${gate.range}, but dsh runs ${gate.runtimeVersion} — a restart will not activate it; ${remedy.en}`,
490
+ ],
491
+ bundle: true,
492
+ hot: false,
493
+ };
494
+ }
406
495
  const patch = patchTextOf(profile, name, activeProfileDir);
407
496
  const complex = patch !== null && parseSimplePatch(patch) === null;
408
497
  return {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dshmarket",
3
3
  "description": "Visual plugin market inside DeepSeek Harness — browse, search, and one-click install community plugins. · DSH 可视化插件市场:逛一逛,点一下,装好。",
4
- "version": "1.66.22",
4
+ "version": "1.66.24",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/types/index.d.ts",
@@ -41,7 +41,7 @@
41
41
  },
42
42
  "peerDependencies": {
43
43
  "@deepseek-ai/cordis": "^4.0.1",
44
- "@deepseek-ai/dsh-settings": "^0.1.0-rc.7 || ^0.1.1-rc.2 || ^0.1.2-alpha.2 || ^0.2.0-rc.1",
44
+ "@deepseek-ai/dsh-settings": "^0.1.0-rc.7 || ^0.1.1-rc.2 || ^0.1.2-alpha.2 || ^0.2.0-rc.1 || ^0.2.1-alpha.3",
45
45
  "@deepseek-ai/schemastery": "^3.18.1"
46
46
  },
47
47
  "dsh": {
package/src/changelog.ts CHANGED
@@ -351,9 +351,10 @@ export interface UpdateNotes {
351
351
  /**
352
352
  * Present for `source`: `missing` when the notes loaded without this
353
353
  * plugin, `unavailable` when they could not be loaded at all — a passing
354
- * failure, not a fact about the plugin.
354
+ * failure, not a fact about the plugin — and `unreadable` when the source is
355
+ * one the market can never read, so waiting will not help (#819).
355
356
  */
356
- reason?: 'missing' | 'unavailable'
357
+ reason?: 'missing' | 'unavailable' | 'unreadable'
357
358
  }
358
359
 
359
360
  /**
@@ -380,9 +381,17 @@ export async function updateNotesFor(
380
381
  const specKey = repoKeyOf(spec) ?? lookupRepoFromUrl(spec)
381
382
  // The market holds no notes for this install, but its history is still
382
383
  // readable where it came from: say which, and point there.
384
+ //
385
+ // Notes come from the catalog's own release feed, which is built from
386
+ // GitHub, so a gitee or self-hosted git install has no feed to find:
387
+ // "the market has not collected this one yet" would promise something that
388
+ // cannot happen, and the reader waits for it (#819). Those say the source
389
+ // cannot be read instead. The url is the same either way — the repository
390
+ // is where the answer actually is.
391
+ const unreadableSource = githubIdentityOf(spec) === null && isGitHostedSpec(spec)
383
392
  const fromSource = (reason: 'missing' | 'unavailable'): UpdateNotes => ({
384
393
  kind: 'source',
385
- reason,
394
+ reason: unreadableSource ? 'unreadable' : reason,
386
395
  sourceUrl: sourceWebUrl(spec) ?? specKey,
387
396
  })
388
397
  try {
@@ -1996,13 +1996,13 @@ export function MarketSection(props: MarketSectionProps) {
1996
1996
  commits?: { items: NoteCommit[]; found: boolean }
1997
1997
  npmTimes?: Array<{ version: string; date: string }>
1998
1998
  sourceUrl?: string | null
1999
- reason?: 'missing' | 'unavailable'
1999
+ reason?: 'missing' | 'unavailable' | 'unreadable'
2000
2000
  }
2001
2001
  type ResolvedNotes =
2002
2002
  | { kind: 'release'; release: NoteRelease }
2003
2003
  | { kind: 'commits'; commits: { items: NoteCommit[]; found: boolean } }
2004
2004
  | { kind: 'npm'; npmTimes: Array<{ version: string; date: string }> }
2005
- | { kind: 'source'; sourceUrl?: string | null; reason?: 'missing' | 'unavailable' }
2005
+ | { kind: 'source'; sourceUrl?: string | null; reason?: 'missing' | 'unavailable' | 'unreadable' }
2006
2006
  | { kind: 'none' }
2007
2007
  interface CompatibilityNotice {
2008
2008
  code: 'soft-incompatible'
@@ -2279,6 +2279,7 @@ export function MarketSection(props: MarketSectionProps) {
2279
2279
  const [restarting, setRestarting] = useState(false)
2280
2280
  const [showTop, setShowTop] = useState(false)
2281
2281
  const [backupBusy, setBackupBusy] = useState(false)
2282
+ const webdavInFlight = useRef(false)
2282
2283
  const [backupMessage, setBackupMessage] = useState<string | null>(null)
2283
2284
  const [backupRestored, setBackupRestored] = useState(false)
2284
2285
  const [pendingBackup, setPendingBackup] = useState<unknown>(null)
@@ -4462,7 +4463,23 @@ export function MarketSection(props: MarketSectionProps) {
4462
4463
  // The market manages itself from its own settings card (Settings → Plugins
4463
4464
  // → Plugin configuration), not as a row here — listing it in both places
4464
4465
  // read as two different controls for the same thing.
4465
- const installedOtherCount = Object.keys(installed).filter(name => name !== selfName).length
4466
+ /**
4467
+ * Installed packages that are not plugins at all: a plain library some
4468
+ * other plugin pulled in — zustand, bufferutil, a peer of ws (#634's
4469
+ * `dependencyOf`, #857). They were rendered as plugin cards with a
4470
+ * working uninstall, which is a real remove path for something a plugin
4471
+ * needs. Out of the list; the count stays visible in one quiet line, so the
4472
+ * list is not silently shorter than package.json.
4473
+ */
4474
+ const dependencyOnlyNames = useMemo(
4475
+ () => new Set(Object.keys(installed).filter(name => {
4476
+ const act = activations[name]
4477
+ return act?.state === 'inert' && act.dependencyOf !== undefined
4478
+ })),
4479
+ [installed, activations],
4480
+ )
4481
+ const installedOtherCount = Object.keys(installed)
4482
+ .filter(name => name !== selfName && !dependencyOnlyNames.has(name)).length
4466
4483
 
4467
4484
  const ignoreUpdateNotices = useCallback((names: string[]) => {
4468
4485
  if (bootId === null || names.length === 0) return
@@ -4560,7 +4577,8 @@ export function MarketSection(props: MarketSectionProps) {
4560
4577
  }, [finishRestore, pendingBackup])
4561
4578
 
4562
4579
  const runWebdav = useCallback((action: 'backup' | 'restore') => {
4563
- if (webdavUrl.trim() === '') return
4580
+ if (webdavUrl.trim() === '' || webdavInFlight.current) return
4581
+ webdavInFlight.current = true
4564
4582
  setBackupBusy(true)
4565
4583
  setBackupMessage(null)
4566
4584
  setRestoreErrors([])
@@ -4577,7 +4595,10 @@ export function MarketSection(props: MarketSectionProps) {
4577
4595
  try { localStorage.setItem('dshm-webdav-last', String(Date.now())) } catch { /* storage unavailable */ }
4578
4596
  setBackupMessage(t('backupDone'))
4579
4597
  }
4580
- }).catch(error => setBackupMessage(String(error))).finally(() => setBackupBusy(false))
4598
+ }).catch(error => setBackupMessage(String(error))).finally(() => {
4599
+ webdavInFlight.current = false
4600
+ setBackupBusy(false)
4601
+ })
4581
4602
  }, [previewBackup, t, webdavPassword, webdavUrl, webdavUser])
4582
4603
 
4583
4604
  /** Map the server's token-source string to a localized label. */
@@ -4702,6 +4723,9 @@ export function MarketSection(props: MarketSectionProps) {
4702
4723
  setExportOpen(true)
4703
4724
  }, [installed, installedBundles])
4704
4725
 
4726
+ const runWebdavRef = useRef(runWebdav)
4727
+ useEffect(() => { runWebdavRef.current = runWebdav }, [runWebdav])
4728
+
4705
4729
  useEffect(() => {
4706
4730
  // Persist only the non-secret WebDAV settings; the password stays
4707
4731
  // server-side/in-memory (see savedWebdav). Storage itself may be
@@ -4709,13 +4733,18 @@ export function MarketSection(props: MarketSectionProps) {
4709
4733
  try {
4710
4734
  localStorage.setItem(WEBDAV_STORAGE_KEY, JSON.stringify({ url: webdavUrl, username: webdavUser, auto: autoBackup }))
4711
4735
  } catch { /* storage unavailable — config just won't survive reload */ }
4712
- if (!autoBackup || webdavUrl.trim() === '') return
4736
+ }, [autoBackup, webdavUrl, webdavUser])
4737
+
4738
+ // Check when the market opens or daily backup is enabled. Editing the
4739
+ // settings (or changing the locale) must not start another upload.
4740
+ useEffect(() => {
4741
+ if (!autoBackup) return
4713
4742
  let last = 0
4714
4743
  try {
4715
4744
  last = Number(localStorage.getItem('dshm-webdav-last')) || 0
4716
4745
  } catch { /* ignore */ }
4717
- if (Date.now() - last >= 24 * 60 * 60 * 1000) runWebdav('backup')
4718
- }, [autoBackup, runWebdav, webdavUrl, webdavUser])
4746
+ if (Date.now() - last >= 24 * 60 * 60 * 1000) runWebdavRef.current('backup')
4747
+ }, [autoBackup])
4719
4748
 
4720
4749
  // #558: count restart-pending session changes (restartNames), not every
4721
4750
  // completed `updatedNames` entry (which would double-count a batch update
@@ -4759,6 +4788,7 @@ export function MarketSection(props: MarketSectionProps) {
4759
4788
  const orderedInstalledEntries = useMemo(() => {
4760
4789
  return Object.entries(displayedInstalled)
4761
4790
  .filter(([name]) => name !== selfName)
4791
+ .filter(([name]) => !dependencyOnlyNames.has(name))
4762
4792
  .sort(([nameA, specA], [nameB, specB]) => {
4763
4793
  // The list reads as "what is running", so a plugin that is off sinks
4764
4794
  // below every one that is on (#745) — even a disabled row with a
@@ -4771,7 +4801,7 @@ export function MarketSection(props: MarketSectionProps) {
4771
4801
  return bUp - aUp
4772
4802
  })
4773
4803
  // eslint-disable-next-line react-hooks/exhaustive-deps -- `updatesLoaded` stands in for `updates`/`updatedNames`: reorder when the check lands, then hold (#631). `effectiveDisabledSet` only re-runs this when a disable state actually changed; the toggled row is the only one that moves.
4774
- }, [isInstalledListActive, displayedInstalled, selfName, updatesLoaded, effectiveDisabledSet])
4804
+ }, [isInstalledListActive, displayedInstalled, selfName, updatesLoaded, effectiveDisabledSet, dependencyOnlyNames])
4775
4805
  const missingRestoreCount = Object.keys(pendingDependencies).filter(name => !installedFiles.includes(name)).length
4776
4806
  // Self-update lives in the header button and the settings card, not this
4777
4807
  // tab's row list (the market itself is filtered out below) — so a pending
@@ -5520,7 +5550,8 @@ export function MarketSection(props: MarketSectionProps) {
5520
5550
  )
5521
5551
 
5522
5552
  /** Installed plugins the market itself cannot group (#60). */
5523
- const groupableNames = Object.keys(installed).filter(name => name !== 'dsh-market' && name !== 'dshmarket')
5553
+ const groupableNames = Object.keys(installed)
5554
+ .filter(name => name !== 'dsh-market' && name !== 'dshmarket' && !dependencyOnlyNames.has(name))
5524
5555
  /** Names already inside some group; everything else shows under "ungrouped". */
5525
5556
  const groupedNames = useMemo(() => new Set(Object.values(groups).flat()), [groups])
5526
5557
  const ungroupedNames = groupableNames.filter(name => !groupedNames.has(name))
@@ -6049,6 +6080,12 @@ export function MarketSection(props: MarketSectionProps) {
6049
6080
  ]}
6050
6081
  />
6051
6082
  <Input className={css.backupInput} icon={<IconLinkOutline14 size={14} />} type="url" value={webdavUrl} placeholder={t('webdavUrl')} onChange={e => setWebdavUrl(e.target.value)} />
6083
+ {/* The field had a placeholder and nothing else, so "is this a
6084
+ folder or a file?" was answered only by trying it — and a
6085
+ reporter who read it as a folder expected the market to
6086
+ name the file (#868). Name the shape, and say why the
6087
+ name has to stay put: restore looks for that same one. */}
6088
+ <p>{t('webdavPathHint')}</p>
6052
6089
  <Input className={css.backupInput} autoComplete="username" value={webdavUser} placeholder={t('webdavUser')} onChange={e => setWebdavUser(e.target.value)} />
6053
6090
  <Input className={css.backupInput} type="password" autoComplete="current-password" value={webdavPassword} placeholder={t('webdavPassword')} onChange={e => setWebdavPassword(e.target.value)} />
6054
6091
  <div className={css.backupActions}>
@@ -7045,6 +7082,13 @@ export function MarketSection(props: MarketSectionProps) {
7045
7082
  }}
7046
7083
  />
7047
7084
  )}
7085
+ {dependencyOnlyNames.size > 0 && (
7086
+ // The list is complete as a list of PLUGINS; the count keeps it
7087
+ // from looking like packages went missing (#857).
7088
+ <p className={css.groupOrgHint}>
7089
+ {t('installedDependencyNote').replace('{0}', String(dependencyOnlyNames.size))}
7090
+ </p>
7091
+ )}
7048
7092
  </>
7049
7093
  )}
7050
7094
  </div>
@@ -7606,7 +7650,9 @@ export function MarketSection(props: MarketSectionProps) {
7606
7650
  )
7607
7651
  : updateNotes.kind === 'source' ? (
7608
7652
  <div className={css.spec}>
7609
- {t(updateNotes.reason === 'unavailable' ? 'notesLoadFail' : 'notesSource')}
7653
+ {t(updateNotes.reason === 'unavailable'
7654
+ ? 'notesLoadFail'
7655
+ : updateNotes.reason === 'unreadable' ? 'notesSourceUnreadable' : 'notesSource')}
7610
7656
  {typeof updateNotes.sourceUrl === 'string' && /^https?:\/\//i.test(updateNotes.sourceUrl)
7611
7657
  ? <>{lang === 'zh' ? '' : ' '}<a className={css.notesSourceLink} href={updateNotes.sourceUrl} target="_blank" rel="noreferrer">{t('notesSourceLink')}</a></>
7612
7658
  : updateNotes.reason !== 'unavailable' && <>{lang === 'zh' ? '' : ' '}{t('notesSourceNoLink')}</>}
@@ -198,6 +198,7 @@ export const zh = {
198
198
  notesNpm: '版本发布时间',
199
199
  notesNone: '该插件未提供更新说明。',
200
200
  notesSource: '市场还没收录这个插件的更新说明。',
201
+ notesSourceUnreadable: '这个插件不是从 GitHub 装的,市场读不到它的改动记录。',
201
202
  notesSourceLink: '去仓库看改了什么',
202
203
  notesSourceNoLink: '可以问问插件作者改了什么。',
203
204
  notesLoadFail: '暂时读不到更新说明,稍后再试。',
@@ -366,6 +367,7 @@ export const zh = {
366
367
  marketUpdate: '更新插件市场',
367
368
  updateAll: '全部更新',
368
369
  heldReleaseNotice: '已安装,但不是最新版:这个 profile 的 minimumReleaseAge 挡住了 {0},安装的是 {1}。',
370
+ installedDependencyNote: '另有 {0} 个包是插件带进来的依赖,不是插件,未列在上方。',
369
371
  heldReleaseAction: '仍安装 {0}',
370
372
  ignoreUpdateNotice: '重启前不再提醒',
371
373
  ignoreAllUpdateNotices: '重启前都不再提醒',
@@ -397,6 +399,7 @@ export const zh = {
397
399
  webdavRestore: '从 WebDAV 恢复',
398
400
  autoBackup: '每天自动备份(打开市场时)',
399
401
  webdavNote: 'WebDAV 地址与用户名仅保存在当前浏览器;密码只存于服务端,每次会话需重新输入。',
402
+ webdavPathHint: '填完整的文件地址(含文件名):备份写进这个文件,恢复也从它读。开启自动备份后每天最多上传一次。',
400
403
  localOnly: 'WebDAV 地址和用户名只保存在此浏览器。',
401
404
  credsWarning: '请注意:备份包含 profile 配置,以及可能含密钥的文件(config.toml、.env 等)。本地导出的文件会原样包含这些内容,上传 WebDAV 前请确认目标可信。',
402
405
  gist: 'GitHub Gist',
@@ -934,6 +937,7 @@ export const en: Record<MarketKey, string> = {
934
937
  notesNpm: 'Version publish times',
935
938
  notesNone: 'This plugin does not publish update notes.',
936
939
  notesSource: "The market doesn't have update notes for this plugin yet.",
940
+ notesSourceUnreadable: 'This plugin was not installed from GitHub, so the market cannot read its change history.',
937
941
  notesSourceLink: 'See what changed in the repository',
938
942
  notesSourceNoLink: 'You can ask the plugin author what changed.',
939
943
  notesLoadFail: 'Update notes are unavailable right now — try again later.',
@@ -1096,6 +1100,7 @@ export const en: Record<MarketKey, string> = {
1096
1100
  marketUpdate: 'Update the plugin market',
1097
1101
  updateAll: 'Update all',
1098
1102
  heldReleaseNotice: 'Installed, but not the newest: this profile\'s minimumReleaseAge held {0} back, so {1} was installed.',
1103
+ installedDependencyNote: '{0} more packages came in as dependencies of plugins — they are not plugins, so they are not listed above.',
1099
1104
  heldReleaseAction: 'Install {0} anyway',
1100
1105
  ignoreUpdateNotice: "Don't remind until restart",
1101
1106
  ignoreAllUpdateNotices: "Don't remind any until restart",
@@ -1127,6 +1132,7 @@ export const en: Record<MarketKey, string> = {
1127
1132
  webdavRestore: 'Restore from WebDAV',
1128
1133
  autoBackup: 'Back up daily (when the market opens)',
1129
1134
  webdavNote: 'The WebDAV URL and username stay in this browser only; the password is kept server-side and must be re-entered each session.',
1135
+ webdavPathHint: 'Give the whole file path, name included: the backup writes that file and restore reads it back. With automatic backup on it uploads at most once a day.',
1130
1136
  localOnly: 'The WebDAV URL and username stay in this browser only.',
1131
1137
  credsWarning: 'Note: backups include profile configuration and files that may hold secrets (config.toml, .env, …). Local exports are unmodified, so upload only to a WebDAV target you trust.',
1132
1138
  gist: 'GitHub Gist',
package/src/install.ts CHANGED
@@ -334,10 +334,16 @@ export async function withHoistRecovery(
334
334
  // no options from us — but the classified message above says the market
335
335
  // retried. Say that it did not, or the user reads a retry that never
336
336
  // happened and looks for the wrong cause.
337
- result = {
338
- ...result,
339
- stderr: `${result.stderr}\n\n这台宿主(官方桌面端)自己执行安装,不接受市场附加的 pnpm 参数(${unavailableOption}),所以这一步没有自动重试。 / This host (the official desktop app) runs the install itself and takes no pnpm options from the market (${unavailableOption}), so that automatic retry could not be attempted.`,
340
- }
337
+ //
338
+ // The wait on a fresh release is the one of these where "no retry" leaves
339
+ // the user with nothing to do at all: the plugin IS published, pnpm just
340
+ // will not take it yet, and the door the market would open on a plain
341
+ // `dsh web` host is shut here — so say when the same button starts
342
+ // working, and what to use meanwhile (#852).
343
+ const note = unavailableOption === RELEASE_AGE_OVERRIDE
344
+ ? `这个插件的新版本比本 profile 的 minimumReleaseAge(新版本等待期)还新,pnpm 因此拒绝了本次改动(改别的插件也一样)。官方桌面端由应用自己执行安装,不接受市场的临时放行参数(${unavailableOption}),所以这里没有自动重试。**等这个版本过了等待期再更新一次即可**;想现在就更新,可以在普通的 dsh web 里装。 / The plugin's new version is younger than this profile's minimumReleaseAge, so pnpm refused this change (a change to another plugin too). The official desktop app runs the install itself and takes no one-shot bypass from the market (${unavailableOption}), so no automatic retry happens here. **Update again once the version is past the wait**; to update right now, install from plain dsh web.`
345
+ : `这台宿主(官方桌面端)自己执行安装,不接受市场附加的 pnpm 参数(${unavailableOption}),所以这一步没有自动重试。 / This host (the official desktop app) runs the install itself and takes no pnpm options from the market (${unavailableOption}), so that automatic retry could not be attempted.`
346
+ result = { ...result, stderr: `${result.stderr}\n\n${note}` }
341
347
  }
342
348
  return result
343
349
  }
@@ -432,7 +438,25 @@ export function failureDetail(result: InstallResult, limit = 300): string {
432
438
  const code = result.pnpmErrorCode === undefined ? '' : `${result.pnpmErrorCode}: `
433
439
  return `${code}${result.pnpmError}`.slice(0, limit)
434
440
  }
435
- return (result.stderr || result.stdout).slice(-limit)
441
+ const text = (result.stderr || result.stdout).trim()
442
+ if (text.length <= limit) return text
443
+ const tail = text.slice(-limit)
444
+ // Cut at a sentence end instead of mid-word, and say that something was
445
+ // dropped (#852). The market appends its own explanation to pnpm's output,
446
+ // and this tail is catalogued into user-facing sentences — including inside
447
+ // a parenthesis. A blunt cut landed inside the previous sentence and welded
448
+ // the fragment to the next one: the reporter's log read
449
+ // `(is, export the log and report it这台宿主…`, which is not a sentence in
450
+ // either language and says nothing about what to do.
451
+ const sentenceEnd = /[.!?。!?]["')\])]?[\s\n]+/gu
452
+ const match = sentenceEnd.exec(tail)
453
+ if (match !== null) {
454
+ const rest = tail.slice(match.index + match[0].length).trim()
455
+ // A boundary so near the end would leave a word or two; the raw tail is
456
+ // more use than the stub.
457
+ if (rest.length >= 40) return `… ${rest}`
458
+ }
459
+ return `…${tail.trim()}`
436
460
  }
437
461
 
438
462
  /**
package/src/verify.ts CHANGED
@@ -27,14 +27,15 @@
27
27
  * missing – not present in node_modules
28
28
  */
29
29
 
30
- import { readFileSync } from 'node:fs'
30
+ import { readFileSync, realpathSync, statSync } from 'node:fs'
31
+ import { createRequire, isBuiltin } from 'node:module'
31
32
  import { Script } from 'node:vm'
32
33
  import { join } from 'node:path'
33
34
  import { listHotMounts, parseSimplePatch } from './hot.ts'
34
35
  import { userPatchPackageReferences } from './patch.ts'
35
36
  import { nameMatchesPackage } from './entry-identity.ts'
36
37
  import { bundlePatchInsertedIds, declaredBundlePatchFiles, hasDshManifest, hasLoadableEntry, profileDir, readInstalled } from './profile.ts'
37
- import { compareSemver, parsePatchFile, satisfiesRange } from './check.ts'
38
+ import { compareSemver, composeLayers, parsePatchFile, satisfiesRange } from './check.ts'
38
39
  import { dshHostInfo } from './dsh-install.ts'
39
40
 
40
41
  export type ActivationState = 'live' | 'restart' | 'incompatible' | 'inert' | 'broken' | 'missing' | 'disabled'
@@ -247,11 +248,16 @@ const isDshPeer = (peer: string): boolean => peer === '@deepseek-ai/dsh' || peer
247
248
  * peers fail at once.
248
249
  */
249
250
  export function hostPeerGate(activeProfileDir: string, name: string, facts: HostRuntimeFacts): PeerGate | null {
251
+ return peerGateAt(join(activeProfileDir, 'node_modules', name), name, facts)
252
+ }
253
+
254
+ /** Same prediction for a patch plugin resolved outside the profile's flat node_modules. */
255
+ function peerGateAt(packageDir: string, name: string, facts: HostRuntimeFacts): PeerGate | null {
250
256
  if (facts.runtimeVersion === null) return null
251
257
  let manifest: { version?: unknown; peerDependencies?: Record<string, unknown> }
252
258
  try {
253
259
  manifest = JSON.parse(
254
- readFileSync(join(activeProfileDir, 'node_modules', name, 'package.json'), 'utf8'),
260
+ readFileSync(join(packageDir, 'package.json'), 'utf8'),
255
261
  )
256
262
  } catch {
257
263
  return null
@@ -278,6 +284,72 @@ export function hostPeerGate(activeProfileDir: string, name: string, facts: Host
278
284
  return null
279
285
  }
280
286
 
287
+ /**
288
+ * #867: only the bundle's own effective loader rows are evidence, never its
289
+ * ordinary dependency closure or arbitrary names in plugin configuration.
290
+ * Compose every declared file together so later disables take effect. An
291
+ * unreadable file or an expression-gated row gives no rejection evidence.
292
+ */
293
+ function bundleRowPeerGate(
294
+ packageDir: string,
295
+ activeProfileDir: string,
296
+ facts: HostRuntimeFacts,
297
+ ): { name: string; gate: PeerGate } | null {
298
+ if (facts.runtimeVersion === null) return null
299
+ try {
300
+ const patches = declaredBundlePatchFiles(packageDir).map(parsePatchFile)
301
+ if (patches.some(rows => rows === null)) return null
302
+ const { resolvableRows } = composeLayers([{
303
+ label: 'bundle', kind: 'bundle', patches: patches.flatMap(rows => rows ?? []), parseError: null,
304
+ }])
305
+ const checked = new Set<string>()
306
+ for (const row of resolvableRows) {
307
+ if (row.activation !== 'required' || typeof row.name !== 'string') continue
308
+ // Bare npm names and subpath entries only. Relative files, builtins,
309
+ // aliases and expressions need runtime resolution; do not guess.
310
+ if (/^[.#/\\]|[:\\]/u.test(row.name) || isBuiltin(row.name)) continue
311
+ const parts = row.name.split('/')
312
+ const name = parts.slice(0, row.name.startsWith('@') ? 2 : 1).join('/')
313
+ if (!/^(?:@[\w.-]+\/)?[\w-][\w.-]*$/u.test(name) || checked.has(name)) continue
314
+ checked.add(name)
315
+ const directory = resolveBundleRowPackage(packageDir, activeProfileDir, name)
316
+ if (directory === null) continue
317
+ const gate = peerGateAt(directory, name, facts)
318
+ if (gate !== null) return { name, gate }
319
+ }
320
+ } catch { /* malformed or cyclic patch data — no reliable prediction */ }
321
+ return null
322
+ }
323
+
324
+ /**
325
+ * Probe Node's search paths, without resolving an entry or requiring a
326
+ * package.json export. The real bundle path reaches pnpm's adjacent and
327
+ * nested dependencies; the profile anchor also covers workspace hoisting.
328
+ * Stop at a present directory even if its manifest is missing: a broken
329
+ * nearer copy must not borrow an incompatible manifest from a fallback.
330
+ */
331
+ function resolveBundleRowPackage(packageDir: string, activeProfileDir: string, name: string): string | null {
332
+ const bundleAnchor = join(realpathSync(packageDir), 'package.json')
333
+ const manifest = JSON.parse(readFileSync(bundleAnchor, 'utf8')) as {
334
+ name?: string; dependencies?: Record<string, unknown>; peerDependencies?: Record<string, unknown>
335
+ }
336
+ // The host publishes dependencies carried by selected bundles into its
337
+ // profile resolution. A stray nested copy not declared by this bundle is
338
+ // no evidence of what a profile row will import.
339
+ const carried = manifest.name === name || Object.hasOwn(manifest.dependencies ?? {}, name)
340
+ || Object.hasOwn(manifest.peerDependencies ?? {}, name)
341
+ const profileAnchor = join(activeProfileDir, 'package.json')
342
+ for (const anchor of carried ? [bundleAnchor, profileAnchor] : [profileAnchor]) {
343
+ for (const searchPath of createRequire(anchor).resolve.paths(name) ?? []) {
344
+ const candidate = join(searchPath, name)
345
+ try {
346
+ if (statSync(candidate).isDirectory()) return candidate
347
+ } catch { /* try the next Node search path */ }
348
+ }
349
+ }
350
+ return null
351
+ }
352
+
281
353
  /** The way out of a peer gate, in the direction the versions allow (#758). */
282
354
  export function peerGateRemedy(gate: PeerGate): { zh: string; en: string } {
283
355
  if (gate.hostTooOld) {
@@ -467,7 +539,8 @@ export function verifyActivation(
467
539
  // range excludes the running runtime — predict that verdict here so
468
540
  // the plugin reads as incompatible instead of promising a restart
469
541
  // that cannot fix it.
470
- const gate = hostPeerGate(activeProfileDir, name, hostFacts ?? defaultHostRuntimeFacts(activeProfileDir))
542
+ const facts = hostFacts ?? defaultHostRuntimeFacts(activeProfileDir)
543
+ const gate = hostPeerGate(activeProfileDir, name, facts)
471
544
  if (gate !== null) {
472
545
  const remedy = peerGateRemedy(gate)
473
546
  return {
@@ -479,6 +552,21 @@ export function verifyActivation(
479
552
  hot: false,
480
553
  }
481
554
  }
555
+ const child = bundleRowPeerGate(dir, activeProfileDir, facts)
556
+ if (child !== null) {
557
+ const { gate, name: plugin } = child
558
+ const remedy = gate.hostTooOld
559
+ ? { zh: '升级 dsh,或更新此插件包以匹配当前 dsh', en: 'upgrade dsh, or update this bundle to match the current dsh' }
560
+ : { zh: '更新此插件包,或安装与当前 dsh 兼容的版本;升级 dsh 解决不了', en: 'update this bundle or install a version compatible with the current dsh — upgrading dsh will not help' }
561
+ return {
562
+ state: 'incompatible',
563
+ reasons: [
564
+ `它加载的插件 ${plugin} 要求 ${gate.peer} ${gate.range},当前 dsh 为 ${gate.runtimeVersion},重启也不会生效;${remedy.zh} / its plugin ${plugin} requires ${gate.peer} ${gate.range}, but dsh runs ${gate.runtimeVersion} — a restart will not activate it; ${remedy.en}`,
565
+ ],
566
+ bundle: true,
567
+ hot: false,
568
+ }
569
+ }
482
570
  const patch = patchTextOf(profile, name, activeProfileDir)
483
571
  const complex = patch !== null && parseSimplePatch(patch) === null
484
572
  return {