dshmarket 1.72.1 → 1.73.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/README.md CHANGED
@@ -57,7 +57,7 @@ its own dsh: it may be older than the one `npm` would give you (#139).
57
57
  ## What you get
58
58
 
59
59
  - **Browse & search** the full community catalog (4200+ plugins, growing daily) — category filters, star counts, top/new sorting, bilingual descriptions that follow your UI language
60
- - **Host-aware discovery** — cards show the DSH requirement declared by `engines.dsh` or lockstep `@deepseek-ai/dsh-*` peers; an opt-in filter hides only confirmed mismatches with the running host. Undeclared, malformed, unavailable, and GitHub-only entries remain visible rather than being guessed incompatible
60
+ - **Host-aware discovery** — cards show the DSH requirement declared by `engines.dsh` or lockstep `@deepseek-ai/dsh-*` peers; the compatibility filter hides only confirmed mismatches with the running host. GitHub-only entries read the linked package's manifest, including monorepo subdirectories; Release downloads read the selected release tag, including the current tag behind `latest/download`. Undeclared, malformed or unavailable requirements stay unknown and visible. Public manifest facts are cached; compatibility is evaluated against the running host
61
61
  - **Screenshots** — AppStore-style screenshots, auto-carousel when there's more than one, click to preview full-size: author-curated shots show right on the card (zero extra requests); plugins without curated shots fall back to automatic README extraction once you open the install dialog. Images load from GitHub hosting only
62
62
  - **Comments** — every card opens the plugin's discussion thread in place. It is the same thread its pages on [dshmarket.com](https://dshmarket.com) and the [catalog](https://awesome-dsh-plugin.com) show, so a plugin has one conversation rather than three. Backed by GitHub Discussions through giscus: it loads when you open it, needs a GitHub account only to post, and the note above it says plainly that opening it contacts giscus.app and GitHub. On local dsh web, reading stays embedded while a dedicated GitHub action opens the exact discussion in a new tab for sign-in and posting, so the cross-site return never carries or depends on the host session
63
63
  - **Favorites** — bookmark plugins and themes from Discover or the Themes tab; a dedicated Favorites tab lists them with search, sort, and install actions. Bookmarks persist in the profile's market state (`state.json`); entries that leave the catalog can be cleared in one click
package/README.zh.md CHANGED
@@ -50,7 +50,7 @@ profile,因此不会出现在桌面端。请通过应用内的 **设置 →
50
50
  ## 你会得到
51
51
 
52
52
  - **逛与搜**——完整社区目录(4200+ 插件,每天在涨),分类筛选、star 数、最热/最新排序,中英描述跟随界面语言
53
- - **按宿主发现**——卡片展示插件通过 `engines.dsh` 或同版本线 `@deepseek-ai/dsh-*` peer 声明的 DSH 要求;可选筛选只隐藏与当前宿主明确不匹配的插件。未声明、格式异常、暂时取不到清单及仅 GitHub 发布的条目仍保持可见,不猜成不兼容
53
+ - **按宿主发现**——卡片展示插件通过 `engines.dsh` 或同版本线 `@deepseek-ai/dsh-*` peer 声明的 DSH 要求;兼容筛选只隐藏与当前宿主明确不匹配的插件。仅 GitHub 发布的条目也会读取链接所指包的清单,支持子目录;Release 下载读取对应标签,`latest/download` 则读取当前发布标签。未声明、格式异常或暂时取不到清单时保持未知并继续显示。公开清单信息会缓存,兼容结论按当前宿主重新判断
54
54
  - **截图展示**——App Store 式截图,多图自动轮播,点开还能看大图;作者在 registry 里策展的截图列表卡片就直接显示(零额外请求),没有策展的插件则在打开安装弹窗时自动从 README 抽取;图片仅从 GitHub 图床加载
55
55
  - **评论**——每张卡片都能就地打开该插件的讨论。它和插件在 [dshmarket.com](https://dshmarket.com) 与[目录站](https://awesome-dsh-plugin.com)上的页面共用同一条讨论,一个插件只有一处对话,而不是三处。底层是 GitHub Discussions(经由 giscus):打开即加载,只有发表评论才需要 GitHub 账号;说明里也直说打开会连接 giscus.app 与 GitHub。本地 dsh web 继续内嵌评论供阅读,登录和发表则通过专用 GitHub 入口在新标签页打开当前插件的精确讨论,不让跨站回跳携带或依赖宿主会话
56
56
  - **收藏**——在「发现」或「主题」页点书签即可收藏插件/主题;「收藏」Tab 集中展示,支持搜索、排序与安装。书签写入 profile 的市场状态(`state.json`);已下架条目可一键清除
package/client/client.js CHANGED
@@ -2491,8 +2491,8 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
2491
2491
  * Catalog matching for locally linked / file: installs. Shared by the host
2492
2492
  * restore route and the Market client so both refuse the same wrong guesses.
2493
2493
  */
2494
- const REPO_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
2495
- function validSubpath(subpath) {
2494
+ const REPO_RE$1 = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
2495
+ function validSubpath$1(subpath) {
2496
2496
  if (!/^[A-Za-z0-9_./-]+$/.test(subpath)) return false;
2497
2497
  return !subpath.split("/").some((seg) => seg === "" || seg === "." || seg === "..");
2498
2498
  }
@@ -2503,12 +2503,12 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
2503
2503
  */
2504
2504
  function catalogMatchKeys(url) {
2505
2505
  const m = /^https:\/\/github\.com\/([^/]+\/[^/]+?)(?:\/tree\/[^/]+\/(.+?))?\/?$/.exec(url);
2506
- if (m === null || !REPO_RE.test(m[1])) return {
2506
+ if (m === null || !REPO_RE$1.test(m[1])) return {
2507
2507
  path: null,
2508
2508
  repo: null
2509
2509
  };
2510
2510
  const subpath = m[2] ?? null;
2511
- if (subpath !== null && !validSubpath(subpath)) return {
2511
+ if (subpath !== null && !validSubpath$1(subpath)) return {
2512
2512
  path: null,
2513
2513
  repo: null
2514
2514
  };
@@ -2652,6 +2652,34 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
2652
2652
  };
2653
2653
  }
2654
2654
  //#endregion
2655
+ //#region src/sources.ts
2656
+ /**
2657
+ * Registry-source knowledge: how a curated registry entry's URL maps to an
2658
+ * installable pnpm target. Pure string logic, no I/O.
2659
+ */
2660
+ const REPO_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
2661
+ function validSubpath(subpath) {
2662
+ if (!/^[A-Za-z0-9_./-]+$/.test(subpath)) return false;
2663
+ return !subpath.split("/").some((seg) => seg === "" || seg === "." || seg === "..");
2664
+ }
2665
+ /**
2666
+ * Parse a registry source url: a github repo, optionally with a
2667
+ * `/tree/<branch>/<subpath>` suffix (how the curated list links monorepo
2668
+ * subpackages, e.g. dsh-plugins#theme-gallery).
2669
+ */
2670
+ function parseSourceUrl(url) {
2671
+ const m = /^https:\/\/github\.com\/([^/]+\/[^/]+?)(?:\/tree\/[^/]+\/(.+?))?\/?$/.exec(url);
2672
+ if (m === null || !REPO_RE.test(m[1])) return null;
2673
+ const subpath = m[2] ?? null;
2674
+ if (subpath !== null) {
2675
+ if (!validSubpath(subpath)) return null;
2676
+ }
2677
+ return {
2678
+ repo: m[1],
2679
+ subpath
2680
+ };
2681
+ }
2682
+ //#endregion
2655
2683
  //#region src/client/market-data.ts
2656
2684
  /** One registry entry from /dsh-market/registry. */
2657
2685
  /**
@@ -2687,6 +2715,11 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
2687
2715
  }
2688
2716
  return categories;
2689
2717
  }
2718
+ /** Keep GitHub packages in one repository distinct, independently of their display names. */
2719
+ function hostCompatibilityKey(plugin) {
2720
+ if (typeof plugin.npm === "string" && plugin.npm !== "") return plugin.npm;
2721
+ return parseSourceUrl(plugin.url) === null ? null : plugin.url;
2722
+ }
2690
2723
  /**
2691
2724
  * Whether a queued operation still applies — `null` when it does.
2692
2725
  *
@@ -2931,7 +2964,8 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
2931
2964
  const categories = pluginCategories(plugin);
2932
2965
  if (options.category !== "all" && !categories.includes(options.category)) return [];
2933
2966
  if (options.sinceDays !== void 0 && !withinDays(plugin.added, options.sinceDays)) return [];
2934
- if (options.compatibleWithHost === true && plugin.npm != null && options.hostCompatibility?.[plugin.npm]?.status === "incompatible") return [];
2967
+ const compatibilityKey = hostCompatibilityKey(plugin);
2968
+ if (options.compatibleWithHost === true && compatibilityKey !== null && options.hostCompatibility?.[compatibilityKey]?.status === "incompatible") return [];
2935
2969
  const relevance = query === "" ? 0 : pluginRelevance(plugin, query, tokens, options.lang, options.categories);
2936
2970
  return relevance === 0 && query !== "" ? [] : [{
2937
2971
  plugin,
@@ -10515,7 +10549,10 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
10515
10549
  const response = await fetch(api("/dsh-market/discovery-compatibility"), {
10516
10550
  method: "POST",
10517
10551
  headers: { "content-type": "application/json" },
10518
- body: JSON.stringify({ packages: chunk })
10552
+ body: JSON.stringify({
10553
+ packages: chunk.filter((key) => !key.startsWith("https://")),
10554
+ ...chunk.some((key) => key.startsWith("https://")) ? { entries: chunk.filter((key) => key.startsWith("https://")) } : {}
10555
+ })
10519
10556
  });
10520
10557
  const body = await response.json();
10521
10558
  if (!response.ok || body.plugins === null || typeof body.plugins !== "object") throw new Error(`HTTP ${String(response.status)}`);
@@ -10934,8 +10971,8 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
10934
10971
  compatibleWithHost
10935
10972
  ], scrollToTop);
10936
10973
  const pagePlugins = plugins.slice((currentPage - 1) * pageSize, currentPage * pageSize);
10937
- const pageHostPackages = [...new Set([...pagePlugins, ...recentPlugins].flatMap((plugin) => typeof plugin.npm === "string" && plugin.npm !== "" ? [plugin.npm] : []))];
10938
- const allHostPackages = (0, react.useMemo)(() => [...new Set((data?.plugins ?? []).flatMap((plugin) => typeof plugin.npm === "string" && plugin.npm !== "" ? [plugin.npm] : []))], [data]);
10974
+ const pageHostPackages = [...new Set([...pagePlugins, ...recentPlugins].flatMap((plugin) => hostCompatibilityKey(plugin) === null ? [] : [hostCompatibilityKey(plugin)]))];
10975
+ const allHostPackages = (0, react.useMemo)(() => [...new Set((data?.plugins ?? []).flatMap((plugin) => hostCompatibilityKey(plugin) === null ? [] : [hostCompatibilityKey(plugin)]))], [data]);
10939
10976
  const pageHostPackagesKey = pageHostPackages.join("\0");
10940
10977
  const allHostPackagesKey = allHostPackages.join("\0");
10941
10978
  (0, react.useEffect)(() => {
@@ -11028,7 +11065,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
11028
11065
  const favoriteThemePagination = usePagination(favoriteThemes.length, favResetDeps, scrollToTop);
11029
11066
  const favoritePagePlugins = favoritePlugins.slice((favoritePluginPagination.currentPage - 1) * favoritePluginPagination.pageSize, favoritePluginPagination.currentPage * favoritePluginPagination.pageSize);
11030
11067
  const favoritePageThemes = favoriteThemes.slice((favoriteThemePagination.currentPage - 1) * favoriteThemePagination.pageSize, favoriteThemePagination.currentPage * favoriteThemePagination.pageSize);
11031
- const favoritePageHostPackages = [...new Set(favoritePagePlugins.flatMap((plugin) => typeof plugin.npm === "string" && plugin.npm !== "" ? [plugin.npm] : []))];
11068
+ const favoritePageHostPackages = [...new Set(favoritePagePlugins.flatMap((plugin) => hostCompatibilityKey(plugin) === null ? [] : [hostCompatibilityKey(plugin)]))];
11032
11069
  const favoritePageHostPackagesKey = favoritePageHostPackages.join("\0");
11033
11070
  (0, react.useEffect)(() => {
11034
11071
  if (tab === "favorites") loadHostCompatibility(favoritePageHostPackages);
@@ -13161,8 +13198,9 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
13161
13198
  const record = recordForUrl(records, p.url);
13162
13199
  const blocked = record !== null && (record.state === "input" || record.state === "failed");
13163
13200
  const queued = record !== null && record.state === "queued";
13164
- const compatibility = typeof p.npm === "string" ? hostCompatibility[p.npm] : void 0;
13165
- const hostRequirementLabel = compatibility?.requirement !== null && compatibility?.requirement !== void 0 ? t("hostRequirement").replace("{0}", compatibility.requirement) : typeof p.npm !== "string" ? t("hostRequirementUnavailable") : compatibility === void 0 ? t("hostRequirementLoading") : compatibility.basis === "undeclared" ? t("hostRequirementUndeclared") : t("hostRequirementUnavailable");
13201
+ const compatibilityKey = hostCompatibilityKey(p);
13202
+ const compatibility = compatibilityKey === null ? void 0 : hostCompatibility[compatibilityKey];
13203
+ const hostRequirementLabel = compatibility?.requirement !== null && compatibility?.requirement !== void 0 ? t("hostRequirement").replace("{0}", compatibility.requirement) : compatibilityKey === null ? t("hostRequirementUnavailable") : compatibility === void 0 ? t("hostRequirementLoading") : compatibility.basis === "undeclared" ? t("hostRequirementUndeclared") : t("hostRequirementUnavailable");
13166
13204
  const hostRequirementTitle = compatibility?.declarations.map((declaration) => declaration.kind === "engine" ? `engines.dsh: ${declaration.range}` : `${declaration.package ?? "peer"}: ${declaration.range}`).join("\n") || hostRequirementLabel;
13167
13205
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
13168
13206
  className: blocked ? `${Market_module_css_default.card} ${Market_module_css_default.cardBlocked}` : Market_module_css_default.card,
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Discovery-time DSH compatibility metadata.
3
3
  *
4
- * The public catalog does not carry npm manifests. Fetching every manifest
4
+ * The public catalog does not carry package manifests. Fetching every manifest
5
5
  * while the market opens would turn one catalog request into more than a
6
6
  * thousand registry requests, so this module supplies a bounded, on-demand
7
7
  * index. Successful public manifest facts are cached beside the market's
@@ -13,6 +13,7 @@ import { dirname } from 'node:path';
13
13
  import { compareSemver, isSemver, satisfiesRange } from './check.js';
14
14
  import { classifyPeer, crossesImplicitCeiling } from './compatibility.js';
15
15
  import { marketFetch } from './net.js';
16
+ import { parseSourceUrl } from './sources.js';
16
17
  const CACHE_SCHEMA = 'dsh-market/discovery-compatibility-cache/v1';
17
18
  const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000;
18
19
  const FAILURE_COOLDOWN_MS = 5 * 60 * 1000;
@@ -21,6 +22,44 @@ const FETCH_TIMEOUT_MS = 8_000;
21
22
  const DEFAULT_CONCURRENCY = 8;
22
23
  const MAX_CACHE_ENTRIES = 5_000;
23
24
  const MAX_RANGE_LENGTH = 256;
25
+ /** Read the linked package at its selected release, never the repository root by accident. */
26
+ async function githubManifest(source, fetcher) {
27
+ const parsed = parseSourceUrl(source.url);
28
+ if (parsed === null)
29
+ throw new Error('not a GitHub package source');
30
+ const json = async (url) => {
31
+ const response = await fetcher(url, {
32
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
33
+ headers: { accept: 'application/json', 'user-agent': 'dsh-market' },
34
+ });
35
+ if (!response.ok)
36
+ throw new Error(`HTTP ${String(response.status)}`);
37
+ return await response.json();
38
+ };
39
+ let ref = /\/tree\/([^/]+)\//.exec(source.url)?.[1] ?? 'HEAD';
40
+ if (source.tarball != null) {
41
+ const release = /^https:\/\/github\.com\/([^/]+\/[^/]+)\/releases\/(?:download\/([^/]+)|latest\/download)\/[^/?#]+\.(?:tgz|tar\.gz)$/.exec(source.tarball);
42
+ if (release === null || release[1].toLowerCase() !== parsed.repo.toLowerCase()) {
43
+ throw new Error('archive does not name this repository release');
44
+ }
45
+ if (release[2] !== undefined)
46
+ ref = decodeURIComponent(release[2]);
47
+ else {
48
+ const latest = record(await json(`https://api.github.com/repos/${parsed.repo}/releases/latest`));
49
+ if (typeof latest?.tag_name !== 'string')
50
+ throw new Error('release tag unavailable');
51
+ ref = latest.tag_name;
52
+ }
53
+ }
54
+ if (ref === '' || ref.length > 256 || ref.split('/').some(part => part === '' || part === '.' || part === '..')) {
55
+ throw new Error('invalid GitHub reference');
56
+ }
57
+ const file = parsed.subpath === null ? 'package.json' : `${parsed.subpath}/package.json`;
58
+ const manifest = await json(`https://raw.githubusercontent.com/${parsed.repo}/${encodeURIComponent(ref)}/${file}`);
59
+ if (record(manifest) === null)
60
+ throw new Error('package manifest unavailable');
61
+ return manifest;
62
+ }
24
63
  function range(value) {
25
64
  if (typeof value !== 'string')
26
65
  return null;
@@ -140,7 +179,7 @@ export function deriveHostCompatibility(facts, hostVersion, hostPackages) {
140
179
  return { status, basis: 'manifest', requirement, declarations };
141
180
  }
142
181
  /**
143
- * Durable, bounded lookup of npm `latest` manifests.
182
+ * Durable, bounded lookup of npm `latest` and catalog GitHub manifests.
144
183
  *
145
184
  * Failed requests are intentionally memory-only and short-lived: a mirror
146
185
  * outage must not become a day-long false "undeclared" result on disk.
@@ -212,7 +251,7 @@ export class DiscoveryManifestIndex {
212
251
  this.fetchWaiters.shift()?.();
213
252
  }
214
253
  }
215
- async fetchOne(name, registry, record = true) {
254
+ async fetchOne(name, registry, record = true, readManifest) {
216
255
  this.load();
217
256
  const now = this.now();
218
257
  const cached = this.entries.get(name);
@@ -230,13 +269,16 @@ export class DiscoveryManifestIndex {
230
269
  return await pending;
231
270
  const request = (async () => {
232
271
  try {
233
- const response = await this.withFetchPermit(async () => await this.fetcher(`${registry}/${encodeURIComponent(name)}/latest`, {
234
- signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
235
- headers: { accept: 'application/json', 'user-agent': 'dsh-market' },
272
+ const value = await this.withFetchPermit(readManifest ?? (async () => {
273
+ const response = await this.fetcher(`${registry}/${encodeURIComponent(name)}/latest`, {
274
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
275
+ headers: { accept: 'application/json', 'user-agent': 'dsh-market' },
276
+ });
277
+ if (!response.ok)
278
+ throw new Error(`HTTP ${String(response.status)}`);
279
+ return await response.json();
236
280
  }));
237
- if (!response.ok)
238
- throw new Error(`HTTP ${String(response.status)}`);
239
- const facts = manifestFacts(await response.json());
281
+ const facts = manifestFacts(value);
240
282
  if (record) {
241
283
  this.entries.set(name, { checkedAt: this.now(), facts });
242
284
  this.dirty = true;
@@ -324,6 +366,25 @@ export class DiscoveryManifestIndex {
324
366
  }
325
367
  return result;
326
368
  }
369
+ /** Catalog-approved GitHub sources, cached by both package path and selected archive. */
370
+ async lookupGitHub(sources) {
371
+ const unique = [...new Map(sources.map(source => [source.url, source])).values()];
372
+ const result = {};
373
+ let next = 0;
374
+ const worker = async () => {
375
+ while (next < unique.length) {
376
+ const source = unique[next++];
377
+ const key = `github:${source.url}\u0000${source.tarball ?? ''}`;
378
+ result[source.url] = await this.fetchOne(key, '', true, () => githubManifest(source, this.fetcher));
379
+ }
380
+ };
381
+ await Promise.all(Array.from({ length: Math.min(this.concurrency, unique.length) }, worker));
382
+ if (this.dirty) {
383
+ this.persist();
384
+ this.dirty = false;
385
+ }
386
+ return result;
387
+ }
327
388
  }
328
389
  /**
329
390
  * The newest release of `npmName` whose own declarations this host satisfies.
package/lib/profile.js CHANGED
@@ -924,19 +924,64 @@ export function bundlePatchInsertedIds(dir) {
924
924
  * conventional root cordis.patch.yml a package may ship instead.
925
925
  */
926
926
  export function bundleOptOutRowIds(dir) {
927
+ return declaredPatchRowIds(dir, 'disabledIds');
928
+ }
929
+ /**
930
+ * Row ids a package's own patch sources declare for one role — the loop the
931
+ * two readers above and below share.
932
+ *
933
+ * They read the same two sources: every declared `dsh.bundle.patch` (one file,
934
+ * or an ordered list of them, #792) and the conventional root
935
+ * `cordis.patch.yml` a package may ship instead. Keeping that loop in one place
936
+ * is the point — `bundlePatchInsertedIds` above is declared-files-only, and a
937
+ * copy of this scan that forgot the root file is exactly how the two sides of
938
+ * one question drift apart.
939
+ *
940
+ * A source that cannot be read contributes nothing rather than throwing: this
941
+ * answers "what does the package say it ships", and a file that is not there
942
+ * says nothing. That is the tolerant half of the family by design — the strict
943
+ * one (`readBundlePatchRows`) refuses the whole declaration, because the host
944
+ * loads none of a patch it cannot read completely.
945
+ */
946
+ function declaredPatchRowIds(dir, role) {
927
947
  const ids = new Set();
928
948
  const read = (file) => {
929
949
  try {
930
- for (const id of parsePatchRows(readFileSync(file, 'utf8')).disabledIds)
950
+ for (const id of parsePatchRows(readFileSync(file, 'utf8'))[role])
931
951
  ids.add(id);
932
952
  }
933
- catch { /* no such patch — nothing shipped off through it */ }
953
+ catch { /* no such patch — nothing declared through it */ }
934
954
  };
935
955
  for (const file of declaredBundlePatchFiles(dir))
936
956
  read(file);
937
957
  read(join(dir, 'cordis.patch.yml'));
938
958
  return [...ids];
939
959
  }
960
+ /**
961
+ * Row ids the installed copy of a package DECLARES it inserts (#1012).
962
+ *
963
+ * The evidence half of `rowIdsForPackage`: the same two patch sources (the
964
+ * declared `dsh.bundle.patch`, one file or an ordered list, and the
965
+ * conventional root `cordis.patch.yml`), insert rows only, and — unlike that
966
+ * reader — no live loader rows.
967
+ *
968
+ * Live rows are exactly what this cannot use. A rename transaction asks the
969
+ * question twice: of the old package just before it is removed (whose rows are
970
+ * still live, but whose patch files are about to be the only copy left) and of
971
+ * the new package right after its first install (which has never been booted,
972
+ * so the loader has never heard of it). Only the package's own declaration can
973
+ * answer for both.
974
+ *
975
+ * That is the same question, and the same parser, as `renamePredecessor` asks
976
+ * on the install path — an install never boots the package either, so it
977
+ * compares declarations too and the two refusals agree on what "one plugin"
978
+ * means (#1012). Where they differ is the root `cordis.patch.yml`: see
979
+ * `declaredPatchRowIds` for why this reader covers it and that gate's
980
+ * declared-only reader does not.
981
+ */
982
+ export function shippedInsertedRowIds(dir) {
983
+ return declaredPatchRowIds(dir, 'insertedIds');
984
+ }
940
985
  /**
941
986
  * Rows of one patch file. Exported because a package may ship its patch at
942
987
  * the conventional path INSTEAD of declaring `dsh.bundle.patch`, and the
package/lib/routes.js CHANGED
@@ -24,7 +24,7 @@ import { diagnosePackageManifests } from './diagnostics.js';
24
24
  import { BOOT_ID, cancelActive, probePnpm, progress, provisionPnpm, runDshPlugin, setBuildEnvSource, TARGET_RE, } from './dsh-cli.js';
25
25
  import { packageOfEntryName } from './entry-identity.js';
26
26
  import { PendingRowJournal, repairOrphanedRows } from './pending-rows.js';
27
- import { addProfileBundle, bundleOptOutRowIds, bundlePatchInsertedIds, bundlesDroppedFromProfile, declareProfileDependency, dropFromManifest, generationManagedNames, hasLoadableEntry, holdsNativeAddon, INBOX_BUNDLES, isDshProfileName, profileDir, readDependencyOwners, readGitResolutionCommit, readInstalled, readInstalledManifest, readInstalledPackageName, readInstalledRepoEvidence, readInstalledVersion, readLockCommits, readProfileBundles, readProfileManifestSnapshot, removeProfileBundle, parsePatchRows, restoreProfileManifest, setAllowBuilds } from './profile.js';
27
+ import { addProfileBundle, bundleOptOutRowIds, bundlePatchInsertedIds, bundlesDroppedFromProfile, declareProfileDependency, dropFromManifest, generationManagedNames, hasLoadableEntry, holdsNativeAddon, INBOX_BUNDLES, isDshProfileName, profileDir, readDependencyOwners, readGitResolutionCommit, readInstalled, readInstalledManifest, readInstalledPackageName, readInstalledRepoEvidence, readInstalledVersion, readLockCommits, readProfileBundles, readProfileManifestSnapshot, removeProfileBundle, parsePatchRows, restoreProfileManifest, setAllowBuilds, shippedInsertedRowIds } from './profile.js';
28
28
  import { assessProfile, classifyPeer, introducedDuplicateNames, introducedRisks } from './compatibility.js';
29
29
  import { runningAgentIds } from './agents.js';
30
30
  import { analyzeProfile, corePackageNames } from './check.js';
@@ -33,7 +33,7 @@ import { applyPreset, deletePreset, listPresets, previewPreset, savePreset } fro
33
33
  import { createProfileSnapshot, DEFAULT_MAX_SNAPSHOTS, deleteSnapshot, listSnapshots, restoreSnapshot } from './snapshot.js';
34
34
  import { readHostRecoveryView, restoreHostRecoveryBackup } from './host-recovery.js';
35
35
  import { trialValidate } from './trial.js';
36
- import { catalogRepoKey, findCatalogEntryForLocal, findInstalledAlias, gitCommitOfTarget, githubCommitOfTarget, githubTargetAtCommit, gitAllowBuildsKey, gitRefOfTarget, gitTargetAtCommit, gitUpdateTarget, hostedRepoKey, lookupRepoFromUrl, pinnedAllowBuildsKeys, installTargetFor, isGenerationLink, isLocalSpec, NPM_NAME_RE, repoOfTarget, restoreBlockedByWorkspace, restoreTargetForLocal, sourceFallbackFor, workspaceProtocolDeps } from './sources.js';
36
+ import { catalogRepoKey, findCatalogEntryForLocal, findInstalledAlias, gitCommitOfTarget, githubCommitOfTarget, githubTargetAtCommit, gitAllowBuildsKey, gitRefOfTarget, gitTargetAtCommit, gitUpdateTarget, hostedRepoKey, lookupRepoFromUrl, pinnedAllowBuildsKeys, installTargetFor, isGenerationLink, isLocalSpec, NPM_NAME_RE, parseSourceUrl, repoOfTarget, restoreBlockedByWorkspace, restoreTargetForLocal, sourceFallbackFor, workspaceProtocolDeps } from './sources.js';
37
37
  import { failureDetail, groupConflictsByOwner, installApplied, isHostPortCollision, isStaleUpdate, parseIgnoredBuildEntries, parseIgnoredBuilds, parsePrepareKey, parsePrepareNotAllowed, pnpmBlockedByOpenFiles, pnpmNeverStarted, pnpmRefusedYoungRelease, RELEASE_AGE_OVERRIDE, removeDanglingHostBridge, retargetCollections, updateApplied, validateAddedPlugins, withHoistRecovery } from './install.js';
38
38
  import { classifyPnpmFailure } from './pnpm-compat.js';
39
39
  import { asChannel, CHANNELS, DIST_TAG, resolveChannel } from './channels.js';
@@ -407,6 +407,7 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
407
407
  const hostAnchorOption = config.dshInstallDir === undefined ? {} : { dshInstallDir: config.dshInstallDir };
408
408
  const persistentLogFile = join(activeProfileDir, '.dsh-market', 'log.ndjson');
409
409
  const discoveryManifests = new DiscoveryManifestIndex(join(activeProfileDir, '.dsh-market', 'discovery-compatibility-v1.json'));
410
+ const githubDiscoveryManifests = new DiscoveryManifestIndex(join(activeProfileDir, '.dsh-market', 'discovery-github-compatibility-v1.json'));
410
411
  configurePersistentLog(persistentLogFile);
411
412
  let agentGuardUnavailableLogged = false;
412
413
  /** Running-agent ids for the mutation gate; logs once when the host exposes no agents service. */
@@ -2536,13 +2537,29 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
2536
2537
  sendJson(response, 400, { error: 'packages must be an array of at most 64 npm package names' });
2537
2538
  return;
2538
2539
  }
2540
+ const requestedEntries = body.entries ?? [];
2541
+ if (!Array.isArray(requestedEntries) || requestedEntries.length + requested.length > 64
2542
+ || !requestedEntries.every(url => typeof url === 'string' && parseSourceUrl(url) !== null)) {
2543
+ sendJson(response, 400, { error: 'entries must be GitHub catalog URLs; at most 64 packages and entries per batch' });
2544
+ return;
2545
+ }
2539
2546
  const packages = [...new Set(requested)];
2540
2547
  try {
2548
+ const urls = new Set(requestedEntries);
2549
+ const entries = urls.size === 0 ? [] : (await loadRegistry()).plugins.filter(entry => urls.has(entry.url) && (entry.npm == null || entry.npm === ''));
2550
+ if (new Set(entries.map(entry => entry.url)).size !== urls.size) {
2551
+ sendJson(response, 400, { error: 'entries must name GitHub-only plugins in the current catalog' });
2552
+ return;
2553
+ }
2541
2554
  const host = dshHostInfo();
2542
2555
  const hostVersion = host?.version ?? null;
2543
2556
  const hostPackages = corePackageNames(host?.directory ?? null);
2544
- const facts = await discoveryManifests.lookup(packages, routesFor(region).npmRegistry);
2545
- const plugins = Object.fromEntries(packages.map(name => [
2557
+ const [npmFacts, githubFacts] = await Promise.all([
2558
+ discoveryManifests.lookup(packages, routesFor(region).npmRegistry),
2559
+ githubDiscoveryManifests.lookupGitHub(entries),
2560
+ ]);
2561
+ const facts = { ...npmFacts, ...githubFacts };
2562
+ const plugins = Object.fromEntries([...packages, ...urls].map(name => [
2546
2563
  name,
2547
2564
  deriveHostCompatibility(facts[name] ?? null, hostVersion, hostPackages),
2548
2565
  ]));
@@ -4035,6 +4052,12 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
4035
4052
  const wasLive = verifyActivation(config.profile, name, liveNames(), activeProfileDir, disabled.has(name)).state === 'live'
4036
4053
  && hasHostHalf(config.profile, name, activeProfileDir);
4037
4054
  const oldRows = rowIdsForPackage(host, activeProfileDir, name);
4055
+ // Identity evidence for a rename (#1012), read BEFORE the removal:
4056
+ // once `remove` runs, the installed old package — and with it the
4057
+ // only record of what it registers — is gone.
4058
+ const oldInsertIds = kind === 'npm-rename'
4059
+ ? shippedInsertedRowIds(join(activeProfileDir, 'node_modules', name))
4060
+ : [];
4038
4061
  const patchFlags = packagePatchFlags(host, activeProfileDir, [name], readUserPatchState(userPatchPath));
4039
4062
  const wasDisabled = disabled.has(name);
4040
4063
  const manifestBefore = manifestCapture.snapshot;
@@ -4060,13 +4083,21 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
4060
4083
  ? { ok: true, detail: null }
4061
4084
  : { ok: false, detail: `${restoredEn} was restored without a loadable entry` };
4062
4085
  };
4063
- const failWithRollback = async (detail, extra = {}) => {
4086
+ // `noteZh`/`noteEn` carry the "what you can do next" sentence, and
4087
+ // only the branch that actually restored the old install may say
4088
+ // it: after a failed rollback there is nothing to reassure the
4089
+ // user with, so the note is dropped rather than contradicting the
4090
+ // "could not be verified" fallout.
4091
+ const failWithRollback = async (detail, extra = {}, status = 500, noteZh = '', noteEn = '') => {
4064
4092
  const rollback = await rollbackMigration();
4065
4093
  logEvent(rollback.ok ? 'warn' : 'error', 'source-migration', `${name}: migration failed — ${detail}; ${rollback.ok ? `${restoredEn} restored` : `rollback failed: ${rollback.detail ?? 'unknown'}`}`);
4066
- sendJson(response, 500, {
4094
+ // A 409 whose rollback failed is not a conflict the user can walk
4095
+ // away from — the install itself is now unverified — so the
4096
+ // status follows the rollback, not the reason we got here.
4097
+ sendJson(response, rollback.ok ? status : 500, {
4067
4098
  ok: false,
4068
4099
  error: rollback.ok
4069
- ? `${detail};已恢复${restoredZh}。 / ${detail}; ${restoredEn} was restored.`
4100
+ ? `${detail};已恢复${restoredZh}。${noteZh === '' ? '' : ` ${noteZh}`} / ${detail}; ${restoredEn} was restored.${noteEn === '' ? '' : ` ${noteEn}`}`
4070
4101
  : `${detail};且${restoredZh}未能验证恢复(${rollback.detail ?? 'unknown'})。 / ${detail}; restoration of ${restoredEn} could not be verified (${rollback.detail ?? 'unknown'}).`,
4071
4102
  rollback: rollback.ok,
4072
4103
  ...extra,
@@ -4090,6 +4121,37 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
4090
4121
  await failWithRollback('npm 目标安装完成后未形成可加载且唯一的依赖。 / The npm target did not produce one loadable replacement dependency.');
4091
4122
  return;
4092
4123
  }
4124
+ // The catalog's `previousNpm` is a CLAIM that the two package names
4125
+ // are one plugin, and the transaction above already acted on it by
4126
+ // replacing what the user had. Ask the packages themselves: the
4127
+ // rows each side declares it inserts are registry evidence, and two
4128
+ // packages that register disjoint rows are not the same plugin —
4129
+ // whatever the catalog says. Refuse and put the old one back rather
4130
+ // than leave a stranger's rows running under the old plugin's name
4131
+ // (#1012). Same question, same parser as the install-path gate
4132
+ // (`renamePredecessor`), which compares the installed package's
4133
+ // declared rows against the incoming registry `cordis.patch.yml`;
4134
+ // here both packages are already on disk, so both sides are read
4135
+ // the same way — including the conventional root patch file, which
4136
+ // that gate only knows on the incoming side.
4137
+ //
4138
+ // Both sides must SHOW evidence: a package with no patch file
4139
+ // declares nothing, and silence is not a contradiction.
4140
+ if (kind === 'npm-rename') {
4141
+ const newInsertIds = shippedInsertedRowIds(join(activeProfileDir, 'node_modules', targetName));
4142
+ if (oldInsertIds.length > 0 && newInsertIds.length > 0 && !sameLoaderRows(oldInsertIds, newInsertIds)) {
4143
+ // Both sides are third-party YAML: clip each id so a hostile
4144
+ // patch cannot turn this refusal into a megabyte response.
4145
+ const rows = (ids) => {
4146
+ const clip = (id) => (id.length <= 48 ? id : `${id.slice(0, 48)}…`);
4147
+ return ids.length <= 3
4148
+ ? ids.map(clip).join(', ')
4149
+ : `${ids.slice(0, 3).map(clip).join(', ')}, +${ids.length - 3}`;
4150
+ };
4151
+ await failWithRollback(`目录声明 ${name} 已改名为 ${targetName},但两边的注册行不同(${name}: ${rows(oldInsertIds)};${targetName}: ${rows(newInsertIds)}),说明它们不是同一个插件。 / The catalog declares ${name} was renamed to ${targetName}, but the two packages register different plugin rows (${name}: ${rows(oldInsertIds)}; ${targetName}: ${rows(newInsertIds)}), so they are not the same plugin.`, {}, 409, `原来的版本仍然可用;如果目录写错了,请报告这一条。`, `The original install is still usable; if the catalog is wrong, report that entry.`);
4152
+ return;
4153
+ }
4154
+ }
4093
4155
  const stack = readBundleStack(activeProfileDir);
4094
4156
  const trial = trialValidate(activeProfileDir, stack.community, hostAnchorOption);
4095
4157
  if (!trial.ok) {
@@ -5357,6 +5419,29 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
5357
5419
  logEvent('warn', 'update-bundles-restored', `${name}: the desktop app removed its bundle row before refusing the update (bundle-in-use) — restored it so the plugin still loads on the next boot (#935)`);
5358
5420
  }
5359
5421
  }
5422
+ // A FAILED update can also drop rows it never intended to touch.
5423
+ // The rollback path ("restoration of the previous build could not
5424
+ // be verified") rewrites the manifest through pnpm and the plugin
5425
+ // command, and neither carries the bundle rows it did not own: a
5426
+ // bystander keeps its dependency and its node_modules but loses
5427
+ // its `dsh.profile.bundles` row, so it silently stops loading on
5428
+ // the next boot while this operation reported a failure about
5429
+ // something else entirely.
5430
+ //
5431
+ // Restoring is sound exactly because the operation FAILED: nobody
5432
+ // expressed a deliberate removal, and the package is still on
5433
+ // disk — putting the row back restores what the user had before
5434
+ // the operation. A successful update keeps the read-only stance
5435
+ // (#720/#696), where a missing row may be a deliberate switch-off.
5436
+ if (!ok && !cancelled) {
5437
+ const lostRows = bundlesDroppedFromProfile(manifestBefore, config.profile, activeProfileDir, new Set(removedDeclaration === null ? [] : [removedDeclaration.name])).filter(bundle => bundle !== name
5438
+ && existsSync(join(activeProfileDir, 'node_modules', bundle, 'package.json')));
5439
+ for (const bundle of lostRows) {
5440
+ if (addProfileBundle(activeProfileDir, bundle)) {
5441
+ logEvent('warn', 'update-bundles-restored', `${bundle}: lost its dsh.profile.bundles row while ${name}'s update failed — restored it so the plugin still loads on the next boot (#720)`);
5442
+ }
5443
+ }
5444
+ }
5360
5445
  logEvent(ok || cancelled ? 'info' : 'error', 'update', `${name} -> ${target} exit=${String(result.exitCode)}${result.timedOut ? ' TIMEOUT' : ''}${cancelled ? ' CANCELLED' : ''}${stale ? ` STALE(${staleReason ?? 'unknown'})` : ''}${ok || cancelled ? '' : ` err=${failureDetail(result)}`}`);
5361
5446
  // A user-cancelled run is a quiet outcome, not an error.
5362
5447
  sendJson(response, ok || cancelled ? 200 : result.busy === true ? 409 : 502, {
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Discovery-time DSH compatibility metadata.
3
3
  *
4
- * The public catalog does not carry npm manifests. Fetching every manifest
4
+ * The public catalog does not carry package manifests. Fetching every manifest
5
5
  * while the market opens would turn one catalog request into more than a
6
6
  * thousand registry requests, so this module supplies a bounded, on-demand
7
7
  * index. Successful public manifest facts are cached beside the market's
@@ -32,6 +32,10 @@ type FetchLike = (url: string, init?: {
32
32
  signal?: AbortSignal;
33
33
  headers?: Record<string, string>;
34
34
  }) => Promise<Response>;
35
+ export interface GitHubManifestSource {
36
+ url: string;
37
+ tarball?: string | null;
38
+ }
35
39
  /** Keep only the small public subset of an npm manifest needed by discovery. */
36
40
  export declare function manifestFacts(value: unknown): NpmManifestFacts;
37
41
  /**
@@ -43,7 +47,7 @@ export declare function manifestFacts(value: unknown): NpmManifestFacts;
43
47
  */
44
48
  export declare function deriveHostCompatibility(facts: NpmManifestFacts | null, hostVersion: string | null, hostPackages: ReadonlySet<string>): HostCompatibility;
45
49
  /**
46
- * Durable, bounded lookup of npm `latest` manifests.
50
+ * Durable, bounded lookup of npm `latest` and catalog GitHub manifests.
47
51
  *
48
52
  * Failed requests are intentionally memory-only and short-lived: a mirror
49
53
  * outage must not become a day-long false "undeclared" result on disk.
@@ -107,6 +111,8 @@ export declare class DiscoveryManifestIndex {
107
111
  lookup(names: readonly string[], registry: string, options?: {
108
112
  record?: boolean;
109
113
  }): Promise<Record<string, NpmManifestFacts | null>>;
114
+ /** Catalog-approved GitHub sources, cached by both package path and selected archive. */
115
+ lookupGitHub(sources: readonly GitHubManifestSource[]): Promise<Record<string, NpmManifestFacts | null>>;
110
116
  }
111
117
  /**
112
118
  * The newest release of `npmName` whose own declarations this host satisfies.
@@ -324,6 +324,29 @@ export declare function bundlePatchInsertedIds(dir: string): string[];
324
324
  * conventional root cordis.patch.yml a package may ship instead.
325
325
  */
326
326
  export declare function bundleOptOutRowIds(dir: string): string[];
327
+ /**
328
+ * Row ids the installed copy of a package DECLARES it inserts (#1012).
329
+ *
330
+ * The evidence half of `rowIdsForPackage`: the same two patch sources (the
331
+ * declared `dsh.bundle.patch`, one file or an ordered list, and the
332
+ * conventional root `cordis.patch.yml`), insert rows only, and — unlike that
333
+ * reader — no live loader rows.
334
+ *
335
+ * Live rows are exactly what this cannot use. A rename transaction asks the
336
+ * question twice: of the old package just before it is removed (whose rows are
337
+ * still live, but whose patch files are about to be the only copy left) and of
338
+ * the new package right after its first install (which has never been booted,
339
+ * so the loader has never heard of it). Only the package's own declaration can
340
+ * answer for both.
341
+ *
342
+ * That is the same question, and the same parser, as `renamePredecessor` asks
343
+ * on the install path — an install never boots the package either, so it
344
+ * compares declarations too and the two refusals agree on what "one plugin"
345
+ * means (#1012). Where they differ is the root `cordis.patch.yml`: see
346
+ * `declaredPatchRowIds` for why this reader covers it and that gate's
347
+ * declared-only reader does not.
348
+ */
349
+ export declare function shippedInsertedRowIds(dir: string): string[];
327
350
  /**
328
351
  * Rows of one patch file. Exported because a package may ship its patch at
329
352
  * the conventional path INSTEAD of declaring `dsh.bundle.patch`, and the
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.72.1",
4
+ "version": "1.73.1",
5
5
  "icon": "assets/logo.svg",
6
6
  "type": "module",
7
7
  "main": "lib/index.js",