dshmarket 1.11.2 → 1.11.3

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
@@ -27,7 +27,7 @@ Restart `dsh web`, then open **Settings → Plugin Market**.
27
27
 
28
28
  ## What you get
29
29
 
30
- - **Browse & search** the full community catalog (800+ plugins, growing daily) — category filters, star counts, top/new sorting, bilingual descriptions that follow your UI language
30
+ - **Browse & search** the full community catalog (1200+ plugins, growing daily) — category filters, star counts, top/new sorting, bilingual descriptions that follow your UI language
31
31
  - **Screenshots** — AppStore-style screenshots in the install dialog: author-curated via the registry, with automatic README extraction as fallback; images load from GitHub hosting only, and only after you open the dialog
32
32
  - **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
33
33
  - **One-click install** — confirm the source, watch live progress; most plugins go live after a page refresh, no restart
@@ -37,9 +37,10 @@ Restart `dsh web`, then open **Settings → Plugin Market**.
37
37
  - **Hot disable / enable** — toggles write `- id: …` + `disabled: true|false` into the profile's `cordis.patch.yml` (the official patch layer, mechanism ported from [dsh-plugin-hub](https://github.com/Noob-stupid/dsh-plugin-hub)): DSH's HMR re-composes within ~1s, no restart, and the loader re-applies the choice on every boot; hand-edited patch rows show as badges, host-infrastructure plugins are protected from toggling, and a malformed patch file is never made worse
38
38
  - **Restart when needed** — changes that cannot hot-load show a one-click restart beside the pending-change banner; the action is restricted to same-origin loopback requests
39
39
  - **Zero jargon** — if a component is missing (pnpm), the market detects it and offers a one-click automatic setup
40
- - **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)
40
+ - **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
41
41
  - **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
42
42
 
43
+ - **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
43
44
  - **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
44
45
 
45
46
  ## Speed
package/README.zh.md CHANGED
@@ -27,16 +27,20 @@ dsh plugin --profile web add dshmarket
27
27
 
28
28
  ## 你会得到
29
29
 
30
- - **逛与搜**——完整社区目录(800+ 插件,每天在涨),分类筛选、star 数、最热/最新排序,中英描述跟随界面语言
30
+ - **逛与搜**——完整社区目录(1200+ 插件,每天在涨),分类筛选、star 数、最热/最新排序,中英描述跟随界面语言
31
31
  - **截图展示**——安装弹窗内 App Store 式截图:作者可在 registry 里策展,没有则自动从 README 抽取;图片仅从 GitHub 图床加载,且只在你打开弹窗后才发请求
32
32
  - **主题**——独立主题页:装完立即生效,点一下切换(主题互斥、选择跨重启保留),卸载即恢复
33
33
  - **一键安装**——确认来源,实时进度;多数插件刷新页面即可用,无需重启
34
34
  - **备份与恢复**——把 profile 的插件清单与配置导出为可读 JSON,换机导入,存到 WebDAV 并每日自动备份,或通过私有 GitHub Gist 跨机器同步;恢复采用**合并**方式(备份之后新装的插件会保留),写入前校验、失败自动回滚
35
35
  - **更新**——逐插件检测(npm 版本或锁定 commit 对比 HEAD),一键更新或全部更新;市场自己也走同一通道升级
36
36
  - **卸载**——两步确认防误触;本次会话装的插件即点即卸
