dshmarket 1.47.0 → 1.49.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/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
@@ -105,7 +105,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
105
105
  installing: "安装中…",
106
106
  installedBadge: "✓ 已安装",
107
107
  alreadyInstalled: "✓ 已安装",
108
- restartBanner: "项变更完成,重启 DeepSeek Harness 后生效",
108
+ restartBanner: "项变更需重启 DeepSeek Harness 后生效",
109
109
  uninstall: "卸载",
110
110
  confirmRemove: "确认卸载?",
111
111
  uninstalling: "卸载中…",
@@ -638,7 +638,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
638
638
  installing: "Installing…",
639
639
  installedBadge: "✓ Installed",
640
640
  alreadyInstalled: "✓ Installed",
641
- restartBanner: "change(s) done — restart DeepSeek Harness to apply",
641
+ restartBanner: "change(s) pending restart — restart DeepSeek Harness to apply",
642
642
  uninstall: "Uninstall",
643
643
  confirmRemove: "Uninstall?",
644
644
  uninstalling: "Uninstalling…",
@@ -6348,6 +6348,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
6348
6348
  const [buildsSkipped, setBuildsSkipped] = (0, react.useState)(null);
6349
6349
  const [updatingAll, setUpdatingAll] = (0, react.useState)(false);
6350
6350
  const [updatedNames, setUpdatedNames] = (0, react.useState)([]);
6351
+ const [restartNames, setRestartNames] = (0, react.useState)([]);
6351
6352
  const [hotUrls, setHotUrls] = (0, react.useState)([]);
6352
6353
  const [hotNames, setHotNames] = (0, react.useState)([]);
6353
6354
  const [progressLine, setProgressLine] = (0, react.useState)(null);
@@ -6667,12 +6668,13 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
6667
6668
  }
6668
6669
  if (Array.isArray(saved.doneUrls) && saved.doneUrls.length > 0) setDoneUrls(saved.doneUrls);
6669
6670
  if (Array.isArray(saved.updated) && saved.updated.length > 0) setUpdatedNames(saved.updated);
6671
+ if (Array.isArray(saved.restartNames) && saved.restartNames.length > 0) setRestartNames(saved.restartNames);
6670
6672
  if (typeof saved.removed === "number" && saved.removed > 0) setRemovedCount(saved.removed);
6671
6673
  if (typeof saved.toggled === "number" && saved.toggled > 0) setToggleRestart(saved.toggled);
6672
6674
  }, [bootId]);
6673
6675
  (0, react.useEffect)(() => {
6674
6676
  if (bootId === null) return;
6675
- if (doneUrls.length === 0 && updatedNames.length === 0 && removedCount === 0 && toggleRestart === 0) {
6677
+ if (doneUrls.length === 0 && updatedNames.length === 0 && restartNames.length === 0 && removedCount === 0 && toggleRestart === 0) {
6676
6678
  sessionStorage.removeItem("dshm-restart");
6677
6679
  return;
6678
6680
  }
@@ -6680,6 +6682,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
6680
6682
  boot: bootId,
6681
6683
  doneUrls,
6682
6684
  updated: updatedNames,
6685
+ restartNames,
6683
6686
  removed: removedCount,
6684
6687
  toggled: toggleRestart
6685
6688
  }));
@@ -6687,6 +6690,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
6687
6690
  bootId,
6688
6691
  doneUrls,
6689
6692
  updatedNames,
6693
+ restartNames,
6690
6694
  removedCount,
6691
6695
  toggleRestart
6692
6696
  ]);
@@ -7307,6 +7311,8 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
7307
7311
  if (status === 200 && body.ok) {
7308
7312
  setRecords((list) => patch(list, updateRecordId, { state: "done" }));
7309
7313
  setUpdatedNames((names) => names.concat(name));
7314
+ const activation = body.activation && typeof body.activation === "object" ? body.activation[name] : void 0;
7315
+ if (!activation || activation.state === "restart") setRestartNames((names) => names.includes(name) ? names : names.concat(name));
7310
7316
  if (body.activation && typeof body.activation === "object") setActivations((prev) => ({
7311
7317
  ...prev,
7312
7318
  ...body.activation
@@ -7854,7 +7860,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
7854
7860
  });
7855
7861
  }, [doGroupAction, groups]);
