dshmarket 1.45.0 → 1.46.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/client/client.js CHANGED
@@ -40,6 +40,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
40
40
  setSelfUpdateReady: "有新版本",
41
41
  setSelfUpdateHint: "更新会下载新版本,重启后生效。",
42
42
  setSelfUpToDateHint: "",
43
+ setSelfHostManagedHint: "这份市场由桌面宿主安装,新版本请在桌面端更新。",
43
44
  setSelfUpdate: "更新",
44
45
  setSelfUpdatedHint: "已下载完成。重启 DeepSeek Harness 后新版本才会生效——前端页面会立即更新,服务端不会。",
45
46
  setRegion: "下载区域",
@@ -154,6 +155,8 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
154
155
  updateFail: "更新失败",
155
156
  upToDate: "已是最新",
156
157
  linkedDev: "本地开发",
158
+ hostUpdateReady: "有新版本 {0}",
159
+ hostUpdateHint: "这份由桌面宿主安装和更新,市场只提醒,不在这里更新",
157
160
  notesLink: "更新内容",
158
161
  notesRelease: "版本说明",
159
162
  notesCommits: "提交记录",
@@ -570,6 +573,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
570
573
  setSelfUpdateReady: "New version available:",
571
574
  setSelfUpdateHint: "Updating downloads the new version; it takes effect after a restart.",
572
575
  setSelfUpToDateHint: "",
576
+ setSelfHostManagedHint: "This copy was installed by the desktop host; update it from the desktop app.",
573
577
  setSelfUpdate: "Update",
574
578
  setSelfUpdatedHint: "Downloaded. Restart DeepSeek Harness for it to take effect — the frontend updates at once, the server does not.",
575
579
  setRegion: "Download region",
@@ -684,6 +688,8 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
684
688
  updateFail: "Update failed",
685
689
  upToDate: "Up to date",
686
690
  linkedDev: "local",
691
+ hostUpdateReady: "New version {0}",
692
+ hostUpdateHint: "Installed and updated by the desktop host; the market only reports it",
687
693
  notesLink: "What changed",
688
694
  notesRelease: "Release notes",
689
695
  notesCommits: "Commits",
@@ -1268,6 +1274,14 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
1268
1274
  function installedForCatalog(installed, bundles) {
1269
1275
  return Object.fromEntries([...bundles.map((name) => [name, "*"]), ...Object.entries(installed)]);
1270
1276
  }
