dshmarket 1.64.0 → 1.65.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -31,15 +31,17 @@ its own dsh: it may be older than the one `npm` would give you (#139).
31
31
 
32
32
  ## What you get
33
33
 
34
- - **Browse & search** the full community catalog (2300+ plugins, growing daily) — category filters, star counts, top/new sorting, bilingual descriptions that follow your UI language
34
+ - **Browse & search** the full community catalog (4200+ plugins, growing daily) — category filters, star counts, top/new sorting, bilingual descriptions that follow your UI language
35
35
  - **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
36
36
  - **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
37
37
  - **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
38
38
  - **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
39
+ - **Groups** — organise installed plugins into named groups (rename, delete, and per-group search); a plugin lives in one group and ungrouped is the default. Purely organisational: the panel says so, and nothing about enable state changes. The grouping itself is local state in the profile's `state.json`
40
+ - **Notes** — write your own one-line description for any installed plugin and it replaces the author's on that row, so a shelf of forty plugins answers "why did I install this" in your words. Stored locally beside groups and favorites, never sent anywhere
39
41
  - **Themes** — a dedicated tab for community themes and skins: install → active immediately, switch with one click (themes are mutually exclusive, your choice survives restarts), uninstall to revert
40
42
  - **One-click install** — confirm the source, watch live progress; most plugins go live after a page refresh, no restart
41
43
  - **Backup & restore** — export your profile's plugin list and configuration as readable JSON, import it on another machine, store it on WebDAV with daily auto-backup, or sync through a private GitHub Gist; restores **merge** (plugins installed after the backup are kept), validate before writing, and roll back on failure
42
- - **Updates** — per-plugin update checks (npm version or pinned commit vs HEAD), one-click update, or update everything at once; the market updates itself the same way
44
+ - **Updates** — per-plugin update checks (npm version or pinned commit vs HEAD), one-click update, or update everything at once; the market updates itself the same way. Each row with an update pending carries a **What changed** link — the release notes, or the commits when the version cannot be aligned to one. A notice you are not acting on can be ignored for the rest of the boot instead of being dismissed again on every reload — scoped to the running host, so a restart brings the reminder back and ignoring it is never the same as turning it off
43
45
  - **Resilient GitHub routes** — in the China download region, Git refs, README content, and avatars each keep their own fallback order. The market remembers the last working route, switches only after transport/HTTP/payload validation fails, and rejects proxy error pages disguised as HTTP 200. If every built-in route fails, **Settings → Plugins → Plugin configuration → GitHub acceleration** accepts one persistent custom HTTPS prefix; `DSHM_GITHUB_PROXY` remains the operator-owned override
44
46
  - **Public update API** — plugin-owned settings pages can use the versioned, capability-gated [update API v1](UPDATE-API-V1.md) (beta) instead of copying package-manager logic or depending on private Market UI responses
45
47
  - **Uninstall** — two-step confirm; plugins installed this session are removed live
@@ -49,7 +51,7 @@ its own dsh: it may be older than the one `npm` would give you (#139).
49
51
  - **Zero jargon** — if a component is missing (pnpm), the market detects it and offers a one-click automatic setup
50
52
  - **Log export** — one click produces a sanitized plain-text log for bug reports (home paths and credential shapes are masked; nothing is ever sent anywhere). The market's version sits next to the page heading, so a screenshot of a problem already carries it
51
53
  - **Settings card** — on dsh 0.1.0-rc.7 and newer the market manages *itself* from **Settings → Plugins → Plugin configuration**, next to every other plugin: see the running version, pick a **release channel** (stable, or beta to try builds still being verified — the market only, never your other plugins; a third *dev* channel appears once developer mode is switched on, and carries builds published straight off a branch), update, or remove the market — with an opt-in cleanup that also drops the disable rows it wrote, so plugins it switched off start running again rather than staying off with no UI left to switch them back on
52
- - **Diagnostics** — the plugin load order and conflict surface, one page: bundle stack with official/community badges, duplicate loader entries, dependency version mismatches, multi-version core packages, overrides and invalid config entries. Plain-language terms, problem blocks highlighted, everything collapsible
54
+ - **Diagnostics** — the plugin load order and conflict surface, one page: bundle stack with official/community badges, duplicate loader entries, dependency version mismatches, multi-version core packages, overrides and invalid config entries, and leftover directories — a package directory an interrupted update left without its `package.json`, and pnpm's own `*_tmp_*` staging directories. Nothing there stops a start, so it is listed rather than warned about, and it is the only place that names what is on disk. Plain-language terms, problem blocks highlighted, everything collapsible
53
55
 
54
56
  - **Load order** — drag community bundles into the order you want, or take the suggested one derived from the plugins' own before/after rules. Nothing is written until a trial composition passes, and the panel tells you what the new order would change (overrides, invalid or duplicate entries) before you apply it
55
57
  - **AI fix** — one click copies a diagnostics-driven fix prompt (errors/warnings/order conflicts + conservative scope instructions) to the clipboard; you paste it into a new conversation and decide whether to send. The prompt first asks the agent to detect whether it is itself the harness running this profile — if so it hard-forbids mutating the live composition, upgrading/restarting the harness or core packages, or reinstalling deps, and instead has it write an idempotent `apply` script plus a `rollback` script, have you run them in an external terminal, and paste the output back
@@ -76,6 +78,8 @@ Installs prefer repo-verified npm packages, then author-supplied prebuilt GitHub
76
78
  allowRestart: false # NOT at the top level beside `name:`
77
79
  ```
78
80
 
81
+ **On dsh 0.1.7 and newer there is no switch to flip**: the host derives a plugin's settings from its own Config schema and no longer serves a plugin-registered settings namespace, so the market has no control there. The profile patch below is the way on every host.
82
+
79
83
  `GET /dsh-market/status` reports `"restart": false` once it has taken effect.
80
84
  - For terminal-attached launches, the detached replacement keeps running after the original terminal closes
81
85
  - Listing ≠ endorsement: plugins are third-party code, install sources you trust
@@ -96,7 +100,9 @@ Fetched live on every open from [awesome-dsh-plugin.com/plugins.json](https://aw
96
100
 
97
101
  There is deliberately no bundled snapshot to fall back on: for a catalog that grows daily, a stale answer is not a degraded one but a wrong one — a plugin published this morning would read as "does not exist".
98
102
 
99
- **If that host is unreachable from your network**, point the market at a mirror instead. Set `DSHM_REGISTRY_URL` in the environment dsh runs in, to anything serving the same `plugins.json` shape:
103
+ In the China download region the same catalog is read from npm first, through the same region-aware routes the plugins themselves arrive on: every China mirror already carries it, it comes off the mirror rather than bouncing back to the origin registry, and it carries a version, so a bad catalog build can be rolled back instead of only being fixed forwards. The origin stays last in the list as the route that has always worked. Region detection happens once and the answer is remembered; **Settings → Plugins → Plugin configuration** can override it.
104
+
105
+ **If that host is unreachable from your network**, point the market at a mirror. Set `DSHM_REGISTRY_URL` in the environment dsh runs in, to anything serving the same `plugins.json` shape:
100
106
 
101
107
  ```sh
102
108
  DSHM_REGISTRY_URL=https://your-mirror.example/plugins.json dsh web
package/README.zh.md CHANGED
@@ -29,15 +29,17 @@ dsh plugin --profile web add dshmarket
29
29
 
30
30
  ## 你会得到
31
31
 
32
- - **逛与搜**——完整社区目录(2300+ 插件,每天在涨),分类筛选、star 数、最热/最新排序,中英描述跟随界面语言
32
+ - **逛与搜**——完整社区目录(4200+ 插件,每天在涨),分类筛选、star 数、最热/最新排序,中英描述跟随界面语言
33
33
  - **按宿主发现**——卡片展示插件通过 `engines.dsh` 或同版本线 `@deepseek-ai/dsh-*` peer 声明的 DSH 要求;可选筛选只隐藏与当前宿主明确不匹配的插件。未声明、格式异常、暂时取不到清单及仅 GitHub 发布的条目仍保持可见,不猜成不兼容
34
34
  - **截图展示**——App Store 式截图,多图自动轮播,点开还能看大图;作者在 registry 里策展的截图列表卡片就直接显示(零额外请求),没有策展的插件则在打开安装弹窗时自动从 README 抽取;图片仅从 GitHub 图床加载
35
35
  - **评论**——每张卡片都能就地打开该插件的讨论。它和插件在 [dshmarket.com](https://dshmarket.com) 与[目录站](https://awesome-dsh-plugin.com)上的页面共用同一条讨论,一个插件只有一处对话,而不是三处。底层是 GitHub Discussions(经由 giscus):打开即加载,只有发表评论才需要 GitHub 账号;说明里也直说打开会连接 giscus.app 与 GitHub。本地 dsh web 继续内嵌评论供阅读,登录和发表则通过专用 GitHub 入口在新标签页打开当前插件的精确讨论,不让跨站回跳携带或依赖宿主会话
36
36
  - **收藏**——在「发现」或「主题」页点书签即可收藏插件/主题;「收藏」Tab 集中展示,支持搜索、排序与安装。书签写入 profile 的市场状态(`state.json`);已下架条目可一键清除
37
+ - **分组**——把已安装插件整理进具名分组(可重命名、删除,分组内单独搜索);一个插件属于一个分组,默认是「未分组」。它只用于整理,面板上写明了这一点,不改变任何启用状态。分组本身是 profile `state.json` 里的本地状态
38
+ - **备注**——给任何已安装插件写一行你自己的说明,它会在这行**代替作者简介**显示——四十个插件的列表因此能用你自己的话回答「我当初为什么装它」。和分组、收藏一样只存在本地,不上传
37
39
  - **主题**——独立主题页:装完立即生效,点一下切换(主题互斥、选择跨重启保留),卸载即恢复
38
40
  - **一键安装**——确认来源,实时进度;多数插件刷新页面即可用,无需重启
39
41
  - **备份与恢复**——把 profile 的插件清单与配置导出为可读 JSON,换机导入,存到 WebDAV 并每日自动备份,或通过私有 GitHub Gist 跨机器同步;恢复采用**合并**方式(备份之后新装的插件会保留),写入前校验、失败自动回滚
40
- - **更新**——逐插件检测(npm 版本或锁定 commit 对比 HEAD),一键更新或全部更新;市场自己也走同一通道升级
42
+ - **更新**——逐插件检测(npm 版本或锁定 commit 对比 HEAD),一键更新或全部更新;市场自己也走同一通道升级。每个有待更新的行上都有「更新内容」链接——有版本说明就是版本说明,对不上版本时给提交记录。暂时不处理的提醒可以**本次不再提醒**(本次 = 当前宿主进程),重启后提醒会回来——「忽略」永远不等于「关闭」
41
43
  - **GitHub 多线路容错**——中国大陆下载区域会为 Git ref、README 与头像分别维护 fallback 顺序,记住上次可用线路,只在传输、HTTP 状态或响应内容校验失败后换线,并拒绝公共反代伪装成 HTTP 200 的 HTML 错误页。内置线路全部失效时,可到**设置 → 插件 → 插件配置 → GitHub 加速**填写一个持久化的自定义 HTTPS 前缀;运维设置的 `DSHM_GITHUB_PROXY` 始终优先
42
44
  - **公共更新接口**——插件自己的设置页可调用带版本号、能力探测和回滚状态的[更新 API v1](UPDATE-API-V1.md)(beta),无需复制包管理逻辑,也不依赖市场 UI 的私有响应字段
43
45
  - **卸载**——两步确认防误触;本次会话装的插件即点即卸
@@ -47,7 +49,7 @@ dsh plugin --profile web add dshmarket
47
49
  - **零术语**——缺组件(pnpm)时市场自己发现、一键自动装好,全程不见命令行
48
50
  - **导出日志**——一键生成脱敏纯文本日志方便反馈(home 路径与密钥形状已打码;任何数据都不会被上传)。市场版本号就在标题旁边,截图反馈时自带版本信息
49
51
  - **设置卡片**——dsh 0.1.0-rc.7 起,市场在 **设置 → 插件 → 插件配置** 里管理**它自己**,和其它插件并排:看当前版本、选择**更新通道**(稳定版,或 Beta 抢先试用还在验证中的版本——只影响市场自己,不影响你装的其它插件;打开开发者模式后还会多出「开发版」通道,直接取开发分支上的构建)、更新、或者移除市场。移除时可勾选一并清理——包括市场写进补丁层的停用行,被它关掉的插件会恢复运行,而不是保持停用却再没有界面能打开它们
50
- - **诊断**——插件加载顺序与冲突一页看全:bundle 栈(官方/社区徽标)、重复的 loader 条目、依赖版本不一致、核心包多版本共存、覆盖项与非法配置条目。术语说人话,问题块高亮,全部可折叠
52
+ - **诊断**——插件加载顺序与冲突一页看全:bundle 栈(官方/社区徽标)、重复的 loader 条目、依赖版本不一致、核心包多版本共存、覆盖项与非法配置条目,以及残留目录——更新被打断后留下的、缺少 `package.json` 的包目录,和 pnpm 自己的 `*_tmp_*` 暂存目录。它们都不妨碍启动,所以只列出来而不报成警告,也是唯一会说清磁盘上到底有什么的地方。术语说人话,问题块高亮,全部可折叠
51
53
  - **加载顺序**——拖拽调整社区 bundle 的顺序,或直接采用按插件自身 before/after 规则推导出的建议顺序。写入前先跑一次静态组合校验,通过才落盘;应用之前面板会告诉你这个顺序会改变什么(多少处覆盖、多少无效或重复条目)
52
54
  - **AI 修复**——一键把诊断结果生成的修复 prompt(错误/警告/顺序冲突 + 保守的改动范围约束)复制到剪贴板,你粘进新对话,自己决定发不发。提示词会先让 Agent 判断自己是不是就是这个 profile 所在的 harness——若是,则严禁改动运行中组合、升级/重启 harness 或核心包、重装依赖,而是让它生成一份幂等的 `apply` 修改脚本和一份 `rollback` 回滚脚本,由你在外部终端执行并把输出拷回给它
53
55
 
@@ -73,6 +75,8 @@ dsh plugin --profile web add dshmarket
73
75
  allowRestart: false # 不要和 `name:` 并排写在顶层
74
76
  ```
75
77
 
78
+ **dsh 0.1.7 及更新版本上没有这个开关可关**:宿主改为从插件自己的 Config schema 派生设置,不再为插件注册的设置命名空间提供服务,市场在那里没有可用的控件。用下面的 profile patch,它在所有宿主上都有效。
79
+
76
80
  生效后 `GET /dsh-market/status` 会返回 `"restart": false`。
77
81
  - 从终端启动时,替代进程脱离原终端,关闭原终端后仍会继续运行
78
82
  - 收录 ≠ 背书:插件是第三方代码,请只安装你信任的来源
@@ -93,6 +97,8 @@ dsh plugin --profile web add dshmarket
93
97
 
94
98
  刻意不做本地快照兜底:目录每天都在增长,过期的答案不是「差一点」而是「错的」——今早刚发布的插件会显示成「不存在」。
95
99
 
100
+ 中国大陆下载区域**优先从 npm 读同一份目录**,走的是插件自己也在走的那套区域感知线路:国内镜像本来就带着它、下载落在镜像而不是绕回源站,而且它有版本号——一份有问题的目录构建可以回滚,而不是只能往前修。源站仍作为「一直能用的那条」排在最后。区域判断只做一次并记住结果,可在**设置 → 插件 → 插件配置**里改。
101
+
96
102
  **如果你的网络访问不了这个域名**,可以改指到镜像:在 dsh 运行的环境里设置 `DSHM_REGISTRY_URL`,指向任何提供相同 `plugins.json` 结构的地址:
97
103
 
98
104
  ```sh
package/client/client.js CHANGED
@@ -336,6 +336,14 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
336
336
  hostIncompatibleTitle: "这个新版本要求更新的 DSH",
337
337
  hostIncompatibleBody: "{plugin} 声明它需要 DSH {requirement},而你现在运行的是 {host}。装上去多半会直接报错,只能手动装回旧版。\n已经停下了,插件还是原来的版本。\n如果你确定这个版本号不准(例如桌面端捆绑的 DSH 不报告版本),也可以继续更新。",
338
338
  hostIncompatibleUnknown: "未知版本",
339
+ hostIncompatibleSearching: "正在查找还兼容当前 DSH 的版本…",
340
+ hostIncompatibleFoundInstall: "可以装 {version}:它声明兼容当前 DSH。",
341
+ hostIncompatibleFoundUpdate: "可以更新到 {version}:它声明兼容当前 DSH。",
342
+ hostIncompatibleNoCompat: "没有找到声明兼容当前 DSH 的版本。",
343
+ hostIncompatibleSearchFailed: "暂时查不到兼容版本。",
344
+ hostIncompatibleInstallCompat: "安装 {version}",
345
+ hostIncompatibleUpdateCompat: "更新到 {version}",
346
+ hostIncompatibleRetry: "重试",
339
347
  hostIncompatibleKeep: "保持现在的版本",
340
348
  hostIncompatibleAnyway: "仍然更新",
341
349
  hostStatusCompatible: "兼容性已满足(声明满足)",
@@ -457,6 +465,8 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
457
465
  perPage: "每页",
458
466
  marketUpdate: "更新插件市场",
459
467
  updateAll: "全部更新",
468
+ heldReleaseNotice: "装好了,但不是最新的:这个 profile 的 minimumReleaseAge 把 {0} 按住了,装的是 {1}。",
469
+ heldReleaseAction: "装 {0}(不等)",
460
470
  ignoreUpdateNotice: "本次不再提醒",
461
471
  ignoreAllUpdateNotices: "本次全部忽略",
462
472
  updateNoticeIgnored: "本次已忽略提醒",
@@ -665,6 +675,11 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
665
675
  checkOverrides: "覆盖关系",
666
676
  checkOverridesEmpty: "没有覆盖关系",
667
677
  checkOverridden: "覆盖了",
678
+ checkResiduals: "残留目录",
679
+ checkResidualsEmpty: "没有发现残缺的包目录或 pnpm 临时目录。",
680
+ checkResidualsHint: "这些目录没有任何声明引用它们,也不妨碍启动;它们是更新被打断时留下的。退出 DSH 之后可以删除,或直接重新安装对应的插件。",
681
+ checkResidualTmp: "pnpm 临时目录",
682
+ checkResidualIncomplete: "残缺的包目录(缺少 package.json)",
668
683
  checkOrphans: "无效的配置条目",
669
684
  checkOrphansEmpty: "没有无效配置条目",
670
685
  orphanInsertNotArray: "格式错误",
@@ -929,6 +944,14 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
929
944
  hostIncompatibleTitle: "This release needs a newer DSH",
930
945
  hostIncompatibleBody: "{plugin} declares that it needs DSH {requirement}, and you are running {host}. Installing it would most likely break the plugin, leaving a manual downgrade as the only way back.\nNothing was changed — the plugin is still on its current version.\nIf you know the version number is wrong (a bundled DSH that does not report one, for example), you can update anyway.",
931
946
  hostIncompatibleUnknown: "an unknown version",
947
+ hostIncompatibleSearching: "Looking for a version that still supports this DSH…",
948
+ hostIncompatibleFoundInstall: "You can install {version} — it declares support for this DSH.",
949
+ hostIncompatibleFoundUpdate: "You can update to {version} — it declares support for this DSH.",
950
+ hostIncompatibleNoCompat: "No version declares support for this DSH.",
951
+ hostIncompatibleSearchFailed: "Could not look up compatible versions.",
952
+ hostIncompatibleInstallCompat: "Install {version}",
953
+ hostIncompatibleUpdateCompat: "Update to {version}",
954
+ hostIncompatibleRetry: "Retry",
932
955
  hostIncompatibleKeep: "Keep the current version",
933
956
  hostIncompatibleAnyway: "Update anyway",
934
957
  hostStatusCompatible: "Host requirement satisfied (declared)",
@@ -1050,6 +1073,8 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
1050
1073
  perPage: "Per page",
1051
1074
  marketUpdate: "Update the plugin market",
1052
1075
  updateAll: "Update all",
1076
+ heldReleaseNotice: "Installed, but not the newest: this profile's minimumReleaseAge is holding {0} back, so {1} is what got installed.",
1077
+ heldReleaseAction: "Install {0} anyway",
1053
1078
  ignoreUpdateNotice: "Ignore for this boot",
1054
1079
  ignoreAllUpdateNotices: "Ignore all for this boot",
1055
1080
  updateNoticeIgnored: "Reminder ignored for this boot",
@@ -1258,6 +1283,11 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
1258
1283
  checkOverrides: "Overrides",
1259
1284
  checkOverridesEmpty: "No overrides",
1260
1285
  checkOverridden: "overrides",
1286
+ checkResiduals: "Leftover directories",
1287
+ checkResidualsEmpty: "No incomplete package directories or pnpm temp directories.",
1288
+ checkResidualsHint: "Nothing in the profile references these, and none of them stops a start: they are what an interrupted update leaves behind. Quit DSH and either delete them or install the plugin again.",
1289
+ checkResidualTmp: "pnpm temp directory",
1290
+ checkResidualIncomplete: "incomplete package (no package.json)",
1261
1291
  checkOrphans: "Invalid config entries",
1262
1292
  checkOrphansEmpty: "No invalid config entries",
1263
1293
  orphanInsertNotArray: "malformed",
@@ -3762,6 +3792,12 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
3762
3792
  onClick: () => props.onRetry?.(record),
3763
3793
  children: t("opRetry")
3764
3794
  }),
3795
+ record.state === "warned" && record.heldRelease !== void 0 && props.onForceInstall !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
3796
+ variant: "outline",
3797
+ size: "sm",
3798
+ onClick: () => props.onForceInstall?.(record),
3799
+ children: t("heldReleaseAction").replace("{0}", record.heldRelease.latest)
3800
+ }),
3765
3801
  isSettled(record) && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
