dshmarket 1.66.23 → 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: "暂时读不到更新说明,稍后再试。",
@@ -589,6 +590,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
589
590
  webdavRestore: "从 WebDAV 恢复",
590
591
  autoBackup: "每天自动备份(打开市场时)",
591
592
  webdavNote: "WebDAV 地址与用户名仅保存在当前浏览器;密码只存于服务端,每次会话需重新输入。",
593
+ webdavPathHint: "填完整的文件地址(含文件名):备份写进这个文件,恢复也从它读。开启自动备份后每天最多上传一次。",
592
594
  localOnly: "WebDAV 地址和用户名只保存在此浏览器。",
593
595
  credsWarning: "请注意:备份包含 profile 配置,以及可能含密钥的文件(config.toml、.env 等)。本地导出的文件会原样包含这些内容,上传 WebDAV 前请确认目标可信。",
594
596
  gist: "GitHub Gist",
@@ -1112,6 +1114,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
1112
1114
  notesNpm: "Version publish times",
1113
1115
  notesNone: "This plugin does not publish update notes.",
1114
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.",
1115
1118
  notesSourceLink: "See what changed in the repository",
1116
1119
  notesSourceNoLink: "You can ask the plugin author what changed.",
1117
1120
  notesLoadFail: "Update notes are unavailable right now — try again later.",
@@ -1306,6 +1309,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
1306
1309
  webdavRestore: "Restore from WebDAV",
1307
1310
  autoBackup: "Back up daily (when the market opens)",
1308
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.",
1309
1313
  localOnly: "The WebDAV URL and username stay in this browser only.",
1310
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.",
1311
1315
  gist: "GitHub Gist",
@@ -8591,6 +8595,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
8591
8595
  const [restarting, setRestarting] = (0, react.useState)(false);
8592
8596
  const [showTop, setShowTop] = (0, react.useState)(false);
8593
8597
  const [backupBusy, setBackupBusy] = (0, react.useState)(false);
8598
+ const webdavInFlight = (0, react.useRef)(false);
8594
8599
  const [backupMessage, setBackupMessage] = (0, react.useState)(null);
8595
8600
  const [backupRestored, setBackupRestored] = (0, react.useState)(false);
8596
8601
  const [pendingBackup, setPendingBackup] = (0, react.useState)(null);
@@ -10743,7 +10748,8 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
10743
10748
  }).catch((error) => setBackupMessage(String(error))).finally(() => setBackupBusy(false));
10744
10749
  }, [finishRestore, pendingBackup]);
10745
10750
  const runWebdav = (0, react.useCallback)((action) => {
10746
- if (webdavUrl.trim() === "") return;
10751
+ if (webdavUrl.trim() === "" || webdavInFlight.current) return;
10752
+ webdavInFlight.current = true;
10747
10753
  setBackupBusy(true);
10748
10754
  setBackupMessage(null);
10749
10755
  setRestoreErrors([]);
@@ -10766,7 +10772,10 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
10766
10772
  } catch {}
10767
10773
  setBackupMessage(t("backupDone"));
10768
10774
  }
10769
- }).catch((error) => setBackupMessage(String(error))).finally(() => setBackupBusy(false));
10775
+ }).catch((error) => setBackupMessage(String(error))).finally(() => {
10776
+ webdavInFlight.current = false;
10777
+ setBackupBusy(false);
10778
+ });
10770
10779
  }, [
10771
10780
  previewBackup,
10772
10781
  t,
@@ -10890,6 +10899,10 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
10890
10899
  setExportIncludeConfig(false);
10891
10900
  setExportOpen(true);
10892
10901
  }, [installed, installedBundles]);
