dshmarket 1.46.1 → 1.47.0

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/README.md CHANGED
@@ -123,6 +123,10 @@ DSHM_REGISTRY_URL=https://your-mirror.example/plugins.json dsh web
123
123
 
124
124
  [local-dsh](https://github.com/liangchen-harold/local-dsh) — a DeepSeek Harness desktop client that can run the model on your own machine: it bundles llama.cpp next to Node, pnpm and DSH, so a downloaded GGUF model answers without any external API. Built on Tauri; Apple Silicon Macs for now. [localdsh.com](https://localdsh.com)
125
125
 
126
+ ### dsh desktop (MochiNek0)
127
+
128
+ [dsh-desktop](https://github.com/MochiNek0/dsh-desktop) — a cross-platform DeepSeek Harness desktop client built on Tauri (Windows, macOS, Linux). It uses the system webview, so the installer is a few megabytes: 2.3 MB on Windows, 5.8 MB on macOS. It starts `dsh web` in the background on launch and embeds it in a native window, sharing sessions and config with the CLI. This market sits first in the recommended list of its built-in plugin panel, one click to install. A Runtime panel enumerates and switches the machine's Node installs and installs or upgrades dsh, no administrator privileges required, and native notifications fire when a turn ends or dsh is waiting on you. [dsh-desktop.cc.cd](https://dsh-desktop.cc.cd/)
129
+
126
130
  ### DSH Get
127
131
 
128
132
  [DSH Get](https://www.dshget.com/) — a searchable web directory for discovering DeepSeek Harness plugins: category filters, bilingual descriptions, install commands and per-plugin detail pages. Its normalized catalog snapshot is public at [bobby-sheng/dshget-data](https://github.com/bobby-sheng/dshget-data).
package/README.zh.md CHANGED
@@ -120,6 +120,10 @@ DSHM_REGISTRY_URL=https://your-mirror.example/plugins.json dsh web
120
120
 
121
121
  [local-dsh](https://github.com/liangchen-harold/local-dsh)——可以把模型跑在本机的 DeepSeek Harness 桌面客户端:发行包内置 llama.cpp 与 Node、pnpm、DSH,下载一个 GGUF 模型就能对话,不必接外部 API。基于 Tauri 构建;目前支持 Apple 芯片的 Mac。[localdsh.com](https://localdsh.com)
122
122
 
123
+ ### dsh desktop(MochiNek0)
124
+
125
+ [dsh-desktop](https://github.com/MochiNek0/dsh-desktop)——基于 Tauri 构建的跨平台 DeepSeek Harness 桌面客户端(Windows / macOS / Linux),界面走系统 webview,安装包只有几 MB(Windows 2.3 MB、macOS 5.8 MB)。启动时自动在后台拉起 `dsh web` 并嵌入原生窗口,会话与配置和 CLI 共享;内置插件面板的推荐位第一条就是本市场,点一下即可装上。另有「运行环境」面板枚举与切换本机 Node、安装或升级 dsh,全程无需管理员权限;回合结束或 dsh 等待你确认时发出系统通知。[dsh-desktop.cc.cd](https://dsh-desktop.cc.cd/)
126
+
123
127
  ### DSH Get
124
128
 
125
129
  [DSH Get](https://www.dshget.com/)——DeepSeek Harness 插件的网页检索目录:分类筛选、中英描述、安装命令与插件详情页;其规范化的目录快照公开在 [bobby-sheng/dshget-data](https://github.com/bobby-sheng/dshget-data)。
package/client/client.js CHANGED
@@ -1591,10 +1591,20 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
1591
1591
  * dependency's spec pins a github repo AND the entry states one, the repos
1592
1592
  * decide — the loose name/npm identities only apply when at least one side
1593
1593
  * carries no repo evidence (npm installs, non-github entries).
1594
+ *
1595
+ * Repo evidence only ever decides by repository ROOT. A monorepo catalog
1596
+ * entry states `owner/repo#path:/pkg` while an npm-installed manifest
1597
+ * usually states the bare `owner/repo` (it rarely declares
1598
+ * `repository.directory`), and reading that asymmetry as a source conflict
1599
+ * kept a genuinely installed subpackage from ever reading as installed.
1594
1600
  */
1601
+ /** Repository root: the part before any `#path:/…` subpath selection. */
1602
+ function repoRoots(ids) {
1603
+ return new Set([...ids].map((id) => id.split("#path:/")[0]));
1604
+ }
1595
1605
  function sameSourceConflict(plugin, spec, repoIdentities = []) {
1596
- const entry = entryRepoIds(plugin);
1597
- const dep = depRepoIds(spec, repoIdentities);
1606
+ const entry = repoRoots(entryRepoIds(plugin));
1607
+ const dep = repoRoots(depRepoIds(spec, repoIdentities));
1598
1608
  if (entry.size === 0 || dep.size === 0) return false;
1599
1609
  for (const id of dep) if (entry.has(id)) return false;
1600
1610
  return true;
@@ -1645,6 +1655,56 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
1645
1655
  for (const id of entryIdentities(plugin)) if (dep.has(id)) return true;
1646
1656
  return false;
1647
1657
  }
1658
+ /**
1659
+ * The same memo, for the branch #262 left behind (#589).
1660
+ *
1661
+ * `looseMatchCount` above covers dependencies installed by version. A
1662
+ * `link:` or `file:` dependency takes the other branch, into
1663
+ * `findCatalogEntryForLocal`, which walks the whole catalog at least twice
1664
+ * per call — once to filter by name, once to collect `/tree/` repos — and
1665
+ * up to twice more when there are identities to probe. Both callers below
1666
+ * run once per rendered card. The reporter profiled ~300ms per repaint at
1667
+ * 24 cards against a 3,627-entry catalog where a version-pinned dependency
1668
+ * paid 1.1ms; a local benchmark measured ~38ms per render at that shape,
1669
+ * and ~1.4s at 96 cards with eight local dependencies.
1670
+ *
1671
+ * The inner key carries the EVIDENCE, not just the name. Installing a plugin
1672
+ * hands the next render a fresh identities array while the catalog array
1673
+ * stays the same, so a name-only key would answer the post-install question
1674
+ * with the pre-install result — which is the same-named-fork confusion #485
1675
+ * asked this matcher to stop making, reintroduced as a cache bug.
1676
+ *
1677
+ * A miss is cached as `null`, which is why the "not cached yet" sentinel
1678
+ * has to be `undefined`: `null` is a real answer here, and it costs the
1679
+ * same full scan to establish as a hit does. It is also the common case —
1680
+ * a checkout you are developing is usually not in the catalog at all.
1681
+ *
1682
+ * The invariant this rests on, stated because the WeakMap cannot enforce it:
1683
+ * the catalog array and the entries inside it are frozen once handed here. A
1684
+ * refetch replaces the array — which is what the outer key is for — but an
1685
+ * in-place `push`, `sort` or `reverse`, or editing a row's `url`, would keep
1686
+ * the key and change the answer. Order is load-bearing too: the matcher
1687
+ * returns the FIRST row that fits. Nothing in the client does any of this
1688
+ * today; `visiblePlugins` and `themePlugins` both sort copies.
1689
+ */
1690
+ const localMatchCache = /* @__PURE__ */ new WeakMap();
1691
+ function cachedEntryForLocal(plugins, name, identities, hints) {
1692
+ let byKey = localMatchCache.get(plugins);
1693
+ if (byKey === void 0) {
1694
+ byKey = /* @__PURE__ */ new Map();
1695
+ localMatchCache.set(plugins, byKey);
1696
+ }
1697
+ const key = JSON.stringify([
1698
+ name,
1699
+ identities,
1700
+ hints
1701
+ ]);
1702
+ const hit = byKey.get(key);
1703
+ if (hit !== void 0) return hit;
1704
+ const entry = findCatalogEntryForLocal(plugins, name, identities, hints);
1705
+ byKey.set(key, entry);
1706
+ return entry;
1707
+ }
1648
1708
  /** The installed dependency name a registry entry corresponds to, or null. */
1649
1709
  function matchInstalledName(plugin, installed, repoIdentities = {}, plugins, repoHints = {}) {
1650
1710
  const ids = entryIdentities(plugin);
@@ -1653,7 +1713,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
1653
1713
  const repos = repoIdentities[name] ?? [];
1654
1714
  if (/^(?:link|file):/i.test(specStr)) {
1655
1715
  if (plugins === void 0) continue;
1656
- const entry = findCatalogEntryForLocal(plugins, name, repos, repoHints[name] ?? []);
1716
+ const entry = cachedEntryForLocal(plugins, name, repos, repoHints[name] ?? []);
1657
1717
  if (entry !== null && entry.url === plugin.url) return name;
1658
1718
  continue;
1659
1719
  }
@@ -2055,7 +2115,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
2055
2115
  }
2056
2116
  /** Catalog row for an installed dependency — strict for local link:/file: specs. */
2057
2117
  function catalogEntryForInstalled(plugins, name, spec, repoIdentities = [], repoHints = []) {
2058
- if (/^(?:link|file):/i.test(spec)) return findCatalogEntryForLocal(plugins, name, repoIdentities, repoHints) ?? void 0;
2118
+ if (/^(?:link|file):/i.test(spec)) return cachedEntryForLocal(plugins, name, repoIdentities, repoHints) ?? void 0;
2059
2119
  return entryForDep(plugins, name, spec, repoIdentities, repoHints);
2060
2120
  }
2061
2121
  //#endregion
@@ -8614,7 +8674,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
8614
8674
  children: updatingName === self ? t("updating") : status.restoreRequired === true ? t("restoreOnline") : t("marketUpdate")
8615
8675
  });
8616
8676
  })(),
8617
- reminderBatchUpdatableNames.length >= 2 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
8677
+ reminderBatchUpdatableNames.length >= 1 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
8618
8678
  variant: "primary",
8619
8679
  size: "sm",
8620
8680
  disabled: updatingAll || updatingName !== null || busyUrl !== null || removingName !== null,
package/lib/accelerate.js CHANGED
@@ -112,6 +112,16 @@ async function tryHeadCommit(repo, proxy, signal, ref) {
112
112
  // metacharacters (`.` is legal in a branch name).
113
113
  const quoted = ref.replace(/[.*+?^${}()|[\]\\]/gu, String.raw `\$&`);
114
114
  for (const namespace of ['heads', 'tags']) {
115
+ // An annotated tag advertises two refs — the tag object and its
116
+ // peeled commit (`refs/tags/<ref>^{}`). pnpm resolves the tag to the
117
+ // peeled commit and codeload records that sha in the lockfile, so the
118
+ // peeled line is the one that must equal `current`; answering with
119
+ // the tag-object sha made annotated-tag installs report an update
120
+ // forever (#597). Lightweight tags have no peeled line and fall
121
+ // through to the direct one.
122
+ const peeled = new RegExp(String.raw `([0-9a-f]{40}) refs/${namespace}/${quoted}\^\{\}(?![^\s])`, 'u').exec(body);
123
+ if (peeled !== null)
124
+ return { kind: 'valid', sha: peeled[1] };
115
125
  const found = new RegExp(String.raw `([0-9a-f]{40}) refs/${namespace}/${quoted}(?![^\s])`, 'u').exec(body);
116
126
  if (found !== null)
117
127
  return { kind: 'valid', sha: found[1] };
package/lib/changelog.js CHANGED
@@ -20,8 +20,8 @@
20
20
  import { fileFromTarball } from './catalog-npm.js';
21
21
  import { marketFetch } from './net.js';
22
22
  import { activeRegion, routesFor } from './regions.js';
23
- import { profileDir, readInstalled, readLockCommits } from './profile.js';
24
- import { lookupRepoFromUrl, repoOfTarget } from './sources.js';
23
+ import { profileDir, readInstalled, readInstalledRepoEvidence, readLockCommits } from './profile.js';
24
+ import { lookupRepoFromUrl, repoOf, repoOfTarget } from './sources.js';
25
25
  import { checkUpdates } from './updates.js';
26
26
  import { loadRegistry } from './registry.js';
27
27
  const UPDATES_PACKAGE = 'dsh-plugin-updates';
@@ -198,7 +198,24 @@ export async function updateNotesFor(profile, explicitDir, name) {
198
198
  if (key === null) {
199
199
  try {
200
200
  const registry = await loadRegistry();
201
- const plugin = registry.plugins.find(p => p.name === name);
201
+ const candidates = registry.plugins.filter(p => p.name === name);
202
+ let plugin;
203
+ if (candidates.length === 1) {
204
+ plugin = candidates[0];
205
+ }
206
+ else if (candidates.length > 1) {
207
+ // Same-named packages exist in the catalog; a bare name match can
208
+ // pick someone else's repo and answer "no notes" for a plugin that
209
+ // ships updates data under its own repository (#598). The installed
210
+ // package declares its repository, so prefer the catalog entry that
211
+ // agrees with it; only a unique agreement is trusted — an ambiguous
212
+ // name falls through to npm publish times, which are honest for any
213
+ // installed npm package.
214
+ const evidence = readInstalledRepoEvidence(profile, name, spec, explicitDir);
215
+ const matches = candidates.filter(p => evidence.identities.some(id => repoOf(p.url)?.toLowerCase() === id.split('#')[0].toLowerCase()));
216
+ if (matches.length === 1)
217
+ plugin = matches[0];
218
+ }
202
219
  if (plugin !== undefined) {
203
220
  key = plugin.url;
204
221
  }
package/lib/hot.js CHANGED
@@ -24,11 +24,62 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
24
24
  return path;
25
25
  };
26
26
  import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
27
+ import { createRequire } from 'node:module';
27
28
  import { join } from 'node:path';
28
29
  import { pathToFileURL } from 'node:url';
29
30
  import { asChannel } from './channels.js';
30
31
  import { asRegion, normalizeGithubProxy } from './regions.js';
31
32
  import { logEvent } from './log.js';
33
+ import { entryArtifactExists } from './profile.js';
34
+ /**
35
+ * Profile-scoped resolution for hot-mount rows: turn a bare package name into
36
+ * the absolute `file://` entry URL of the package just installed into
37
+ * `profileDir`.
38
+ *
39
+ * Include-tree rows reach `Include.import` as BARE names (`name:
40
+ * '@scope/pkg'`), and the base class resolves them against the LOADER's own
41
+ * location — the host closure
42
+ * (`closures/<fp>/node_modules/…/cordis-plugin-loader`), whose parent walk
43
+ * can never reach `home/profiles/<profile>/node_modules/`. Under a host whose
44
+ * loader sits in an immutable dependency closure, EVERY market hot mount dies
45
+ * with `Cannot find module '<pkg>' from '…/cordis-plugin-loader/…'` and falls
46
+ * back to "restart required", blaming the plugin for what is a resolution
47
+ * anchor problem.
48
+ *
49
+ * Resolving the row name HERE, against the profile the package was actually
50
+ * installed into, is anchor-independent: `require.resolve` walks
51
+ * `profileDir/node_modules` natively, so the tree hands the loader a
52
+ * `file://` URL needing no further resolution. Non-bare specifiers (relative
53
+ * paths, `file://`, `cordis:` builtins) and names that do not resolve under
54
+ * the profile pass through unchanged, preserving base-class semantics for
55
+ * every shape this fix does not own.
56
+ *
57
+ * The fallback keeps the name bare rather than synthesising a URL: a package
58
+ * whose entry cannot be located via `require.resolve` (no `main`/exports —
59
+ * the market's own `entryArtifactExists` heuristic covers those shapes before
60
+ * an install is accepted) is not something this resolver should guess about.
61
+ * Client-only shims never reach this function (their rows are replaced by a
62
+ * no-op host module before the file is written).
63
+ */
64
+ export function resolveProfileEntry(profileDir, name) {
65
+ if (!name || name.startsWith('.') || name.startsWith('cordis:') || name.startsWith('file://'))
66
+ return name;
67
+ const packageDir = join(profileDir, 'node_modules', ...name.split('/'));
68
+ try {
69
+ return pathToFileURL(createRequire(join(profileDir, 'package.json')).resolve(name)).href;
70
+ }
71
+ catch {
72
+ // require.resolve needs a resolvable package entry; the market's install
73
+ // validation accepts a broader artifact set (exports objects, index.js).
74
+ // Fall back to the index.js artifact so a valid mount is not refused over
75
+ // resolver strictness — and keep the bare name when no checkable entry
76
+ // exists, letting the loader produce its own (accurate) error.
77
+ if (entryArtifactExists(packageDir)) {
78
+ return pathToFileURL(join(packageDir, 'index.js')).href;
79
+ }
80
+ return name;
81
+ }
82
+ }
32
83
  const HOT_DIR = '.dsh-market';
33
84
  /**
34
85
  * Ceiling for one hot-mount activation, env-overridable like the install
@@ -394,8 +445,14 @@ export async function hotMount(ctx, profileDir, packageName) {
394
445
  mkdirSync(dir, { recursive: true, mode: 0o700 });
395
446
  hotSequence += 1;
396
447
  const file = join(dir, `hot-${String(hotSequence)}.yml`);
448
+ // Rows carry ABSOLUTE file:// entry URLs, resolved against this profile:
449
+ // the loader's own parent-walk (from the host closure) can never reach
450
+ // `profileDir/node_modules`, so bare names in the file would fail to
451
+ // import on closure-hosted loaders. The file remains a faithful record —
452
+ // `cleanHotDir` wipes it on every boot and the bundle layer owns
453
+ // persistence, so nothing reads these files back.
397
454
  const yml = rows
398
- .map(row => `- id: 'mkt-${row.id}'\n name: '${row.name}'\n`)
455
+ .map(row => `- id: 'mkt-${row.id}'\n name: '${resolveProfileEntry(profileDir, row.name)}'\n`)
399
456
  .join('');
400
457
  writeFileSync(file, yml);
401
458
  const handle = ctx.plugin(HotTree, { path: pathToFileURL(file).href });
@@ -403,16 +460,25 @@ export async function hotMount(ctx, profileDir, packageName) {
403
460
  await raceActivationTimeout(handle.await());
404
461
  }
405
462
  catch (error) {
463
+ // A failed or wedged mount must leave NOTHING behind: the disposed
464
+ // subtree stops retrying the import, and the input file is removed so
465
+ // it cannot be re-imported by a later boot or replay (a leftover file
466
+ // re-throwing the same resolve error on every composition replay
467
+ // produced unbounded error-log growth on a closure-hosted loader).
468
+ try {
469
+ Promise.resolve(handle.dispose()).catch(() => { });
470
+ }
471
+ catch { /* best effort */ }
472
+ try {
473
+ rmSync(file, { force: true });
474
+ }
475
+ catch { /* best effort */ }
406
476
  if (error instanceof ActivationTimeout) {
407
477
  // A wedged activation would otherwise hold this request open forever:
408
478
  // the route's `finally { installing = false }` never runs, so every
409
479
  // later install/update/uninstall gets 409'd until a host restart.
410
480
  // Unwind the half-mounted subtree best-effort; disposal never blocks
411
481
  // the reply, and the caller falls back to restart activation.
412
- try {
413
- Promise.resolve(handle.dispose()).catch(() => { });
414
- }
415
- catch { /* best effort */ }
416
482
  }
417
483
  throw error;
418
484
  }
@@ -32,6 +32,37 @@ interface HotContext {
32
32
  warn(message: string): void;
33
33
  };
34
34
  }
35
+ /**
36
+ * Profile-scoped resolution for hot-mount rows: turn a bare package name into
37
+ * the absolute `file://` entry URL of the package just installed into
38
+ * `profileDir`.
39
+ *
40
+ * Include-tree rows reach `Include.import` as BARE names (`name:
41
+ * '@scope/pkg'`), and the base class resolves them against the LOADER's own
42
+ * location — the host closure
43
+ * (`closures/<fp>/node_modules/…/cordis-plugin-loader`), whose parent walk
44
+ * can never reach `home/profiles/<profile>/node_modules/`. Under a host whose
45
+ * loader sits in an immutable dependency closure, EVERY market hot mount dies
46
+ * with `Cannot find module '<pkg>' from '…/cordis-plugin-loader/…'` and falls
47
+ * back to "restart required", blaming the plugin for what is a resolution
48
+ * anchor problem.
49
+ *
50
+ * Resolving the row name HERE, against the profile the package was actually
51
+ * installed into, is anchor-independent: `require.resolve` walks
52
+ * `profileDir/node_modules` natively, so the tree hands the loader a
53
+ * `file://` URL needing no further resolution. Non-bare specifiers (relative
54
+ * paths, `file://`, `cordis:` builtins) and names that do not resolve under
55
+ * the profile pass through unchanged, preserving base-class semantics for
56
+ * every shape this fix does not own.
57
+ *
58
+ * The fallback keeps the name bare rather than synthesising a URL: a package
59
+ * whose entry cannot be located via `require.resolve` (no `main`/exports —
60
+ * the market's own `entryArtifactExists` heuristic covers those shapes before
61
+ * an install is accepted) is not something this resolver should guess about.
62
+ * Client-only shims never reach this function (their rows are replaced by a
63
+ * no-op host module before the file is written).
64
+ */
65
+ export declare function resolveProfileEntry(profileDir: string, name: string): string;
35
66
  /**
36
67
  * Insert rows of a plugin's bundle patch, or null when the patch contains
37
68
  * anything beyond plain `id`/`name` insert rows (config blocks, disables,
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.46.1",
4
+ "version": "1.47.0",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/types/index.d.ts",
package/src/accelerate.ts CHANGED
@@ -132,6 +132,15 @@ async function tryHeadCommit(
132
132
  // metacharacters (`.` is legal in a branch name).
133
133
  const quoted = ref.replace(/[.*+?^${}()|[\]\\]/gu, String.raw`\$&`)
134
134
  for (const namespace of ['heads', 'tags']) {
135
+ // An annotated tag advertises two refs — the tag object and its
136
+ // peeled commit (`refs/tags/<ref>^{}`). pnpm resolves the tag to the
137
+ // peeled commit and codeload records that sha in the lockfile, so the
138
+ // peeled line is the one that must equal `current`; answering with
139
+ // the tag-object sha made annotated-tag installs report an update
140
+ // forever (#597). Lightweight tags have no peeled line and fall
141
+ // through to the direct one.
142
+ const peeled = new RegExp(String.raw`([0-9a-f]{40}) refs/${namespace}/${quoted}\^\{\}(?![^\s])`, 'u').exec(body)
143
+ if (peeled !== null) return { kind: 'valid', sha: peeled[1]! }
135
144
  const found = new RegExp(String.raw`([0-9a-f]{40}) refs/${namespace}/${quoted}(?![^\s])`, 'u').exec(body)
136
145
  if (found !== null) return { kind: 'valid', sha: found[1]! }
137
146
  }
package/src/changelog.ts CHANGED
@@ -21,10 +21,10 @@
21
21
  import { fileFromTarball } from './catalog-npm.ts'
22
22
  import { marketFetch } from './net.ts'
23
23
  import { activeRegion, routesFor } from './regions.ts'
24
- import { profileDir, readInstalled, readLockCommits } from './profile.ts'
25
- import { lookupRepoFromUrl, repoOfTarget } from './sources.ts'
24
+ import { profileDir, readInstalled, readInstalledRepoEvidence, readLockCommits } from './profile.ts'
25
+ import { lookupRepoFromUrl, repoOf, repoOfTarget } from './sources.ts'
26
26
  import { checkUpdates } from './updates.ts'
27
- import { loadRegistry } from './registry.ts'
27
+ import { loadRegistry, type RegistryPlugin } from './registry.ts'
28
28
 
29
29
  const UPDATES_PACKAGE = 'dsh-plugin-updates'
30
30
  const UPDATES_FILE = 'package/updates.json'
@@ -236,7 +236,24 @@ export async function updateNotesFor(
236
236
  if (key === null) {
237
237
  try {
238
238
  const registry = await loadRegistry()
239
- const plugin = registry.plugins.find(p => p.name === name)
239
+ const candidates = registry.plugins.filter(p => p.name === name)
240
+ let plugin: RegistryPlugin | undefined
241
+ if (candidates.length === 1) {
242
+ plugin = candidates[0]!
243
+ } else if (candidates.length > 1) {
244
+ // Same-named packages exist in the catalog; a bare name match can
245
+ // pick someone else's repo and answer "no notes" for a plugin that
246
+ // ships updates data under its own repository (#598). The installed
247
+ // package declares its repository, so prefer the catalog entry that
248
+ // agrees with it; only a unique agreement is trusted — an ambiguous
249
+ // name falls through to npm publish times, which are honest for any
250
+ // installed npm package.
251
+ const evidence = readInstalledRepoEvidence(profile, name, spec, explicitDir)
252
+ const matches = candidates.filter(p => evidence.identities.some(
253
+ id => repoOf(p.url)?.toLowerCase() === id.split('#')[0]!.toLowerCase(),
254
+ ))
255
+ if (matches.length === 1) plugin = matches[0]!
256
+ }
240
257
  if (plugin !== undefined) {
241
258
  key = plugin.url
242
259
  }
@@ -3745,7 +3745,7 @@ export function MarketSection(props: MarketSectionProps) {
3745
3745
  >{updatingName === self ? t('updating') : status.restoreRequired === true ? t('restoreOnline') : t('marketUpdate')}</Button>
3746
3746
  )
3747
3747
  })()}
3748
- {reminderBatchUpdatableNames.length >= 2 && (
3748
+ {reminderBatchUpdatableNames.length >= 1 && (
3749
3749
  <Button
3750
3750
  variant="primary"
3751
3751
  size="sm"
@@ -691,10 +691,20 @@ function entryRepoIds(plugin: RegistryPlugin): Set<string> {
691
691
  * dependency's spec pins a github repo AND the entry states one, the repos
692
692
  * decide — the loose name/npm identities only apply when at least one side
693
693
  * carries no repo evidence (npm installs, non-github entries).
694
+ *
695
+ * Repo evidence only ever decides by repository ROOT. A monorepo catalog
696
+ * entry states `owner/repo#path:/pkg` while an npm-installed manifest
697
+ * usually states the bare `owner/repo` (it rarely declares
698
+ * `repository.directory`), and reading that asymmetry as a source conflict
699
+ * kept a genuinely installed subpackage from ever reading as installed.
694
700
  */
701
+ /** Repository root: the part before any `#path:/…` subpath selection. */
702
+ function repoRoots(ids: ReadonlySet<string>): Set<string> {
703
+ return new Set([...ids].map(id => id.split('#path:/')[0]!))
704
+ }
695
705
  function sameSourceConflict(plugin: RegistryPlugin, spec: string, repoIdentities: readonly string[] = []): boolean {
696
- const entry = entryRepoIds(plugin)
697
- const dep = depRepoIds(spec, repoIdentities)
706
+ const entry = repoRoots(entryRepoIds(plugin))
707
+ const dep = repoRoots(depRepoIds(spec, repoIdentities))
698
708
  if (entry.size === 0 || dep.size === 0) return false
699
709
  for (const id of dep) if (entry.has(id)) return false
700
710
  return true
@@ -754,6 +764,66 @@ function looseMatches(plugin: RegistryPlugin, name: string): boolean {
754
764
  return false
755
765
  }
756
766
 
767
+ /**
768
+ * The same memo, for the branch #262 left behind (#589).
769
+ *
770
+ * `looseMatchCount` above covers dependencies installed by version. A
771
+ * `link:` or `file:` dependency takes the other branch, into
772
+ * `findCatalogEntryForLocal`, which walks the whole catalog at least twice
773
+ * per call — once to filter by name, once to collect `/tree/` repos — and
774
+ * up to twice more when there are identities to probe. Both callers below
775
+ * run once per rendered card. The reporter profiled ~300ms per repaint at
776
+ * 24 cards against a 3,627-entry catalog where a version-pinned dependency
777
+ * paid 1.1ms; a local benchmark measured ~38ms per render at that shape,
778
+ * and ~1.4s at 96 cards with eight local dependencies.
779
+ *
780
+ * The inner key carries the EVIDENCE, not just the name. Installing a plugin
781
+ * hands the next render a fresh identities array while the catalog array
782
+ * stays the same, so a name-only key would answer the post-install question
783
+ * with the pre-install result — which is the same-named-fork confusion #485
784
+ * asked this matcher to stop making, reintroduced as a cache bug.
785
+ *
786
+ * A miss is cached as `null`, which is why the "not cached yet" sentinel
787
+ * has to be `undefined`: `null` is a real answer here, and it costs the
788
+ * same full scan to establish as a hit does. It is also the common case —
789
+ * a checkout you are developing is usually not in the catalog at all.
790
+ *
791
+ * The invariant this rests on, stated because the WeakMap cannot enforce it:
792
+ * the catalog array and the entries inside it are frozen once handed here. A
793
+ * refetch replaces the array — which is what the outer key is for — but an
794
+ * in-place `push`, `sort` or `reverse`, or editing a row's `url`, would keep
795
+ * the key and change the answer. Order is load-bearing too: the matcher
796
+ * returns the FIRST row that fits. Nothing in the client does any of this
797
+ * today; `visiblePlugins` and `themePlugins` both sort copies.
798
+ */
799
+ const localMatchCache = new WeakMap<RegistryPlugin[], Map<string, RegistryPlugin | null>>()
800
+
801
+ function cachedEntryForLocal(
802
+ plugins: RegistryPlugin[],
803
+ name: string,
804
+ identities: readonly string[],
805
+ hints: readonly string[],
806
+ ): RegistryPlugin | null {
807
+ let byKey = localMatchCache.get(plugins)
808
+ if (byKey === undefined) {
809
+ byKey = new Map<string, RegistryPlugin | null>()
810
+ localMatchCache.set(plugins, byKey)
811
+ }
812
+ // JSON, not a delimiter-joined string. `[]` and `['']` join to the same
813
+ // thing and they are NOT the same question: an empty-but-present identity
814
+ // list has size 1, so it enters the evidence branch and refuses to guess,
815
+ // while an absent one falls through to the unique-name match. A key that
816
+ // cannot tell those apart lets whichever ran first answer for both, which
817
+ // is the guess this matcher exists to refuse. Stringifying the arrays is
818
+ // injective for free, and its cost is noise beside the scan it replaces.
819
+ const key = JSON.stringify([name, identities, hints])
820
+ const hit = byKey.get(key)
821
+ if (hit !== undefined) return hit
822
+ const entry = findCatalogEntryForLocal(plugins, name, identities, hints)
823
+ byKey.set(key, entry)
824
+ return entry
825
+ }
826
+
757
827
  /** The installed dependency name a registry entry corresponds to, or null. */
758
828
  export function matchInstalledName(
759
829
  plugin: RegistryPlugin,
@@ -772,7 +842,7 @@ export function matchInstalledName(
772
842
  // else's fork as installed (#485).
773
843
  if (/^(?:link|file):/i.test(specStr)) {
774
844
  if (plugins === undefined) continue
775
- const entry = findCatalogEntryForLocal(plugins, name, repos, repoHints[name] ?? [])
845
+ const entry = cachedEntryForLocal(plugins, name, repos, repoHints[name] ?? [])
776
846
  if (entry !== null && entry.url === plugin.url) return name
777
847
  continue
778
848
  }
@@ -1316,7 +1386,7 @@ export function catalogEntryForInstalled(
1316
1386
  repoHints: readonly string[] = [],
1317
1387
  ): RegistryPlugin | undefined {
1318
1388
  if (/^(?:link|file):/i.test(spec)) {
1319
- return findCatalogEntryForLocal(plugins, name, repoIdentities, repoHints) ?? undefined
1389
+ return cachedEntryForLocal(plugins, name, repoIdentities, repoHints) ?? undefined
1320
1390
  }
1321
1391
  return entryForDep(plugins, name, spec, repoIdentities, repoHints)
1322
1392
  }
package/src/hot.ts CHANGED
@@ -17,11 +17,13 @@
17
17
  */
18
18
 
19
19
  import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
20
+ import { createRequire } from 'node:module'
20
21
  import { join } from 'node:path'
21
22
  import { pathToFileURL } from 'node:url'
22
23
  import { asChannel, type Channel } from './channels.ts'
23
24
  import { asRegion, normalizeGithubProxy, type Region } from './regions.ts'
24
25
  import { logEvent } from './log.ts'
26
+ import { entryArtifactExists } from './profile.ts'
25
27
 
26
28
  interface HotRow {
27
29
  id: string
@@ -38,6 +40,54 @@ interface HotContext {
38
40
  logger?: { info?(message: string): void; warn(message: string): void }
39
41
  }
40
42
 
43
+ /**
44
+ * Profile-scoped resolution for hot-mount rows: turn a bare package name into
45
+ * the absolute `file://` entry URL of the package just installed into
46
+ * `profileDir`.
47
+ *
48
+ * Include-tree rows reach `Include.import` as BARE names (`name:
49
+ * '@scope/pkg'`), and the base class resolves them against the LOADER's own
50
+ * location — the host closure
51
+ * (`closures/<fp>/node_modules/…/cordis-plugin-loader`), whose parent walk
52
+ * can never reach `home/profiles/<profile>/node_modules/`. Under a host whose
53
+ * loader sits in an immutable dependency closure, EVERY market hot mount dies
54
+ * with `Cannot find module '<pkg>' from '…/cordis-plugin-loader/…'` and falls
55
+ * back to "restart required", blaming the plugin for what is a resolution
56
+ * anchor problem.
57
+ *
58
+ * Resolving the row name HERE, against the profile the package was actually
59
+ * installed into, is anchor-independent: `require.resolve` walks
60
+ * `profileDir/node_modules` natively, so the tree hands the loader a
61
+ * `file://` URL needing no further resolution. Non-bare specifiers (relative
62
+ * paths, `file://`, `cordis:` builtins) and names that do not resolve under
63
+ * the profile pass through unchanged, preserving base-class semantics for
64
+ * every shape this fix does not own.
65
+ *
66
+ * The fallback keeps the name bare rather than synthesising a URL: a package
67
+ * whose entry cannot be located via `require.resolve` (no `main`/exports —
68
+ * the market's own `entryArtifactExists` heuristic covers those shapes before
69
+ * an install is accepted) is not something this resolver should guess about.
70
+ * Client-only shims never reach this function (their rows are replaced by a
71
+ * no-op host module before the file is written).
72
+ */
73
+ export function resolveProfileEntry(profileDir: string, name: string): string {
74
+ if (!name || name.startsWith('.') || name.startsWith('cordis:') || name.startsWith('file://')) return name
75
+ const packageDir = join(profileDir, 'node_modules', ...name.split('/'))
76
+ try {
77
+ return pathToFileURL(createRequire(join(profileDir, 'package.json')).resolve(name)).href
78
+ } catch {
79
+ // require.resolve needs a resolvable package entry; the market's install
80
+ // validation accepts a broader artifact set (exports objects, index.js).
81
+ // Fall back to the index.js artifact so a valid mount is not refused over
82
+ // resolver strictness — and keep the bare name when no checkable entry
83
+ // exists, letting the loader produce its own (accurate) error.
84
+ if (entryArtifactExists(packageDir)) {
85
+ return pathToFileURL(join(packageDir, 'index.js')).href
86
+ }
87
+ return name
88
+ }
89
+ }
90
+
41
91
  const HOT_DIR = '.dsh-market'
42
92
 
43
93
  /**
@@ -505,21 +555,33 @@ export async function hotMount(ctx: HotContext, profileDir: string, packageName:
505
555
  mkdirSync(dir, { recursive: true, mode: 0o700 })
506
556
  hotSequence += 1
507
557
  const file = join(dir, `hot-${String(hotSequence)}.yml`)
558
+ // Rows carry ABSOLUTE file:// entry URLs, resolved against this profile:
559
+ // the loader's own parent-walk (from the host closure) can never reach
560
+ // `profileDir/node_modules`, so bare names in the file would fail to
561
+ // import on closure-hosted loaders. The file remains a faithful record —
562
+ // `cleanHotDir` wipes it on every boot and the bundle layer owns
563
+ // persistence, so nothing reads these files back.
508
564
  const yml = rows
509
- .map(row => `- id: 'mkt-${row.id}'\n name: '${row.name}'\n`)
565
+ .map(row => `- id: 'mkt-${row.id}'\n name: '${resolveProfileEntry(profileDir, row.name)}'\n`)
510
566
  .join('')
511
567
  writeFileSync(file, yml)
512
568
  const handle = ctx.plugin(HotTree, { path: pathToFileURL(file).href })
513
569
  try {
514
570
  await raceActivationTimeout(handle.await())
515
571
  } catch (error) {
572
+ // A failed or wedged mount must leave NOTHING behind: the disposed
573
+ // subtree stops retrying the import, and the input file is removed so
574
+ // it cannot be re-imported by a later boot or replay (a leftover file
575
+ // re-throwing the same resolve error on every composition replay
576
+ // produced unbounded error-log growth on a closure-hosted loader).
577
+ try { Promise.resolve(handle.dispose()).catch(() => {}) } catch { /* best effort */ }
578
+ try { rmSync(file, { force: true }) } catch { /* best effort */ }
516
579
  if (error instanceof ActivationTimeout) {
517
580
  // A wedged activation would otherwise hold this request open forever:
518
581
  // the route's `finally { installing = false }` never runs, so every
519
582
  // later install/update/uninstall gets 409'd until a host restart.
520
583
  // Unwind the half-mounted subtree best-effort; disposal never blocks
521
584
  // the reply, and the caller falls back to restart activation.
522
- try { Promise.resolve(handle.dispose()).catch(() => {}) } catch { /* best effort */ }
523
585
  }
524
586
  throw error
525
587
  }