1277
+ /**
1278
+ * A `link:` the desktop host wrote for one of its generations (#497). The
1279
+ * test the server applies (`isGenerationLink` in sources.ts), repeated here
1280
+ * because the client bundle cannot import server modules.
1281
+ */
1282
+ function isGenerationSpec(spec) {
1283
+ return /^link:/i.test(spec) && /(?:^|[\\/])\.generations[\\/]live[\\/]/i.test(spec);
1284
+ }
1271
1285
  function groupSwitchState(members, disabled) {
1272
1286
  const list = members ?? [];
1273
1287
  if (list.length === 0) return "empty";
@@ -6440,6 +6454,14 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
6440
6454
  const [visibleCatsOneRow, setVisibleCatsOneRow] = (0, react.useState)(null);
6441
6455
  const catsWrapRef = (0, react.useRef)(null);
6442
6456
  const [catsStuck, setCatsStuck] = (0, react.useState)(false);
6457
+ /** While the sticky header is pinned, expansion is this flag — not
6458
+ * `catsOpen`. Becoming stuck collapses on the SAME render (stuckExpanded
6459
+ * starts false) instead of a follow-up `useLayoutEffect` that flipped
6460
+ * `catsOpen` and forced a second commit; that delayed height change is
6461
+ * what lined up with the host Settings dialog hitching after tab 收放.
6462
+ * An explicit chevron click while stuck sets this true and keeps
6463
+ * `catsOpen` in sync so unstuck restores the user's choice. */
6464
+ const [stuckExpanded, setStuckExpanded] = (0, react.useState)(false);
6443
6465
  const [catsSentinel, setCatsSentinel] = (0, react.useState)(null);
6444
6466
  const refreshInstalled = (0, react.useCallback)((force) => {
6445
6467
  fetch(api("/dsh-market/installed"), { cache: "no-store" }).then((res) => res.json()).then((body) => {
@@ -8496,7 +8518,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
8496
8518
  if (leftView && root !== null && wrap !== null) {
8497
8519
  if (root.scrollHeight - root.clientHeight <= wrap.offsetHeight) return;
8498
8520
  }
8499
- setCatsStuck(leftView);
8521
+ setCatsStuck((prev) => prev === leftView ? prev : leftView);
8500
8522
  }, {
8501
8523
  root: bodyRef.current,
8502
8524
  threshold: 0
@@ -8504,28 +8526,14 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
8504
8526
  observer.observe(catsSentinel);
8505
8527
  return () => observer.disconnect();
8506
8528
  }, [catsSentinel]);
8507
- /**
8508
- * Becoming stuck auto-collapses an open row — a REAL `catsOpen` flip, not
8509
- * a display-only override. An earlier version faked this by computing a
8510
- * separate "effectively open" value for rendering while leaving `catsOpen`
8511
- * itself true; the chevron's own click handler only ever toggled the real
8512
- * `catsOpen`, so while stuck it flipped a value the render path had
8513
- * already stopped consulting — clicking "expand" did nothing visible
8514
- * (reported: "吸顶滚动了之后,展开没反应了"). Driving the same state the
8515
- * chevron drives means the chevron always works, stuck or not.
8516
- */
8517
- const catsAutoCollapsedRef = (0, react.useRef)(false);
8518
- (0, react.useLayoutEffect)(() => {
8519
- if (catsStuck) {
8520
- if (catsOpen) {
8521
- setCatsOpen(false);
8522
- catsAutoCollapsedRef.current = true;
8523
- }
8524
- } else if (catsAutoCollapsedRef.current) {
8525
- setCatsOpen(true);
8526
- catsAutoCollapsedRef.current = false;
8527
- }
8529
+ (0, react.useEffect)(() => {
8530
+ if (!catsStuck) setStuckExpanded(false);
8528
8531
  }, [catsStuck]);
8532
+ /** Expanded chips + chevron share one value. Stuck uses `stuckExpanded`
8533
+ * so pinning collapses without rewriting `catsOpen` in a layout effect
8534
+ * (see stuckExpanded state). Leaving stuck falls back to `catsOpen`,
8535
+ * which still holds the pre-pin / in-pin user choice. */
8536
+ const catsExpanded = catsStuck ? stuckExpanded : catsOpen;
8529
8537
  /**
8530
8538
  * A fresh install (hotUrls/hotNames) and a toggle/group action
8531
8539
  * (refreshNames) both end in the same place — "reload the page" — and
@@ -8975,7 +8983,10 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
8975
8983
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
8976
8984
  className: Market_module_css_default.body,
8977
8985
  ref: bodyRef,
8978
- onScroll: (e) => setShowTop(e.currentTarget.scrollTop > 400),
8986
+ onScroll: (e) => {
8987
+ const show = e.currentTarget.scrollTop > 400;
8988
+ setShowTop((prev) => prev === show ? prev : show);
8989
+ },
8979
8990
  children: tab === "backup" ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
8980
8991
  className: Market_module_css_default.backupGrid,
8981
8992
  children: [
@@ -9257,8 +9268,8 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
9257
9268
  className: visibleCats === null ? `${Market_module_css_default.catsWrap} ${Market_module_css_default.catsCollapsed}` : Market_module_css_default.catsWrap,
9258
9269
  children: (() => {
9259
9270
  const budget = catsStuck ? visibleCatsOneRow : visibleCats;
9260
- const ordered = orderedCategories(categories, cat, catsOpen, budget);
9261
- const shown = catsOpen || budget === null ? ordered : ordered.slice(0, Math.max(0, budget - 1));
9271
+ const ordered = orderedCategories(categories, cat, catsExpanded, budget);
9272
+ const shown = catsExpanded || budget === null ? ordered : ordered.slice(0, Math.max(0, budget - 1));
9262
9273
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
9263
9274
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Pill, {
9264
9275
  "data-chip": "1",
@@ -9276,11 +9287,12 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
9276
9287
  variant: "ghost",
9277
9288
  size: "sm",
9278
9289
  className: Market_module_css_default.catsToggle,
9279
- icon: catsOpen ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconChevronUpOutline14, { size: 14 }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconChevronDownOutline14, { size: 14 }),
9280
- "aria-label": catsOpen ? t("catsLess") : t("catsMore"),
9290
+ icon: catsExpanded ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconChevronUpOutline14, { size: 14 }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconChevronDownOutline14, { size: 14 }),
9291
+ "aria-label": catsExpanded ? t("catsLess") : t("catsMore"),
9281
9292
  onClick: () => {
9282
- catsAutoCollapsedRef.current = false;
9283
- setCatsOpen((o) => !o);
9293
+ const next = !catsExpanded;
9294
+ if (catsStuck) setStuckExpanded(next);
9295
+ setCatsOpen(next);
9284
9296
  }
9285
9297
  })
9286
9298
  ] });
@@ -9828,7 +9840,8 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
9828
9840
  const missing = pendingBackup !== null && !installedFiles.includes(name);
9829
9841
  const entry = data === null ? void 0 : catalogEntryForInstalled(data.plugins, name, String(spec), repoIdentities[name], repoHints[name]);
9830
9842
  const status = updates[name];
9831
- const localDev = /^(?:link|file):/i.test(String(spec)) || status?.kind === "linked";
9843
+ const generation = status?.kind === "generation" || isGenerationSpec(String(spec));
9844
+ const localDev = !generation && (/^(?:link|file):/i.test(String(spec)) || status?.kind === "linked");
9832
9845
  const act = activations[name];
9833
9846
  const meta = act !== void 0 ? activationMeta(act.state, t) : null;
9834
9847
  const version = status && status.version ? "v" + status.version : "";
@@ -9949,7 +9962,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
9949
9962
  ]
9950
9963
  });
9951
9964
  })(),
9952
- status !== void 0 && status.updateAvailable && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
9965
+ status !== void 0 && (status.updateAvailable || generation && status.latest != null) && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
9953
9966
  className: Market_module_css_default.noteRow,
9954
9967
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
9955
9968
  type: "button",
@@ -10095,6 +10108,10 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
10095
10108
  className: Market_module_css_default.warnBtn,
10096
10109
  disabled: true,
10097
10110
  children: t("updating")
10111
+ }) : status !== void 0 && generation && status.latest != null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
10112
+ className: Market_module_css_default.metaTag,
10113
+ title: t("hostUpdateHint"),
10114
+ children: t("hostUpdateReady").replace("{0}", status.latest)
10098
10115
  }) : status && status.updateAvailable ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
10099
10116
  variant: "primary",
10100
10117
  size: "sm",
@@ -10734,7 +10751,8 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
10734
10751
  updateAvailable: own.updateAvailable === true,
10735
10752
  latest: own.latest ?? null,
10736
10753
  channelSwitch: own.channelSwitch ?? null,
10737
- restoreRequired: own.restoreRequired === true
10754
+ restoreRequired: own.restoreRequired === true,
10755
+ hostManaged: own.kind === "generation"
10738
10756
  };
10739
10757
  }
10740
10758
  /**
@@ -10960,7 +10978,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
10960
10978
  }, [post, t]);
10961
10979
  /** One label + hint block with an optional action, the host's row shape. */
10962
10980
  const row = (label, hint, action) => (0, react.createElement)("div", { className: Market_module_css_default.setRow }, (0, react.createElement)("div", { className: Market_module_css_default.setLabelBox }, (0, react.createElement)("div", { className: Market_module_css_default.setLabel }, label), (0, react.createElement)("div", { className: Market_module_css_default.setHint }, hint)), action);
10963
- const body = phase === "removed" ? row(t("setSelfRemoved"), t("setSelfRemovedHint"), null) : (0, react.createElement)(react.Fragment, null, status?.selfManaged === true ? row(update?.updateAvailable === true && update.latest !== null ? `${t("setSelfUpdateReady")} ${update.latest}` : update?.channelSwitch != null ? `${t("setChannelSwitch")} ${update.channelSwitch}` : t("setSelfUpToDate"), phase === "updated" ? t("setSelfUpdatedHint") : update?.channelSwitch != null ? t("setChannelSwitchHint") : update?.updateAvailable === true ? t("setSelfUpdateHint") : t("setSelfUpToDateHint"), phase === "updated" ? null : update?.updateAvailable === true ? (0, react.createElement)(_deepseek_ai_dsh_client_ui_primitives.Button, {
10981
+ const body = phase === "removed" ? row(t("setSelfRemoved"), t("setSelfRemovedHint"), null) : (0, react.createElement)(react.Fragment, null, status?.selfManaged === true ? row((update?.updateAvailable === true || update?.hostManaged === true) && update.latest !== null ? `${t("setSelfUpdateReady")} ${update.latest}` : update?.channelSwitch != null ? `${t("setChannelSwitch")} ${update.channelSwitch}` : t("setSelfUpToDate"), phase === "updated" ? t("setSelfUpdatedHint") : update?.channelSwitch != null ? t("setChannelSwitchHint") : update?.updateAvailable === true ? t("setSelfUpdateHint") : update?.hostManaged === true && update.latest !== null ? t("setSelfHostManagedHint") : t("setSelfUpToDateHint"), phase === "updated" ? null : update?.updateAvailable === true ? (0, react.createElement)(_deepseek_ai_dsh_client_ui_primitives.Button, {
10964
10982
  variant: "primary",
10965
10983
  size: "sm",
10966
10984
  disabled: busy,
@@ -36,6 +36,12 @@ function record(value) {
36
36
  export function manifestFacts(value) {
37
37
  const manifest = record(value) ?? {};
38
38
  const engines = record(manifest.engines);
39
+ // #577: the ecosystem declares the host requirement in BOTH shapes —
40
+ // top-level `engines.dsh` and `dsh.engines.dsh` under the manifest's own
41
+ // `dsh` field (the natural home, and what e.g. @linxin666/dsh-web-all
42
+ // publishes). Neither position is authoritative, so read both; when a
43
+ // manifest carries both, the top-level declaration wins.
44
+ const dshEngines = record(record(manifest.dsh)?.engines);
39
45
  const peers = record(manifest.peerDependencies);
40
46
  const peerDependencies = {};
41
47
  for (const [name, raw] of Object.entries(peers ?? {})) {
@@ -47,7 +53,7 @@ export function manifestFacts(value) {
47
53
  }
48
54
  return {
49
55
  version: range(manifest.version),
50
- enginesDsh: range(engines?.dsh),
56
+ enginesDsh: range(engines?.dsh) ?? range(dshEngines?.dsh),
51
57
  peerDependencies,
52
58
  };
53
59
  }
package/lib/install.js CHANGED
@@ -97,7 +97,7 @@ export async function withHoistRecovery(run, profile, pluginArgs, profileDirecto
97
97
  logEvent('warn', 'install', `a too-young release blocks pnpm's lockfile verification (#39) — retrying once with ${RELEASE_AGE_OVERRIDE}`);
98
98
  result = await run(profile, [pluginArgs[0], RELEASE_AGE_OVERRIDE, ...pluginArgs.slice(1)]);
99
99
  }
100
- else if (failure?.code === 'fetch-404'
100
+ else if ((failure?.code === 'fetch-404' || failure?.code === 'no-matching-version')
101
101
  && isUnpublishedHostPeer(failure.pkg, profile, profileDirectory)
102
102
  && (pluginArgs[0] === 'add' || pluginArgs[0] === 'remove')
103
103
  && !pluginArgs.includes(AUTO_INSTALL_PEERS_OFF)) {
@@ -341,6 +341,29 @@ export function classifyPnpmFailure(output, exitCode) {
341
341
  message: `有一个依赖在 registry 上不存在${zh},pnpm 因此拒绝任何安装操作。它可能是之前失败操作残留在 profile package.json 里的幽灵依赖(可手动删除该行),也可能是需要登录的私有包 / a dependency cannot be resolved from the registry${en}; pnpm refuses every install while it is present. It may be a ghost entry left in the profile's package.json by an earlier failed operation (remove that line by hand), or a private package needing registry credentials`,
342
342
  };
343
343
  }
344
+ // #569: a host peer that only ever published pre-releases (e.g.
345
+ // `@deepseek-ai/dsh-tools` with nothing above `-rc.*`) resolves to NO
346
+ // stable version, and pnpm reports ERR_PNPM_NO_MATCHING_VERSION — the
347
+ // package exists, the requested range matches nothing. This is the same
348
+ // unpublished-host-peer shape #289 recovers from, just with a different
349
+ // error code, so the classifier names it separately and the #289 retry
350
+ // shares it via install.ts's gate. Real output (pnpm 12.4.1): see
351
+ // tests/pnpm-compat.spec.ts, which pins this wording.
352
+ if (output.includes('ERR_PNPM_NO_MATCHING_VERSION')) {
353
+ const pkg = /No matching version found for\s+((?:@[^/\s]+\/)?[^@\s]+)@/.exec(output)?.[1]
354
+ ?? /GET\s+\S*\/([^/\s]+):/.exec(output)?.[1].replace(/%2[Ff]/g, '/');
355
+ const zh = pkg === undefined ? '' : `(${pkg})`;
356
+ const en = pkg === undefined ? '' : ` (${pkg})`;
357
+ const hostPeer = pkg !== undefined && HOST_NAMESPACE_RE.test(pkg);
358
+ return {
359
+ code: 'no-matching-version',
360
+ recoverable: false,
361
+ pkg,
362
+ message: hostPeer
363
+ ? `插件声明依赖的宿主包${zh}在 registry 上没有满足其版本范围的发布版(宿主运行时会自带它,npm 上不会出现这个版本)。市场会自动重试一次,放行这条 peer 依赖 / a plugin's declared host peer${en} has no published version satisfying its range — the runtime provides it, so npm carries no such version. The market retries once with that peer exempted from auto-install`
364
+ : `插件声明的一个依赖版本范围在 registry 上没有可满足的版本${zh},通常是该版本被弃用或从未发布 / a dependency of this plugin declared a version range with no matching release on the registry${en} — the range resolves to nothing (withdrawn or never published)`,
365
+ };
366
+ }
344
367
  // #389 by @qq1054435284: on Windows, pnpm stages the new version in a
345
368
  // sibling `<name>_tmp_<pid>_<n>` directory and renames it over the old one.
346
369
  // Windows refuses that rename while any file underneath the target is open,
package/lib/profile.js CHANGED
@@ -7,7 +7,7 @@ import { existsSync, readdirSync, readFileSync, realpathSync, renameSync, statSy
7
7
  import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
8
8
  import { isDeepStrictEqual } from 'node:util';
9
9
  import { resolveDshHome } from './home-paths.js';
10
- import { githubRemoteIdentities, githubRepoIdentities } from './sources.js';
10
+ import { githubRemoteIdentities, githubRepoIdentities, isGitHostedSpec } from './sources.js';
11
11
  /**
12
12
  * Whether a profile name follows DSH's own directory-name contract.
13
13
  *
@@ -408,12 +408,41 @@ export function readInstalledRepoIdentities(profile, name, spec, explicitDir) {
408
408
  * Discover declared repository identities and weaker local-origin hints. A
409
409
  * package.json repository declaration is authoritative; Git origin is only a
410
410
  * disambiguation hint because a checkout may legitimately point at a fork.
411
+ *
412
+ * Read for local AND registry specs, but never for a spec that already names
413
+ * its own source (#544 by @QinYupan; boundary from @bulingbuling688 in #548).
414
+ *
415
+ * The bug: two same-named catalog entries and an ordinary npm install. The
416
+ * manifest's `repository` — `git+https://github.com/MrmoLabs/dsh-mermaid.git`
417
+ * — is the one fact that says WHICH of the two is installed, and it sits in
418
+ * the same package.json for an npm install as for a local one. This returned
419
+ * empty for anything not `link:`/`file:`, so the client fell back to name
420
+ * matching, found two candidates, and matched NEITHER: the Discover card kept
421
+ * offering Install on a plugin that was running.
422
+ *
423
+ * Why a `github:`/URL install must NOT be read the same way: its spec already
424
+ * states the source, and the manifest can disagree with it. A fork installed
425
+ * as `github:myfork/plugin` usually still declares the UPSTREAM repository,
426
+ * because almost nobody edits that field when forking. Adding it as an
427
+ * identity made the upstream's card read as installed — measured, and the
428
+ * same mistake as #485: a weaker signal allowed to outvote a definite one.
429
+ * The first version of this fix widened to every spec kind and had exactly
430
+ * that hole.
431
+ *
432
+ * What stays local-only for the same reason it always was: the git-origin
433
+ * hint (there is no checkout to read for a registry install) and the local
434
+ * source directory walk.
411
435
  */
412
436
  export function readInstalledRepoEvidence(profile, name, spec, explicitDir) {
413
- if (!PACKAGE_NAME_RE.test(name) || !/^(?:link|file):/i.test(spec))
437
+ if (!PACKAGE_NAME_RE.test(name))
438
+ return { identities: [], hints: [] };
439
+ const local = /^(?:link|file):/i.test(spec);
440
+ // A spec that names its own source is the authority on it; see above.
441
+ if (!local && (isGitHostedSpec(spec) || /^https?:/i.test(spec.trim()))) {
414
442
  return { identities: [], hints: [] };
443
+ }
415
444
  const root = profileDir(profile, explicitDir);
416
- const sourceDir = localSpecDirectory(root, spec);
445
+ const sourceDir = local ? localSpecDirectory(root, spec) : null;
417
446
  const installedDir = installedPackageDirectory(root, name);
418
447
  const manifestDir = installedDir ?? sourceDir;
419
448
  const manifest = manifestDir === null ? readInstalledManifest(profile, name, explicitDir) : manifestAt(manifestDir);
package/lib/routes.js CHANGED
@@ -28,7 +28,7 @@ import { applyBundleOrder, mergeOrder, readBundleRules, readBundleStack, validat
28
28
  import { applyPreset, deletePreset, listPresets, previewPreset, savePreset } from './presets.js';
29
29
  import { createProfileSnapshot, DEFAULT_MAX_SNAPSHOTS, deleteSnapshot, listSnapshots, restoreSnapshot } from './snapshot.js';
30
30
  import { trialValidate } from './trial.js';
31
- import { codeloadAllowBuildsKey, findCatalogEntryForLocal, findInstalledAlias, githubCommitOfTarget, githubTargetAtCommit, gitAllowBuildsKey, gitUpdateTarget, installTargetFor, isLocalSpec, NPM_NAME_RE, repoOfTarget, restoreBlockedByWorkspace, restoreTargetForLocal, workspaceProtocolDeps } from './sources.js';
31
+ import { codeloadAllowBuildsKey, findCatalogEntryForLocal, findInstalledAlias, githubCommitOfTarget, githubTargetAtCommit, gitAllowBuildsKey, gitUpdateTarget, installTargetFor, isGenerationLink, isLocalSpec, NPM_NAME_RE, repoOfTarget, restoreBlockedByWorkspace, restoreTargetForLocal, workspaceProtocolDeps } from './sources.js';
32
32
  import { failureDetail, groupConflictsByOwner, isStaleUpdate, parseIgnoredBuilds, parsePrepareNotAllowed, pnpmNeverStarted, RELEASE_AGE_OVERRIDE, retargetCollections, validateAddedPlugins, withHoistRecovery } from './install.js';
33
33
  import { asChannel, CHANNELS, DIST_TAG, resolveChannel } from './channels.js';
34
34
  import { asRegion, githubProxyManaged, normalizeGithubProxy, REGIONS, routesFor, setActiveRegion, setCustomGithubProxy, } from './regions.js';
@@ -1048,6 +1048,20 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup) {
1048
1048
  return false;
1049
1049
  }
1050
1050
  };
1051
+ /**
1052
+ * The npm package an install with no registry spec of its own is compared
1053
+ * against: a `file:` package matched to the catalog (#429), or a
1054
+ * generation the desktop host linked in (#497). Null for everything else
1055
+ * — a developer's own `link:` checkout is never compared online.
1056
+ */
1057
+ const onlineSourceOf = (plugins, name, spec) => {
1058
+ if (!spec.toLowerCase().startsWith('file:') && !isGenerationLink(spec))
1059
+ return null;
1060
+ const evidence = readInstalledRepoEvidence(config.profile, name, spec, activeProfileDir);
1061
+ const entry = findCatalogEntryForLocal(plugins, name, evidence.identities, evidence.hints);
1062
+ const target = entry === null ? null : restoreTargetForLocal(entry, evidence.identities);
1063
+ return target !== null && NPM_NAME_RE.test(target) ? target : null;
1064
+ };
1051
1065
  const disposers = [
1052
1066
  host.webServer.register({
1053
1067
  kind: 'exact',
@@ -1109,7 +1123,22 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup) {
1109
1123
  const force = forceCheckFrom(request);
1110
1124
  const channel = activeChannel();
1111
1125
  const channelFor = SELF_NAMES.has(name) ? new Map([[name, channel]]) : undefined;
1112
- const update = (await checkUpdates(config.profile, force, activeProfileDir, channelFor))[name];
1126
+ // The same source lookup the market page makes, so a generation
1127
+ // (#497) or a catalog-matched local package answers here with the
1128
+ // release it can be compared against rather than with nothing.
1129
+ const spec = readInstalled(config.profile, activeProfileDir)[name];
1130
+ const onlineSourceFor = new Map();
1131
+ if (spec !== undefined && (spec.toLowerCase().startsWith('file:') || isGenerationLink(spec))) {
1132
+ try {
1133
+ const source = onlineSourceOf((await loadRegistry()).plugins, name, spec);
1134
+ if (source !== null)
1135
+ onlineSourceFor.set(name, source);
1136
+ }
1137
+ catch (error) {
1138
+ logEvent('warn', 'updates', `package source lookup failed — ${error instanceof Error ? error.message : String(error)}`);
1139
+ }
1140
+ }
1141
+ const update = (await checkUpdates(config.profile, force, activeProfileDir, channelFor, onlineSourceFor))[name];
1113
1142
  if (update === undefined) {
1114
1143
  sendJson(response, 404, { schema: UPDATE_API_V1_SCHEMA, error: 'plugin is not installed' });
1115
1144
  return;
@@ -2373,13 +2402,9 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup) {
2373
2402
  const migration = findGitToNpmMigration(registry.plugins, spec);
2374
2403
  if (migration !== null)
2375
2404
  sourceMigrationFor.set(name, migration);
2376
- if (!spec.toLowerCase().startsWith('file:'))
2377
- continue;
2378
- const evidence = readInstalledRepoEvidence(config.profile, name, spec, activeProfileDir);
2379
- const entry = findCatalogEntryForLocal(registry.plugins, name, evidence.identities, evidence.hints);
2380
- const target = entry === null ? null : restoreTargetForLocal(entry, evidence.identities);
2381
- if (target !== null && NPM_NAME_RE.test(target))
2382
- onlineSourceFor.set(name, target);
2405
+ const source = onlineSourceOf(registry.plugins, name, spec);
2406
+ if (source !== null)
2407
+ onlineSourceFor.set(name, source);
2383
2408
  }
2384
2409
  }
2385
2410
  catch (error) {
package/lib/sources.js CHANGED
@@ -492,6 +492,18 @@ export function gitUploadPackUrl(spec) {
492
492
  const base = url.toString().replace(/\/+$/, '');
493
493
  return `${base}/info/refs?service=git-upload-pack`;
494
494
  }
495
+ /**
496
+ * A `link:` that points into a generation the desktop host materialised
497
+ * (#497): `link:../.generations/live/<pkg>+<version>+<hash>/node_modules/<pkg>`.
498
+ * That is the host's production install, not a developer's checkout — it
499
+ * came from the registry and has releases to compare against. The host
500
+ * recognises its own installs by the `.generations/live/` segment, and no
501
+ * hand-written link ever lands under that directory, so the same test is
502
+ * enough here.
503
+ */
504
+ export function isGenerationLink(spec) {
505
+ return /^link:/i.test(spec) && /(?:^|[\\/])\.generations[\\/]live[\\/]/i.test(spec);
506
+ }
495
507
  export { findCatalogEntryForLocal, resolveCatalogRestore } from './catalog-local-match.js';
496
508
  /**
497
509
  * pnpm add target for restoring a local checkout onto a catalog entry.
@@ -33,7 +33,7 @@ export declare function pluginArgsFor(profileDir: string, pluginArgs: string[]):
33
33
  */
34
34
  export declare const HOST_NAMESPACE_RE: RegExp;
35
35
  export interface PnpmFailure {
36
- code: 'adding-to-root' | 'not-a-workspace' | 'hoist-pattern-diff' | 'pnpm-missing' | 'release-age-violation' | 'ignored-builds' | 'git-prepare-not-allowed' | 'git-prepare-failed' | 'tarball-url-mismatch' | 'fetch-404' | 'transient-network' | 'fetch-timeout' | 'unexpected-store' | 'patch-failed' | 'missing-tarball-integrity' | 'windows-file-locked' | 'pnpm-unusable' | 'missing-local-dependency';
36
+ code: 'adding-to-root' | 'not-a-workspace' | 'hoist-pattern-diff' | 'pnpm-missing' | 'release-age-violation' | 'ignored-builds' | 'git-prepare-not-allowed' | 'git-prepare-failed' | 'tarball-url-mismatch' | 'fetch-404' | 'no-matching-version' | 'transient-network' | 'fetch-timeout' | 'unexpected-store' | 'patch-failed' | 'missing-tarball-integrity' | 'windows-file-locked' | 'pnpm-unusable' | 'missing-local-dependency';
37
37
  /** Bilingual, actionable message shown to the user instead of the raw wall of text. */
38
38
  message: string;
39
39
  /** True when re-running `pnpm install` in the profile is the documented recovery. */
@@ -121,6 +121,30 @@ export interface InstalledRepoEvidence {
121
121
  * Discover declared repository identities and weaker local-origin hints. A
122
122
  * package.json repository declaration is authoritative; Git origin is only a
123
123
  * disambiguation hint because a checkout may legitimately point at a fork.
124
+ *
125
+ * Read for local AND registry specs, but never for a spec that already names
126
+ * its own source (#544 by @QinYupan; boundary from @bulingbuling688 in #548).
127
+ *
128
+ * The bug: two same-named catalog entries and an ordinary npm install. The
129
+ * manifest's `repository` — `git+https://github.com/MrmoLabs/dsh-mermaid.git`
130
+ * — is the one fact that says WHICH of the two is installed, and it sits in
131
+ * the same package.json for an npm install as for a local one. This returned
132
+ * empty for anything not `link:`/`file:`, so the client fell back to name
133
+ * matching, found two candidates, and matched NEITHER: the Discover card kept
134
+ * offering Install on a plugin that was running.
135
+ *
136
+ * Why a `github:`/URL install must NOT be read the same way: its spec already
137
+ * states the source, and the manifest can disagree with it. A fork installed
138
+ * as `github:myfork/plugin` usually still declares the UPSTREAM repository,
139
+ * because almost nobody edits that field when forking. Adding it as an
140
+ * identity made the upstream's card read as installed — measured, and the
141
+ * same mistake as #485: a weaker signal allowed to outvote a definite one.
142
+ * The first version of this fix widened to every spec kind and had exactly
143
+ * that hole.
144
+ *
145
+ * What stays local-only for the same reason it always was: the git-origin
146
+ * hint (there is no checkout to read for a registry install) and the local
147
+ * source directory walk.
124
148
  */
125
149
  export declare function readInstalledRepoEvidence(profile: string, name: string, spec: string, explicitDir?: string): InstalledRepoEvidence;
126
150
  /** Pinned commit per `owner/repo` from the profile lockfile's codeload tarball URLs. */
@@ -177,6 +177,16 @@ export declare function gitUpdateTarget(spec: string): string | null;
177
177
  * Userinfo is stripped so update checks do not resend embedded credentials.
178
178
  */
179
179
  export declare function gitUploadPackUrl(spec: string): string | null;
180
+ /**
181
+ * A `link:` that points into a generation the desktop host materialised
182
+ * (#497): `link:../.generations/live/<pkg>+<version>+<hash>/node_modules/<pkg>`.
183
+ * That is the host's production install, not a developer's checkout — it
184
+ * came from the registry and has releases to compare against. The host
185
+ * recognises its own installs by the `.generations/live/` segment, and no
186
+ * hand-written link ever lands under that directory, so the same test is
187
+ * enough here.
188
+ */
189
+ export declare function isGenerationLink(spec: string): boolean;
180
190
  export { findCatalogEntryForLocal, resolveCatalogRestore } from './catalog-local-match.ts';
181
191
  /**
182
192
  * pnpm add target for restoring a local checkout onto a catalog entry.
@@ -5,7 +5,14 @@
5
5
  */
6
6
  import { type Channel } from './channels.ts';
7
7
  export interface UpdateStatus {
8
- kind: 'github' | 'npm' | 'linked';
8
+ /**
9
+ * `generation` is a `link:` the desktop host wrote (#497): the host
10
+ * installs it and reconciles it at startup, so the market names a newer
11
+ * release under `latest` and never offers to apply it. For that kind
12
+ * `latest` is null when the installed build is current or nothing newer
13
+ * can be confirmed.
14
+ */
15
+ kind: 'github' | 'npm' | 'linked' | 'generation';
9
16
  version: string | null;
10
17
  current: string | null;
11
18
  latest: string | null;
@@ -116,8 +123,9 @@ export declare function checkUpdates(profile: string, force?: boolean, explicitD
116
123
  */
117
124
  channelFor?: ReadonlyMap<string, Channel>,
118
125
  /**
119
- * Curated npm sources for `file:` installs that were matched to the market
120
- * catalog. `link:` workspaces remain development sources and are never
121
- * opted into online updates.
126
+ * Curated npm sources for the installs that carry no registry spec of
127
+ * their own: `file:` packages matched to the market catalog (#429) and
128
+ * the generations the desktop host links in (#497). Any other `link:` is
129
+ * a development workspace and is never opted into online updates.
122
130
  */
123
131
  onlineSourceFor?: ReadonlyMap<string, string>): Promise<Record<string, UpdateStatus>>;
package/lib/updates.js CHANGED
@@ -8,7 +8,7 @@ import { resolveHeadCommit } from './accelerate.js';
8
8
  import { marketFetch } from './net.js';
9
9
  import { activeRegion } from './regions.js';
10
10
  import { profileDir, readGitResolutionCommit, readInstalled, readInstalledVersion, readLockCommits } from './profile.js';
11
- import { gitCommitOfTarget, gitUploadPackUrl, githubCommitOfTarget, githubRefOfTarget, isGitHostedSpec, repoOfTarget } from './sources.js';
11
+ import { gitCommitOfTarget, gitUploadPackUrl, githubCommitOfTarget, githubRefOfTarget, isGenerationLink, isGitHostedSpec, repoOfTarget } from './sources.js';
12
12
  const UPDATES_TTL_MS = 30 * 60 * 1000;
13
13
  const GIT_REMOTE_HEAD_TIMEOUT_MS = 6000;
14
14
  let updatesCache = null;
@@ -254,9 +254,10 @@ export async function checkUpdates(profile, force = false, explicitDir,
254
254
  */
255
255
  channelFor = new Map(),
256
256
  /**
257
- * Curated npm sources for `file:` installs that were matched to the market
258
- * catalog. `link:` workspaces remain development sources and are never
259
- * opted into online updates.
257
+ * Curated npm sources for the installs that carry no registry spec of
258
+ * their own: `file:` packages matched to the market catalog (#429) and
259
+ * the generations the desktop host links in (#497). Any other `link:` is
260
+ * a development workspace and is never opted into online updates.
260
261
  */
261
262
  onlineSourceFor = new Map()) {
262
263
  const activeProfileDir = profileDir(profile, explicitDir);
@@ -289,6 +290,28 @@ onlineSourceFor = new Map()) {
289
290
  return;
290
291
  }
291
292
  if (normalizedSpec.startsWith('link:')) {
293
+ if (isGenerationLink(spec)) {
294
+ // The desktop host's production install, not a checkout (#497): it
295
+ // came from the registry and has a release history to compare
296
+ // against. Reported, never offered — the host reconciles `live/`
297
+ // against its own desired.json at startup, so an update applied
298
+ // here would appear to work and silently revert on the next boot.
299
+ // Only a release that is actually newer is named; current and
300
+ // unknown both read as null, so a lagging `latest` tag (#64) can't
301
+ // advertise a downgrade either.
302
+ const onlineSource = onlineSourceFor.get(name);
303
+ const stable = onlineSource === undefined ? null : await fetchNpmLatest(onlineSource);
304
+ const channel = channelFor.get(name);
305
+ const newest = channel === undefined || onlineSource === undefined
306
+ ? stable
307
+ : await versionOnChannel(onlineSource, channel, stable);
308
+ result[name] = {
309
+ kind: 'generation', version, current: version,
310
+ latest: isUpgrade(version, newest) ? newest : null,
311
+ updateAvailable: false,
312
+ };
313
+ return;
314
+ }
292
315
  result[name] = { kind: 'linked', version, current: null, latest: null, updateAvailable: false };
293
316
  return;
294
317
  }
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.45.0",
4
+ "version": "1.46.0",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/types/index.d.ts",
@@ -50,8 +50,6 @@
50
50
  },
51
51
  "client": {
52
52
  "inject": [
53
- "@deepseek-ai/dsh-client-connection",
54
- "@deepseek-ai/dsh-client-runtime",
55
53
  "@deepseek-ai/dsh-client-locale",
56
54
  "@deepseek-ai/dsh-client-ui-settings",
57
55
  "@deepseek-ai/dsh-client-ui-theme"
@@ -42,7 +42,7 @@ import type { OperationRecord } from './operations.ts'
42
42
  import { Diagnostics } from './Diagnostics.tsx'
43
43
  import { exportMarketLog } from './self-check.ts'
44
44
  import {
45
- api, applyGithubRouting, avatarColor, catalogEntryForInstalled, entryForDep, githubRouteCandidates, groupSwitchState, humanOutput, installedForCatalog, isInstalled, looksTerminal, matchInstalledName, orderedCategories, pluginCategories,
45
+ api, applyGithubRouting, avatarColor, catalogEntryForInstalled, entryForDep, githubRouteCandidates, groupSwitchState, humanOutput, installedForCatalog, isGenerationSpec, isInstalled, looksTerminal, matchInstalledName, orderedCategories, pluginCategories,
46
46
  formatCount, pageItems, pluginName, pluginScreenshotCandidates, pluginScreenshots, pluginsForFavorites, rankThemeScreenshots, readSession, rememberGithubRoute, resetScreenshotsCache, resolveCatalogRestore, safeScreenshots, staleFavoriteUrls, themePlugins as themePluginsOf, themeSwatch, TIME_RANGE_DAYS, visiblePlugins,
47
47
  } from './market-data.ts'
48
48
  import type {
@@ -1542,6 +1542,14 @@ export function MarketSection(props: MarketSectionProps) {
1542
1542
  // (padding, sticky `top`), which drifts silently whenever that CSS
1543
1543
  // changes. The sentinel just reports what's actually true on screen.
1544
1544
  const [catsStuck, setCatsStuck] = useState(false)
1545
+ /** While the sticky header is pinned, expansion is this flag — not
1546
+ * `catsOpen`. Becoming stuck collapses on the SAME render (stuckExpanded
1547
+ * starts false) instead of a follow-up `useLayoutEffect` that flipped
1548
+ * `catsOpen` and forced a second commit; that delayed height change is
1549
+ * what lined up with the host Settings dialog hitching after tab 收放.
1550
+ * An explicit chevron click while stuck sets this true and keeps
1551
+ * `catsOpen` in sync so unstuck restores the user's choice. */
1552
+ const [stuckExpanded, setStuckExpanded] = useState(false)
1545
1553
  const [catsSentinel, setCatsSentinel] = useState<HTMLDivElement | null>(null)
1546
1554
 
1547
1555
  const refreshInstalled = useCallback((force?: boolean) => {
@@ -3638,32 +3646,23 @@ export function MarketSection(props: MarketSectionProps) {
3638
3646
  const overflow = root.scrollHeight - root.clientHeight
3639
3647
  if (overflow <= wrap.offsetHeight) return
3640
3648
  }
3641
- setCatsStuck(leftView)
3649
+ setCatsStuck(prev => (prev === leftView ? prev : leftView))
3642
3650
  },
3643
3651
  { root: bodyRef.current, threshold: 0 },
3644
3652
  )
3645
3653
  observer.observe(catsSentinel)
3646
3654
  return () => observer.disconnect()
3647
3655
  }, [catsSentinel])
3648
- /**
3649
- * Becoming stuck auto-collapses an open row — a REAL `catsOpen` flip, not
3650
- * a display-only override. An earlier version faked this by computing a
3651
- * separate "effectively open" value for rendering while leaving `catsOpen`
3652
- * itself true; the chevron's own click handler only ever toggled the real
3653
- * `catsOpen`, so while stuck it flipped a value the render path had
3654
- * already stopped consulting — clicking "expand" did nothing visible
3655
- * (reported: "吸顶滚动了之后,展开没反应了"). Driving the same state the
3656
- * chevron drives means the chevron always works, stuck or not.
3657
- */
3658
- const catsAutoCollapsedRef = useRef(false)
3659
- useLayoutEffect(() => {
3660
- if (catsStuck) {
3661
- if (catsOpen) { setCatsOpen(false); catsAutoCollapsedRef.current = true }
3662
- } else if (catsAutoCollapsedRef.current) {
3663
- setCatsOpen(true)
3664
- catsAutoCollapsedRef.current = false
3665
- }
3656
+ // Drop any in-pin expand once the header unpins, so the next pin starts
3657
+ // collapsed without a rising-edge setState in the observer.
3658
+ useEffect(() => {
3659
+ if (!catsStuck) setStuckExpanded(false)
3666
3660
  }, [catsStuck])
3661
+ /** Expanded chips + chevron share one value. Stuck uses `stuckExpanded`
3662
+ * so pinning collapses without rewriting `catsOpen` in a layout effect
3663
+ * (see stuckExpanded state). Leaving stuck falls back to `catsOpen`,
3664
+ * which still holds the pre-pin / in-pin user choice. */
3665
+ const catsExpanded = catsStuck ? stuckExpanded : catsOpen
3667
3666
 
3668
3667
  /**
3669
3668
  * A fresh install (hotUrls/hotNames) and a toggle/group action
@@ -4011,7 +4010,10 @@ export function MarketSection(props: MarketSectionProps) {
4011
4010
  <div
4012
4011
  className={css.body}
4013
4012
  ref={bodyRef}
4014
- onScroll={e => setShowTop(e.currentTarget.scrollTop > 400)}
4013
+ onScroll={e => {
4014
+ const show = e.currentTarget.scrollTop > 400
4015
+ setShowTop(prev => (prev === show ? prev : show))
4016
+ }}
4015
4017
  >
4016
4018
  {tab === 'backup'
4017
4019
  ? (
@@ -4168,12 +4170,12 @@ export function MarketSection(props: MarketSectionProps) {
4168
4170
  {(() => {
4169
4171
  // Collapsed, the selected category is pulled to the front so it never hides.
4170
4172
  // Whenever collapsed (default, or auto-collapsed by the sticky
4171
- // header going stuck — see catsAutoCollapsedRef above), a stuck
4173
+ // header going stuck — see catsExpanded / stuckExpanded), a stuck
4172
4174
  // header uses the one-row budget instead of the two-row one so an
4173
4175
  // already-open list that just got pinned shrinks further.
4174
4176
  const budget = catsStuck ? visibleCatsOneRow : visibleCats
4175
- const ordered = orderedCategories(categories, cat, catsOpen, budget)
4176
- const shown = catsOpen || budget === null ? ordered : ordered.slice(0, Math.max(0, budget - 1))
4177
+ const ordered = orderedCategories(categories, cat, catsExpanded, budget)
4178
+ const shown = catsExpanded || budget === null ? ordered : ordered.slice(0, Math.max(0, budget - 1))
4177
4179
  return (
4178
4180
  <>
4179
4181
  <Pill data-chip="1" active={cat === 'all'} onClick={() => setCat('all')}>{t('all') + ' (' + formatCount(data!.count) + ')'}</Pill>
@@ -4189,13 +4191,12 @@ export function MarketSection(props: MarketSectionProps) {
4189
4191
  variant="ghost"
4190
4192
  size="sm"
4191
4193
  className={css.catsToggle}
4192
- icon={catsOpen ? <IconChevronUpOutline14 size={14} /> : <IconChevronDownOutline14 size={14} />}
4193
- aria-label={catsOpen ? t('catsLess') : t('catsMore')}
4194
+ icon={catsExpanded ? <IconChevronUpOutline14 size={14} /> : <IconChevronDownOutline14 size={14} />}
4195
+ aria-label={catsExpanded ? t('catsLess') : t('catsMore')}
4194
4196
  onClick={() => {
4195
- // An explicit click always wins — don't let the next
4196
- // stuck/unstuck transition second-guess it.
4197
- catsAutoCollapsedRef.current = false
4198
- setCatsOpen(o => !o)
4197
+ const next = !catsExpanded
4198
+ if (catsStuck) setStuckExpanded(next)
4199
+ setCatsOpen(next)
4199
4200
  }}
4200
4201
  />
4201
4202
  </>
@@ -4592,7 +4593,13 @@ export function MarketSection(props: MarketSectionProps) {
4592
4593
  const missing = pendingBackup !== null && !installedFiles.includes(name)
4593
4594
  const entry = data === null ? undefined : catalogEntryForInstalled(data.plugins, name, String(spec), repoIdentities[name], repoHints[name])
4594
4595
  const status = updates[name]
4595
- const localDev = /^(?:link|file):/i.test(String(spec)) || status?.kind === 'linked'
4596
+ // A generation is the desktop host's own install (#497):
4597
+ // the host updates it, the market only says a newer
4598
+ // release exists. Not a development checkout, so no
4599
+ // "local" tag and no restore — the host would put the
4600
+ // generation straight back.
4601
+ const generation = status?.kind === 'generation' || isGenerationSpec(String(spec))
4602
+ const localDev = !generation && (/^(?:link|file):/i.test(String(spec)) || status?.kind === 'linked')
4596
4603
  const act = activations[name]
4597
4604
  const meta = act !== undefined ? activationMeta(act.state, t) : null
4598
4605
  const version = status && status.version ? 'v' + status.version : ''
@@ -4706,7 +4713,7 @@ export function MarketSection(props: MarketSectionProps) {
4706
4713
  one quiet line in the flow the row already
4707
4714
  reserves for conditional content, so rows
4708
4715
  without it are pixel-identical to before. */}
4709
- {status !== undefined && status.updateAvailable && (
4716
+ {status !== undefined && (status.updateAvailable || (generation && status.latest != null)) && (
4710
4717
  <div className={css.noteRow}>
4711
4718
  <button
4712
4719
  type="button"
@@ -4848,6 +4855,8 @@ export function MarketSection(props: MarketSectionProps) {
4848
4855
  ? <span className={`${css.metaTag} ${css.metaTagOk}`}>{act?.state === 'live' ? t('updatedLive') : t('updated')}</span>
4849
4856
  : updatingName === name
4850
4857
  ? <Button variant="primary" size="sm" className={css.warnBtn} disabled>{t('updating')}</Button>
4858
+ : status !== undefined && generation && status.latest != null
4859
+ ? <span className={css.metaTag} title={t('hostUpdateHint')}>{t('hostUpdateReady').replace('{0}', status.latest)}</span>
4851
4860
  : status && status.updateAvailable
4852
4861
  ? (
4853
4862
  <Button
@@ -105,12 +105,14 @@ interface SelfUpdate {
105
105
  channelSwitch: string | null
106
106
  /** The current build came from a local package and must switch to the online release. */
107
107
  restoreRequired: boolean
108
+ /** A generation the desktop host installed (#497): a newer release is named here, never applied from here. */
109
+ hostManaged: boolean
108
110
  }
109
111
 
110
112
  type Phase = 'idle' | 'confirming' | 'working' | 'removed' | 'updated' | 'failed'
111
113
 
112
114
  /** The market's own row as api('/dsh-market/updates') sends it. */
113
- interface RawUpdate { updateAvailable?: boolean; latest?: string; channelSwitch?: string; restoreRequired?: boolean }
115
+ interface RawUpdate { updateAvailable?: boolean; latest?: string; channelSwitch?: string; restoreRequired?: boolean; kind?: string }
114
116
 
115
117
  const CHANNELS: Channel[] = ['stable', 'beta', 'dev']
116
118
  const asChannel = (value: unknown): Channel | null =>
@@ -168,6 +170,7 @@ function readUpdate(own: RawUpdate): SelfUpdate {
168
170
  latest: own.latest ?? null,
169
171
  channelSwitch: own.channelSwitch ?? null,
170
172
  restoreRequired: own.restoreRequired === true,
173
+ hostManaged: own.kind === 'generation',
171
174
  }
172
175
  }
173
176
 
@@ -404,7 +407,7 @@ export function SettingsCard({ t, onRemoved }: SettingsCardProps): ReactElement
404
407
  // An older offer is not an update, and calling it one would have
405
408
  // the user click "更新" to go backwards. It IS what picking an
406
409
  // earlier channel asked for, so it is offered — under its own name.
407
- update?.updateAvailable === true && update.latest !== null
410
+ (update?.updateAvailable === true || update?.hostManaged === true) && update.latest !== null
408
411
  ? `${t('setSelfUpdateReady')} ${update.latest}`
409
412
  : update?.channelSwitch != null
410
413
  ? `${t('setChannelSwitch')} ${update.channelSwitch}`
@@ -416,7 +419,10 @@ export function SettingsCard({ t, onRemoved }: SettingsCardProps): ReactElement
416
419
  // Explains what the Update button does — only worth saying
417
420
  // when that button is actually on screen. Already up to date,
418
421
  // it read as an instruction for an action that wasn't there.
419
- : update?.updateAvailable === true ? t('setSelfUpdateHint') : t('setSelfUpToDateHint'),
422
+ : update?.updateAvailable === true ? t('setSelfUpdateHint')
423
+ // Named, not offered: the desktop host owns this install.
424
+ : update?.hostManaged === true && update.latest !== null ? t('setSelfHostManagedHint')
425
+ : t('setSelfUpToDateHint'),
420
426
  phase === 'updated'
421
427
  ? null
422
428
  : update?.updateAvailable === true
@@ -7,6 +7,7 @@ export const zh = {
7
7
  setSelfUpdateReady: '有新版本',
8
8
  setSelfUpdateHint: '更新会下载新版本,重启后生效。',
9
9
  setSelfUpToDateHint: '',
10
+ setSelfHostManagedHint: '这份市场由桌面宿主安装,新版本请在桌面端更新。',
10
11
  setSelfUpdate: '更新',
11
12
  setSelfUpdatedHint: '已下载完成。重启 DeepSeek Harness 后新版本才会生效——前端页面会立即更新,服务端不会。',
12
13
  setRegion: '下载区域',
@@ -121,6 +122,8 @@ export const zh = {
121
122
  updateFail: '更新失败',
122
123
  upToDate: '已是最新',
123
124
  linkedDev: '本地开发',
125
+ hostUpdateReady: '有新版本 {0}',
126
+ hostUpdateHint: '这份由桌面宿主安装和更新,市场只提醒,不在这里更新',
124
127
  notesLink: '更新内容',
125
128
  notesRelease: '版本说明',
126
129
  notesCommits: '提交记录',
@@ -545,6 +548,7 @@ export const en: Record<MarketKey, string> = {
545
548
  setSelfUpdateReady: 'New version available:',
546
549
  setSelfUpdateHint: 'Updating downloads the new version; it takes effect after a restart.',
547
550
  setSelfUpToDateHint: '',
551
+ setSelfHostManagedHint: 'This copy was installed by the desktop host; update it from the desktop app.',
548
552
  setSelfUpdate: 'Update',
549
553
  setSelfUpdatedHint: 'Downloaded. Restart DeepSeek Harness for it to take effect — the frontend updates at once, the server does not.',
550
554
  setRegion: 'Download region',
@@ -659,6 +663,8 @@ export const en: Record<MarketKey, string> = {
659
663
  updateFail: 'Update failed',
660
664
  upToDate: 'Up to date',
661
665
  linkedDev: 'local',
666
+ hostUpdateReady: 'New version {0}',
667
+ hostUpdateHint: 'Installed and updated by the desktop host; the market only reports it',
662
668
  notesLink: 'What changed',
663
669
  notesRelease: 'Release notes',
664
670
  notesCommits: 'Commits',
@@ -124,10 +124,21 @@ export interface GistExportResult {
124
124
  gistUrl: string
125
125
  }
126
126
 
127
+ /**
128
+ * A `link:` the desktop host wrote for one of its generations (#497). The
129
+ * test the server applies (`isGenerationLink` in sources.ts), repeated here
130
+ * because the client bundle cannot import server modules.
131
+ */
132
+ export function isGenerationSpec(spec: string): boolean {
133
+ return /^link:/i.test(spec) && /(?:^|[\\/])\.generations[\\/]live[\\/]/i.test(spec)
134
+ }
135
+
127
136
  /** Per-package update status from /dsh-market/updates. */
128
137
  export interface UpdateStatus {
129
138
  updateAvailable?: boolean
130
139
  version?: string
140
+ /** `github` | `npm` | `linked` | `generation` — the last is a host-managed
141
+ install (#497): `latest` names a newer release, never an offer. */
131
142
  kind?: string
132
143
  /** What is installed and what the source of truth offers — versions for npm
133
144
  packages, commit shas for github installs; the notes dialog (#294) shows
@@ -79,6 +79,12 @@ function record(value: unknown): Record<string, unknown> | null {
79
79
  export function manifestFacts(value: unknown): NpmManifestFacts {
80
80
  const manifest = record(value) ?? {}
81
81
  const engines = record(manifest.engines)
82
+ // #577: the ecosystem declares the host requirement in BOTH shapes —
83
+ // top-level `engines.dsh` and `dsh.engines.dsh` under the manifest's own
84
+ // `dsh` field (the natural home, and what e.g. @linxin666/dsh-web-all
85
+ // publishes). Neither position is authoritative, so read both; when a
86
+ // manifest carries both, the top-level declaration wins.
87
+ const dshEngines = record(record(manifest.dsh)?.engines)
82
88
  const peers = record(manifest.peerDependencies)
83
89
  const peerDependencies: Record<string, string> = {}
84
90
  for (const [name, raw] of Object.entries(peers ?? {})) {
@@ -88,7 +94,7 @@ export function manifestFacts(value: unknown): NpmManifestFacts {
88
94
  }
89
95
  return {
90
96
  version: range(manifest.version),
91
- enginesDsh: range(engines?.dsh),
97
+ enginesDsh: range(engines?.dsh) ?? range(dshEngines?.dsh),
92
98
  peerDependencies,
93
99
  }
94
100
  }
package/src/install.ts CHANGED
@@ -112,7 +112,7 @@ export async function withHoistRecovery(
112
112
  logEvent('warn', 'install', `a too-young release blocks pnpm's lockfile verification (#39) — retrying once with ${RELEASE_AGE_OVERRIDE}`)
113
113
  result = await run(profile, [pluginArgs[0], RELEASE_AGE_OVERRIDE, ...pluginArgs.slice(1)])
114
114
  } else if (
115
- failure?.code === 'fetch-404'
115
+ (failure?.code === 'fetch-404' || failure?.code === 'no-matching-version')
116
116
  && isUnpublishedHostPeer(failure.pkg, profile, profileDirectory)
117
117
  && (pluginArgs[0] === 'add' || pluginArgs[0] === 'remove')
118
118
  && !pluginArgs.includes(AUTO_INSTALL_PEERS_OFF)
@@ -46,7 +46,7 @@ export const HOST_NAMESPACE_RE = /^@deepseek-ai\//
46
46
  export interface PnpmFailure {
47
47
  code: 'adding-to-root' | 'not-a-workspace' | 'hoist-pattern-diff' | 'pnpm-missing' | 'release-age-violation'
48
48
  | 'ignored-builds' | 'git-prepare-not-allowed' | 'git-prepare-failed' | 'tarball-url-mismatch'
49
- | 'fetch-404' | 'transient-network' | 'fetch-timeout'
49
+ | 'fetch-404' | 'no-matching-version' | 'transient-network' | 'fetch-timeout'
50
50
  | 'unexpected-store' | 'patch-failed' | 'missing-tarball-integrity' | 'windows-file-locked'
51
51
  | 'pnpm-unusable' | 'missing-local-dependency'
52
52
  /** Bilingual, actionable message shown to the user instead of the raw wall of text. */
@@ -378,6 +378,29 @@ export function classifyPnpmFailure(output: string, exitCode?: number | null): P
378
378
  message: `有一个依赖在 registry 上不存在${zh},pnpm 因此拒绝任何安装操作。它可能是之前失败操作残留在 profile package.json 里的幽灵依赖(可手动删除该行),也可能是需要登录的私有包 / a dependency cannot be resolved from the registry${en}; pnpm refuses every install while it is present. It may be a ghost entry left in the profile's package.json by an earlier failed operation (remove that line by hand), or a private package needing registry credentials`,
379
379
  }
380
380
  }
381
+ // #569: a host peer that only ever published pre-releases (e.g.
382
+ // `@deepseek-ai/dsh-tools` with nothing above `-rc.*`) resolves to NO
383
+ // stable version, and pnpm reports ERR_PNPM_NO_MATCHING_VERSION — the
384
+ // package exists, the requested range matches nothing. This is the same
385
+ // unpublished-host-peer shape #289 recovers from, just with a different
386
+ // error code, so the classifier names it separately and the #289 retry
387
+ // shares it via install.ts's gate. Real output (pnpm 12.4.1): see
388
+ // tests/pnpm-compat.spec.ts, which pins this wording.
389
+ if (output.includes('ERR_PNPM_NO_MATCHING_VERSION')) {
390
+ const pkg = /No matching version found for\s+((?:@[^/\s]+\/)?[^@\s]+)@/.exec(output)?.[1]
391
+ ?? /GET\s+\S*\/([^/\s]+):/.exec(output)?.[1].replace(/%2[Ff]/g, '/')
392
+ const zh = pkg === undefined ? '' : `(${pkg})`
393
+ const en = pkg === undefined ? '' : ` (${pkg})`
394
+ const hostPeer = pkg !== undefined && HOST_NAMESPACE_RE.test(pkg)
395
+ return {
396
+ code: 'no-matching-version',
397
+ recoverable: false,
398
+ pkg,
399
+ message: hostPeer
400
+ ? `插件声明依赖的宿主包${zh}在 registry 上没有满足其版本范围的发布版(宿主运行时会自带它,npm 上不会出现这个版本)。市场会自动重试一次,放行这条 peer 依赖 / a plugin's declared host peer${en} has no published version satisfying its range — the runtime provides it, so npm carries no such version. The market retries once with that peer exempted from auto-install`
401
+ : `插件声明的一个依赖版本范围在 registry 上没有可满足的版本${zh},通常是该版本被弃用或从未发布 / a dependency of this plugin declared a version range with no matching release on the registry${en} — the range resolves to nothing (withdrawn or never published)`,
402
+ }
403
+ }
381
404
  // #389 by @qq1054435284: on Windows, pnpm stages the new version in a
382
405
  // sibling `<name>_tmp_<pid>_<n>` directory and renames it over the old one.
383
406
  // Windows refuses that rename while any file underneath the target is open,
package/src/profile.ts CHANGED
@@ -8,7 +8,7 @@ import { existsSync, readdirSync, readFileSync, realpathSync, renameSync, statSy
8
8
  import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
9
9
  import { isDeepStrictEqual } from 'node:util'
10
10
  import { resolveDshHome } from './home-paths.ts'
11
- import { githubRemoteIdentities, githubRepoIdentities } from './sources.ts'
11
+ import { githubRemoteIdentities, githubRepoIdentities, isGitHostedSpec } from './sources.ts'
12
12
 
13
13
  /**
14
14
  * Whether a profile name follows DSH's own directory-name contract.
@@ -431,6 +431,30 @@ export interface InstalledRepoEvidence {
431
431
  * Discover declared repository identities and weaker local-origin hints. A
432
432
  * package.json repository declaration is authoritative; Git origin is only a
433
433
  * disambiguation hint because a checkout may legitimately point at a fork.
434
+ *
435
+ * Read for local AND registry specs, but never for a spec that already names
436
+ * its own source (#544 by @QinYupan; boundary from @bulingbuling688 in #548).
437
+ *
438
+ * The bug: two same-named catalog entries and an ordinary npm install. The
439
+ * manifest's `repository` — `git+https://github.com/MrmoLabs/dsh-mermaid.git`
440
+ * — is the one fact that says WHICH of the two is installed, and it sits in
441
+ * the same package.json for an npm install as for a local one. This returned
442
+ * empty for anything not `link:`/`file:`, so the client fell back to name
443
+ * matching, found two candidates, and matched NEITHER: the Discover card kept
444
+ * offering Install on a plugin that was running.
445
+ *
446
+ * Why a `github:`/URL install must NOT be read the same way: its spec already
447
+ * states the source, and the manifest can disagree with it. A fork installed
448
+ * as `github:myfork/plugin` usually still declares the UPSTREAM repository,
449
+ * because almost nobody edits that field when forking. Adding it as an
450
+ * identity made the upstream's card read as installed — measured, and the
451
+ * same mistake as #485: a weaker signal allowed to outvote a definite one.
452
+ * The first version of this fix widened to every spec kind and had exactly
453
+ * that hole.
454
+ *
455
+ * What stays local-only for the same reason it always was: the git-origin
456
+ * hint (there is no checkout to read for a registry install) and the local
457
+ * source directory walk.
434
458
  */
435
459
  export function readInstalledRepoEvidence(
436
460
  profile: string,
@@ -438,9 +462,14 @@ export function readInstalledRepoEvidence(
438
462
  spec: string,
439
463
  explicitDir?: string,
440
464
  ): InstalledRepoEvidence {
441
- if (!PACKAGE_NAME_RE.test(name) || !/^(?:link|file):/i.test(spec)) return { identities: [], hints: [] }
465
+ if (!PACKAGE_NAME_RE.test(name)) return { identities: [], hints: [] }
466
+ const local = /^(?:link|file):/i.test(spec)
467
+ // A spec that names its own source is the authority on it; see above.
468
+ if (!local && (isGitHostedSpec(spec) || /^https?:/i.test(spec.trim()))) {
469
+ return { identities: [], hints: [] }
470
+ }
442
471
  const root = profileDir(profile, explicitDir)
443
- const sourceDir = localSpecDirectory(root, spec)
472
+ const sourceDir = local ? localSpecDirectory(root, spec) : null
444
473
  const installedDir = installedPackageDirectory(root, name)
445
474
  const manifestDir = installedDir ?? sourceDir
446
475
  const manifest = manifestDir === null ? readInstalledManifest(profile, name, explicitDir) : manifestAt(manifestDir)
package/src/routes.ts CHANGED
@@ -36,7 +36,7 @@ import { applyBundleOrder, mergeOrder, readBundleRules, readBundleStack, validat
36
36
  import { applyPreset, deletePreset, listPresets, previewPreset, savePreset } from './presets.ts'
37
37
  import { createProfileSnapshot, DEFAULT_MAX_SNAPSHOTS, deleteSnapshot, listSnapshots, restoreSnapshot } from './snapshot.ts'
38
38
  import { trialValidate } from './trial.ts'
39
- import { codeloadAllowBuildsKey, findCatalogEntryForLocal, findInstalledAlias, githubCommitOfTarget, githubTargetAtCommit, gitAllowBuildsKey, gitUpdateTarget, installTargetFor, isLocalSpec, NPM_NAME_RE, repoOfTarget, restoreBlockedByWorkspace, restoreTargetForLocal, workspaceProtocolDeps } from './sources.ts'
39
+ import { codeloadAllowBuildsKey, findCatalogEntryForLocal, findInstalledAlias, githubCommitOfTarget, githubTargetAtCommit, gitAllowBuildsKey, gitUpdateTarget, installTargetFor, isGenerationLink, isLocalSpec, NPM_NAME_RE, repoOfTarget, restoreBlockedByWorkspace, restoreTargetForLocal, workspaceProtocolDeps } from './sources.ts'
40
40
  import { failureDetail, groupConflictsByOwner, isStaleUpdate, parseIgnoredBuilds, parsePrepareNotAllowed, pnpmNeverStarted, RELEASE_AGE_OVERRIDE, retargetCollections, validateAddedPlugins, withHoistRecovery } from './install.ts'
41
41
  import { asChannel, CHANNELS, DIST_TAG, resolveChannel, type Channel } from './channels.ts'
42
42
  import {
@@ -1156,6 +1156,24 @@ export function mountMarketRoutes(
1156
1156
  try { return new URL(request.url ?? '', 'http://localhost').searchParams.get('force') === '1' } catch { return false }
1157
1157
  }
1158
1158
 
1159
+ /**
1160
+ * The npm package an install with no registry spec of its own is compared
1161
+ * against: a `file:` package matched to the catalog (#429), or a
1162
+ * generation the desktop host linked in (#497). Null for everything else
1163
+ * — a developer's own `link:` checkout is never compared online.
1164
+ */
1165
+ const onlineSourceOf = (
1166
+ plugins: Awaited<ReturnType<typeof loadRegistry>>['plugins'],
1167
+ name: string,
1168
+ spec: string,
1169
+ ): string | null => {
1170
+ if (!spec.toLowerCase().startsWith('file:') && !isGenerationLink(spec)) return null
1171
+ const evidence = readInstalledRepoEvidence(config.profile, name, spec, activeProfileDir)
1172
+ const entry = findCatalogEntryForLocal(plugins, name, evidence.identities, evidence.hints)
1173
+ const target = entry === null ? null : restoreTargetForLocal(entry, evidence.identities)
1174
+ return target !== null && NPM_NAME_RE.test(target) ? target : null
1175
+ }
1176
+
1159
1177
  const disposers = [
1160
1178
  host.webServer.register({
1161
1179
  kind: 'exact',
@@ -1218,7 +1236,20 @@ export function mountMarketRoutes(
1218
1236
  const force = forceCheckFrom(request)
1219
1237
  const channel = activeChannel()
1220
1238
  const channelFor = SELF_NAMES.has(name) ? new Map([[name, channel]]) : undefined
1221
- const update = (await checkUpdates(config.profile, force, activeProfileDir, channelFor))[name]
1239
+ // The same source lookup the market page makes, so a generation
1240
+ // (#497) or a catalog-matched local package answers here with the
1241
+ // release it can be compared against rather than with nothing.
1242
+ const spec = readInstalled(config.profile, activeProfileDir)[name]
1243
+ const onlineSourceFor = new Map<string, string>()
1244
+ if (spec !== undefined && (spec.toLowerCase().startsWith('file:') || isGenerationLink(spec))) {
1245
+ try {
1246
+ const source = onlineSourceOf((await loadRegistry()).plugins, name, spec)
1247
+ if (source !== null) onlineSourceFor.set(name, source)
1248
+ } catch (error) {
1249
+ logEvent('warn', 'updates', `package source lookup failed — ${error instanceof Error ? error.message : String(error)}`)
1250
+ }
1251
+ }
1252
+ const update = (await checkUpdates(config.profile, force, activeProfileDir, channelFor, onlineSourceFor))[name]
1222
1253
  if (update === undefined) {
1223
1254
  sendJson(response, 404, { schema: UPDATE_API_V1_SCHEMA, error: 'plugin is not installed' })
1224
1255
  return
@@ -2470,11 +2501,8 @@ export function mountMarketRoutes(
2470
2501
  for (const [name, spec] of Object.entries(installed)) {
2471
2502
  const migration = findGitToNpmMigration(registry.plugins, spec)
2472
2503
  if (migration !== null) sourceMigrationFor.set(name, migration)
2473
- if (!spec.toLowerCase().startsWith('file:')) continue
2474
- const evidence = readInstalledRepoEvidence(config.profile, name, spec, activeProfileDir)
2475
- const entry = findCatalogEntryForLocal(registry.plugins, name, evidence.identities, evidence.hints)
2476
- const target = entry === null ? null : restoreTargetForLocal(entry, evidence.identities)
2477
- if (target !== null && NPM_NAME_RE.test(target)) onlineSourceFor.set(name, target)
2504
+ const source = onlineSourceOf(registry.plugins, name, spec)
2505
+ if (source !== null) onlineSourceFor.set(name, source)
2478
2506
  }
2479
2507
  } catch (error) {
2480
2508
  logEvent('warn', 'updates', `package source lookup failed — ${error instanceof Error ? error.message : String(error)}`)
package/src/sources.ts CHANGED
@@ -463,6 +463,19 @@ export function gitUploadPackUrl(spec: string): string | null {
463
463
  return `${base}/info/refs?service=git-upload-pack`
464
464
  }
465
465
 
466
+ /**
467
+ * A `link:` that points into a generation the desktop host materialised
468
+ * (#497): `link:../.generations/live/<pkg>+<version>+<hash>/node_modules/<pkg>`.
469
+ * That is the host's production install, not a developer's checkout — it
470
+ * came from the registry and has releases to compare against. The host
471
+ * recognises its own installs by the `.generations/live/` segment, and no
472
+ * hand-written link ever lands under that directory, so the same test is
473
+ * enough here.
474
+ */
475
+ export function isGenerationLink(spec: string): boolean {
476
+ return /^link:/i.test(spec) && /(?:^|[\\/])\.generations[\\/]live[\\/]/i.test(spec)
477
+ }
478
+
466
479
  export { findCatalogEntryForLocal, resolveCatalogRestore } from './catalog-local-match.ts'
467
480
 
468
481
  /**
package/src/updates.ts CHANGED
@@ -9,10 +9,17 @@ import { resolveHeadCommit } from './accelerate.ts'
9
9
  import { marketFetch } from './net.ts'
10
10
  import { activeRegion } from './regions.ts'
11
11
  import { profileDir, readGitResolutionCommit, readInstalled, readInstalledVersion, readLockCommits } from './profile.ts'
12
- import { gitCommitOfTarget, gitUploadPackUrl, githubCommitOfTarget, githubRefOfTarget, isGitHostedSpec, repoOfTarget } from './sources.ts'
12
+ import { gitCommitOfTarget, gitUploadPackUrl, githubCommitOfTarget, githubRefOfTarget, isGenerationLink, isGitHostedSpec, repoOfTarget } from './sources.ts'
13
13
 
14
14
  export interface UpdateStatus {
15
- kind: 'github' | 'npm' | 'linked'
15
+ /**
16
+ * `generation` is a `link:` the desktop host wrote (#497): the host
17
+ * installs it and reconciles it at startup, so the market names a newer
18
+ * release under `latest` and never offers to apply it. For that kind
19
+ * `latest` is null when the installed build is current or nothing newer
20
+ * can be confirmed.
21
+ */
22
+ kind: 'github' | 'npm' | 'linked' | 'generation'
16
23
  version: string | null
17
24
  current: string | null
18
25
  latest: string | null
@@ -293,9 +300,10 @@ export async function checkUpdates(
293
300
  */
294
301
  channelFor: ReadonlyMap<string, Channel> = new Map(),
295
302
  /**
296
- * Curated npm sources for `file:` installs that were matched to the market
297
- * catalog. `link:` workspaces remain development sources and are never
298
- * opted into online updates.
303
+ * Curated npm sources for the installs that carry no registry spec of
304
+ * their own: `file:` packages matched to the market catalog (#429) and
305
+ * the generations the desktop host links in (#497). Any other `link:` is
306
+ * a development workspace and is never opted into online updates.
299
307
  */
300
308
  onlineSourceFor: ReadonlyMap<string, string> = new Map(),
301
309
  ): Promise<Record<string, UpdateStatus>> {
@@ -329,6 +337,28 @@ export async function checkUpdates(
329
337
  return
330
338
  }
331
339
  if (normalizedSpec.startsWith('link:')) {
340
+ if (isGenerationLink(spec)) {
341
+ // The desktop host's production install, not a checkout (#497): it
342
+ // came from the registry and has a release history to compare
343
+ // against. Reported, never offered — the host reconciles `live/`
344
+ // against its own desired.json at startup, so an update applied
345
+ // here would appear to work and silently revert on the next boot.
346
+ // Only a release that is actually newer is named; current and
347
+ // unknown both read as null, so a lagging `latest` tag (#64) can't
348
+ // advertise a downgrade either.
349
+ const onlineSource = onlineSourceFor.get(name)
350
+ const stable = onlineSource === undefined ? null : await fetchNpmLatest(onlineSource)
351
+ const channel = channelFor.get(name)
352
+ const newest = channel === undefined || onlineSource === undefined
353
+ ? stable
354
+ : await versionOnChannel(onlineSource, channel, stable)
355
+ result[name] = {
356
+ kind: 'generation', version, current: version,
357
+ latest: isUpgrade(version, newest) ? newest : null,
358
+ updateAvailable: false,
359
+ }
360
+ return
361
+ }
332
362
  result[name] = { kind: 'linked', version, current: null, latest: null, updateAvailable: false }
333
363
  return
334
364
  }