3766
3802
  variant: "ghost",
3767
3803
  size: "sm",
@@ -5498,6 +5534,36 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
5498
5534
  }, i))
5499
5535
  })
5500
5536
  }),
5537
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Section, {
5538
+ title: t("checkResiduals"),
5539
+ count: report.residuals?.length ?? 0,
5540
+ empty: t("checkResidualsEmpty"),
5541
+ problem: false,
5542
+ overview: report.residuals?.[0]?.path,
5543
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
5544
+ className: Market_module_css_default.panelNote,
5545
+ children: t("checkResidualsHint")
5546
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
5547
+ className: Market_module_css_default.diagList,
5548
+ children: (report.residuals ?? []).map((residual) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5549
+ className: Market_module_css_default.diagRow,
5550
+ children: [
5551
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5552
+ className: Market_module_css_default.nm,
5553
+ children: residual.name
5554
+ }),
5555
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5556
+ className: Market_module_css_default.spec,
5557
+ children: t(residual.kind === "tmp-directory" ? "checkResidualTmp" : "checkResidualIncomplete")
5558
+ }),
5559
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("code", {
5560
+ className: Market_module_css_default.diagVal,
5561
+ children: residual.path
5562
+ })
5563
+ ]
5564
+ }, residual.path))
5565
+ })]
5566
+ }),
5501
5567
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(CollapsibleSection, {
5502
5568
  title: t("orderSection"),
5503
5569
  count: order.length,
@@ -7119,6 +7185,44 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
7119
7185
  const [restoreConfirm, setRestoreConfirm] = (0, react.useState)(null);
7120
7186
  /** A release whose declared host requirement this host does not meet (#404). kind distinguishes the update dialog from the fresh-install dialog. */
7121
7187
  const [hostIncompatible, setHostIncompatible] = (0, react.useState)(null);
7188
+ /**
7189
+ * The answer to "the newest release is too new for this host — what CAN I
7190
+ * install?" (#581). The dialog asks as soon as it opens, because that
7191
+ * question is the only way out it can offer: the alternative the route
7192
+ * gives (install anyway) is a decision about breaking the host, and most
7193
+ * users reaching this dialog have no way to weigh it.
7194
+ */
7195
+ const [findingCompat, setFindingCompat] = (0, react.useState)({ status: "idle" });
7196
+ const [compatRetry, setCompatRetry] = (0, react.useState)(0);
7197
+ (0, react.useEffect)(() => {
7198
+ if (hostIncompatible === null || hostIncompatible.npmName === null) {
7199
+ setFindingCompat({ status: "idle" });
7200
+ return;
7201
+ }
7202
+ const controller = new AbortController();
7203
+ setFindingCompat({ status: "loading" });
7204
+ fetch(api("/dsh-market/find-compatible"), {
7205
+ method: "POST",
7206
+ headers: { "content-type": "application/json" },
7207
+ body: JSON.stringify({
7208
+ npmName: hostIncompatible.npmName,
7209
+ upgradeOnly: hostIncompatible.kind === "update"
7210
+ }),
7211
+ signal: controller.signal
7212
+ }).then(async (response) => {
7213
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
7214
+ return await response.json();
7215
+ }).then((data) => {
7216
+ if (controller.signal.aborted) return;
7217
+ setFindingCompat(typeof data.compatibleVersion === "string" && data.compatibleVersion !== "" ? {
7218
+ status: "found",
7219
+ version: data.compatibleVersion
7220
+ } : { status: "not-found" });
7221
+ }).catch(() => {
7222
+ if (!controller.signal.aborted) setFindingCompat({ status: "error" });
7223
+ });
7224
+ return () => controller.abort();
7225
+ }, [hostIncompatible, compatRetry]);
7122
7226
  const [restoreBlocked, setRestoreBlocked] = (0, react.useState)(null);
7123
7227
  const [migrationConfirm, setMigrationConfirm] = (0, react.useState)(null);
7124
7228
  /** Determinate percent parsed from pnpm's Progress line, when available. */
@@ -7849,7 +7953,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
7849
7953
  const rest = entries.length > 1 ? ` (+${entries.length - 1})` : "";
7850
7954
  return `${first.name} — ${first.layers.join(" / ")}${rest}`;
7851
7955
  };
7852
- const doInstall = (0, react.useCallback)((plugin, force = false) => {
7956
+ const doInstall = (0, react.useCallback)((plugin, force = false, version) => {
7853
7957
  setBuildsSkipped(null);
7854
7958
  setConfirming(null);
7855
7959
  setInstallError(null);
@@ -7872,7 +7976,8 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
7872
7976
  headers: { "content-type": "application/json" },
7873
7977
  body: JSON.stringify({
7874
7978
  url: plugin.url,
7875
- ...force ? { force: true } : {}
7979
+ ...force ? { force: true } : {},
7980
+ ...version !== void 0 ? { version } : {}
7876
7981
  })
7877
7982
  }).then((res) => res.json().then((body) => ({
7878
7983
  status: res.status,
@@ -7911,7 +8016,11 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
7911
8016
  setHotNames((names) => names.includes(plugin.name) ? names : names.concat(plugin.name));
7912
8017
  } else setDoneUrls((urls) => urls.includes(plugin.url) ? urls : urls.concat(plugin.url));
7913
8018
  if (body.compatibility?.code === "soft-incompatible") setCompatibilityNotice(body.compatibility);
7914
- setRecords((list) => patch(list, recordId, body.compatibility?.code === "soft-incompatible" ? {
8019
+ setRecords((list) => patch(list, recordId, body.heldRelease !== void 0 ? {
8020
+ state: "warned",
8021
+ reason: t("heldReleaseNotice").replace("{0}", String(body.heldRelease.latest)).replace("{1}", String(body.heldRelease.installed ?? "")),
8022
+ heldRelease: body.heldRelease
8023
+ } : body.compatibility?.code === "soft-incompatible" ? {
7915
8024
  state: "warned",
7916
8025
  reason: t("compatRiskBanner")
7917
8026
  } : {
@@ -7946,6 +8055,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
7946
8055
  version: String(notice.version ?? ""),
7947
8056
  requirement: typeof notice.requirement === "string" ? notice.requirement : null,
7948
8057
  hostVersion: typeof notice.hostVersion === "string" ? notice.hostVersion : null,
8058
+ npmName: typeof notice.npmName === "string" ? notice.npmName : typeof plugin.npm === "string" ? plugin.npm : null,
7949
8059
  plugin
7950
8060
  });
7951
8061
  return;
@@ -8188,7 +8298,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
8188
8298
  body: "{}"
8189
8299
  }).catch(() => {});
8190
8300
  }, []);
8191
- const doUpdate = (0, react.useCallback)((name, force = false, restore = false) => {
8301
+ const doUpdate = (0, react.useCallback)((name, force = false, restore = false, compatVersion) => {
8192
8302
  setInstallError(null);
8193
8303
  setActivationWarnings([]);
8194
8304
  setStaleName((prev) => prev === name ? null : prev);
@@ -8210,7 +8320,8 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
8210
8320
  body: JSON.stringify({
8211
8321
  name,
8212
8322
  ...force ? { force: true } : {},
8213
- ...restore ? { restore: true } : {}
8323
+ ...restore ? { restore: true } : {},
8324
+ ...compatVersion !== void 0 ? { compatVersion } : {}
8214
8325
  })
8215
8326
  }).then((res) => res.json().then((body) => ({
8216
8327
  status: res.status,
@@ -8267,6 +8378,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
8267
8378
  version: String(notice.version ?? ""),
8268
8379
  requirement: typeof notice.requirement === "string" ? notice.requirement : null,
8269
8380
  hostVersion: typeof notice.hostVersion === "string" ? notice.hostVersion : null,
8381
+ npmName: typeof notice.npmName === "string" ? notice.npmName : null,
8270
8382
  plugin: null
8271
8383
  });
8272
8384
  return;
@@ -9790,6 +9902,12 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
9790
9902
  onDismiss: (record) => setRecords((list) => drop(list, record.id)),
9791
9903
  onRefresh: () => location.reload(),
9792
9904
  onResolveConflict: resolveConflict,
9905
+ onForceInstall: (record) => {
9906
+ const plugin = record.url === void 0 ? void 0 : data?.plugins.find((p) => p.url === record.url);
9907
+ if (plugin === void 0) return;
9908
+ setRecords((list) => drop(list, record.id));
9909
+ doInstall(plugin, true);
9910
+ },
9793
9911
  onApproveBuilds: (record) => {
9794
9912
  const names = record.blockedBuilds ?? [];
9795
9913
  if (names.length === 0) return;
@@ -11701,24 +11819,47 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
11701
11819
  open: true,
11702
11820
  onClose: () => setHostIncompatible(null),
11703
11821
  title: t("hostIncompatibleTitle"),
11704
- description: t(hostIncompatible.kind === "install" ? "hostIncompatibleBodyInstall" : "hostIncompatibleBody").replace("{plugin}", `${hostIncompatible.name} ${hostIncompatible.version}`.trim()).replace("{requirement}", hostIncompatible.requirement ?? t("hostIncompatibleUnknown")).replace("{host}", hostIncompatible.hostVersion ?? t("hostIncompatibleUnknown")),
11705
- footer: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
11706
- variant: "primary",
11707
- onClick: () => setHostIncompatible(null),
11708
- children: t(hostIncompatible.kind === "install" ? "hostIncompatibleCancel" : "hostIncompatibleKeep")
11709
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
11710
- variant: "ghost",
11711
- disabled: hostIncompatible.kind === "install" ? busyUrl !== null : updatingName !== null,
11712
- onClick: () => {
11713
- const kind = hostIncompatible.kind;
11714
- const target = hostIncompatible.name;
11715
- const forcePlugin = hostIncompatible.plugin;
11716
- setHostIncompatible(null);
11717
- if (kind === "install" && forcePlugin !== null) doInstall(forcePlugin, true);
11718
- else doUpdate(target, true);
11719
- },
11720
- children: t(hostIncompatible.kind === "install" ? "hostIncompatibleInstallAnyway" : "hostIncompatibleAnyway")
11721
- })] })
11822
+ description: [t(hostIncompatible.kind === "install" ? "hostIncompatibleBodyInstall" : "hostIncompatibleBody").replace("{plugin}", `${hostIncompatible.name} ${hostIncompatible.version}`.trim()).replace("{requirement}", hostIncompatible.requirement ?? t("hostIncompatibleUnknown")).replace("{host}", hostIncompatible.hostVersion ?? t("hostIncompatibleUnknown")), hostIncompatible.npmName === null ? null : findingCompat.status === "loading" ? t("hostIncompatibleSearching") : findingCompat.status === "found" ? t(hostIncompatible.kind === "install" ? "hostIncompatibleFoundInstall" : "hostIncompatibleFoundUpdate").replace("{version}", findingCompat.version) : findingCompat.status === "not-found" ? t("hostIncompatibleNoCompat") : findingCompat.status === "error" ? t("hostIncompatibleSearchFailed") : null].filter((line) => line !== null).join("\n"),
11823
+ footer: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
11824
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
11825
+ variant: findingCompat.status === "found" ? "outline" : "primary",
11826
+ onClick: () => setHostIncompatible(null),
11827
+ children: t(hostIncompatible.kind === "install" ? "hostIncompatibleCancel" : "hostIncompatibleKeep")
11828
+ }),
11829
+ findingCompat.status === "found" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
11830
+ variant: "primary",
11831
+ disabled: hostIncompatible.kind === "install" ? busyUrl !== null : updatingName !== null,
11832
+ onClick: () => {
11833
+ const kind = hostIncompatible.kind;
11834
+ const target = hostIncompatible.name;
11835
+ const plugin = hostIncompatible.plugin;
11836
+ const version = findingCompat.version;
11837
+ setHostIncompatible(null);
11838
+ if (kind === "install") {
11839
+ if (plugin !== null) doInstall(plugin, false, version);
11840
+ } else doUpdate(target, false, false, version);
11841
+ },
11842
+ children: t(hostIncompatible.kind === "install" ? "hostIncompatibleInstallCompat" : "hostIncompatibleUpdateCompat").replace("{version}", findingCompat.version)
11843
+ }),
11844
+ findingCompat.status === "error" && hostIncompatible.npmName !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
11845
+ variant: "outline",
11846
+ onClick: () => setCompatRetry((n) => n + 1),
11847
+ children: t("hostIncompatibleRetry")
11848
+ }),
11849
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
11850
+ variant: "ghost",
11851
+ disabled: hostIncompatible.kind === "install" ? busyUrl !== null : updatingName !== null,
11852
+ onClick: () => {
11853
+ const kind = hostIncompatible.kind;
11854
+ const target = hostIncompatible.name;
11855
+ const forcePlugin = hostIncompatible.plugin;
11856
+ setHostIncompatible(null);
11857
+ if (kind === "install" && forcePlugin !== null) doInstall(forcePlugin, true);
11858
+ else doUpdate(target, true);
11859
+ },
11860
+ children: t(hostIncompatible.kind === "install" ? "hostIncompatibleInstallAnyway" : "hostIncompatibleAnyway")
11861
+ })
11862
+ ] })
11722
11863
  }),
