dshmarket 1.46.1 → 1.48.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/UPDATE-API-V1.md CHANGED
@@ -33,6 +33,37 @@ supported features, restart owner and endpoint paths. A client must hide its
33
33
  restart button when `restart.supported` is false. Desktop and supervised hosts
34
34
  normally delegate restart to their owning shell or operator.
35
35
 
36
+ ## Count what can be updated
37
+
38
+ ```http
39
+ GET /dsh-market/api/v1/updates/summary
40
+ ```
41
+
42
+ ```json
43
+ {
44
+ "schema": "dsh-market/update-api/v1",
45
+ "checked": 12,
46
+ "updatable": 3,
47
+ "packages": [
48
+ { "name": "dsh-mcp-connector", "source": "npm", "installedVersion": "1.1.0", "latestVersion": "1.2.0" }
49
+ ]
50
+ }
51
+ ```
52
+
53
+ `checked` is the denominator — how many installed plugins the answer looked
54
+ at. Read it, or a badge cannot tell "nothing to update" from "nothing was
55
+ looked at". The two are different answers and only one of them means the
56
+ profile is up to date.
57
+
58
+ `packages` carries the same objects the single-package endpoint below
59
+ returns, and lists only plugins that can be updated. A badge needs
60
+ `updatable`; a panel needs the rows; neither should have to filter the whole
61
+ profile itself.
62
+
63
+ `GET /dsh-market/api/v1/capabilities` reports this endpoint at
64
+ `endpoints.updatesSummary` and sets `features.updatesSummary`. Check that
65
+ rather than probing for the path.
66
+
36
67
  ## Check one installed package
37
68
 