7856
7862
  const selfName = installed["dshmarket"] !== void 0 ? "dshmarket" : "dsh-market";
7857
- const updatableNames = Object.keys(installed).filter((name) => name !== selfName && !updatedNames.includes(name) && updates[name] && updates[name].updateAvailable);
7863
+ const updatableNames = Object.keys(installed).filter((name) => name !== selfName && !updatedNames.includes(name) && !effectiveDisabledSet.has(name) && updates[name] && updates[name].updateAvailable);
7858
7864
  const batchUpdatableNames = updatableNames.filter((name) => updates[name]?.restoreRequired !== true);
7859
7865
  const ignoredUpdateSet = (0, react.useMemo)(() => new Set(ignoredUpdateNames), [ignoredUpdateNames]);
7860
7866
  const reminderUpdatableNames = updatableNames.filter((name) => !ignoredUpdateSet.has(name));
@@ -8102,7 +8108,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
8102
8108
  webdavUrl,
8103
8109
  webdavUser
8104
8110
  ]);
8105
- const sessionPendingRestart = doneUrls.length + updatedNames.length + removedCount + toggleRestart + (backupRestored ? 1 : 0);
8111
+ const sessionPendingRestart = doneUrls.length + restartNames.length + removedCount + toggleRestart + (backupRestored ? 1 : 0);
8106
8112
  /**
8107
8113
  * Plugins the HOST reports as restart-pending, independent of what this
8108
8114
  * browser session happens to remember. Installing and then reloading the
@@ -10720,6 +10726,86 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
10720
10726
  });
10721
10727
  }
10722
10728
  //#endregion
10729
+ //#region src/client/market-element.ts
10730
+ /**
10731
+ * The market's panel, as an element, built from explicit dependencies.
10732
+ *
10733
+ * Two callers want the same thing and must not drift apart:
10734
+ *
10735
+ * - the `settings.section` this package registers, whose slot passes down a
10736
+ * host-chosen `preferredSubsectionId`;
10737
+ * - `market.render()`, for a host shell that renders the market inside its
10738
+ * own container (#602, from the Tauri desktop).
10739
+ *
10740
+ * A module function taking its dependencies rather than a closure over the
10741
+ * cordis context, so the wiring — which locale, which theme, which log
10742
+ * exporter reaches the panel — is something a test can assert instead of
10743
+ * something only a running host can reveal.
10744
+ */
10745
+ /**
10746
+ * @param props - see {@link MarketElementProps}.
10747
+ * @returns the panel wrapped in its error boundary.
10748
+ */
10749
+ function marketElement(props) {
10750
+ return (0, react.createElement)(MarketErrorBoundary, {
10751
+ text: props.crashText,
10752
+ actions: (0, react.createElement)("button", {
10753
+ type: "button",
10754
+ onClick: () => {
10755
+ props.exportLog();
10756
+ }
10757
+ }, props.t("exportLog"))
10758
+ }, (0, react.createElement)(MarketSection, {
10759
+ t: props.t,
10760
+ locale: props.locale,
10761
+ theme: props.theme,
10762
+ themeStore: props.themeStore,
10763
+ preferredSubsectionId: props.preferredSubsectionId
10764
+ }));
10765
+ }
10766
+ //#endregion
10767
+ //#region src/client/section-gate.ts
10768
+ /**
10769
+ * @param register - registers the entry and returns its disposer. Called
10770
+ * only when the entry should be visible; may be called again after a
10771
+ * retraction, which is why the disposer is required rather than optional.
10772
+ * @returns the gate.
10773
+ */
10774
+ function createSectionGate(register) {
10775
+ let ready = false;
10776
+ let wanted = true;
10777
+ let removed = false;
10778
+ let dispose = null;
10779
+ const apply = () => {
10780
+ if (!ready) return;
10781
+ const shouldShow = wanted && !removed;
10782
+ if (shouldShow && dispose === null) {
10783
+ dispose = register();
10784
+ return;
10785
+ }
10786
+ if (!shouldShow && dispose !== null) {
10787
+ const stop = dispose;
10788
+ dispose = null;
10789
+ stop();
10790
+ }
10791
+ };
10792
+ return {
10793
+ available: () => {
10794
+ ready = true;
10795
+ apply();
10796
+ },
10797
+ setVisible: (visible) => {
10798
+ wanted = visible;
10799
+ apply();
10800
+ },
10801
+ visible: () => dispose !== null,
10802
+ retire: () => {
10803
+ removed = true;
10804
+ apply();
10805
+ }
10806
+ };
10807
+ }
10808
+ //#endregion
10723
10809
  //#region src/client/SettingsCard.tsx