10902
+ const runWebdavRef = (0, react.useRef)(runWebdav);
10903
+ (0, react.useEffect)(() => {
10904
+ runWebdavRef.current = runWebdav;
10905
+ }, [runWebdav]);
10893
10906
  (0, react.useEffect)(() => {
10894
10907
  try {
10895
10908
  localStorage.setItem(WEBDAV_STORAGE_KEY, JSON.stringify({
@@ -10898,18 +10911,19 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
10898
10911
  auto: autoBackup
10899
10912
  }));
10900
10913
  } catch {}
10901
- if (!autoBackup || webdavUrl.trim() === "") return;
10902
- let last = 0;
10903
- try {
10904
- last = Number(localStorage.getItem("dshm-webdav-last")) || 0;
10905
- } catch {}
10906
- if (Date.now() - last >= 864e5) runWebdav("backup");
10907
10914
  }, [
10908
10915
  autoBackup,
10909
- runWebdav,
10910
10916
  webdavUrl,
10911
10917
  webdavUser
10912
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]);
10913
10927
  const sessionPendingRestart = doneUrls.length + restartNames.length + removedCount + toggleRestart + (backupRestored ? 1 : 0);
10914
10928
  /**
10915
10929
  * Plugins the HOST reports as restart-pending, independent of what this
@@ -12460,6 +12474,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
12460
12474
  placeholder: t("webdavUrl"),
12461
12475
  onChange: (e) => setWebdavUrl(e.target.value)
12462
12476
  }),
12477
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: t("webdavPathHint") }),
12463
12478
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Input, {
12464
12479
  className: Market_module_css_default.backupInput,
12465
12480
  autoComplete: "username",
@@ -14283,7 +14298,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
14283
14298
  })]
14284
14299
  }) : updateNotes.kind === "source" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
14285
14300
  className: Market_module_css_default.spec,
14286
- 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", {
14287
14302
  className: Market_module_css_default.notesSourceLink,
14288
14303
  href: updateNotes.sourceUrl,
14289
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 {
@@ -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.23",
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)
@@ -4576,7 +4577,8 @@ export function MarketSection(props: MarketSectionProps) {
4576
4577
  }, [finishRestore, pendingBackup])
4577
4578
 
4578
4579
  const runWebdav = useCallback((action: 'backup' | 'restore') => {
4579
- if (webdavUrl.trim() === '') return
4580
+ if (webdavUrl.trim() === '' || webdavInFlight.current) return
4581
+ webdavInFlight.current = true
4580
4582
  setBackupBusy(true)
4581
4583
  setBackupMessage(null)
4582
4584
  setRestoreErrors([])
@@ -4593,7 +4595,10 @@ export function MarketSection(props: MarketSectionProps) {
4593
4595
  try { localStorage.setItem('dshm-webdav-last', String(Date.now())) } catch { /* storage unavailable */ }
4594
4596
  setBackupMessage(t('backupDone'))
4595
4597
  }
4596
- }).catch(error => setBackupMessage(String(error))).finally(() => setBackupBusy(false))
4598
+ }).catch(error => setBackupMessage(String(error))).finally(() => {
4599
+ webdavInFlight.current = false
4600
+ setBackupBusy(false)
4601
+ })
4597
4602
  }, [previewBackup, t, webdavPassword, webdavUrl, webdavUser])
4598
4603
 
4599
4604
  /** Map the server's token-source string to a localized label. */
@@ -4718,6 +4723,9 @@ export function MarketSection(props: MarketSectionProps) {
4718
4723
  setExportOpen(true)
4719
4724
  }, [installed, installedBundles])
4720
4725
 
4726
+ const runWebdavRef = useRef(runWebdav)
4727
+ useEffect(() => { runWebdavRef.current = runWebdav }, [runWebdav])
4728
+
4721
4729
  useEffect(() => {
4722
4730
  // Persist only the non-secret WebDAV settings; the password stays
4723
4731
  // server-side/in-memory (see savedWebdav). Storage itself may be
@@ -4725,13 +4733,18 @@ export function MarketSection(props: MarketSectionProps) {
4725
4733
  try {
4726
4734
  localStorage.setItem(WEBDAV_STORAGE_KEY, JSON.stringify({ url: webdavUrl, username: webdavUser, auto: autoBackup }))
4727
4735
  } catch { /* storage unavailable — config just won't survive reload */ }
4728
- 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
4729
4742
  let last = 0
4730
4743
  try {
4731
4744
  last = Number(localStorage.getItem('dshm-webdav-last')) || 0
4732
4745
  } catch { /* ignore */ }
4733
- if (Date.now() - last >= 24 * 60 * 60 * 1000) runWebdav('backup')
4734
- }, [autoBackup, runWebdav, webdavUrl, webdavUser])
4746
+ if (Date.now() - last >= 24 * 60 * 60 * 1000) runWebdavRef.current('backup')
4747
+ }, [autoBackup])
4735
4748
 