11723
11864
  restoreBlocked !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Modal, {
11724
11865
  open: true,
@@ -12659,6 +12800,15 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
12659
12800
  function missingPrimitives(mod, required = REQUIRED_PRIMITIVES) {
12660
12801
  return required.filter((name) => mod[name] === void 0);
12661
12802
  }
12803
+ /**
12804
+ * The package name the host keys a bundle's own configuration by.
12805
+ *
12806
+ * `plugins.bundle.config` on dsh 0.1.7+ is keyed by the BUNDLE's package
12807
+ * name. The market's is `dshmarket` — the name `dsh plugin add dshmarket`
12808
+ * installs and the one its own `package.json` declares — which is not the
12809
+ * same string as the locale namespace (`dsh-market`) this file uses for copy.
12810
+ */
12811
+ const MARKET_PACKAGE_NAME = "dshmarket";
12662
12812
  const name = "dsh-market";
12663
12813
  const inject = [
12664
12814
  "slots",
@@ -12751,6 +12901,18 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
12751
12901
  }
12752
12902
  })));
12753
12903
  });
12904
+ const bundleConfigCtx = ctx;
12905
+ bundleConfigCtx.slots.inject("plugins.bundle.config", () => bundleConfigCtx.slots.register({
12906
+ name: "plugins.bundle.config",
12907
+ key: MARKET_PACKAGE_NAME,
12908
+ locale: NS,
12909
+ inject: () => ({ t })
12910
+ }, (ownerProps = {}) => ownerProps.view === "summary" ? null : (0, react.createElement)(SettingsCard, {
12911
+ t,
12912
+ onRemoved: () => {
12913
+ sectionGate.retire();
12914
+ }
12915
+ })));
12754
12916
  const Toast = () => (0, react.createElement)(InstallToast, { t });
