dshmarket 1.45.1 → 1.46.1

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';
@@ -433,14 +433,31 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup) {
433
433
  return { changed: [...changed], partial: changed.size > 0 };
434
434
  }
435
435
  /**
436
- * Apply one enable/disable request: persist the choice in state.json, then
437
- * drive the live composition. Covers every mount form — hot mounts and
438
- * client-only shims go through hotUnmount/hotMount, bundle-layer entries
439
- * through setEntryDisabled. Enabling a THEME goes through the caller's
440
- * activateTheme instead so the Themes tab's exclusivity stays intact.
436
+ * Apply one enable/disable request: drive the live composition, then
437
+ * persist the choice in state.json. Covers every mount form — hot mounts
438
+ * and client-only shims go through hotUnmount/hotMount, bundle-layer
439
+ * entries through setEntryDisabled. Enabling a THEME goes through the
440
+ * caller's activateTheme instead so the Themes tab's exclusivity stays
441
+ * intact.
442
+ *
443
+ * A FAILED ENABLE LEAVES EVERYTHING AS IT WAS (#575). The choice used to be
444
+ * recorded before the mount was attempted and persisted whatever happened,
445
+ * so enabling a plugin that crashes on import — deterministically, every
446
+ * time — wrote "enabled" into state.json anyway. The next boot tried the
447
+ * import again and died again; the reporter measured 24 restarts before
448
+ * restoring the disable by hand. The toggle route's patch-layer gate
449
+ * (@JINITAIMEI121 in #584) closed the same hole in cordis.patch.yml; this
450
+ * closes it in the market's own store, which is the ONLY durable state a
451
+ * client-only plugin has — that plugin kind has no bundle rows, so the
452
+ * patch gate never runs for it.
453
+ *
454
+ * A failed DISABLE still persists, and that asymmetry is deliberate: the
455
+ * user asked for OFF, and a failed unmount leaves the plugin live only for
456
+ * this session. There the durable disable is the contract, not an error.
441
457
  */
442
458
  async function setPluginEnabled(name, enabled) {
443
459
  const dir = activeProfileDir;
460
+ const wasDisabled = disabled.has(name);
444
461
  if (enabled)
445
462
  disabled.delete(name);
446
463
  else
@@ -474,6 +491,14 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup) {
474
491
  ok = true;
475
492
  }
476
493
  }
494
+ if (!ok && enabled) {
495
+ // Put the in-memory view back before persisting: it is the same object
496
+ // the route reports as `disabled`, so restoring it keeps the reply, the
497
+ // store and the patch layer telling one story.
498
+ if (wasDisabled)
499
+ disabled.add(name);
500
+ logEvent('warn', 'toggle', `${name}: enable failed; leaving it disabled rather than persisting a state that crashes at boot (#575)`);
501
+ }
477
502
  writeMarketState(dir, { disabled, groups, groupOrder });
478
503
  return { ok, reason };
479
504
  }
@@ -1048,6 +1073,20 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup) {
1048
1073
  return false;
1049
1074
  }
1050
1075
  };