38
69
  ```http
@@ -123,6 +154,37 @@ same-origin, no forwarding headers, no package mutation in progress, and a Host
123
154
  whose lifecycle is not owned by Desktop or a supervisor. Clients must feature
124
155
  detect it; they must not invent an alternative process-control path.
125
156
 
157
+ ## Rendering the market's panel elsewhere (client-side)
158
+
159
+ A host shell that wants the market inside its own container — rather than in
160
+ the settings page — reads the client service this package publishes:
161
+
162
+ ```ts
163
+ const market = ctx.reflect.get('market')
164
+ const element = market.render({ preferredSubsectionId: 'installed' })
165
+ ```
166
+
167
+ `render()` returns the market's own panel wrapped in its error boundary, as a
168
+ React element. Same page, same React instance: this package's client bundle
169
+ resolves react through the host's module table, so the element mounts
170
+ anywhere in that tree.
171
+
172
+ | member | |
173
+ |---|---|
174
+ | `version` | `1` |
175
+ | `render(props?)` | the panel element; `preferredSubsectionId` is optional |
176
+ | `setSettingsVisible(visible)` | register or retract the market's own `settings.section` entry |
177
+ | `settingsVisible()` | whether that entry is registered right now |
178
+
179
+ `ctx.provide(name, value)` and `ctx.reflect.provide(name, value)` are the
180
+ same call — cordis's `Service.provide` delegates to the reflect layer — so
181
+ either idiom reaches this service.
182
+
183
+ What `render()` is **not**: a way to rearrange the market. It hands over the
184
+ whole panel, chrome included. Cutting the market into host-fillable regions
185
+ is a different design, and one host asking is not yet evidence that it fits
186
+ anyone else.
187
+
126
188
  ## Compatibility policy
127
189
 
128
190
  - New optional response fields may be added within v1.
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,
@@ -10660,6 +10720,86 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
10660
10720
  });
10661
10721
  }
10662
10722
  //#endregion
10723
+ //#region src/client/market-element.ts
10724
+ /**
10725
+ * The market's panel, as an element, built from explicit dependencies.
10726
+ *
10727
+ * Two callers want the same thing and must not drift apart:
10728
+ *
10729
+ * - the `settings.section` this package registers, whose slot passes down a
10730
+ * host-chosen `preferredSubsectionId`;
10731
+ * - `market.render()`, for a host shell that renders the market inside its
10732
+ * own container (#602, from the Tauri desktop).
10733
+ *
10734
+ * A module function taking its dependencies rather than a closure over the
10735
+ * cordis context, so the wiring — which locale, which theme, which log
10736
+ * exporter reaches the panel — is something a test can assert instead of
10737
+ * something only a running host can reveal.
10738
+ */
10739
+ /**
10740
+ * @param props - see {@link MarketElementProps}.
10741
+ * @returns the panel wrapped in its error boundary.
10742
+ */
10743
+ function marketElement(props) {
10744
+ return (0, react.createElement)(MarketErrorBoundary, {
10745
+ text: props.crashText,
10746
+ actions: (0, react.createElement)("button", {
10747
+ type: "button",
10748
+ onClick: () => {
10749
+ props.exportLog();
10750
+ }
10751
+ }, props.t("exportLog"))
10752
+ }, (0, react.createElement)(MarketSection, {
10753
+ t: props.t,
10754
+ locale: props.locale,
10755
+ theme: props.theme,
10756
+ themeStore: props.themeStore,
10757
+ preferredSubsectionId: props.preferredSubsectionId
10758
+ }));
10759
+ }
10760
+ //#endregion
10761
+ //#region src/client/section-gate.ts
10762
+ /**
10763
+ * @param register - registers the entry and returns its disposer. Called
10764
+ * only when the entry should be visible; may be called again after a
10765
+ * retraction, which is why the disposer is required rather than optional.
10766
+ * @returns the gate.
10767
+ */
10768
+ function createSectionGate(register) {
10769
+ let ready = false;
10770
+ let wanted = true;
10771
+ let removed = false;
10772
+ let dispose = null;
10773
+ const apply = () => {
10774
+ if (!ready) return;
10775
+ const shouldShow = wanted && !removed;
10776
+ if (shouldShow && dispose === null) {
10777
+ dispose = register();
10778
+ return;
10779
+ }
10780
+ if (!shouldShow && dispose !== null) {
10781
+ const stop = dispose;
10782
+ dispose = null;
10783
+ stop();
10784
+ }
10785
+ };
10786
+ return {
10787
+ available: () => {
10788
+ ready = true;
10789
+ apply();
10790
+ },
10791
+ setVisible: (visible) => {
10792
+ wanted = visible;
10793
+ apply();
10794
+ },
10795
+ visible: () => dispose !== null,
10796
+ retire: () => {
10797
+ removed = true;
10798
+ apply();
10799
+ }
10800
+ };
10801
+ }
10802
+ //#endregion
10663
10803
  //#region src/client/SettingsCard.tsx
10664
10804
  /**
10665
10805
  * The market's card on the plugin configuration page (dsh >= 0.1.0-rc.7).
@@ -11150,8 +11290,32 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
11150
11290
  en
11151
11291
  }), "dsh-market: dictionaries");
11152
11292
  const t = ctx.locale.bind(NS);
11153
- let retireSection = null;
11154
- ctx.slots.inject("settings.section", () => {
11293
+ /**
11294
+ * The market's own panel, as an element — one builder for the settings
11295
+ * section this package registers and for `market.render()` (#602). Built
11296
+ * per call: the props are live (locale, theme, the host's preferred
11297
+ * subsection), and a cached element would freeze the first caller's.
11298
+ */
11299
+ const buildMarketElement = (ownerProps = {}) => marketElement({
11300
+ t,
11301
+ locale: ctx.locale,
11302
+ theme: ctx.theme,
11303
+ themeStore: {
11304
+ subscribe: (cb) => ctx.on("theme/change", cb),
11305
+ getSnapshot: () => ctx.theme.getTheme()
11306
+ },
11307
+ crashText: {
11308
+ title: t("crashTitle"),
11309
+ hint: t("crashHint"),
11310
+ reload: t("crashReload"),
11311
+ details: t("crashDetails")
11312
+ },
11313
+ exportLog: () => {
11314
+ exportMarketLog().catch(() => {});
11315
+ },
11316
+ preferredSubsectionId: ownerProps.preferredSubsectionId
11317
+ });
11318
+ const sectionGate = createSectionGate(() => {
11155
11319
  const off = ctx.slots.register({
11156
11320
  name: "settings.section",
11157
11321
  id: "market",
@@ -11159,32 +11323,31 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
11159
11323
  label: () => t("nav"),
11160
11324
  locale: NS,
11161
11325
  inject: () => ({ t })
11162
- }, (ownerProps = {}) => (0, react.createElement)(MarketErrorBoundary, {
11163
- text: {
11164
- title: t("crashTitle"),
11165
- hint: t("crashHint"),
11166
- reload: t("crashReload"),
11167
- details: t("crashDetails")
11168
- },
11169
- actions: (0, react.createElement)("button", {
11170
- type: "button",
11171
- onClick: () => {
11172
- exportMarketLog().catch(() => {});
11173
- }
11174
- }, t("exportLog"))
11175
- }, (0, react.createElement)(MarketSection, {
11176
- t,
11177
- locale: ctx.locale,
11178
- theme: ctx.theme,
11179
- themeStore: {
11180
- subscribe: (cb) => ctx.on("theme/change", cb),
11181
- getSnapshot: () => ctx.theme.getTheme()
11182
- },
11183
- preferredSubsectionId: ownerProps.preferredSubsectionId
11184
- })));
11185
- if (typeof off === "function") retireSection = off;
11186
- return off;
11326
+ }, (ownerProps = {}) => buildMarketElement(ownerProps));
11327
+ return typeof off === "function" ? off : () => {};
11187
11328
  });
11329
+ ctx.slots.inject("settings.section", () => {
11330
+ sectionGate.available();
11331
+ });
11332
+ const marketControl = {
11333
+ version: 1,
11334
+ setSettingsVisible: (visible) => {
11335
+ sectionGate.setVisible(visible);
11336
+ },
11337
+ settingsVisible: () => sectionGate.visible(),
11338
+ /**
11339
+ * The market's panel as an element, for a host that renders it inside
11340
+ * its own container. Same page, same React instance — this package's
11341
+ * bundle resolves react through the host's module table, so an element
11342
+ * returned here mounts anywhere in that tree.
11343
+ *
11344
+ * What it is NOT: a way to rearrange the market. It hands over the whole
11345
+ * panel, chrome included. Cutting the market into host-fillable regions
11346
+ * is a different design and has not been asked for by a second host yet.
11347
+ */
11348
+ render: (props = {}) => buildMarketElement(props)
11349
+ };
11350
+ if (typeof ctx.provide === "function") ctx.provide("market", marketControl);
11188
11351
  ctx.inject(["settingsScope"], (scoped) => {
11189
11352
  scoped.slots.inject("settings.plugin.item", () => scoped.slots.register({
11190
11353
  name: "settings.plugin.item",
@@ -11194,9 +11357,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
11194
11357
  }, () => (0, react.createElement)(SettingsCard, {
11195
11358
  t,
11196
11359
  onRemoved: () => {
11197
- const off = retireSection;
11198
- retireSection = null;
11199
- off?.();
11360
+ sectionGate.retire();
11200
11361
  }
11201
11362
  })));
11202
11363
  });
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/dsh-cli.js CHANGED
@@ -192,6 +192,44 @@ export function toolSearchDirs(platform = process.platform, env = process.env, h
192
192
  // one place a user goes looking for an answer.
193
193
  return [...new Set(dirs.filter(dir => dir.trim() !== ''))];
194
194
  }
195
+ /**
196
+ * Stop git asking for credentials down a channel nobody is listening on
197
+ * (#587).
198
+ *
199
+ * `CI=true` below is the same defence one layer up, and pnpm reads it. git
200
+ * does not — it has its own switch, and it was not set. The gap only opens
201
+ * when a spec reaches pnpm's git fetcher instead of the codeload tarball
202
+ * path `accelerate.ts` describes: `github:owner/repo#path:/sub` is one, and
203
+ * pnpm really does shell out to `git` for it, trying HTTPS first.
204
+ *
205
+ * git's credential prompt opens the controlling terminal, not stdin. There
206
+ * is no terminal here, so the question is never seen and never answered:
207
+ * the reporter caught `git.exe` alive for eight minutes having burned 0.05s
208
+ * of CPU, and only the fifteen-minute install timeout ended it. Refusing
209
+ * the prompt turns that into a fast, readable failure.
210
+ *
211
+ * It is a default, not an override: a value the caller set wins, and blank
212
+ * counts as unset because an empty `GIT_TERMINAL_PROMPT` is not a setting
213
+ * git can parse either. Credential helpers and `GIT_ASKPASS` are untouched
214
+ * and still answer first — this closes only the terminal fallback, which is
215
+ * precisely the branch that cannot work from a spawned child.
216
+ *
217
+ * Scope, stated plainly because it is narrower than the issue title
218
+ * suggests: this is the HTTPS half. pnpm falls back to `git@github.com:`
219
+ * when HTTPS fails, and the ssh side needs `GIT_SSH_COMMAND`, which
220
+ * overrides `core.sshCommand` and `GIT_SSH` — the two ordinary ways to
221
+ * choose an identity — and whose `BatchMode=yes` would disable
222
+ * `SSH_ASKPASS`, breaking key-passphrase installs that work today. That
223
+ * half needs a policy decision, so it is not made here. Note also that on
224
+ * POSIX `runDshPlugin` spawns detached, so the subtree has no controlling
225
+ * terminal and the prompt already dies instantly; the hang the issue
226
+ * reports needs Windows, where the spawn is not detached.
227
+ */
228
+ export function gitEnvForPnpm(env = process.env) {
229
+ if ((env.GIT_TERMINAL_PROMPT ?? '').trim() !== '')
230
+ return {};
231
+ return { GIT_TERMINAL_PROMPT: '0' };
232
+ }
195
233
  function spawnEnv() {
196
234
  // pnpm v10+ blocks forever on a silent interactive prompt without a TTY;
197
235
  // CI mode forces it to act or fail instead of asking.
@@ -201,7 +239,13 @@ function spawnEnv() {
201
239
  if (!parts.includes(bin))
202
240
  parts.push(bin);
203
241
  }
204
- return { ...process.env, ...proxyEnvForPnpm(process.env, activeRegion()), CI: 'true', PATH: parts.join(separator) };
242
+ return {
243
+ ...process.env,
244
+ ...proxyEnvForPnpm(process.env, activeRegion()),
245
+ ...gitEnvForPnpm(process.env),
246
+ CI: 'true',
247
+ PATH: parts.join(separator),
248
+ };
205
249
  }