10724
10810
  /**
10725
10811
  * The market's card on the plugin configuration page (dsh >= 0.1.0-rc.7).
@@ -11210,8 +11296,32 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
11210
11296
  en
11211
11297
  }), "dsh-market: dictionaries");
11212
11298
  const t = ctx.locale.bind(NS);
11213
- let retireSection = null;
11214
- ctx.slots.inject("settings.section", () => {
11299
+ /**
11300
+ * The market's own panel, as an element — one builder for the settings
11301
+ * section this package registers and for `market.render()` (#602). Built
11302
+ * per call: the props are live (locale, theme, the host's preferred
11303
+ * subsection), and a cached element would freeze the first caller's.
11304
+ */
11305
+ const buildMarketElement = (ownerProps = {}) => marketElement({
11306
+ t,
11307
+ locale: ctx.locale,
11308
+ theme: ctx.theme,
11309
+ themeStore: {
11310
+ subscribe: (cb) => ctx.on("theme/change", cb),
11311
+ getSnapshot: () => ctx.theme.getTheme()
11312
+ },
11313
+ crashText: {
11314
+ title: t("crashTitle"),
11315
+ hint: t("crashHint"),
11316
+ reload: t("crashReload"),
11317
+ details: t("crashDetails")
11318
+ },
11319
+ exportLog: () => {
11320
+ exportMarketLog().catch(() => {});
11321
+ },
11322
+ preferredSubsectionId: ownerProps.preferredSubsectionId
11323
+ });
11324
+ const sectionGate = createSectionGate(() => {
11215
11325
  const off = ctx.slots.register({
11216
11326
  name: "settings.section",
11217
11327
  id: "market",
@@ -11219,32 +11329,31 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
11219
11329
  label: () => t("nav"),
11220
11330
  locale: NS,
11221
11331
  inject: () => ({ t })
11222
- }, (ownerProps = {}) => (0, react.createElement)(MarketErrorBoundary, {
11223
- text: {
11224
- title: t("crashTitle"),
11225
- hint: t("crashHint"),
11226
- reload: t("crashReload"),
11227
- details: t("crashDetails")
11228
- },
11229
- actions: (0, react.createElement)("button", {
11230
- type: "button",
11231
- onClick: () => {
11232
- exportMarketLog().catch(() => {});
11233
- }
11234
- }, t("exportLog"))
11235
- }, (0, react.createElement)(MarketSection, {
11236
- t,
11237
- locale: ctx.locale,
11238
- theme: ctx.theme,
11239
- themeStore: {
11240
- subscribe: (cb) => ctx.on("theme/change", cb),
11241
- getSnapshot: () => ctx.theme.getTheme()
11242
- },
11243
- preferredSubsectionId: ownerProps.preferredSubsectionId
11244
- })));
11245
- if (typeof off === "function") retireSection = off;
11246
- return off;
11332
+ }, (ownerProps = {}) => buildMarketElement(ownerProps));
11333
+ return typeof off === "function" ? off : () => {};
11334
+ });
11335
+ ctx.slots.inject("settings.section", () => {
11336
+ sectionGate.available();
11247
11337
  });