37
+ - **热禁用 / 启用**——开关会往 profile 的 `cordis.patch.yml`(官方补丁层,机制移植自 [dsh-plugin-hub](https://github.com/Noob-stupid/dsh-plugin-hub))写入 `- id: …` + `disabled: true|false`:DSH 的 HMR 约 1 秒内重新组合,无需重启,loader 每次启动都会重新应用这个选择;手工改过的补丁行会显示成徽标,宿主基础设施插件禁止开关,补丁文件格式不对时绝不会被写得更糟
37
38
  - **按需重启**——无法热加载的变更会在待重启提示旁显示一键重启;操作仅接受本机同源请求
38
39
  - **零术语**——缺组件(pnpm)时市场自己发现、一键自动装好,全程不见命令行
39
- - **导出日志**——一键生成脱敏纯文本日志方便反馈(home 路径与密钥形状已打码;任何数据都不会被上传)
40
+ - **导出日志**——一键生成脱敏纯文本日志方便反馈(home 路径与密钥形状已打码;任何数据都不会被上传)。市场版本号就在标题旁边,截图反馈时自带版本信息
41
+ - **诊断**——插件加载顺序与冲突一页看全:bundle 栈(官方/社区徽标)、重复的 loader 条目、依赖版本不一致、核心包多版本共存、覆盖项与非法配置条目。术语说人话,问题块高亮,全部可折叠
42
+ - **加载顺序**——拖拽调整社区 bundle 的顺序,或直接采用按插件自身 before/after 规则推导出的建议顺序。写入前先跑一次静态组合校验,通过才落盘;应用之前面板会告诉你这个顺序会改变什么(多少处覆盖、多少无效或重复条目)
43
+ - **AI 修复**——一键把诊断结果生成的修复 prompt(错误/警告/顺序冲突 + 保守的改动范围约束)复制到剪贴板,你粘进新对话,自己决定发不发
40
44
 
41
45
  ## 速度
42
46
 
package/client/client.js CHANGED
@@ -919,146 +919,146 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
919
919
  document.head.appendChild(tag);
920
920
  }
921
921
  var Market_module_css_default = {
922
- "switchKnob": "eGUBIq_switchKnob",
923
- "warnLine": "eGUBIq_warnLine",
924
- "pageInfo": "eGUBIq_pageInfo",
925
- "sp": "eGUBIq_sp",
926
- "ovFrom": "eGUBIq_ovFrom",
927
- "backupCheckList": "eGUBIq_backupCheckList",
928
- "pageEllipsis": "eGUBIq_pageEllipsis",
929
- "barFill": "eGUBIq_barFill",
930
- "tabSearch": "eGUBIq_tabSearch",
931
- "switchOn": "eGUBIq_switchOn",
932
- "modalNote": "eGUBIq_modalNote",
933
- "diagVal": "eGUBIq_diagVal",
934
- "assignRow": "eGUBIq_assignRow",
935
- "collapseHead": "eGUBIq_collapseHead",
922
+ "on": "eGUBIq_on",
923
+ "diagBadgeOfficial": "eGUBIq_diagBadgeOfficial",
924
+ "shots": "eGUBIq_shots",
925
+ "tab": "eGUBIq_tab",
926
+ "backupCard": "eGUBIq_backupCard",
927
+ "spin": "eGUBIq_spin",
928
+ "ovByTag": "eGUBIq_ovByTag",
936
929
  "spec": "eGUBIq_spec",
937
- "orphRow": "eGUBIq_orphRow",
938
- "srcBtn": "eGUBIq_srcBtn",
930
+ "inlineInput": "eGUBIq_inlineInput",
931
+ "star": "eGUBIq_star",
932
+ "backupWarn": "eGUBIq_backupWarn",
933
+ "specTagGit": "eGUBIq_specTagGit",
934
+ "groupHead": "eGUBIq_groupHead",
935
+ "collapseIcon": "eGUBIq_collapseIcon",
939
936
  "card": "eGUBIq_card",
940
- "backupActions": "eGUBIq_backupActions",
941
- "diagBadgeShadow": "eGUBIq_diagBadgeShadow",
942
937
  "dragging": "eGUBIq_dragging",
943
- "tag": "eGUBIq_tag",
944
- "diagList": "eGUBIq_diagList",
945
- "diagBadgeOfficial": "eGUBIq_diagBadgeOfficial",
946
- "grow": "eGUBIq_grow",
947
- "actWarn": "eGUBIq_actWarn",
948
- "inlineInput": "eGUBIq_inlineInput",
949
- "spin": "eGUBIq_spin",
950
- "panelNote": "eGUBIq_panelNote",
951
- "collapseTitle": "eGUBIq_collapseTitle",
952
- "root": "eGUBIq_root",
953
- "groupName": "eGUBIq_groupName",
938
+ "tabSearchRow": "eGUBIq_tabSearchRow",
939
+ "head": "eGUBIq_head",
940
+ "barWave": "eGUBIq_barWave",
941
+ "owner": "eGUBIq_owner",
942
+ "groupMember": "eGUBIq_groupMember",
943
+ "cmd": "eGUBIq_cmd",
944
+ "bannerIcon": "eGUBIq_bannerIcon",
945
+ "swatches": "eGUBIq_swatches",
946
+ "ovFrom": "eGUBIq_ovFrom",
954
947
  "sub": "eGUBIq_sub",
955
- "irow": "eGUBIq_irow",
956
- "groupMembers": "eGUBIq_groupMembers",
957
- "diagBadgeCommunity": "eGUBIq_diagBadgeCommunity",
958
- "backupWarn": "eGUBIq_backupWarn",
959
- "diagIndex": "eGUBIq_diagIndex",
960
- "diagArrow": "eGUBIq_diagArrow",
961
- "viewBar": "eGUBIq_viewBar",
948
+ "dragHandle": "eGUBIq_dragHandle",
949
+ "diagPage": "eGUBIq_diagPage",
950
+ "titleRow": "eGUBIq_titleRow",
951
+ "tabs": "eGUBIq_tabs",
952
+ "diagRow": "eGUBIq_diagRow",
962
953
  "sectionOverview": "eGUBIq_sectionOverview",
954
+ "specTag": "eGUBIq_specTag",
955
+ "switch": "eGUBIq_switch",
956
+ "collapseBody": "eGUBIq_collapseBody",
963
957
  "orphBadge": "eGUBIq_orphBadge",
964
- "bannerHint": "eGUBIq_bannerHint",
965
- "empty": "eGUBIq_empty",
966
- "act": "eGUBIq_act",
967
- "groupActions": "eGUBIq_groupActions",
968
- "sectAction": "eGUBIq_sectAction",
969
- "diagEmpty": "eGUBIq_diagEmpty",
970
- "swatches": "eGUBIq_swatches",
971
- "dragHandle": "eGUBIq_dragHandle",
972
- "viewOn": "eGUBIq_viewOn",
973
- "cmd": "eGUBIq_cmd",
958
+ "backupMessage": "eGUBIq_backupMessage",
959
+ "diagBadgeShadow": "eGUBIq_diagBadgeShadow",
960
+ "bar": "eGUBIq_bar",
961
+ "backupCheckList": "eGUBIq_backupCheckList",
974
962
  "groupAddPanel": "eGUBIq_groupAddPanel",
975
- "assignSelect": "eGUBIq_assignSelect",
976
- "collapseIcon": "eGUBIq_collapseIcon",
977
- "ovByTag": "eGUBIq_ovByTag",
978
- "diagKey": "eGUBIq_diagKey",
979
- "tab": "eGUBIq_tab",
980
- "backupGrid": "eGUBIq_backupGrid",
981
- "cats": "eGUBIq_cats",
963
+ "descTight": "eGUBIq_descTight",
964
+ "av": "eGUBIq_av",
982
965
  "catsRow": "eGUBIq_catsRow",
983
- "actWhy": "eGUBIq_actWhy",
984
- "deprecate": "eGUBIq_deprecate",
985
- "nm": "eGUBIq_nm",
986
- "groupHint": "eGUBIq_groupHint",
987
- "diagRow": "eGUBIq_diagRow",
988
- "switch": "eGUBIq_switch",
989
- "groupHead": "eGUBIq_groupHead",
990
- "dot": "eGUBIq_dot",
991
- "head": "eGUBIq_head",
992
- "top": "eGUBIq_top",
993
- "hiddenFile": "eGUBIq_hiddenFile",
994
- "backupInput": "eGUBIq_backupInput",
995
- "grid": "eGUBIq_grid",
996
- "irowMissing": "eGUBIq_irowMissing",
997
966
  "okState": "eGUBIq_okState",
998
- "diagPage": "eGUBIq_diagPage",
999
- "dshmSlide": "eGUBIq_dshmSlide",
1000
- "diagSection": "eGUBIq_diagSection",
1001
- "err": "eGUBIq_err",
1002
- "tabSearchRow": "eGUBIq_tabSearchRow",
967
+ "grow": "eGUBIq_grow",
968
+ "pager": "eGUBIq_pager",
1003
969
  "depBadge": "eGUBIq_depBadge",
1004
- "catsWrap": "eGUBIq_catsWrap",
1005
- "av": "eGUBIq_av",
1006
- "specTag": "eGUBIq_specTag",
1007
- "ovRow": "eGUBIq_ovRow",
1008
- "groupCreate": "eGUBIq_groupCreate",
970
+ "groupRow": "eGUBIq_groupRow",
971
+ "act": "eGUBIq_act",
972
+ "version": "eGUBIq_version",
973
+ "viewBar": "eGUBIq_viewBar",
974
+ "switchKnob": "eGUBIq_switchKnob",
975
+ "groupName": "eGUBIq_groupName",
976
+ "diagBundle": "eGUBIq_diagBundle",
977
+ "panelNote": "eGUBIq_panelNote",
978
+ "tag": "eGUBIq_tag",
979
+ "diagSummaryItem": "eGUBIq_diagSummaryItem",
1009
980
  "body": "eGUBIq_body",
1010
- "foot": "eGUBIq_foot",
1011
- "on": "eGUBIq_on",
1012
- "fixFallback": "eGUBIq_fixFallback",
1013
- "themesGrid": "eGUBIq_themesGrid",
1014
- "dragOver": "eGUBIq_dragOver",
1015
- "topBtn": "eGUBIq_topBtn",
1016
- "diagSummary": "eGUBIq_diagSummary",
1017
- "loading": "eGUBIq_loading",
1018
- "descTight": "eGUBIq_descTight",
981
+ "progress": "eGUBIq_progress",
982
+ "catsCollapsed": "eGUBIq_catsCollapsed",
1019
983
  "row1": "eGUBIq_row1",
984
+ "dot": "eGUBIq_dot",
985
+ "ovRow": "eGUBIq_ovRow",
986
+ "specTagFile": "eGUBIq_specTagFile",
987
+ "root": "eGUBIq_root",
988
+ "cats": "eGUBIq_cats",
989
+ "pct": "eGUBIq_pct",
1020
990
  "desc": "eGUBIq_desc",
1021
- "banner": "eGUBIq_banner",
1022
- "owner": "eGUBIq_owner",
1023
- "staleAction": "eGUBIq_staleAction",
991
+ "switchOn": "eGUBIq_switchOn",
992
+ "backupInput": "eGUBIq_backupInput",
1024
993
  "actLive": "eGUBIq_actLive",
1025
- "pager": "eGUBIq_pager",
1026
- "diagBundle": "eGUBIq_diagBundle",
994
+ "assignRow": "eGUBIq_assignRow",
995
+ "groupMembers": "eGUBIq_groupMembers",
996
+ "groupCreate": "eGUBIq_groupCreate",
1027
997
  "shot": "eGUBIq_shot",
1028
- "pct": "eGUBIq_pct",
998
+ "diagSummaryMeta": "eGUBIq_diagSummaryMeta",
1029
999
  "diagMeta": "eGUBIq_diagMeta",
1030
- "specTagGit": "eGUBIq_specTagGit",
1000
+ "viewOn": "eGUBIq_viewOn",
1001
+ "ovArrow": "eGUBIq_ovArrow",
1002
+ "diagVal": "eGUBIq_diagVal",
1003
+ "staleAction": "eGUBIq_staleAction",
1004
+ "collapseHead": "eGUBIq_collapseHead",
1005
+ "dshmSlide": "eGUBIq_dshmSlide",
1006
+ "bannerHint": "eGUBIq_bannerHint",
1007
+ "dragOver": "eGUBIq_dragOver",
1008
+ "diagIndex": "eGUBIq_diagIndex",
1009
+ "actWarn": "eGUBIq_actWarn",
1010
+ "diagAlert": "eGUBIq_diagAlert",
1011
+ "backupGrid": "eGUBIq_backupGrid",
1012
+ "empty": "eGUBIq_empty",
1013
+ "diagBadgeCommunity": "eGUBIq_diagBadgeCommunity",
1014
+ "top": "eGUBIq_top",
1015
+ "fixFallback": "eGUBIq_fixFallback",
1016
+ "diagList": "eGUBIq_diagList",
1017
+ "srcBtn": "eGUBIq_srcBtn",
1018
+ "tabSearch": "eGUBIq_tabSearch",
1019
+ "orphRow": "eGUBIq_orphRow",
1020
+ "diagSummary": "eGUBIq_diagSummary",
1031
1021
  "fixFallbackText": "eGUBIq_fixFallbackText",
1032
- "groupRow": "eGUBIq_groupRow",
1022
+ "diagEmpty": "eGUBIq_diagEmpty",
1023
+ "modalNote": "eGUBIq_modalNote",
1024
+ "grid": "eGUBIq_grid",
1025
+ "nm": "eGUBIq_nm",
1026
+ "depLine": "eGUBIq_depLine",
1027
+ "switchMixed": "eGUBIq_switchMixed",
1033
1028
  "src": "eGUBIq_src",
1034
- "diagCount": "eGUBIq_diagCount",
1029
+ "irowMissing": "eGUBIq_irowMissing",
1030
+ "catsToggle": "eGUBIq_catsToggle",
1031
+ "barFill": "eGUBIq_barFill",
1032
+ "assignSelect": "eGUBIq_assignSelect",
1033
+ "collapseTitle": "eGUBIq_collapseTitle",
1034
+ "groupHint": "eGUBIq_groupHint",
1035
+ "actWhy": "eGUBIq_actWhy",
1036
+ "loading": "eGUBIq_loading",
1037
+ "backupCheck": "eGUBIq_backupCheck",
1038
+ "diagSection": "eGUBIq_diagSection",
1039
+ "topBtn": "eGUBIq_topBtn",
1040
+ "banner": "eGUBIq_banner",
1041
+ "catsWrap": "eGUBIq_catsWrap",
1042
+ "themesGrid": "eGUBIq_themesGrid",
1043
+ "irow": "eGUBIq_irow",
1044
+ "groupActions": "eGUBIq_groupActions",
1035
1045
  "actBroken": "eGUBIq_actBroken",
1036
- "backupCard": "eGUBIq_backupCard",
1037
- "diagAlert": "eGUBIq_diagAlert",
1038
- "catsCollapsed": "eGUBIq_catsCollapsed",
1039
- "barWave": "eGUBIq_barWave",
1040
- "switchMixed": "eGUBIq_switchMixed",
1041
- "star": "eGUBIq_star",
1042
- "depLine": "eGUBIq_depLine",
1046
+ "title": "eGUBIq_title",
1047
+ "backupActions": "eGUBIq_backupActions",
1048
+ "hiddenFile": "eGUBIq_hiddenFile",
1049
+ "pageEllipsis": "eGUBIq_pageEllipsis",
1050
+ "sectAction": "eGUBIq_sectAction",
1051
+ "deprecate": "eGUBIq_deprecate",
1052
+ "diagArrow": "eGUBIq_diagArrow",
1053
+ "sp": "eGUBIq_sp",
1054
+ "diagCount": "eGUBIq_diagCount",
1043
1055
  "viewBtn": "eGUBIq_viewBtn",
1044
- "backupMessage": "eGUBIq_backupMessage",
1045
- "progress": "eGUBIq_progress",
1056
+ "diagKey": "eGUBIq_diagKey",
1057
+ "err": "eGUBIq_err",
1046
1058
  "pagerPages": "eGUBIq_pagerPages",
1047
- "shots": "eGUBIq_shots",
1048
- "ovArrow": "eGUBIq_ovArrow",
1049
- "tabs": "eGUBIq_tabs",
1050
- "bar": "eGUBIq_bar",
1051
- "collapseBody": "eGUBIq_collapseBody",
1052
- "diagSummaryMeta": "eGUBIq_diagSummaryMeta",
1053
- "title": "eGUBIq_title",
1054
- "titleRow": "eGUBIq_titleRow",
1055
- "diagSummaryItem": "eGUBIq_diagSummaryItem",
1056
- "backupCheck": "eGUBIq_backupCheck",
1057
- "specTagFile": "eGUBIq_specTagFile",
1058
- "groupMember": "eGUBIq_groupMember",
1059
- "catsToggle": "eGUBIq_catsToggle",
1060
- "version": "eGUBIq_version",
1061
- "bannerIcon": "eGUBIq_bannerIcon"
1059
+ "warnLine": "eGUBIq_warnLine",
1060
+ "pageInfo": "eGUBIq_pageInfo",
1061
+ "foot": "eGUBIq_foot"
1062
1062
  };
1063
1063
  //#endregion
1064
1064
  //#region src/client/Diagnostics.tsx
package/lib/hot.js CHANGED
@@ -93,7 +93,13 @@ function readPkgDsh(profileDir, packageName) {
93
93
  export function parseSimplePatch(patchText) {
94
94
  const rows = [];
95
95
  let pending = null;
96
- for (const raw of patchText.split('\n')) {
96
+ // Split on CRLF too. `#.*$` cannot strip a comment that ends in \r
97
+ // JS treats \r as a line terminator, so `.` will not cross it and `$`
98
+ // only anchors at the very end — leaving the comment text in place,
99
+ // matching none of the row shapes, and failing the whole patch. Any
100
+ // plugin whose patch was authored on Windows then reads as
101
+ // "contains config/expression rows" and can never hot-mount.
102
+ for (const raw of patchText.split(/\r?\n/)) {
97
103
  const line = raw.replace(/#.*$/, '').trimEnd();
98
104
  if (line.trim() === '')
99
105
  continue;
@@ -386,7 +392,7 @@ export function readUserPatchControls(profileDir) {
386
392
  const names = new Set();
387
393
  try {
388
394
  const text = readFileSync(join(profileDir, 'cordis.patch.yml'), 'utf8');
389
- for (const line of text.split('\n')) {
395
+ for (const line of text.split(/\r?\n/)) {
390
396
  const id = /^\s*-?\s*id:\s*['"]?([A-Za-z0-9._/@-]+)/.exec(line);
391
397
  if (id !== null)
392
398
  ids.add(id[1]);
package/lib/patch.js CHANGED
@@ -29,7 +29,7 @@ import { join } from 'node:path';
29
29
  import { fileURLToPath } from 'node:url';
30
30
  import { logEvent } from './log.js';
31
31
  import { parsePatchFile } from './check.js';
32
- import { bundlePatchInsertedIds } from './profile.js';
32
+ import { bundlePatchInsertedIds, parsePatchRows } from './profile.js';
33
33
  /**
34
34
  * Host infrastructure rows: disabling any of these breaks the very chain
35
35
  * the patch layer runs on (e.g. timer → HMR, webserver → the page itself),
@@ -189,25 +189,13 @@ export function rowIdsForPackage(host, profileDirectory, packageName) {
189
189
  catch { /* package not installed — loader side may still know it */ }
190
190
  // The conventional location too: a package may ship cordis.patch.yml at
191
191
  // its root without declaring dsh.bundle.patch (the loader probes it too).
192
- // Same rule only ids nested under an `insert:` key.
192
+ // Same parser, deliberately: this used to be a second hand-rolled scan
193
+ // that closed the insert block only on `id:` lines, so `- disable:` with
194
+ // nested ids under it claimed the neighbour's rows — #147 all over again
195
+ // on the path #147 did not touch.
193
196
  try {
194
- let insertIndent = null;
195
- for (const raw of readFileSync(join(packageDir, 'cordis.patch.yml'), 'utf8').split('\n')) {
196
- const line = raw.replace(/#.*$/u, '');
197
- if (line.trim() === '')
198
- continue;
199
- const indent = line.length - line.trimStart().length;
200
- if (/^\s*-?\s*insert:\s*$/u.test(line)) {
201
- insertIndent = indent;
202
- continue;
203
- }
204
- const id = /^\s*-?\s*id:\s*['"]?([A-Za-z0-9_.-]+)/u.exec(line);
205
- if (id === null)
206
- continue;
207
- if (insertIndent !== null && indent > insertIndent)
208
- ids.add(id[1]);
209
- else
210
- insertIndent = null;
197
+ for (const id of parsePatchRows(readFileSync(join(packageDir, 'cordis.patch.yml'), 'utf8')).insertedIds) {
198
+ ids.add(id);
211
199
  }
212
200
  }
213
201
  catch { /* no conventional patch — nothing more to add */ }
package/lib/profile.js CHANGED
@@ -205,27 +205,30 @@ export function bundlePatchInsertedIds(dir) {
205
205
  * but for "what does this bundle bring in" any row counts. `insertedIds` is
206
206
  * the subset nested under an `insert:` key (#147).
207
207
  */
208
- function readBundlePatchRows(dir) {
209
- let patchPath;
210
- try {
211
- const manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
212
- const declared = manifest.dsh?.bundle?.patch;
213
- if (typeof declared !== 'string' || declared === '')
214
- return { names: [], ids: [], insertedIds: [] };
215
- patchPath = join(dir, declared);
216
- }
217
- catch {
218
- return { names: [], ids: [], insertedIds: [] };
219
- }
208
+ /**
209
+ * Rows of one patch file. Exported because a package may ship its patch at
210
+ * the conventional path INSTEAD of declaring `dsh.bundle.patch`, and the
211
+ * patch layer has to read that one by the same rules — a second hand-rolled
212
+ * scan drifted from this one and re-introduced #147 on that path (it closed
213
+ * the insert block only on `id:` lines, so `- disable:` followed by nested
214
+ * ids claimed the neighbour's rows).
215
+ */
216
+ export function parsePatchRows(text) {
220
217
  const names = [];
221
218
  const ids = [];
222
219
  const insertedIds = [];
223
- try {
220
+ {
224
221
  // `insert:` opens a nested list; every id below it, at deeper
225
222
  // indentation, is a row this package brings in. A row at or above the
226
223
  // `insert:` indentation closes the block — those target OTHER plugins.
227
224
  let insertIndent = null;
228
- for (const raw of readFileSync(patchPath, 'utf8').split('\n')) {
225
+ // CRLF too, for consistency with the hot-mount parser, which a
226
+ // Windows-authored patch genuinely broke. Here it changes no outcome
227
+ // today — every row pattern below is `^`-anchored and a comment line
228
+ // always starts with `#`, so an unstripped comment matches nothing —
229
+ // and it is deliberately NOT covered by a spec, because a test that
230
+ // passes with or without the change tests nothing.
231
+ for (const raw of text.split(/\r?\n/)) {
229
232
  const line = raw.replace(/#.*$/, '');
230
233
  if (line.trim() === '')
231
234
  continue;
@@ -256,9 +259,22 @@ function readBundlePatchRows(dir) {
256
259
  }
257
260
  }
258
261
  }
259
- catch { /* unreadable patch — nothing to report */ }
260
262
  return { names, ids, insertedIds };
261
263
  }
264
+ /** Rows of the patch a package DECLARES through `dsh.bundle.patch`. */
265
+ function readBundlePatchRows(dir) {
266
+ const empty = { names: [], ids: [], insertedIds: [] };
267
+ try {
268
+ const manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
269
+ const declared = manifest.dsh?.bundle?.patch;
270
+ if (typeof declared !== 'string' || declared === '')
271
+ return empty;
272
+ return parsePatchRows(readFileSync(join(dir, declared), 'utf8'));
273
+ }
274
+ catch {
275
+ return empty;
276
+ }
277
+ }
262
278
  /** The profile manifest's `dsh.profile.bundles` — what the CLI reconciled. */
263
279
  export function readProfileBundles(profileDirectory) {
264
280
  try {
@@ -397,7 +413,7 @@ export function setAllowBuilds(profile, packages, explicitDir) {
397
413
  const map = {};
398
414
  const blockMatch = blockRe.exec(yaml);
399
415
  if (blockMatch !== null) {
400
- for (const line of blockMatch[1].split('\n')) {
416
+ for (const line of blockMatch[1].split(/\r?\n/)) {
401
417
  // The key itself may contain colons: git-hosted deps are only matched
402
418
  // by a `name@git+https://…` key (#68). The anchored boolean tail makes
403
419
  // the split land on the LAST colon, never inside a `://` — and doubles
package/lib/routes.js CHANGED
@@ -15,7 +15,7 @@ import { createGroup, deleteGroup, removeFromGroups, renameGroup, setGroupMember
15
15
  import { exportLogs, logEvent } from './log.js';
16
16
  import { diagnosePackageManifests } from './diagnostics.js';
17
17
  import { BOOT_ID, cancelActive, probePnpm, progress, provisionPnpm, runDshPlugin, } from './dsh-cli.js';
18
- import { INBOX_BUNDLES, profileDir, readInstalled, readInstalledManifest, readInstalledVersion, readLockCommits, readManifestDeps, readProfileBundles, restoreManifestDeps, setAllowBuilds } from './profile.js';
18
+ import { hasLoadableEntry, INBOX_BUNDLES, profileDir, readInstalled, readInstalledManifest, readInstalledVersion, readLockCommits, readManifestDeps, readProfileBundles, restoreManifestDeps, setAllowBuilds } from './profile.js';
19
19
  import { analyzeProfile } from './check.js';
20
20
  import { applyBundleOrder, mergeOrder, readBundleRules, readBundleStack, validateOrder } from './order.js';
21
21
  import { trialValidate } from './trial.js';
@@ -238,8 +238,24 @@ export function mountMarketRoutes(host, config, commandRuntime) {
238
238
  function liveNames() {
239
239
  const live = new Set(listHotMounts());
240
240
  for (const entry of host.loader.entries()) {
241
- if (entry.fiber !== undefined && entry.options.name !== undefined)
241
+ if (entry.fiber === undefined)
242
+ continue;
243
+ if (entry.options.name !== undefined)
242
244
  live.add(entry.options.name);
245
+ // Entry IDS too, under a `#` prefix that cannot collide with a package
246
+ // name. A CARRIER bundle's row names the package it mounts, not
247
+ // itself (#156: @tt-a1i/archify-dsh inserts an entry named
248
+ // @deepseek-ai/dsh-skill-filesystem), so its own name never appears
249
+ // here — but the id it created does, and that id is unique to its
250
+ // patch. Verification needs both, and putting them in one set means
251
+ // no call site can pass the names and forget the ids.
252
+ if (entry.options.id !== undefined && entry.options.id !== '') {
253
+ live.add(`#${entry.options.id}`);
254
+ // Loader ids may carry an include prefix (`include:archify-…`).
255
+ const bare = entry.options.id.split(':').pop();
256
+ if (bare !== undefined && bare !== entry.options.id)
257
+ live.add(`#${bare}`);
258
+ }
243
259
  }
244
260
  return live;
245
261
  }
@@ -1056,6 +1072,24 @@ export function mountMarketRoutes(host, config, commandRuntime) {
1056
1072
  if (stale)
1057
1073
  ok = false;
1058
1074
  }
1075
+ // The new build has to be loadable (#159). pnpm exits 0 for any
1076
+ // tarball it can extract, and the version really did change, so
1077
+ // nothing above notices a package that arrived without its entry
1078
+ // artifact — a registry mirror serving a source-only tarball for
1079
+ // a just-published version is the reported case, a plugin author
1080
+ // shipping a broken `files` list is the other one.
1081
+ //
1082
+ // Activation cannot stand in for this check: a package updating
1083
+ // ITSELF still reports live, because the running fiber belongs to
1084
+ // the OLD code that is already in memory. The failure only
1085
+ // surfaces on the next boot, as a profile that will not start.
1086
+ let brokenEntry = false;
1087
+ if (ok && !hasLoadableEntry(activeProfileDir, name)) {
1088
+ brokenEntry = true;
1089
+ ok = false;
1090
+ const rolledBack = restoreManifestDeps(config.profile, manifestBefore, activeProfileDir);
1091
+ logEvent('error', 'update', `${name}: updated build has no loadable entry — rolled back the pin${rolledBack.length > 0 ? ` (${rolledBack.join(', ')})` : ''}`);
1092
+ }
1059
1093
  if (ok) {
1060
1094
  invalidateUpdates();
1061
1095
  activation = { [name]: verifyActivation(config.profile, name, liveNames(), activeProfileDir, disabled.has(name)) };
@@ -1071,6 +1105,11 @@ export function mountMarketRoutes(host, config, commandRuntime) {
1071
1105
  : staleReason === 'release-age'
1072
1106
  ? '这个新版本刚发布不久。为了安全,系统默认会等它发布满一天后再安装——刚发布的版本偶尔会被发现问题然后撤回。可以明天再试,或点「立即更新」不再等待。 / This version was just released; for safety, installs normally wait about a day after a release. Try again tomorrow, or click "Update now" to install it right away.'
1073
1107
  : '更新命令执行完成,但版本没有变化,原因未能确认。点「立即更新」重试通常能解决;若仍不行,请导出日志反馈。 / The update command completed but the version did not change; the cause could not be confirmed. Clicking "Update now" to retry usually resolves it — if not, export the log and report it.';
1108
+ // Actionable, because the user's own recovery is the right one:
1109
+ // the bad artifact is cached under its integrity hash, so a plain
1110
+ // re-add reuses it — the package has to be removed first.
1111
+ const brokenEntryError = !brokenEntry ? null
1112
+ : `${name} 更新后缺少入口文件(package.json 的 main/exports 指向的文件不存在),已回滚版本号以免下次启动失败。这通常是镜像源在新版本刚发布时同步不完整。请先卸载再从官方源重装:dsh plugin --profile ${config.profile} remove ${name} 然后 add ${name} --registry=https://registry.npmjs.org / ${name} arrived without the entry file its package.json points at; the version pin was rolled back so the next boot still works. A registry mirror serving an incomplete tarball for a just-published version is the usual cause. Remove it and reinstall from the official registry — a plain retry reuses the cached bad artifact.`;
1074
1113
  const cancelDiff = cancelled ? changedSince(beforeInstalled) : null;
1075
1114
  // Build-script blocks hit updates too (#69): a leftover invalid
1076
1115
  // allowBuilds entry (pnpm's placeholder bug, #56) or a newly
@@ -1090,7 +1129,7 @@ export function mountMarketRoutes(host, config, commandRuntime) {
1090
1129
  activation,
1091
1130
  ignoredBuilds,
1092
1131
  staleReason: staleReason ?? undefined,
1093
- error: staleError ?? undefined,
1132
+ error: brokenEntryError ?? staleError ?? undefined,
1094
1133
  exitCode: result.exitCode,
1095
1134
  timedOut: result.timedOut,
1096
1135
  stdout: result.stdout,
@@ -82,6 +82,25 @@ export declare function bundlePatchEntryIds(dir: string): string[];
82
82
  * the DeepSeek model with it.
83
83
  */
84
84
  export declare function bundlePatchInsertedIds(dir: string): string[];
85
+ /**
86
+ * `name:` and `id:` rows of the package's declared bundle patch. Line-wise
87
+ * on purpose: the strict hot-mount parser rejects config/expression rows,
88
+ * but for "what does this bundle bring in" any row counts. `insertedIds` is
89
+ * the subset nested under an `insert:` key (#147).
90
+ */
91
+ /**
92
+ * Rows of one patch file. Exported because a package may ship its patch at
93
+ * the conventional path INSTEAD of declaring `dsh.bundle.patch`, and the
94
+ * patch layer has to read that one by the same rules — a second hand-rolled
95
+ * scan drifted from this one and re-introduced #147 on that path (it closed
96
+ * the insert block only on `id:` lines, so `- disable:` followed by nested
97
+ * ids claimed the neighbour's rows).
98
+ */
99
+ export declare function parsePatchRows(text: string): {
100
+ names: string[];
101
+ ids: string[];
102
+ insertedIds: string[];
103
+ };
85
104
  /** The profile manifest's `dsh.profile.bundles` — what the CLI reconciled. */
86
105
  export declare function readProfileBundles(profileDirectory: string): string[];
87
106
  /**
package/lib/verify.js CHANGED
@@ -26,7 +26,7 @@
26
26
  import { readFileSync } from 'node:fs';
27
27
  import { join } from 'node:path';
28
28
  import { listHotMounts, parseSimplePatch } from './hot.js';
29
- import { hasDshManifest, hasLoadableEntry, profileDir } from './profile.js';
29
+ import { bundlePatchInsertedIds, hasDshManifest, hasLoadableEntry, profileDir } from './profile.js';
30
30
  /** The profile manifest's `dsh.profile.bundles` — what the CLI reconciled. */
31
31
  function readBundles(profile, explicitDir) {
32
32
  try {
@@ -58,6 +58,28 @@ function liveIncludes(live, packageName) {
58
58
  return true;
59
59
  return false;
60
60
  }
61
+ /**
62
+ * True when a loader entry this package's OWN bundle patch inserts is up.
63
+ *
64
+ * A carrier bundle (#103) ships no plugin of its own: its patch mounts
65
+ * ANOTHER package with configuration, so the live entry carries that other
66
+ * package's name and `liveIncludes` can never match. The entry ID is the
67
+ * part that belongs to this package — its patch created it — which is why
68
+ * matching on it is both sufficient and precise: a neighbour that happens
69
+ * to mount the same package does so under a different id.
70
+ *
71
+ * Without this the market kept telling users to restart for a plugin that
72
+ * had been running since the restart (#156).
73
+ */
74
+ function carriedRowLive(live, profileDirectory, packageName) {
75
+ try {
76
+ return bundlePatchInsertedIds(join(profileDirectory, 'node_modules', packageName))
77
+ .some(id => live.has(`#${id}`));
78
+ }
79
+ catch {
80
+ return false;
81
+ }
82
+ }
61
83
  function readPkgDsh(profile, name, explicitDir) {
62
84
  try {
63
85
  const manifest = JSON.parse(readFileSync(join(profileDir(profile, explicitDir), 'node_modules', name, 'package.json'), 'utf8'));
@@ -106,7 +128,7 @@ export function verifyActivation(profile, name, live = new Set(listHotMounts()),
106
128
  // loaded by name from a bundle patch — @deepseek-ai/dsh-tools is loaded by
107
129
  // the official dsh-base patch and has no `dsh` field at all — so this check
108
130
  // has to come before any manifest-based verdict.
109
- const loaderLive = liveIncludes(live, name);
131
+ const loaderLive = liveIncludes(live, name) || carriedRowLive(live, activeProfileDir, name);
110
132
  if (!hasDshManifest(dir)) {
111
133
  if (loaderLive) {
112
134
  return {
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.11.2",
4
+ "version": "1.11.3",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/types/index.d.ts",
@@ -21,7 +21,7 @@
21
21
  "plugin-manager"
22
22
  ],
23
23
  "scripts": {
24
- "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.client.json --noEmit",
24
+ "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.client.json --noEmit && tsc -p tsconfig.tests.json --noEmit",
25
25
  "build": "tsc -p tsconfig.json && npm run build:client",
26
26
  "build:client": "tsdown && node scripts/normalize-client-banner.mjs",
27
27
  "check": "npm run typecheck && npm run build && node scripts/restart-smoke.mjs",
package/src/hot.ts CHANGED
@@ -110,7 +110,13 @@ function readPkgDsh(profileDir: string, packageName: string): { client?: unknown
110
110
  export function parseSimplePatch(patchText: string): HotRow[] | null {
111
111
  const rows: HotRow[] = []
112
112
  let pending: string | null = null
113
- for (const raw of patchText.split('\n')) {
113
+ // Split on CRLF too. `#.*$` cannot strip a comment that ends in \r
114
+ // JS treats \r as a line terminator, so `.` will not cross it and `$`
115
+ // only anchors at the very end — leaving the comment text in place,
116
+ // matching none of the row shapes, and failing the whole patch. Any
117
+ // plugin whose patch was authored on Windows then reads as
118
+ // "contains config/expression rows" and can never hot-mount.
119
+ for (const raw of patchText.split(/\r?\n/)) {
114
120
  const line = raw.replace(/#.*$/, '').trimEnd()
115
121
  if (line.trim() === '') continue
116
122
  if (/^-\s+insert:\s*$/.test(line)) continue
@@ -428,7 +434,7 @@ export function readUserPatchControls(profileDir: string): { ids: Set<string>; n
428
434
  const names = new Set<string>()
429
435
  try {
430
436
  const text = readFileSync(join(profileDir, 'cordis.patch.yml'), 'utf8')
431
- for (const line of text.split('\n')) {
437
+ for (const line of text.split(/\r?\n/)) {
432
438
  const id = /^\s*-?\s*id:\s*['"]?([A-Za-z0-9._/@-]+)/.exec(line)
433
439
  if (id !== null) ids.add(id[1])
434
440
  const name = /^\s*name:\s*['"]?([^'"\s]+)/.exec(line)
package/src/patch.ts CHANGED
@@ -30,7 +30,7 @@ import { join } from 'node:path'
30
30
  import { fileURLToPath } from 'node:url'
31
31
  import { logEvent } from './log.ts'
32
32
  import { parsePatchFile } from './check.ts'
33
- import { bundlePatchInsertedIds } from './profile.ts'
33
+ import { bundlePatchInsertedIds, parsePatchRows } from './profile.ts'
34
34
 
35
35
  /** The slice of the loader tree this module needs. */
36
36
  export interface PatchHost {
@@ -205,21 +205,13 @@ export function rowIdsForPackage(host: PatchHost, profileDirectory: string, pack
205
205
  } catch { /* package not installed — loader side may still know it */ }
206
206
  // The conventional location too: a package may ship cordis.patch.yml at
207
207
  // its root without declaring dsh.bundle.patch (the loader probes it too).
208
- // Same rule only ids nested under an `insert:` key.
208
+ // Same parser, deliberately: this used to be a second hand-rolled scan
209
+ // that closed the insert block only on `id:` lines, so `- disable:` with
210
+ // nested ids under it claimed the neighbour's rows — #147 all over again
211
+ // on the path #147 did not touch.
209
212
  try {
210
- let insertIndent: number | null = null
211
- for (const raw of readFileSync(join(packageDir, 'cordis.patch.yml'), 'utf8').split('\n')) {
212
- const line = raw.replace(/#.*$/u, '')
213
- if (line.trim() === '') continue
214
- const indent = line.length - line.trimStart().length
215
- if (/^\s*-?\s*insert:\s*$/u.test(line)) {
216
- insertIndent = indent
217
- continue
218
- }
219
- const id = /^\s*-?\s*id:\s*['"]?([A-Za-z0-9_.-]+)/u.exec(line)
220
- if (id === null) continue
221
- if (insertIndent !== null && indent > insertIndent) ids.add(id[1])
222
- else insertIndent = null
213
+ for (const id of parsePatchRows(readFileSync(join(packageDir, 'cordis.patch.yml'), 'utf8')).insertedIds) {
214
+ ids.add(id)
223
215
  }
224
216
  } catch { /* no conventional patch — nothing more to add */ }
225
217
  const prefix = includePrefix(host)
package/src/profile.ts CHANGED
@@ -211,27 +211,30 @@ export function bundlePatchInsertedIds(dir: string): string[] {
211
211
  * but for "what does this bundle bring in" any row counts. `insertedIds` is
212
212
  * the subset nested under an `insert:` key (#147).
213
213
  */
214
- function readBundlePatchRows(dir: string): { names: string[]; ids: string[]; insertedIds: string[] } {
215
- let patchPath: string
216
- try {
217
- const manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as {
218
- dsh?: { bundle?: { patch?: unknown } }
219
- }
220
- const declared = manifest.dsh?.bundle?.patch
221
- if (typeof declared !== 'string' || declared === '') return { names: [], ids: [], insertedIds: [] }
222
- patchPath = join(dir, declared)
223
- } catch {
224
- return { names: [], ids: [], insertedIds: [] }
225
- }
214
+ /**
215
+ * Rows of one patch file. Exported because a package may ship its patch at
216
+ * the conventional path INSTEAD of declaring `dsh.bundle.patch`, and the
217
+ * patch layer has to read that one by the same rules — a second hand-rolled
218
+ * scan drifted from this one and re-introduced #147 on that path (it closed
219
+ * the insert block only on `id:` lines, so `- disable:` followed by nested
220
+ * ids claimed the neighbour's rows).
221
+ */
222
+ export function parsePatchRows(text: string): { names: string[]; ids: string[]; insertedIds: string[] } {
226
223
  const names: string[] = []
227
224
  const ids: string[] = []
228
225
  const insertedIds: string[] = []
229
- try {
226
+ {
230
227
  // `insert:` opens a nested list; every id below it, at deeper
231
228
  // indentation, is a row this package brings in. A row at or above the
232
229
  // `insert:` indentation closes the block — those target OTHER plugins.
233
230
  let insertIndent: number | null = null
234
- for (const raw of readFileSync(patchPath, 'utf8').split('\n')) {
231
+ // CRLF too, for consistency with the hot-mount parser, which a
232
+ // Windows-authored patch genuinely broke. Here it changes no outcome
233
+ // today — every row pattern below is `^`-anchored and a comment line
234
+ // always starts with `#`, so an unstripped comment matches nothing —
235
+ // and it is deliberately NOT covered by a spec, because a test that
236
+ // passes with or without the change tests nothing.
237
+ for (const raw of text.split(/\r?\n/)) {
235
238
  const line = raw.replace(/#.*$/, '')
236
239
  if (line.trim() === '') continue
237
240
  const indent = line.length - line.trimStart().length
@@ -256,10 +259,25 @@ function readBundlePatchRows(dir: string): { names: string[]; ids: string[]; ins
256
259
  }
257
260
  }
258
261
  }
259
- } catch { /* unreadable patch — nothing to report */ }
262
+ }
260
263
  return { names, ids, insertedIds }
261
264
  }
262
265
 
266
+ /** Rows of the patch a package DECLARES through `dsh.bundle.patch`. */
267
+ function readBundlePatchRows(dir: string): { names: string[]; ids: string[]; insertedIds: string[] } {
268
+ const empty = { names: [], ids: [], insertedIds: [] }
269
+ try {
270
+ const manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as {
271
+ dsh?: { bundle?: { patch?: unknown } }
272
+ }
273
+ const declared = manifest.dsh?.bundle?.patch
274
+ if (typeof declared !== 'string' || declared === '') return empty
275
+ return parsePatchRows(readFileSync(join(dir, declared), 'utf8'))
276
+ } catch {
277
+ return empty
278
+ }
279
+ }
280
+
263
281
  /** The profile manifest's `dsh.profile.bundles` — what the CLI reconciled. */
264
282
  export function readProfileBundles(profileDirectory: string): string[] {
265
283
  try {
@@ -396,7 +414,7 @@ export function setAllowBuilds(profile: string, packages: string[], explicitDir?
396
414
  const map: Record<string, string> = {}
397
415
  const blockMatch = blockRe.exec(yaml)
398
416
  if (blockMatch !== null) {
399
- for (const line of blockMatch[1].split('\n')) {
417
+ for (const line of blockMatch[1].split(/\r?\n/)) {
400
418
  // The key itself may contain colons: git-hosted deps are only matched
401
419
  // by a `name@git+https://…` key (#68). The anchored boolean tail makes
402
420
  // the split land on the LAST colon, never inside a `://` — and doubles
package/src/routes.ts CHANGED
@@ -23,7 +23,7 @@ import {
23
23
  BOOT_ID, cancelActive, probePnpm, progress, provisionPnpm, runDshPlugin,
24
24
  type PluginCommandRuntime,
25
25
  } from './dsh-cli.ts'
26
- import { INBOX_BUNDLES, profileDir, readInstalled, readInstalledManifest, readInstalledVersion, readLockCommits, readManifestDeps, readProfileBundles, restoreManifestDeps, setAllowBuilds } from './profile.ts'
26
+ import { hasLoadableEntry, INBOX_BUNDLES, profileDir, readInstalled, readInstalledManifest, readInstalledVersion, readLockCommits, readManifestDeps, readProfileBundles, restoreManifestDeps, setAllowBuilds } from './profile.ts'
27
27
  import { analyzeProfile } from './check.ts'
28
28
  import { applyBundleOrder, mergeOrder, readBundleRules, readBundleStack, validateOrder } from './order.ts'
29
29
  import { trialValidate } from './trial.ts'
@@ -281,7 +281,21 @@ export function mountMarketRoutes(
281
281
  function liveNames(): Set<string> {
282
282
  const live = new Set(listHotMounts())
283
283
  for (const entry of host.loader.entries()) {
284
- if (entry.fiber !== undefined && entry.options.name !== undefined) live.add(entry.options.name)
284
+ if (entry.fiber === undefined) continue
285
+ if (entry.options.name !== undefined) live.add(entry.options.name)
286
+ // Entry IDS too, under a `#` prefix that cannot collide with a package
287
+ // name. A CARRIER bundle's row names the package it mounts, not
288
+ // itself (#156: @tt-a1i/archify-dsh inserts an entry named
289
+ // @deepseek-ai/dsh-skill-filesystem), so its own name never appears
290
+ // here — but the id it created does, and that id is unique to its
291
+ // patch. Verification needs both, and putting them in one set means
292
+ // no call site can pass the names and forget the ids.
293
+ if (entry.options.id !== undefined && entry.options.id !== '') {
294
+ live.add(`#${entry.options.id}`)
295
+ // Loader ids may carry an include prefix (`include:archify-…`).
296
+ const bare = entry.options.id.split(':').pop()
297
+ if (bare !== undefined && bare !== entry.options.id) live.add(`#${bare}`)
298
+ }
285
299
  }
286
300
  return live
287
301
  }
@@ -1089,6 +1103,25 @@ export function mountMarketRoutes(
1089
1103
  })
1090
1104
  if (stale) ok = false
1091
1105
  }
1106
+ // The new build has to be loadable (#159). pnpm exits 0 for any
1107
+ // tarball it can extract, and the version really did change, so
1108
+ // nothing above notices a package that arrived without its entry
1109
+ // artifact — a registry mirror serving a source-only tarball for
1110
+ // a just-published version is the reported case, a plugin author
1111
+ // shipping a broken `files` list is the other one.
1112
+ //
1113
+ // Activation cannot stand in for this check: a package updating
1114
+ // ITSELF still reports live, because the running fiber belongs to
1115
+ // the OLD code that is already in memory. The failure only
1116
+ // surfaces on the next boot, as a profile that will not start.
1117
+ let brokenEntry = false
1118
+ if (ok && !hasLoadableEntry(activeProfileDir, name)) {
1119
+ brokenEntry = true
1120
+ ok = false
1121
+ const rolledBack = restoreManifestDeps(config.profile, manifestBefore, activeProfileDir)
1122
+ logEvent('error', 'update',
1123
+ `${name}: updated build has no loadable entry — rolled back the pin${rolledBack.length > 0 ? ` (${rolledBack.join(', ')})` : ''}`)
1124
+ }
1092
1125
  if (ok) {
1093
1126
  invalidateUpdates()
1094
1127
  activation = { [name]: verifyActivation(config.profile, name, liveNames(), activeProfileDir, disabled.has(name)) }
@@ -1104,6 +1137,12 @@ export function mountMarketRoutes(
1104
1137
  : staleReason === 'release-age'
1105
1138
  ? '这个新版本刚发布不久。为了安全,系统默认会等它发布满一天后再安装——刚发布的版本偶尔会被发现问题然后撤回。可以明天再试,或点「立即更新」不再等待。 / This version was just released; for safety, installs normally wait about a day after a release. Try again tomorrow, or click "Update now" to install it right away.'
1106
1139
  : '更新命令执行完成,但版本没有变化,原因未能确认。点「立即更新」重试通常能解决;若仍不行,请导出日志反馈。 / The update command completed but the version did not change; the cause could not be confirmed. Clicking "Update now" to retry usually resolves it — if not, export the log and report it.'
1140
+ // Actionable, because the user's own recovery is the right one:
1141
+ // the bad artifact is cached under its integrity hash, so a plain
1142
+ // re-add reuses it — the package has to be removed first.
1143
+ const brokenEntryError = !brokenEntry ? null
1144
+ : `${name} 更新后缺少入口文件(package.json 的 main/exports 指向的文件不存在),已回滚版本号以免下次启动失败。这通常是镜像源在新版本刚发布时同步不完整。请先卸载再从官方源重装:dsh plugin --profile ${config.profile} remove ${name} 然后 add ${name} --registry=https://registry.npmjs.org / ${name} arrived without the entry file its package.json points at; the version pin was rolled back so the next boot still works. A registry mirror serving an incomplete tarball for a just-published version is the usual cause. Remove it and reinstall from the official registry — a plain retry reuses the cached bad artifact.`
1145
+
1107
1146
  const cancelDiff = cancelled ? changedSince(beforeInstalled) : null
1108
1147
  // Build-script blocks hit updates too (#69): a leftover invalid
1109
1148
  // allowBuilds entry (pnpm's placeholder bug, #56) or a newly
@@ -1124,7 +1163,7 @@ export function mountMarketRoutes(
1124
1163
  activation,
1125
1164
  ignoredBuilds,
1126
1165
  staleReason: staleReason ?? undefined,
1127
- error: staleError ?? undefined,
1166
+ error: brokenEntryError ?? staleError ?? undefined,
1128
1167
  exitCode: result.exitCode,
1129
1168
  timedOut: result.timedOut,
1130
1169
  stdout: result.stdout,
package/src/verify.ts CHANGED
@@ -27,7 +27,7 @@
27
27
  import { readFileSync } from 'node:fs'
28
28
  import { join } from 'node:path'
29
29
  import { listHotMounts, parseSimplePatch } from './hot.ts'
30
- import { hasDshManifest, hasLoadableEntry, profileDir } from './profile.ts'
30
+ import { bundlePatchInsertedIds, hasDshManifest, hasLoadableEntry, profileDir } from './profile.ts'
31
31
 
32
32
  export type ActivationState = 'live' | 'restart' | 'inert' | 'broken' | 'missing' | 'disabled'
33
33
 
@@ -77,6 +77,28 @@ function liveIncludes(live: ReadonlySet<string>, packageName: string): boolean {
77
77
  return false
78
78
  }
79
79
 
80
+ /**
81
+ * True when a loader entry this package's OWN bundle patch inserts is up.
82
+ *
83
+ * A carrier bundle (#103) ships no plugin of its own: its patch mounts
84
+ * ANOTHER package with configuration, so the live entry carries that other
85
+ * package's name and `liveIncludes` can never match. The entry ID is the
86
+ * part that belongs to this package — its patch created it — which is why
87
+ * matching on it is both sufficient and precise: a neighbour that happens
88
+ * to mount the same package does so under a different id.
89
+ *
90
+ * Without this the market kept telling users to restart for a plugin that
91
+ * had been running since the restart (#156).
92
+ */
93
+ function carriedRowLive(live: ReadonlySet<string>, profileDirectory: string, packageName: string): boolean {
94
+ try {
95
+ return bundlePatchInsertedIds(join(profileDirectory, 'node_modules', packageName))
96
+ .some(id => live.has(`#${id}`))
97
+ } catch {
98
+ return false
99
+ }
100
+ }
101
+
80
102
  function readPkgDsh(profile: string, name: string, explicitDir?: string): PkgDsh | null {
81
103
  try {
82
104
  const manifest = JSON.parse(
@@ -136,7 +158,7 @@ export function verifyActivation(
136
158
  // loaded by name from a bundle patch — @deepseek-ai/dsh-tools is loaded by
137
159
  // the official dsh-base patch and has no `dsh` field at all — so this check
138
160
  // has to come before any manifest-based verdict.
139
- const loaderLive = liveIncludes(live, name)
161
+ const loaderLive = liveIncludes(live, name) || carriedRowLive(live, activeProfileDir, name)
140
162
  if (!hasDshManifest(dir)) {
141
163
  if (loaderLive) {
142
164
  return {