206
250
  const INSTALL_TIMEOUT_MS = Number(process.env.DSH_MARKET_INSTALL_TIMEOUT_MS) || 15 * 60 * 1000;
207
251
  /**
@@ -1,6 +1,6 @@
1
1
  /** Locate the DSH host package in CLI and packaged Desktop runtimes. */
2
2
  import { readFileSync, realpathSync } from 'node:fs';
3
- import { dirname, join, resolve } from 'node:path';
3
+ import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
4
4
  /** The entry with symlinks resolved, or unchanged when it cannot be read. */
5
5
  function realpathOf(entry) {
6
6
  try {
@@ -14,27 +14,77 @@ function realpathOf(entry) {
14
14
  }
15
15
  }
16
16
  const DSH_PACKAGE = '@deepseek-ai/dsh';
17
+ const DESKTOP_PACKAGE = '@deepseek-ai/dsh-desktop';
18
+ const APPLICATION_ROOTS = ['app.asar.unpacked', 'app.asar', 'app'];
19
+ // The split runtime reported in #553. Only these local, identity-checked
20
+ // packages corroborate the shell; never resolve witnesses from a profile.
21
+ const DESKTOP_RUNTIME_PACKAGES = ['dsh-base', 'dsh-web-app', 'dsh-web', 'dsh-settings'];
17
22
  /**
18
- * The host package's own manifest, or null when `directory` is not it.
19
- * @returns the parsed manifest of `@deepseek-ai/dsh`, or null.
23
+ * A package's own manifest, or null when its identity cannot be confirmed.
20
24
  */
21
- function readDshManifest(directory) {
25
+ function readDshManifest(directory, name = DSH_PACKAGE) {
22
26
  try {
23
27
  const manifest = JSON.parse(readFileSync(join(directory, 'package.json'), 'utf8'));
24
- return manifest.name === DSH_PACKAGE ? { name: DSH_PACKAGE, version: manifest.version } : null;
28
+ return manifest.name === name ? { name, version: manifest.version } : null;
25
29
  }
26
30
  catch {
27
31
  return null;
28
32
  }
29
33
  }
30
- function isDshPackage(directory) {
31
- return readDshManifest(directory) !== null;
34
+ function entryDirectories(entry) {
35
+ if (entry === undefined)
36
+ return [];
37
+ const directories = [];
38
+ let directory = resolve(dirname(realpathOf(entry)));
39
+ for (let depth = 0; depth < 10; depth += 1) {
40
+ directories.push(directory);
41
+ const parent = dirname(directory);
42
+ if (parent === directory)
43
+ break;
44
+ directory = parent;
45
+ }
46
+ return directories;
47
+ }
48
+ function desktopDirectories() {
49
+ const { resourcesPath } = process;
50
+ if (typeof resourcesPath !== 'string' || resourcesPath.length === 0)
51
+ return [];
52
+ return APPLICATION_ROOTS.map(root => join(resourcesPath, root));
53
+ }
54
+ function declaredVersion(manifest) {
55
+ return typeof manifest?.version === 'string' && manifest.version !== '' ? manifest.version : 'unknown';
56
+ }
57
+ function flatDesktopHost(directory) {
58
+ const shell = readDshManifest(directory, DESKTOP_PACKAGE);
59
+ if (shell === null)
60
+ return null;
61
+ const runtime = DESKTOP_RUNTIME_PACKAGES.map(name => {
62
+ const candidate = join(directory, 'node_modules', '@deepseek-ai', name);
63
+ // Bundled pnpm links are fine; a link to a mutable profile/global package
64
+ // is not evidence about the version of the embedded runtime.
65
+ const path = relative(realpathOf(directory), realpathOf(join(candidate, 'package.json')));
66
+ if (isAbsolute(path) || path === '..' || path.startsWith(`..${sep}`))
67
+ return null;
68
+ return readDshManifest(candidate, `@deepseek-ai/${name}`);
69
+ });
70
+ // A shell alone is not a dependency anchor. Either in-box bundle can
71
+ // establish the directory even if the other version witnesses are broken.
72
+ if (runtime[0] === null && runtime[1] === null)
73
+ return null;
74
+ const version = declaredVersion(shell);
75
+ // DSH split packages share a release line, but a desktop shell need not.
76
+ // Require agreement from every witness; missing/conflicting evidence is
77
+ // unknown, not the shell version or a majority vote among split packages.
78
+ const parsed = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.exec(version);
79
+ const corroborated = parsed !== null && !(parsed[1]?.split('.').some(part => /^0\d+$/.test(part)) ?? false)
80
+ && runtime.every(manifest => manifest?.version === version);
81
+ return { directory, version: corroborated ? version : 'unknown' };
32
82
  }
33
83
  /**
34
84
  * The version of the DSH host this market is running inside.
35
85
  *
36
- * Read from the same manifest `findDshInstallDir` already parses to identify
37
- * the package — the version was sitting in that object and being discarded.
86
+ * CLI versions come from the host manifest. Flat Desktop shells additionally
87
+ * require agreement with their bundled split runtime, never profile packages.
38
88
  *
39
89
  * Worth reporting because the host version has repeatedly been the thing
40
90
  * neither side could see. #293 turned on it (the reporter was on
@@ -51,17 +101,20 @@ function isDshPackage(directory) {
51
101
  * legitimately land here).
52
102
  */
53
103
  export function dshHostInfo(entry = process.argv[1]) {
54
- const directory = findDshInstallDir(entry);
55
- if (directory === null)
56
- return null;
57
- const manifest = readDshManifest(directory);
58
- // Located but unversioned: report the directory anyway. "The host is here
59
- // and declares no version" is a fact worth carrying, and it is not the
60
- // same fact as "no host found".
61
- const version = typeof manifest?.version === 'string' && manifest.version !== ''
62
- ? manifest.version
63
- : 'unknown';
64
- return { version, directory };
104
+ const ancestors = entryDirectories(entry);
105
+ const applications = desktopDirectories();
106
+ // Keep the entire legacy search order ahead of newly supported shells.
107
+ for (const directory of [...ancestors, ...applications.map(root => join(root, 'node_modules', '@deepseek-ai', 'dsh'))]) {
108
+ const manifest = readDshManifest(directory);
109
+ if (manifest !== null)
110
+ return { directory, version: declaredVersion(manifest) };
111
+ }
112
+ for (const directory of [...ancestors, ...applications]) {
113
+ const host = flatDesktopHost(directory);
114
+ if (host !== null)
115
+ return host;
116
+ }
117
+ return null;
65
118
  }
66
119
  /**
67
120
  * Walk up from the CLI entry first, then inspect Electron's authoritative
@@ -84,25 +137,5 @@ export function dshHostInfo(entry = process.argv[1]) {
84
137
  * why this survived: the case that worked is the one that gets tested.
85
138
  */
86
139
  export function findDshInstallDir(entry = process.argv[1]) {
87
- if (entry !== undefined) {
88
- let directory = resolve(dirname(realpathOf(entry)));
89
- for (let depth = 0; depth < 10; depth += 1) {
90
- if (isDshPackage(directory))
91
- return directory;
92
- const parent = dirname(directory);
93
- if (parent === directory)
94
- break;
95
- directory = parent;
96
- }
97
- }
98
- const electronProcess = process;
99
- if (typeof electronProcess.resourcesPath !== 'string'
100
- || electronProcess.resourcesPath.length === 0)
101
- return null;
102
- for (const applicationRoot of ['app.asar.unpacked', 'app.asar', 'app']) {
103
- const candidate = join(electronProcess.resourcesPath, applicationRoot, 'node_modules', '@deepseek-ai', 'dsh');
104
- if (isDshPackage(candidate))
105
- return candidate;
106
- }
107
- return null;
140
+ return dshHostInfo(entry)?.directory ?? null;
108
141
  }