11338
+ const marketControl = {
11339
+ version: 1,
11340
+ setSettingsVisible: (visible) => {
11341
+ sectionGate.setVisible(visible);
11342
+ },
11343
+ settingsVisible: () => sectionGate.visible(),
11344
+ /**
11345
+ * The market's panel as an element, for a host that renders it inside
11346
+ * its own container. Same page, same React instance — this package's
11347
+ * bundle resolves react through the host's module table, so an element
11348
+ * returned here mounts anywhere in that tree.
11349
+ *
11350
+ * What it is NOT: a way to rearrange the market. It hands over the whole
11351
+ * panel, chrome included. Cutting the market into host-fillable regions
11352
+ * is a different design and has not been asked for by a second host yet.
11353
+ */
11354
+ render: (props = {}) => buildMarketElement(props)
11355
+ };
11356
+ if (typeof ctx.provide === "function") ctx.provide("market", marketControl);
11248
11357
  ctx.inject(["settingsScope"], (scoped) => {
11249
11358
  scoped.slots.inject("settings.plugin.item", () => scoped.slots.register({
11250
11359
  name: "settings.plugin.item",
@@ -11254,9 +11363,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
11254
11363
  }, () => (0, react.createElement)(SettingsCard, {
11255
11364
  t,
11256
11365
  onRemoved: () => {
11257
- const off = retireSection;
11258
- retireSection = null;
11259
- off?.();
11366
+ sectionGate.retire();
11260
11367
  }
11261
11368
  })));
11262
11369
  });
package/lib/dsh-cli.js CHANGED
@@ -192,6 +192,71 @@ 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
+ }
233
+ /**
234
+ * Every `--config.<key>=<value>` override in the argv, repeated as the
235
+ * `PNPM_CONFIG_<KEY>` environment variable that pnpm 12 still reads.
236
+ *
237
+ * pnpm 12 ignores some `--config.<key>` overrides on the command line, in
238
+ * either spelling, without a word: `fetchTimeout` (#615; measured on
239
+ * 12.2.1, 12.3.0 and 12.4.1) and `auto-install-peers` (12.4.1 auto-installs
240
+ * the peer with the flag present and not with the variable), so the retries
241
+ * that carry them ran exactly like the first attempt. `PNPM_CONFIG_*` is
242
+ * honoured by 11.8, 11.21 and 12.4 alike, so the argument stays for the
243
+ * versions that read it and the variable carries the same value for the
244
+ * ones that do not. Scoped to the run that carries the flag; nothing is set
245
+ * otherwise, so a user's own values are untouched on every other run. Keys
246
+ * are accepted in either spelling, so respelling a constant (as #600 did
247
+ * for the release-age one) cannot silently drop the variable.
248
+ */
249
+ export function pnpmConfigEnvForArgs(pluginArgs) {
250
+ const env = {};
251
+ for (const arg of pluginArgs) {
252
+ const match = /^--config\.([A-Za-z][A-Za-z0-9-]*)=(\S+)$/.exec(arg);
253
+ if (match === null)
254
+ continue;
255
+ const key = match[1].replace(/([a-z0-9])([A-Z])/g, '$1_$2').replace(/-/g, '_').toUpperCase();
256
+ env[`PNPM_CONFIG_${key}`] = match[2];
257
+ }
258
+ return env;
259
+ }
195
260
  function spawnEnv() {
196
261
  // pnpm v10+ blocks forever on a silent interactive prompt without a TTY;
197
262
  // CI mode forces it to act or fail instead of asking.
@@ -201,7 +266,13 @@ function spawnEnv() {
201
266
  if (!parts.includes(bin))
202
267
  parts.push(bin);
203
268
  }
204
- return { ...process.env, ...proxyEnvForPnpm(process.env, activeRegion()), CI: 'true', PATH: parts.join(separator) };
269
+ return {
270
+ ...process.env,
271
+ ...proxyEnvForPnpm(process.env, activeRegion()),
272
+ ...gitEnvForPnpm(process.env),
273
+ CI: 'true',
274
+ PATH: parts.join(separator),
275
+ };
205
276
  }
206
277
  const INSTALL_TIMEOUT_MS = Number(process.env.DSH_MARKET_INSTALL_TIMEOUT_MS) || 15 * 60 * 1000;
207
278
  /**
@@ -695,7 +766,7 @@ export function runDshPlugin(profile, pluginArgs) {
695
766
  // pnpm v10 blocks forever on a silent interactive prompt without a TTY
696
767
  // (observed on re-add over a pinned git spec); CI mode forces it to act
697
768
  // or fail instead of asking.
698
- env: spawnEnv(),
769
+ env: { ...spawnEnv(), ...pnpmConfigEnvForArgs(pluginArgs) },
699
770
  stdio: ['ignore', 'pipe', 'pipe'],
700
771
  viaShell,
701
772
  // Own process group on POSIX so cancel/timeout can kill the whole
@@ -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
  }