1076
+ /**
1077
+ * The npm package an install with no registry spec of its own is compared
1078
+ * against: a `file:` package matched to the catalog (#429), or a
1079
+ * generation the desktop host linked in (#497). Null for everything else
1080
+ * — a developer's own `link:` checkout is never compared online.
1081
+ */
1082
+ const onlineSourceOf = (plugins, name, spec) => {
1083
+ if (!spec.toLowerCase().startsWith('file:') && !isGenerationLink(spec))
1084
+ return null;
1085
+ const evidence = readInstalledRepoEvidence(config.profile, name, spec, activeProfileDir);
1086
+ const entry = findCatalogEntryForLocal(plugins, name, evidence.identities, evidence.hints);
1087
+ const target = entry === null ? null : restoreTargetForLocal(entry, evidence.identities);
1088
+ return target !== null && NPM_NAME_RE.test(target) ? target : null;
1089
+ };
1051
1090
  const disposers = [
1052
1091
  host.webServer.register({
1053
1092
  kind: 'exact',
@@ -1109,7 +1148,22 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup) {
1109
1148
  const force = forceCheckFrom(request);
1110
1149
  const channel = activeChannel();
1111
1150
  const channelFor = SELF_NAMES.has(name) ? new Map([[name, channel]]) : undefined;
1112
- const update = (await checkUpdates(config.profile, force, activeProfileDir, channelFor))[name];
1151
+ // The same source lookup the market page makes, so a generation
1152
+ // (#497) or a catalog-matched local package answers here with the
1153
+ // release it can be compared against rather than with nothing.
1154
+ const spec = readInstalled(config.profile, activeProfileDir)[name];
1155
+ const onlineSourceFor = new Map();
1156
+ if (spec !== undefined && (spec.toLowerCase().startsWith('file:') || isGenerationLink(spec))) {
1157
+ try {
1158
+ const source = onlineSourceOf((await loadRegistry()).plugins, name, spec);
1159
+ if (source !== null)
1160
+ onlineSourceFor.set(name, source);
1161
+ }
1162
+ catch (error) {
1163
+ logEvent('warn', 'updates', `package source lookup failed — ${error instanceof Error ? error.message : String(error)}`);
1164
+ }
1165
+ }
1166
+ const update = (await checkUpdates(config.profile, force, activeProfileDir, channelFor, onlineSourceFor))[name];
1113
1167
  if (update === undefined) {
1114
1168
  sendJson(response, 404, { schema: UPDATE_API_V1_SCHEMA, error: 'plugin is not installed' });
1115
1169
  return;
@@ -1975,7 +2029,18 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup) {
1975
2029
  }
1976
2030
  }
1977
2031
  let patchWrite = null;
1978
- if (patchRows.length > 0) {
2032
+ // #575: a failed ENABLE must not flip the durable patch layer.
2033
+ // The hot-mount failure may be deterministic (a plugin that
2034
+ // crashes on import), and persisting "enabled" turns a transient
2035
+ // in-session error into a boot crash loop — the loader re-applies
2036
+ // the flipped rows on every start. The frontend already shows the
2037
+ // plugin as still disabled, and the next explicit enable retries
2038
+ // cleanly. Disables keep their unconditional write: a failed
2039
+ // unmount leaves the plugin live in-session, and the user asked
2040
+ // for it OFF — the durable disable is then the contract, not an
2041
+ // error.
2042
+ const patchGate = ok || !enabled;
2043
+ if (patchRows.length > 0 && patchGate) {
1979
2044
  for (const rowId of patchRows) {
1980
2045
  const result = enabled ? await enableRow(userPatchPath, rowId) : await disableRow(userPatchPath, rowId);
1981
2046
  if (!result.ok && patchWrite === null)
@@ -2373,13 +2438,9 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup) {
2373
2438
  const migration = findGitToNpmMigration(registry.plugins, spec);
2374
2439
  if (migration !== null)
2375
2440
  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);
2441
+ const source = onlineSourceOf(registry.plugins, name, spec);
2442
+ if (source !== null)
2443
+ onlineSourceFor.set(name, source);
2383
2444
  }
2384
2445
  }
2385
2446
  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.1",
4
+ "version": "1.46.1",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/types/index.d.ts",
@@ -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 {
@@ -487,14 +487,31 @@ export function mountMarketRoutes(
487
487
  }
488
488
 
489
489
  /**
490
- * Apply one enable/disable request: persist the choice in state.json, then
491
- * drive the live composition. Covers every mount form — hot mounts and
492
- * client-only shims go through hotUnmount/hotMount, bundle-layer entries
493
- * through setEntryDisabled. Enabling a THEME goes through the caller's
494
- * activateTheme instead so the Themes tab's exclusivity stays intact.
490
+ * Apply one enable/disable request: drive the live composition, then
491
+ * persist the choice in state.json. Covers every mount form — hot mounts
492
+ * and client-only shims go through hotUnmount/hotMount, bundle-layer
493
+ * entries through setEntryDisabled. Enabling a THEME goes through the
494
+ * caller's activateTheme instead so the Themes tab's exclusivity stays
495
+ * intact.
496
+ *
497
+ * A FAILED ENABLE LEAVES EVERYTHING AS IT WAS (#575). The choice used to be
498
+ * recorded before the mount was attempted and persisted whatever happened,
499
+ * so enabling a plugin that crashes on import — deterministically, every
500
+ * time — wrote "enabled" into state.json anyway. The next boot tried the
501
+ * import again and died again; the reporter measured 24 restarts before
502
+ * restoring the disable by hand. The toggle route's patch-layer gate
503
+ * (@JINITAIMEI121 in #584) closed the same hole in cordis.patch.yml; this
504
+ * closes it in the market's own store, which is the ONLY durable state a
505
+ * client-only plugin has — that plugin kind has no bundle rows, so the
506
+ * patch gate never runs for it.
507
+ *
508
+ * A failed DISABLE still persists, and that asymmetry is deliberate: the
509
+ * user asked for OFF, and a failed unmount leaves the plugin live only for
510
+ * this session. There the durable disable is the contract, not an error.
495
511
  */
496
512
  async function setPluginEnabled(name: string, enabled: boolean): Promise<{ ok: boolean; reason?: string }> {
497
513
  const dir = activeProfileDir
514
+ const wasDisabled = disabled.has(name)
498
515
  if (enabled) disabled.delete(name)
499
516
  else disabled.add(name)
500
517
  let ok: boolean
@@ -522,6 +539,13 @@ export function mountMarketRoutes(
522
539
  ok = true
523
540
  }
524
541
  }
542
+ if (!ok && enabled) {
543
+ // Put the in-memory view back before persisting: it is the same object
544
+ // the route reports as `disabled`, so restoring it keeps the reply, the
545
+ // store and the patch layer telling one story.
546
+ if (wasDisabled) disabled.add(name)
547
+ logEvent('warn', 'toggle', `${name}: enable failed; leaving it disabled rather than persisting a state that crashes at boot (#575)`)
548
+ }
525
549
  writeMarketState(dir, { disabled, groups, groupOrder })
526
550
  return { ok, reason }
527
551
  }
@@ -1156,6 +1180,24 @@ export function mountMarketRoutes(
1156
1180
  try { return new URL(request.url ?? '', 'http://localhost').searchParams.get('force') === '1' } catch { return false }
1157
1181
  }
1158
1182
 
1183
+ /**
1184
+ * The npm package an install with no registry spec of its own is compared
1185
+ * against: a `file:` package matched to the catalog (#429), or a
1186
+ * generation the desktop host linked in (#497). Null for everything else
1187
+ * — a developer's own `link:` checkout is never compared online.
1188
+ */
1189
+ const onlineSourceOf = (
1190
+ plugins: Awaited<ReturnType<typeof loadRegistry>>['plugins'],
1191
+ name: string,
1192
+ spec: string,
1193
+ ): string | null => {
1194
+ if (!spec.toLowerCase().startsWith('file:') && !isGenerationLink(spec)) return null
1195
+ const evidence = readInstalledRepoEvidence(config.profile, name, spec, activeProfileDir)
1196
+ const entry = findCatalogEntryForLocal(plugins, name, evidence.identities, evidence.hints)
1197
+ const target = entry === null ? null : restoreTargetForLocal(entry, evidence.identities)
1198
+ return target !== null && NPM_NAME_RE.test(target) ? target : null
1199
+ }
1200
+
1159
1201
  const disposers = [
1160
1202
  host.webServer.register({
1161
1203
  kind: 'exact',
@@ -1218,7 +1260,20 @@ export function mountMarketRoutes(
1218
1260
  const force = forceCheckFrom(request)
1219
1261
  const channel = activeChannel()
1220
1262
  const channelFor = SELF_NAMES.has(name) ? new Map([[name, channel]]) : undefined
1221
- const update = (await checkUpdates(config.profile, force, activeProfileDir, channelFor))[name]
1263
+ // The same source lookup the market page makes, so a generation
1264
+ // (#497) or a catalog-matched local package answers here with the
1265
+ // release it can be compared against rather than with nothing.
1266
+ const spec = readInstalled(config.profile, activeProfileDir)[name]
1267
+ const onlineSourceFor = new Map<string, string>()
1268
+ if (spec !== undefined && (spec.toLowerCase().startsWith('file:') || isGenerationLink(spec))) {
1269
+ try {
1270
+ const source = onlineSourceOf((await loadRegistry()).plugins, name, spec)
1271
+ if (source !== null) onlineSourceFor.set(name, source)
1272
+ } catch (error) {
1273
+ logEvent('warn', 'updates', `package source lookup failed — ${error instanceof Error ? error.message : String(error)}`)
1274
+ }
1275
+ }
1276
+ const update = (await checkUpdates(config.profile, force, activeProfileDir, channelFor, onlineSourceFor))[name]
1222
1277
  if (update === undefined) {
1223
1278
  sendJson(response, 404, { schema: UPDATE_API_V1_SCHEMA, error: 'plugin is not installed' })
1224
1279
  return
@@ -2078,7 +2133,18 @@ export function mountMarketRoutes(
2078
2133
  }
2079
2134
  }
2080
2135
  let patchWrite: { ok: boolean; reason: string | null } | null = null
2081
- if (patchRows.length > 0) {
2136
+ // #575: a failed ENABLE must not flip the durable patch layer.
2137
+ // The hot-mount failure may be deterministic (a plugin that
2138
+ // crashes on import), and persisting "enabled" turns a transient
2139
+ // in-session error into a boot crash loop — the loader re-applies
2140
+ // the flipped rows on every start. The frontend already shows the
2141
+ // plugin as still disabled, and the next explicit enable retries
2142
+ // cleanly. Disables keep their unconditional write: a failed
2143
+ // unmount leaves the plugin live in-session, and the user asked
2144
+ // for it OFF — the durable disable is then the contract, not an
2145
+ // error.
2146
+ const patchGate = ok || !enabled
2147
+ if (patchRows.length > 0 && patchGate) {
2082
2148
  for (const rowId of patchRows) {
2083
2149
  const result = enabled ? await enableRow(userPatchPath, rowId) : await disableRow(userPatchPath, rowId)
2084
2150
  if (!result.ok && patchWrite === null) patchWrite = result
@@ -2470,11 +2536,8 @@ export function mountMarketRoutes(
2470
2536
  for (const [name, spec] of Object.entries(installed)) {
2471
2537
  const migration = findGitToNpmMigration(registry.plugins, spec)
2472
2538
  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)
2539
+ const source = onlineSourceOf(registry.plugins, name, spec)
2540
+ if (source !== null) onlineSourceFor.set(name, source)
2478
2541
  }
2479
2542
  } catch (error) {
2480
2543
  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
  }