12755
12917
  ctx.slots.inject("shell.overlay", () => ctx.slots.register({
12756
12918
  name: "shell.overlay",
package/lib/check.js CHANGED
@@ -25,7 +25,7 @@
25
25
  * (same js-yaml dialect incl. `!!js` scalars), so the rows reported here are
26
26
  * what actually mounts at boot.
27
27
  */
28
- import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
28
+ import { existsSync, lstatSync, readdirSync, readFileSync, statSync } from 'node:fs';
29
29
  import { createRequire, isBuiltin } from 'node:module';
30
30
  import { dirname, isAbsolute, join, resolve } from 'node:path';
31
31
  import { JSON_SCHEMA, Type, load } from 'js-yaml';
@@ -323,6 +323,10 @@ function comparePre(a, b) {
323
323
  return 0;
324
324
  }
325
325
  /** Compare two semver strings: negative | zero | positive (prerelease < release of same base). */
326
+ /** Whether a string is a well-formed semver `compareSemver` can order. */
327
+ export function isSemver(value) {
328
+ return parseSemver(value) !== null;
329
+ }
326
330
  export function compareSemver(a, b) {
327
331
  const av = parseSemver(a);
328
332
  const bv = parseSemver(b);
@@ -854,6 +858,89 @@ export function buildBundleLayers(profileDirectory, bundleNames, specs, dshInsta
854
858
  }));
855
859
  return { bundles, layers };
856
860
  }
861
+ /**
862
+ * Which directories in `node_modules` are leftovers (#663).
863
+ *
864
+ * Two shapes, and both need to be VISIBLE rather than cleaned: a directory
865
+ * without a readable `package.json` (what a lock-blocked update leaves), and
866
+ * pnpm's `<name>_tmp_<pid>_<n>` staging directory (what an interrupted one
867
+ * leaves). Neither can be removed from in here in the case that produces
868
+ * them — the plugin's own process holds the directory open, so the rename
869
+ * and delete that would clear it are the operations that were just refused.
870
+ * Telling the user what is on disk, and which of it is merely junk, is the
871
+ * part this process can do.
872
+ *
873
+ * Bounded on purpose. The top level is scanned in full (that is where the
874
+ * profile's own packages live), plus one level inside the virtual store:
875
+ * pnpm stages a package's update in the `node_modules` beside it, so that is
876
+ * where a dependency's temp directory appears. It does NOT recurse through
877
+ * the whole virtual store: on a large Windows profile that is thousands of
878
+ * directories for a page the user opens by hand, and the store's OWN temp
879
+ * directories are already reclaimed by `cleanOrphanedStoreTmp`.
880
+ */
881
+ export function findResidualDirectories(profileDirectory, declared) {
882
+ const out = [];
883
+ // pnpm's staging name, from its own rename: `<name>_tmp_<pid>_<n>`.
884
+ const tmpShape = /^(.+)_tmp_\d+_\w+$/;
885
+ const consider = (directory, entry, relative) => {
886
+ // pnpm's own bookkeeping lives in here beside the packages: `.pnpm` (the
887
+ // virtual store), `.bin`, `.modules.yaml`, `.ignored`. None of them is a
888
+ // package, so none of them is a broken one.
889
+ if (entry.startsWith('.'))
890
+ return;
891
+ let stats;
892
+ try {
893
+ stats = lstatSync(join(directory, entry));
894
+ }
895
+ catch {
896
+ return;
897
+ }
898
+ // A symlink is how pnpm links every installed package to its store
899
+ // entry. A DANGLING one is a different failure (and `#708` cleans the
900
+ // market's own); counting healthy links as leftovers would flag every
901
+ // profile in existence.
902
+ if (!stats.isDirectory())
903
+ return;
904
+ const tmp = tmpShape.exec(entry);
905
+ if (tmp !== null) {
906
+ out.push({ name: tmp[1], path: join(relative, entry), kind: 'tmp-directory', declared: declared.has(tmp[1]) });
907
+ return;
908
+ }
909
+ if (declared.has(entry))
910
+ return;
911
+ let readable = false;
912
+ try {
913
+ JSON.parse(readFileSync(join(directory, entry, 'package.json'), 'utf8'));
914
+ readable = true;
915
+ }
916
+ catch { /* missing or unparseable — that is the finding */ }
917
+ if (!readable)
918
+ out.push({ name: entry, path: join(relative, entry), kind: 'incomplete-package', declared: false });
919
+ };
920
+ const nodeModules = join(profileDirectory, 'node_modules');
921
+ try {
922
+ for (const entry of readdirSync(nodeModules))
923
+ consider(nodeModules, entry, 'node_modules');
924
+ }
925
+ catch { /* no node_modules: nothing to report */ }
926
+ const pnpmDirectory = join(nodeModules, '.pnpm');
927
+ let pnpmEntries = [];
928
+ try {
929
+ pnpmEntries = readdirSync(pnpmDirectory);
930
+ }
931
+ catch { /* no virtual store */ }
932
+ for (const entry of pnpmEntries) {
933
+ const nested = join(pnpmDirectory, entry, 'node_modules');
934
+ const relative = join('node_modules', '.pnpm', entry, 'node_modules');
935
+ try {
936
+ for (const name of readdirSync(nested))
937
+ consider(nested, name, relative);
938
+ }
939
+ catch { /* unreadable nested node_modules */ }
940
+ }
941
+ out.sort((left, right) => left.path.localeCompare(right.path));
942
+ return out;
943
+ }
857
944
  /**
858
945
  * Analyze one profile directory (issue #98, phase 1). Pure function of the
859
946
  * directory contents — safe to call on every market open.
@@ -875,6 +962,11 @@ export function analyzeProfile(profileDirectory, options = {}) {
875
962
  ? manifest.dsh.profile.bundles.filter((name) => typeof name === 'string')
876
963
  : [];
877
964
  const specs = manifest?.dependencies ?? {};
965
+ // Declared = what the profile asks to load. A leftover directory under one
966
+ // of these names is a boot problem (the bundle layers above judge it), and
967
+ // an undeclared one is junk the user can clear — the distinction is the
968
+ // whole point of the listing (#663).
969
+ const declaredNames = new Set([...Object.keys(specs), ...bundleNames]);
878
970
  const built = buildBundleLayers(profileDirectory, bundleNames, specs, dshInstall);
879
971
  const bundles = built.bundles;
880
972
  const bundleLayers = built.layers;
@@ -1126,6 +1218,7 @@ export function analyzeProfile(profileDirectory, options = {}) {
1126
1218
  multiVersion,
1127
1219
  orderConflicts,
1128
1220
  suggestedOrder,
1221
+ residuals: findResidualDirectories(profileDirectory, declaredNames),
1129
1222
  summary: {
1130
1223
  ok: errors.length === 0,
1131
1224
  errors,