4736
4749
  // #558: count restart-pending session changes (restartNames), not every
4737
4750
  // completed `updatedNames` entry (which would double-count a batch update
@@ -6067,6 +6080,12 @@ export function MarketSection(props: MarketSectionProps) {
6067
6080
  ]}
6068
6081
  />
6069
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>
6070
6089
  <Input className={css.backupInput} autoComplete="username" value={webdavUser} placeholder={t('webdavUser')} onChange={e => setWebdavUser(e.target.value)} />
6071
6090
  <Input className={css.backupInput} type="password" autoComplete="current-password" value={webdavPassword} placeholder={t('webdavPassword')} onChange={e => setWebdavPassword(e.target.value)} />
6072
6091
  <div className={css.backupActions}>
@@ -7631,7 +7650,9 @@ export function MarketSection(props: MarketSectionProps) {
7631
7650
  )
7632
7651
  : updateNotes.kind === 'source' ? (
7633
7652
  <div className={css.spec}>
7634
- {t(updateNotes.reason === 'unavailable' ? 'notesLoadFail' : 'notesSource')}
7653
+ {t(updateNotes.reason === 'unavailable'
7654
+ ? 'notesLoadFail'
7655
+ : updateNotes.reason === 'unreadable' ? 'notesSourceUnreadable' : 'notesSource')}
7635
7656
  {typeof updateNotes.sourceUrl === 'string' && /^https?:\/\//i.test(updateNotes.sourceUrl)
7636
7657
  ? <>{lang === 'zh' ? '' : ' '}<a className={css.notesSourceLink} href={updateNotes.sourceUrl} target="_blank" rel="noreferrer">{t('notesSourceLink')}</a></>
7637
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: '暂时读不到更新说明,稍后再试。',
@@ -398,6 +399,7 @@ export const zh = {
398
399
  webdavRestore: '从 WebDAV 恢复',
399
400
  autoBackup: '每天自动备份(打开市场时)',
400
401
  webdavNote: 'WebDAV 地址与用户名仅保存在当前浏览器;密码只存于服务端,每次会话需重新输入。',
402
+ webdavPathHint: '填完整的文件地址(含文件名):备份写进这个文件,恢复也从它读。开启自动备份后每天最多上传一次。',
401
403
  localOnly: 'WebDAV 地址和用户名只保存在此浏览器。',
402
404
  credsWarning: '请注意:备份包含 profile 配置,以及可能含密钥的文件(config.toml、.env 等)。本地导出的文件会原样包含这些内容,上传 WebDAV 前请确认目标可信。',
403
405
  gist: 'GitHub Gist',
@@ -935,6 +937,7 @@ export const en: Record<MarketKey, string> = {
935
937
  notesNpm: 'Version publish times',
936
938
  notesNone: 'This plugin does not publish update notes.',
937
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.',
938
941
  notesSourceLink: 'See what changed in the repository',
939
942
  notesSourceNoLink: 'You can ask the plugin author what changed.',
940
943
  notesLoadFail: 'Update notes are unavailable right now — try again later.',
@@ -1129,6 +1132,7 @@ export const en: Record<MarketKey, string> = {
1129
1132
  webdavRestore: 'Restore from WebDAV',
1130
1133
  autoBackup: 'Back up daily (when the market opens)',
1131
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.',
1132
1136
  localOnly: 'The WebDAV URL and username stay in this browser only.',
1133
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.',
1134
1138
  gist: 'GitHub Gist',
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 {